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, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct AgentRuntimeUserInputRequestInput { questions: Vec, } #[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, pub(crate) allow_freeform: bool, pub(crate) response_id: Option, 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, goal_revision: u64, goal_snapshot_fingerprint: String, planned_steer_cursor: u64, request_id: String, questions: Vec, questions_sha256: String, question_count: u32, option_count: u32, question_chars: u32, question_message_id: String, status: String, #[serde(default)] response_id: Option, #[serde(default)] answers: BTreeMap, #[serde(default)] answers_sha256: Option, #[serde(default)] answer_count: u32, #[serde(default)] answer_chars: u32, #[serde(default)] answer_message_id: Option, #[serde(default)] observation: Option, created_at: u64, #[serde(default)] answer_prepared_at: Option, #[serde(default)] answered_at: Option, #[serde(default)] cancelled_at: Option, 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(value: &T) -> Result { 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 { 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::() )) } 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 { 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, ) -> Result, 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, String> { let request = serde_json::from_value::(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::>(); 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::(); 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::() }) .sum::(); ( 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 { 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 { 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 { 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 { 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, ) -> Result<(BTreeMap, u32), String> { let expected_ids = questions .iter() .map(|question| question.id.as_str()) .collect::>(); let actual_ids = answers .keys() .map(String::as_str) .collect::>(); 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 { 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, 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::( 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 { 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 { 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 { 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 { 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, 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, ) -> 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, 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, 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 { let value = serde_json::from_str::(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 { 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")); } }