Merge remote-tracking branch 'origin/master' into fix/multi-select
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 3m18s

This commit is contained in:
2026-08-03 15:58:47 +08:00
100 changed files with 10602 additions and 1113 deletions
@@ -11,7 +11,7 @@
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 0,
"maxRetries": 2,
"retryBackoffMs": 500
},
"agentLlm": {},
+2 -2
View File
@@ -4,8 +4,8 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "npm --prefix ../.. exec tauri -- dev",
"game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat",
"dev": "node scripts/start-tauri-dev.mjs",
"game-chat": "node scripts/start-tauri-dev.mjs --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
@@ -452,10 +452,7 @@ async function runConfigWizardRegressionChecks() {
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
assert.equal(
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
path.join(
fs.realpathSync.native(realConfigAncestor),
'missing-appdata',
),
path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'),
);
const gitRoot = path.join(testRoot, 'tracked-repository');
@@ -1260,6 +1257,21 @@ if (
);
}
if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
throw new Error(
'AI game creator shell dev must run through the managed Tauri dev launcher',
);
}
if (
packageConfig.scripts?.['game-chat'] !==
'node scripts/start-tauri-dev.mjs --game-chat'
) {
throw new Error(
'AI game creator shell game-chat must run through the managed Tauri dev launcher',
);
}
const gameChatInitialUrlApply =
'apply_game_chat_initial_window_url(tauri_context.config_mut(), options)';
const gameChatInitialUrlApplyIndexes = Array.from(
@@ -120,7 +120,7 @@ export function deterministicLaneDefenseInitialHtml() {
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}}
</style>
</head>
<body><main>
<body><main><img src="assets/art-spritesheet.png" alt="Garden defenders" width="96" height="96">
<h1>灵露花园</h1><p>GENARRATIVE_REAL_E2E_VISIBLE</p><p>Goal: defend the garden and win every wave.</p>
<div class="toolbar"><button data-playtest-id="start">Start Game</button><button data-playtest-id="speed-up">Speed Up</button><button data-playtest-id="next-level">Next Level</button><button data-playtest-id="restart">Restart</button></div>
<div class="plants"><button data-playtest-id="defender-option">露华花</button><button id="thorn">棘刺芽</button></div>
@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -134,6 +135,21 @@ async function readExistingViteServer() {
return httpGetText(viteUrl);
}
function isVitePortListening() {
return new Promise((resolveRequest) => {
const socket = net.connect({ host: viteHost, port: vitePort });
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
});
socket.once('error', () => resolveRequest(false));
socket.setTimeout(1000, () => {
socket.destroy();
resolveRequest(true);
});
});
}
function isAiGameCreatorServer(response) {
return (
response &&
@@ -144,17 +160,6 @@ function isAiGameCreatorServer(response) {
);
}
async function isExistingViteProxyReady() {
const response = await httpGetText(`${viteUrl}api/auth/me`, 2000);
return Boolean(
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
!response.body.includes('<title>AI 游戏创作</title>') &&
!response.body.includes('/src/main.tsx'),
);
}
async function readExistingViteMarker() {
const response = await httpGetText(viteMarkerUrl, 2000);
if (!response || response.statusCode !== 200) {
@@ -167,24 +172,49 @@ async function readExistingViteMarker() {
}
}
async function isExistingVitePairedWithBackend(apiTarget) {
const marker = await readExistingViteMarker();
return Boolean(
marker &&
marker.schemaVersion === 1 &&
marker.app === 'ai-game-creator-shell' &&
marker.apiTarget === apiTarget,
async function preflightExistingVite({
readServer = readExistingViteServer,
portListening = isVitePortListening,
readMarker = readExistingViteMarker,
} = {}) {
const existing = await readServer();
if (!existing) {
if (await portListening()) {
throw new Error(
`${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
}
if (!isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
const marker = await readMarker();
const markerApiTarget =
marker?.schemaVersion === 1 &&
marker?.app === 'ai-game-creator-shell' &&
typeof marker?.apiTarget === 'string'
? marker.apiTarget
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
`${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
);
}
function spawnChild(command, args, options, spawnImpl = spawn) {
const useShell = process.platform === 'win32';
const isPosix = process.platform !== 'win32';
const useShell = options.shell ?? !isPosix;
const child = spawnImpl(command, args, {
...options,
shell: useShell,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: !useShell,
detached: isPosix,
stdio: 'inherit',
});
const lifecycle = {
@@ -192,7 +222,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
promise: null,
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null,
processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null,
};
lifecycle.promise = new Promise((resolveLifecycle) => {
child.once('error', (error) => {
@@ -270,6 +300,142 @@ function stopChild(child, signal = 'SIGTERM') {
}
}
function isProcessGroupAlive(processGroupId, killImpl = process.kill) {
if (!Number.isInteger(processGroupId)) {
return false;
}
try {
killImpl(-processGroupId, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return true;
}
await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs));
}
return check();
}
function runWindowsTaskkill(
processId,
{ spawnImpl = spawn, timeoutMs = 5000 } = {},
) {
return new Promise((resolveRequest) => {
const taskkill = spawnImpl(
'taskkill.exe',
['/PID', String(processId), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true,
},
);
let settled = false;
const timeout = setTimeout(() => {
try {
taskkill.kill('SIGKILL');
} catch {
// ignore taskkill timeout races
}
finish({ timedOut: true, code: null, error: null });
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolveRequest(result);
};
taskkill.once('error', (error) =>
finish({ timedOut: false, code: null, error }),
);
taskkill.once('exit', (code) =>
finish({ timedOut: false, code: code ?? 0, error: null }),
);
});
}
async function terminateChildTree(
child,
{
platform = process.platform,
gracefulTimeoutMs = 2500,
forceTimeoutMs = 2000,
killImpl = process.kill,
taskkillImpl = runWindowsTaskkill,
} = {},
) {
if (!child) {
return { stopped: true, forced: false };
}
if (platform === 'win32') {
if (!Number.isInteger(child.pid)) {
stopChild(child, 'SIGTERM');
return { stopped: true, forced: false };
}
const result = await taskkillImpl(child.pid);
return {
stopped:
!result?.timedOut &&
!result?.error &&
[0, 128].includes(result?.code ?? 0),
forced: true,
result,
};
}
const processGroupId = childLifecycles.get(child)?.processGroupId;
if (!Number.isInteger(processGroupId)) {
stopChild(child, 'SIGTERM');
const lifecycle = childLifecycles.get(child);
if (lifecycle) {
await Promise.race([
lifecycle.promise,
new Promise((resolveWait) =>
setTimeout(resolveWait, gracefulTimeoutMs),
),
]);
}
if (child.exitCode == null && child.signalCode == null) {
stopChild(child, 'SIGKILL');
return { stopped: false, forced: true };
}
return { stopped: true, forced: false };
}
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
gracefulTimeoutMs,
)
) {
return { stopped: true, forced: false };
}
try {
killImpl(-processGroupId, 'SIGKILL');
} catch (error) {
if (error?.code !== 'ESRCH') {
return { stopped: false, forced: true, error };
}
}
const stopped = await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
forceTimeoutMs,
);
return { stopped, forced: true };
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
@@ -340,19 +506,9 @@ async function startVite(apiTarget) {
const existing = await readExistingViteServer();
if (existing) {
if (
isAiGameCreatorServer(existing) &&
(await isExistingVitePairedWithBackend(apiTarget)) &&
(await isExistingViteProxyReady())
) {
console.log(
`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`,
);
return null;
}
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`,
`${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
);
}
throw new Error(
@@ -384,6 +540,7 @@ async function main() {
}
try {
await preflightExistingVite();
const backend = await ensureBackend({
onBackendChild(child) {
backendChild = child;
@@ -422,6 +579,10 @@ async function main() {
);
return 1;
} finally {
await Promise.all([
terminateChildTree(viteChild),
terminateChildTree(backendChild),
]);
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
@@ -439,10 +600,13 @@ export {
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
preflightExistingVite,
readChildFailure,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
waitForBackendReady,
waitForChildTermination,
};
@@ -0,0 +1,128 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
preflightExistingVite,
spawnChild,
stopChild,
terminateChildTree,
waitForChildTermination,
} from './start-dev-stack.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
function parseLauncherArguments(argv) {
const args = [...argv];
const gameChat = args[0] === '--game-chat';
if (gameChat) {
args.shift();
}
return { gameChat, args };
}
function buildTauriArguments(argv) {
const { gameChat, args } = parseLauncherArguments(argv);
if (gameChat) {
return ['dev', '--', '--', '--game-chat', ...args];
}
return ['dev', ...args];
}
function spawnTauriCli(argv) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
shell: false,
});
}
async function runTauriDev(
argv = process.argv.slice(2),
{
preflight = preflightExistingVite,
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
} = {},
) {
await preflight();
const tauriArguments = buildTauriArguments(argv);
const child = spawnCli(tauriArguments);
let resolveShutdown;
let shutdownSignal = '';
let repeatedSignal = false;
const shutdownRequested = new Promise((resolveRequest) => {
resolveShutdown = resolveRequest;
});
const signalHandlers = new Map();
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
if (!shutdownSignal) {
shutdownSignal = signal;
stopChild(child, 'SIGTERM');
resolveShutdown(signal);
return;
}
repeatedSignal = true;
stopChild(child, 'SIGKILL');
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const childResult = waitForCli(child);
const outcome = await Promise.race([
childResult.then((failure) => ({ type: 'exit', failure })),
shutdownRequested.then((signal) => ({ type: 'signal', signal })),
]);
const cleanup = await terminateTree(child, {
gracefulTimeoutMs: repeatedSignal ? 0 : 2500,
});
if (!cleanup.stopped) {
console.error(
'[ai-game-creator-shell] Tauri dev exited, but its process tree could not be fully stopped.',
);
return 1;
}
if (outcome.type === 'signal') {
return 1;
}
const { failure } = outcome;
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} finally {
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
}
}
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
export {
buildTauriArguments,
isDirectModuleExecution,
parseLauncherArguments,
runTauriDev,
spawnTauriCli,
};
if (isDirectModuleExecution()) {
try {
process.exitCode = await runTauriDev();
} catch (error) {
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}
@@ -367,10 +367,26 @@ pub(crate) fn has_game_creator_agent_llm_override(
config: &GameCreatorAppConfig,
agent_id: &str,
) -> bool {
config
.agent_llm
.get(agent_id)
.is_some_and(|patch| !is_empty_game_creator_llm_patch(patch))
config.agent_llm.get(agent_id).is_some_and(|patch| {
if is_empty_game_creator_llm_patch(patch) {
return false;
}
let only_canonical_reasoning_default = patch.api_key.is_none()
&& patch.base_url.is_none()
&& patch.model.is_none()
&& patch.api_kind.is_none()
&& patch.stream.is_none()
&& patch.web_search_enabled.is_none()
&& patch.context_window_tokens.is_none()
&& patch.auto_compact_token_limit.is_none()
&& patch.tool_output_token_limit.is_none()
&& patch.request_timeout_ms.is_none()
&& patch.max_retries.is_none()
&& patch.retry_backoff_ms.is_none()
&& patch.reasoning_effort.as_deref()
== game_creator_llm_agent_default_reasoning_effort(agent_id);
!only_canonical_reasoning_default
})
}
pub(crate) async fn request_agent_role_brief_with_config(
@@ -283,7 +283,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
&action_fingerprint,
pending_action,
false,
|| observe_agent_runtime_task_list(root),
|| observe_agent_runtime_task_list(root, agent_id, run_id),
),
"task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input),
"task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input),
@@ -414,7 +414,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
action_id,
&action.input,
),
"agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input),
"agent.schedule_ready" => {
observe_agent_runtime_schedule_ready_tasks(root, agent_id, run_id, &action.input)
}
"agent.action_history" => observe_agent_runtime_project_snapshot_with_lock(
root,
agent_id,
@@ -262,11 +262,15 @@ fn autonomous_initial_delegate_expected_artifacts(
pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_contract(
plan: &AgentRuntimeToolPlan,
) -> Result<(), String> {
let mut code_prototype = None;
let mut quality_review = None;
let mut design_director = None;
let mut art_director = None;
let mut art_asset_plan = None;
let mut code_director = None;
for action in &plan.actions {
if action.tool.trim() == "agent.spawn_isolated" {
return Err(autonomous_initial_collaboration_contract_error(
"首批只允许激活 design-director、art-director、code-director,不得启动 isolated child",
));
}
let Some(input) = autonomous_initial_delegate_input(action)? else {
continue;
};
@@ -284,11 +288,14 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
));
};
let slot = match target_agent_id {
"code-prototype" => &mut code_prototype,
AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review,
"design-director" => &mut design_director,
"art-director" => &mut art_director,
"art-asset-plan" => &mut art_asset_plan,
_ => continue,
"code-director" => &mut code_director,
_ => {
return Err(autonomous_initial_collaboration_contract_error(format!(
"首批只允许激活 design-director、art-director、code-director,不得委派底层 Agent{target_agent_id}"
)));
}
};
if slot.replace(input).is_some() {
return Err(autonomous_initial_collaboration_contract_error(format!(
@@ -297,111 +304,80 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
}
}
let code_prototype = code_prototype.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-prototype 委派")
let design_director = design_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 design-director 委派")
})?;
let code_task = autonomous_initial_delegate_task(code_prototype, "code-prototype")?;
let code_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(code_prototype.clone()),
let design_task = autonomous_initial_delegate_task(design_director, "design-director")?;
let design_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(design_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(code_task.as_str())
if !std::iter::once(design_task.as_str())
.chain(design_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 design-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目",
));
}
let design_artifacts =
autonomous_initial_delegate_expected_artifacts(design_director, "design-director")?;
if !design_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(
"首批 design-director 的 expectedArtifacts 必须为 []",
));
}
let art_director = art_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 art-director 委派")
})?;
let art_task = autonomous_initial_delegate_task(art_director, "art-director")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
let code_director = code_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
})?;
let code_task = autonomous_initial_delegate_task(code_director, "code-director")?;
let code_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(code_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if !std::iter::once(code_task.as_str())
.chain(code_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 code-prototype 必须是非只读实现任务",
"首批 code-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目",
));
}
let code_artifacts =
autonomous_initial_delegate_expected_artifacts(code_prototype, "code-prototype")?;
if !code_artifacts
.iter()
.any(|path| path == AGENT_RUNTIME_GAME_INDEX_PATH)
{
return Err(autonomous_initial_collaboration_contract_error(format!(
"首批 code-prototype 的 expectedArtifacts 必须包含 {AGENT_RUNTIME_GAME_INDEX_PATH}"
)));
}
let quality_review = quality_review.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 quality-review 委派")
})?;
let quality_task =
autonomous_initial_delegate_task(quality_review, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID)?;
let quality_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(quality_review.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if !std::iter::once(quality_task.as_str())
.chain(quality_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
autonomous_initial_delegate_expected_artifacts(code_director, "code-director")?;
if !code_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(
"首批 quality-review task 或 acceptanceCriteria 必须显式声明只读且不得修改项目",
"首批 code-director 的 expectedArtifacts 必须为 []",
));
}
let quality_artifacts = autonomous_initial_delegate_expected_artifacts(
quality_review,
AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID,
)?;
if !quality_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(
"首批 quality-review 的 expectedArtifacts 必须为 []",
));
}
if let Some(art_director) = art_director {
let art_task = autonomous_initial_delegate_task(art_director, "art-director")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
}
if let Some(art_asset_plan) = art_asset_plan {
let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_asset_plan.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-asset-plan 必须是非只读美术生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?;
let missing_artifacts = ["assets/manifest.art.json", "assets/art-spritesheet.png"]
.into_iter()
.filter(|required| !art_artifacts.iter().any(|path| path == required))
.collect::<Vec<_>>();
if !missing_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(format!(
"首批 art-asset-plan 的 expectedArtifacts 缺少:{}",
missing_artifacts.join(", ")
)));
}
}
Ok(())
}
@@ -560,7 +536,34 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
root: &Path,
) -> Result<bool, String> {
let manifest = read_manifest_for_project(root)?;
let seed_task_ids = new_game_creation_app_seed_tasks()
let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.ok()
.filter(|runtime| {
runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_runtime_supervisor_source_is_trusted(&runtime.state.source)
})
.map(|runtime| runtime.state.source)
.or_else(|| {
let path = game_creator_agent_runtime_task_path(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
);
read_all_game_creator_agent_runtime_tasks(&path)
.ok()
.map(latest_game_creator_agent_runtime_tasks)
.and_then(|records| {
records.into_iter().rev().find_map(|record| {
(record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& record.parent_run_id.is_none()
&& agent_runtime_supervisor_source_is_trusted(&record.source))
.then_some(record.source)
})
})
})
.ok_or_else(|| {
"无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string()
})?;
let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source)
.into_iter()
.map(|task| task.id)
.collect::<BTreeSet<_>>();
@@ -1479,6 +1482,24 @@ pub(in crate::agent) fn restrict_agent_runtime_supervisor_collaboration_repair_t
Ok(())
}
pub(in crate::agent) fn restrict_agent_runtime_autonomous_initial_collaboration_repair_tools(
request: &mut LlmRunRequest,
) -> Result<(), String> {
let delegate_function = native_runtime_function_name("agent.delegate")
.ok_or_else(|| "无法生成 autonomous 首批协作修复工具名:agent.delegate".to_string())?;
request
.function_tools
.retain(|tool| tool.name == delegate_function);
if !request
.function_tools
.iter()
.any(|tool| tool.name == delegate_function)
{
return Err("autonomous 首批协作修复工具目录缺少 agent.delegate".to_string());
}
Ok(())
}
pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collaboration_repair(
error: &str,
) -> bool {
@@ -1495,6 +1516,93 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo
mod tests {
use super::*;
fn autonomous_initial_delegate(
agent_id: &str,
expected_artifacts: &[&str],
) -> AgentRuntimeToolAction {
let read_only_planner = matches!(agent_id, "design-director" | "code-director");
AgentRuntimeToolAction {
tool: "agent.delegate".to_string(),
reason: Some("建立首批 Leader 规划".to_string()),
input: serde_json::json!({
"agentId": agent_id,
"task": if read_only_planner {
format!("{agent_id} 只读完成首轮专业规划,不得修改项目")
} else {
format!("{agent_id} 完成首轮专业交付")
},
"acceptanceCriteria": if read_only_planner {
vec!["只读给出可供后续底层 Agent 按需执行的规划,不得修改项目"]
} else {
vec!["视觉规范可供后续底层 Agent 按需执行"]
},
"expectedArtifacts": expected_artifacts,
"repairOfDelegationId": null,
"runId": null,
}),
}
}
fn autonomous_initial_leader_plan() -> AgentRuntimeToolPlan {
AgentRuntimeToolPlan {
thinking_summary: "首批只激活程策美 Leader".to_string(),
plan_update: None,
plan: Vec::new(),
actions: vec![
autonomous_initial_delegate("design-director", &[]),
autonomous_initial_delegate("art-director", &["assets/art-spec.png"]),
autonomous_initial_delegate("code-director", &[]),
],
response: String::new(),
}
}
#[test]
fn autonomous_initial_collaboration_accepts_only_three_leaders() {
validate_agent_runtime_autonomous_initial_collaboration_contract(
&autonomous_initial_leader_plan(),
)
.expect("three leader initial contract");
}
#[test]
fn autonomous_initial_collaboration_rejects_bottom_agent() {
let mut plan = autonomous_initial_leader_plan();
plan.actions[2] =
autonomous_initial_delegate("code-prototype", &[AGENT_RUNTIME_GAME_INDEX_PATH]);
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("bottom agent must be rejected");
assert!(error.contains("不得委派底层 Agentcode-prototype"));
}
#[test]
fn autonomous_initial_collaboration_rejects_isolated_child() {
let mut plan = autonomous_initial_leader_plan();
plan.actions.push(AgentRuntimeToolAction {
tool: "agent.spawn_isolated".to_string(),
reason: None,
input: serde_json::json!({"children": [], "joinMode": "all"}),
});
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("isolated child must be rejected");
assert!(error.contains("不得启动 isolated child"));
}
#[test]
fn autonomous_initial_collaboration_requires_leader_artifacts() {
let mut plan = autonomous_initial_leader_plan();
plan.actions[1] = autonomous_initial_delegate("art-director", &[]);
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("art artifact must be required");
assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png"));
}
#[test]
fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() {
let temporary = tempfile::tempdir().expect("create manifest DAG policy root");
@@ -115,7 +115,7 @@ pub(in crate::agent) fn execute_game_creator_agent_runtime_parallel_safe_read_at
"git.inspect" => observe_agent_runtime_git_inspect(root, &action.input),
"file.list" => observe_agent_runtime_file_list(root, &action.input),
"file.read" => observe_agent_runtime_file(root, &action.input),
"task.list" => observe_agent_runtime_task_list(root),
"task.list" => observe_agent_runtime_task_list(root, agent_id, run_id),
_ => AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "rejected".to_string(),
@@ -655,6 +655,20 @@ pub(in crate::agent) fn supervisor_collaboration_policy_completion_blocker_at_lo
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return None;
}
if read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)
.ok()
.flatten()
.is_some_and(|binding| {
binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
&& binding.root_agent_id == binding.agent_id
&& binding.root_run_id == binding.run_id
})
{
// game-chat 首版由 source-aware manifest scheduler 固定编排
// code -> static smoke -> playtest,不再要求 Provider 建立额外委派波。
return None;
}
let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) {
Ok(resolution) => resolution.policy,
Err(error) => {
@@ -2,6 +2,43 @@ use super::*;
const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。";
const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。已登记的平台视觉规范图 ../assets/art-spec.png 是首版必需资源,必须在主要游戏画面中显著可见使用:至少把规范图实际绘制为主要背景,并从规范图中绘制玩家角色和目标实体。禁止仅放置隐藏 img、透明或屏外元素、微小水印、不可见预加载或只在源码中引用;也禁止用纯几何图形冒充平台图片使用。若规范图无法加载,游戏必须明确失败关闭,不能退回纯 Canvas 几何兜底。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。";
fn game_chat_fast_path_prompt_for_root_source(
agent_id: &str,
root_source: &str,
) -> Option<&'static str> {
(agent_id.trim() == "code-prototype"
&& root_source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE)
.then_some(GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT)
}
pub(in crate::agent) fn agent_runtime_root_source_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<String, String> {
let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?
.ok_or_else(|| "Agent Runtime 缺少 Run Profile 绑定,无法解析 root source".to_string())?;
if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id {
return Ok(binding.source);
}
let root_binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&binding.root_agent_id,
&binding.root_run_id,
)?
.ok_or_else(|| "Agent Runtime 缺少 root Run Profile 绑定".to_string())?;
if root_binding.agent_id != binding.root_agent_id
|| root_binding.run_id != binding.root_run_id
|| root_binding.root_agent_id != root_binding.agent_id
|| root_binding.root_run_id != root_binding.run_id
{
return Err("Agent Runtime root Run Profile 绑定身份不一致".to_string());
}
Ok(root_binding.source)
}
fn game_creator_agent_context_preload_notice(agent_id: &str) -> &'static str {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
"下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取"
@@ -126,6 +163,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
system_prompt.push_str(
"\n\n当前 Run Profile 为 autonomous-game-build。不得调用 user.input_request,也不得为了等待确认而中断;对不改变核心目标的缺失细节,直接采用可逆、保守且可试玩的默认值。只使用当前 autoTools 推进项目内实现、委派和验证,不得请求 project.git_commit、command.exec、command.start、command.stdin、command.terminate 或其他仍需确认的动作。Project Supervisor 必须持续编排到最小可玩闭环通过 Runtime 完成门禁;专业 Agent 必须完成自己的合同并把结果交回父 Run。",
);
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
system_prompt.push_str(
"\n\nautonomous-game-build 的正式 manifest 任务图是唯一首轮专业执行链。不得在 manifest 之前另行创建 code-prototype、quality-review、art-director、design-foundation 或 art-asset-plan 的首批 agent.delegate;这些角色会由 Runtime 按 manifest 依赖顺序调度。没有待认领的显式返工合同时也不得额外委派。请直接推进/观察 manifest,Runtime 会在你尝试收束时调度 ready task,并在任务图完成前阻止最终交付。",
);
}
system_prompt.push_str(&format!(
"\n\n自主构建专业 Agent 在首次项目修改前最多允许 {AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT} 轮 planning 探索。达到上限后,本响应必须直接调用 file.write、file.patch、file.delete、project.patchset、project.restore、canvas.asset_generate 等实际项目修改工具;若当前专业合同确实只要求只读验收,则必须调用 respond_to_user 交付结论。不得继续只调用 update_agent_plan、读取、搜索、状态查询或空验证。"
));
@@ -137,6 +179,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
system_prompt.push_str(playtest_contract);
system_prompt.push_str(" 只有当前 revision 通过 game.static_smoke,并由 preview.validate 对上述固定状态面和控件完成真实浏览器动作后,Runtime 才允许最终回复;不要伪造已通过 observation。");
}
if autonomous_game_build {
let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?;
if let Some(fast_path_prompt) =
game_chat_fast_path_prompt_for_root_source(agent_id, &root_source)
{
system_prompt.push_str("\n\n");
system_prompt.push_str(fast_path_prompt);
}
}
let mut request = LlmRunRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(prompt),
@@ -344,10 +395,13 @@ pub(in crate::agent) fn build_game_creator_background_agent_context(
#[cfg(test)]
mod tests {
use super::{
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
build_game_creator_agent_background_tool_plan_request,
game_creator_agent_context_preload_notice, init_local_game_project_at,
start_game_creator_agent_runtime_task_at, GameCreatorMcpCatalog,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
game_chat_fast_path_prompt_for_root_source, game_creator_agent_context_preload_notice,
init_local_game_project_at, start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink,
GameCreatorMcpCatalog, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
};
@@ -451,4 +505,87 @@ mod tests {
assert!(protocol.contains("不得反复提交 final response"));
assert!(protocol.contains("不得按项目正文硬编码"));
}
#[test]
fn game_chat_fast_path_prompt_forces_one_shot_playable_write_only_for_code_agent() {
let prompt = game_chat_fast_path_prompt_for_root_source(
"code-prototype",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
)
.expect("game-chat code fast path prompt");
assert!(prompt.contains("五分钟快车道"));
assert!(prompt.contains("一次 Provider planning"));
assert!(prompt.contains("直接调用一次 file.write"));
assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派"));
assert!(prompt.contains("../assets/art-spec.png"));
assert!(prompt.contains("平台视觉规范图"));
assert!(prompt.contains("主要背景"));
assert!(prompt.contains("玩家角色和目标实体"));
assert!(prompt.contains("禁止仅放置隐藏 img"));
assert!(prompt.contains("不能退回纯 Canvas 几何兜底"));
assert!(!prompt.contains("art-spritesheet.png"));
assert!(game_chat_fast_path_prompt_for_root_source(
"quality-review",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
)
.is_none());
assert!(game_chat_fast_path_prompt_for_root_source(
"code-prototype",
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
)
.is_none());
assert!(game_chat_fast_path_prompt_for_root_source(
"code-prototype",
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
)
.is_none());
assert!(game_chat_fast_path_prompt_for_root_source(
"code-prototype",
"agent-background-task",
)
.is_none());
}
#[test]
fn root_source_resolver_uses_root_binding_for_game_chat_child() {
let temporary = tempfile::tempdir().expect("temporary project root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "root-source-project", "root source test")
.expect("project init");
let parent = bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"root-source-game-chat-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind game-chat root profile");
let child_link = AgentRuntimeTaskLink {
parent_agent_id: Some(parent.agent_id.clone()),
parent_run_id: Some(parent.run_id.clone()),
delegation_id: Some("root-source-game-chat-child-delegation".to_string()),
};
let child = bind_game_creator_agent_runtime_run_profile_at(
&root,
"code-prototype",
"root-source-game-chat-child",
"agent-ready-task-scheduler",
None,
Some(&child_link),
)
.expect("bind game-chat child profile");
assert_eq!(
agent_runtime_root_source_at(&root, &parent.agent_id, &parent.run_id)
.expect("resolve root source"),
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
);
assert_eq!(
agent_runtime_root_source_at(&root, &child.agent_id, &child.run_id)
.expect("resolve child root source"),
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
);
}
}
@@ -52,7 +52,7 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec<String> {
.collect::<Vec<_>>()
})
.unwrap_or_default();
for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] {
for agent_id in ["design-director", "art-director", "code-director"] {
if error.contains(&format!("缺少 {agent_id} 委派"))
&& !missing.iter().any(|value| value == agent_id)
{
@@ -62,6 +62,31 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec<String> {
missing
}
fn is_autonomous_initial_leader_delegate_action(action: &AgentRuntimeToolAction) -> bool {
if action.tool.trim() != "agent.delegate" {
return false;
}
let Some(input) = action.input.as_object() else {
return false;
};
if input
.get("repairOfDelegationId")
.or_else(|| input.get("repair_of_delegation_id"))
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
{
return false;
}
matches!(
input
.get("agentId")
.or_else(|| input.get("agent_id"))
.and_then(serde_json::Value::as_str)
.map(str::trim),
Some("design-director" | "art-director" | "code-director")
)
}
fn restrict_supervisor_collaboration_repair_to_missing_agents(
request: &mut LlmRunRequest,
error: &str,
@@ -843,9 +868,22 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
if force_supervisor_initial_collaboration {
supervisor_collaboration_repair_active = true;
if let Some(actions) = supervisor_collaboration_candidate_actions.take() {
supervisor_collaboration_repair_actions = actions;
supervisor_collaboration_repair_actions =
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
actions
.into_iter()
.filter(is_autonomous_initial_leader_delegate_action)
.collect()
} else {
actions
};
}
restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?;
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
restrict_agent_runtime_autonomous_initial_collaboration_repair_tools(
&mut request,
)?;
}
restrict_supervisor_collaboration_repair_to_missing_agents(
&mut request,
&protocol_error,
@@ -854,7 +892,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.htmlquality-review 的 task 必须显式声明只读不得修改项目,expectedArtifacts 必须为 []如当前 policy 的 requiredStaticAgentIds 包含 art-director必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegaterepairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 agent.delegate。必须在同一响应一次性建立完整首批合同,且只允许以下三个非 repair 委派,各出现一次:design-director 与 code-director 的 task 或 acceptanceCriteria 必须显式声明只读不得修改项目,expectedArtifacts 必须为 []art-director 必须非只读规范图生成任务,expectedArtifacts 必须包含 assets/art-spec.png。三者都必须提供非空 task、1-8 条 acceptanceCriteria,并设置 repairOfDelegationId=null、runId=null。不得委派 code-prototype、quality-review、design-foundation、art-asset-plan 或其它底层 Agent,不得调用 agent.spawn_isolated不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
)
} else {
format!(
@@ -1081,12 +1119,42 @@ mod supervisor_collaboration_repair_tests {
.is_empty());
assert_eq!(
supervisor_collaboration_missing_agent_ids(
"Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0"
"Project Supervisor 首批协作不满足项目合同:missingStaticAgents=design-director,art-director,code-director · isolatedChildrenTotal=0"
),
vec!["code-prototype".to_string(), "quality-review".to_string()]
vec![
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
]
);
}
#[test]
fn autonomous_initial_repair_keeps_only_leader_delegates() {
let actions = [
collaboration_action(
"agent.delegate",
serde_json::json!({"agentId": "design-director", "repairOfDelegationId": null}),
),
collaboration_action(
"agent.delegate",
serde_json::json!({"agentId": "code-prototype", "repairOfDelegationId": null}),
),
collaboration_action(
"agent.spawn_isolated",
serde_json::json!({"children": [], "joinMode": "all"}),
),
];
let kept = actions
.iter()
.filter(|action| is_autonomous_initial_leader_delegate_action(action))
.map(|action| action.input["agentId"].as_str().expect("leader id"))
.collect::<Vec<_>>();
assert_eq!(kept, vec!["design-director"]);
}
#[test]
fn isolated_repair_replaces_the_single_accumulated_slot() {
let accumulated = vec![collaboration_action(
@@ -501,6 +501,90 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
assert_eq!(assistants, vec![response]);
}
#[test]
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
assert!(
!GameCreatorLlmConfig::default().stream,
"the production default exercises the non-stream final-reply path"
);
let project = tempfile::tempdir().expect("create non-stream professional reply project");
let root = project.path();
init_local_game_project_at(
root,
"non-stream-professional-reply",
"非流式专业 Agent 回复",
)
.expect("initialize non-stream professional reply project");
let mut state = start_game_creator_agent_runtime_task_at(
root,
"art-director",
"生成 game-chat 首版统一视觉规范",
"non-stream-art-director-run",
"agent-delegate",
"整理专业 Agent 最终回复",
vec!["生成并登记统一视觉规范图".to_string()],
)
.expect("start non-stream professional runtime");
state.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string());
state.parent_run_id = Some("game-chat-parent-run".to_string());
state.delegation_id = Some("game-chat-art-director-delegation".to_string());
state.loop_iteration = 1;
state.status = "running".to_string();
state.phase = "response".to_string();
state.current_action = "直接采用非流式最终回复".to_string();
state.waiting_on = "finalization 持久化".to_string();
state.next_step = "提交 durable response stream 投影".to_string();
state.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &state)
.expect("append non-stream professional runtime task");
write_game_creator_agent_runtime_state(root, &state)
.expect("write non-stream professional runtime state");
let response_revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read non-stream response revision")
.revision;
assert!(
read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id,)
.expect("read absent pre-finalization response stream")
.is_none(),
"stream=false must enter finalization without a pre-existing stream sidecar"
);
let response = "美术 Agent:统一视觉规范图已生成并登记。";
let completed = finish_game_creator_agent_background_runtime_turn_at(
root,
state.clone(),
response,
response_revision,
&[],
)
.expect("finalize non-stream professional reply");
assert!(matches!(
completed,
AgentBackgroundFinalizationOutcome::Completed(_)
));
let mut later_revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision before later stage mutation");
later_revision.revision = later_revision.revision.saturating_add(1);
later_revision.updated_at = unix_timestamp();
write_game_creator_agent_runtime_project_revision(root, &later_revision)
.expect("simulate a later game-chat stage advancing project revision");
let queried = read_game_creator_agent_runtime_at(root, &state.agent_id)
.expect("query completed professional runtime after revision advance");
let stream = queried
.response_stream
.expect("durable professional final reply remains queryable");
assert_eq!(
stream.status,
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED
);
assert_eq!(stream.request_kind, "final-reply");
assert_eq!(stream.response_revision, response_revision);
assert_eq!(stream.accumulated_text, response);
}
#[test]
fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
let (project, state, response_revision, snapshot) =
@@ -100,6 +100,16 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi
pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join";
pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui";
pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli";
pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat";
pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool {
matches!(
source.trim(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
| AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
| AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
)
}
pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str =
"game-creator-run-profile-binding.v1";
pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str =
@@ -196,10 +206,13 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt {
mod entrypoints;
mod finalization;
mod game_chat_fast_path;
mod interaction;
mod lifecycle_control;
mod main_loop;
#[cfg(test)]
mod main_loop_deadline_tests;
#[cfg(test)]
mod main_loop_tests;
mod pending_execution;
mod pending_recovery;
@@ -210,6 +223,7 @@ mod task_start;
pub(in crate::agent) use entrypoints::*;
pub(in crate::agent) use finalization::*;
pub(in crate::agent) use game_chat_fast_path::*;
pub(in crate::agent) use interaction::*;
pub(in crate::agent) use lifecycle_control::*;
pub(in crate::agent) use main_loop::*;
@@ -268,6 +282,7 @@ pub(crate) use provider_recovery::{
};
pub(crate) use recovery_scan::{
cleanup_game_creator_agent_runtime_completed_finalizations_at,
has_recoverable_game_creator_agent_background_tasks_at,
resume_game_creator_agent_background_tasks_at,
resume_game_creator_agent_pending_action_for_agent_at,
wake_pending_game_creator_agent_background_tasks_at,
@@ -761,10 +761,7 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at(
if binding.parent_run_id.is_some()
|| binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| binding.root_run_id != task.run_id
|| !matches!(
binding.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
|| !agent_runtime_supervisor_source_is_trusted(&binding.source)
{
return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
use super::main_loop::{
await_game_chat_absolute_deadline_at, finish_game_chat_absolute_deadline_timeout_at,
game_chat_absolute_deadline_from_bound_at,
};
use super::*;
#[test]
fn game_chat_absolute_deadline_is_root_bound_at_plus_hard_budget() {
let now_instant = tokio::time::Instant::now();
let bound_at = 10_000;
let now_unix = bound_at + 17;
let deadline = game_chat_absolute_deadline_from_bound_at(now_instant, now_unix, bound_at);
assert_eq!(
deadline.duration_since(now_instant),
std::time::Duration::from_secs(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS - 17)
);
}
#[test]
fn game_chat_absolute_deadline_is_immediate_once_root_budget_is_exhausted() {
let now_instant = tokio::time::Instant::now();
let bound_at = 20_000;
let deadline = game_chat_absolute_deadline_from_bound_at(
now_instant,
bound_at + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + 1,
bound_at,
);
assert_eq!(deadline, now_instant);
}
#[tokio::test]
async fn game_chat_absolute_deadline_stops_a_never_resolving_in_flight_action() {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(20);
let started = std::time::Instant::now();
let result = await_game_chat_absolute_deadline_at(
deadline,
std::future::pending::<AgentBackgroundTaskOutcome>(),
)
.await;
assert!(
result.is_err(),
"never-resolving action must hit the hard deadline"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(1),
"deadline test must not hang"
);
}
#[tokio::test]
async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
let result = await_game_chat_absolute_deadline_at(deadline, async { "completed" }).await;
assert_eq!(result.expect("in-flight action completes"), "completed");
}
#[test]
fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-reconciliation-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
"执行可能悬挂的首版写入",
"game-chat-deadline-reconciliation-run",
"agent-ready-task-scheduler",
"正在执行首版写入",
vec!["执行首版写入".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "file.write".to_string(),
reason: Some("模拟截止时仍在途的写入".to_string()),
input: serde_json::json!({
"path": "game/index.html",
"content": "<!doctype html><title>deadline</title>"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "准备首版写入".to_string(),
plan_update: None,
plan: vec!["写入首版".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let project_revision =
read_game_creator_agent_runtime_project_revision(&root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(&root)
.expect("repository context")
.fingerprint;
let pending = build_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime,
&runtime.current_task,
&plan,
&[],
&project_revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
None,
)
.expect("build pending action");
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
.expect("write pending action");
runtime.pending_tool_action = Some(pending.summary());
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "等待人工核对在途动作".to_string();
runtime.waiting_on = "开发者核对副作用".to_string();
runtime.next_step = "核对后恢复".to_string();
runtime.error = Some("模拟 needs-reconciliation".to_string());
append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task");
write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state");
let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root,
&runtime.agent_id,
&runtime.session_id,
runtime.clone(),
);
assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished));
let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id)
.expect("read terminal runtime")
.state;
assert_eq!(terminal.run_id, runtime.run_id);
assert_eq!(terminal.status, "failed");
assert_eq!(terminal.phase, "failed");
assert!(terminal.pending_tool_action.is_none());
assert!(!game_creator_agent_runtime_pending_tool_action_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
assert!(!game_creator_agent_runtime_provider_action_batch_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
fs::remove_dir_all(root).ok();
}
@@ -25,6 +25,86 @@ fn register_autonomous_recovery_visual_fixture(root: &Path, local_path: &str, ki
.expect("register autonomous recovery visual fixture");
}
fn register_game_chat_art_spec_fixture(root: &Path) {
image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 140, 220, u8::MAX]))
.save(root.join(AGENT_RUNTIME_ART_SPEC_PATH))
.expect("write game-chat art spec PNG");
register_local_asset_at(
root,
AGENT_RUNTIME_ART_SPEC_PATH,
"icon-spec",
"image/png",
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("game-chat-canvas".to_string()),
resource_id: Some("game-chat-art-spec-resource".to_string()),
asset_object_id: Some("game-chat-art-spec-object".to_string()),
task_id: Some("art-director".to_string()),
prompt: None,
model: None,
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
generation_kind: Some("spec".to_string()),
reference_resource_ids: Vec::new(),
},
)
.expect("register game-chat art spec");
}
fn queue_game_chat_fast_path_child(
root: &Path,
root_run_id: &str,
root_task: &str,
child_id: &str,
) -> (AgentRuntimeState, AgentRuntimeState) {
let root_session = resolve_agent_conversation_session_id_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
root_task,
root_run_id,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue game-chat root");
let root_state = agent_runtime_state_from_task_record(&root_record);
let manifest = read_manifest_for_project(root).expect("read game-chat manifest");
let task = manifest
.tasks
.iter()
.find(|task| task.id == child_id)
.unwrap_or_else(|| panic!("missing game-chat child task {child_id}"));
let child_session = resolve_agent_conversation_session_id_at(root, child_id, None, true)
.expect("resolve game-chat child session");
let child_record = append_unique_game_creator_agent_runtime_pending_task(
root,
child_id,
&child_session,
&render_autonomous_manifest_ready_task_background_prompt(task),
&autonomous_manifest_ready_task_run_id(root_run_id, child_id),
"agent-ready-task-scheduler",
None,
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(root_state.agent_id.clone()),
parent_run_id: Some(root_state.run_id.clone()),
delegation_id: None,
}),
)
.expect("queue game-chat fast-path child");
(
root_state,
agent_runtime_state_from_task_record(&child_record),
)
}
#[test]
fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() {
let root = std::env::temp_dir().join(format!(
@@ -65,7 +145,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
write_local_project_file_at(
root,
AGENT_RUNTIME_GAME_INDEX_PATH,
"<!doctype html><title>可玩塔防</title><canvas></canvas>",
&render_game_chat_fast_path_html("可玩塔防"),
)
.expect("write autonomous game index");
for (path, content) in [
@@ -84,6 +164,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
}
revision
};
register_game_chat_art_spec_fixture(root);
for task in new_game_creation_app_seed_tasks() {
update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed)
.expect("complete autonomous manifest task");
@@ -263,6 +344,214 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf
));
}
#[test]
fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planning() {
let _config_guard = crate::tests::write_test_local_config(
r#"{"editorApi":{"apiKey":"game-chat-fast-art-key"}}"#.to_string(),
);
let temporary = tempfile::tempdir().expect("create game-chat art root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-art-director", "星空飞船收集能量")
.expect("init game-chat art project");
let (root_state, child_state) = queue_game_chat_fast_path_child(
&root,
"game-chat-art-director-root",
"制作星空飞船收集能量小游戏",
"art-director",
);
let binding = read_game_creator_agent_runtime_run_profile_binding(
&root,
&root_state.agent_id,
&root_state.run_id,
)
.expect("read game-chat root binding")
.expect("game-chat root binding exists");
let plan = game_chat_fast_path_plan_at(
&root,
&child_state,
&child_state.current_task,
binding.bound_at,
)
.expect("build deterministic art-director plan")
.expect("art-director fast path must bypass Provider planning");
assert_eq!(plan.actions.len(), 1);
assert_eq!(plan.actions[0].tool, "canvas.asset_generate");
assert_eq!(
plan.actions[0].input["outputPath"],
AGENT_RUNTIME_ART_SPEC_PATH
);
assert_eq!(plan.actions[0].input["assetKind"], "icon-spec");
assert_eq!(plan.actions[0].input["aspectRatio"], "1:1");
assert_eq!(plan.actions[0].input["imageSize"], "1K");
assert_eq!(plan.actions[0].input["replaceExisting"], false);
let serialized = serde_json::to_string(&plan).expect("serialize deterministic art plan");
assert!(!serialized.contains("art-spritesheet"));
assert!(!serialized.contains("icon-spritesheets/generations"));
assert!(!root.join("assets/manifest.art.json").exists());
}
#[test]
fn game_chat_art_stage_fails_before_provider_when_editor_api_is_missing() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let temporary = tempfile::tempdir().expect("create unconfigured game-chat art root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-art-unconfigured", "太空躲避")
.expect("init unconfigured game-chat art project");
let (root_state, child_state) = queue_game_chat_fast_path_child(
&root,
"game-chat-art-unconfigured-root",
"制作太空躲避小游戏",
"art-director",
);
let binding = read_game_creator_agent_runtime_run_profile_binding(
&root,
&root_state.agent_id,
&root_state.run_id,
)
.expect("read game-chat root binding")
.expect("game-chat root binding exists");
let error = game_chat_fast_path_plan_at(
&root,
&child_state,
&child_state.current_task,
binding.bound_at,
)
.expect_err("unconfigured game-chat art must fail closed before Provider planning");
assert!(error.contains("External Editor API Key"));
assert!(error.contains("平台美术资源"));
}
#[test]
fn game_chat_art_fast_path_idempotently_settles_registered_art_spec() {
let _config_guard = crate::tests::write_test_local_config(
r#"{"editorApi":{"apiKey":"game-chat-idempotent-art-key"}}"#.to_string(),
);
let art_director_temporary = tempfile::tempdir().expect("create idempotent art-spec root");
let art_director_root = art_director_temporary.path().join("project");
init_local_game_project_at(
&art_director_root,
"game-chat-idempotent-art-spec",
"海岛收集",
)
.expect("init idempotent art-spec project");
register_game_chat_art_spec_fixture(&art_director_root);
let (root_state, art_director_state) = queue_game_chat_fast_path_child(
&art_director_root,
"game-chat-idempotent-art-spec-root",
"制作海岛收集小游戏",
"art-director",
);
let bound_at = read_game_creator_agent_runtime_run_profile_binding(
&art_director_root,
&root_state.agent_id,
&root_state.run_id,
)
.expect("read idempotent art-spec root binding")
.expect("idempotent art-spec root binding exists")
.bound_at;
let art_director_plan = game_chat_fast_path_plan_at(
&art_director_root,
&art_director_state,
&art_director_state.current_task,
bound_at,
)
.expect("settle registered art spec")
.expect("registered art spec must use deterministic settlement");
assert!(art_director_plan.actions.is_empty());
assert!(art_director_plan.response.contains("已生成并登记"));
}
#[test]
fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_mutation() {
let _config_guard = crate::tests::write_test_local_config(
r#"{"editorApi":{"apiKey":"game-chat-code-revision-key"}}"#.to_string(),
);
let temporary = tempfile::tempdir().expect("create game-chat code revision root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-code-revision", "太空飞船收集能量")
.expect("init game-chat code revision project");
let (root_state, art_director_state) = queue_game_chat_fast_path_child(
&root,
"game-chat-code-revision-root",
"制作太空飞船收集能量小游戏",
"art-director",
);
{
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
&root,
"test.game-chat.art-director.mutate",
)
.expect("acquire art-director mutation lock");
let revision = prepare_agent_runtime_project_mutation_locked(
&root,
&art_director_state.agent_id,
&art_director_state.run_id,
"canvas.asset_generate",
)
.expect("advance global revision for art-director");
assert_eq!(revision, 1);
}
let manifest = read_manifest_for_project(&root).expect("read game-chat manifest");
let code_task = manifest
.tasks
.iter()
.find(|task| task.id == "code-prototype")
.expect("code-prototype manifest task");
let code_session =
resolve_agent_conversation_session_id_at(&root, "code-prototype", None, true)
.expect("resolve code-prototype session");
let code_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
"code-prototype",
&code_session,
&render_autonomous_manifest_ready_task_background_prompt(code_task),
&autonomous_manifest_ready_task_run_id(&root_state.run_id, "code-prototype"),
"agent-ready-task-scheduler",
None,
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(root_state.agent_id.clone()),
parent_run_id: Some(root_state.run_id.clone()),
delegation_id: None,
}),
)
.expect("queue code-prototype child after art mutation");
let code_state = agent_runtime_state_from_task_record(&code_record);
let code_gate = read_game_creator_agent_runtime_verification_gate(
&root,
&code_state.agent_id,
&code_state.run_id,
)
.expect("read code-prototype gate");
assert_eq!(code_gate.mutation_revision, None);
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read global revision")
.revision,
1
);
let bound_at = read_game_creator_agent_runtime_run_profile_binding(
&root,
&root_state.agent_id,
&root_state.run_id,
)
.expect("read game-chat root binding")
.expect("game-chat root binding exists")
.bound_at;
let plan = game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at)
.expect("evaluate code-prototype fast path");
assert!(
plan.is_none(),
"art-director's global revision must not skip the code Provider"
);
}
#[test]
fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
assert_eq!(
@@ -277,6 +566,167 @@ fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
);
}
#[test]
fn game_chat_single_round_converges_without_another_provider_plan_after_playtest() {
const RUN_ID: &str = "game-chat-single-round-convergence";
const TASK: &str = "生成一个可玩的原创塔防小游戏,完成一轮后停止并打开预览";
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let temporary = tempfile::tempdir().expect("create game-chat convergence root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-convergence", TASK)
.expect("init game-chat convergence project");
let session_id = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat Supervisor session");
let task_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&session_id,
TASK,
RUN_ID,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue game-chat Supervisor task");
let mut runtime = agent_runtime_state_from_task_record(&task_record);
apply_agent_runtime_plan_update(
&mut runtime,
&AgentRuntimePlanUpdate {
explanation: "模型原本规划了继续迭代".to_string(),
steps: vec![
AgentRuntimePlanUpdateStep {
step: "实现游戏".to_string(),
status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(),
},
AgentRuntimePlanUpdateStep {
step: "完成试玩".to_string(),
status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(),
},
AgentRuntimePlanUpdateStep {
step: "继续下一轮".to_string(),
status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(),
},
],
},
)
.expect("apply structured game-chat plan");
assert!(runtime.plan_revision > 0);
let revision = prepare_autonomous_completion_evidence(&root, &runtime);
for task in new_game_creation_app_seed_tasks() {
if !matches!(
task.id.as_str(),
"design-director"
| "art-director"
| "code-director"
| "code-prototype"
| "preview-readiness"
| "preview-playtest"
) {
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
}
}
let mut provider_plan = AgentRuntimeToolPlan {
actions: vec![AgentRuntimeToolAction {
tool: "agent.run_status".to_string(),
reason: Some("模型原本还想继续轮询".to_string()),
input: serde_json::json!({}),
}],
..AgentRuntimeToolPlan::default()
};
let convergence = prepare_game_chat_single_round_convergence_at(
&root,
&mut runtime,
&mut provider_plan,
unix_timestamp(),
)
.expect("prepare deterministic game-chat convergence")
.expect("playtest-complete game-chat run must converge");
assert_eq!(convergence.1, revision);
assert!(convergence.0.contains(&format!("revision {revision}")));
assert!(provider_plan.actions.is_empty());
assert!(runtime
.plan_steps
.iter()
.all(|step| step.status == "completed"));
assert_eq!(runtime.current_action, "首个可试玩版本已完成,正在结束本轮");
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &runtime).is_none());
}
#[test]
fn game_chat_single_round_cannot_converge_after_the_hard_budget() {
const RUN_ID: &str = "game-chat-single-round-hard-budget";
const TASK: &str = "生成一个可玩的原创塔防小游戏,并在五分钟内停止";
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let temporary = tempfile::tempdir().expect("create game-chat hard budget root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-hard-budget", TASK)
.expect("init game-chat hard budget project");
let session_id = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat Supervisor session");
let task_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&session_id,
TASK,
RUN_ID,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue game-chat Supervisor task");
let mut runtime = agent_runtime_state_from_task_record(&task_record);
prepare_autonomous_completion_evidence(&root, &runtime);
for task in new_game_creation_app_seed_tasks() {
if !matches!(
task.id.as_str(),
"design-director"
| "art-director"
| "code-director"
| "code-prototype"
| "preview-readiness"
| "preview-playtest"
) {
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
}
}
let binding = read_game_creator_agent_runtime_run_profile_binding(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read game-chat root binding")
.expect("game-chat root binding exists");
let mut provider_plan = AgentRuntimeToolPlan::default();
let error = prepare_game_chat_single_round_convergence_at(
&root,
&mut runtime,
&mut provider_plan,
binding.bound_at + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS,
)
.expect_err("hard-budget-expired game-chat run must not converge");
assert!(error.starts_with(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX));
assert!(!runtime
.observations
.iter()
.any(|observation| observation.contains("单轮完成门已通过")));
}
#[test]
fn autonomous_specialist_empty_plan_uses_internal_completion_fallback() {
assert_eq!(
@@ -1,7 +1,17 @@
use super::*;
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
error.starts_with("项目正在被其他写操作占用:")
|| error.contains("另一个程序正在使用此文件")
|| normalized.contains("sharing violation")
|| normalized.contains("lock violation")
|| normalized.contains("os error 32")
|| normalized.contains("os error 33")
|| normalized.contains("resource temporarily unavailable")
|| normalized.contains("would block")
|| normalized.contains("timed out")
|| normalized.contains("timeout")
}
pub(in crate::agent) fn schedule_waiting_provider_retry_wake_after_lane_release(
@@ -109,7 +119,7 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass(
agent_id: &str,
run_id: &str,
) {
for _ in 0..40 {
for _ in 0..200 {
tokio::time::sleep(Duration::from_millis(10)).await;
let Ok(Some(task)) =
read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)
@@ -124,6 +134,9 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass(
Ok(false) => match autonomous_manifest_dag_in_progress_at(root) {
Ok(true) => return,
Ok(false) => {}
Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => {
continue;
}
Err(error) => {
let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at(
root, agent_id, run_id, &error,
@@ -422,6 +422,108 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at(
.map_err(|error| redact_agent_runtime_error(root, &error, 500))
}
pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
root: &Path,
) -> Result<bool, String> {
validate_project_root(root)?;
for relative_directory in [
".agent/runtime/finalizations",
".agent/runtime/tool-plan-handoffs",
".agent/runtime/provider-retries",
".agent/runtime/pending-actions",
".agent/runtime/parallel-read-batches",
".agent/runtime/provider-action-batches",
".agent/runtime/cancel",
] {
if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) {
return Ok(true);
}
}
if durable_process_session_recovery_exists_at(root) {
return Ok(true);
}
for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? {
match read_recoverable_game_creator_agent_runtime_task(root, &agent_id) {
Ok(Some(_)) | Err(_) => return Ok(true),
Ok(None) => {}
}
}
Ok(false)
}
fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> bool {
let mut pending_directories = vec![directory.to_path_buf()];
while let Some(directory) = pending_directories.pop() {
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(_) => return true,
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(_) => return true,
};
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => return true,
};
if file_type.is_symlink() || !file_type.is_dir() {
return true;
}
pending_directories.push(entry.path());
}
}
false
}
fn durable_process_session_recovery_exists_at(root: &Path) -> bool {
let directory = root.join(".agent/runtime/process-sessions");
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Err(_) => return true,
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(_) => return true,
};
let Some(file_name) = entry.file_name().to_str().map(str::to_string) else {
return true;
};
if !file_name.ends_with(".json") || file_name.ends_with(".output.json") {
continue;
}
match entry.file_type() {
Ok(file_type) if !file_type.is_symlink() && file_type.is_file() => {}
_ => return true,
}
let relative_path = format!(".agent/runtime/process-sessions/{file_name}");
let record = match read_agent_runtime_json_sidecar_with_max_bytes::<ProcessSessionRecord>(
root,
&relative_path,
"Agent Runtime process session preflight",
32 * 1024,
) {
Ok(Some(record)) => record,
Ok(None) | Err(_) => return true,
};
if record.schema_version != "3" || record.needs_reconciliation {
return true;
}
if !matches!(
record.status.as_str(),
"exited" | "terminated" | "timed-out" | "output-limit-exceeded" | "failed"
) {
return true;
}
}
false
}
pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at(
root: &Path,
) -> Result<Vec<AgentRuntimeResult>, String> {
@@ -119,10 +119,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at(
source: &str,
run_profile: &str,
) -> Result<AgentRuntimeResult, String> {
if !matches!(
source,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
) {
if !agent_runtime_supervisor_source_is_trusted(source) {
return Err("Project Supervisor 提交 source 不受信任".to_string());
}
normalize_agent_runtime_run_profile(Some(run_profile))?;
@@ -641,10 +638,7 @@ fn current_autonomous_game_build_root_task_at(
&& record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& record.parent_agent_id.is_none()
&& record.parent_run_id.is_none()
&& matches!(
record.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
&& agent_runtime_supervisor_source_is_trusted(&record.source)
&& seen_run_ids.insert(record.run_id.clone())
{
root_run_ids.push(record.run_id.clone());
@@ -658,8 +652,41 @@ fn current_autonomous_game_build_root_task_at(
.find(|record| record.run_id == *current_run_id))
}
fn autonomous_manifest_ready_task_ids(tasks: &[GameCreationAppTaskState]) -> Vec<String> {
new_game_creation_app_seed_tasks()
pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source(
source: &str,
) -> Vec<GameCreationAppTaskState> {
let seed_tasks = new_game_creation_app_seed_tasks();
if source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE {
seed_tasks
.into_iter()
.filter_map(|mut task| {
let dependencies = match task.id.as_str() {
"design-director" => Some(Vec::new()),
"art-director" => Some(Vec::new()),
"code-director" => Some(Vec::new()),
"code-prototype" => Some(vec![
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
]),
"preview-readiness" => Some(vec!["code-prototype".to_string()]),
"preview-playtest" => Some(vec!["preview-readiness".to_string()]),
_ => None,
}?;
task.dependencies = dependencies;
Some(task)
})
.collect()
} else {
seed_tasks
}
}
pub(in crate::agent) fn autonomous_manifest_ready_task_ids(
tasks: &[GameCreationAppTaskState],
source: &str,
) -> Vec<String> {
autonomous_manifest_seed_tasks_for_source(source)
.into_iter()
.filter_map(|seed_task| {
let task = tasks.iter().find(|task| task.id == seed_task.id)?;
@@ -669,7 +696,7 @@ fn autonomous_manifest_ready_task_ids(tasks: &[GameCreationAppTaskState]) -> Vec
| GameCreationAppTaskStatus::WaitingForConfirmation
);
(status_is_ready
&& task.dependencies.iter().all(|dependency| {
&& seed_task.dependencies.iter().all(|dependency| {
task_has_status(tasks, dependency, GameCreationAppTaskStatus::Completed)
}))
.then(|| task.id.clone())
@@ -893,9 +920,17 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
.count();
let available = 3usize.saturating_sub(active_count);
let seed_task_order = new_game_creation_app_seed_tasks();
let allowed_seed_task_ids =
autonomous_manifest_seed_tasks_for_source(&parent_binding.source)
.into_iter()
.map(|task| task.id)
.collect::<BTreeSet<_>>();
let mut candidates = Vec::new();
for seed_task in &seed_task_order {
if !allowed_seed_task_ids.contains(&seed_task.id) {
continue;
}
let Some(task) = manifest.tasks.iter().find(|task| task.id == seed_task.id) else {
continue;
};
@@ -917,7 +952,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
candidates.push((task.clone(), false));
}
}
for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks)
for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source)
.into_iter()
.take(limit.min(available))
{
@@ -1133,8 +1168,12 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
manifest_task,
&task_text,
)?;
let game_chat_requires_visual_asset = root_parent_binding.source
== AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
&& manifest_task.id == "art-director";
if status == GameCreationAppTaskStatus::Completed
&& autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id)
&& (autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id)
|| game_chat_requires_visual_asset)
&& !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id)
{
status = GameCreationAppTaskStatus::Failed;
@@ -1166,6 +1205,50 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
));
}
}
if status == GameCreationAppTaskStatus::Completed
&& state.agent_id == "code-prototype"
&& root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
{
let revision = read_game_creator_agent_runtime_project_revision(root)?;
let child_gate = read_game_creator_agent_runtime_verification_gate(
root,
&state.agent_id,
&state.run_id,
)?;
if child_gate.verified_revision != Some(revision.revision)
|| child_gate.last_verification_tool.as_deref() != Some("game.static_smoke")
|| child_gate.last_verification_status.as_deref()
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
{
return Err("game-chat code-prototype 缺少当前 revision 的静态验证凭证".to_string());
}
let mut root_gate = read_game_creator_agent_runtime_verification_gate(
root,
&parent_agent_id,
&parent_run_id,
)?;
root_gate.requires_verification = false;
root_gate.mutation_revision = None;
root_gate.verified_revision = Some(revision.revision);
root_gate.last_mutation_tool = None;
root_gate.last_verification_tool = Some("game.static_smoke".to_string());
root_gate.last_verification_status =
Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string());
root_gate.failed_playtest_revision = None;
root_gate.updated_at = unix_timestamp();
write_game_creator_agent_runtime_verification_gate(root, &root_gate)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.game_chat.static_verification_projected",
"agentId": state.agent_id,
"runId": state.run_id,
"parentAgentId": parent_agent_id,
"parentRunId": parent_run_id,
"revision": revision.revision,
}),
)?;
}
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
append_agent_db_record(
root,
@@ -1183,9 +1266,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}),
)?;
let completed = status == GameCreationAppTaskStatus::Completed;
let game_chat_single_round =
root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE;
let root = root.to_path_buf();
tauri::async_runtime::spawn(async move {
if completed {
if completed && !game_chat_single_round {
if let Err(error) = schedule_autonomous_game_build_ready_tasks_at(
&root,
&parent_agent_id,
@@ -1242,9 +1327,17 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
} else {
""
};
return format!(
let code_visual_asset_requirement = if task.id == "code-prototype"
&& editor_api_key_is_configured()
{
" External Editor API 已配置时必须先调用 asset.list 核对 Canvas 登记与资源有效性:game-chat Run 使用 assets/art-spec.pngicon-spec、images/generations/spec),GUI/CLI Run 使用 assets/art-spritesheet.pnggame/index.html 必须通过 HTML、CSS background 或 Canvas drawImage 可见使用对应资源,不得只用 emoji、色块、CSS 绘图或占位文本冒充。"
} else {
""
};
let owner_prompt = format!(
"{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSONcode-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest。"
);
return format!("{owner_prompt}{code_visual_asset_requirement}");
}
format!(
"{base}\n\n这是 autonomous-game-build 的只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest。"

Some files were not shown because too many files have changed in this diff Show More