绑定Agent工具确认到精确动作

Runtime 为工具输入生成 SHA-256 指纹和安全摘要

确认票据仅放行同一 Agent、run、command 和输入

开发窗口接通待确认动作的确认继续入口

补充动作变更阻断和界面确认回归测试

同步 Runtime V1 技术方案和项目记忆
This commit is contained in:
AIGameCreator App
2026-07-10 09:01:32 +08:00
parent d150f77301
commit a5c85c912b
10 changed files with 613 additions and 30 deletions
+1
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"shared-contracts",
"tauri",
"tauri-build",
@@ -10,6 +10,7 @@ tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
+201 -15
View File
@@ -1,4 +1,5 @@
use super::*;
use sha2::{Digest, Sha256};
static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
@@ -377,7 +378,7 @@ fn start_game_creator_agent_background_task_with_run_id_at(
run_id: &str,
) -> Result<(AgentRuntimeResult, String), String> {
start_game_creator_agent_background_task_with_confirmed_tool_at(
root, agent_id, task, run_id, None, "",
root, agent_id, task, run_id, None, None, "",
)
}
@@ -387,6 +388,7 @@ fn start_game_creator_agent_background_task_with_confirmed_tool_at(
task: &str,
run_id: &str,
confirmed_command_id: Option<&str>,
confirmed_action_fingerprint: Option<&str>,
confirmation_note: &str,
) -> Result<(AgentRuntimeResult, String), String> {
start_game_creator_agent_background_task_with_source_at(
@@ -396,6 +398,7 @@ fn start_game_creator_agent_background_task_with_confirmed_tool_at(
run_id,
"agent-background-task",
confirmed_command_id,
confirmed_action_fingerprint,
confirmation_note,
)
}
@@ -407,6 +410,7 @@ fn start_game_creator_agent_background_task_with_source_at(
run_id: &str,
source: &str,
confirmed_command_id: Option<&str>,
confirmed_action_fingerprint: Option<&str>,
confirmation_note: &str,
) -> Result<(AgentRuntimeResult, String), String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
@@ -447,11 +451,15 @@ fn start_game_creator_agent_background_task_with_source_at(
}),
)?;
if let Some(command_id) = confirmed_command_id {
let action_fingerprint = confirmed_action_fingerprint
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Agent Runtime 工具确认缺少动作指纹".to_string())?;
write_game_creator_agent_runtime_tool_confirmation(
root,
&agent_id,
&run_id,
command_id,
action_fingerprint,
confirmation_note,
)?;
}
@@ -522,6 +530,7 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks_at(
&run_id,
"agent-ready-task-scheduler",
None,
None,
"",
) {
Ok((result, actual_run_id)) => {
@@ -776,6 +785,12 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at(
.ok_or_else(|| "未找到待确认工具动作".to_string())?;
let command_id = game_creator_agent_runtime_tool_command_id(&tool_call.tool)
.ok_or_else(|| format!("待确认工具不在白名单中:{}", tool_call.tool))?;
let action_fingerprint = tool_call
.action_fingerprint
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "待确认工具动作缺少精确指纹,请重新提交该任务".to_string())?;
let input_summary = tool_call.input_summary.clone();
let note = sanitize_agent_runtime_text(note, 240);
let confirmed_task = if note.trim().is_empty() {
format!(
@@ -800,6 +815,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at(
&confirmed_task,
&requested_run_id,
Some(command_id),
Some(action_fingerprint),
&note,
)?;
append_game_creator_agent_runtime_task_record(
@@ -830,6 +846,8 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at(
"confirmedRunId": confirmed_run_id,
"tool": tool_call.tool,
"commandId": command_id,
"actionFingerprint": action_fingerprint,
"inputSummary": input_summary,
"note": note,
}),
)?;
@@ -1103,7 +1121,7 @@ async fn run_game_creator_agent_background_task(
}
let observation_summary = observation.summary();
runtime.observations.push(observation_summary.clone());
append_agent_runtime_tool_call_record(&mut runtime, action, &observation);
append_agent_runtime_tool_call_record(&root, &mut runtime, action, &observation);
if observation.is_waiting_for_confirmation() {
runtime.status = "waiting-for-confirmation".to_string();
runtime.phase = "waiting-for-confirmation".to_string();
@@ -1167,6 +1185,8 @@ async fn run_game_creator_agent_background_task(
"taskId": runtime.task_id,
"runId": runtime.run_id,
"tool": observation.tool,
"actionFingerprint": agent_runtime_tool_action_fingerprint(action),
"inputSummary": agent_runtime_tool_action_input_summary(&root, action),
"summary": observation.summary,
}),
);
@@ -1382,6 +1402,7 @@ enum AgentRuntimeToolPolicyBlock {
}
fn append_agent_runtime_tool_call_record(
root: &Path,
runtime: &mut AgentRuntimeState,
action: &AgentRuntimeToolAction,
observation: &AgentRuntimeToolObservation,
@@ -1389,6 +1410,8 @@ fn append_agent_runtime_tool_call_record(
runtime.recent_tool_calls.push(AgentRuntimeToolCallRecord {
tool: observation.tool.clone(),
status: observation.status.clone(),
action_fingerprint: Some(agent_runtime_tool_action_fingerprint(action)),
input_summary: agent_runtime_tool_action_input_summary(root, action),
reason: action
.reason
.as_deref()
@@ -1411,6 +1434,112 @@ fn append_agent_runtime_tool_call_record(
}
}
fn agent_runtime_tool_action_fingerprint(action: &AgentRuntimeToolAction) -> String {
let payload = serde_json::json!({
"tool": action.tool.trim(),
"input": &action.input,
});
let encoded = serde_json::to_vec(&payload).unwrap_or_default();
format!("{:x}", Sha256::digest(encoded))
}
fn agent_runtime_tool_action_input_summary(
root: &Path,
action: &AgentRuntimeToolAction,
) -> Option<String> {
let input = &action.input;
let tool = action.tool.trim();
let text = |keys: &[&str]| agent_runtime_tool_input_text(input, keys);
let relative_path = |keys: &[&str]| {
let value = text(keys);
if Path::new(&value).is_absolute() {
"[absolute path rejected]".to_string()
} else {
value
}
};
let chars = |keys: &[&str]| {
keys.iter()
.find_map(|key| input.get(*key).and_then(|value| value.as_str()))
.map(|value| value.chars().count())
.unwrap_or(0)
};
let list_len = |keys: &[&str]| {
keys.iter()
.find_map(|key| input.get(*key).and_then(|value| value.as_array()))
.map(Vec::len)
.unwrap_or(0)
};
let summary = match tool {
"memory.read" => format!("scope={}", text(&["scope"])),
"memory.write" => format!(
"scope={} · mode={} · title={} · contentChars={}",
text(&["scope"]),
text(&["mode"]),
text(&["title"]),
chars(&["content"])
),
"project.restore" | "project.diff" => format!(
"checkpointId={}",
text(&["checkpointId", "checkpoint_id", "id"])
),
"file.list" | "file.read" => format!("path={}", relative_path(&["path"])),
"file.write" => format!(
"path={} · contentChars={}",
relative_path(&["path"]),
chars(&["content"])
),
"task.create" => format!(
"taskId={} · title={} · group={} · role={} · dependencies={} · artifacts={} · criteria={}",
text(&["taskId", "task_id", "id"]),
text(&["title"]),
text(&["group"]),
text(&["role"]),
list_len(&["dependencies"]),
list_len(&["artifacts"]),
list_len(&["acceptanceCriteria", "acceptance_criteria"])
),
"task.update" => format!(
"taskId={} · status={}",
text(&["taskId", "task_id", "id"]),
text(&["status"])
),
"command.run_limited" => format!(
"commandId={}",
text(&["commandId", "command_id", "id"])
),
"canvas.asset_generate" => format!("promptChars={}", chars(&["prompt"])),
"blackboard.write" => format!(
"title={} · contentChars={}",
text(&["title"]),
chars(&["content"])
),
"agent.message" => format!(
"agentId={} · contentChars={}",
text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]),
chars(&["content"])
),
"agent.delegate" => format!(
"agentId={} · runId={} · taskChars={}",
text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]),
text(&["runId", "run_id"]),
chars(&["task"])
),
"agent.schedule_ready" => format!(
"limit={}",
input.get("limit").and_then(|value| value.as_u64()).unwrap_or(1)
),
"agent.run_status" => format!(
"scope={} · agentId={}",
text(&["scope"]),
text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"])
),
_ => String::new(),
};
let summary = redact_agent_runtime_project_paths(root, &summary, 320);
(!summary.trim().is_empty()).then_some(summary)
}
fn update_agent_runtime_plan_steps(runtime: &mut AgentRuntimeState, plan: Vec<String>) {
runtime.plan = plan
.into_iter()
@@ -1700,11 +1829,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action(
action: &AgentRuntimeToolAction,
) -> AgentRuntimeToolObservation {
let tool = action.tool.trim();
let action_fingerprint = agent_runtime_tool_action_fingerprint(action);
let command_id = game_creator_agent_runtime_tool_command_id(tool);
if let Some(command_id) = command_id {
if let Some(blocked) =
game_creator_agent_runtime_tool_policy_block(root, agent_id, run_id, command_id)
{
if let Some(blocked) = game_creator_agent_runtime_tool_policy_block(
root,
agent_id,
run_id,
command_id,
&action_fingerprint,
) {
let (status, summary) = match blocked {
AgentRuntimeToolPolicyBlock::Denied(summary) => ("blocked", summary),
AgentRuntimeToolPolicyBlock::RequiresConfirmation(summary) => {
@@ -1836,6 +1970,7 @@ fn write_game_creator_agent_runtime_tool_confirmation(
agent_id: &str,
run_id: &str,
command_id: &str,
action_fingerprint: &str,
note: &str,
) -> Result<(), String> {
let path =
@@ -1853,6 +1988,7 @@ fn write_game_creator_agent_runtime_tool_confirmation(
"agentId": agent_id,
"runId": run_id,
"commandId": command_id,
"actionFingerprint": action_fingerprint,
"note": sanitize_agent_runtime_text(note, 240),
"updatedAt": unix_timestamp(),
});
@@ -1871,20 +2007,40 @@ fn consume_game_creator_agent_runtime_tool_confirmation(
agent_id: &str,
run_id: &str,
command_id: &str,
action_fingerprint: &str,
) -> Result<bool, String> {
if run_id.trim().is_empty() {
return Ok(false);
}
let path =
game_creator_agent_runtime_tool_confirmation_path(root, agent_id, run_id, command_id);
match fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(format!(
"读取 Agent Runtime 工具确认失败:{}: {error}",
path.display()
))
}
};
let payload = serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
format!(
"解析 Agent Runtime 工具确认失败:{}: {error}",
path.display()
)
})?;
let confirmed_fingerprint = payload
.get("actionFingerprint")
.and_then(|value| value.as_str())
.unwrap_or_default();
fs::remove_file(&path).map_err(|error| {
format!(
"消费 Agent Runtime 工具确认失败:{}: {error}",
path.display()
)),
}
)
})?;
Ok(!action_fingerprint.trim().is_empty() && confirmed_fingerprint == action_fingerprint)
}
fn agent_runtime_executable_tools() -> Vec<&'static str> {
@@ -1997,6 +2153,7 @@ fn game_creator_agent_runtime_tool_policy_block(
agent_id: &str,
run_id: &str,
command_id: &str,
action_fingerprint: &str,
) -> Option<AgentRuntimeToolPolicyBlock> {
let view = match read_project_permission_policy_at(root) {
Ok(view) => view,
@@ -2039,7 +2196,11 @@ fn game_creator_agent_runtime_tool_policy_block(
.any(|command| command == command_id)
{
match consume_game_creator_agent_runtime_tool_confirmation(
root, &agent_id, run_id, command_id,
root,
&agent_id,
run_id,
command_id,
action_fingerprint,
) {
Ok(true) => return None,
Ok(false) => {}
@@ -2062,7 +2223,11 @@ fn game_creator_agent_runtime_tool_policy_block(
.unwrap_or(false)
{
match consume_game_creator_agent_runtime_tool_confirmation(
root, &agent_id, run_id, command_id,
root,
&agent_id,
run_id,
command_id,
action_fingerprint,
) {
Ok(true) => return None,
Ok(false) => {}
@@ -3517,12 +3682,23 @@ fn format_agent_runtime_task_observation(task: &AgentRuntimeTaskRecord) -> Strin
}
fn format_agent_runtime_tool_call_observation(call: &AgentRuntimeToolCallRecord) -> String {
format!(
let mut output = format!(
"{} / {} / {}",
call.tool,
call.status,
sanitize_agent_runtime_text(&call.summary, 120)
)
);
if let Some(input_summary) = call
.input_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
{
output.push_str(&format!(
" / target={}",
sanitize_agent_runtime_text(input_summary, 160)
));
}
output
}
fn agent_runtime_tool_input_text(input: &serde_json::Value, keys: &[&str]) -> String {
@@ -4993,6 +5169,16 @@ fn render_agent_runtime_prompt_context(root: &Path, agent_id: &str) -> Result<St
redact_agent_runtime_project_paths(root, reason, 160)
));
}
if let Some(input_summary) = call
.input_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
{
line.push_str(&format!(
";目标:{}",
redact_agent_runtime_project_paths(root, input_summary, 220)
));
}
if let Some(detail) = call
.detail
.as_deref()
@@ -438,7 +438,8 @@ pub(crate) fn confirm_game_creator_agent_runtime_task(
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
enforce_project_auto_permission_policy(root, "agent.resume")?;
// 该命令只由开发者显式点击“确认继续”触发。
enforce_project_permission_policy(root, "agent.resume")?;
confirm_game_creator_agent_runtime_task_at(
root,
agent_id.trim(),
@@ -226,6 +226,10 @@ struct AgentRuntimeToolCallRecord {
#[serde(default)]
status: String,
#[serde(default)]
action_fingerprint: Option<String>,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
summary: String,
@@ -4897,7 +4897,7 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action()
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["file.read".to_string()],
confirm_commands: vec!["file.read".to_string(), "agent.resume".to_string()],
agent_policies: BTreeMap::new(),
},
)
@@ -4953,13 +4953,31 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action()
.expect("first plan llm request");
let waiting_runtime = wait_for_agent_runtime_confirmation(&root, "design-director");
assert_eq!(waiting_runtime.status, "waiting-for-confirmation");
let pending_tool_call = waiting_runtime
.recent_tool_calls
.iter()
.rev()
.find(|call| call.status == "waiting-for-confirmation")
.expect("pending tool call");
assert_eq!(
pending_tool_call.input_summary.as_deref(),
Some("path=game/notes.txt")
);
assert_eq!(
pending_tool_call
.action_fingerprint
.as_deref()
.expect("action fingerprint")
.len(),
64
);
let confirmed = confirm_game_creator_agent_runtime_task_at(
&root,
"design-director",
"design-confirm-run",
"design-confirm-run-approved",
"允许读取项目笔记",
let confirmed = confirm_game_creator_agent_runtime_task(
root.to_string_lossy().into_owned(),
"design-director".to_string(),
"design-confirm-run".to_string(),
"design-confirm-run-approved".to_string(),
"允许读取项目笔记".to_string(),
)
.expect("confirm waiting task");
assert_eq!(confirmed.state.run_id, "design-confirm-run-approved");
@@ -4995,6 +5013,126 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action()
assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation.approved\""));
assert!(agent_db.contains("\"confirmedRunId\":\"design-confirm-run-approved\""));
assert!(agent_db.contains("\"tool\":\"file.read\""));
assert!(agent_db.contains("\"actionFingerprint\":"));
assert!(agent_db.contains("\"inputSummary\":\"path=game/notes.txt\""));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_confirmation_is_bound_to_exact_tool_input() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
fs::write(root.join("game/notes.txt"), "已批准读取的笔记").expect("write approved notes");
fs::write(root.join("game/other.txt"), "未批准读取的笔记").expect("write changed notes");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["file.read".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let (sender, receiver) = mpsc::channel();
let approved_plan = serde_json::json!({
"thinkingSummary": "需要读已批准的项目笔记",
"plan": ["读取已批准笔记"],
"actions": [
{
"tool": "file.read",
"reason": "读取已批准目标",
"input": { "path": "game/notes.txt" }
}
],
"response": ""
})
.to_string();
let changed_plan = serde_json::json!({
"thinkingSummary": "改为读取另一个文件",
"plan": ["读取未批准目标"],
"actions": [
{
"tool": "file.read",
"reason": "尝试复用旧确认读取不同目标",
"input": { "path": "game/other.txt" }
}
],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![approved_plan, changed_plan],
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"后台分析当前玩法循环",
"design-exact-confirm-run",
)
.expect("start background task");
receiver
.recv_timeout(Duration::from_secs(2))
.expect("first plan llm request");
let first_waiting = wait_for_agent_runtime_confirmation(&root, "design-director");
assert_eq!(first_waiting.run_id, "design-exact-confirm-run");
confirm_game_creator_agent_runtime_task_at(
&root,
"design-director",
"design-exact-confirm-run",
"design-exact-confirm-approved",
"只允许读取 game/notes.txt",
)
.expect("confirm exact action");
receiver
.recv_timeout(Duration::from_secs(2))
.expect("changed plan llm request");
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
let changed_waiting = wait_for_agent_runtime_confirmation(&root, "design-director");
assert_eq!(changed_waiting.run_id, "design-exact-confirm-approved");
assert_eq!(changed_waiting.status, "waiting-for-confirmation");
assert!(!changed_waiting
.observations
.iter()
.any(|item| item.contains("file.readok")));
let changed_tool_call = changed_waiting
.recent_tool_calls
.iter()
.rev()
.find(|call| call.status == "waiting-for-confirmation")
.expect("changed pending tool call");
assert_eq!(
changed_tool_call.input_summary.as_deref(),
Some("path=game/other.txt")
);
assert_ne!(
changed_tool_call.action_fingerprint,
first_waiting
.recent_tool_calls
.iter()
.rev()
.find(|call| call.status == "waiting-for-confirmation")
.and_then(|call| call.action_fingerprint.clone())
);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(!agent_db.contains("未批准读取的笔记"));
fs::remove_dir_all(root).ok();
}
+66
View File
@@ -270,6 +270,8 @@ interface AgentRuntimeToolPolicySnapshot {
interface AgentRuntimeToolCallRecord {
tool: string;
status: string;
actionFingerprint?: string | null;
inputSummary?: string | null;
reason: string | null;
summary: string;
detail: string | null;
@@ -926,6 +928,7 @@ function AgentRuntimeStatusPanel({
key={`${runtime.sessionId}-tool-${toolCall.updatedAt}-${index}`}
>
{`${toolCall.tool} · ${toolCall.status} · ${toolCall.summary}`}
{toolCall.inputSummary ? ` · 目标:${toolCall.inputSummary}` : ''}
{toolCall.reason ? ` · ${toolCall.reason}` : ''}
</small>
))}
@@ -3934,6 +3937,66 @@ export function WorkspaceLauncher({
}
}
async function handleAgentChatConfirmRuntimeTask(runId: string) {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) {
return;
}
const llmWarning = getCurrentAgentChatLlmWarning(agent);
if (llmWarning) {
setAgentChatStatus(llmWarning);
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('需要在 Tauri App 内运行');
return;
}
const saveVersion = agentChatLoadVersionRef.current + 1;
agentChatLoadVersionRef.current = saveVersion;
setAgentChatBackgroundBusy(true);
setAgentChatStatus('正在确认并继续 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'confirm_game_creator_agent_runtime_task',
{
projectPath: projectPathForChat,
agentId: agent.id,
runId,
nextRunId: createAgentChatRunId('launcher-agent-confirm'),
note: '开发者已确认待执行工具动作',
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatRuntime(agentRuntimeStateFromResult(runtime));
setAgentChatRuntimeError('');
const conversation = await invoke<LocalConversationResult>(
'read_local_conversation',
{
projectPath: projectPathForChat,
agentId: agent.id,
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatMessages(conversation.messages);
setAgentChatStatus(agentRuntimeStartStatus(runtime));
} catch (error) {
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatStatus(error instanceof Error ? error.message : String(error));
} finally {
if (agentChatLoadVersionRef.current === saveVersion) {
setAgentChatBackgroundBusy(false);
}
}
}
const projectRows = recentWorkspaces.map((workspace) => {
const directoryStatus = recentWorkspaceStatuses[workspace];
const isPendingStatus = directoryStatus === undefined;
@@ -4608,6 +4671,9 @@ export function WorkspaceLauncher({
onRetryRuntimeTask={(runId) =>
void handleAgentChatRetryRuntimeTask(runId)
}
onConfirmRuntimeTask={(runId) =>
void handleAgentChatConfirmRuntimeTask(runId)
}
/>
</section>
<div className="launcher-agent-chat-messages" aria-label="Agent 聊天记录">
@@ -1436,6 +1436,8 @@ describe('AI 游戏创作 App 界面边界', () => {
{
tool: 'file.read',
status: 'ok',
actionFingerprint: 'a'.repeat(64),
inputSummary: 'path=game/notes.txt',
reason: '读取笔记',
summary: '已读取 game/notes.txt',
detail: 'game/notes.txt: 连续上下文笔记',
@@ -1690,7 +1692,9 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.getByText('file.readok · 已读取 game/notes.txt')).not.toBeNull();
expect(screen.getByText('最近动作')).not.toBeNull();
expect(
screen.getByText('file.read · ok · 已读取 game/notes.txt · 读取笔记'),
screen.getByText(
'file.read · ok · 已读取 game/notes.txt · 目标:path=game/notes.txt · 读取笔记',
),
).not.toBeNull();
expect(screen.getByText('最近事件')).not.toBeNull();
expect(
@@ -1780,6 +1784,188 @@ describe('AI 游戏创作 App 界面边界', () => {
).not.toBeNull();
});
it('confirms the exact pending tool action from the developer agent window', async () => {
const waitingRuntimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'launcher-agent-waiting',
source: 'agent-background-task',
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
currentTask: '读取角色规范笔记',
currentGoal: '确认角色规范依据',
currentAction: '等待确认工具 file.read',
waitingOn: '开发者确认 Agent 工具动作',
nextStep: '确认或调整策略后继续 Agent 工具动作:file.read',
loopIteration: 1,
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目笔记'],
observations: ['file.readwaiting-for-confirmation'],
recentToolCalls: [
{
tool: 'file.read',
status: 'waiting-for-confirmation',
actionFingerprint: 'b'.repeat(64),
inputSummary: 'path=game/notes.txt',
reason: '读取角色规范依据',
summary: '项目权限策略要求用户确认:file.read',
detail: null,
updatedAt: 5000,
},
],
taskQueue: {
total: 1,
pending: 0,
running: 0,
waitingForConfirmation: 1,
cancelled: 0,
completed: 0,
failed: 0,
latestRunId: 'launcher-agent-waiting',
updatedAt: 5000,
},
allowedTools: ['file.read'],
lastResponse: null,
error: null,
updatedAt: 5000,
};
const waitingTask = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'launcher-agent-waiting',
source: 'agent-background-task',
task: '读取角色规范笔记',
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
currentAction: '等待确认工具 file.read',
error: null,
updatedAt: 5000,
};
const runtimeResult = {
state: waitingRuntimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: waitingRuntimeState.taskQueue,
recentEvents: [],
recentTasks: [waitingTask],
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'check_game_creator_llm_config') {
return {
configured: true,
apiKeyPresent: true,
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-5.5',
apiKind: 'openai_chat',
stream: true,
error: null,
agents: [
{
agentId: 'design-director',
label: '拆解创作方向',
configured: true,
apiKeyPresent: true,
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-5.5',
apiKind: 'openai_chat',
stream: true,
error: null,
},
],
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: [],
};
}
if (command === 'read_game_creator_agent_runtime') {
return runtimeResult;
}
if (command === 'confirm_game_creator_agent_runtime_task') {
const runId = String(args?.nextRunId ?? 'launcher-agent-confirmed');
const taskQueue = {
...waitingRuntimeState.taskQueue,
running: 1,
waitingForConfirmation: 0,
latestRunId: runId,
};
return {
...runtimeResult,
state: {
...waitingRuntimeState,
runId,
status: 'running',
phase: 'planning',
currentAction: '生成 Agent 工具计划(第 1 轮)',
waitingOn: 'Agent 输出计划或回复',
taskQueue,
},
taskQueue,
recentTasks: [
{
...waitingTask,
runId,
status: 'running',
phase: 'planning',
},
],
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
expect(
await screen.findByText(
'waiting-for-confirmation / waiting-for-confirmation',
),
).not.toBeNull();
expect(
screen.getByText(
'file.read · waiting-for-confirmation · 项目权限策略要求用户确认:file.read · 目标:path=game/notes.txt · 读取角色规范依据',
),
).not.toBeNull();
const runtimeActions = screen.getByLabelText('Agent Runtime 操作');
const confirmButton = within(runtimeActions).getByRole('button', {
name: '确认继续',
}) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(false);
fireEvent.click(confirmButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_game_creator_agent_runtime_task',
expect.objectContaining({
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
runId: 'launcher-agent-waiting',
note: '开发者已确认待执行工具动作',
}),
);
});
expect(await screen.findByText('running / planning')).not.toBeNull();
});
it('shows queued developer agent background tasks when the agent is already running', async () => {
const runningRuntimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
@@ -18566,7 +18752,7 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(
await screen.findByText(
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
),
).not.toBeNull();
expect(screen.queryByText('project.policy_write')).toBeNull();
@@ -4070,10 +4070,10 @@
- 2026-07-10 调整:Agent Runtime 后台任务的 `runId` 是同一 Agent 任务历史的身份,不允许复用覆盖。`start_game_creator_agent_runtime_task``agent.delegate` 和 retry 进入后台队列前会读取该 Agent 全量 task JSONL 历史;若调用方传入的规范化 runId 已存在,Runtime 自动追加 `-dup-<timestamp>-<attempt>` 生成实际 runId。任务队列、delegate observation 和 `agent.db` 审计都必须使用实际 runId,避免 `latest_game_creator_agent_runtime_tasks` 按 runId 去重时折叠掉不同任务。
- 2026-07-10 调整:Agent Runtime 的 `memory.write scope=agent` 只能写当前 Agent 自己的私有记忆。若 action 指定其他 `agentId / targetAgentId`Runtime 返回 `blocked` observation,不写目标 Agent 私有记忆、不写 `agent.runtime.memory.write` 审计;跨 Agent 共享稳定结论必须走 `blackboard.write`,给单个 Agent 留上下文必须走 `agent.message`
- 2026-07-10 调整:Agent Runtime 和本地对话使用 append-only JSONL 作为事实源时,进程内必须按目标文件路径串行追加整行。`.agent/agent.db``.agent/conversations/**/*.jsonl``.agent/runtime/events/*.jsonl``.agent/runtime/tasks/*.jsonl``.agent/activity.jsonl``.agent/output.jsonl` 统一走共享追加 helper,避免多个后台 Agent 并行完成时 JSON record 与换行交错。
- 2026-07-10 调整:Agent Runtime 待确认工具动作支持确认后继续。开发者确认 `waiting-for-confirmation` run 时,Runtime 为新 run 写入一次性 `.agent/runtime/confirmations/<agentId>/<runId>/<commandId>.json` 票据并重新入队;工具权限 gate 在 deny 之后、confirm 阶段消费该票据,只放行对应 Agent/run/command 一次。原 waiting run 追加 `completed/confirmed` 任务记录,避免队列长期显示等待确认;审计记录写 `agent.runtime.tool_confirmation.approved`
- 2026-07-10 调整:Agent Runtime 待确认工具动作支持确认后继续。开发者确认 `waiting-for-confirmation` run 时,Runtime 为新 run 写入一次性 `.agent/runtime/confirmations/<agentId>/<runId>/<commandId>.json` 票据并重新入队;票据同时保存工具名与输入 JSON 的 SHA-256 `actionFingerprint`权限 gate 在 deny 之后、confirm 阶段只放行对应 Agent/run/command/fingerprint 一次,模型若把同一工具改成其他路径、checkpoint 或目标 Agent,旧票据立即失效并重新进入待确认。`recentToolCalls` 和确认审计只额外保存安全 `inputSummary`,例如相对路径、checkpoint id、目标 Agent 或内容字符数,不保存待写正文、消息正文、素材 prompt 或 API Key。开发窗口必须把 `onConfirmRuntimeTask` 接入真实“确认继续”按钮;该显式确认命令允许 `agent.resume` 处于 confirm,只继续服从 deny,自动重启恢复仍要求 `agent.resume` 为 auto。原 waiting run 追加 `completed/confirmed` 任务记录,避免队列长期显示等待确认;审计记录写 `agent.runtime.tool_confirmation.approved`
- 2026-07-10 调整:Agent Runtime 工具箱新增 `task.create`,用于让 Agent 把目标拆成新的 manifest 任务,而不只能更新 seed task。该工具默认 `confirm` 权限,写入前要求 taskId 唯一、依赖指向已有任务、列表长度受限,并写 `agent.runtime.task.create` 审计;策略要求确认或拒绝时不修改 `.agent/manifest.json`
- 2026-07-10 调整:Agent Runtime 新增 `agent.schedule_ready` 调度入口,默认 `confirm` 权限。命令会扫描 `.agent/manifest.json` 中依赖已完成且仍为 `pending` 的 ready task,先把任务标成 `running`,再用 taskId 作为 Agent id 投递到既有后台队列,source 记为 `agent-ready-task-scheduler`,并写 `agent.runtime.ready_task.scheduled` 审计;后续执行仍走原 per-agent 锁、任务 JSONL、LLM loop、工具策略和事件流,不新增独立 worker。默认确认策略下该命令不会静默调度。
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt。状态面板展示最近动作与安全目标摘要时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥、待写正文或任意未过滤输入。
- 2026-07-10 调整:Agent Runtime state 新增 `currentGoal``waitingOn``currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。
- 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。
- 2026-07-10 调整:Agent Runtime state 新增 `planSteps / activePlanStepIndex`。Runtime 从 Agent 输出的 `plan` 派生结构化计划步骤,并在 action / observation / response / error 生命周期中更新 `pending / active / completed / failed` 和 detail;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示步骤进度,不再只依赖不可定位的 plan 字符串。
File diff suppressed because one or more lines are too long