Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/user_input.rs
T
lhk229 47a3240e81
Project CI / Repository checks (pull_request) Successful in 4m51s
Project CI / Frontend tests (pull_request) Successful in 7m39s
Project CI / Backend tests (pull_request) Successful in 11m23s
Project CI / Native shell tests (pull_request) Failing after 13m38s
按 rustfmt 修正 V1 删除后残留的格式偏差
Repository checks 门禁的 cargo fmt --check 要求,仅格式化、无行为变化
2026-09-08 11:25:17 +00:00

1264 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use sha2::{Digest, Sha256};
pub(crate) const GAME_CREATOR_USER_INPUT_REQUEST_TOOL: &str = "user.input_request";
/// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 动作的 `task` 字段存的
/// 不是本 run 的任务,而是这段由 Runtime 生成的转述指令,delegationId 编码在前缀
/// 之后。系统里另外五处都要按这个前缀反解,此前各自抄了一份 `strip_prefix` 链;
/// 抄本之间一旦漂移,转述路径会静默失去与原 delivery 的绑定,所以收成一处。
pub(crate) const AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX: &str =
"子 Agent 需要用户澄清后才能继续。delegationId=";
/// 从转述 pending 的 `task` 里取回它绑定的原 delegationId;不是转述 task 返回 None。
pub(crate) fn agent_runtime_delegate_clarification_delegation_id(task: &str) -> Option<&str> {
task.strip_prefix(AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX)
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
}
/// 该任务是否是 Runtime 代 Supervisor 生成的子 Agent 澄清转述任务。
///
/// 判据只看 task,不看工具:用户答完后
/// `run_recovered_game_creator_context_on_fresh_task` 会把 `pending.task` 当作同一
/// run 后续每一轮的任务,所以转述任务会一路传播到 `agent.run_status`、
/// `agent.delegate` 等动作上,而不是只停在那一次 `user.input_request`。按工具收窄
/// 会让续跑第一步就被判身份变化——run 15 实测如此。
pub(crate) fn agent_runtime_task_is_delegate_clarification_relay(task: &str) -> bool {
agent_runtime_delegate_clarification_delegation_id(task).is_some()
}
pub(crate) const AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION: &str =
"game-creator-runtime-user-input.v1";
pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_PENDING: &str = "pending";
pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED: &str = "answer-prepared";
pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED: &str = "answered";
pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED: &str = "cancelled";
const AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES: usize = 128 * 1024;
const AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS: usize = 3;
pub(crate) const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2;
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3;
const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64;
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12;
/// 立项策划澄清卡的 header 是「这一轮要定的是什么」本身,不是一个 12 字的标题格。
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400;
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60;
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240;
/// 该 header 能用到的字符上限。
/// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。
///
/// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。
/// 通道比 schema 窄的后果不是「模型写短一点」——子 Agent 提了一个完全合法的
/// 问题,Runtime 会在父 run 认领回执时拒收,整条委派链就此阻断。真正的逐字段
/// 复核仍在 `parse_game_creator_agent_user_input_questions`,这个上限只是粗筛。
///
/// JSON 语法开销按每个标量字段一对引号加冒号逗号、每层括号若干字符宽估。
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = {
const OPTION_SYNTAX_CHARS: usize = 40;
const QUESTION_SYNTAX_CHARS: usize = 64;
const ENVELOPE_SYNTAX_CHARS: usize = 64;
let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS
+ OPTION_SYNTAX_CHARS;
let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option
+ QUESTION_SYNTAX_CHARS;
AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS * per_question + ENVELOPE_SYNTAX_CHARS
};
const AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS: usize = 4_000;
const AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS: usize = 8_000;
const AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS: usize = 160;
#[cfg(test)]
pub(crate) const AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST: &str =
".agent/runtime/test-stop-user-input-after-answer-prepared";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeUserInputOption {
pub(crate) label: String,
pub(crate) description: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeUserInputQuestion {
pub(crate) id: String,
pub(crate) header: String,
pub(crate) question: String,
pub(crate) options: Vec<AgentRuntimeUserInputOption>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeUserInputRequestInput {
questions: Vec<AgentRuntimeUserInputQuestion>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentRuntimeUserInputRequestView {
pub(crate) schema_version: String,
pub(crate) request_id: String,
pub(crate) agent_id: String,
pub(crate) task_id: String,
pub(crate) session_id: String,
pub(crate) run_id: String,
pub(crate) action_id: String,
pub(crate) status: String,
pub(crate) questions: Vec<AgentRuntimeUserInputQuestion>,
pub(crate) allow_freeform: bool,
pub(crate) response_id: Option<String>,
pub(crate) requested_at: u64,
pub(crate) updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeUserInputRecord {
schema_version: String,
project_id: String,
agent_id: String,
task_id: String,
session_id: String,
run_id: String,
source: String,
action_id: String,
action_fingerprint: String,
goal_id: Option<String>,
goal_revision: u64,
goal_snapshot_fingerprint: String,
planned_steer_cursor: u64,
request_id: String,
questions: Vec<AgentRuntimeUserInputQuestion>,
questions_sha256: String,
question_count: u32,
option_count: u32,
question_chars: u32,
question_message_id: String,
status: String,
#[serde(default)]
response_id: Option<String>,
#[serde(default)]
answers: BTreeMap<String, String>,
#[serde(default)]
answers_sha256: Option<String>,
#[serde(default)]
answer_count: u32,
#[serde(default)]
answer_chars: u32,
#[serde(default)]
answer_message_id: Option<String>,
#[serde(default)]
observation: Option<AgentRuntimeToolObservation>,
created_at: u64,
#[serde(default)]
answer_prepared_at: Option<u64>,
#[serde(default)]
answered_at: Option<u64>,
#[serde(default)]
cancelled_at: Option<u64>,
updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AgentRuntimeUserInputRecovery {
Waiting(AgentRuntimeUserInputRequestView),
Answered {
request: AgentRuntimeUserInputRequestView,
observation: AgentRuntimeToolObservation,
},
Cancelled,
}
fn user_input_sha256_bytes(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn user_input_sha256_json<T: Serialize>(value: &T) -> Result<String, String> {
serde_json::to_vec(value)
.map(|bytes| user_input_sha256_bytes(&bytes))
.map_err(|error| format!("序列化用户输入请求指纹失败:{error}"))
}
fn valid_user_input_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn user_input_identity_component(value: &str) -> String {
user_input_sha256_bytes(value.as_bytes())
.chars()
.take(32)
.collect()
}
fn user_input_request_id(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<String, String> {
let identity = serde_json::json!({
"projectId": game_creator_agent_runtime_context_project_id(root)?,
"agentId": pending.agent_id,
"taskId": pending.task_id,
"sessionId": pending.session_id,
"runId": pending.run_id,
"actionId": pending.action_id,
"actionFingerprint": pending.action_fingerprint,
});
Ok(format!(
"user-input-{}",
user_input_sha256_json(&identity)?
.chars()
.take(32)
.collect::<String>()
))
}
fn user_input_question_message_id(request_id: &str) -> String {
format!(
"user-input-question-{}",
user_input_identity_component(request_id)
)
}
fn user_input_answer_message_id(request_id: &str, response_id: &str) -> String {
format!(
"user-input-answer-{}",
user_input_identity_component(&format!("{request_id}\n{response_id}"))
)
}
fn user_input_relative_path(agent_id: &str, run_id: &str, request_id: &str) -> String {
format!(
".agent/runtime/user-input/{}/{}/{}.json",
user_input_identity_component(agent_id),
user_input_identity_component(run_id),
request_id
)
}
fn normalize_single_line_user_input_text(
value: &str,
max_chars: usize,
label: &str,
) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("{label} 不能为空"));
}
if value.chars().count() > max_chars {
return Err(format!("{label} 超过 {max_chars} 字符上限"));
}
if value.contains(['\n', '\r'])
|| value
.chars()
.any(|character| character.is_control() && character != '\t')
{
return Err(format!("{label} 必须是单行文本"));
}
Ok(value.to_string())
}
fn valid_snake_case_user_input_id(value: &str) -> bool {
if value.is_empty() || value.len() > AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS {
return false;
}
let mut previous_underscore = false;
for (index, byte) in value.bytes().enumerate() {
let valid = byte.is_ascii_lowercase()
|| (index > 0 && byte.is_ascii_digit())
|| (index > 0 && byte == b'_');
if !valid || (byte == b'_' && previous_underscore) {
return false;
}
previous_underscore = byte == b'_';
}
!previous_underscore
}
fn normalize_user_input_questions(
questions: Vec<AgentRuntimeUserInputQuestion>,
) -> Result<Vec<AgentRuntimeUserInputQuestion>, String> {
if questions.is_empty() || questions.len() > AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS {
return Err(format!(
"user.input_request questions 必须在 1..={AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS} 之间"
));
}
let mut normalized = Vec::with_capacity(questions.len());
let mut seen_ids = std::collections::BTreeSet::new();
for (question_index, question) in questions.into_iter().enumerate() {
let id = question.id.trim().to_string();
if !valid_snake_case_user_input_id(&id) || !seen_ids.insert(id.clone()) {
return Err(format!(
"user.input_request question #{} 的 id 必须是唯一 snake_case",
question_index + 1
));
}
let header = normalize_single_line_user_input_text(
&question.header,
AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS,
&format!("user.input_request question {} header", question_index + 1),
)?;
let question_text = normalize_single_line_user_input_text(
&question.question,
AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS,
&format!(
"user.input_request question {} question",
question_index + 1
),
)?;
if question.options.len() < AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS
|| question.options.len() > AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS
{
return Err(format!(
"user.input_request question #{} 必须提供 {AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}-{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个选项",
question_index + 1
));
}
let mut options = Vec::with_capacity(question.options.len());
let mut seen_labels = std::collections::BTreeSet::new();
for (option_index, option) in question.options.into_iter().enumerate() {
let label = normalize_single_line_user_input_text(
&option.label,
AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS,
&format!(
"user.input_request question {} option {} label",
question_index + 1,
option_index + 1
),
)?;
if !seen_labels.insert(label.clone()) {
return Err(format!(
"user.input_request question #{} 不能包含重复选项",
question_index + 1
));
}
let description = normalize_single_line_user_input_text(
&option.description,
AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS,
&format!(
"user.input_request question {} option {} description",
question_index + 1,
option_index + 1
),
)?;
options.push(AgentRuntimeUserInputOption { label, description });
}
normalized.push(AgentRuntimeUserInputQuestion {
id,
header,
question: question_text,
options,
});
}
Ok(normalized)
}
pub(crate) fn parse_game_creator_agent_user_input_questions(
input: &serde_json::Value,
) -> Result<Vec<AgentRuntimeUserInputQuestion>, String> {
let request = serde_json::from_value::<AgentRuntimeUserInputRequestInput>(input.clone())
.map_err(|error| format!("user.input_request 输入无效:{error}"))?;
normalize_user_input_questions(request.questions)
}
pub(crate) fn validate_game_creator_agent_user_input_tool_plan(
plan: &AgentRuntimeToolPlan,
) -> Result<(), String> {
let user_input_actions = plan
.actions
.iter()
.filter(|action| action.tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL)
.collect::<Vec<_>>();
if user_input_actions.is_empty() {
return Ok(());
}
if user_input_actions.len() != 1 || plan.actions.len() != 1 || !plan.response.trim().is_empty()
{
return Err(
"Agent 工具计划协议错误:user.input_request 必须是本轮唯一 action,且 response 必须为空"
.to_string(),
);
}
parse_game_creator_agent_user_input_questions(&user_input_actions[0].input).map(|_| ())
}
fn user_input_question_counts(questions: &[AgentRuntimeUserInputQuestion]) -> (u32, u32, u32) {
let option_count = questions
.iter()
.map(|question| question.options.len())
.sum::<usize>();
let question_chars = questions
.iter()
.map(|question| {
question.id.chars().count()
+ question.header.chars().count()
+ question.question.chars().count()
+ question
.options
.iter()
.map(|option| option.label.chars().count() + option.description.chars().count())
.sum::<usize>()
})
.sum::<usize>();
(
u32::try_from(questions.len()).unwrap_or(u32::MAX),
u32::try_from(option_count).unwrap_or(u32::MAX),
u32::try_from(question_chars).unwrap_or(u32::MAX),
)
}
pub(crate) fn game_creator_agent_user_input_action_input_summary(
input: &serde_json::Value,
) -> Option<String> {
let questions = parse_game_creator_agent_user_input_questions(input).ok()?;
let (question_count, option_count, question_chars) = user_input_question_counts(&questions);
let questions_sha256 = user_input_sha256_json(&questions).ok()?;
Some(format!(
"questionCount={question_count} · optionCount={option_count} · questionChars={question_chars} · questionsSha256={questions_sha256}"
))
}
fn validate_user_input_action_owner(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<(), String> {
if pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
return Err("当前 pending action 不是 user.input_request".to_string());
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&pending.agent_id,
&pending.run_id,
)?;
if task.as_ref().is_some_and(|task| {
task.parent_agent_id.is_some()
|| task.parent_run_id.is_some()
|| task.delegation_id.is_some()
}) || matches!(
pending.source.as_str(),
"agent-delegate"
| "agent-delegate-retry"
| AGENT_RUNTIME_ISOLATED_CHILD_SOURCE
| AGENT_RUNTIME_ISOLATED_JOIN_SOURCE
) || pending.agent_id.starts_with("child-")
{
return Err("委派专业 Agent 与动态隔离子 Agent 不能直接向终端用户请求输入".to_string());
}
Ok(())
}
fn build_new_user_input_record(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<AgentRuntimeUserInputRecord, String> {
validate_user_input_action_owner(root, pending)?;
let questions = parse_game_creator_agent_user_input_questions(&pending.action.input)?;
let request_id = user_input_request_id(root, pending)?;
let questions_sha256 = user_input_sha256_json(&questions)?;
let (question_count, option_count, question_chars) = user_input_question_counts(&questions);
let now = unix_timestamp();
Ok(AgentRuntimeUserInputRecord {
schema_version: AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION.to_string(),
project_id: game_creator_agent_runtime_context_project_id(root)?,
agent_id: pending.agent_id.clone(),
task_id: pending.task_id.clone(),
session_id: pending.session_id.clone(),
run_id: pending.run_id.clone(),
source: pending.source.clone(),
action_id: pending.action_id.clone(),
action_fingerprint: pending.action_fingerprint.clone(),
goal_id: pending.goal_id.clone(),
goal_revision: pending.goal_revision,
goal_snapshot_fingerprint: pending.goal_snapshot_fingerprint.clone(),
planned_steer_cursor: pending.planned_steer_cursor,
request_id: request_id.clone(),
questions,
questions_sha256,
question_count,
option_count,
question_chars,
question_message_id: user_input_question_message_id(&request_id),
status: AGENT_RUNTIME_USER_INPUT_STATUS_PENDING.to_string(),
response_id: None,
answers: BTreeMap::new(),
answers_sha256: None,
answer_count: 0,
answer_chars: 0,
answer_message_id: None,
observation: None,
created_at: now,
answer_prepared_at: None,
answered_at: None,
cancelled_at: None,
updated_at: now,
})
}
fn user_input_record_view(
record: &AgentRuntimeUserInputRecord,
) -> AgentRuntimeUserInputRequestView {
AgentRuntimeUserInputRequestView {
schema_version: record.schema_version.clone(),
request_id: record.request_id.clone(),
agent_id: record.agent_id.clone(),
task_id: record.task_id.clone(),
session_id: record.session_id.clone(),
run_id: record.run_id.clone(),
action_id: record.action_id.clone(),
status: record.status.clone(),
questions: record.questions.clone(),
allow_freeform: true,
response_id: record.response_id.clone(),
requested_at: record.created_at,
updated_at: record.updated_at,
}
}
fn render_user_input_question_message(record: &AgentRuntimeUserInputRecord) -> String {
let mut lines = vec!["继续当前任务前,我需要你确认以下信息:".to_string()];
for (index, question) in record.questions.iter().enumerate() {
lines.push(format!(
"\n{}. {}{}",
index + 1,
question.header,
question.question
));
for option in &question.options {
lines.push(format!("- {}{}", option.label, option.description));
}
}
lines.push("\n每题都可以选择一个选项,也可以直接填写其他答案。".to_string());
lines.join("\n")
}
fn render_user_input_answer_message(
record: &AgentRuntimeUserInputRecord,
) -> Result<String, String> {
let mut lines = vec!["我对这次澄清的回答:".to_string()];
for question in &record.questions {
let answer = record
.answers
.get(&question.id)
.ok_or_else(|| format!("用户输入回答缺少 questionId={}", question.id))?;
lines.push(format!("- {}{}", question.header, answer));
}
Ok(lines.join("\n"))
}
fn append_user_input_question_message(
root: &Path,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
append_local_conversation_message_for_session_idempotent_at(
root,
Some(&record.agent_id),
Some(&record.session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: render_user_input_question_message(record),
agent_id: None,
},
&record.question_message_id,
)
.map(|_| ())
}
fn append_user_input_answer_message(
root: &Path,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let message_id = record
.answer_message_id
.as_deref()
.ok_or_else(|| "用户输入回答缺少稳定 messageId".to_string())?;
append_local_conversation_message_for_session_idempotent_at(
root,
Some(&record.agent_id),
Some(&record.session_id),
LocalConversationMessage {
role: "user".to_string(),
content: render_user_input_answer_message(record)?,
agent_id: None,
},
message_id,
)
.map(|_| ())
}
fn normalize_user_input_response_id(response_id: &str) -> Result<String, String> {
let response_id = response_id.trim();
if response_id.is_empty()
|| response_id.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS
|| response_id.chars().any(char::is_control)
{
return Err("用户输入 responseId 无效".to_string());
}
Ok(response_id.to_string())
}
fn normalize_user_input_answers(
questions: &[AgentRuntimeUserInputQuestion],
answers: BTreeMap<String, String>,
) -> Result<(BTreeMap<String, String>, u32), String> {
let expected_ids = questions
.iter()
.map(|question| question.id.as_str())
.collect::<std::collections::BTreeSet<_>>();
let actual_ids = answers
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>();
if expected_ids != actual_ids {
return Err("用户输入 answers 必须完整且只能包含当前请求的问题 id".to_string());
}
let mut normalized = BTreeMap::new();
let mut total_chars = 0usize;
for question in questions {
let answer = answers
.get(&question.id)
.map(String::as_str)
.unwrap_or_default()
.trim();
let answer_chars = answer.chars().count();
if answer.is_empty() || answer_chars > AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS {
return Err(format!(
"用户输入 questionId={} 的回答必须在 1..={} 字符之间",
question.id, AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS
));
}
if answer
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err(format!(
"用户输入 questionId={} 的回答包含无效控制字符",
question.id
));
}
total_chars = total_chars.saturating_add(answer_chars);
normalized.insert(question.id.clone(), answer.to_string());
}
if total_chars > AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS {
return Err(format!(
"用户输入回答合计超过 {AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS} 字符上限"
));
}
Ok((normalized, u32::try_from(total_chars).unwrap_or(u32::MAX)))
}
fn build_user_input_observation(
record: &AgentRuntimeUserInputRecord,
) -> Result<AgentRuntimeToolObservation, String> {
let response_id = record
.response_id
.as_deref()
.ok_or_else(|| "用户输入回答缺少 responseId".to_string())?;
let answers_sha256 = record
.answers_sha256
.as_deref()
.ok_or_else(|| "用户输入回答缺少内容指纹".to_string())?;
let detail = serde_json::to_string(&serde_json::json!({
"requestId": record.request_id,
"responseId": response_id,
"questions": record.questions,
"answers": record.answers,
"questionCount": record.question_count,
"answerCount": record.answer_count,
"answerChars": record.answer_chars,
"questionsSha256": record.questions_sha256,
"answersSha256": answers_sha256,
}))
.map_err(|error| format!("序列化用户输入 observation 失败:{error}"))?;
Ok(AgentRuntimeToolObservation {
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
status: "ok".to_string(),
summary: format!("用户已回答 {} 个澄清问题", record.answer_count),
detail: Some(detail),
})
}
fn validate_user_input_record_payload(
root: &Path,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let normalized_questions = normalize_user_input_questions(record.questions.clone())?;
let questions_sha256 = user_input_sha256_json(&normalized_questions)?;
let (question_count, option_count, question_chars) =
user_input_question_counts(&normalized_questions);
if record.schema_version != AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION
|| record.project_id != game_creator_agent_runtime_context_project_id(root)?
|| record.questions != normalized_questions
|| record.questions_sha256 != questions_sha256
|| record.question_count != question_count
|| record.option_count != option_count
|| record.question_chars != question_chars
|| record.question_message_id != user_input_question_message_id(&record.request_id)
|| record.agent_id.trim().is_empty()
|| record.task_id.trim().is_empty()
|| record.session_id.trim().is_empty()
|| record.run_id.trim().is_empty()
|| record.source.trim().is_empty()
|| record.action_id.trim().is_empty()
|| !valid_user_input_sha256(&record.action_fingerprint)
|| record.request_id.trim().is_empty()
|| record.created_at == 0
|| record.updated_at == 0
|| !valid_user_input_sha256(&record.questions_sha256)
{
return Err("用户输入请求 sidecar payload 身份或问题正文冲突".to_string());
}
if !matches!(
record.status.as_str(),
AGENT_RUNTIME_USER_INPUT_STATUS_PENDING
| AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED
| AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED
| AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED
) {
return Err("用户输入请求 sidecar 状态无效".to_string());
}
let has_answer = record.response_id.is_some()
|| !record.answers.is_empty()
|| record.answers_sha256.is_some()
|| record.answer_count > 0
|| record.answer_chars > 0
|| record.answer_message_id.is_some()
|| record.answer_prepared_at.is_some()
|| record.answered_at.is_some()
|| record.observation.is_some();
if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_PENDING {
if has_answer || record.cancelled_at.is_some() {
return Err("pending 用户输入请求不能携带回答或终态字段".to_string());
}
return Ok(());
}
if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED {
if record.cancelled_at.is_none() || record.observation.is_some() {
return Err("cancelled 用户输入请求缺少取消时间或携带 observation".to_string());
}
if !has_answer {
return Ok(());
}
}
let response_id = normalize_user_input_response_id(
record
.response_id
.as_deref()
.ok_or_else(|| "用户输入 sidecar 缺少 responseId".to_string())?,
)?;
let (answers, answer_chars) =
normalize_user_input_answers(&record.questions, record.answers.clone())?;
let answers_sha256 = user_input_sha256_json(&answers)?;
if record.answers != answers
|| record.answers_sha256.as_deref() != Some(answers_sha256.as_str())
|| record.answer_count != record.question_count
|| record.answer_chars != answer_chars
|| record.answer_message_id.as_deref()
!= Some(user_input_answer_message_id(&record.request_id, &response_id).as_str())
|| record.answer_prepared_at.is_none()
|| !valid_user_input_sha256(&answers_sha256)
{
return Err("用户输入请求 sidecar 回答身份或正文冲突".to_string());
}
if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED {
if record.answered_at.is_some()
|| record.cancelled_at.is_some()
|| record.observation.is_some()
{
return Err("answer-prepared 用户输入请求包含非法终态字段".to_string());
}
return Ok(());
}
if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED {
let expected_observation = build_user_input_observation(record)?;
if record.answered_at.is_none()
|| record.cancelled_at.is_some()
|| record.observation.as_ref() != Some(&expected_observation)
{
return Err("answered 用户输入请求缺少可重算 observation".to_string());
}
}
Ok(())
}
fn validate_user_input_record(
root: &Path,
pending: &AgentRuntimePendingToolAction,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let expected = build_new_user_input_record(root, pending)?;
if record.project_id != expected.project_id
|| record.agent_id != expected.agent_id
|| record.task_id != expected.task_id
|| record.session_id != expected.session_id
|| record.run_id != expected.run_id
|| record.source != expected.source
|| record.action_id != expected.action_id
|| record.action_fingerprint != expected.action_fingerprint
|| record.goal_id != expected.goal_id
|| record.goal_revision != expected.goal_revision
|| record.goal_snapshot_fingerprint != expected.goal_snapshot_fingerprint
|| record.planned_steer_cursor != expected.planned_steer_cursor
|| record.request_id != expected.request_id
|| record.questions != expected.questions
|| record.questions_sha256 != expected.questions_sha256
|| record.question_count != expected.question_count
|| record.option_count != expected.option_count
|| record.question_chars != expected.question_chars
|| record.question_message_id != expected.question_message_id
{
return Err("用户输入请求 sidecar 身份或问题正文冲突".to_string());
}
validate_user_input_record_payload(root, record)
}
fn read_user_input_record(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<Option<AgentRuntimeUserInputRecord>, String> {
let request_id = user_input_request_id(root, pending)?;
let relative_path = user_input_relative_path(&pending.agent_id, &pending.run_id, &request_id);
let record = read_agent_runtime_json_sidecar_with_max_bytes::<AgentRuntimeUserInputRecord>(
root,
&relative_path,
"Agent Runtime 用户输入请求",
AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES,
)?;
if let Some(record) = record.as_ref() {
validate_user_input_record(root, pending, record)?;
}
Ok(record)
}
fn write_user_input_record(
root: &Path,
pending: &AgentRuntimePendingToolAction,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
validate_user_input_record(root, pending, record)?;
let relative_path =
user_input_relative_path(&record.agent_id, &record.run_id, &record.request_id);
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&relative_path,
"Agent Runtime 用户输入请求",
record,
AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES,
)
}
fn finish_prepared_user_input_answer_with_project_lock(
root: &Path,
pending: &AgentRuntimePendingToolAction,
mut record: AgentRuntimeUserInputRecord,
project_lock: Option<&ProjectWriteLock>,
) -> Result<AgentRuntimeUserInputRecord, String> {
let _ = project_lock;
append_user_input_answer_message(root, &record)?;
let observation = build_user_input_observation(&record)?;
let now = unix_timestamp();
record.status = AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED.to_string();
record.observation = Some(observation);
record.answered_at = Some(now);
record.updated_at = now;
write_user_input_record(root, pending, &record)?;
bind_user_input_record_to_static_delegate_at(root, pending, &record)?;
Ok(record)
}
fn bind_user_input_record_to_static_delegate_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task)
else {
return Ok(());
};
bind_static_delegate_clarification_answer_at(
root,
&pending.agent_id,
&pending.run_id,
delegation_id,
&record.request_id,
&record.questions_sha256,
record
.answers_sha256
.as_deref()
.ok_or_else(|| "用户输入请求 answered 状态缺少答案指纹".to_string())?,
)
}
pub(crate) fn prepare_game_creator_agent_user_input_request_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<AgentRuntimeUserInputRecovery, String> {
prepare_game_creator_agent_user_input_request_with_project_lock_at(root, pending, None)
}
pub(crate) fn prepare_game_creator_agent_user_input_request_at_locked(
root: &Path,
pending: &AgentRuntimePendingToolAction,
project_lock: &ProjectWriteLock,
) -> Result<AgentRuntimeUserInputRecovery, String> {
if !project_lock.guards_project_root(root)? {
return Err("恢复 planning 用户输入缺少当前项目写锁".to_string());
}
prepare_game_creator_agent_user_input_request_with_project_lock_at(
root,
pending,
Some(project_lock),
)
}
fn prepare_game_creator_agent_user_input_request_with_project_lock_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
project_lock: Option<&ProjectWriteLock>,
) -> Result<AgentRuntimeUserInputRecovery, String> {
validate_user_input_action_owner(root, pending)?;
let mut record = match read_user_input_record(root, pending)? {
Some(record) => record,
None => {
let record = build_new_user_input_record(root, pending)?;
write_user_input_record(root, pending, &record)?;
record
}
};
append_user_input_question_message(root, &record)?;
if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED {
record = finish_prepared_user_input_answer_with_project_lock(
root,
pending,
record,
project_lock,
)?;
}
match record.status.as_str() {
AGENT_RUNTIME_USER_INPUT_STATUS_PENDING => Ok(AgentRuntimeUserInputRecovery::Waiting(
user_input_record_view(&record),
)),
AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED => {
append_user_input_answer_message(root, &record)?;
let observation = build_user_input_observation(&record)?;
if record.observation.as_ref() != Some(&observation) {
return Err("用户输入请求 observation 重算冲突".to_string());
}
bind_user_input_record_to_static_delegate_at(root, pending, &record)?;
Ok(AgentRuntimeUserInputRecovery::Answered {
request: user_input_record_view(&record),
observation,
})
}
AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED => Ok(AgentRuntimeUserInputRecovery::Cancelled),
_ => Err("用户输入请求处于无法恢复的状态".to_string()),
}
}
pub(crate) fn read_game_creator_agent_user_input_request_view_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<Option<AgentRuntimeUserInputRequestView>, String> {
if pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
return Ok(None);
}
let Some(record) = read_user_input_record(root, pending)? else {
return Err("waiting-for-user-input 缺少规范 sidecar".to_string());
};
Ok(matches!(
record.status.as_str(),
AGENT_RUNTIME_USER_INPUT_STATUS_PENDING | AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED
)
.then(|| user_input_record_view(&record)))
}
pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
request_id: &str,
response_id: &str,
answers: BTreeMap<String, String>,
) -> Result<
(
AgentRuntimeUserInputRequestView,
AgentRuntimeToolObservation,
),
String,
> {
answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at(
root,
pending,
request_id,
response_id,
answers,
None,
)
}
pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at_locked(
root: &Path,
pending: &AgentRuntimePendingToolAction,
request_id: &str,
response_id: &str,
answers: BTreeMap<String, String>,
project_lock: &ProjectWriteLock,
) -> Result<
(
AgentRuntimeUserInputRequestView,
AgentRuntimeToolObservation,
),
String,
> {
if !project_lock.guards_project_root(root)? {
return Err("提交 planning 用户回答缺少当前项目写锁".to_string());
}
answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at(
root,
pending,
request_id,
response_id,
answers,
Some(project_lock),
)
}
fn answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
request_id: &str,
response_id: &str,
answers: BTreeMap<String, String>,
project_lock: Option<&ProjectWriteLock>,
) -> Result<
(
AgentRuntimeUserInputRequestView,
AgentRuntimeToolObservation,
),
String,
> {
validate_user_input_action_owner(root, pending)?;
let request_id = request_id.trim();
let response_id = normalize_user_input_response_id(response_id)?;
let mut record = read_user_input_record(root, pending)?
.ok_or_else(|| "用户输入请求 sidecar 缺失".to_string())?;
if record.request_id != request_id {
return Err("用户输入 requestId 已变化,请刷新后重试".to_string());
}
let (answers, answer_chars) = normalize_user_input_answers(&record.questions, answers)?;
let answers_sha256 = user_input_sha256_json(&answers)?;
match record.status.as_str() {
AGENT_RUNTIME_USER_INPUT_STATUS_PENDING => {
let now = unix_timestamp();
record.status = AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED.to_string();
record.response_id = Some(response_id.clone());
record.answers = answers;
record.answers_sha256 = Some(answers_sha256);
record.answer_count = record.question_count;
record.answer_chars = answer_chars;
record.answer_message_id = Some(user_input_answer_message_id(
&record.request_id,
&response_id,
));
record.answer_prepared_at = Some(now);
record.updated_at = now;
write_user_input_record(root, pending, &record)?;
#[cfg(test)]
if std::fs::remove_file(
root.join(AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST),
)
.is_ok()
{
return Err("测试注入:用户回答停在 answer-prepared".to_string());
}
}
AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED
| AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED => {
if record.response_id.as_deref() != Some(response_id.as_str())
|| record.answers != answers
|| record.answers_sha256.as_deref() != Some(answers_sha256.as_str())
{
return Err("用户输入请求已经使用不同 responseId 或回答提交".to_string());
}
}
AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED => {
return Err("用户输入请求已取消".to_string());
}
_ => return Err("用户输入请求状态无效".to_string()),
}
if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED {
record = finish_prepared_user_input_answer_with_project_lock(
root,
pending,
record,
project_lock,
)?;
}
append_user_input_question_message(root, &record)?;
append_user_input_answer_message(root, &record)?;
let observation = build_user_input_observation(&record)?;
if record.observation.as_ref() != Some(&observation) {
return Err("用户输入请求 answered observation 冲突".to_string());
}
bind_user_input_record_to_static_delegate_at(root, pending, &record)?;
Ok((user_input_record_view(&record), observation))
}
pub(crate) fn cancel_game_creator_agent_user_input_request_for_pending_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<(), String> {
if pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
return Ok(());
}
let Some(mut record) = read_user_input_record(root, pending)? else {
return Ok(());
};
if matches!(
record.status.as_str(),
AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED | AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED
) {
return Ok(());
}
let now = unix_timestamp();
record.status = AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED.to_string();
record.observation = None;
record.answered_at = None;
record.cancelled_at = Some(now);
record.updated_at = now;
write_user_input_record(root, pending, &record)
}
pub(crate) fn game_creator_agent_user_input_public_observation_metadata(
detail: &str,
) -> Option<String> {
let value = serde_json::from_str::<serde_json::Value>(detail).ok()?;
let request_id = value.get("requestId")?.as_str()?;
let response_id = value.get("responseId")?.as_str()?;
let question_count = value.get("questionCount")?.as_u64()?;
let answer_count = value.get("answerCount")?.as_u64()?;
let answer_chars = value.get("answerChars")?.as_u64()?;
let answers_sha256 = value.get("answersSha256")?.as_str()?;
if !request_id.starts_with("user-input-")
|| request_id.chars().count() > 64
|| response_id.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS
|| !valid_user_input_sha256(answers_sha256)
|| question_count == 0
|| question_count > AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS as u64
|| answer_count != question_count
|| answer_chars > AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS as u64
{
return None;
}
Some(format!(
"requestId={request_id} · responseIdSha256={} · questionCount={question_count} · answerCount={answer_count} · answerChars={answer_chars} · answersSha256={answers_sha256}",
user_input_sha256_bytes(response_id.as_bytes())
))
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_questions() -> Vec<AgentRuntimeUserInputQuestion> {
vec![AgentRuntimeUserInputQuestion {
id: "target_platform".to_string(),
header: "运行平台".to_string(),
question: "首版优先支持哪个平台?".to_string(),
options: vec![
AgentRuntimeUserInputOption {
label: "Web".to_string(),
description: "先交付浏览器版本。".to_string(),
},
AgentRuntimeUserInputOption {
label: "桌面端".to_string(),
description: "先交付桌面客户端。".to_string(),
},
],
}]
}
#[test]
fn user_input_questions_require_unique_snake_case_ids_and_two_options() {
assert!(normalize_user_input_questions(valid_questions()).is_ok());
let mut invalid_id = valid_questions();
invalid_id[0].id = "Target Platform".to_string();
assert!(normalize_user_input_questions(invalid_id)
.expect_err("invalid id must fail")
.contains("snake_case"));
let mut one_option = valid_questions();
one_option[0].options.truncate(1);
assert!(normalize_user_input_questions(one_option)
.expect_err("one option must fail")
.contains("2-3"));
}
#[test]
fn user_input_answers_must_cover_every_question_once() {
let questions = valid_questions();
let answers = BTreeMap::from([("target_platform".to_string(), "Web".to_string())]);
let (normalized, chars) =
normalize_user_input_answers(&questions, answers).expect("normalize full answers");
assert_eq!(normalized["target_platform"], "Web");
assert_eq!(chars, 3);
assert!(normalize_user_input_answers(&questions, BTreeMap::new())
.expect_err("missing answer must fail")
.contains("完整"));
}
#[test]
fn user_input_tool_plan_must_be_the_sole_action_with_empty_response() {
let mut plan = AgentRuntimeToolPlan {
thinking_summary: "需要用户选择目标平台".to_string(),
plan_update: None,
plan: Vec::new(),
actions: vec![AgentRuntimeToolAction {
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
reason: Some("平台会改变实现路径".to_string()),
input: serde_json::json!({"questions": valid_questions()}),
}],
response: String::new(),
};
validate_game_creator_agent_user_input_tool_plan(&plan)
.expect("sole user input action should be valid");
plan.response = "不能同时结束任务".to_string();
assert!(validate_game_creator_agent_user_input_tool_plan(&plan)
.expect_err("response with user input action must fail")
.contains("唯一 action"));
plan.response.clear();
plan.actions.push(AgentRuntimeToolAction {
tool: "project.index".to_string(),
reason: Some("不能混入其他动作".to_string()),
input: serde_json::json!({}),
});
assert!(validate_game_creator_agent_user_input_tool_plan(&plan)
.expect_err("mixed user input action must fail")
.contains("唯一 action"));
}
#[test]
fn user_input_public_metadata_never_contains_answer_text() {
let detail = serde_json::to_string(&serde_json::json!({
"requestId": "user-input-0123456789abcdef0123456789abcdef",
"responseId": "response-private",
"questionCount": 1,
"answerCount": 1,
"answerChars": 12,
"answersSha256": "a".repeat(64),
"answers": {"target_platform": "PRIVATE_ANSWER"},
}))
.expect("serialize detail");
let metadata = game_creator_agent_user_input_public_observation_metadata(&detail)
.expect("public metadata");
assert!(!metadata.contains("PRIVATE_ANSWER"));
assert!(!metadata.contains("response-private"));
assert!(metadata.contains("answerChars=12"));
}
}