From 56f02cc46bfdf4bf7e8057a892444736a773dd93 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Tue, 14 Jul 2026 17:08:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8D=95Agent=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E8=BF=BD=E5=8A=A0=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增同一 Run 持久化追加指令、Provider 中断与安全重规划链路 补齐开发窗口、项目 Agent 面板和 CLI 的 steer 入口与状态交互 升级 Runner 协议并支持旧 Runner 空闲退出后平滑替换 增加真实 Provider steer 验收脚本并修复 finalization 与 Git 提交回归 同步 Runtime 技术方案、实施计划和共享决策记录 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/agent-runtime-steer-real-e2e.mjs | 426 +++++ .../src-tauri/src/agent.rs | 1704 ++++++++++++++++- .../src-tauri/src/cli.rs | 179 ++ .../src-tauri/src/commands.rs | 32 + .../src-tauri/src/git_inspect.rs | 272 ++- .../src-tauri/src/main.rs | 32 + .../src-tauri/src/project.rs | 13 +- .../src-tauri/src/runner.rs | 368 +++- .../src-tauri/src/tests.rs | 702 ++++++- apps/ai-game-creator-shell/src/App.tsx | 422 +++- .../tests/appSurface.test.ts | 494 +++++ .../shared-memory/decision-log.md | 9 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 18 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + package.json | 1 + 16 files changed, 4369 insertions(+), 306 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/agent-runtime-steer-real-e2e.mjs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 241af28e4..c53e4119a 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -13,6 +13,7 @@ "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", + "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" }, "dependencies": { diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-steer-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-steer-real-e2e.mjs new file mode 100644 index 000000000..961f1fd7e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-steer-real-e2e.mjs @@ -0,0 +1,426 @@ +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const repoRoot = path.resolve(appRoot, '../..'); +const manifestPath = path.join(appRoot, 'src-tauri/Cargo.toml'); +const agentId = 'code-prototype'; +const runId = `steer-real-${Date.now()}-${randomUUID().slice(0, 8)}`; +const projectRoot = path.join(os.tmpdir(), `genarrative-${runId}`); +const marker = `STEER_REAL_${randomUUID().replaceAll('-', '').toUpperCase()}`; +const instruction = `运行中补充:继续保持只读,不调用写入、命令、预览或生成工具;最终回复必须原样包含 ${marker},并明确说明已按追加指令更新。`; +const timeoutMs = 8 * 60 * 1000; +const pollMs = 200; + +const options = parseArguments(process.argv.slice(2)); +const evidence = { + providerInterrupted: false, + steerAttempts: 0, + taskRunIds: [], + steerStatuses: [], + userMessageCount: 0, + assistantMessageCount: 0, + publicInstructionLeakCount: 0, + loadedKeyLeakCount: 0, +}; +let status = 'FAIL'; +let error = null; +let binary = null; + +try { + const config = await loadConfig(options.configDir); + const secrets = collectSecrets(config); + assert(isAgentConfigured(config, agentId), 'llm-not-configured'); + binary = await prepareCliBinary(); + await runCli( + [ + '--agent-enqueue', + '--init', + projectRoot, + agentId, + runId, + '这是只读验收。不要修改文件、不要执行命令、不要预览或生成资产;请仔细分析后给出一句简短结论。', + ], + null, + 120_000, + ); + + const initial = await waitForRuntime( + (runtime) => runtime.state.runId === runId && isSteerable(runtime.state), + 60_000, + ); + const sessionId = initial.state.sessionId; + assert(sessionId, 'missing-session-id'); + + let latestSteerId = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const steerId = `steer-real-${attempt}-${randomUUID().slice(0, 8)}`; + const result = await runCli( + [ + '--agent-steer', + projectRoot, + agentId, + sessionId, + runId, + steerId, + '--stdin', + ], + instruction, + 120_000, + ); + assert(!result.stdout.includes(instruction), 'steer-body-stdout-leak'); + const steer = parsePrefixedJson(result.stdout, 'steerJson='); + evidence.steerAttempts = attempt; + latestSteerId = steerId; + evidence.providerInterrupted ||= steer.providerInterrupted === true; + if (evidence.providerInterrupted) break; + await waitForRuntime( + (runtime) => + runtime.state.runId === runId && + Number(runtime.state.appliedSteerCursor ?? 0) >= attempt && + isSteerable(runtime.state), + 60_000, + ); + } + assert(latestSteerId, 'steer-not-accepted'); + assert(evidence.providerInterrupted, 'provider-not-interrupted'); + + const terminal = await waitForRuntime( + (runtime) => + runtime.recentTasks?.some( + (task) => task.runId === runId && isTerminalTask(task), + ) === true, + timeoutMs, + ); + const terminalTask = terminal.recentTasks.find( + (task) => task.runId === runId && isTerminalTask(task), + ); + assert(terminalTask?.status === 'completed', 'steered-run-not-completed'); + + const taskRecords = await readJsonl( + path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`), + ); + evidence.taskRunIds = [...new Set(taskRecords.map((record) => record.runId))]; + assert( + evidence.taskRunIds.length === 1 && evidence.taskRunIds[0] === runId, + 'steer-created-new-run', + ); + assert( + !taskRecords.some((record) => JSON.stringify(record).includes(instruction)), + 'steer-body-task-leak', + ); + + const steerRecords = await readJsonl( + path.join( + projectRoot, + '.agent/runtime/steers', + agentId, + `${runId}.jsonl`, + ), + ); + evidence.steerStatuses = steerRecords.map((record) => record.status); + assert(evidence.steerStatuses.includes('prepared'), 'steer-prepared-missing'); + assert(evidence.steerStatuses.includes('queued'), 'steer-queued-missing'); + assert(evidence.steerStatuses.includes('applied'), 'steer-applied-missing'); + assert(evidence.steerStatuses.at(-1) === 'closed', 'steer-ledger-not-closed'); + assert( + steerRecords + .filter((record) => record.status === 'prepared') + .every((record) => typeof record.instruction === 'string'), + 'steer-prepared-body-missing', + ); + assert( + steerRecords + .filter((record) => record.status !== 'prepared') + .every((record) => record.instruction == null), + 'steer-body-transition-leak', + ); + + const conversationPath = + sessionId === `agent-session-${agentId}` + ? path.join( + projectRoot, + '.agent/conversations/agents', + `${agentId}.jsonl`, + ) + : path.join( + projectRoot, + '.agent/conversations/agents', + agentId, + 'sessions', + `${sessionId}.jsonl`, + ); + const messages = await readJsonl(conversationPath); + evidence.userMessageCount = messages.filter( + (message) => message.role === 'user', + ).length; + evidence.assistantMessageCount = messages.filter( + (message) => message.role === 'assistant', + ).length; + assert( + evidence.userMessageCount === evidence.steerAttempts + 1, + 'steer-user-message-count-invalid', + ); + assert(evidence.assistantMessageCount === 1, 'assistant-count-invalid'); + const assistant = messages.find((message) => message.role === 'assistant'); + assert(assistant?.content.includes(marker), 'final-assistant-missing-marker'); + + const publicPaths = [ + path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`), + path.join(projectRoot, '.agent/runtime/events', `${agentId}.jsonl`), + path.join(projectRoot, '.agent/runtime/agents', `${agentId}.json`), + path.join(projectRoot, '.agent/agent.db'), + ]; + evidence.publicInstructionLeakCount = await countNeedleInFiles( + publicPaths, + instruction, + ); + assert(evidence.publicInstructionLeakCount === 0, 'steer-body-public-leak'); + evidence.loadedKeyLeakCount = await countNeedlesInTree( + projectRoot, + secrets, + ); + assert(evidence.loadedKeyLeakCount === 0, 'loaded-key-project-leak'); + status = 'PASS'; +} catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + if (error === 'llm-not-configured') status = 'BLOCKED'; +} finally { + if (!options.keepProject) { + await fs.rm(projectRoot, { recursive: true, force: true }); + } + process.stdout.write( + `${JSON.stringify( + { + status, + suite: 'steer', + runId, + projectKept: options.keepProject, + evidence, + error, + }, + null, + 2, + )}\n`, + ); + process.exitCode = status === 'PASS' ? 0 : status === 'BLOCKED' ? 2 : 1; +} + +function parseArguments(args) { + let configDir = null; + let keepProject = false; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--config-dir') { + configDir = args[++index]; + } else if (args[index] === '--keep-project') { + keepProject = true; + } else { + throw new Error(`unknown-argument:${args[index]}`); + } + } + assert(configDir && path.isAbsolute(configDir), 'config-dir-not-absolute'); + const resolved = path.resolve(configDir); + assert(!isInside(repoRoot, resolved), 'config-dir-inside-repository'); + return { configDir: resolved, keepProject }; +} + +async function loadConfig(configDir) { + const content = await fs.readFile( + path.join(configDir, 'game-creator.config.json'), + 'utf8', + ); + return JSON.parse(content); +} + +function isAgentConfigured(config, targetAgentId) { + const effective = { + apiKey: config.llm?.apiKey, + baseUrl: config.llm?.baseUrl, + model: config.llm?.model, + ...(config.agentLlm?.[targetAgentId] ?? {}), + }; + return ['apiKey', 'baseUrl', 'model'].every( + (key) => typeof effective[key] === 'string' && effective[key].trim(), + ); +} + +function collectSecrets(config) { + const secrets = []; + for (const candidate of [ + config.llm?.apiKey, + config.editorApi?.apiKey, + ...Object.values(config.agentLlm ?? {}).map((agent) => agent?.apiKey), + ]) { + if (typeof candidate === 'string' && candidate.trim().length >= 8) { + secrets.push(candidate.trim()); + } + } + return [...new Set(secrets)]; +} + +async function prepareCliBinary() { + const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + await runProcess( + cargo, + ['build', '--quiet', '--manifest-path', manifestPath], + null, + 15 * 60 * 1000, + ); + const metadata = await runProcess( + cargo, + [ + 'metadata', + '--format-version', + '1', + '--no-deps', + '--manifest-path', + manifestPath, + ], + null, + 120_000, + ); + const parsed = JSON.parse(metadata.stdout); + return path.join( + parsed.target_directory, + 'debug', + `genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`, + ); +} + +async function runCli(args, input = null, commandTimeoutMs = 60_000) { + return runProcess( + binary, + [...args, '--config-dir', options.configDir], + input, + commandTimeoutMs, + ); +} + +async function waitForRuntime(predicate, waitMs) { + const deadline = Date.now() + waitMs; + let latest = null; + while (Date.now() < deadline) { + const result = await runCli([ + '--agent-runtime-status', + projectRoot, + agentId, + ]); + latest = parsePrefixedJson(result.stdout, 'runtimeJson='); + if (predicate(latest)) return latest; + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + throw new Error( + `runtime-timeout:${latest?.state?.status ?? 'missing'}/${latest?.state?.phase ?? 'missing'}`, + ); +} + +function isSteerable(runtime) { + return ( + ['running', 'waiting-for-confirmation'].includes(runtime.status) && + !['cancelling', 'finalizing', 'needs-reconciliation'].includes( + runtime.phase, + ) + ); +} + +function isTerminalTask(task) { + return ['completed', 'failed', 'cancelled'].includes(task.status); +} + +function parsePrefixedJson(stdout, prefix) { + const line = stdout + .split(/\r?\n/u) + .find((candidate) => candidate.startsWith(prefix)); + assert(line, `missing-output:${prefix}`); + return JSON.parse(line.slice(prefix.length)); +} + +async function readJsonl(file) { + const content = await fs.readFile(file, 'utf8'); + return content + .split(/\r?\n/u) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); +} + +async function countNeedleInFiles(files, needle) { + let count = 0; + for (const file of files) { + const content = await fs.readFile(file).catch(() => Buffer.alloc(0)); + count += countNeedle(content, Buffer.from(needle)); + } + return count; +} + +async function countNeedlesInTree(root, needles) { + let count = 0; + const entries = await fs.readdir(root, { withFileTypes: true }); + for (const entry of entries) { + const target = path.join(root, entry.name); + if (entry.isDirectory()) { + count += await countNeedlesInTree(target, needles); + } else if (entry.isFile()) { + const content = await fs.readFile(target); + for (const needle of needles) count += countNeedle(content, Buffer.from(needle)); + } + } + return count; +} + +function countNeedle(haystack, needle) { + if (needle.length === 0) return 0; + let count = 0; + let offset = 0; + while (offset <= haystack.length - needle.length) { + const index = haystack.indexOf(needle, offset); + if (index < 0) break; + count += 1; + offset = index + needle.length; + } + return count; +} + +function runProcess(program, args, input, commandTimeoutMs) { + return new Promise((resolve, reject) => { + const child = spawn(program, args, { + cwd: appRoot, + env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, commandTimeoutMs); + child.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString('utf8'); + }); + child.on('error', reject); + child.on('close', (code, signal) => { + clearTimeout(timer); + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`command-failed:${code ?? signal}:${stderr.trim()}`)); + } + }); + if (input === null) child.stdin.end(); + else child.stdin.end(input); + }); +} + +function isInside(parent, target) { + const relative = path.relative(parent, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function assert(condition, code) { + if (!condition) throw new Error(code); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 4e6864f94..3a434947e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1,6 +1,7 @@ use super::*; use sha2::{Digest, Sha256}; use std::io::{Seek, SeekFrom}; +use std::sync::atomic::{AtomicBool, Ordering}; static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); @@ -10,7 +11,7 @@ fn external_agent_runner_owns_background_execution() -> bool { pub(crate) const AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION: &str = "game-creator-pending-action.v3"; -const AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING: &str = "pending-confirmation"; +pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING: &str = "pending-confirmation"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED: &str = "approved"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING: &str = "executing"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED: &str = "observed-approved"; @@ -30,6 +31,11 @@ const AGENT_RUNTIME_PROJECT_REVISION_RELATIVE_PATH: &str = ".agent/runtime/proje const AGENT_RUNTIME_SIDECAR_MAX_BYTES: usize = 16 * 1024; const AGENT_RUNTIME_PENDING_ACTION_SIDECAR_MAX_BYTES: usize = 512 * 1024; const AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES: usize = 512 * 1024; +const AGENT_RUNTIME_STEER_SCHEMA_VERSION: &str = "game-creator-runtime-steer.v1"; +pub(crate) const AGENT_RUNTIME_STEER_MAX_INSTRUCTION_BYTES: usize = 4 * 1024; +const AGENT_RUNTIME_STEER_MAX_RUN_BYTES: usize = 16 * 1024; +pub(crate) const AGENT_RUNTIME_STEER_MAX_RUN_COUNT: usize = 16; +const AGENT_RUNTIME_STEER_LEDGER_MAX_BYTES: usize = 256 * 1024; const AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING: &str = "running"; const AGENT_RUNTIME_VERIFICATION_STATUS_PASSED: &str = "passed"; pub(crate) const AGENT_RUNTIME_VERIFICATION_STATUS_FAILED: &str = "failed"; @@ -806,6 +812,7 @@ fn game_creator_agent_runtime_finalization_matches_state( && journal.parent_agent_id == state.parent_agent_id && journal.parent_run_id == state.parent_run_id && journal.delegation_id == state.delegation_id + && journal.response_steer_cursor == state.applied_steer_cursor } fn read_game_creator_agent_runtime_state_for_finalization_resume( @@ -986,7 +993,24 @@ fn resume_game_creator_agent_finalization_at( &mut journal, &mut |_| Ok(()), ) { - Ok(_) => { + Ok(completed) => { + if let Err(error) = + close_game_creator_agent_runtime_steer_ledger_at_locked(root, &completed) + { + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.steer.close_failed", + "agentId": completed.agent_id, + "taskId": completed.task_id, + "sessionId": completed.session_id, + "runId": completed.run_id, + "source": completed.source, + "appliedSteerCursor": completed.applied_steer_cursor, + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + } let result = read_game_creator_agent_runtime_at(root, agent_id)?; let _ = append_agent_db_record( root, @@ -2236,7 +2260,11 @@ fn resolve_game_creator_agent_runtime_pending_tool_action( if pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { return Err("Agent Runtime 待确认动作已处理,请刷新状态".to_string()); } - let action_fingerprint = agent_runtime_tool_action_fingerprint(&pending.action, &pending.task); + let action_fingerprint = agent_runtime_pending_tool_action_fingerprint( + &pending.action, + &pending.task, + pending.planned_steer_cursor, + ); let expected_action_id = agent_runtime_tool_action_id( &pending.run_id, pending.loop_iteration, @@ -3141,6 +3169,29 @@ async fn run_game_creator_agent_background_task_pass_with_context( let mut converged = false; let mut context_stalled = continuation.context_stalled; + if continuation.applied_steer_cursor < runtime.applied_steer_cursor { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "Agent Runtime state 的 steer cursor 超前于 context bundle", + ); + } + runtime.applied_steer_cursor = continuation.applied_steer_cursor; + runtime.applied_steer_refs = continuation.applied_steer_refs.clone(); + if let Err(error) = + validate_agent_runtime_steer_refs(runtime.applied_steer_cursor, &runtime.applied_steer_refs) + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } @@ -3167,6 +3218,30 @@ async fn run_game_creator_agent_background_task_pass_with_context( if context_stalled { break 'agent_loop; } + match consume_game_creator_agent_runtime_steers( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &context_tracker, + ) { + Ok(true) => { + plan = AgentRuntimeToolPlan::default(); + context_stalled = false; + } + Ok(false) => {} + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("消费运行中追加指令失败:{error}"), + ); + } + } runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = u32::try_from( (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) @@ -3206,7 +3281,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); } }; - plan = match request_game_creator_agent_background_tool_plan_at( + let requested_plan = match request_game_creator_agent_background_tool_plan_at( &root, &agent_id, &runtime.session_id, @@ -3214,6 +3289,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( &task, &observations, loop_index + 1, + runtime.applied_steer_cursor, ) .await { @@ -3252,6 +3328,58 @@ async fn run_game_creator_agent_background_task_pass_with_context( } }; + let Some(requested_plan) = requested_plan else { + match consume_game_creator_agent_runtime_steers( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &context_tracker, + ) { + Ok(_) => { + plan = AgentRuntimeToolPlan::default(); + continue 'agent_loop; + } + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("中断 Provider 后消费追加指令失败:{error}"), + ); + } + } + }; + plan = requested_plan; + + match consume_game_creator_agent_runtime_steers( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &context_tracker, + ) { + Ok(true) => { + plan = AgentRuntimeToolPlan::default(); + continue 'agent_loop; + } + Ok(false) => {} + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("Provider 返回后消费追加指令失败:{error}"), + ); + } + } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } @@ -3500,6 +3628,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( observations.push(budget_observation); } + let mut steered_during_actions = false; for (action_index, action) in plan .actions .iter() @@ -3669,78 +3798,116 @@ async fn run_game_creator_agent_background_task_pass_with_context( return AgentBackgroundTaskOutcome::NeedsReconciliation; } } - pending_action.status = - AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); - pending_action.updated_at = unix_timestamp(); - if let Err(error) = - write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) - { - pending_action.status = - AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + let action_is_current = + match mark_game_creator_agent_runtime_auto_action_executing_if_current( &root, - &mut runtime, - &pending_action, - &format!("自动工具动作尚未执行,但无法持久化 executing 状态:{error}"), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - let _ = append_game_creator_agent_runtime_auto_tool_action_executing_record( - &root, - &pending_action, - ); - let observation = - execute_game_creator_agent_runtime_tool_action_with_pending_action( - &root, - &agent_id, - runtime.run_id.as_str(), - &pending_action.task, - action, - Some(&pending_action.action_id), - Some(&pending_action), - ) - .await; - if observation.is_waiting_for_confirmation() { - pending_action.execution_mode = - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + &mut pending_action, + ) { + Ok(current) => current, + Err(error) => { + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("自动工具动作尚未执行,但无法持久化 executing 状态:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + }; + if !action_is_current { + let observation = AgentRuntimeToolObservation { + tool: action.tool.clone(), + status: "blocked".to_string(), + summary: "运行中追加指令已使当前计划过期,工具动作未执行".to_string(), + detail: Some(format!( + "plannedSteerCursor={} · current steer queued", + pending_action.planned_steer_cursor + )), + }; pending_action.status = - AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); - pending_action.observation = None; - } else { - pending_action.status = - AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); pending_action.observation = Some(observation.clone()); - } - pending_action.updated_at = unix_timestamp(); - if let Err(error) = - write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) - { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + pending_action.updated_at = unix_timestamp(); + if let Err(error) = write_game_creator_agent_runtime_pending_tool_action( &root, - &mut runtime, &pending_action, - &format!("自动工具动作已返回,但无法持久化 observation:{error}"), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( - &root, - &pending_action, - &observation, - ); - if observation.requires_reconciliation() { - if persist_agent_runtime_reconciliation_observation_before_cancellation( + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("过期工具动作未执行,但无法持久化拒绝观察:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( &root, - &mut runtime, &pending_action, &observation, - ) { - return AgentBackgroundTaskOutcome::Finished; + ); + durable_action = Some(pending_action); + observation + } else { + let _ = append_game_creator_agent_runtime_auto_tool_action_executing_record( + &root, + &pending_action, + ); + let observation = + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + &agent_id, + runtime.run_id.as_str(), + &pending_action.task, + action, + Some(&pending_action.action_id), + Some(&pending_action), + ) + .await; + if observation.is_waiting_for_confirmation() { + pending_action.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + pending_action.observation = None; + } else { + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending_action.observation = Some(observation.clone()); } - return AgentBackgroundTaskOutcome::NeedsReconciliation; + pending_action.updated_at = unix_timestamp(); + if let Err(error) = write_game_creator_agent_runtime_pending_tool_action( + &root, + &pending_action, + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("自动工具动作已返回,但无法持久化 observation:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( + &root, + &pending_action, + &observation, + ); + if observation.requires_reconciliation() { + if persist_agent_runtime_reconciliation_observation_before_cancellation( + &root, + &mut runtime, + &pending_action, + &observation, + ) { + return AgentBackgroundTaskOutcome::Finished; + } + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + durable_action = Some(pending_action); + observation } - durable_action = Some(pending_action); - observation } } else { execute_game_creator_agent_runtime_tool_action( @@ -4020,11 +4187,39 @@ async fn run_game_creator_agent_background_task_pass_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + match consume_game_creator_agent_runtime_steers( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index + 1, + &context_tracker, + ) { + Ok(true) => { + steered_during_actions = true; + break; + } + Ok(false) => {} + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("工具观察后消费追加指令失败:{error}"), + ); + } + } if repository_context_drifted { break; } } + if steered_during_actions { + plan = AgentRuntimeToolPlan::default(); + } + let checkpoint = match checkpoint_game_creator_agent_runtime_context( &root, &mut runtime, @@ -4168,13 +4363,62 @@ async fn run_game_creator_agent_background_task_pass_with_context( &task, &plan, &observations, + runtime.applied_steer_cursor, ) .await; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + let response_next_loop_index = + usize::try_from(runtime.loop_iteration).unwrap_or(usize::MAX); + match consume_game_creator_agent_runtime_steers( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + response_next_loop_index, + &context_tracker, + ) { + Ok(true) => { + let continuation = continuation_for_game_creator_agent_runtime_steer( + &runtime, + &AgentRuntimeToolPlan::default(), + &observations, + response_next_loop_index, + &context_tracker, + ); + return AgentBackgroundTaskOutcome::ContinueSameRun { + state: runtime, + continuation, + }; + } + Ok(false) => {} + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("最终回复后消费追加指令失败:{error}"), + ); + } + } let reply = match final_reply_result { - Ok(reply) => reply, + Ok(Some(reply)) => reply, + Ok(None) => { + let continuation = continuation_for_game_creator_agent_runtime_steer( + &runtime, + &plan, + &observations, + response_next_loop_index, + &context_tracker, + ); + return AgentBackgroundTaskOutcome::ContinueSameRun { + state: runtime, + continuation, + }; + } Err(_) if !plan.response.trim().is_empty() => plan.response.clone(), Err(error) => { let failed_runtime = @@ -4469,6 +4713,8 @@ pub(crate) struct AgentRuntimeFinalizationJournal { pub(crate) response: String, pub(crate) response_fingerprint: String, pub(crate) response_revision: u64, + #[serde(default)] + pub(crate) response_steer_cursor: u64, pub(crate) verification_gate: AgentRuntimeVerificationGate, pub(crate) finalization_id: String, pub(crate) message_id: String, @@ -4503,6 +4749,10 @@ pub(crate) struct AgentRuntimeContextBundle { pub(crate) observations: Vec, pub(crate) verification_gate: AgentRuntimeVerificationGate, #[serde(default)] + pub(crate) applied_steer_cursor: u64, + #[serde(default)] + pub(crate) applied_steer_refs: Vec, + #[serde(default)] pub(crate) window_completed_loops: u32, #[serde(default)] pub(crate) window_observation_fingerprints: Vec, @@ -4529,6 +4779,1096 @@ pub(crate) struct AgentRuntimeContinuationContext { window_observation_fingerprints: Vec, last_window_fingerprint: Option, context_stalled: bool, + applied_steer_cursor: u64, + applied_steer_refs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeSteerLedgerRecord { + schema_version: String, + project_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + #[serde(default)] + steer_id: Option, + sequence: u64, + #[serde(default)] + message_id: Option, + #[serde(default)] + instruction_sha256: Option, + #[serde(default)] + content_chars: u32, + #[serde(default)] + content_bytes: u32, + #[serde(default)] + instruction: Option, + status: String, + accepted_via: String, + accepted_at: u64, + #[serde(default)] + applied_at: Option, + updated_at: u64, +} + +#[derive(Clone, Debug)] +struct AgentRuntimeSteerEntry { + identity: AgentRuntimeSteerLedgerRecord, + instruction: String, + status: String, + applied_at: Option, +} + +#[derive(Clone, Debug, Default)] +struct AgentRuntimeSteerLedgerSnapshot { + entries: BTreeMap, + closed_cursor: Option, +} + +#[derive(Default)] +struct AgentRuntimeProviderInterrupt { + interrupted: AtomicBool, + notify: tokio::sync::Notify, +} + +static GAME_CREATOR_AGENT_PROVIDER_INTERRUPTS: OnceLock< + Mutex>>, +> = OnceLock::new(); + +fn validate_agent_runtime_steer_refs( + cursor: u64, + refs: &[AgentRuntimeSteerRef], +) -> Result<(), String> { + if refs.len() > AGENT_RUNTIME_STEER_MAX_RUN_COUNT { + return Err("Agent Runtime steer refs 超过上限".to_string()); + } + let mut previous = 0u64; + let mut ids = std::collections::BTreeSet::new(); + for steer_ref in refs { + if steer_ref.sequence == 0 + || steer_ref.sequence <= previous + || steer_ref.sequence > cursor + || steer_ref.steer_id.trim().is_empty() + || !ids.insert(steer_ref.steer_id.clone()) + || steer_ref.message_id.trim().is_empty() + || steer_ref.instruction_sha256.len() != 64 + || !steer_ref + .instruction_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || steer_ref.content_chars == 0 + { + return Err("Agent Runtime steer ref 身份或顺序无效".to_string()); + } + previous = steer_ref.sequence; + } + if refs.last().map(|item| item.sequence).unwrap_or(0) != cursor && cursor != 0 { + return Err("Agent Runtime steer cursor 与 refs 不匹配".to_string()); + } + Ok(()) +} + +fn game_creator_agent_runtime_steer_ledger_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + ".agent/runtime/steers/{}/{}.jsonl", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +pub(crate) fn game_creator_agent_runtime_steer_ledger_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> PathBuf { + root.join(game_creator_agent_runtime_steer_ledger_relative_path( + agent_id, run_id, + )) +} + +fn agent_runtime_steer_status_rank(status: &str) -> Option { + match status { + "prepared" => Some(1), + "conversation-persisted" => Some(2), + "queued" => Some(3), + "applied" => Some(4), + "closed" => Some(5), + _ => None, + } +} + +fn validate_agent_runtime_steer_instruction(instruction: &str) -> Result { + let instruction = instruction.trim(); + if instruction.is_empty() { + return Err("追加指令不能为空".to_string()); + } + if instruction.as_bytes().len() > AGENT_RUNTIME_STEER_MAX_INSTRUCTION_BYTES { + return Err(format!( + "追加指令超过 {} 字节上限", + AGENT_RUNTIME_STEER_MAX_INSTRUCTION_BYTES + )); + } + if instruction.chars().any(|character| { + character == '\0' || (character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + }) { + return Err("追加指令包含不允许的控制字符".to_string()); + } + Ok(instruction.to_string()) +} + +fn normalize_agent_runtime_steer_id(steer_id: &str) -> Result { + let steer_id = steer_id.trim(); + if steer_id.is_empty() || steer_id.len() > 160 { + return Err("steerId 不能为空且不能超过 160 字节".to_string()); + } + if !steer_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err("steerId 只能包含 ASCII 字母、数字、点、冒号、连字符或下划线".to_string()); + } + Ok(steer_id.to_string()) +} + +fn agent_runtime_steer_message_id( + project_id: &str, + agent_id: &str, + task_id: &str, + session_id: &str, + run_id: &str, + steer_id: &str, +) -> String { + let payload = + format!("{project_id}\n{agent_id}\n{task_id}\n{session_id}\n{run_id}\n{steer_id}"); + let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes())); + format!( + "agent-steer-{}", + fingerprint.chars().take(32).collect::() + ) +} + +fn read_game_creator_agent_runtime_steer_ledger( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let relative_path = game_creator_agent_runtime_steer_ledger_relative_path(agent_id, run_id); + let path = resolve_local_project_path(root, &relative_path)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AgentRuntimeSteerLedgerSnapshot::default()) + } + Err(error) => { + return Err(format!( + "读取 Agent Runtime steer ledger 失败:{}: {error}", + path.display() + )) + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("Agent Runtime steer ledger 必须是普通文件".to_string()); + } + if metadata.len() > AGENT_RUNTIME_STEER_LEDGER_MAX_BYTES as u64 { + return Err(format!( + "Agent Runtime steer ledger 超过 {} 字节上限", + AGENT_RUNTIME_STEER_LEDGER_MAX_BYTES + )); + } + let file = File::open(&path).map_err(|error| { + format!( + "读取 Agent Runtime steer ledger 失败:{}: {error}", + path.display() + ) + })?; + let mut snapshot = AgentRuntimeSteerLedgerSnapshot::default(); + for (index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "读取 Agent Runtime steer ledger 失败:{}:{}: {error}", + path.display(), + index + 1 + ) + })?; + let line = line.trim(); + if line.is_empty() { + continue; + } + let record = + serde_json::from_str::(line).map_err(|error| { + format!( + "解析 Agent Runtime steer ledger 失败:{}:{}: {error}", + path.display(), + index + 1 + ) + })?; + if record.schema_version != AGENT_RUNTIME_STEER_SCHEMA_VERSION + || record.agent_id != agent_id + || record.run_id != run_id + || record.project_id.trim().is_empty() + || record.task_id.trim().is_empty() + || record.session_id.trim().is_empty() + || record.source.trim().is_empty() + || record.accepted_at == 0 + || record.updated_at == 0 + || agent_runtime_steer_status_rank(&record.status).is_none() + { + return Err(format!( + "Agent Runtime steer ledger 身份或状态无效:{}:{}", + path.display(), + index + 1 + )); + } + if record.status == "closed" { + if record.steer_id.is_some() + || record.message_id.is_some() + || record.instruction_sha256.is_some() + || record.instruction.is_some() + { + return Err("Agent Runtime steer ledger closed 记录包含指令身份".to_string()); + } + if snapshot + .closed_cursor + .is_some_and(|cursor| cursor != record.sequence) + { + return Err("Agent Runtime steer ledger closed cursor 冲突".to_string()); + } + snapshot.closed_cursor = Some(record.sequence); + continue; + } + if snapshot.closed_cursor.is_some() { + return Err("Agent Runtime steer ledger 已关闭后仍出现指令记录".to_string()); + } + let steer_id = record + .steer_id + .as_deref() + .ok_or_else(|| "Agent Runtime steer ledger 缺少 steerId".to_string())?; + let message_id = record + .message_id + .as_deref() + .ok_or_else(|| "Agent Runtime steer ledger 缺少 messageId".to_string())?; + let instruction_sha256 = record + .instruction_sha256 + .as_deref() + .ok_or_else(|| "Agent Runtime steer ledger 缺少 instructionSha256".to_string())?; + if record.sequence == 0 + || message_id.trim().is_empty() + || instruction_sha256.len() != 64 + || !instruction_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || record.content_chars == 0 + || record.content_bytes == 0 + { + return Err("Agent Runtime steer ledger 指令元数据无效".to_string()); + } + if let Some(existing) = snapshot.entries.get_mut(steer_id) { + for (expected, actual, field) in [ + ( + &existing.identity.project_id, + &record.project_id, + "projectId", + ), + (&existing.identity.agent_id, &record.agent_id, "agentId"), + (&existing.identity.task_id, &record.task_id, "taskId"), + ( + &existing.identity.session_id, + &record.session_id, + "sessionId", + ), + (&existing.identity.run_id, &record.run_id, "runId"), + (&existing.identity.source, &record.source, "source"), + ] { + if expected != actual { + return Err(format!("Agent Runtime steer ledger 身份冲突:{field}")); + } + } + if existing.identity.sequence != record.sequence + || existing.identity.message_id.as_deref() != Some(message_id) + || existing.identity.instruction_sha256.as_deref() != Some(instruction_sha256) + || existing.identity.content_chars != record.content_chars + || existing.identity.content_bytes != record.content_bytes + || existing.identity.accepted_at != record.accepted_at + || agent_runtime_steer_status_rank(&record.status) + < agent_runtime_steer_status_rank(&existing.status) + { + return Err(format!( + "Agent Runtime steer ledger 状态推进冲突:steerId={steer_id}" + )); + } + if record.instruction.is_some() { + return Err("Agent Runtime steer 正文只能出现在 prepared 记录".to_string()); + } + existing.status = record.status.clone(); + existing.applied_at = record.applied_at; + } else { + if record.status != "prepared" { + return Err(format!( + "Agent Runtime steer 首条记录必须是 prepared:steerId={steer_id}" + )); + } + let instruction = record + .instruction + .clone() + .ok_or_else(|| "Agent Runtime steer prepared 缺少正文".to_string())?; + let normalized = validate_agent_runtime_steer_instruction(&instruction)?; + if normalized != instruction + || instruction.as_bytes().len() != record.content_bytes as usize + || instruction.chars().count() != record.content_chars as usize + || format!("{:x}", Sha256::digest(instruction.as_bytes())) != instruction_sha256 + { + return Err("Agent Runtime steer prepared 正文与元数据不匹配".to_string()); + } + snapshot.entries.insert( + steer_id.to_string(), + AgentRuntimeSteerEntry { + identity: record.clone(), + instruction, + status: record.status.clone(), + applied_at: record.applied_at, + }, + ); + } + } + if snapshot.entries.len() > AGENT_RUNTIME_STEER_MAX_RUN_COUNT + || snapshot + .entries + .values() + .map(|entry| entry.identity.content_bytes as usize) + .sum::() + > AGENT_RUNTIME_STEER_MAX_RUN_BYTES + { + return Err("Agent Runtime steer ledger 超过 run 容量上限".to_string()); + } + let mut sequences = snapshot + .entries + .values() + .map(|entry| entry.identity.sequence) + .collect::>(); + sequences.sort_unstable(); + if sequences + .iter() + .enumerate() + .any(|(index, sequence)| *sequence != index as u64 + 1) + { + return Err("Agent Runtime steer sequence 不连续".to_string()); + } + Ok(snapshot) +} + +fn append_game_creator_agent_runtime_steer_record( + root: &Path, + record: &AgentRuntimeSteerLedgerRecord, +) -> Result<(), String> { + let path = game_creator_agent_runtime_steer_ledger_path(root, &record.agent_id, &record.run_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime steer ledger 目录失败:{}: {error}", + parent.display() + ) + })?; + } + if fs::symlink_metadata(&path) + .ok() + .is_some_and(|metadata| metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err("Agent Runtime steer ledger 必须是普通文件".to_string()); + } + let line = serde_json::to_string(record) + .map_err(|error| format!("序列化 Agent Runtime steer 失败:{error}"))?; + append_jsonl_line(&path, &line, "Agent Runtime steer") +} + +fn agent_runtime_steer_record_for_status( + entry: &AgentRuntimeSteerEntry, + status: &str, +) -> AgentRuntimeSteerLedgerRecord { + let mut record = entry.identity.clone(); + record.instruction = None; + record.status = status.to_string(); + record.updated_at = unix_timestamp(); + record.applied_at = (status == "applied").then_some(record.updated_at); + record +} + +fn agent_runtime_steer_ref(entry: &AgentRuntimeSteerEntry) -> AgentRuntimeSteerRef { + AgentRuntimeSteerRef { + steer_id: entry.identity.steer_id.clone().unwrap_or_default(), + sequence: entry.identity.sequence, + message_id: entry.identity.message_id.clone().unwrap_or_default(), + instruction_sha256: entry + .identity + .instruction_sha256 + .clone() + .unwrap_or_default(), + content_chars: entry.identity.content_chars, + } +} + +fn ensure_game_creator_agent_runtime_steer_audit( + root: &Path, + state: &AgentRuntimeState, + entry: &AgentRuntimeSteerEntry, + status: &str, + provider_interrupted: bool, +) -> Result<(), String> { + let steer_id = entry.identity.steer_id.as_deref().unwrap_or_default(); + let (records, _) = read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + if let Some(existing) = records.iter().find(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) == Some("agent.runtime.steer") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(state.agent_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(state.run_id.as_str()) + && record.get("steerId").and_then(serde_json::Value::as_str) == Some(steer_id) + && record.get("status").and_then(serde_json::Value::as_str) == Some(status) + }) { + if existing.get("sequence").and_then(serde_json::Value::as_u64) + != Some(entry.identity.sequence) + || existing + .get("instructionSha256") + .and_then(serde_json::Value::as_str) + != entry.identity.instruction_sha256.as_deref() + { + return Err(format!( + "Agent Runtime steer 审计身份冲突:steerId={steer_id} status={status}" + )); + } + return Ok(()); + } + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.steer", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "steerId": steer_id, + "sequence": entry.identity.sequence, + "messageId": entry.identity.message_id, + "instructionSha256": entry.identity.instruction_sha256, + "contentChars": entry.identity.content_chars, + "status": status, + "providerInterrupted": provider_interrupted, + }), + ) +} + +fn validate_agent_runtime_steer_target_state(state: &AgentRuntimeState) -> Result<(), String> { + if state.agent_id.starts_with("child-") || state.source == AGENT_RUNTIME_ISOLATED_CHILD_SOURCE { + return Err("动态隔离子 Agent 暂不接受运行中追加指令".to_string()); + } + if matches!( + state.status.as_str(), + "completed" | "failed" | "cancelled" | "cancelling" + ) || matches!( + state.phase.as_str(), + "completed" | "failed" | "cancelled" | "cancelling" | "finalizing" | "needs-reconciliation" + ) { + return Err(format!( + "当前 Agent run 状态不接受追加指令:status={} phase={}", + state.status, state.phase + )); + } + if !matches!( + state.status.as_str(), + "running" | "waiting-for-confirmation" + ) { + return Err(format!( + "当前 Agent run 不是可追加的非终态:{}", + state.status + )); + } + Ok(()) +} + +pub(crate) fn steer_game_creator_agent_runtime_task_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + steer_id: &str, + instruction: &str, + accepted_via: &str, +) -> Result { + validate_project_root(root)?; + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = session_id.trim(); + let run_id = run_id.trim(); + if session_id.is_empty() || run_id.is_empty() { + return Err("追加指令必须绑定 sessionId 和 runId".to_string()); + } + let steer_id = normalize_agent_runtime_steer_id(steer_id)?; + let instruction = validate_agent_runtime_steer_instruction(instruction)?; + let accepted_via = sanitize_agent_runtime_text(accepted_via.trim(), 32); + if accepted_via.is_empty() { + return Err("追加指令缺少 acceptedVia".to_string()); + } + let _lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "runtime.steer")?; + let runtime = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + let state = runtime.state.clone(); + if state.run_id != run_id || state.session_id != session_id { + return Err("追加指令与当前 Agent 的 session/run 身份不匹配".to_string()); + } + validate_agent_runtime_steer_target_state(&state)?; + if game_creator_agent_runtime_finalization_path(root, &agent_id, run_id).exists() { + return Err("当前 Agent run 已进入最终持久化,不再接受追加指令".to_string()); + } + let project_id = game_creator_agent_runtime_context_project_id(root)?; + let instruction_sha256 = format!("{:x}", Sha256::digest(instruction.as_bytes())); + let message_id = agent_runtime_steer_message_id( + &project_id, + &agent_id, + &state.task_id, + session_id, + run_id, + &steer_id, + ); + let mut snapshot = read_game_creator_agent_runtime_steer_ledger(root, &agent_id, run_id)?; + if snapshot.closed_cursor.is_some() { + return Err("当前 Agent run 已关闭追加指令入口".to_string()); + } + let entry = if let Some(existing) = snapshot.entries.get(&steer_id) { + if existing.identity.project_id != project_id + || existing.identity.task_id != state.task_id + || existing.identity.session_id != session_id + || existing.identity.source != state.source + || existing.identity.message_id.as_deref() != Some(message_id.as_str()) + || existing.identity.instruction_sha256.as_deref() != Some(instruction_sha256.as_str()) + || existing.instruction != instruction + { + return Err(format!("steer-id-conflict: {steer_id}")); + } + existing.clone() + } else { + let total_bytes = snapshot + .entries + .values() + .map(|entry| entry.identity.content_bytes as usize) + .sum::(); + if snapshot.entries.len() >= AGENT_RUNTIME_STEER_MAX_RUN_COUNT + || total_bytes.saturating_add(instruction.as_bytes().len()) + > AGENT_RUNTIME_STEER_MAX_RUN_BYTES + { + return Err("当前 Agent run 的追加指令已达到容量上限".to_string()); + } + let sequence = snapshot.entries.len() as u64 + 1; + let now = unix_timestamp(); + let record = AgentRuntimeSteerLedgerRecord { + schema_version: AGENT_RUNTIME_STEER_SCHEMA_VERSION.to_string(), + project_id, + agent_id: agent_id.clone(), + task_id: state.task_id.clone(), + session_id: session_id.to_string(), + run_id: run_id.to_string(), + source: state.source.clone(), + steer_id: Some(steer_id.clone()), + sequence, + message_id: Some(message_id.clone()), + instruction_sha256: Some(instruction_sha256.clone()), + content_chars: u32::try_from(instruction.chars().count()).unwrap_or(u32::MAX), + content_bytes: u32::try_from(instruction.as_bytes().len()).unwrap_or(u32::MAX), + instruction: Some(instruction.clone()), + status: "prepared".to_string(), + accepted_via: accepted_via.clone(), + accepted_at: now, + applied_at: None, + updated_at: now, + }; + append_game_creator_agent_runtime_steer_record(root, &record)?; + let entry = AgentRuntimeSteerEntry { + identity: record, + instruction: instruction.clone(), + status: "prepared".to_string(), + applied_at: None, + }; + snapshot.entries.insert(steer_id.clone(), entry.clone()); + entry + }; + append_local_conversation_message_for_session_idempotent_at( + root, + Some(&agent_id), + Some(session_id), + LocalConversationMessage { + role: "user".to_string(), + content: instruction, + agent_id: Some(agent_id.clone()), + }, + &message_id, + )?; + let mut latest_status = entry.status.clone(); + for status in ["conversation-persisted", "queued"] { + if agent_runtime_steer_status_rank(&latest_status) < agent_runtime_steer_status_rank(status) + { + let record = agent_runtime_steer_record_for_status(&entry, status); + append_game_creator_agent_runtime_steer_record(root, &record)?; + latest_status = status.to_string(); + } + } + let provider_interrupted = + interrupt_game_creator_agent_runtime_provider_request_at(root, &agent_id, run_id)?; + ensure_game_creator_agent_runtime_steer_audit( + root, + &state, + &entry, + "queued", + provider_interrupted, + )?; + append_game_creator_agent_runtime_action_event( + root, + &state, + "steer.queued", + state.status.as_str(), + state.phase.as_str(), + "已接收运行中追加指令,当前 run 将在安全边界重新规划。", + Some(&format!( + "sequence={} · chars={} · sha256={}", + entry.identity.sequence, + entry.identity.content_chars, + entry + .identity + .instruction_sha256 + .as_deref() + .unwrap_or_default() + )), + &steer_id, + )?; + let runtime = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + Ok(AgentRuntimeSteerResult { + runtime, + steer_id, + sequence: entry.identity.sequence, + status: latest_status, + provider_interrupted, + }) +} + +fn agent_runtime_provider_interrupt_key( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let root = fs::canonicalize(root) + .map_err(|error| format!("规范化 Agent Runtime 项目路径失败:{error}"))?; + Ok(format!("{}\n{agent_id}\n{run_id}", root.to_string_lossy())) +} + +fn game_creator_agent_provider_interrupts( +) -> &'static Mutex>> { + GAME_CREATOR_AGENT_PROVIDER_INTERRUPTS.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +pub(crate) fn interrupt_game_creator_agent_runtime_provider_request_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?; + let active = { + let registry = game_creator_agent_provider_interrupts() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + registry.get(&key).cloned() + }; + let Some(active) = active else { + return Ok(false); + }; + let first = !active.interrupted.swap(true, Ordering::AcqRel); + active.notify.notify_one(); + Ok(first) +} + +pub(crate) fn validate_game_creator_agent_runtime_steer_notification_at( + root: &Path, + agent_id: &str, + run_id: &str, + steer_id: &str, +) -> Result<(), String> { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let steer_id = normalize_agent_runtime_steer_id(steer_id)?; + let snapshot = read_game_creator_agent_runtime_steer_ledger(root, &agent_id, run_id.trim())?; + let entry = snapshot + .entries + .get(&steer_id) + .ok_or_else(|| "runtime.steer 通知未命中持久 steer ledger".to_string())?; + if !matches!(entry.status.as_str(), "queued" | "applied") { + return Err(format!( + "runtime.steer 通知对应的持久状态不可中断 Provider:{}", + entry.status + )); + } + Ok(()) +} + +fn register_game_creator_agent_runtime_provider_request( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(String, Arc), String> { + let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?; + let active = Arc::new(AgentRuntimeProviderInterrupt::default()); + let mut registry = game_creator_agent_provider_interrupts() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if registry.insert(key.clone(), active.clone()).is_some() { + return Err("同一 Agent run 已存在 Provider 请求".to_string()); + } + Ok((key, active)) +} + +fn unregister_game_creator_agent_runtime_provider_request( + key: &str, + active: &Arc, +) { + let mut registry = game_creator_agent_provider_interrupts() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if registry + .get(key) + .is_some_and(|candidate| Arc::ptr_eq(candidate, active)) + { + registry.remove(key); + } +} + +fn game_creator_agent_runtime_has_queued_steer_after_cursor( + root: &Path, + agent_id: &str, + run_id: &str, + cursor: u64, +) -> Result { + let snapshot = read_game_creator_agent_runtime_steer_ledger(root, agent_id, run_id)?; + Ok(snapshot + .entries + .values() + .any(|entry| entry.identity.sequence > cursor && entry.status != "applied")) +} + +fn ordered_game_creator_agent_runtime_steer_entries( + snapshot: &AgentRuntimeSteerLedgerSnapshot, +) -> Vec { + let mut entries = snapshot.entries.values().cloned().collect::>(); + entries.sort_by_key(|entry| entry.identity.sequence); + entries +} + +fn promote_incomplete_game_creator_agent_runtime_steers_at_locked( + root: &Path, + state: &AgentRuntimeState, + snapshot: &AgentRuntimeSteerLedgerSnapshot, +) -> Result<(), String> { + for entry in ordered_game_creator_agent_runtime_steer_entries(snapshot) { + if entry.status == "applied" { + continue; + } + let message_id = entry.identity.message_id.as_deref().unwrap_or_default(); + append_local_conversation_message_for_session_idempotent_at( + root, + Some(&state.agent_id), + Some(&state.session_id), + LocalConversationMessage { + role: "user".to_string(), + content: entry.instruction.clone(), + agent_id: Some(state.agent_id.clone()), + }, + message_id, + )?; + let mut current_status = entry.status.clone(); + for status in ["conversation-persisted", "queued"] { + if agent_runtime_steer_status_rank(¤t_status) + < agent_runtime_steer_status_rank(status) + { + append_game_creator_agent_runtime_steer_record( + root, + &agent_runtime_steer_record_for_status(&entry, status), + )?; + current_status = status.to_string(); + } + } + } + Ok(()) +} + +fn verify_game_creator_agent_runtime_steer_conversation( + root: &Path, + state: &AgentRuntimeState, + entry: &AgentRuntimeSteerEntry, +) -> Result<(), String> { + let message_id = entry.identity.message_id.as_deref().unwrap_or_default(); + let message = read_local_conversation_message_by_id_for_session_at( + root, + Some(&state.agent_id), + Some(&state.session_id), + message_id, + )? + .ok_or_else(|| format!("运行中追加指令缺少 conversation 消息:{message_id}"))?; + if message.role != "user" + || message.agent_id.as_deref() != Some(state.agent_id.as_str()) + || message.content != entry.instruction + || format!("{:x}", Sha256::digest(message.content.as_bytes())) + != entry + .identity + .instruction_sha256 + .as_deref() + .unwrap_or_default() + { + return Err(format!( + "运行中追加指令 conversation 身份或正文冲突:steerId={}", + entry.identity.steer_id.as_deref().unwrap_or_default() + )); + } + Ok(()) +} + +pub(crate) fn consume_game_creator_agent_runtime_steers( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + next_loop_index: usize, + context_tracker: &AgentRuntimeContextWindowTracker, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.steer.consume", + )?; + let mut snapshot = + read_game_creator_agent_runtime_steer_ledger(root, &runtime.agent_id, &runtime.run_id)?; + if snapshot.closed_cursor.is_some() { + runtime.queued_steer_count = 0; + return Ok(false); + } + promote_incomplete_game_creator_agent_runtime_steers_at_locked(root, runtime, &snapshot)?; + snapshot = + read_game_creator_agent_runtime_steer_ledger(root, &runtime.agent_id, &runtime.run_id)?; + let ordered = ordered_game_creator_agent_runtime_steer_entries(&snapshot); + for entry in ordered + .iter() + .filter(|entry| entry.identity.sequence <= runtime.applied_steer_cursor) + { + verify_game_creator_agent_runtime_steer_conversation(root, runtime, entry)?; + if entry.status != "applied" { + append_game_creator_agent_runtime_steer_record( + root, + &agent_runtime_steer_record_for_status(entry, "applied"), + )?; + } + ensure_game_creator_agent_runtime_steer_audit(root, runtime, entry, "applied", false)?; + } + let pending = ordered + .into_iter() + .filter(|entry| entry.identity.sequence > runtime.applied_steer_cursor) + .collect::>(); + runtime.queued_steer_count = u32::try_from(pending.len()).unwrap_or(u32::MAX); + if pending.is_empty() { + return Ok(false); + } + for entry in &pending { + if entry.identity.project_id != game_creator_agent_runtime_context_project_id(root)? + || entry.identity.task_id != runtime.task_id + || entry.identity.session_id != runtime.session_id + || entry.identity.source != runtime.source + || entry.status != "queued" + { + return Err(format!( + "运行中追加指令与当前 Runtime 身份或状态不匹配:steerId={}", + entry.identity.steer_id.as_deref().unwrap_or_default() + )); + } + verify_game_creator_agent_runtime_steer_conversation(root, runtime, entry)?; + } + let previous_cursor = runtime.applied_steer_cursor; + let mut refs = snapshot + .entries + .values() + .filter(|entry| entry.identity.sequence <= previous_cursor) + .map(agent_runtime_steer_ref) + .collect::>(); + refs.extend(pending.iter().map(agent_runtime_steer_ref)); + refs.sort_by_key(|steer_ref| steer_ref.sequence); + runtime.applied_steer_cursor = pending + .last() + .map(|entry| entry.identity.sequence) + .unwrap_or(previous_cursor); + runtime.applied_steer_refs = refs; + runtime.queued_steer_count = 0; + validate_agent_runtime_steer_refs(runtime.applied_steer_cursor, &runtime.applied_steer_refs)?; + let observation = AgentRuntimeToolObservation { + tool: "runtime.steer".to_string(), + status: "ok".to_string(), + summary: format!( + "已应用 {} 条运行中追加指令,steerCursor={},旧计划需重新生成", + pending.len(), + runtime.applied_steer_cursor + ), + detail: Some(format!( + "previousCursor={previous_cursor} · appliedSequences={}", + pending + .iter() + .map(|entry| entry.identity.sequence.to_string()) + .collect::>() + .join(",") + )), + }; + runtime.observations.push(observation.summary()); + observations.push(observation.clone()); + runtime.updated_at = unix_timestamp(); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + write_game_creator_agent_runtime_state(root, runtime)?; + for entry in &pending { + append_game_creator_agent_runtime_steer_record( + root, + &agent_runtime_steer_record_for_status(entry, "applied"), + )?; + ensure_game_creator_agent_runtime_steer_audit(root, runtime, entry, "applied", false)?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "steer.applied", + runtime.status.as_str(), + runtime.phase.as_str(), + "运行中追加指令已进入当前 run 上下文,旧计划已作废。", + Some(&format!( + "sequence={} · chars={} · sha256={}", + entry.identity.sequence, + entry.identity.content_chars, + entry + .identity + .instruction_sha256 + .as_deref() + .unwrap_or_default() + )), + entry.identity.steer_id.as_deref().unwrap_or_default(), + )?; + } + Ok(true) +} + +pub(crate) fn render_game_creator_agent_runtime_steers_for_prompt( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, +) -> Result { + let runtime = read_game_creator_agent_runtime_for_session_at(root, agent_id, Some(session_id))?; + if runtime.state.run_id != run_id { + return Err("渲染追加指令时 Runtime run 身份不匹配".to_string()); + } + let snapshot = read_game_creator_agent_runtime_steer_ledger(root, agent_id, run_id)?; + let mut rendered = Vec::new(); + for entry in ordered_game_creator_agent_runtime_steer_entries(&snapshot) { + if entry.identity.sequence > runtime.state.applied_steer_cursor || entry.status != "applied" + { + continue; + } + verify_game_creator_agent_runtime_steer_conversation(root, &runtime.state, &entry)?; + rendered.push(serde_json::json!({ + "sequence": entry.identity.sequence, + "steerId": entry.identity.steer_id, + "instruction": entry.instruction, + })); + } + serde_json::to_string_pretty(&rendered) + .map_err(|error| format!("序列化运行中追加指令失败:{error}")) +} + +fn close_game_creator_agent_runtime_steer_ledger_at_locked( + root: &Path, + state: &AgentRuntimeState, +) -> Result<(), String> { + let snapshot = + read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?; + if let Some(cursor) = snapshot.closed_cursor { + if cursor != state.applied_steer_cursor { + return Err("Agent Runtime steer ledger closed cursor 与最终回复不匹配".to_string()); + } + return Ok(()); + } + if snapshot.entries.values().any(|entry| { + entry.identity.sequence > state.applied_steer_cursor || entry.status != "applied" + }) { + return Err("Agent Runtime steer ledger 仍有未应用指令,禁止关闭".to_string()); + } + let now = unix_timestamp(); + append_game_creator_agent_runtime_steer_record( + root, + &AgentRuntimeSteerLedgerRecord { + schema_version: AGENT_RUNTIME_STEER_SCHEMA_VERSION.to_string(), + project_id: game_creator_agent_runtime_context_project_id(root)?, + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + steer_id: None, + sequence: state.applied_steer_cursor, + message_id: None, + instruction_sha256: None, + content_chars: 0, + content_bytes: 0, + instruction: None, + status: "closed".to_string(), + accepted_via: "runtime-finalization".to_string(), + accepted_at: now, + applied_at: None, + updated_at: now, + }, + ) +} + +pub(crate) async fn await_game_creator_agent_runtime_provider_request( + root: &Path, + agent_id: &str, + run_id: &str, + applied_steer_cursor: u64, + request: F, +) -> Result, String> +where + F: std::future::Future>, +{ + let (key, active) = + register_game_creator_agent_runtime_provider_request(root, agent_id, run_id)?; + if game_creator_agent_runtime_has_queued_steer_after_cursor( + root, + agent_id, + run_id, + applied_steer_cursor, + )? { + active.interrupted.store(true, Ordering::Release); + active.notify.notify_one(); + } + let notified = active.notify.notified(); + tokio::pin!(notified); + tokio::pin!(request); + let result = if active.interrupted.load(Ordering::Acquire) { + Ok(None) + } else { + tokio::select! { + biased; + _ = &mut notified => Ok(None), + result = &mut request => result.map(Some), + } + }; + unregister_game_creator_agent_runtime_provider_request(&key, &active); + result } impl AgentRuntimeContextWindowTracker { @@ -5554,9 +6894,10 @@ fn game_creator_agent_runtime_finalization_id( run_id: &str, response_fingerprint: &str, response_revision: u64, + response_steer_cursor: u64, ) -> String { let payload = format!( - "{project_id}\n{agent_id}\n{session_id}\n{run_id}\n{response_fingerprint}\n{response_revision}" + "{project_id}\n{agent_id}\n{session_id}\n{run_id}\n{response_fingerprint}\n{response_revision}\n{response_steer_cursor}" ); let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes())); format!( @@ -5590,6 +6931,7 @@ fn build_game_creator_agent_runtime_finalization_journal( &state.run_id, &response_fingerprint, response_revision, + state.applied_steer_cursor, ); let message_id = game_creator_agent_runtime_finalization_message_id( &state.agent_id, @@ -5612,6 +6954,7 @@ fn build_game_creator_agent_runtime_finalization_journal( response: response.to_string(), response_fingerprint, response_revision, + response_steer_cursor: state.applied_steer_cursor, verification_gate: read_game_creator_agent_runtime_verification_gate( root, &state.agent_id, @@ -5672,6 +7015,7 @@ fn validate_game_creator_agent_runtime_finalization_journal( &journal.run_id, &journal.response_fingerprint, journal.response_revision, + journal.response_steer_cursor, ); let expected_message_id = game_creator_agent_runtime_finalization_message_id( &journal.agent_id, @@ -5848,6 +7192,13 @@ fn sanitize_game_creator_agent_runtime_context_bundle( ), observations: compact_agent_runtime_context_observations(root, &bundle.observations), verification_gate: bundle.verification_gate.clone(), + applied_steer_cursor: bundle.applied_steer_cursor, + applied_steer_refs: bundle + .applied_steer_refs + .iter() + .take(AGENT_RUNTIME_STEER_MAX_RUN_COUNT) + .cloned() + .collect(), window_completed_loops: bundle.window_completed_loops, window_observation_fingerprints: bundle .window_observation_fingerprints @@ -5943,6 +7294,13 @@ pub(crate) fn build_game_creator_agent_runtime_context_bundle( &runtime.agent_id, &runtime.run_id, )?, + applied_steer_cursor: runtime.applied_steer_cursor, + applied_steer_refs: runtime + .applied_steer_refs + .iter() + .take(AGENT_RUNTIME_STEER_MAX_RUN_COUNT) + .cloned() + .collect(), window_completed_loops: u32::try_from(context_tracker.completed_loops).unwrap_or(u32::MAX), window_observation_fingerprints: context_tracker .observation_signatures @@ -6166,6 +7524,7 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( if bundle.observations.len() > AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT { return Err("Agent Runtime context bundle 观察数量超过上限".to_string()); } + validate_agent_runtime_steer_refs(bundle.applied_steer_cursor, &bundle.applied_steer_refs)?; Ok(Some(bundle)) } @@ -6186,9 +7545,30 @@ pub(crate) fn continuation_from_game_creator_agent_runtime_context_bundle( window_observation_fingerprints: bundle.window_observation_fingerprints, last_window_fingerprint: bundle.last_window_fingerprint, context_stalled: bundle.context_stalled, + applied_steer_cursor: bundle.applied_steer_cursor, + applied_steer_refs: bundle.applied_steer_refs, } } +fn continuation_for_game_creator_agent_runtime_steer( + runtime: &AgentRuntimeState, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + next_loop_index: usize, + context_tracker: &AgentRuntimeContextWindowTracker, +) -> AgentRuntimeContinuationContext { + let mut continuation = AgentRuntimeContinuationContext { + plan: plan.clone(), + observations: observations.to_vec(), + next_loop_index, + applied_steer_cursor: runtime.applied_steer_cursor, + applied_steer_refs: runtime.applied_steer_refs.clone(), + ..AgentRuntimeContinuationContext::default() + }; + context_tracker.apply_to_continuation(&mut continuation); + continuation +} + fn checkpoint_game_creator_agent_runtime_context( root: &Path, runtime: &mut AgentRuntimeState, @@ -6286,6 +7666,8 @@ pub(crate) struct AgentRuntimePendingToolAction { pub(crate) observations: Vec, pub(crate) project_revision_before: AgentRuntimeProjectRevision, pub(crate) verification_gate_before: AgentRuntimeVerificationGate, + #[serde(default)] + pub(crate) planned_steer_cursor: u64, pub(crate) action: AgentRuntimeToolAction, pub(crate) action_id: String, pub(crate) action_fingerprint: String, @@ -6352,7 +7734,8 @@ fn build_game_creator_agent_runtime_pending_tool_action( observation: Option, ) -> Result { let task = sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS); - let action_fingerprint = agent_runtime_tool_action_fingerprint(action, &task); + let action_fingerprint = + agent_runtime_pending_tool_action_fingerprint(action, &task, runtime.applied_steer_cursor); let occurrence_nonce = unix_timestamp_nanos().min(u128::from(u64::MAX)) as u64; let action_index = u32::try_from(action_index).unwrap_or(u32::MAX); let action_id = agent_runtime_tool_action_id( @@ -6389,6 +7772,7 @@ fn build_game_creator_agent_runtime_pending_tool_action( &runtime.agent_id, &runtime.run_id, )?, + planned_steer_cursor: runtime.applied_steer_cursor, action: action.clone(), action_id, action_fingerprint, @@ -6401,6 +7785,28 @@ fn build_game_creator_agent_runtime_pending_tool_action( }) } +pub(crate) fn mark_game_creator_agent_runtime_auto_action_executing_if_current( + root: &Path, + pending: &mut AgentRuntimePendingToolAction, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.tool_action.executing", + )?; + if game_creator_agent_runtime_has_queued_steer_after_cursor( + root, + &pending.agent_id, + &pending.run_id, + pending.planned_steer_cursor, + )? { + return Ok(false); + } + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + Ok(true) +} + fn append_game_creator_agent_runtime_auto_tool_action_executing_record( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -7390,8 +8796,7 @@ pub(crate) fn agent_runtime_git_commit_safe_detail_value( ) -> Option { let value = serde_json::from_str::(detail).ok()?; let valid_object_id = |value: &str| { - matches!(value.len(), 40 | 64) - && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) }; let parent_head = value.get("parentHead")?.as_str()?; let commit_head = value.get("commitHead")?.as_str()?; @@ -7399,9 +8804,7 @@ pub(crate) fn agent_runtime_git_commit_safe_detail_value( if !valid_object_id(parent_head) || !valid_object_id(commit_head) || message_sha256.len() != 64 - || !message_sha256 - .bytes() - .all(|byte| byte.is_ascii_hexdigit()) + || !message_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { return None; } @@ -7741,6 +9144,24 @@ pub(crate) fn agent_runtime_tool_action_fingerprint( format!("{:x}", Sha256::digest(encoded)) } +fn agent_runtime_pending_tool_action_fingerprint( + action: &AgentRuntimeToolAction, + task: &str, + planned_steer_cursor: u64, +) -> String { + let base = agent_runtime_tool_action_fingerprint(action, task); + if planned_steer_cursor == 0 { + return base; + } + let payload = serde_json::json!({ + "fingerprintVersion": AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION, + "baseActionFingerprint": base, + "plannedSteerCursor": planned_steer_cursor, + }); + let encoded = serde_json::to_vec(&payload).unwrap_or_default(); + format!("{:x}", Sha256::digest(encoded)) +} + pub(crate) fn agent_runtime_tool_action_id( run_id: &str, loop_iteration: u32, @@ -8332,7 +9753,8 @@ async fn request_game_creator_agent_background_tool_plan_at( task: &str, observations: &[AgentRuntimeToolObservation], loop_index: usize, -) -> Result { + applied_steer_cursor: u64, +) -> Result, String> { let (llm, config_path, mut request) = build_game_creator_agent_background_tool_plan_request( root, agent_id, @@ -8355,15 +9777,28 @@ async fn request_game_creator_agent_background_tool_plan_at( AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS ) }; - let response = request_game_creator_agent_llm_text_retrying_recoverable( - &client, - &llm, - request.clone(), - operation.as_str(), - false, + let provider_request = async { + request_game_creator_agent_llm_text_retrying_recoverable( + &client, + &llm, + request.clone(), + operation.as_str(), + false, + ) + .await + .map_err(|error| format!("{config_path} {operation}调用 LLM 失败:{error}")) + }; + let Some(response) = await_game_creator_agent_runtime_provider_request( + root, + agent_id, + run_id, + applied_steer_cursor, + provider_request, ) - .await - .map_err(|error| format!("{config_path} {operation}调用 LLM 失败:{error}"))?; + .await? + else { + return Ok(None); + }; match parse_game_creator_agent_tool_plan_llm_response(&response) { Ok(parsed) => { append_agent_db_record( @@ -8380,7 +9815,7 @@ async fn request_game_creator_agent_background_tool_plan_at( "responseId": response.response_id, }), )?; - return Ok(parsed.plan); + return Ok(Some(parsed.plan)); } Err(error) if repair_attempt < AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS => { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { @@ -8440,7 +9875,8 @@ async fn request_game_creator_agent_background_final_reply_at( task: &str, plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], -) -> Result { + applied_steer_cursor: u64, +) -> Result, String> { let (llm, config_path, request) = build_game_creator_agent_background_final_reply_request( root, agent_id, @@ -8451,20 +9887,33 @@ async fn request_game_creator_agent_background_final_reply_at( observations, )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; - let response = request_game_creator_agent_llm_text_retrying_recoverable( - &client, - &llm, - request, - "后台 Agent 最终回复", - true, + let provider_request = async { + request_game_creator_agent_llm_text_retrying_recoverable( + &client, + &llm, + request, + "后台 Agent 最终回复", + true, + ) + .await + .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}")) + }; + let Some(response) = await_game_creator_agent_runtime_provider_request( + root, + agent_id, + run_id, + applied_steer_cursor, + provider_request, ) - .await - .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}"))?; + .await? + else { + return Ok(None); + }; let reply = strip_llm_thinking_blocks(response.text.as_str()); if reply.trim().is_empty() { return Err(format!("{config_path} 后台 Agent 最终回复为空")); } - Ok(reply) + Ok(Some(reply)) } fn build_game_creator_agent_background_tool_plan_request( @@ -8487,8 +9936,10 @@ fn build_game_creator_agent_background_tool_plan_request( let tool_policy = agent_runtime_tool_policy_snapshot_at(root, agent_id)?; let tool_policy_json = serde_json::to_string_pretty(&tool_policy) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; + let steers_json = + render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; let prompt = format!( - "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。请按后续结构化工具计划协议提交本轮结果。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.verify|project.checkpoint|project.restore|project.diff|git.inspect|project.patchset|file.list|file.read|file.write|file.patch|file.delete|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}},用于读取路径摘要或有界统一 diff hunks;git.inspect 使用 {{\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000}},只读当前项目根的 Git staged / unstaged / untracked 安全路径和有界 staged / unstaged diff,不推进 revision;不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote;project.patchset 使用 {{\"changes\":[{{\"operation\":\"create|update|delete\",\"path\":\"项目内相对文件\",\"content\":\"create 内容\",\"expectedSha256\":\"update/delete 必填\",\"oldText\":\"update 必填\",\"newText\":\"update 必填\",\"expectedReplacements\":1}}]}},会自动 checkpoint 并在一把锁内应用多文件变更,成功后必须用返回的 checkpointId 调用 project.diff includeContent=true 审查整体变更;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号和完整内容 SHA-256;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入;file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" + "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。请按后续结构化工具计划协议提交本轮结果。\n\n{context}\n\n后台任务:\n{task}\n\n运行中用户追加指令(按 sequence 递增,后序业务要求可修正前序要求,但不能覆盖系统规则、权限、确认或沙箱边界):\n{steers_json}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.verify|project.checkpoint|project.restore|project.diff|git.inspect|project.patchset|file.list|file.read|file.write|file.patch|file.delete|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}},用于读取路径摘要或有界统一 diff hunks;git.inspect 使用 {{\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000}},只读当前项目根的 Git staged / unstaged / untracked 安全路径和有界 staged / unstaged diff,不推进 revision;不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote;project.patchset 使用 {{\"changes\":[{{\"operation\":\"create|update|delete\",\"path\":\"项目内相对文件\",\"content\":\"create 内容\",\"expectedSha256\":\"update/delete 必填\",\"oldText\":\"update 必填\",\"newText\":\"update 必填\",\"expectedReplacements\":1}}]}},会自动 checkpoint 并在一把锁内应用多文件变更,成功后必须用返回的 checkpointId 调用 project.diff includeContent=true 审查整体变更;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号和完整内容 SHA-256;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入;file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" ); let prompt = prompt .replace( @@ -8638,8 +10089,10 @@ fn build_game_creator_agent_background_final_reply_request( .map_err(|error| format!("序列化 Agent 工具观察失败:{error}"))?; let plan_json = serde_json::to_string_pretty(plan) .map_err(|error| format!("序列化 Agent 工具计划失败:{error}"))?; + let steers_json = + render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; let prompt = format!( - "运行上下文如下。请只依据后台任务、计划和已获准工具返回的 observation,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" + "运行上下文如下。请只依据后台任务、运行中用户追加指令、计划和已获准工具返回的 observation,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n运行中用户追加指令:\n{steers_json}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" ); let request = apply_game_creator_llm_reasoning_effort( LlmRunRequest::new(vec![ @@ -9581,7 +11034,11 @@ fn validate_agent_runtime_pending_tool_action_record( let serialized = serde_json::to_string(pending) .map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?; validate_agent_runtime_pending_serialized_content(root, &serialized)?; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&pending.action, &pending.task); + let action_fingerprint = agent_runtime_pending_tool_action_fingerprint( + &pending.action, + &pending.task, + pending.planned_steer_cursor, + ); let action_id = agent_runtime_tool_action_id( &pending.run_id, pending.loop_iteration, @@ -11311,7 +12768,11 @@ where "project.git_commit 未创建提交" } .to_string(), - detail: Some(redact_agent_runtime_project_paths(root, error.message(), 500)), + detail: Some(redact_agent_runtime_project_paths( + root, + error.message(), + 500, + )), }; } }; @@ -17657,6 +19118,29 @@ where root, "runtime.background.complete", )?; + let steer_snapshot = + read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?; + if steer_snapshot.entries.values().any(|entry| { + entry.identity.sequence > state.applied_steer_cursor || entry.status != "applied" + }) { + return Ok(AgentBackgroundFinalizationOutcome::Stale( + AgentRuntimeToolObservation { + tool: "runtime.steer".to_string(), + status: "stale".to_string(), + summary: "最终回复生成期间收到运行中追加指令,旧回复已作废".to_string(), + detail: Some(format!( + "responseSteerCursor={} · latestSteerSequence={}", + state.applied_steer_cursor, + steer_snapshot + .entries + .values() + .map(|entry| entry.identity.sequence) + .max() + .unwrap_or(0) + )), + }, + )); + } let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let blocker = if let Some(blocker) = process_session_completion_blocker_at_locked(root, &state.agent_id, &state.run_id) @@ -17719,7 +19203,26 @@ where &mut journal, &mut checkpoint, ) { - Ok(completed) => Ok(AgentBackgroundFinalizationOutcome::Completed(completed)), + Ok(completed) => { + if let Err(error) = + close_game_creator_agent_runtime_steer_ledger_at_locked(root, &completed) + { + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.steer.close_failed", + "agentId": completed.agent_id, + "taskId": completed.task_id, + "sessionId": completed.session_id, + "runId": completed.run_id, + "source": completed.source, + "appliedSteerCursor": completed.applied_steer_cursor, + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + } + Ok(AgentBackgroundFinalizationOutcome::Completed(completed)) + } Err(error) => { record_game_creator_agent_runtime_finalization_pending(root, &state, &error); Ok(AgentBackgroundFinalizationOutcome::Pending(error)) @@ -17958,6 +19461,9 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age task_queue: AgentRuntimeTaskQueueSummary::default(), allowed_tools: default_game_creator_agent_runtime_allowed_tools(), tool_policy: AgentRuntimeToolPolicySnapshot::default(), + applied_steer_cursor: 0, + applied_steer_refs: Vec::new(), + queued_steer_count: 0, last_response: None, error: None, updated_at: unix_timestamp(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 59888c67f..75027b10f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -31,6 +31,13 @@ pub(crate) enum CliCommand { run_id: String, action_id: String, }, + AgentSteer { + project_path: PathBuf, + agent_id: String, + session_id: String, + run_id: String, + steer_id: String, + }, AgentResume { project_path: PathBuf, }, @@ -49,6 +56,7 @@ impl CliCommand { Self::AgentTask { .. } | Self::AgentEnqueue { .. } | Self::AgentConfirm { .. } + | Self::AgentSteer { .. } | Self::AgentResume { .. } ) } @@ -72,6 +80,7 @@ impl CliCommand { Self::AgentChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentConfirm { project_path, .. } + | Self::AgentSteer { project_path, .. } | Self::AgentResume { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), Self::LlmStatus | Self::RunnerStatus => None, @@ -216,6 +225,33 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) lines } +fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result { + const MAX_INSTRUCTION_BYTES: usize = 4 * 1024; + const MAX_STDIN_BYTES: u64 = 8 * 1024; + let mut bytes = Vec::new(); + reader + .take(MAX_STDIN_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("从 stdin 读取 Agent 追加指令失败:{error}"))?; + if bytes.len() as u64 > MAX_STDIN_BYTES { + return Err(format!( + "Agent 追加指令 stdin 超过 {MAX_STDIN_BYTES} 字节上限" + )); + } + let instruction = String::from_utf8(bytes) + .map_err(|_| "Agent 追加指令 stdin 必须是 UTF-8 文本".to_string())?; + let instruction = instruction.trim(); + if instruction.is_empty() { + return Err("Agent 追加指令 stdin 不能为空".to_string()); + } + if instruction.len() > MAX_INSTRUCTION_BYTES { + return Err(format!( + "Agent 追加指令 stdin 超过 {MAX_INSTRUCTION_BYTES} 字节上限" + )); + } + Ok(instruction.to_string()) +} + pub(crate) fn parse_cli_command(args: &[String]) -> Result, String> { if args.first().map(String::as_str) == Some("--llm-status") { return Ok(Some(CliCommand::LlmStatus)); @@ -248,6 +284,23 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S action_id: args[4].trim().to_string(), })); } + if args.first().map(String::as_str) == Some("--agent-steer") { + const USAGE: &str = "用法:--agent-steer <本地项目绝对路径> --stdin"; + if args.len() != 7 || args.last().map(String::as_str) != Some("--stdin") { + return Err(USAGE.to_string()); + } + let values = &args[1..6]; + if values.iter().any(|value| value.trim().is_empty()) { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentSteer { + project_path: PathBuf::from(&values[0]), + agent_id: values[1].trim().to_string(), + session_id: values[2].trim().to_string(), + run_id: values[3].trim().to_string(), + steer_id: values[4].trim().to_string(), + })); + } if args.first().map(String::as_str) == Some("--agent-resume") { if args.len() != 2 { return Err("用法:--agent-resume <本地项目绝对路径>".to_string()); @@ -542,6 +595,41 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { ); Ok(()) } + CliCommand::AgentSteer { + project_path, + agent_id, + session_id, + run_id, + steer_id, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + let instruction = read_cli_agent_steer_instruction(&mut std::io::stdin().lock())?; + let mut result = steer_game_creator_agent_runtime_task_at( + &project_path, + &agent_id, + &session_id, + &run_id, + &steer_id, + &instruction, + "cli", + )?; + if !result.provider_interrupted { + result.provider_interrupted = + steer_external_agent_runner(&project_path, &agent_id, &run_id, &steer_id)?; + } + println!("agent.steer.accepted"); + println!("agentId={agent_id}"); + println!("sessionId={session_id}"); + println!("runId={run_id}"); + println!("steerId={steer_id}"); + println!( + "steerJson={}", + serde_json::to_string(&result) + .map_err(|error| format!("序列化 Agent steer 结果失败:{error}"))? + ); + Ok(()) + } CliCommand::AgentResume { project_path } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; @@ -629,3 +717,94 @@ fn initialize_cli_agent_project(project_path: &Path, initialize: bool) -> Result } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn parses_agent_steer_with_stdin_only_contract() { + let command = parse_cli_command(&[ + "--agent-steer".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "run-9".to_string(), + "steer-11".to_string(), + "--stdin".to_string(), + ]) + .expect("parse agent steer") + .expect("agent steer command"); + + assert_eq!( + command, + CliCommand::AgentSteer { + project_path: PathBuf::from("/tmp/game-project"), + agent_id: "code-prototype".to_string(), + session_id: "session-7".to_string(), + run_id: "run-9".to_string(), + steer_id: "steer-11".to_string(), + } + ); + assert!(command.requires_external_agent_runner()); + } + + #[test] + fn agent_steer_rejects_missing_params_and_argv_instruction() { + assert!(parse_cli_command(&["--agent-steer".to_string()]).is_err()); + assert!(parse_cli_command(&[ + "--agent-steer".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "run-9".to_string(), + "steer-11".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--agent-steer".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "run-9".to_string(), + "steer-11".to_string(), + "正文不能出现在 argv".to_string(), + ]) + .is_err()); + } + + #[test] + fn reads_agent_steer_instruction_only_from_stdin() { + let mut stdin = Cursor::new(" 先停下当前方案,改用键盘操作。\n"); + assert_eq!( + read_cli_agent_steer_instruction(&mut stdin).as_deref(), + Ok("先停下当前方案,改用键盘操作。") + ); + let mut empty = Cursor::new(" \r\n\t"); + assert!(read_cli_agent_steer_instruction(&mut empty).is_err()); + let mut exact_with_newline = Cursor::new(format!("{}\n", "x".repeat(4 * 1024))); + assert_eq!( + read_cli_agent_steer_instruction(&mut exact_with_newline) + .expect("read exact-size instruction") + .len(), + 4 * 1024 + ); + let mut oversized = Cursor::new(vec![b'x'; 4 * 1024 + 1]); + assert!(read_cli_agent_steer_instruction(&mut oversized).is_err()); + } + + #[test] + fn agent_steer_requires_external_config_dir() { + let mut command = CliCommand::AgentSteer { + project_path: std::env::current_dir().expect("current directory"), + agent_id: "code-prototype".to_string(), + session_id: "session-7".to_string(), + run_id: "run-9".to_string(), + steer_id: "steer-11".to_string(), + }; + let error = prepare_cli_command_paths(&mut command, None) + .expect_err("agent steer must require config dir"); + assert!(error.contains("--config-dir")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d4495eec7..d2777a9f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -435,6 +435,38 @@ pub(crate) fn start_game_creator_agent_runtime_task( ) } +#[tauri::command] +pub(crate) fn steer_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + session_id: String, + run_id: String, + steer_id: String, + instruction: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + let mut result = steer_game_creator_agent_runtime_task_at( + root, + agent_id.trim(), + session_id.trim(), + run_id.trim(), + steer_id.trim(), + instruction.trim(), + "tauri", + )?; + if !result.provider_interrupted + && external_agent_runner_enabled() + && !external_agent_runner_is_server_process() + { + result.provider_interrupted = + steer_external_agent_runner(root, agent_id.trim(), run_id.trim(), steer_id.trim())?; + } + Ok(result) +} + #[tauri::command] pub(crate) fn cancel_game_creator_agent_runtime_task( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs index 1d0950bde..33ef15c67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs @@ -946,33 +946,19 @@ fn commit_local_git_worktree_at_with_hook( )); } - let update_result = run_git_commit_owned( - &root, - &command, - &[ - "update-ref".to_string(), - "--create-reflog".to_string(), - "-m".to_string(), - GIT_COMMIT_REFLOG_MESSAGE.to_string(), - "HEAD".to_string(), - commit_head.clone(), - expected_head.to_string(), - ], - None, - None, - GitCommandInput::None, - "原子更新 Git 分支", - ); + let update_result = update_git_head_transaction(&root, &command, &commit_head, expected_head); if let Err(error) = update_result { - let current_ref = run_git( - &root, - &command, - &["rev-parse", "--verify", &preflight.branch_ref], - ); if matches!( - current_ref.as_deref(), - Ok(current) - if current.trim() != expected_head && current.trim() != commit_head + update_ref_failure_is_explicit_expected_old_competition( + &root, + &command, + &git_dir, + &preflight.branch_ref, + expected_head, + &commit_head, + error.message(), + ), + Ok(true) ) { return Err(cleanup_pre_ref_index_lock( &index_lock_path, @@ -981,11 +967,17 @@ fn commit_local_git_worktree_at_with_hook( )); } return Err(LocalGitCommitError::reconciliation(format!( - "Git 分支更新结果无法安全确认:{}", + "Git ref/reflog 事务结果无法安全确认,或遗留 ref/reflog lock:{}", error.message() ))); } + ensure_no_git_ref_transaction_locks(&git_dir, &preflight.branch_ref).map_err(|error| { + LocalGitCommitError::reconciliation(format!( + "Git 分支已前移,但检测到遗留 ref/reflog lock:{error}" + )) + })?; + if let Err(error) = hook(LocalGitCommitHookPoint::AfterUpdateRef) { return Err(LocalGitCommitError::reconciliation(format!( "Git 分支已前移,但后续步骤失败:{error}" @@ -1003,23 +995,13 @@ fn commit_local_git_worktree_at_with_hook( LocalGitCommitError::reconciliation(format!("Git 分支已前移,但安装新 index 失败:{error}")) })?; - let branch_after = - run_git(&root, &command, &["symbolic-ref", "--quiet", "HEAD"]).map_err(|error| { + verify_git_head_and_reflogs(&root, &command, &preflight.branch_ref, &commit_head).map_err( + |error| { LocalGitCommitError::reconciliation(format!( - "Git 分支已前移,但无法复核附着分支:{error}" + "Git 分支已前移,但 HEAD / branch ref 或双 reflog 复核失败:{error}" )) - })?; - let head_after = - run_git(&root, &command, &["rev-parse", "--verify", "HEAD"]).map_err(|error| { - LocalGitCommitError::reconciliation(format!( - "Git 分支已前移,但无法复核新 HEAD:{error}" - )) - })?; - if branch_after.trim() != preflight.branch_ref || head_after.trim() != commit_head { - return Err(LocalGitCommitError::reconciliation( - "Git 分支已前移,但最终 HEAD 或附着分支不一致", - )); - } + }, + )?; let remaining_changed_count = read_remaining_changed_count(&root, &command).map_err(|error| { LocalGitCommitError::reconciliation(format!( @@ -1374,6 +1356,136 @@ fn resolved_commit_blob_mode( Ok(mode) } +fn update_git_head_transaction( + root: &Path, + command: &GitInspectCommandContext, + commit_head: &str, + expected_head: &str, +) -> Result<(), LocalGitCommitError> { + let input = format!("start\nupdate HEAD {commit_head} {expected_head}\nprepare\ncommit\n"); + run_git_commit_owned( + root, + command, + &[ + "update-ref".to_string(), + "--create-reflog".to_string(), + "-m".to_string(), + GIT_COMMIT_REFLOG_MESSAGE.to_string(), + "--stdin".to_string(), + ], + None, + None, + GitCommandInput::Bytes(input.as_bytes()), + "原子更新 Git ref/reflog 事务", + ) + .map(|_| ()) +} + +fn update_ref_failure_is_explicit_expected_old_competition( + root: &Path, + command: &GitInspectCommandContext, + git_dir: &Path, + branch_ref: &str, + expected_head: &str, + commit_head: &str, + error_message: &str, +) -> Result { + ensure_no_git_ref_transaction_locks(git_dir, branch_ref)?; + let attached_branch = + read_attached_branch_ref_from_head(git_dir).map_err(|error| error.message().to_string())?; + if attached_branch != branch_ref { + return Ok(false); + } + let current_branch = run_git(root, command, &["rev-parse", "--verify", branch_ref])?; + let current_head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?; + let current_branch = current_branch.trim().to_ascii_lowercase(); + let current_head = current_head.trim().to_ascii_lowercase(); + let expected_head = expected_head.to_ascii_lowercase(); + let commit_head = commit_head.to_ascii_lowercase(); + if current_branch != current_head + || current_branch == expected_head + || current_branch == commit_head + { + return Ok(false); + } + let detail = error_message.to_ascii_lowercase(); + Ok(detail.contains("cannot lock ref") + && detail.contains(&format!( + "is at {current_branch} but expected {expected_head}" + ))) +} + +fn verify_git_head_and_reflogs( + root: &Path, + command: &GitInspectCommandContext, + branch_ref: &str, + commit_head: &str, +) -> Result<(), String> { + let attached_branch = run_git(root, command, &["symbolic-ref", "--quiet", "HEAD"])?; + let head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?; + let branch = run_git(root, command, &["rev-parse", "--verify", branch_ref])?; + if attached_branch.trim() != branch_ref + || head.trim() != commit_head + || branch.trim() != commit_head + { + return Err("最终 HEAD、附着分支或 branch ref 不一致".to_string()); + } + + for reference in ["HEAD", branch_ref] { + let reflog = run_git( + root, + command, + &["reflog", "show", "-1", "--format=%H%x09%gs", reference], + )?; + let reflog = reflog.trim_end_matches(['\r', '\n']); + let Some((reflog_head, reflog_message)) = reflog.split_once('\t') else { + return Err(format!("{reference} reflog 缺少结构化尾项")); + }; + if reflog_head != commit_head || reflog_message != GIT_COMMIT_REFLOG_MESSAGE { + return Err(format!("{reference} reflog 未同步到受控提交")); + } + } + Ok(()) +} + +fn ensure_no_git_ref_transaction_locks(git_dir: &Path, branch_ref: &str) -> Result<(), String> { + for lock_path in git_ref_transaction_lock_paths(git_dir, branch_ref) + .map_err(|error| error.message().to_string())? + { + match fs::symlink_metadata(&lock_path) { + Ok(_) => { + let relative = lock_path.strip_prefix(git_dir).unwrap_or(&lock_path); + return Err(format!("存在 Git 事务锁 {}", relative.display())); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("检查 Git 事务锁失败:{error}")), + } + } + Ok(()) +} + +fn git_ref_transaction_lock_paths( + git_dir: &Path, + branch_ref: &str, +) -> Result, LocalGitCommitError> { + validate_local_branch_ref(branch_ref)?; + let branch_path = git_dir.join(branch_ref); + let branch_reflog = git_dir.join("logs").join(branch_ref); + Ok(vec![ + append_lock_suffix(&git_dir.join("HEAD")), + append_lock_suffix(&branch_path), + git_dir.join("packed-refs.lock"), + append_lock_suffix(&git_dir.join("logs/HEAD")), + append_lock_suffix(&branch_reflog), + ]) +} + +fn append_lock_suffix(path: &Path) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(".lock"); + PathBuf::from(value) +} + fn cleanup_pre_ref_index_lock( lock_path: &Path, lock_file: &mut Option, @@ -1396,9 +1508,17 @@ fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io: #[cfg(windows)] fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io::Result<()> { use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + const MOVEFILE_WRITE_THROUGH: u32 = 0x8; + #[link(name = "kernel32")] + extern "system" { + fn MoveFileExW( + existing_file_name: *const u16, + new_file_name: *const u16, + flags: u32, + ) -> i32; + } let source = index_lock_path .as_os_str() @@ -2171,6 +2291,8 @@ fn build_sandboxed_git_command( .env("GIT_PAGER", "cat") .env("PAGER", "cat") .env("TERM", "dumb") + .env("LC_ALL", "C") + .env("LANG", "C") .env("GIT_NO_REPLACE_OBJECTS", "1") .env("GIT_ATTR_NOSYSTEM", "1") .env("GIT_LFS_SKIP_SMUDGE", "1") @@ -2872,6 +2994,49 @@ mod tests { assert!(root.join(".git/index.lock").is_file()); } + #[test] + fn git_commit_expected_old_competition_with_leftover_reflog_lock_needs_reconciliation() { + let fixture = tempfile::tempdir().expect("create fixture"); + let root = fixture.path(); + init_git_commit_fixture(root, true); + fs::write(root.join("game.txt"), "change\n").expect("modify file"); + let (head, fingerprint) = git_commit_snapshot(root); + let competitor = git_output( + root, + &[ + "commit-tree", + "HEAD^{tree}", + "-p", + "HEAD", + "-m", + "competitor", + ], + ); + let mut hook = |point| { + if point == LocalGitCommitHookPoint::BeforeUpdateRef { + git(root, &["update-ref", "refs/heads/main", &competitor, &head]); + fs::write(root.join(".git/logs/HEAD.lock"), "leftover reflog lock\n") + .expect("create leftover reflog lock"); + } + Ok(()) + }; + + let error = commit_local_git_worktree_at_with_hook( + root, + "must reconcile", + &["game.txt".to_string()], + &head, + &fingerprint, + &mut hook, + ) + .expect_err("leftover reflog lock makes update-ref outcome unsafe"); + + assert!(error.needs_reconciliation()); + assert_eq!(git_output(root, &["rev-parse", "HEAD"]), competitor); + assert!(root.join(".git/logs/HEAD.lock").is_file()); + assert!(root.join(".git/index.lock").is_file()); + } + #[test] fn git_commit_failure_after_ref_update_needs_reconciliation() { let fixture = tempfile::tempdir().expect("create fixture"); @@ -2919,6 +3084,25 @@ mod tests { assert!(!index_lock.exists()); } + #[cfg(windows)] + #[test] + fn git_commit_windows_index_install_uses_replace_existing_semantics() { + let fixture = tempfile::tempdir().expect("create fixture"); + let index = fixture.path().join("index"); + let index_lock = fixture.path().join("index.lock"); + fs::write(&index, b"existing windows index").expect("write existing index"); + fs::write(&index_lock, b"replacement windows index").expect("write replacement index"); + + install_git_index_lock(&index_lock, &index) + .expect("MoveFileExW must atomically replace an existing index"); + + assert_eq!( + fs::read(&index).expect("read replaced windows index"), + b"replacement windows index" + ); + assert!(!index_lock.exists()); + } + #[test] fn bounded_git_output_reports_discarded_tail_bytes() { let input = vec![b'x'; GIT_INSPECT_OUTPUT_MAX_BYTES + 17]; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 676c006f0..e8ee9f4ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -216,6 +216,12 @@ struct AgentRuntimeState { #[serde(default)] tool_policy: AgentRuntimeToolPolicySnapshot, #[serde(default)] + applied_steer_cursor: u64, + #[serde(default)] + applied_steer_refs: Vec, + #[serde(default)] + queued_steer_count: u32, + #[serde(default)] last_response: Option, #[serde(default)] error: Option, @@ -223,6 +229,21 @@ struct AgentRuntimeState { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeSteerRef { + #[serde(default)] + steer_id: String, + #[serde(default)] + sequence: u64, + #[serde(default)] + message_id: String, + #[serde(default)] + instruction_sha256: String, + #[serde(default)] + content_chars: u32, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeToolPolicySnapshot { @@ -424,6 +445,16 @@ struct AgentRuntimeResult { recent_tasks: Vec, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeSteerResult { + runtime: AgentRuntimeResult, + steer_id: String, + sequence: u64, + status: String, + provider_interrupted: bool, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentRuntimeUpdateEvent { @@ -1383,6 +1414,7 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, start_game_creator_agent_runtime_task, + steer_game_creator_agent_runtime_task, cancel_game_creator_agent_runtime_task, retry_game_creator_agent_runtime_task, confirm_game_creator_agent_runtime_task, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 7d9cf134d..85695118d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -3919,13 +3919,12 @@ impl Default for ProjectPermissionPolicy { } } -const PROJECT_PERMISSION_MANDATORY_CONFIRM_COMMANDS: &[&str] = - &[ - "project.git_commit", - "command.start", - "command.stdin", - "command.terminate", - ]; +const PROJECT_PERMISSION_MANDATORY_CONFIRM_COMMANDS: &[&str] = &[ + "project.git_commit", + "command.start", + "command.stdin", + "command.terminate", +]; pub(crate) fn read_project_permission_policy_at( root: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 82407c345..c4a5bf975 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 1; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 2; const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -91,7 +91,7 @@ impl ExternalAgentRunnerProjectExecutionOwnerRecord { impl ExternalAgentRunnerEndpoint { fn validate_shape(&self) -> Result<(), String> { - if self.pid == 0 { + if self.protocol_version == 0 || self.pid == 0 { return Err("Agent Runner endpoint 缺少有效 pid".to_string()); } if self.boot_id.trim().is_empty() || self.boot_id.len() > 128 { @@ -152,14 +152,16 @@ impl ExternalAgentRunnerStatus { #[derive(Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct ExternalAgentRunnerRequestParams { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] root: Option, - #[serde(default, alias = "agentId")] + #[serde(default, alias = "agentId", skip_serializing_if = "Option::is_none")] agent: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] run_id: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] action_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + steer_id: Option, } #[derive(Deserialize, Serialize)] @@ -1986,9 +1988,9 @@ fn external_agent_runner_request_agent( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| "runtime.continue_action 请求缺少 agent".to_string())?; + .ok_or_else(|| "Runtime 请求缺少 agent".to_string())?; if agent.len() > 256 { - return Err("runtime.continue_action agent 过长".to_string()); + return Err("Runtime 请求 agent 过长".to_string()); } Ok(agent.to_string()) } @@ -2002,9 +2004,9 @@ fn external_agent_runner_request_run_id( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| "runtime.continue_action 请求缺少 runId".to_string())?; + .ok_or_else(|| "Runtime 请求缺少 runId".to_string())?; if run_id.len() > 256 { - return Err("runtime.continue_action runId 过长".to_string()); + return Err("Runtime 请求 runId 过长".to_string()); } Ok(run_id.to_string()) } @@ -2025,6 +2027,22 @@ fn external_agent_runner_request_action_id( Ok(action_id.to_string()) } +fn external_agent_runner_request_steer_id( + request: &ExternalAgentRunnerRequest, +) -> Result { + let steer_id = request + .params + .steer_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "runtime.steer 请求缺少 steerId".to_string())?; + if steer_id.len() > 256 { + return Err("runtime.steer steerId 过长".to_string()); + } + Ok(steer_id.to_string()) +} + fn dispatch_external_agent_runner_runtime_request( request: &ExternalAgentRunnerRequest, state: &ExternalAgentRunnerServerState, @@ -2044,7 +2062,7 @@ fn dispatch_external_agent_runner_runtime_request( if matches!( request.method.as_str(), - "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" + "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" | "runtime.steer" ) && state.draining.load(Ordering::Acquire) { return ExternalAgentRunnerResponse::failure( @@ -2056,7 +2074,7 @@ fn dispatch_external_agent_runner_runtime_request( let token = state.endpoint_snapshot().token; let response = match request.method.as_str() { - "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" => { + "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" | "runtime.steer" => { let root = match external_agent_runner_request_root(request) { Ok(root) => root, Err(error) => { @@ -2077,16 +2095,14 @@ fn dispatch_external_agent_runner_runtime_request( ); } }; - // Main/agent wiring supplies synchronous Result-returning entry points; the protocol - // deliberately discards their internal success payload and only reports acceptance. let result = match request.method.as_str() { "runtime.wake_pending" => { crate::wake_pending_game_creator_agent_background_tasks_at(&root) - .map(|_| ()) + .map(|_| json!({ "accepted": true })) .map_err(|error| error.to_string()) } "runtime.resume" => crate::resume_game_creator_agent_background_tasks_at(&root) - .map(|_| ()) + .map(|_| json!({ "accepted": true })) .map_err(|error| error.to_string()), "runtime.continue_action" => (|| { let agent = external_agent_runner_request_agent(request)?; @@ -2095,16 +2111,31 @@ fn dispatch_external_agent_runner_runtime_request( crate::resume_game_creator_agent_pending_action_for_agent_at( &root, &agent, &run_id, &action_id, ) - .map(|_| ()) + .map(|_| json!({ "accepted": true })) .map_err(|error| error.to_string()) })(), + "runtime.steer" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let steer_id = external_agent_runner_request_steer_id(request)?; + crate::validate_game_creator_agent_runtime_steer_notification_at( + &root, &agent, &run_id, &steer_id, + )?; + let provider_interrupted = + crate::interrupt_game_creator_agent_runtime_provider_request_at( + &root, &agent, &run_id, + )?; + crate::wake_pending_game_creator_agent_background_tasks_at(&root) + .map_err(|error| error.to_string())?; + Ok(json!({ + "accepted": true, + "providerInterrupted": provider_interrupted, + })) + })(), _ => unreachable!(), }; match result { - Ok(()) => ExternalAgentRunnerResponse::success( - &request.request_id, - json!({ "accepted": true }), - ), + Ok(result) => ExternalAgentRunnerResponse::success(&request.request_id, result), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, "runtime-error", @@ -2241,6 +2272,7 @@ fn handle_external_agent_runner_request( "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" + | "runtime.steer" | "runner.shutdown_if_idle" | "shutdown_if_idle" => dispatch_external_agent_runner_runtime_request(&request, state), _ => ExternalAgentRunnerResponse::failure( @@ -2753,17 +2785,18 @@ fn launch_external_agent_runner(config_dir: &Path) -> Result { .map_err(|error| format!("启动外部 Agent Runner 失败:{error}")) } -fn send_external_agent_runner_request_with_id( +fn send_external_agent_runner_request_with_protocol_and_id( endpoint: &ExternalAgentRunnerEndpoint, + protocol_version: u32, request_id: String, method: &str, params: ExternalAgentRunnerRequestParams, ) -> Result { - if endpoint.protocol_version != EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION { + if endpoint.protocol_version != protocol_version { return Err("Agent Runner endpoint 协议版本不兼容".to_string()); } let request = ExternalAgentRunnerRequest { - protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + protocol_version, request_id: request_id.clone(), token: endpoint.token.clone(), method: method.to_string(), @@ -2787,7 +2820,7 @@ fn send_external_agent_runner_request_with_id( .map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?; let response = serde_json::from_slice::(&response_payload) .map_err(|_| "解析 Agent Runner 响应失败".to_string())?; - if response.protocol_version != EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION { + if response.protocol_version != protocol_version { return Err("Agent Runner 响应协议版本不兼容".to_string()); } if response.request_id != request_id { @@ -2806,6 +2839,21 @@ fn send_external_agent_runner_request_with_id( )) } +fn send_external_agent_runner_request_with_id( + endpoint: &ExternalAgentRunnerEndpoint, + request_id: String, + method: &str, + params: ExternalAgentRunnerRequestParams, +) -> Result { + send_external_agent_runner_request_with_protocol_and_id( + endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id, + method, + params, + ) +} + fn send_external_agent_runner_request( endpoint: &ExternalAgentRunnerEndpoint, method: &str, @@ -2824,6 +2872,38 @@ fn ping_external_agent_runner(endpoint: &ExternalAgentRunnerEndpoint) -> Result< .map(|_| ()) } +fn retire_incompatible_external_agent_runner( + endpoint_path: &Path, + endpoint: &ExternalAgentRunnerEndpoint, +) -> Result<(), String> { + let request_id = random_identifier(b"genarrative-agent-runner-upgrade-request-id")?; + let result = send_external_agent_runner_request_with_protocol_and_id( + endpoint, + endpoint.protocol_version, + request_id, + "runner.shutdown_if_idle", + ExternalAgentRunnerRequestParams::default(), + )?; + if result.get("idle").and_then(Value::as_bool) != Some(true) { + return Err(format!( + "Agent Runner 协议需要从 {} 升级到 {},但旧 Runner 仍有任务,暂不能重启", + endpoint.protocol_version, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION + )); + } + + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + loop { + match read_external_agent_runner_endpoint(endpoint_path) { + Ok(current) if current.boot_id == endpoint.boot_id => {} + _ => return Ok(()), + } + if Instant::now() >= deadline { + return Err("旧版 Agent Runner 未在协议升级期限内退出".to_string()); + } + thread::sleep(Duration::from_millis(50)); + } +} + fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -2855,9 +2935,22 @@ fn wait_for_external_agent_runner( fn ensure_external_agent_runner(config_dir: &Path) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); - if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path) { - if ping_external_agent_runner(&endpoint).is_ok() { - return Ok(endpoint); + if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { + if endpoint.protocol_version == EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION { + if ping_external_agent_runner(&endpoint).is_ok() { + return Ok(endpoint); + } + } else { + let legacy_ping = send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + ); + if legacy_ping.is_ok() { + retire_incompatible_external_agent_runner(&endpoint_path, &endpoint)?; + } } } let mut child = launch_external_agent_runner(config_dir)?; @@ -2945,7 +3038,8 @@ fn send_external_agent_runner_runtime_request( agent: Option<&str>, run_id: Option<&str>, action_id: Option<&str>, -) -> Result<(), String> { + steer_id: Option<&str>, +) -> Result { let root = canonicalize_external_agent_runner_project_root(root)?; let root = root .to_str() @@ -2967,17 +3061,19 @@ fn send_external_agent_runner_runtime_request( agent: agent.map(str::to_string), run_id: run_id.map(str::to_string), action_id: action_id.map(str::to_string), + steer_id: steer_id.map(str::to_string), }, ) - .map(|_| ()) } pub(crate) fn wake_external_agent_runner_pending(root: &Path) -> Result<(), String> { - send_external_agent_runner_runtime_request(root, "runtime.wake_pending", None, None, None) + send_external_agent_runner_runtime_request(root, "runtime.wake_pending", None, None, None, None) + .map(|_| ()) } pub(crate) fn resume_external_agent_runner(root: &Path) -> Result<(), String> { - send_external_agent_runner_runtime_request(root, "runtime.resume", None, None, None) + send_external_agent_runner_runtime_request(root, "runtime.resume", None, None, None, None) + .map(|_| ()) } pub(crate) fn continue_external_agent_runner_action( @@ -2998,7 +3094,42 @@ pub(crate) fn continue_external_agent_runner_action( Some(agent), Some(run_id), Some(action_id), + None, ) + .map(|_| ()) +} + +pub(crate) fn steer_external_agent_runner( + root: &Path, + agent: &str, + run_id: &str, + steer_id: &str, +) -> Result { + if [agent, run_id, steer_id] + .into_iter() + .any(|value| value.trim().is_empty()) + { + return Err("追加 Agent 指令必须同时提供 agent/runId/steerId".to_string()); + } + let agent = agent.trim(); + let run_id = run_id.trim(); + let steer_id = steer_id.trim(); + let result = send_external_agent_runner_runtime_request( + root, + "runtime.steer", + Some(agent), + Some(run_id), + None, + Some(steer_id), + )?; + parse_external_agent_runner_steer_result(&result) +} + +fn parse_external_agent_runner_steer_result(result: &Value) -> Result { + result + .get("providerInterrupted") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner runtime.steer 响应缺少 providerInterrupted".to_string()) } pub(crate) fn notify_external_agent_runner(root: &Path, kind: &str) -> Result<(), String> { @@ -3008,7 +3139,8 @@ pub(crate) fn notify_external_agent_runner(root: &Path, kind: &str) -> Result<() "continue_action 通知必须改用 typed helper 并绑定 agent/runId/actionId".to_string(), ); } - send_external_agent_runner_runtime_request(root, method, agent.as_deref(), None, None) + send_external_agent_runner_runtime_request(root, method, agent.as_deref(), None, None, None) + .map(|_| ()) } fn read_external_agent_runner_status_at(config_dir: Option<&Path>) -> ExternalAgentRunnerStatus { @@ -3204,6 +3336,7 @@ mod tests { agent: Some("code-prototype".to_string()), run_id: Some("run-exact-7".to_string()), action_id: Some("action-exact-9".to_string()), + ..ExternalAgentRunnerRequestParams::default() }, }; @@ -3224,6 +3357,174 @@ mod tests { assert_eq!(wire["params"]["actionId"], "action-exact-9"); } + #[test] + fn steer_params_bind_identity_without_instruction_body() { + let instruction = "把角色移动速度改快一些"; + let request = ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-wire-1".to_string(), + token: "steer-private-token-steer-private-token".to_string(), + method: "runtime.steer".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some("/tmp/steer-project".to_string()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-steer-7".to_string()), + steer_id: Some("steer-9".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + + assert_eq!( + external_agent_runner_request_steer_id(&request).as_deref(), + Ok("steer-9") + ); + let wire = serde_json::to_value(&request).expect("serialize steer request"); + let params = wire["params"].as_object().expect("steer params object"); + assert_eq!(params.len(), 4); + assert_eq!( + params.get("root").and_then(Value::as_str), + Some("/tmp/steer-project") + ); + assert_eq!( + params.get("agent").and_then(Value::as_str), + Some("code-prototype") + ); + assert_eq!( + params.get("runId").and_then(Value::as_str), + Some("run-steer-7") + ); + assert_eq!( + params.get("steerId").and_then(Value::as_str), + Some("steer-9") + ); + let wire = serde_json::to_string(&wire).expect("serialize steer wire value"); + assert!(!wire.contains(instruction)); + assert!(!wire.contains("instruction")); + assert!(!wire.contains("content")); + } + + #[test] + fn typed_steer_result_requires_provider_interrupted_boolean() { + assert_eq!( + parse_external_agent_runner_steer_result(&json!({ + "providerInterrupted": true, + })), + Ok(true) + ); + assert_eq!( + parse_external_agent_runner_steer_result(&json!({ + "providerInterrupted": false, + })), + Ok(false) + ); + assert!(parse_external_agent_runner_steer_result(&json!({ "accepted": true })).is_err()); + } + + #[test] + fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at(&root, "project-steer-rpc", "Runner steer 测试") + .expect("initialize steer project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "实现一个可验证的键盘操作原型", + "run-steer-rpc", + "agent-background-task", + "准备规划实现步骤", + vec!["读取项目".to_string(), "实现并验证".to_string()], + ) + .expect("start steer runtime"); + let persisted = crate::steer_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + &runtime.session_id, + "run-steer-rpc", + "steer-rpc-1", + "先停下当前方案,改用键盘操作。", + "runner-test", + ) + .expect("persist steer before runner notification"); + assert_eq!(persisted.steer_id, "steer-rpc-1"); + let appdata = directory.0.join("appdata"); + fs::create_dir_all(&appdata).expect("create runner appdata"); + let token = "steer-rpc-private-token-steer-rpc-private-token"; + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-rpc-boot", 30303), + ); + let request = ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-rpc-request-1".to_string(), + token: token.to_string(), + method: "runtime.steer".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-steer-rpc".to_string()), + steer_id: Some("steer-rpc-1".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + + let first = dispatch_external_agent_runner_runtime_request(&request, &state); + assert!(first.ok, "runtime.steer failed: {:?}", first.error); + assert_eq!( + first + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + + let replay = dispatch_external_agent_runner_runtime_request(&request, &state); + assert_eq!(replay, first); + + let mut conflict = request; + conflict.params.steer_id = Some("steer-rpc-2".to_string()); + let conflict = dispatch_external_agent_runner_runtime_request(&conflict, &state); + assert!(!conflict.ok); + assert_eq!( + conflict.error.as_ref().map(|error| error.code.as_str()), + Some("request-id-conflict") + ); + } + + #[test] + fn draining_rejects_runtime_steer() { + let directory = unique_test_directory(); + let token = "steer-draining-token-steer-draining-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-draining-boot", 31312), + ); + state.draining.store(true, Ordering::Release); + let response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-draining-1".to_string(), + token: token.to_string(), + method: "runtime.steer".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(directory.0.to_string_lossy().into_owned()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-steer-draining".to_string()), + steer_id: Some("steer-draining".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + + assert!(!response.ok); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("runner-draining") + ); + } + #[test] fn draining_rejects_new_runtime_writes() { let directory = unique_test_directory(); @@ -3244,6 +3545,7 @@ mod tests { agent: Some("code-prototype".to_string()), run_id: Some("run-draining".to_string()), action_id: Some("action-draining".to_string()), + ..ExternalAgentRunnerRequestParams::default() }, }, &state, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 7caad8a1a..79f260650 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -179,6 +179,7 @@ fn pending_tool_action_for_test( &state.run_id, ) .expect("read verification gate before pending action"), + planned_steer_cursor: state.applied_steer_cursor, action, action_id: agent_runtime_tool_action_id( &state.run_id, @@ -1497,6 +1498,42 @@ fn spawn_interactive_mock_llm_server_with_capture( base_url } +fn spawn_interruptible_mock_llm_server_with_capture( + response_count: usize, + request_sender: mpsc::Sender, + response_receiver: mpsc::Receiver, +) -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("interruptible mock llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for _ in 0..response_count { + let (mut stream, _) = listener.accept().expect("interruptible mock llm accept"); + let request_text = read_mock_http_request(&mut stream); + request_sender + .send(request_text) + .expect("capture interruptible mock llm request"); + let response_content = response_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("interruptible mock llm response content"); + let body = serde_json::json!({ + "id": "resp_game_creator_interruptible_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + base_url +} + fn spawn_mock_llm_transport_failures_then_response( failure_count: usize, response_content: String, @@ -12728,7 +12765,7 @@ fn finalization_resume_completes_persisted_assistant_without_llm_replay() { "{:x}", Sha256::digest( format!( - "project-1\n{}\n{}\n{}\n{}\n0", + "project-1\n{}\n{}\n{}\n{}\n0\n0", state.agent_id, state.session_id, state.run_id, response_fingerprint ) .as_bytes() @@ -12780,6 +12817,7 @@ fn finalization_resume_completes_persisted_assistant_without_llm_replay() { "response": response, "responseFingerprint": response_fingerprint, "responseRevision": 0, + "responseSteerCursor": 0, "verificationGate": read_game_creator_agent_runtime_verification_gate( &root, "design-director", @@ -20107,7 +20145,10 @@ fn agent_runtime_git_commit_prompt_and_input_summary_keep_the_safe_contract() { "expectedSnapshotFingerprint", "expectedHead", ] { - assert!(prompt.contains(expected), "missing prompt contract: {expected}"); + assert!( + prompt.contains(expected), + "missing prompt contract: {expected}" + ); } let root = unique_project_path(); @@ -20122,8 +20163,8 @@ fn agent_runtime_git_commit_prompt_and_input_summary_keep_the_safe_contract() { "expectedSnapshotFingerprint": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" }), }; - let summary = agent_runtime_tool_action_input_summary(&root, &action) - .expect("git commit input summary"); + let summary = + agent_runtime_tool_action_input_summary(&root, &action).expect("git commit input summary"); assert!(summary.contains("title=提交已验证修改")); assert!(summary.contains("pathCount=2")); assert!(summary.contains("game/main.js,game/style.css")); @@ -20222,13 +20263,11 @@ fn agent_runtime_git_commit_rejects_unverified_revision_without_moving_head() { fn agent_runtime_git_commit_commits_only_selected_paths_and_persists_safe_audit() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "受控 Git 提交项目").expect("project init"); - fs::write(root.join("game/selected.txt"), "before selected\n") - .expect("write selected fixture"); + fs::write(root.join("game/selected.txt"), "before selected\n").expect("write selected fixture"); fs::write(root.join("game/unselected.txt"), "before unselected\n") .expect("write unselected fixture"); let original_head = seed_agent_runtime_git_fixture(&root); - fs::write(root.join("game/selected.txt"), "after selected\n") - .expect("modify selected fixture"); + fs::write(root.join("game/selected.txt"), "after selected\n").expect("modify selected fixture"); fs::write(root.join("game/unselected.txt"), "after unselected\n") .expect("modify unselected fixture"); fs::write(root.join("game/new-selected.txt"), "new selected\n") @@ -20248,8 +20287,8 @@ fn agent_runtime_git_commit_commits_only_selected_paths_and_persists_safe_audit( "project.verify", true, ); - let _lock = acquire_project_write_lock(&root, "test.git_commit.passed") - .expect("acquire project lock"); + let _lock = + acquire_project_write_lock(&root, "test.git_commit.passed").expect("acquire project lock"); let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000) .expect("inspect verified git fixture"); let fingerprint = inspect @@ -20326,8 +20365,7 @@ fn agent_runtime_git_commit_commits_only_selected_paths_and_persists_safe_audit( #[test] fn agent_runtime_git_commit_audit_failure_requires_reconciliation_after_ref_moves() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "Git 提交审计失败项目") - .expect("project init"); + init_local_game_project_at(&root, "project-1", "Git 提交审计失败项目").expect("project init"); fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture"); let original_head = seed_agent_runtime_git_fixture(&root); fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture"); @@ -20341,12 +20379,7 @@ fn agent_runtime_git_commit_audit_failure_requires_reconciliation_after_ref_move vec!["保留可核对 commit SHA".to_string()], ) .expect("start runtime state"); - advance_project_revision_for_test( - &root, - "code-prototype", - &runtime.run_id, - "file.write", - ); + advance_project_revision_for_test(&root, "code-prototype", &runtime.run_id, "file.write"); persist_project_verification_for_test( &root, "code-prototype", @@ -20371,10 +20404,8 @@ fn agent_runtime_git_commit_audit_failure_requires_reconciliation_after_ref_move "expectedSnapshotFingerprint": fingerprint, }), }; - let action_fingerprint = - agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); - let action_id = - agent_runtime_tool_action_id(&runtime.run_id, 0, 0, 1, &action_fingerprint); + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(&runtime.run_id, 0, 0, 1, &action_fingerprint); let observation = observe_agent_runtime_project_git_commit_with_audit( &root, "code-prototype", @@ -20395,7 +20426,10 @@ fn agent_runtime_git_commit_audit_failure_requires_reconciliation_after_ref_move ); let safe_detail = agent_runtime_git_commit_safe_detail_value( &root, - observation.detail.as_deref().expect("reconciliation detail"), + observation + .detail + .as_deref() + .expect("reconciliation detail"), ) .expect("reconciliation detail remains receipt-safe"); assert_eq!(safe_detail["commitHead"], commit_head); @@ -20434,8 +20468,7 @@ fn agent_runtime_git_commit_audit_failure_requires_reconciliation_after_ref_move #[tokio::test] async fn agent_runtime_git_commit_requires_confirmation_by_default() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "Git 提交默认确认项目") - .expect("project init"); + init_local_game_project_at(&root, "project-1", "Git 提交默认确认项目").expect("project init"); fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture"); let original_head = seed_agent_runtime_git_fixture(&root); fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture"); @@ -20489,8 +20522,7 @@ async fn agent_runtime_git_commit_requires_confirmation_by_default() { #[tokio::test] async fn agent_runtime_git_commit_rejects_stale_pending_gate_before_moving_ref() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "Git 提交待确认漂移项目") - .expect("project init"); + init_local_game_project_at(&root, "project-1", "Git 提交待确认漂移项目").expect("project init"); fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture"); let original_head = seed_agent_runtime_git_fixture(&root); fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture"); @@ -20514,12 +20546,7 @@ async fn agent_runtime_git_commit_rejects_stale_pending_gate_before_moving_ref() &AgentRuntimeContextWindowTracker::default(), ) .expect("persist repository context"); - advance_project_revision_for_test( - &root, - "code-prototype", - &state.run_id, - "file.write", - ); + advance_project_revision_for_test(&root, "code-prototype", &state.run_id, "file.write"); persist_project_verification_for_test( &root, "code-prototype", @@ -20563,12 +20590,7 @@ async fn agent_runtime_git_commit_rejects_stale_pending_gate_before_moving_ref() "确认已审阅的本地提交", ) .expect("write git commit confirmation ticket"); - advance_project_revision_for_test( - &root, - "code-prototype", - &state.run_id, - "file.patch", - ); + advance_project_revision_for_test(&root, "code-prototype", &state.run_id, "file.patch"); let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, @@ -20595,8 +20617,7 @@ async fn agent_runtime_git_commit_rejects_stale_pending_gate_before_moving_ref() #[test] fn agent_runtime_git_commit_executing_recovery_never_replays_commit() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "Git 提交执行中恢复项目") - .expect("project init"); + init_local_game_project_at(&root, "project-1", "Git 提交执行中恢复项目").expect("project init"); fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture"); let original_head = seed_agent_runtime_git_fixture(&root); fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture"); @@ -20681,12 +20702,9 @@ fn agent_runtime_git_commit_executing_recovery_never_replays_commit() { record["recordType"] == "agent.runtime.tool_confirmation.needs_reconciliation" && record["actionId"] == pending.action_id })); - let persisted = read_game_creator_agent_runtime_pending_tool_action( - &root, - "code-prototype", - &state.run_id, - ) - .expect("executing pending action remains for reconciliation"); + let persisted = + read_game_creator_agent_runtime_pending_tool_action(&root, "code-prototype", &state.run_id) + .expect("executing pending action remains for reconciliation"); assert_eq!( persisted.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING @@ -32203,3 +32221,593 @@ fn process_session_agent_runtime_start_audit_failure_terminates_target_in_isolat "isolated start audit failure test failed: {status}" ); } + +fn start_agent_runtime_steer_fixture(root: &Path, run_id: &str) -> AgentRuntimeState { + init_local_game_project_at(root, "project-steer", "运行中追加指令测试项目") + .expect("initialize steer fixture"); + start_game_creator_agent_runtime_task_for_session_at( + root, + "code-prototype", + None, + "实现一个可验证的键盘操作原型", + run_id, + "agent-background-task", + "准备规划实现步骤", + vec!["读取项目".to_string(), "实现并验证".to_string()], + ) + .expect("start steer runtime") +} + +fn read_agent_runtime_steer_jsonl( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Vec { + fs::read_to_string(game_creator_agent_runtime_steer_ledger_path( + root, agent_id, run_id, + )) + .expect("read steer ledger") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("parse steer ledger line")) + .collect() +} + +#[test] +fn agent_runtime_steer_is_idempotent_rejects_conflicts_and_enforces_capacity() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "steer-contract-run"); + let first = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-1", + "先保留现有结构,但把移动改成键盘控制。", + "test", + ) + .expect("accept first steer"); + let duplicate = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-1", + "先保留现有结构,但把移动改成键盘控制。", + "test", + ) + .expect("retry same steer"); + assert_eq!(first.sequence, 1); + assert_eq!(duplicate.sequence, 1); + assert_eq!(duplicate.status, "queued"); + assert_eq!( + read_agent_runtime_steer_jsonl(&root, &state.agent_id, &state.run_id).len(), + 3, + "prepared/conversation-persisted/queued must remain exactly once" + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&state.agent_id), + Some(&state.session_id), + ) + .expect("read steer conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "user") + .count(), + 1 + ); + let conflict = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-1", + "换成完全不同的指令。", + "test", + ) + .expect_err("same steer id with different body must fail"); + assert!(conflict.contains("steer-id-conflict")); + + for index in 2..=AGENT_RUNTIME_STEER_MAX_RUN_COUNT { + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &format!("steer-{index}"), + &format!("第 {index} 条追加要求。"), + "test", + ) + .expect("accept steer within count limit"); + } + let overflow = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-overflow", + "超过数量上限的追加要求。", + "test", + ) + .expect_err("seventeenth steer must fail"); + assert!(overflow.contains("容量上限")); + assert!(steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + "wrong-session", + &state.run_id, + "wrong-session-steer", + "不应接受。", + "test", + ) + .is_err()); + assert!(steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + "wrong-run", + "wrong-run-steer", + "不应接受。", + "test", + ) + .is_err()); +} + +#[test] +fn agent_runtime_steer_sequences_are_unique_under_concurrent_acceptance() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "steer-concurrent-run"); + let root = Arc::new(root); + let state = Arc::new(state); + let mut workers = Vec::new(); + for index in 0..8 { + let root = root.clone(); + let state = state.clone(); + workers.push(std::thread::spawn(move || { + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &format!("steer-concurrent-{index}"), + &format!("并发追加指令 {index}"), + "test", + ) + .expect("accept concurrent steer") + .sequence + })); + } + let mut sequences = workers + .into_iter() + .map(|worker| worker.join().expect("join steer worker")) + .collect::>(); + sequences.sort_unstable(); + assert_eq!(sequences, (1..=8).collect::>()); +} + +#[test] +fn agent_runtime_steer_rejects_invalid_body_and_reconciliation_state() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "steer-invalid-run"); + assert!(steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-too-large", + &"x".repeat(AGENT_RUNTIME_STEER_MAX_INSTRUCTION_BYTES + 1), + "test", + ) + .expect_err("oversized steer must fail") + .contains("字节上限")); + assert!(steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-control", + "非法\u{0007}控制字符", + "test", + ) + .expect_err("control character steer must fail") + .contains("控制字符")); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "等待人工核对".to_string(); + write_game_creator_agent_runtime_state(&root, &state).expect("write reconciliation state"); + assert!(steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-reconciliation", + "不应越过待核对边界。", + "test", + ) + .expect_err("reconciliation run must reject steer") + .contains("不接受追加指令")); +} + +#[test] +fn agent_runtime_steer_blocks_stale_auto_action_and_defers_pending_confirmation() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "steer-action-gate-run"); + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("执行旧计划的第二步".to_string()), + input: serde_json::json!({ + "path": "game/stale-action.txt", + "content": "must-not-run" + }), + }; + let mut stale_auto = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + stale_auto.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-before-action-2", + "停止旧计划,不要执行第二个写入动作。", + "test", + ) + .expect("queue steer before second auto action"); + assert!( + !mark_game_creator_agent_runtime_auto_action_executing_if_current(&root, &mut stale_auto,) + .expect("check stale auto action gate") + ); + assert!(!root.join("game/stale-action.txt").exists()); + + let pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, + None, + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("persist confirmation pending before steer"); + state.status = "waiting-for-confirmation".to_string(); + state.phase = "waiting-for-confirmation".to_string(); + state.pending_tool_action = Some(pending.summary()); + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist waiting confirmation state"); + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-during-confirmation", + "待确认动作保持原样,之后再应用这条要求。", + "test", + ) + .expect("queue steer during confirmation"); + let restored = + read_game_creator_agent_runtime_pending_tool_action(&root, &state.agent_id, &state.run_id) + .expect("read pending confirmation after steer"); + assert_eq!(restored.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING); + assert_eq!(restored.action_id, pending.action_id); + assert_eq!(restored.action_fingerprint, pending.action_fingerprint); +} + +#[test] +fn agent_runtime_steer_context_cursor_repairs_missing_applied_transition() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "steer-recovery-run"); + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-recovery-1", + "改为先修复失败测试,再继续实现。", + "test", + ) + .expect("queue recovery steer"); + let plan = AgentRuntimeToolPlan::default(); + let tracker = AgentRuntimeContextWindowTracker::default(); + let mut observations = Vec::new(); + assert!(consume_game_creator_agent_runtime_steers( + &root, + &mut state, + "实现一个可验证的键盘操作原型", + &plan, + &mut observations, + 0, + &tracker, + ) + .expect("consume steer")); + assert_eq!(state.applied_steer_cursor, 1); + assert_eq!(state.applied_steer_refs.len(), 1); + assert!(render_game_creator_agent_runtime_steers_for_prompt( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + ) + .expect("render applied steers") + .contains("先修复失败测试")); + let context = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect("read context bundle") + .expect("context exists"); + assert_eq!(context.applied_steer_cursor, 1); + assert_eq!(context.applied_steer_refs, state.applied_steer_refs); + + let ledger_path = + game_creator_agent_runtime_steer_ledger_path(&root, &state.agent_id, &state.run_id); + let retained = fs::read_to_string(&ledger_path) + .expect("read ledger before simulated crash") + .lines() + .filter(|line| { + serde_json::from_str::(line) + .ok() + .and_then(|record| record["status"].as_str().map(str::to_string)) + .as_deref() + != Some("applied") + }) + .collect::>() + .join("\n"); + fs::write(&ledger_path, format!("{retained}\n")).expect("remove applied transition"); + assert!(!consume_game_creator_agent_runtime_steers( + &root, + &mut state, + "实现一个可验证的键盘操作原型", + &plan, + &mut observations, + 0, + &tracker, + ) + .expect("repair missing applied transition")); + let applied_count = read_agent_runtime_steer_jsonl(&root, &state.agent_id, &state.run_id) + .iter() + .filter(|record| record["status"] == "applied") + .count(); + assert_eq!(applied_count, 1); +} + +#[tokio::test] +async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "steer-provider-run"); + let (provider_started_tx, provider_started_rx) = tokio::sync::oneshot::channel(); + let wait_root = root.clone(); + let wait_agent = state.agent_id.clone(); + let wait_run = state.run_id.clone(); + let provider_wait = tokio::spawn(async move { + await_game_creator_agent_runtime_provider_request( + &wait_root, + &wait_agent, + &wait_run, + 0, + async move { + let _ = provider_started_tx.send(()); + std::future::pending::>().await + }, + ) + .await + }); + provider_started_rx.await.expect("provider future started"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-provider-1", + "停止等待旧方案,按新要求重新规划。", + "test", + ) + .expect("queue steer during provider wait"); + assert!(steer.provider_interrupted); + let outcome = tokio::time::timeout(Duration::from_secs(2), provider_wait) + .await + .expect("provider wait interrupted promptly") + .expect("provider wait task joined") + .expect("provider wait result"); + assert!(outcome.is_none()); +} + +#[tokio::test] +async fn background_agent_runtime_inflight_steer_discards_old_plan_without_new_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-steer-e2e", "Provider 中断追加指令测试") + .expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interruptible_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "steer-provider-key", + "baseUrl": {base_url:?}, + "model": "steer-provider-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "steer-inflight-background-run"; + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "先创建旧方案文件,再完成任务", + run_id, + ) + .expect("start background steer task"); + let first_request = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("first planning request in flight"); + assert!(!first_request.contains("不要创建旧方案文件")); + let running = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read running runtime") + .state; + assert_eq!(running.run_id, run_id); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + &running.session_id, + run_id, + "steer-inflight-e2e-1", + "不要创建旧方案文件,直接说明已按新要求收束。", + "test", + ) + .expect("steer in-flight provider"); + assert!(steer.provider_interrupted); + + let old_plan = serde_json::json!({ + "thinkingSummary": "旧计划准备写文件", + "plan": ["创建旧方案文件"], + "actions": [{ + "tool": "file.write", + "reason": "执行旧要求", + "input": {"path": "game/old-plan.txt", "content": "stale"} + }], + "response": "" + }) + .to_string(); + response_sender + .send(old_plan) + .expect("release discarded old provider response"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("second planning request after steer"); + assert!(second_request.contains("不要创建旧方案文件")); + response_sender + .send(final_tool_plan_response( + "已按新要求收束,没有执行旧写入计划。", + )) + .expect("complete replacement planning response"); + + let mut runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read steer runtime while waiting"); + for _ in 0..250 { + if runtime + .recent_tasks + .iter() + .any(|task| task.run_id == run_id && task.status == "completed") + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read steer runtime while waiting"); + } + assert!(runtime + .recent_tasks + .iter() + .any(|task| task.run_id == run_id && task.status == "completed")); + assert!(!root.join("game/old-plan.txt").exists()); + let task_records = fs::read_to_string(&runtime.task_path) + .expect("read full steer task ledger") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + let run_ids = task_records + .iter() + .map(|task| task.run_id.as_str()) + .collect::>(); + assert_eq!(run_ids, std::collections::BTreeSet::from([run_id])); + assert!(!task_records + .iter() + .any(|task| task.task.contains("不要创建旧方案文件"))); + let conversation = read_local_conversation_for_session_at( + &root, + Some("code-prototype"), + Some(&running.session_id), + ) + .expect("read completed steer conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 1 + ); + assert!(conversation + .messages + .iter() + .any(|message| message.content.contains("没有执行旧写入计划"))); +} + +#[test] +fn agent_runtime_finalization_rejects_pending_steer_then_closes_after_apply() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "steer-finalization-run"); + let plan = AgentRuntimeToolPlan::default(); + let tracker = AgentRuntimeContextWindowTracker::default(); + let mut observations = Vec::new(); + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-finalization-1", + "最终回复前补充这一条验收要求。", + "test", + ) + .expect("queue steer before finalization"); + let stale = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "旧回复不应落盘", + 0, + &observations, + ) + .expect("finalization stale result"); + assert!(matches!( + stale, + AgentBackgroundFinalizationOutcome::Stale(ref observation) + if observation.tool == "runtime.steer" + )); + assert!(consume_game_creator_agent_runtime_steers( + &root, + &mut state, + "实现一个可验证的键盘操作原型", + &plan, + &mut observations, + 0, + &tracker, + ) + .expect("apply final steer")); + let completed = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "已按补充要求完成。", + 0, + &observations, + ) + .expect("complete finalization after steer"); + assert!(matches!( + completed, + AgentBackgroundFinalizationOutcome::Completed(_) + )); + assert_eq!( + read_agent_runtime_steer_jsonl(&root, &state.agent_id, &state.run_id) + .last() + .and_then(|record| record["status"].as_str()), + Some("closed") + ); + let rejected = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-too-late", + "完成后不应再接受。", + "test", + ) + .expect_err("completed run rejects steer"); + assert!(rejected.contains("不接受追加指令") || rejected.contains("关闭")); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 49b6a4a19..cebcfa54e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -111,6 +111,7 @@ type LauncherView = | 'project-development'; type HomeAgentMode = 'game' | 'art' | 'doc'; type AgentChatInteractionMode = 'run' | 'chat'; +type AgentBackgroundSubmitMode = 'steer' | 'queue'; type AgentChatReplyPhase = | 'idle' | 'saving-user' @@ -375,6 +376,14 @@ interface AgentRuntimeResult { recentTasks?: AgentRuntimeTaskRecord[]; } +interface AgentRuntimeSteerResult { + runtime: AgentRuntimeResult; + steerId: string; + sequence: number; + status: string; + providerInterrupted: boolean; +} + interface GameCreatorAgentRuntimeUpdateEvent { projectPath: string; agentId: string; @@ -806,6 +815,66 @@ function isAgentRuntimeTerminalState(runtime: AgentRuntimeState) { ); } +function isAgentRuntimeSteerableState(runtime: AgentRuntimeState) { + if (isAgentRuntimeTerminalState(runtime)) { + return false; + } + const blockedStates = ['cancelling', 'finalizing', 'needs-reconciliation']; + if ( + blockedStates.includes(runtime.status) || + blockedStates.includes(runtime.phase) + ) { + return false; + } + return ( + ['running', 'waiting-for-confirmation'].includes(runtime.status) || + [ + 'planning', + 'llm', + 'action', + 'observation', + 'waiting-for-confirmation', + 'waiting-for-isolated-join', + 'response', + ].includes(runtime.phase) + ); +} + +function matchingAgentRuntimeForSteer( + runtimes: Array, + agentId: string, + sessionId: string | null, +) { + if (!sessionId) { + return null; + } + return ( + runtimes.find( + (runtime) => + runtime?.agentId === agentId && + runtime.sessionId === sessionId && + isAgentRuntimeSteerableState(runtime), + ) ?? null + ); +} + +function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) { + const runId = result.runtime.state.runId; + if (result.providerInterrupted) { + return `已中断 Provider,正在同一 Run 重新规划:${runId}`; + } + if (result.status === 'applied') { + return `追加指令已应用,当前 Run 正在继续:${runId}`; + } + return `追加指令已排队,等待当前 Run 应用:${runId}`; +} + +function agentRuntimeCancelStatus(runtime: AgentRuntimeState, runId: string) { + return runtime.status === 'cancelling' || runtime.phase === 'cancelling' + ? `正在取消后台任务:${runId}` + : `已取消后台任务:${runId}`; +} + function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { @@ -3124,6 +3193,8 @@ export function WorkspaceLauncher({ const [agentChatInput, setAgentChatInput] = useState(''); const [agentChatInteractionMode, setAgentChatInteractionMode] = useState('run'); + const [agentChatRunSubmitMode, setAgentChatRunSubmitMode] = + useState('steer'); const [agentChatReplyPhase, setAgentChatReplyPhase] = useState('idle'); const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent'); @@ -3165,6 +3236,10 @@ export function WorkspaceLauncher({ | null >(null); + useEffect(() => { + setAgentChatRunSubmitMode('steer'); + }, [agentChatSelectedAgentId, agentChatSelectedSessionId]); + function setAgentChatPendingRuntimeRun( pendingRun: AgentChatPendingRuntimeRun | null, ) { @@ -4086,6 +4161,7 @@ export function WorkspaceLauncher({ agentChatShouldFollowLatestRef.current = true; setAgentChatReplyPhase('idle'); setAgentChatPendingRuntimeRun(null); + setAgentChatRunSubmitMode('steer'); setAgentChatSessions([]); setAgentChatSelectedSessionId(null); setAgentChatActiveSessionId(null); @@ -4989,34 +5065,71 @@ export function WorkspaceLauncher({ const saveVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = saveVersion; agentChatShouldFollowLatestRef.current = true; - const requestedRunId = createAgentChatRunId('launcher-agent-task'); + const steerRuntime = + agentChatRunSubmitMode === 'steer' + ? matchingAgentRuntimeForSteer( + [agentChatRuntime, agentChatActiveRuntime], + agent.id, + sessionIdForTask, + ) + : null; + const requestedRunId = + steerRuntime?.runId ?? createAgentChatRunId('launcher-agent-task'); let pendingRunId = requestedRunId; + const previousPendingRun = agentChatPendingRuntimeRunRef.current; setAgentChatBackgroundBusy(true); - setAgentChatInput(''); - setAgentChatPendingRuntimeRun({ - projectPath: projectPathForChat, - agentId: agent.id, - sessionId: sessionIdForTask, - runId: requestedRunId, - messageCount: agentChatMessages.length, - }); - setAgentChatStatus('正在启动 Agent 后台任务'); + if (steerRuntime) { + setAgentChatStatus(`正在向当前 Run 追加指令:${steerRuntime.runId}`); + } else { + setAgentChatInput(''); + setAgentChatPendingRuntimeRun({ + projectPath: projectPathForChat, + agentId: agent.id, + sessionId: sessionIdForTask, + runId: requestedRunId, + messageCount: agentChatMessages.length, + }); + setAgentChatStatus('正在启动 Agent 后台任务'); + } try { - const runtime = await invoke( - 'start_game_creator_agent_runtime_task', - { - projectPath: projectPathForChat, - agentId: agent.id, - task: content, - runId: requestedRunId, - ...agentChatSessionInvokeArgs(sessionIdForTask), - }, - ); + let runtime: AgentRuntimeResult; + let successStatus: string; + if (steerRuntime) { + const steer = await invoke( + 'steer_game_creator_agent_runtime_task', + { + projectPath: projectPathForChat, + agentId: agent.id, + sessionId: steerRuntime.sessionId, + runId: steerRuntime.runId, + steerId: createAgentChatRunId('launcher-agent-steer'), + instruction: content, + }, + ); + runtime = steer.runtime; + successStatus = agentRuntimeSteerStatus(steer); + } else { + runtime = await invoke( + 'start_game_creator_agent_runtime_task', + { + projectPath: projectPathForChat, + agentId: agent.id, + task: content, + runId: requestedRunId, + ...agentChatSessionInvokeArgs(sessionIdForTask), + }, + ); + successStatus = agentRuntimeStartStatus(runtime); + } if (agentChatLoadVersionRef.current !== saveVersion) { return; } const runtimeState = agentRuntimeStateFromResult(runtime); - pendingRunId = agentRuntimeStartedRunId(runtime, requestedRunId); + pendingRunId = steerRuntime + ? runtimeState.runId + : agentRuntimeStartedRunId(runtime, requestedRunId); + setAgentChatInput(''); + setAgentChatRunSubmitMode('steer'); setAgentChatPendingRuntimeRun({ projectPath: projectPathForChat, agentId: agent.id, @@ -5027,36 +5140,49 @@ export function WorkspaceLauncher({ setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: projectPathForChat, - agentId: agent.id, - ...agentChatSessionInvokeArgs(sessionIdForTask), - }, - ); - if (agentChatLoadVersionRef.current !== saveVersion) { - return; + try { + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: projectPathForChat, + agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForTask), + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatMessages(conversation.messages); + if (agentChatPendingRuntimeRunRef.current?.runId === pendingRunId) { + setAgentChatPendingRuntimeRun({ + ...agentChatPendingRuntimeRunRef.current, + messageCount: conversation.messages.length, + }); + } + setAgentChatConversationPath(conversation.path); + updateAgentChatSessionMessageCount( + sessionIdForTask, + conversation.messages.length, + ); + } catch (error) { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatRuntimeError( + `对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } } - setAgentChatMessages(conversation.messages); - if (agentChatPendingRuntimeRunRef.current?.runId === pendingRunId) { - setAgentChatPendingRuntimeRun({ - ...agentChatPendingRuntimeRunRef.current, - messageCount: conversation.messages.length, - }); - } - setAgentChatConversationPath(conversation.path); - updateAgentChatSessionMessageCount( - sessionIdForTask, - conversation.messages.length, - ); - setAgentChatStatus(agentRuntimeStartStatus(runtime)); + setAgentChatStatus(successStatus); } catch (error) { if (agentChatLoadVersionRef.current !== saveVersion) { return; } - if (agentChatPendingRuntimeRunRef.current?.runId === pendingRunId) { - setAgentChatPendingRuntimeRun(null); + if ( + !steerRuntime && + agentChatPendingRuntimeRunRef.current?.runId === pendingRunId + ) { + setAgentChatPendingRuntimeRun(previousPendingRun); } setAgentChatInput(content); setAgentChatStatus( @@ -5111,7 +5237,7 @@ export function WorkspaceLauncher({ setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); - setAgentChatStatus(`已取消后台任务:${runId}`); + setAgentChatStatus(agentRuntimeCancelStatus(runtimeState, runId)); } catch (error) { if (agentChatLoadVersionRef.current !== saveVersion) { return; @@ -5445,6 +5571,13 @@ export function WorkspaceLauncher({ const currentAgentChatLlmWarning = getCurrentAgentChatLlmWarning( currentAgentChatAgent, ); + const currentAgentChatSteerRuntime = currentAgentChatAgent + ? matchingAgentRuntimeForSteer( + [agentChatRuntime, agentChatActiveRuntime], + currentAgentChatAgent.id, + agentChatSelectedSessionId, + ) + : null; const currentAgentChatWaiting = agentChatReplyPhase !== 'idle' || agentChatPendingRuntimeRun !== null; const currentAgentChatWaitingStatus = @@ -6341,6 +6474,23 @@ export function WorkspaceLauncher({ setAgentChatInput(event.currentTarget.value) } /> + {agentChatInteractionMode === 'run' && + currentAgentChatSteerRuntime ? ( + + ) : null} @@ -13889,6 +14044,11 @@ export function App() { >([]); const [agentConversationRuntime, setAgentConversationRuntime] = useState(null); + const [agentConversationSessionId, setAgentConversationSessionId] = useState< + string | null + >(null); + const [agentConversationRunSubmitMode, setAgentConversationRunSubmitMode] = + useState('steer'); const [agentConversationRuntimeError, setAgentConversationRuntimeError] = useState(''); const [agentConversationVisibleCount, setAgentConversationVisibleCount] = @@ -13932,6 +14092,8 @@ export function App() { const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); + const agentConversationSessionIdRef = useRef(null); + agentConversationSessionIdRef.current = agentConversationSessionId; const agentRuntimeResumeProjectPathRef = useRef(null); const agentRunHistoryLoadingMoreRef = useRef(false); const initialProjectOpenedRef = useRef(false); @@ -14020,6 +14182,13 @@ export function App() { if (payload.agentId !== selectedAgentIdRef.current) { return; } + if ( + agentConversationSessionIdRef.current !== null && + payload.runtime.state.sessionId !== + agentConversationSessionIdRef.current + ) { + return; + } setAgentConversationRuntime((current) => normalizeAgentRuntimeState(nextRuntime, current), ); @@ -14880,6 +15049,8 @@ export function App() { setAgentConversationInput(''); setAgentConversationMessages([]); setAgentConversationRuntime(null); + setAgentConversationSessionId(null); + setAgentConversationRunSubmitMode('steer'); setAgentConversationRuntimeError(''); setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentConversationStatus('未选择 agent'); @@ -14979,6 +15150,8 @@ export function App() { setAgentConversationInput(''); setAgentConversationMessages([]); setAgentConversationRuntime(null); + setAgentConversationSessionId(null); + setAgentConversationRunSubmitMode('steer'); setAgentConversationRuntimeError(''); setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentMemoryContent(''); @@ -15036,12 +15209,14 @@ export function App() { return; } setAgentConversationMessages(result.messages); + setAgentConversationSessionId(result.sessionId ?? null); try { const runtime = await invoke( 'read_game_creator_agent_runtime', { projectPath: nextProjectPath, agentId: agent.id, + ...(result.sessionId ? { sessionId: result.sessionId } : {}), }, ); if (agentConversationLoadVersionRef.current === loadVersion) { @@ -15130,6 +15305,8 @@ export function App() { setAgentConversationInput(''); setAgentConversationMessages([]); setAgentConversationRuntime(null); + setAgentConversationSessionId(null); + setAgentConversationRunSubmitMode('steer'); setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentConversationStatus('未选择 agent'); setAgentConversationSaving(false); @@ -15469,6 +15646,7 @@ export function App() { agent: AgentStatusCard, content: string, skipPolicyConfirm = false, + submitMode = agentConversationRunSubmitMode, ) { if (!agent || !content || agentConversationBackgroundBusyRef.current) { return; @@ -15488,6 +15666,14 @@ export function App() { setAgentConversationStatus(llmWarning); return; } + const steerRuntime = + submitMode === 'steer' + ? matchingAgentRuntimeForSteer( + [agentConversationRuntime], + agent.id, + agentConversationSessionId, + ) + : null; const saveVersion = agentConversationLoadVersionRef.current; try { if ( @@ -15496,9 +15682,19 @@ export function App() { invoke, 'conversation.write', nextProjectPath, - `启动 ${agent.title} 后台任务`, - '准备启动 Agent 后台任务。', - () => void startSelectedAgentBackgroundTask(agent, content, true), + steerRuntime + ? `向 ${agent.title} 当前 Run 追加指令` + : `启动 ${agent.title} 后台任务`, + steerRuntime + ? '准备向当前 Agent Run 追加指令。' + : '准备启动 Agent 后台任务。', + () => + void startSelectedAgentBackgroundTask( + agent, + content, + true, + submitMode, + ), )) ) { setAgentConversationStatus('等待确认'); @@ -15512,38 +15708,86 @@ export function App() { } agentConversationBackgroundBusyRef.current = true; setAgentConversationBackgroundBusy(true); - setAgentConversationInput(''); - setAgentConversationStatus('正在启动 Agent 后台任务'); - try { - const runtime = await invoke( - 'start_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - task: content, - runId: createAgentChatRunId('agent-background-task'), - }, + if (steerRuntime) { + setAgentConversationStatus( + `正在向当前 Run 追加指令:${steerRuntime.runId}`, ); + } else { + setAgentConversationInput(''); + setAgentConversationStatus('正在启动 Agent 后台任务'); + } + try { + let runtime: AgentRuntimeResult; + let successStatus: string; + if (steerRuntime) { + const steer = await invoke( + 'steer_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: agent.id, + sessionId: steerRuntime.sessionId, + runId: steerRuntime.runId, + steerId: createAgentChatRunId('agent-steer'), + instruction: content, + }, + ); + runtime = steer.runtime; + successStatus = agentRuntimeSteerStatus(steer); + } else { + runtime = await invoke( + 'start_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: agent.id, + task: content, + runId: createAgentChatRunId('agent-background-task'), + ...(agentConversationSessionId + ? { sessionId: agentConversationSessionId } + : {}), + }, + ); + successStatus = agentRuntimeStartStatus(runtime); + } if (agentConversationLoadVersionRef.current !== saveVersion) { return; } const nextRuntime = agentRuntimeStateFromResult(runtime); + setAgentConversationInput(''); + setAgentConversationRunSubmitMode('steer'); + setAgentConversationSessionId(nextRuntime.sessionId); setAgentConversationRuntime(nextRuntime); rememberAgentRuntimeState(nextRuntime); setAgentConversationRuntimeError(''); - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; + try { + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: nextProjectPath, + agentId: agent.id, + sessionId: nextRuntime.sessionId, + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationMessages(conversation.messages); + setAgentConversationSessionId( + conversation.sessionId ?? nextRuntime.sessionId, + ); + } catch (error) { + if (agentConversationLoadVersionRef.current === saveVersion) { + setAgentConversationRuntimeError( + `对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } } - setAgentConversationMessages(conversation.messages); - setAgentConversationStatus(agentRuntimeStartStatus(runtime)); - setCommandLog((current) => [...current, 'agent.runtime.background_task']); + setAgentConversationStatus(successStatus); + setCommandLog((current) => [ + ...current, + steerRuntime ? 'agent.runtime.steer' : 'agent.runtime.background_task', + ]); } catch (error) { if (agentConversationLoadVersionRef.current !== saveVersion) { return; @@ -15595,7 +15839,7 @@ export function App() { setAgentConversationRuntime(nextRuntime); rememberAgentRuntimeState(nextRuntime); setAgentConversationRuntimeError(''); - setAgentConversationStatus(`已取消后台任务:${runId}`); + setAgentConversationStatus(agentRuntimeCancelStatus(nextRuntime, runId)); setCommandLog((current) => [ ...current, 'agent.runtime.background_task.cancel', @@ -22429,6 +22673,13 @@ export function App() { const selectedAgentLlmWarning = selectedAgent ? formatAgentLlmConfigWarning(llmConfigStatus, selectedAgent) : null; + const selectedAgentSteerRuntime = selectedAgent + ? matchingAgentRuntimeForSteer( + [agentConversationRuntime], + selectedAgent.id, + agentConversationSessionId, + ) + : null; function showEarlierConversationMessages() { setConversationVisibleCount((current) => @@ -23531,6 +23782,21 @@ export function App() { > 发送 + {selectedAgentSteerRuntime ? ( + + ) : null}