Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/user_input.rs
T
AIGameCreator App e399eaf5de 补齐智能体持久化用户输入流程
新增 user.input_request 状态机与私有 sidecar,支持重启恢复、取消和同一 Run 续跑
接入终端聊天、开发 Agent 聊天、项目 Agent 对话和 Project Supervisor 问题卡
扩展任务队列、能力清单与前后端共享契约
补齐单元测试、界面回归、真实 Runtime E2E 和技术文档
2026-07-16 03:44:31 +08:00

1057 lines
40 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";
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;
const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64;
const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12;
const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400;
const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60;
const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240;
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;
#[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() < 2 || question.options.len() > 3 {
return Err(format!(
"user.input_request question #{} 必须提供 2-3 个选项",
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,
"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(
root: &Path,
pending: &AgentRuntimePendingToolAction,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let expected = build_new_user_input_record(root, pending)?;
if record.schema_version != AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION
|| 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
|| record.created_at == 0
|| record.updated_at == 0
|| !valid_user_input_sha256(&record.questions_sha256)
{
return Err("用户输入请求 sidecar 身份或问题正文冲突".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 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(
root: &Path,
pending: &AgentRuntimePendingToolAction,
mut record: AgentRuntimeUserInputRecord,
) -> Result<AgentRuntimeUserInputRecord, String> {
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)?;
Ok(record)
}
pub(crate) fn prepare_game_creator_agent_user_input_request_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> 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(root, pending, record)?;
}
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());
}
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,
> {
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)?;
}
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(root, pending, record)?;
}
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());
}
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"));
}
}