e399eaf5de
新增 user.input_request 状态机与私有 sidecar,支持重启恢复、取消和同一 Run 续跑 接入终端聊天、开发 Agent 聊天、项目 Agent 对话和 Project Supervisor 问题卡 扩展任务队列、能力清单与前后端共享契约 补齐单元测试、界面回归、真实 Runtime E2E 和技术文档
1057 lines
40 KiB
Rust
1057 lines
40 KiB
Rust
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"));
|
||
}
|
||
}
|