Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/user_input.rs
T
lhk229 e31d8a2a5b
Project CI / Repository checks (push) Successful in 4m0s
Project CI / Frontend tests (push) Successful in 4m34s
Project CI / Backend tests (push) Successful in 6m48s
Project CI / Native shell tests (push) Successful in 16m12s
修复链路上存在的若干阻塞性漏洞 (#199)
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/199
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-28 17:09:02 +08:00

1650 lines
65 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 字的标题格。
///
/// 策划子 Agent 每轮都是全新 run,跨轮只能靠委派正文里转述的既往问答认路;header 带
/// 主题时它一眼能看出哪几条轴已经关掉。原型(`local-scripts/deisgn_agent`)就是这么
/// 做的:header 上限 60 字、写成 `第 N 轮 · 当前要决定:…`,决定台账的 `topic` 直接取
/// 它。本仓库把 header 压成固定 8 字的轮号计数器后这条通路就断了。
///
/// 放宽只对 `第{N}轮` 这一种形状生效(`plan_clarification_header_limit`)。通用问询今天
/// 能过的 header 明天逐字照过——做游戏 / 做素材两条泳道拿到的仍是 12 字上限,这里没有
/// 任何一条既有请求会因此改变结果。
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS: usize = 60;
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 能用到的字符上限。
///
/// 判据是形状而不是身份:这个函数在通用 `user.input_request` 解析路径上,六个调用点里
/// 有两个(工具计划校验、动作摘要)拿不到 root,问不出「这封信是不是策划链路的」。形状
/// 判据只放宽、从不收紧——非策划 header 一律走 12 字原路,策划 header 的真正定形由
/// `planning_coordinator::validate_exact_plan_clarification_question` 逐字兜底。
fn plan_clarification_header_limit(header: &str) -> usize {
let Some(rest) = header.trim_start().strip_prefix('第') else {
return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS;
};
let rest = rest.trim_start();
let digits = rest
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>();
if digits.is_empty() || !rest[digits.len()..].trim_start().starts_with('轮') {
return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS;
}
AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS
}
/// 一份 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;
// 取两种 header 里宽的那个:通道窄于 schema 的后果是一封完全合法的策划信封在父 run
// 认领回执时被拒、整条委派链阻断,正是这个常量当初要防的那件事。
let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_PLAN_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";
fn planning_answer_requires_precheck(status: &str) -> bool {
matches!(
status,
AGENT_RUNTIME_USER_INPUT_STATUS_PENDING | AGENT_RUNTIME_USER_INPUT_STATUS_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, Eq, PartialEq)]
pub(crate) struct PlanStaticDelegateAnsweredInput {
pub(crate) request_id: String,
pub(crate) response_id: String,
pub(crate) question: AgentRuntimeUserInputQuestion,
pub(crate) answer: String,
pub(crate) questions_sha256: String,
pub(crate) answers_sha256: String,
}
#[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,
plan_clarification_header_limit(&question.header),
&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 validate_answered_plan_static_delegate_user_input_identity(
delivery: &StaticDelegateDeliveryRecord,
root_task: &AgentRuntimeTaskRecord,
record: &AgentRuntimeUserInputRecord,
request_id: &str,
expected_questions_sha256: &str,
expected_answers_sha256: &str,
) -> Result<(), String> {
// The task record, rather than the static Supervisor agent ID, is the
// durable source of `taskId`: task IDs are not a substitute identity for
// agent IDs. Keep every other sidecar field anchored to this same root
// task so a valid answer cannot be transplanted across root runs/sessions.
if root_task.agent_id != delivery.parent_agent_id
|| root_task.task_id.trim().is_empty()
|| root_task.session_id != delivery.parent_session_id
|| root_task.run_id != delivery.parent_run_id
|| root_task.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE
|| root_task.parent_agent_id.is_some()
|| root_task.parent_run_id.is_some()
{
return Err("planning user-input sidecar 的 plan root task 身份冲突".to_string());
}
if record.agent_id != root_task.agent_id
|| record.task_id != root_task.task_id
|| record.session_id != root_task.session_id
|| record.run_id != root_task.run_id
|| record.source != root_task.source
|| record.request_id != request_id
|| record.status != AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED
|| record.questions_sha256 != expected_questions_sha256
|| record.answers_sha256.as_deref() != Some(expected_answers_sha256)
|| record.questions.len() != 1
|| record.answers.len() != 1
{
return Err("planning user-input sidecar 与 delivery 回答绑定冲突".to_string());
}
Ok(())
}
/// Read the answered Supervisor sidecar that is already bound to one exact
/// planning delivery. Only the fields needed for the derived plan-session
/// projection escape this module; the complete private answer record remains
/// encapsulated here.
pub(crate) fn read_answered_plan_static_delegate_user_input_at(
root: &Path,
delivery: &StaticDelegateDeliveryRecord,
) -> Result<PlanStaticDelegateAnsweredInput, String> {
if delivery.parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|| delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent
|| delivery.structured_result.as_ref().is_none_or(|result| {
result.contract_status != StaticDelegateContractStatus::NeedsUserInput
})
{
return Err("planning answer 只能从已认领的 NeedsUserInput delivery 读取".to_string());
}
validate_project_supervisor_plan_root_binding_for_crate_at(
root,
&delivery.parent_agent_id,
&delivery.parent_run_id,
)?;
let request_id = delivery
.clarification_request_id
.as_deref()
.ok_or_else(|| "planning delivery 尚未绑定 user-input requestId".to_string())?;
let expected_answers_sha256 = delivery
.clarification_answers_sha256
.as_deref()
.ok_or_else(|| "planning delivery 尚未绑定 answersSha256".to_string())?;
let relative_path = user_input_relative_path(
&delivery.parent_agent_id,
&delivery.parent_run_id,
request_id,
);
let record = read_agent_runtime_json_sidecar_with_max_bytes::<AgentRuntimeUserInputRecord>(
root,
&relative_path,
"Fast GDD 澄清回答",
AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES,
)?
.ok_or_else(|| "planning delivery 已绑定答案但 user-input sidecar 缺失".to_string())?;
validate_user_input_record_payload(root, &record)?;
let expected_questions_sha256 = delivery
.structured_result
.as_ref()
.and_then(|result| result.user_input_questions_sha256.as_deref())
.ok_or_else(|| "planning delivery 缺少 questionsSha256".to_string())?;
let root_task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&delivery.parent_agent_id,
&delivery.parent_run_id,
)?
.ok_or_else(|| "planning user-input sidecar 缺少所属 plan root task".to_string())?;
validate_answered_plan_static_delegate_user_input_identity(
delivery,
&root_task,
&record,
request_id,
expected_questions_sha256,
expected_answers_sha256,
)?;
let question = record.questions[0].clone();
let answer = record
.answers
.get(&question.id)
.cloned()
.ok_or_else(|| "planning user-input sidecar 缺少唯一答案".to_string())?;
Ok(PlanStaticDelegateAnsweredInput {
request_id: record.request_id,
response_id: record
.response_id
.ok_or_else(|| "planning user-input sidecar 缺少 responseId".to_string())?,
question,
answer,
questions_sha256: record.questions_sha256,
answers_sha256: record
.answers_sha256
.ok_or_else(|| "planning user-input sidecar 缺少 answersSha256".to_string())?,
})
}
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> {
match project_lock {
Some(project_lock) => validate_plan_clarification_answer_for_pending_at_locked(
root,
pending,
&record.questions,
&record.answers,
project_lock,
)?,
None => validate_plan_clarification_answer_for_pending_at(
root,
pending,
&record.questions,
&record.answers,
)?,
}
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)?;
if planning_answer_requires_precheck(&record.status) {
match project_lock {
Some(project_lock) => validate_plan_clarification_answer_for_pending_at_locked(
root,
pending,
&record.questions,
&answers,
project_lock,
)?,
None => validate_plan_clarification_answer_for_pending_at(
root,
pending,
&record.questions,
&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(),
},
],
}]
}
/// 放宽 header 上限只对策划澄清卡那一种形状生效,且只放宽、不收紧。
///
/// 两个方向都得钉:同一条 31 字的 header,带 `第N轮·` 前缀要过(策划卡装的是决定
/// 主题本身),不带就必须照旧被 12 字挡下——否则这次改动就顺手把做游戏 / 做素材
/// 的通用问询也放宽了,而那两条泳道本轮不该有任何行为变化。
#[test]
fn only_the_plan_clarification_header_shape_gets_the_wider_limit() {
let long_topic = "当前要决定:一局里玩家靠什么目标获得满足";
assert!(long_topic.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS);
let mut plan_header = valid_questions();
plan_header[0].header = format!("第1轮·{long_topic}");
assert!(normalize_user_input_questions(plan_header).is_ok());
let mut generic_header = valid_questions();
generic_header[0].header = long_topic.to_string();
assert!(normalize_user_input_questions(generic_header)
.expect_err("通用 header 不得因为策划分支被放宽")
.contains(&AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS.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"));
}
#[test]
fn planning_answer_precheck_stops_after_answered_commit() {
assert!(planning_answer_requires_precheck(
AGENT_RUNTIME_USER_INPUT_STATUS_PENDING
));
assert!(planning_answer_requires_precheck(
AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED
));
assert!(!planning_answer_requires_precheck(
AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED
));
assert!(!planning_answer_requires_precheck(
AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED
));
}
#[test]
fn answered_plan_sidecar_requires_root_task_id_not_supervisor_agent_id() {
// Keep this at the pure identity seam: the production reader has
// already validated the sidecar payload, project identity and durable
// Run Profile binding before it reaches this check.
let mut root_task = AgentRuntimeTaskRecord {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
task_id: "plan-root-task-id".to_string(),
session_id: "plan-root-session".to_string(),
run_id: "plan-root-run".to_string(),
source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.to_string(),
run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(),
run_profile_binding_fingerprint: "binding".to_string(),
parent_agent_id: None,
parent_run_id: None,
delegation_id: None,
goal_id: None,
goal_revision: 0,
goal_status: None,
task: "收敛 Fast GDD".to_string(),
status: "running".to_string(),
phase: "waiting-for-user-input".to_string(),
current_action: "等待用户回答".to_string(),
terminal_detail: None,
error: None,
updated_at: 1,
};
let question = AgentRuntimeUserInputQuestion {
id: "route_choice".to_string(),
header: "第1轮·当前要决定:首版路线".to_string(),
question: "它决定第一批关卡按什么规则组合。".to_string(),
options: vec![
AgentRuntimeUserInputOption {
label: "接受推荐".to_string(),
description: "采用推荐。".to_string(),
},
AgentRuntimeUserInputOption {
label: "暂按推荐".to_string(),
description: "暂按推荐。".to_string(),
},
AgentRuntimeUserInputOption {
label: "需要原型验证".to_string(),
description: "先验证。".to_string(),
},
],
};
let questions = vec![question.clone()];
let answers = BTreeMap::from([("route_choice".to_string(), "接受推荐".to_string())]);
let questions_sha256 = user_input_sha256_json(&questions).expect("questions fingerprint");
let answers_sha256 = user_input_sha256_json(&answers).expect("answers fingerprint");
let request_id = "user-input-plan-root-task-id";
let response_id = "plan-root-answer";
let record = AgentRuntimeUserInputRecord {
schema_version: AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION.to_string(),
project_id: "project-id".to_string(),
agent_id: root_task.agent_id.clone(),
task_id: root_task.task_id.clone(),
session_id: root_task.session_id.clone(),
run_id: root_task.run_id.clone(),
source: root_task.source.clone(),
action_id: "action-id".to_string(),
action_fingerprint: "a".repeat(64),
goal_id: None,
goal_revision: 0,
goal_snapshot_fingerprint: String::new(),
planned_steer_cursor: 0,
request_id: request_id.to_string(),
questions,
questions_sha256: questions_sha256.clone(),
question_count: 1,
option_count: 3,
question_chars: user_input_question_counts(&[question.clone()]).2,
question_message_id: user_input_question_message_id(request_id),
status: AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED.to_string(),
response_id: Some(response_id.to_string()),
answers,
answers_sha256: Some(answers_sha256.clone()),
answer_count: 1,
answer_chars: "接受推荐".chars().count() as u32,
answer_message_id: Some(user_input_answer_message_id(request_id, response_id)),
observation: None,
created_at: 1,
answer_prepared_at: Some(1),
answered_at: Some(1),
cancelled_at: None,
updated_at: 1,
};
let delivery = StaticDelegateDeliveryRecord {
schema_version: "game-creator-static-delegate-delivery.v1".to_string(),
parent_agent_id: root_task.agent_id.clone(),
parent_session_id: root_task.session_id.clone(),
parent_run_id: root_task.run_id.clone(),
parent_action_id: "delegate-action".to_string(),
delegation_id: "delegation-id".to_string(),
target_agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(),
target_session_id: "planning-session".to_string(),
target_run_id: "planning-run".to_string(),
acceptance_criteria: Vec::new(),
expected_artifacts: Vec::new(),
repair_of_delegation_id: None,
clarification_request_id: Some(request_id.to_string()),
clarification_answers_sha256: Some(answers_sha256.clone()),
status: StaticDelegateDeliveryStatus::ClaimedByParent,
terminal_status: Some("completed".to_string()),
result_summary: Some("需要用户澄清".to_string()),
structured_result: None,
claimed_by_action_id: Some("claim-action".to_string()),
updated_at: 1,
};
validate_answered_plan_static_delegate_user_input_identity(
&delivery,
&root_task,
&record,
request_id,
&questions_sha256,
&answers_sha256,
)
.expect("actual plan root taskId is accepted");
let mut wrong_task_id = record.clone();
wrong_task_id.task_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string();
assert!(validate_answered_plan_static_delegate_user_input_identity(
&delivery,
&root_task,
&wrong_task_id,
request_id,
&questions_sha256,
&answers_sha256,
)
.expect_err("agentId must not be accepted as taskId")
.contains("delivery 回答绑定冲突"));
root_task.parent_run_id = Some("unexpected-parent".to_string());
assert!(validate_answered_plan_static_delegate_user_input_identity(
&delivery,
&root_task,
&record,
request_id,
&questions_sha256,
&answers_sha256,
)
.expect_err("plan root task must not have a parent")
.contains("plan root task 身份冲突"));
}
}