5分钟策划agent #159
@@ -16,5 +16,8 @@
|
||||
"retryBackoffMs": 500
|
||||
},
|
||||
"agentLlm": {},
|
||||
"mcpServers": {}
|
||||
"mcpServers": {},
|
||||
"planning": {
|
||||
"capabilityEnabled": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
|
||||
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
|
||||
"test:plan": "node scripts/agent-swarm-test-chat.mjs --plan --task \"我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。\"",
|
||||
"test:plan:manual": "node scripts/agent-swarm-test-chat.mjs --plan",
|
||||
"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",
|
||||
|
||||
@@ -36,6 +36,8 @@ export const ungeneratedGameEntryMarker =
|
||||
'还没有生成游戏。回到聊天输入创意并确认生成后';
|
||||
export const defaultRealSwarmTestTask =
|
||||
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
|
||||
export const defaultRealSwarmPlanTask =
|
||||
'我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。';
|
||||
export const swarmTurnReportPrefix = '[turn.report] ';
|
||||
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
|
||||
|
||||
@@ -138,7 +140,8 @@ export const usage = `用法:
|
||||
--keep-project 保留自动创建的一次性项目
|
||||
--no-open 手工模式启动预览但不自动打开浏览器
|
||||
--task <需求> 通过 manual 入口非交互提交自定义需求
|
||||
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
|
||||
--plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
|
||||
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
|
||||
--dry-run 只检查目录发现和项目准备,不启动 LLM
|
||||
-h, --help 显示帮助`;
|
||||
|
||||
@@ -169,6 +172,7 @@ export function parseSwarmTestArguments(args) {
|
||||
keepProject: false,
|
||||
openBrowser: true,
|
||||
task: null,
|
||||
plan: false,
|
||||
timeoutMinutes: null,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
@@ -193,6 +197,8 @@ export function parseSwarmTestArguments(args) {
|
||||
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
|
||||
options.task = task;
|
||||
index += 1;
|
||||
} else if (argument === '--plan') {
|
||||
options.plan = true;
|
||||
} else if (argument === '--timeout-minutes') {
|
||||
if (options.timeoutMinutes !== null) {
|
||||
throw new Error('--timeout-minutes 只能指定一次');
|
||||
@@ -211,11 +217,16 @@ export function parseSwarmTestArguments(args) {
|
||||
}
|
||||
|
||||
export function shouldStartPersistentPreview(options) {
|
||||
return !options.task;
|
||||
// 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。
|
||||
return !options.task && !options.plan;
|
||||
}
|
||||
|
||||
export function resolveSwarmTestTimeoutMs(options) {
|
||||
const minutes = options.timeoutMinutes ?? (options.task ? 50 : null);
|
||||
// 立项策划的设计目标是五分钟出方案,给一分钟余量;再久就是卡住了,早失败
|
||||
// 比让 harness 空等更有用。做游戏那条链路的 50 分钟不变。
|
||||
const planMinutes = options.plan ? 6 : null;
|
||||
const minutes =
|
||||
options.timeoutMinutes ?? planMinutes ?? (options.task ? 50 : null);
|
||||
return minutes === null ? null : minutes * 60_000;
|
||||
}
|
||||
|
||||
@@ -658,7 +669,18 @@ export async function cleanupSwarmTestProject(project) {
|
||||
}
|
||||
|
||||
export function buildCargoCliArguments(cliArguments) {
|
||||
return ['run', '--manifest-path', cargoManifestPath, '--', ...cliArguments];
|
||||
// `--quiet` only silences cargo's own build chatter; compiler errors and the
|
||||
// CLI's stdout still come through. Without it the crate's several hundred
|
||||
// dead-code warnings are reprinted on every spawn and bury the run output
|
||||
// this script exists to show.
|
||||
return [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--manifest-path',
|
||||
cargoManifestPath,
|
||||
'--',
|
||||
...cliArguments,
|
||||
];
|
||||
}
|
||||
|
||||
function spawnChild(command, args, options = {}) {
|
||||
@@ -923,13 +945,79 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) {
|
||||
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
|
||||
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
|
||||
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
|
||||
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
||||
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
||||
|
||||
export function nextSwarmAutoPilotReply(output) {
|
||||
if (swarmConfirmationPromptPattern.test(output)) return 'approve';
|
||||
if (swarmUserInputPromptPattern.test(output)) return '1';
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLI 的 REPL 是「先打印提示符再读行」,所以第一个「你>」出现时本轮还没开始跑:
|
||||
// 它就是用来读我们这条任务的。收 stdin 必须等到投递之后的下一个提示符——那才是
|
||||
// 本轮结束、CLI 回到待输入状态。绝大多数情况下此前已经打印过 turn 回执,但总控也
|
||||
// 可能判定直接回复而不起持久 Run,那条路径没有回执,只等回执会一直干等到超时。
|
||||
const swarmChatPromptPattern = /(^|\n)你> $/u;
|
||||
|
||||
export function swarmAutoPilotSitsAtPrompt(output) {
|
||||
return swarmChatPromptPattern.test(output);
|
||||
}
|
||||
|
||||
export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
|
||||
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
|
||||
}
|
||||
|
||||
// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input,
|
||||
// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settle,CLI 也就永远
|
||||
// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。
|
||||
// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案,
|
||||
// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次
|
||||
// --plan-gdd-status,没有待决定审批时不会有任何写入。
|
||||
const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u;
|
||||
|
||||
export function swarmOutputAwaitsPlanGddApproval(line) {
|
||||
return planGddApprovalWaitPattern.test(line);
|
||||
}
|
||||
|
||||
async function runTaskCargo(
|
||||
cliArguments,
|
||||
task,
|
||||
setActiveChild,
|
||||
timeoutMs,
|
||||
autoPilot = false,
|
||||
onPlanGddApprovalWait = null,
|
||||
) {
|
||||
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
});
|
||||
setActiveChild(child);
|
||||
const reportLines = [];
|
||||
let pendingLine = '';
|
||||
let settled = false;
|
||||
let taskSubmitted = false;
|
||||
let promptsSeen = 0;
|
||||
let sittingAtPrompt = false;
|
||||
let planGddApproval = null;
|
||||
let planGddApprovalError = null;
|
||||
let planGddApprovalStarted = false;
|
||||
let planGddApprovalPromise = null;
|
||||
const startPlanGddApproval = () => {
|
||||
planGddApprovalStarted = true;
|
||||
console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve');
|
||||
planGddApprovalPromise = onPlanGddApprovalWait()
|
||||
.then((value) => {
|
||||
planGddApproval = value;
|
||||
})
|
||||
.catch((error) => {
|
||||
planGddApprovalError = error;
|
||||
// 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。
|
||||
void terminateChildTree(child).catch(() => {});
|
||||
});
|
||||
};
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
@@ -940,10 +1028,41 @@ async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) {
|
||||
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line;
|
||||
if (normalizedLine.startsWith(swarmTurnReportPrefix)) {
|
||||
reportLines.push(normalizedLine);
|
||||
settled = true;
|
||||
}
|
||||
if (
|
||||
onPlanGddApprovalWait &&
|
||||
!planGddApprovalStarted &&
|
||||
swarmOutputAwaitsPlanGddApproval(normalizedLine)
|
||||
) {
|
||||
startPlanGddApproval();
|
||||
}
|
||||
}
|
||||
if (!autoPilot || child.stdin.writableEnded) return;
|
||||
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
|
||||
if (atPrompt && !sittingAtPrompt) promptsSeen += 1;
|
||||
sittingAtPrompt = atPrompt;
|
||||
// turn 已给出回执、或 CLI 回到了投递之后的下一个提示符,都说明本轮结束。
|
||||
if (
|
||||
settled ||
|
||||
(taskSubmitted &&
|
||||
swarmAutoPilotShouldCloseInput(pendingLine, promptsSeen - 1))
|
||||
) {
|
||||
child.stdin.end();
|
||||
return;
|
||||
}
|
||||
const reply = nextSwarmAutoPilotReply(pendingLine);
|
||||
if (reply === null) return;
|
||||
console.log(`[自动应答] ${reply}`);
|
||||
pendingLine = '';
|
||||
child.stdin.write(`${reply}\n`);
|
||||
});
|
||||
child.stdin.end(`${task}\n`);
|
||||
if (autoPilot) {
|
||||
child.stdin.write(`${task}\n`);
|
||||
taskSubmitted = true;
|
||||
} else {
|
||||
child.stdin.end(`${task}\n`);
|
||||
}
|
||||
try {
|
||||
const result = await childExitWithTimeout(
|
||||
child,
|
||||
@@ -956,7 +1075,13 @@ async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) {
|
||||
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
|
||||
reportLines.push(normalizedPendingLine);
|
||||
}
|
||||
return { ...result, turnReportOutput: reportLines.join('\n') };
|
||||
await planGddApprovalPromise;
|
||||
if (planGddApprovalError) throw planGddApprovalError;
|
||||
return {
|
||||
...result,
|
||||
turnReportOutput: reportLines.join('\n'),
|
||||
planGddApproval,
|
||||
};
|
||||
} finally {
|
||||
setActiveChild(null);
|
||||
}
|
||||
@@ -1645,6 +1770,176 @@ export async function validateSwarmProjectArtifacts(projectPath, options) {
|
||||
return inspection;
|
||||
}
|
||||
|
||||
// 这四条路径的权威定义都在 Rust 侧 `planning_storage.rs`(`PLAN_SESSION_PATH`、
|
||||
// `PLAN_GDD_INDEX_PATH`、`PLAN_STORAGE_ROOT`、`PLAN_FAST_GDD_PATH`)。跨语言没有共享
|
||||
// 常量的通道,改路径时要连同 `GddApprovalCard.tsx` 一起动。
|
||||
export const planningOutputPaths = [
|
||||
'.agent/planning/session.json',
|
||||
'.agent/planning/index.json',
|
||||
'.agent/planning/pending.json',
|
||||
'game/fast_gdd.md',
|
||||
];
|
||||
|
||||
export async function inspectPlanningOutputs(projectPath) {
|
||||
const outputs = [];
|
||||
for (const relativePath of planningOutputPaths) {
|
||||
const absolutePath = path.join(projectPath, ...relativePath.split('/'));
|
||||
const metadata = await lstat(absolutePath).catch((error) => {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
outputs.push({
|
||||
path: relativePath,
|
||||
exists: Boolean(metadata?.isFile()),
|
||||
bytes: metadata?.isFile() ? metadata.size : 0,
|
||||
});
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
async function reportPlanningOutputs(projectPath) {
|
||||
const outputs = await inspectPlanningOutputs(projectPath);
|
||||
console.log('\n立项策划产物:');
|
||||
for (const output of outputs) {
|
||||
console.log(
|
||||
output.exists
|
||||
? ` [有] ${output.path}(${output.bytes} 字节)`
|
||||
: ` [无] ${output.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const planGddApprovalTimeoutMs = 60_000;
|
||||
export const planGddStatusOutputPrefix = 'planGddStateJson=';
|
||||
export const planGddDecisionOutputPrefix = 'planGddDecisionJson=';
|
||||
|
||||
function parsePrefixedJsonLine(output, prefix, label) {
|
||||
const line = output
|
||||
.split('\n')
|
||||
.map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value))
|
||||
.find((value) => value.startsWith(prefix));
|
||||
if (!line) throw new Error(`${label}缺少 ${prefix} 输出`);
|
||||
try {
|
||||
return JSON.parse(line.slice(prefix.length));
|
||||
} catch (error) {
|
||||
throw new Error(`解析${label}失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePlanGddStatusOutput(output) {
|
||||
return parsePrefixedJsonLine(
|
||||
output,
|
||||
planGddStatusOutputPrefix,
|
||||
'Fast GDD 审批状态',
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePlanGddDecisionOutput(output) {
|
||||
return parsePrefixedJsonLine(
|
||||
output,
|
||||
planGddDecisionOutputPrefix,
|
||||
'Fast GDD 审批回执',
|
||||
);
|
||||
}
|
||||
|
||||
// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张
|
||||
// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据
|
||||
// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。
|
||||
export function planGddAutoApprovalIsPending(state) {
|
||||
return Boolean(state?.pendingApproval);
|
||||
}
|
||||
|
||||
async function settlePlanGddApproval(
|
||||
projectPath,
|
||||
runtimeConfigPath,
|
||||
setActiveChild,
|
||||
) {
|
||||
const readStatus = async () => {
|
||||
const result = await runCapturedCargo(
|
||||
['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批状态查询',
|
||||
},
|
||||
);
|
||||
if (result.code !== 0 || result.signal) {
|
||||
throw new Error(
|
||||
`读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
return parsePlanGddStatusOutput(result.stdout);
|
||||
};
|
||||
|
||||
const before = await readStatus();
|
||||
if (!planGddAutoApprovalIsPending(before)) {
|
||||
return { decided: false, state: before };
|
||||
}
|
||||
const decision = await runCapturedCargo(
|
||||
[
|
||||
'--config-dir',
|
||||
runtimeConfigPath,
|
||||
'--plan-gdd-decide',
|
||||
projectPath,
|
||||
'approve',
|
||||
],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批决定',
|
||||
},
|
||||
);
|
||||
if (decision.code !== 0 || decision.signal) {
|
||||
throw new Error(
|
||||
`提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
const receipt = parsePlanGddDecisionOutput(decision.stdout);
|
||||
// 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending,
|
||||
// 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有
|
||||
// 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。
|
||||
let recovered = false;
|
||||
if (receipt.recoveryPending) {
|
||||
const resume = await runCapturedCargo(
|
||||
['--config-dir', runtimeConfigPath, '--agent-resume', projectPath],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批后恢复后台任务',
|
||||
},
|
||||
);
|
||||
if (resume.code !== 0 || resume.signal) {
|
||||
throw new Error(
|
||||
`审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
recovered = true;
|
||||
}
|
||||
return { decided: true, receipt, recovered, state: await readStatus() };
|
||||
}
|
||||
|
||||
async function reportPlanGddApproval(approval) {
|
||||
const { state } = approval;
|
||||
console.log('\nFast GDD 审批:');
|
||||
if (!approval.decided) {
|
||||
console.log(` [无待决定审批] 当前投影状态=${state.state}`);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
|
||||
);
|
||||
if (approval.recovered) {
|
||||
console.log(
|
||||
' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口',
|
||||
);
|
||||
}
|
||||
if (state.session) {
|
||||
console.log(
|
||||
` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasConfiguredEditorApiKey(configDir) {
|
||||
let configured = false;
|
||||
for (const fileName of [configFileName, localConfigFileName]) {
|
||||
@@ -1797,17 +2092,29 @@ export async function runSwarmTestChat(options) {
|
||||
const setActiveChild = (child) => {
|
||||
activeChild = child;
|
||||
};
|
||||
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
|
||||
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
|
||||
const concurrentChildren = new Set();
|
||||
const setConcurrentChild = (child) => {
|
||||
if (child) concurrentChildren.add(child);
|
||||
else concurrentChildren.clear();
|
||||
};
|
||||
const stopRequested = () => receivedSignal !== null;
|
||||
const handleSignal = (signal) => {
|
||||
const repeatedSignal = receivedSignal !== null;
|
||||
receivedSignal ??= signal;
|
||||
if (!activeChild) return;
|
||||
void terminateChildTree(activeChild, signal, repeatedSignal).catch(
|
||||
() => {},
|
||||
);
|
||||
const targets = [activeChild, ...concurrentChildren].filter(Boolean);
|
||||
if (targets.length === 0) return;
|
||||
for (const target of targets) {
|
||||
void terminateChildTree(target, signal, repeatedSignal).catch(() => {});
|
||||
}
|
||||
if (repeatedSignal) return;
|
||||
forceTerminationHandle = setTimeout(() => {
|
||||
void terminateChildTree(activeChild, 'SIGKILL', true).catch(() => {});
|
||||
for (const target of [activeChild, ...concurrentChildren].filter(
|
||||
Boolean,
|
||||
)) {
|
||||
void terminateChildTree(target, 'SIGKILL', true).catch(() => {});
|
||||
}
|
||||
}, childTerminationGraceMs);
|
||||
forceTerminationHandle.unref();
|
||||
};
|
||||
@@ -1869,10 +2176,11 @@ export async function runSwarmTestChat(options) {
|
||||
);
|
||||
}
|
||||
console.log('LLM 配置已就绪。');
|
||||
const requirementNoun = options.plan ? '立项策划需求' : '游戏需求';
|
||||
console.log(
|
||||
options.task
|
||||
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n'
|
||||
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n',
|
||||
? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n`
|
||||
: `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`,
|
||||
);
|
||||
|
||||
phase = 'chat';
|
||||
@@ -1882,7 +2190,8 @@ export async function runSwarmTestChat(options) {
|
||||
runtimeConfig.path,
|
||||
'--swarm-chat',
|
||||
'--init',
|
||||
'--autonomous-game-build',
|
||||
// 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。
|
||||
options.plan ? '--plan' : '--autonomous-game-build',
|
||||
project.path,
|
||||
];
|
||||
let chat;
|
||||
@@ -1895,6 +2204,15 @@ export async function runSwarmTestChat(options) {
|
||||
timeoutDeadline === null
|
||||
? null
|
||||
: Math.max(1, timeoutDeadline - Date.now()),
|
||||
options.plan,
|
||||
options.plan
|
||||
? () =>
|
||||
settlePlanGddApproval(
|
||||
project.path,
|
||||
runtimeConfig.path,
|
||||
setConcurrentChild,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: await runInteractiveCargo(chatArguments, setActiveChild);
|
||||
} catch (error) {
|
||||
@@ -1910,6 +2228,31 @@ export async function runSwarmTestChat(options) {
|
||||
if (options.task) {
|
||||
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput);
|
||||
}
|
||||
if (options.plan) {
|
||||
// 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况,
|
||||
// 是否收束已经由 CLI 的退出码判过了。
|
||||
// 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D
|
||||
// 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。
|
||||
phase = 'plan-approval';
|
||||
const approval =
|
||||
chat.planGddApproval ??
|
||||
(await settlePlanGddApproval(
|
||||
project.path,
|
||||
runtimeConfig.path,
|
||||
setConcurrentChild,
|
||||
));
|
||||
if (receivedSignal) break session;
|
||||
phase = 'plan-report';
|
||||
await reportPlanGddApproval(approval);
|
||||
await reportPlanningOutputs(project.path);
|
||||
phase = 'complete';
|
||||
console.log(
|
||||
approval.decided
|
||||
? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。'
|
||||
: '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。',
|
||||
);
|
||||
break session;
|
||||
}
|
||||
phase = 'artifact-validation';
|
||||
const requireEditorImages = await hasConfiguredEditorApiKey(
|
||||
runtimeConfig.path,
|
||||
|
||||
@@ -1548,11 +1548,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(
|
||||
tauriConfig.build?.beforeDevCommand?.includes(
|
||||
'run ai-game-creator-shell:dev-server',
|
||||
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
||||
)) {
|
||||
if (
|
||||
!(
|
||||
tauriConfig.build?.beforeDevCommand?.includes(
|
||||
'run ai-game-creator-shell:dev-server',
|
||||
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
|
||||
);
|
||||
|
||||
@@ -39,6 +39,11 @@ struct PromptBundleManifest {
|
||||
struct PromptCompositions {
|
||||
runtime: Vec<String>,
|
||||
supervisor: Vec<String>,
|
||||
/// 立项策划根 run 的 Supervisor system prompt。它不是 `supervisor` 的差集,
|
||||
/// 而是一份独立的完整清单:plan 根的工具面只有 7 个原生工具,专业组、
|
||||
/// isolated child、任务图与视觉产物合同在这条链路上全部不可执行,逐段
|
||||
/// 减法会把「plan 根到底看到什么」摊在两个函数的四个否定分支里。
|
||||
supervisor_plan: Vec<String>,
|
||||
supervisor_chat: SupervisorChatComposition,
|
||||
}
|
||||
|
||||
@@ -109,6 +114,10 @@ struct ProviderFragments {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AgentCatalog {
|
||||
supervisor: AgentGroup,
|
||||
/// 立项策划子 Agent。与 `supervisor` 平级、**不进 `groups`**:`specialist_nodes`
|
||||
/// 只从 `groups[].roles[]` 派生,因此它不参与 `build.rs` 与种子 DAG 的一致性
|
||||
/// 校验,「做游戏」的 16 任务 DAG 一行不动。详见技术方案第 3.1 节。
|
||||
planning: AgentGroup,
|
||||
groups: Vec<AgentGroup>,
|
||||
}
|
||||
|
||||
@@ -237,6 +246,12 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
|
||||
§ions,
|
||||
&["$base", "$visualContract"],
|
||||
)?;
|
||||
validate_composition(
|
||||
"supervisorPlan",
|
||||
&manifest.compositions.supervisor_plan,
|
||||
§ions,
|
||||
&["$header"],
|
||||
)?;
|
||||
validate_section_reference(
|
||||
&manifest.compositions.supervisor_chat.identity,
|
||||
§ions,
|
||||
@@ -299,6 +314,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
|
||||
.supervisor
|
||||
.roles
|
||||
.iter()
|
||||
.chain(manifest.agent_catalog.planning.roles.iter())
|
||||
.chain(
|
||||
manifest
|
||||
.agent_catalog
|
||||
@@ -354,6 +370,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
|
||||
.runtime
|
||||
.iter()
|
||||
.chain(manifest.compositions.supervisor.iter())
|
||||
.chain(manifest.compositions.supervisor_plan.iter())
|
||||
.filter(|item| !item.starts_with('$'))
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
@@ -426,6 +443,14 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str
|
||||
{
|
||||
register("composition supervisor", section);
|
||||
}
|
||||
for section in manifest
|
||||
.compositions
|
||||
.supervisor_plan
|
||||
.iter()
|
||||
.filter(|section| !section.starts_with('$'))
|
||||
{
|
||||
register("composition supervisorPlan", section);
|
||||
}
|
||||
register("composition supervisorChat.identity", identity);
|
||||
register(
|
||||
"composition supervisorChat.finalReply",
|
||||
@@ -454,9 +479,19 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str
|
||||
"composition supervisor",
|
||||
"composition supervisorChat.identity",
|
||||
]);
|
||||
// plan 根 composition 是 Supervisor system prompt 的第二条 lane,不是另一种
|
||||
// 语义面。它按设计复用 runtime lane 的 `isolatedAgentContract`(`agent.delegate`
|
||||
// 的 expectedArtifacts/writeScopes 合同)和 supervisor lane 的 `supervisorRepair`
|
||||
// (返工必须逐字继承原合同)。除这两个方向外,跨所有者复用仍然是错误。
|
||||
let allowed_plan_runtime_owners =
|
||||
BTreeSet::from(["composition runtime", "composition supervisorPlan"]);
|
||||
let allowed_plan_supervisor_owners =
|
||||
BTreeSet::from(["composition supervisor", "composition supervisorPlan"]);
|
||||
for (section, section_owners) in owners {
|
||||
if section_owners.len() > 1
|
||||
&& !(section == identity && section_owners == allowed_identity_owners)
|
||||
&& section_owners != allowed_plan_runtime_owners
|
||||
&& section_owners != allowed_plan_supervisor_owners
|
||||
{
|
||||
return Err(format!(
|
||||
"Prompt section 跨语义所有者复用:{section} -> {section_owners:?}"
|
||||
@@ -693,11 +728,17 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> {
|
||||
if catalog.supervisor.roles.len() != 1 {
|
||||
return Err("agentCatalog.supervisor 必须且只能包含一个 role".to_string());
|
||||
}
|
||||
if catalog.planning.roles.len() != 1 {
|
||||
return Err("agentCatalog.planning 必须且只能包含一个 role".to_string());
|
||||
}
|
||||
if catalog.groups.is_empty() {
|
||||
return Err("agentCatalog.groups 不能为空".to_string());
|
||||
}
|
||||
let mut group_brief_names = BTreeSet::new();
|
||||
for group in std::iter::once(&catalog.supervisor).chain(catalog.groups.iter()) {
|
||||
for group in std::iter::once(&catalog.supervisor)
|
||||
.chain(std::iter::once(&catalog.planning))
|
||||
.chain(catalog.groups.iter())
|
||||
{
|
||||
if !group_brief_names.insert(group.brief_path_name.as_str()) {
|
||||
return Err(format!(
|
||||
"agent group briefPathName 重复:{}",
|
||||
@@ -705,7 +746,10 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> {
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut generated_names = BTreeSet::from(["PROJECT_SUPERVISOR".to_string()]);
|
||||
let mut generated_names = BTreeSet::from([
|
||||
"PROJECT_SUPERVISOR".to_string(),
|
||||
"PROJECT_PLANNING".to_string(),
|
||||
]);
|
||||
for group in &catalog.groups {
|
||||
let generated = rust_identifier(&group.id);
|
||||
if !generated
|
||||
@@ -731,6 +775,12 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> {
|
||||
&mut task_ids,
|
||||
&mut tool_ids,
|
||||
)?;
|
||||
validate_agent_group(
|
||||
&catalog.planning,
|
||||
&mut group_ids,
|
||||
&mut task_ids,
|
||||
&mut tool_ids,
|
||||
)?;
|
||||
for group in &catalog.groups {
|
||||
validate_agent_group(group, &mut group_ids, &mut task_ids, &mut tool_ids)?;
|
||||
}
|
||||
@@ -892,6 +942,10 @@ fn render_rust(manifest: &PromptBundleManifest, sections: &BTreeMap<String, Stri
|
||||
"RUNTIME_PROMPT_SUPERVISOR_COMPOSITION",
|
||||
&manifest.compositions.supervisor,
|
||||
));
|
||||
output.push_str(&render_string_slice_const(
|
||||
"RUNTIME_PROMPT_SUPERVISOR_PLAN_COMPOSITION",
|
||||
&manifest.compositions.supervisor_plan,
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"pub(crate) const RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION: &[&str] = &[{}, {}];\n",
|
||||
rust_literal(&manifest.compositions.supervisor_chat.identity),
|
||||
@@ -990,6 +1044,26 @@ fn render_agent_catalog(catalog: &AgentCatalog) -> String {
|
||||
"static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = {};\n",
|
||||
render_group_value(&catalog.supervisor, "&PROJECT_SUPERVISOR_AGENT_ROLES")
|
||||
));
|
||||
let planning_role = &catalog.planning.roles[0];
|
||||
output.push_str(&format!(
|
||||
"pub(crate) const GAME_CREATOR_PROJECT_PLANNING_AGENT_ID: &str = {};\n",
|
||||
rust_literal(&planning_role.task_id)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"pub(crate) const GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH: &str = {};\n",
|
||||
rust_literal(&format!(
|
||||
"memory/agents/{}",
|
||||
catalog.planning.brief_path_name
|
||||
))
|
||||
));
|
||||
output.push_str(&render_role_array(
|
||||
"PROJECT_PLANNING_AGENT_ROLES",
|
||||
&catalog.planning.roles,
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"static PROJECT_PLANNING_AGENT_DEFINITION: AgentGroupDefinition = {};\n",
|
||||
render_group_value(&catalog.planning, "&PROJECT_PLANNING_AGENT_ROLES")
|
||||
));
|
||||
for group in &catalog.groups {
|
||||
let roles_name = format!("{}_AGENT_ROLES", rust_identifier(&group.id));
|
||||
output.push_str(&render_role_array(&roles_name, &group.roles));
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
"supervisorVisualWithEditor": "supervisor/visual-contract-with-editor.md",
|
||||
"supervisorPlaybook": "supervisor/playbook.md",
|
||||
"supervisorClaimGate": "supervisor/claim-gate.md",
|
||||
"supervisorRepair": "supervisor/repair.md"
|
||||
"supervisorRepair": "supervisor/repair.md",
|
||||
"projectPlanningRoleBrief": "roles/project-planning.md",
|
||||
"planCommon": "plan/common.md",
|
||||
"planSupervisorIdentity": "plan/supervisor-identity.md",
|
||||
"planSupervisorPlaybook": "plan/supervisor-playbook.md"
|
||||
},
|
||||
"compositions": {
|
||||
"runtime": [
|
||||
@@ -45,6 +49,14 @@
|
||||
"supervisorClaimGate",
|
||||
"supervisorRepair"
|
||||
],
|
||||
"supervisorPlan": [
|
||||
"$header",
|
||||
"planCommon",
|
||||
"isolatedAgentContract",
|
||||
"planSupervisorIdentity",
|
||||
"planSupervisorPlaybook",
|
||||
"supervisorRepair"
|
||||
],
|
||||
"supervisorChat": {
|
||||
"identity": "supervisorIdentityContract",
|
||||
"finalReply": "supervisorFinalReplyContract"
|
||||
@@ -64,12 +76,22 @@
|
||||
{
|
||||
"agentId": "project-supervisor",
|
||||
"rootSourceKind": "supervisorGameChat",
|
||||
"sections": ["projectSupervisorGameChatRouting"]
|
||||
"sections": [
|
||||
"projectSupervisorGameChatRouting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"agentId": "code-prototype",
|
||||
"rootSourceKind": "supervisorGameChat",
|
||||
"sections": ["codePrototypeGameChat"]
|
||||
"sections": [
|
||||
"codePrototypeGameChat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"agentId": "project-planning",
|
||||
"sections": [
|
||||
"projectPlanningRoleBrief"
|
||||
]
|
||||
}
|
||||
],
|
||||
"providerFragments": {
|
||||
@@ -98,6 +120,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"planning": {
|
||||
"id": "planning",
|
||||
"label": "立项策划",
|
||||
"role": "Project Planning",
|
||||
"briefPathName": "project-planning.md",
|
||||
"roles": [
|
||||
{
|
||||
"id": "project-planning",
|
||||
"role": "Project Planning",
|
||||
"taskId": "project-planning",
|
||||
"toolId": "agent.runtime.project-planning",
|
||||
"briefPathName": "project-planning.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"id": "design",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
用户只描述玩法类型、机制或相似体验时,不代表授权复刻现有游戏。所有专业 Agent 必须创建原创标题、阵营、资源、单位名称、角色造型、界面术语和视觉语言;禁止沿用、翻译或近似改写现有游戏的专有角色、单位名、Logo、贴图、标志性布局与受保护视觉语言。除非用户明确提供有权使用的项目内素材,否则不得把 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防元素写入策划、记忆、代码、图片提示或正式产物。
|
||||
|
||||
静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId/continuationOfDelegationId/questionsSha256/answersSha256,普通委派后三项传 null。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready、needs-user-input 与 needs-repair;前者仍需语义验收,needs-repair 不能作为成功。专业 Agent 若缺少会实质改变结果的用户事实,不能调用 user.input_request,必须以最终回复首行 `AGC_NEEDS_USER_INPUT_V1`,下一行短 JSON `{"questions":[...]}` 返回 1-3 个结构化问题;Runtime 会把它作为内部回执交给 Supervisor。Supervisor 对每个原 delivery 逐一用现有 user.input_request 提问,收齐对应答案后最多创建一次 continuation 委派,并同时提交 continuationOfDelegationId、questionsSha256、answersSha256;Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation。
|
||||
|
||||
|
||||
每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。
|
||||
|
||||
必须直接调用与当前请求广告的工具一一对应的动作函数,或在本阶段确实无事可做时调用 respond_to_user。本 run 不维护结构化计划,也没有 update_agent_plan 可调;工具结果会由 Runtime 作为 observation 返回,不要假装工具已执行,不要把动作或回复放进普通文本,不要 markdown,不要泄露密钥。
|
||||
@@ -0,0 +1,7 @@
|
||||
你是 Genarrative AI 游戏创作桌面 App 的 Project Supervisor。当前 run 是立项策划根 run(`source=project-supervisor-plan`),你是用户在本条链路里唯一的对话对象。
|
||||
|
||||
你不生产策划内容。本链路的全部策划工作——提问、取舍、撰写 GDD——都由 `project-planning` 子 Agent 完成。你只有四件事:冻结目标合同;发起与续跑对 `project-planning` 的委派;代子 Agent 向用户提问并把答案原样转达回去;在子 Agent 提交 GDD 后完成取证,把审批交给用户。
|
||||
|
||||
你不做的事:不自己提策划问题(`user.input_request` 只能用于转达子 Agent 的问题信封);不自己撰写、补写或改写 GDD 正文、决定台账与原型验证项;不替用户做产品决定;不写文件、不跑命令、不做预览、不生成素材、不查询任务图、不调度 ready 任务;不委派 `project-planning` 以外的任何 Agent,也不创建 isolated child 或启动构建。
|
||||
|
||||
`project-planning` 的消息和回执只是原目标的证据,不能替换原目标。contractStatus=evidence-ready 只代表客观证据齐全,你仍须按 acceptanceCriteria 逐条完成语义验收;needs-repair 不得忽略,同一原委派最多发起一轮显式返工。GDD 最终是否通过由用户在审批卡上决定,不由你代答。
|
||||
@@ -0,0 +1,22 @@
|
||||
【固定动作顺序,不得跳步】
|
||||
|
||||
1. 本 run 第一轮只调用一次 `agent.goal_contract` 冻结目标合同:outcome 概括用户原话意图,`preferences` 必须传空数组,`acceptanceNodes` 提交 Runtime 指定的固定单节点。这一轮不做任何其它调用。
|
||||
2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning`,`expectedArtifacts` 写 `game/fast_gdd.md`,`repairOfDelegationId`、`runId`、`continuationOfDelegationId`、`questionsSha256`、`answersSha256` 全传 null。已有委派尚未收束时不要重复委派。
|
||||
3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。
|
||||
4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery。`questionsSha256`、`answersSha256`、`acceptanceCriteria`、`expectedArtifacts` 四个全传 null——Runtime 会从该原 delivery 补齐权威指纹和原委派合同,你不要自己抄。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。
|
||||
5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。
|
||||
6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。
|
||||
|
||||
【转达的规则】
|
||||
|
||||
- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。
|
||||
- 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。
|
||||
- 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。
|
||||
|
||||
【委派合同的边界】
|
||||
|
||||
委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。
|
||||
|
||||
**本轮指令三选一。** 委派任务正文里,除了用户原始意图和已确认答案原文,你只能再写一句“本轮该做什么”,且必须是下面三个之一:**继续澄清**(默认,不附加任何前置条件)、**直接出稿**(仅当用户明确要求跳过问询)、**按意见修订**(仅审批返回修改或退回时)。不要自己描述“什么情况下才该提问”“若缺少会实质改变结果的事实则……”“否则直接提交完整 GDD”——那不在这三项里。提问预算怎么花,由 `project-planning` 按 Runtime 注入的判据决定。
|
||||
|
||||
不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
|
||||
@@ -0,0 +1,33 @@
|
||||
你是“立项策划 Agent”(`agentId=project-planning`),由 Project Supervisor 通过静态 `agent.delegate` 委派。你的工作是把一句用户需求收敛成可审批的 MVP Fast GDD;你只负责玩法澄清、原型验证建议和最小 GDD,不负责完整游戏构建。
|
||||
|
||||
## 身份与边界
|
||||
|
||||
- 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。
|
||||
- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。
|
||||
- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。
|
||||
- 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。
|
||||
|
||||
## 目标与轮次
|
||||
|
||||
- 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。
|
||||
- **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。
|
||||
- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断**且影响首个可玩闭环**的 = 空白。提问名额只花在空白项上;有默认建议兜底的字段一律先用默认建议,不占轮次。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。
|
||||
- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。
|
||||
- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。
|
||||
- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。
|
||||
|
||||
## 低幻觉与 GDD 约束
|
||||
|
||||
- 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。
|
||||
- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。
|
||||
- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。
|
||||
- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。
|
||||
- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。
|
||||
- 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。
|
||||
- GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。
|
||||
- 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。
|
||||
|
||||
## 输出纪律
|
||||
|
||||
- 澄清模式只返回 `AGC_NEEDS_USER_INPUT_V1` 终态信封,不再调用其他函数;成稿模式只在 `plan.submit_gdd` 被广告时调用它并等待 Runtime 校验;收到 revise/reject observation 后按同一 GDD 谱系修订,收到 approve 后只做简短收尾。
|
||||
- 必须直接调用当前请求广告的原生函数;不要输出 JSON、代码围栏或内部思考过程,不要假装已经写入文件、完成审批或启动构建。
|
||||
@@ -2,4 +2,8 @@
|
||||
|
||||
需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
|
||||
|
||||
委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。
|
||||
|
||||
对 `project-planning` 的澄清 continuation,必须按 A/B/“需要原型验证”三项合同原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。若用户后续自由填写推翻已确认决定,保留用户答案原文逐字不改写、不拆分、不搬轮次,并在被推翻决定后注明“已被第 N 轮回答推翻,以后者为准”,在新决定 topic 中写明推翻关系。
|
||||
|
||||
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
|
||||
|
||||
@@ -2619,6 +2619,8 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
request_slot: "direct-chat".to_string(),
|
||||
web_search_enabled: config.llm.web_search_enabled,
|
||||
allow_idle_context_compaction: false,
|
||||
// direct-codex 不是立项策划链路,没有 planning session 可绑定。
|
||||
planning_session_binding: None,
|
||||
};
|
||||
let api_kind =
|
||||
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
|
||||
@@ -2668,6 +2670,8 @@ pub(crate) async fn direct_game_creator_home_codex_chat(
|
||||
request_slot: "direct-home-chat".to_string(),
|
||||
web_search_enabled: config.llm.web_search_enabled,
|
||||
allow_idle_context_compaction: false,
|
||||
// 直连 Codex 的首页对话不属于任何立项策划 session。
|
||||
planning_session_binding: None,
|
||||
};
|
||||
let api_kind =
|
||||
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
|
||||
@@ -2789,6 +2793,7 @@ mod tests {
|
||||
request_slot: "slot-1".to_string(),
|
||||
web_search_enabled: false,
|
||||
allow_idle_context_compaction: false,
|
||||
planning_session_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -336,6 +336,9 @@ pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result<
|
||||
if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string());
|
||||
}
|
||||
if task_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return Ok(GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH.to_string());
|
||||
}
|
||||
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
|
||||
for role in group.roles {
|
||||
if role.task_id == task_id {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ pub(in crate::agent) use run_status_observation::*;
|
||||
pub(in crate::agent) use structured_plan::*;
|
||||
pub(in crate::agent) use tool_plan_protocol::*;
|
||||
|
||||
pub(crate) use crate::agent::runtime_protocol::plan_gdd_completion_blocker_at_locked;
|
||||
#[cfg(test)]
|
||||
pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test;
|
||||
#[cfg(test)]
|
||||
@@ -72,7 +73,9 @@ pub(crate) use autonomous_policy::{
|
||||
pub(crate) use context_compaction::compact_game_creator_agent_runtime_session_at;
|
||||
pub(crate) use parallel_ledger::{
|
||||
agent_runtime_confirmation_path_component, agent_runtime_parallel_read_batch_len,
|
||||
agent_runtime_tool_is_parallel_safe_read, game_creator_agent_runtime_parallel_read_batch_path,
|
||||
agent_runtime_tool_allowed_for_agent, agent_runtime_tool_is_parallel_safe_read,
|
||||
agent_runtime_tool_rejected_by_agent_identity,
|
||||
game_creator_agent_runtime_parallel_read_batch_path,
|
||||
game_creator_agent_runtime_pending_tool_action_path,
|
||||
game_creator_agent_runtime_provider_action_batch_path,
|
||||
};
|
||||
@@ -113,6 +116,7 @@ pub(crate) use project_gates::{
|
||||
};
|
||||
pub(crate) use provider_action_batch::{
|
||||
prepare_game_creator_agent_runtime_provider_action_batch,
|
||||
prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding,
|
||||
update_game_creator_agent_runtime_provider_batch_member, AgentRuntimePendingToolAction,
|
||||
AgentRuntimeProviderActionBatch,
|
||||
};
|
||||
@@ -131,17 +135,28 @@ pub(crate) use response_stream::filter_agent_runtime_response_stream_for_test;
|
||||
pub(crate) use structured_plan::{
|
||||
activate_agent_runtime_plan_step, activate_agent_runtime_response_plan_step,
|
||||
apply_agent_runtime_plan_update, complete_agent_runtime_active_plan_step,
|
||||
complete_agent_runtime_remaining_plan_steps, retry_agent_runtime_active_plan_step,
|
||||
sanitize_agent_runtime_plan_update,
|
||||
complete_agent_runtime_remaining_plan_steps, plan_update_idle_rounds_require_repair,
|
||||
retry_agent_runtime_active_plan_step, sanitize_agent_runtime_plan_update,
|
||||
AgentRuntimePlanUpdateOutcome,
|
||||
};
|
||||
// 不带 agentId 的三个解析入口走 `"__all_agents__"` 哨兵、跳过按身份的工具面
|
||||
// 复核,只对测试开放;生产代码必须用 `_for_agent`。
|
||||
#[cfg(test)]
|
||||
pub(crate) use tool_plan_protocol::{
|
||||
parse_game_creator_agent_tool_plan_llm_response,
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog,
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified,
|
||||
};
|
||||
pub(crate) use tool_plan_protocol::{
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent,
|
||||
parse_game_creator_agent_tool_plan_response,
|
||||
};
|
||||
pub(crate) use tool_policy_snapshot::{
|
||||
agent_runtime_acceptance_evidence_tools, agent_runtime_executable_tools,
|
||||
agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at,
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
|
||||
agent_runtime_acceptance_evidence_tools,
|
||||
agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools,
|
||||
agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools,
|
||||
agent_runtime_plan_root_supervisor_tools_for_stage,
|
||||
agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at,
|
||||
PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
|
||||
AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
|
||||
};
|
||||
|
||||
@@ -573,6 +573,28 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
|
||||
}))
|
||||
.ok();
|
||||
}
|
||||
if observation.tool == "file.read" {
|
||||
let first_line = observation.detail.as_deref()?.lines().next()?.trim();
|
||||
let mut fields = first_line.split('·').map(str::trim);
|
||||
let path = normalize_relative_path(fields.next()?).ok()?;
|
||||
let sha_field = fields.next()?;
|
||||
let content_sha256 = sha_field.strip_prefix("sha256=")?;
|
||||
if content_sha256.len() != 64
|
||||
|| !content_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let lines = fields.next()?.strip_prefix("lines ")?;
|
||||
if lines.is_empty() || path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return serde_json::to_string(&serde_json::json!({
|
||||
"path": path,
|
||||
"contentSha256": content_sha256,
|
||||
"lines": lines,
|
||||
}))
|
||||
.ok();
|
||||
}
|
||||
if observation.tool == GAME_CREATOR_MCP_CALL_TOOL {
|
||||
return game_creator_mcp_public_result_metadata(
|
||||
observation.detail.as_deref().unwrap_or_default(),
|
||||
|
||||
+216
-14
@@ -37,6 +37,29 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let tool = action.tool.trim();
|
||||
if tool == PLAN_SUBMIT_GDD_TOOL && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: tool.to_string(),
|
||||
status: "rejected".to_string(),
|
||||
summary: "plan.submit_gdd 仅允许 project-planning Agent".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
&& !matches!(tool, "file.read" | "file.list")
|
||||
{
|
||||
// `plan.submit_gdd` is intentionally handled by the planning submit
|
||||
// branch in the Runtime main loop. If it ever reaches the generic
|
||||
// executor (including recovery or a stale pending record), fail
|
||||
// closed instead of treating the durable mutation as an ordinary
|
||||
// command action.
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: tool.to_string(),
|
||||
status: "rejected".to_string(),
|
||||
summary: "当前 Agent 身份不允许执行该工具".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
let action_fingerprint = pending_action
|
||||
.map(|pending| {
|
||||
agent_runtime_pending_tool_action_fingerprint(
|
||||
@@ -260,16 +283,25 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
false,
|
||||
|| observe_agent_runtime_file_list(root, &action.input),
|
||||
),
|
||||
"file.read" => observe_agent_runtime_project_snapshot_with_lock(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
action,
|
||||
&action_fingerprint,
|
||||
pending_action,
|
||||
false,
|
||||
|| observe_agent_runtime_file(root, &action.input),
|
||||
),
|
||||
"file.read" => {
|
||||
let mut observation = observe_agent_runtime_project_snapshot_with_lock(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
action,
|
||||
&action_fingerprint,
|
||||
pending_action,
|
||||
false,
|
||||
|| observe_agent_runtime_file(root, &action.input),
|
||||
);
|
||||
append_agent_runtime_file_read_evidence_ref(
|
||||
&mut observation,
|
||||
agent_id,
|
||||
run_id,
|
||||
action_id,
|
||||
);
|
||||
observation
|
||||
}
|
||||
"file.write" => observe_agent_runtime_file_write(
|
||||
root,
|
||||
agent_id,
|
||||
@@ -406,7 +438,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
"agent.message" => {
|
||||
observe_agent_runtime_agent_message(root, agent_id, run_id, &action.input)
|
||||
}
|
||||
"agent.delegate" => observe_agent_runtime_project_snapshot_with_lock(
|
||||
"agent.delegate" => observe_agent_runtime_project_snapshot_with_lock_guard(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
@@ -414,13 +446,14 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
&action_fingerprint,
|
||||
pending_action,
|
||||
true,
|
||||
|| {
|
||||
observe_agent_runtime_agent_delegate(
|
||||
|project_write_lock| {
|
||||
observe_agent_runtime_agent_delegate_at_locked(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
action_id,
|
||||
&action.input,
|
||||
project_write_lock,
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -489,6 +522,60 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
}
|
||||
}
|
||||
|
||||
/// 让 `file.read` 的 observation 自带 durable actionId。
|
||||
///
|
||||
/// §13.0 的审批前置取证要求把每一页 `file.read` 的 actionId 放进
|
||||
/// `agent.acceptance_update.evidence`,而 observation 结构里只有
|
||||
/// `{tool,status,summary,detail}`,模型没有第二条途径拿到它——只能回头查
|
||||
/// `agent.action_history`。实测它会为了一个 actionId 连查四次:拿到 1 条怀疑漏了
|
||||
/// 分页,拿到全量又怀疑混进了别的工具,而这些结果自始至终都在它上下文里
|
||||
/// (`agent.action_history` 的 observation detail 有 8000 字符的专属额度,不会被裁)。
|
||||
/// 加 prompt 约束对这种"不敢信"没用,把取 id 这件事从工具变成事实才有用。
|
||||
///
|
||||
/// `command.exec` 早就是这么做的:durable observation 直接返回可复用的
|
||||
/// `sourceActionId`,合同里明写「不要先猜 actionId 或为取得它额外查询动作历史」。
|
||||
/// 这里把同一条路铺给 `file.read`。
|
||||
///
|
||||
/// 追加在 detail 首行末尾是安全的:`agent_runtime_action_safe_detail_value` 解析
|
||||
/// `file.read` 时只取前三个 `·` 字段(path / sha256 / lines),多出来的字段不参与,
|
||||
/// durable receipt 与既有的取证解析都不受影响。
|
||||
fn append_agent_runtime_file_read_evidence_ref(
|
||||
observation: &mut AgentRuntimeToolObservation,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
action_id: Option<&str>,
|
||||
) {
|
||||
if observation.status != "ok" {
|
||||
return;
|
||||
}
|
||||
let Some(action_id) = action_id.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
if observation.summary.contains("sourceActionId=") {
|
||||
return;
|
||||
}
|
||||
let agent_id = agent_id.trim();
|
||||
let run_id = run_id.trim();
|
||||
if agent_id.is_empty() || run_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
// 写进 **summary** 而不是 detail:`file.read` 的 detail 在投影给模型和事件流之前
|
||||
// 会被换成 durable receipt 的 safe_detail(只有 path / contentSha256 / lines 三个
|
||||
// 字段),追加在 detail 上的字段到不了模型手里。实测一次 run 里模型照着新指令
|
||||
// 满世界找 sourceActionId、一次也没看到,只能反复重读全文再猜 actionId,28 次
|
||||
// file.read、5 次 acceptance_update 才收敛。summary 是原样保留的,也没有任何
|
||||
// 解析方依赖它的形状。
|
||||
// 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用
|
||||
// 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期
|
||||
// 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次
|
||||
// 拒绝。反正 `validate_fast_gdd_evidence_identity` 只接受当前根 run 的回执,
|
||||
// 合法取值唯一,本来就不该让它猜。
|
||||
observation.summary = format!(
|
||||
"{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}",
|
||||
observation.summary
|
||||
);
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock<F>(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -501,6 +588,31 @@ pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock<F>(
|
||||
) -> AgentRuntimeToolObservation
|
||||
where
|
||||
F: FnOnce() -> AgentRuntimeToolObservation,
|
||||
{
|
||||
observe_agent_runtime_project_snapshot_with_lock_guard(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
action,
|
||||
action_fingerprint,
|
||||
pending_action,
|
||||
validate_revision_gate,
|
||||
|_| observe(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock_guard<F>(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
action: &AgentRuntimeToolAction,
|
||||
action_fingerprint: &str,
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
validate_revision_gate: bool,
|
||||
observe: F,
|
||||
) -> AgentRuntimeToolObservation
|
||||
where
|
||||
F: FnOnce(&ProjectWriteLock) -> AgentRuntimeToolObservation,
|
||||
{
|
||||
let tool = action.tool.trim();
|
||||
let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
@@ -528,7 +640,7 @@ where
|
||||
) {
|
||||
return observation;
|
||||
}
|
||||
observe()
|
||||
observe(&_lock)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_agent_runtime_project_snapshot_action_after_lock(
|
||||
@@ -814,3 +926,93 @@ mod canvas_only_execution_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod file_read_source_action_id_tests {
|
||||
use super::*;
|
||||
|
||||
fn file_read_observation() -> AgentRuntimeToolObservation {
|
||||
AgentRuntimeToolObservation {
|
||||
tool: "file.read".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: "已读取 game/fast_gdd.md 第 1-134 行(共 134 行)".to_string(),
|
||||
detail: Some(format!(
|
||||
"game/fast_gdd.md · sha256={} · lines 1-134 of 134
|
||||
第一行内容",
|
||||
"a".repeat(64)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加的 sourceActionId 必须能被模型看见,且不能破坏 durable receipt 的
|
||||
/// safe_detail 解析——取证门就是从那里读 path / contentSha256 / lines 的。
|
||||
#[test]
|
||||
fn appending_the_source_action_id_keeps_the_receipt_safe_detail_parseable() {
|
||||
let mut observation = file_read_observation();
|
||||
append_agent_runtime_file_read_evidence_ref(
|
||||
&mut observation,
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
Some("action-0123456789abcdef01234567"),
|
||||
);
|
||||
assert!(
|
||||
observation.summary.ends_with(
|
||||
"· sourceAgentId=project-supervisor · sourceRunId=run-1 · sourceActionId=action-0123456789abcdef01234567"
|
||||
),
|
||||
"evidence 三元组必须整份写在 summary 上——detail 会被换成 safe_detail,到不了模型手里;只给 actionId 则另外两个字段要靠模型回忆,实测会记错",
|
||||
);
|
||||
let detail = observation.detail.clone().expect("detail");
|
||||
assert!(!detail.contains("sourceActionId="), "detail 保持原样");
|
||||
assert!(detail.contains("第一行内容"), "首行之后的内容必须保留");
|
||||
|
||||
let root = std::env::temp_dir();
|
||||
let safe_detail = crate::agent::runtime_actions::action_audit::
|
||||
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation)
|
||||
.expect("safe detail still parses");
|
||||
let value = serde_json::from_str::<serde_json::Value>(&safe_detail).expect("json");
|
||||
assert_eq!(value["path"], "game/fast_gdd.md");
|
||||
assert_eq!(value["lines"], "1-134 of 134");
|
||||
assert_eq!(value["contentSha256"], "a".repeat(64));
|
||||
}
|
||||
|
||||
/// 失败的读取、缺 actionId、以及重复调用都不得改写 detail。
|
||||
#[test]
|
||||
fn appending_is_a_no_op_without_a_successful_read_or_an_action_id() {
|
||||
let mut failed = file_read_observation();
|
||||
failed.status = "failed".to_string();
|
||||
let before = failed.summary.clone();
|
||||
append_agent_runtime_file_read_evidence_ref(
|
||||
&mut failed,
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
Some("action-1"),
|
||||
);
|
||||
assert_eq!(failed.summary, before);
|
||||
|
||||
let mut missing = file_read_observation();
|
||||
let before = missing.summary.clone();
|
||||
append_agent_runtime_file_read_evidence_ref(
|
||||
&mut missing,
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
None,
|
||||
);
|
||||
assert_eq!(missing.summary, before);
|
||||
|
||||
let mut twice = file_read_observation();
|
||||
append_agent_runtime_file_read_evidence_ref(
|
||||
&mut twice,
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
Some("action-1"),
|
||||
);
|
||||
let once = twice.summary.clone();
|
||||
append_agent_runtime_file_read_evidence_ref(
|
||||
&mut twice,
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
Some("action-2"),
|
||||
);
|
||||
assert_eq!(twice.summary, once, "已经带了 id 就不再追加第二个");
|
||||
}
|
||||
}
|
||||
|
||||
+33
-1
@@ -4,6 +4,32 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at(
|
||||
root: &Path,
|
||||
runtime: &mut AgentRuntimeState,
|
||||
pending: &mut AgentRuntimePendingToolAction,
|
||||
) -> Result<(), String> {
|
||||
persist_game_creator_agent_user_input_wait_with_project_lock_at(root, runtime, pending, None)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at_locked(
|
||||
root: &Path,
|
||||
runtime: &mut AgentRuntimeState,
|
||||
pending: &mut AgentRuntimePendingToolAction,
|
||||
project_lock: &ProjectWriteLock,
|
||||
) -> Result<(), String> {
|
||||
if !project_lock.guards_project_root(root)? {
|
||||
return Err("持久化 planning 用户输入等待缺少当前项目写锁".to_string());
|
||||
}
|
||||
persist_game_creator_agent_user_input_wait_with_project_lock_at(
|
||||
root,
|
||||
runtime,
|
||||
pending,
|
||||
Some(project_lock),
|
||||
)
|
||||
}
|
||||
|
||||
fn persist_game_creator_agent_user_input_wait_with_project_lock_at(
|
||||
root: &Path,
|
||||
runtime: &mut AgentRuntimeState,
|
||||
pending: &mut AgentRuntimePendingToolAction,
|
||||
project_lock: Option<&ProjectWriteLock>,
|
||||
) -> Result<(), String> {
|
||||
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Err("自主构建 Run 禁止进入 waiting-for-user-input".to_string());
|
||||
@@ -13,7 +39,13 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at(
|
||||
pending.observation = None;
|
||||
pending.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_pending_tool_action(root, pending)?;
|
||||
let request = match prepare_game_creator_agent_user_input_request_at(root, pending)? {
|
||||
let recovered_user_input = match project_lock {
|
||||
Some(project_lock) => {
|
||||
prepare_game_creator_agent_user_input_request_at_locked(root, pending, project_lock)
|
||||
}
|
||||
None => prepare_game_creator_agent_user_input_request_at(root, pending),
|
||||
}?;
|
||||
let request = match recovered_user_input {
|
||||
AgentRuntimeUserInputRecovery::Waiting(request) => request,
|
||||
AgentRuntimeUserInputRecovery::Answered { .. } => {
|
||||
return Err("新建用户输入等待时 sidecar 已进入 answered,需由恢复路径继续".to_string());
|
||||
|
||||
+194
-13
@@ -269,11 +269,20 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_
|
||||
"自主构建非只读专业 Agent 必须先完成本人 run 的项目修改";
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX: &str =
|
||||
"自主构建非只读专业 Agent 必须先验证本人 run 的项目修改";
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX: &str =
|
||||
"自主构建固定 owner 的 Runtime 内部产物验证身份不可用";
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX: &str =
|
||||
"自主构建只读专业 Agent 禁止执行写入或副作用动作";
|
||||
pub(in crate::agent) const AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER: &str =
|
||||
"唯一允许的项目写动作是 canvas.asset_generate";
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_autonomous_uses_owner_artifact_validation(
|
||||
agent_id: &str,
|
||||
) -> bool {
|
||||
!autonomous_manifest_owner_artifact_paths(agent_id).is_empty()
|
||||
&& !matches!(agent_id, "code-prototype" | "publish-package")
|
||||
}
|
||||
|
||||
fn autonomous_initial_collaboration_contract_error(detail: impl AsRef<str>) -> String {
|
||||
format!(
|
||||
"{AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX};autonomous-game-build 首批协作合同无效:{}",
|
||||
@@ -606,7 +615,7 @@ pub(crate) fn refresh_agent_runtime_autonomous_convergence_snapshot_after_provid
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at(
|
||||
pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
@@ -639,17 +648,23 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at(
|
||||
&& agent_runtime_autonomous_supervisor_plan_prepares_repair(plan)
|
||||
{
|
||||
let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?;
|
||||
if barrier.ready_unclaimed_count > 0 || barrier.unobserved_claim_count > 0 {
|
||||
if barrier.ready_unclaimed_count > 0
|
||||
|| barrier.unobserved_claim_count > 0
|
||||
|| barrier.user_revision_pending_count > 0
|
||||
|| barrier.unknown_contract_status_count > 0
|
||||
{
|
||||
let active_delegations =
|
||||
active_static_delegate_delivery_count_at(root, agent_id, run_id)?;
|
||||
if is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(plan) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair",
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}、userRevisionPending={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair",
|
||||
barrier.ready_unclaimed_count,
|
||||
barrier.unobserved_claim_count,
|
||||
barrier.repair_required_count,
|
||||
barrier.user_revision_pending_count,
|
||||
barrier.unknown_contract_status_count,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -663,21 +678,34 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at(
|
||||
let active_delegations = active_static_delegate_delivery_count_at(root, agent_id, run_id)?;
|
||||
let must_claim_or_wait = barrier.ready_unclaimed_count > 0
|
||||
|| barrier.unobserved_claim_count > 0
|
||||
|| barrier.user_revision_pending_count > 0
|
||||
|| barrier.unknown_contract_status_count > 0
|
||||
|| active_delegations >= 3;
|
||||
if must_claim_or_wait {
|
||||
if is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(plan) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision",
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、userRevisionPending={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision",
|
||||
barrier.waiting_count,
|
||||
barrier.ready_unclaimed_count,
|
||||
barrier.unobserved_claim_count,
|
||||
barrier.user_revision_pending_count,
|
||||
barrier.unknown_contract_status_count,
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_game_chat_code_safe_default_repair_liveness_at(root, agent_id, run_id, plan)?;
|
||||
|
||||
if autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?
|
||||
&& !plan.response.trim().is_empty()
|
||||
{
|
||||
// 固定 pre-code 产物没有 Provider 可调用的验证动作。专业回复门确认
|
||||
// 当前 run 已有 mutation 后,即使此前累积了较长只读尾部,也必须让
|
||||
// finalization 进入 Runtime 确定性验证,不能再强迫模型伪造验证动作。
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let has_current_playtest_receipt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
let contract = read_autonomous_completion_contract(root, agent_id, run_id)?;
|
||||
match contract.as_ref() {
|
||||
@@ -1050,14 +1078,22 @@ pub(in crate::agent) fn agent_runtime_autonomous_verified_delivery_allows_plan_c
|
||||
agent_id: &str,
|
||||
verification_gate: &AgentRuntimeVerificationGate,
|
||||
) -> bool {
|
||||
agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
&& verification_gate
|
||||
.mutation_revision
|
||||
.is_some_and(|mutation_revision| {
|
||||
verification_gate
|
||||
.verified_revision
|
||||
.is_some_and(|verified_revision| verified_revision >= mutation_revision)
|
||||
})
|
||||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
return false;
|
||||
}
|
||||
let Some(mutation_revision) = verification_gate.mutation_revision else {
|
||||
return false;
|
||||
};
|
||||
if agent_id == "code-prototype"
|
||||
&& !verification_gate
|
||||
.static_smoke_verified_revision
|
||||
.is_some_and(|revision| revision >= mutation_revision)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
verification_gate
|
||||
.verified_revision
|
||||
.is_some_and(|verified_revision| verified_revision >= mutation_revision)
|
||||
&& verification_gate.last_verification_status.as_deref()
|
||||
== Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|
||||
}
|
||||
@@ -1188,9 +1224,11 @@ pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_action
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_delivery(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
read_only_delivery: bool,
|
||||
runtime_owner_artifact_validation_available: bool,
|
||||
verification_gate: &AgentRuntimeVerificationGate,
|
||||
plan: &AgentRuntimeToolPlan,
|
||||
) -> Result<(), String> {
|
||||
@@ -1208,12 +1246,42 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_de
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX};当前 respond_to_user 没有本人 run 的 mutationRevision。必须先执行实际项目修改,不能以其它 Agent 的 revision、只读检查、空验证或任务文案代替"
|
||||
));
|
||||
}
|
||||
if runtime_owner_artifact_validation_available
|
||||
&& agent_runtime_autonomous_uses_owner_artifact_validation(agent_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let trusted_game_chat_canvas_delivery = agent_id == "art-asset-plan"
|
||||
&& game_chat_delegated_art_asset_plan_uses_canvas_verification_at(root, agent_id, run_id)?;
|
||||
if agent_runtime_autonomous_uses_owner_artifact_validation(agent_id)
|
||||
&& !trusted_game_chat_canvas_delivery
|
||||
{
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX};当前 {agent_id}/{run_id} 既不是可信完整 autonomous DAG 的当前固定 owner child,也不是可信 game-chat code-prototype 的当前动态美术委派,不能借用 Runtime 内部验证或普通 Canvas 凭证"
|
||||
));
|
||||
}
|
||||
if trusted_game_chat_canvas_delivery
|
||||
&& (verification_gate.last_verification_tool.as_deref() != Some("canvas.asset_generate")
|
||||
|| verification_gate.static_smoke_verified_revision.is_some())
|
||||
{
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};可信 game-chat 动态美术委派只接受本人 canvas.asset_generate 的通过凭证,不能借用 project.verify、game.static_smoke 或其它验证"
|
||||
));
|
||||
}
|
||||
if !agent_runtime_autonomous_verified_delivery_allows_plan_completion(
|
||||
agent_id,
|
||||
verification_gate,
|
||||
) {
|
||||
let required_verification = if agent_id == "code-prototype" {
|
||||
format!(
|
||||
"code-prototype 必须由本人 run 执行 game.static_smoke,且 staticSmokeVerifiedRevision 覆盖 mutationRevision;当前 staticSmokeVerifiedRevision={:?}",
|
||||
verification_gate.static_smoke_verified_revision
|
||||
)
|
||||
} else {
|
||||
"必须先只验证本人 run 的最新 mutation revision".to_string()
|
||||
};
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};当前 mutationRevision={:?}、verifiedRevision={:?}、verificationStatus={}。必须先只验证本人 run 的最新 mutation revision,通过后才能 respond_to_user",
|
||||
"{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};当前 mutationRevision={:?}、verifiedRevision={:?}、verificationStatus={}。{required_verification},通过后才能 respond_to_user",
|
||||
verification_gate.mutation_revision,
|
||||
verification_gate.verified_revision,
|
||||
verification_gate
|
||||
@@ -2139,6 +2207,119 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_owner_delivery_uses_internal_validation_only_for_fixed_pre_code_roles() {
|
||||
let temporary = tempfile::tempdir().expect("create owner delivery policy root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "owner-delivery-policy", "owner 收束策略")
|
||||
.expect("init owner delivery policy project");
|
||||
let plan = AgentRuntimeToolPlan {
|
||||
response: "玩法规格与界面基础已经完成。".to_string(),
|
||||
..AgentRuntimeToolPlan::default()
|
||||
};
|
||||
let gate_for = |agent_id: &str, run_id: &str| AgentRuntimeVerificationGate {
|
||||
schema_version: "test".to_string(),
|
||||
project_id: "test".to_string(),
|
||||
agent_id: agent_id.to_string(),
|
||||
run_id: run_id.to_string(),
|
||||
requires_verification: true,
|
||||
mutation_revision: Some(7),
|
||||
verified_revision: None,
|
||||
last_mutation_tool: Some("file.write".to_string()),
|
||||
last_verification_tool: None,
|
||||
last_verification_status: None,
|
||||
static_smoke_verified_revision: None,
|
||||
static_smoke_verified_game_index_sha256: None,
|
||||
failed_playtest_revision: None,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
for agent_id in [
|
||||
"design-foundation",
|
||||
"balance-seed",
|
||||
"art-asset-plan",
|
||||
"audio-asset-plan",
|
||||
] {
|
||||
assert!(agent_runtime_autonomous_uses_owner_artifact_validation(
|
||||
agent_id
|
||||
));
|
||||
let run_id = format!("{agent_id}-run");
|
||||
let gate = gate_for(agent_id, &run_id);
|
||||
let unavailable_error = validate_agent_runtime_autonomous_specialist_response_delivery(
|
||||
&root, agent_id, &run_id, false, false, &gate, &plan,
|
||||
)
|
||||
.expect_err("fixed owner must not fall back to Provider-visible verification");
|
||||
assert!(unavailable_error
|
||||
.contains(AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX));
|
||||
assert!(!unavailable_error.starts_with(
|
||||
AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX
|
||||
));
|
||||
|
||||
validate_agent_runtime_autonomous_specialist_response_delivery(
|
||||
&root, agent_id, &run_id, false, true, &gate, &plan,
|
||||
)
|
||||
.expect("trusted Runtime owner-artifact validation may run inside finalization");
|
||||
|
||||
let mut manually_verified = gate.clone();
|
||||
manually_verified.verified_revision = Some(7);
|
||||
manually_verified.last_verification_tool = Some("game.static_smoke".to_string());
|
||||
manually_verified.last_verification_status =
|
||||
Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string());
|
||||
manually_verified.static_smoke_verified_revision = Some(7);
|
||||
assert!(
|
||||
validate_agent_runtime_autonomous_specialist_response_delivery(
|
||||
&root,
|
||||
agent_id,
|
||||
&run_id,
|
||||
false,
|
||||
false,
|
||||
&manually_verified,
|
||||
&plan,
|
||||
)
|
||||
.expect_err("manual smoke must not replace fixed owner Runtime validation identity")
|
||||
.contains(AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX)
|
||||
);
|
||||
|
||||
let mut wrong_run_gate = gate;
|
||||
wrong_run_gate.run_id = "other-run".to_string();
|
||||
assert!(
|
||||
validate_agent_runtime_autonomous_specialist_response_delivery(
|
||||
&root,
|
||||
agent_id,
|
||||
&run_id,
|
||||
false,
|
||||
true,
|
||||
&wrong_run_gate,
|
||||
&plan,
|
||||
)
|
||||
.expect_err("another run's gate must not authorize Runtime owner validation")
|
||||
.contains("verification gate 与当前 run 身份不匹配")
|
||||
);
|
||||
}
|
||||
|
||||
for agent_id in ["code-prototype", "publish-package", "art-director"] {
|
||||
assert!(!agent_runtime_autonomous_uses_owner_artifact_validation(
|
||||
agent_id
|
||||
));
|
||||
}
|
||||
let publish_gate = gate_for("publish-package", "publish-package-run");
|
||||
assert!(
|
||||
validate_agent_runtime_autonomous_specialist_response_delivery(
|
||||
&root,
|
||||
"publish-package",
|
||||
"publish-package-run",
|
||||
false,
|
||||
true,
|
||||
&publish_gate,
|
||||
&plan,
|
||||
)
|
||||
.expect_err(
|
||||
"caller flag must not classify publish-package as an internal-validation owner"
|
||||
)
|
||||
.contains(AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_read_only_validation_actions_are_role_scoped() {
|
||||
let plan_for = |tool: &str, input: serde_json::Value| AgentRuntimeToolPlan {
|
||||
|
||||
+38
-1
@@ -25,6 +25,11 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
root,
|
||||
"runtime.context_compaction.build",
|
||||
)?;
|
||||
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
// Advance the immutable Provider-usage projection before any
|
||||
// request/source bytes are rebuilt from the plan session.
|
||||
fold_plan_provider_usage_before_new_request_at_locked(root, Some((agent_id, run_id)))?;
|
||||
}
|
||||
let source = build_game_creator_agent_runtime_context_compaction_source(
|
||||
root,
|
||||
agent_id,
|
||||
@@ -60,7 +65,20 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id);
|
||||
let config_path = format!("agentLlm.{template_agent_id}");
|
||||
let request = build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?;
|
||||
let mut request =
|
||||
build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?;
|
||||
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
if planning_agent {
|
||||
if allow_idle_context_compaction {
|
||||
return Err(
|
||||
"project-planning 不支持脱离 active run 的 idle context compaction".to_string(),
|
||||
);
|
||||
}
|
||||
let wire_bytes =
|
||||
capture_plan_provider_structured_injections_at(root, session_id, observations)?;
|
||||
let message = render_plan_provider_structured_injections_message(&wire_bytes)?;
|
||||
request.messages.insert(1, LlmMessage::user(message));
|
||||
}
|
||||
let estimated_request_tokens = estimate_game_creator_llm_request_tokens(&request)?;
|
||||
validate_game_creator_llm_request_context_budget(
|
||||
&llm,
|
||||
@@ -88,6 +106,22 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
applied_steer_cursor,
|
||||
)?
|
||||
};
|
||||
let snapshot = if planning_agent {
|
||||
let request_context_fingerprint =
|
||||
game_creator_agent_runtime_plan_provider_request_context_fingerprint(
|
||||
&llm, &request,
|
||||
)?;
|
||||
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
|
||||
let binding = capture_plan_provider_session_binding_for_snapshot(
|
||||
root,
|
||||
&runtime,
|
||||
&snapshot,
|
||||
&request_context_fingerprint,
|
||||
)?;
|
||||
snapshot.with_planning_session_binding(Some(binding))
|
||||
} else {
|
||||
snapshot
|
||||
};
|
||||
(snapshot, source, llm, config_path, request)
|
||||
};
|
||||
let handoff_identity =
|
||||
@@ -154,6 +188,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
|
||||
root,
|
||||
&base_request_id,
|
||||
snapshot.planning_session_binding.is_some(),
|
||||
)
|
||||
.map(|value| value.0)
|
||||
.unwrap_or(base_request_id);
|
||||
@@ -177,6 +212,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
|
||||
root,
|
||||
&base_request_id,
|
||||
snapshot.planning_session_binding.is_some(),
|
||||
)
|
||||
.map(|value| value.0)
|
||||
.unwrap_or(base_request_id);
|
||||
@@ -210,6 +246,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
|
||||
root,
|
||||
&base_request_id,
|
||||
snapshot.planning_session_binding.is_some(),
|
||||
)
|
||||
.map(|value| value.0)
|
||||
.unwrap_or(base_request_id);
|
||||
|
||||
@@ -41,6 +41,9 @@ pub(in crate::agent) fn agent_runtime_parallel_read_batch_is_auto_at(
|
||||
actions: &[AgentRuntimeToolAction],
|
||||
) -> bool {
|
||||
actions.iter().all(|action| {
|
||||
if !agent_runtime_tool_allowed_for_agent(agent_id, action.tool.trim()) {
|
||||
return false;
|
||||
}
|
||||
let Some(command_id) = game_creator_agent_runtime_tool_command_id(action.tool.trim())
|
||||
else {
|
||||
return false;
|
||||
@@ -104,11 +107,125 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
|
||||
"agent.route_manifest" => Some("agent.route_manifest"),
|
||||
"agent.action_history" => Some("agent.audit"),
|
||||
"agent.run_status" => Some("agent.run_status"),
|
||||
PLAN_SUBMIT_GDD_TOOL => Some(PLAN_SUBMIT_GDD_TOOL),
|
||||
GAME_CREATOR_MCP_CALL_TOOL => Some(GAME_CREATOR_MCP_CALL_TOOL),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the original provider/runtime tool identity before translating it to
|
||||
/// a project permission command. Some tools intentionally share a command
|
||||
/// id (for example `project.search` and `file.read`); policy lookup alone must
|
||||
/// not turn that aliasing into an identity escalation for a restricted Agent.
|
||||
pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool {
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return matches!(
|
||||
tool.trim(),
|
||||
"file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL
|
||||
);
|
||||
}
|
||||
if tool.trim() == PLAN_SUBMIT_GDD_TOOL {
|
||||
return false;
|
||||
}
|
||||
if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
|
||||
// `user.input_request` is a protocol control handled by the main
|
||||
// loop, not by the command-id policy map. It remains available to
|
||||
// standard Agents and is separately denied for autonomous profiles.
|
||||
return true;
|
||||
}
|
||||
game_creator_agent_runtime_tool_command_id(tool.trim()).is_some()
|
||||
}
|
||||
|
||||
/// 身份层面的**显式**拒绝:该 Agent 身份带 exact allowlist,且工具不在其中。
|
||||
///
|
||||
/// **未知工具名不属于本判据。** `agent_runtime_tool_allowed_for_agent` 对普通
|
||||
/// Agent 退化成「这个工具名是否已知」,用它做身份门会把「模型编了个不存在的
|
||||
/// 工具」这种普通协议错误误判成身份违规。协议错误的既有语义是:走到执行层产出
|
||||
/// 一条 `rejected` observation,run 继续,由下一轮 tool-plan 收束;升级成身份
|
||||
/// 拒绝会让整个 run 进 needs-reconciliation 而**不再发出 follow-up 请求**。
|
||||
///
|
||||
/// 因此凡是「命中即中断 run 或整体拒绝动作」的调用点都必须用本判据,不能直接
|
||||
/// 用 `agent_runtime_tool_allowed_for_agent`。
|
||||
pub(crate) fn agent_runtime_tool_rejected_by_agent_identity(agent_id: &str, tool: &str) -> bool {
|
||||
agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
&& !agent_runtime_tool_allowed_for_agent(agent_id, tool)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod identity_tests {
|
||||
use super::*;
|
||||
|
||||
/// 普通 Agent 编出来的未知工具名是**协议错误**,不是身份违规。
|
||||
///
|
||||
/// 判成身份违规会让 main_loop 把整个 run 打进 needs-reconciliation、不再发出
|
||||
/// follow-up tool-plan——曾导致 `background_agent_runtime_persists_receipts_for_rejected_actions`
|
||||
/// 在等待第二次 Provider 请求时超时。
|
||||
#[test]
|
||||
fn unknown_tool_on_ordinary_agent_is_not_an_identity_rejection() {
|
||||
assert!(!agent_runtime_tool_rejected_by_agent_identity(
|
||||
"design-director",
|
||||
"runtime.unknown"
|
||||
));
|
||||
assert!(!agent_runtime_tool_rejected_by_agent_identity(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"runtime.unknown"
|
||||
));
|
||||
assert!(!agent_runtime_tool_rejected_by_agent_identity(
|
||||
"design-director",
|
||||
"file.read"
|
||||
));
|
||||
}
|
||||
|
||||
/// planning 身份仍是 exact allowlist:未知工具与越权工具都算身份拒绝。
|
||||
#[test]
|
||||
fn planning_identity_still_rejects_unknown_and_out_of_scope_tools() {
|
||||
assert!(agent_runtime_tool_rejected_by_agent_identity(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"runtime.unknown"
|
||||
));
|
||||
assert!(agent_runtime_tool_rejected_by_agent_identity(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.write"
|
||||
));
|
||||
assert!(!agent_runtime_tool_rejected_by_agent_identity(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.read"
|
||||
));
|
||||
assert!(!agent_runtime_tool_rejected_by_agent_identity(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
PLAN_SUBMIT_GDD_TOOL
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_identity_does_not_inherit_project_search_alias() {
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.read"
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.list"
|
||||
));
|
||||
assert!(!agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"project.search"
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
PLAN_SUBMIT_GDD_TOOL
|
||||
));
|
||||
assert!(!agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
PLAN_SUBMIT_GDD_TOOL
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"project.search"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
|
||||
@@ -140,6 +140,12 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_pendin
|
||||
root, &error,
|
||||
)));
|
||||
}
|
||||
if !agent_runtime_tool_allowed_for_agent(&pending.agent_id, &pending.action.tool) {
|
||||
return Ok(Some(agent_runtime_tool_policy_block_observation(
|
||||
&pending.action.tool,
|
||||
AgentRuntimeToolPolicyBlock::Denied("当前 Agent 身份不允许执行该原始工具".to_string()),
|
||||
)));
|
||||
}
|
||||
if let Some(observation) = pending_repository_context_drift_observation(root, pending)? {
|
||||
return Ok(Some(observation));
|
||||
}
|
||||
@@ -313,6 +319,12 @@ pub(in crate::agent) fn prepare_and_execute_game_creator_agent_runtime_parallel_
|
||||
{
|
||||
return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible);
|
||||
}
|
||||
if !actions
|
||||
.iter()
|
||||
.all(|action| agent_runtime_tool_allowed_for_agent(&runtime.agent_id, &action.tool))
|
||||
{
|
||||
return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible);
|
||||
}
|
||||
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
&root,
|
||||
"runtime.parallel_read_batch",
|
||||
@@ -583,7 +595,7 @@ pub(crate) fn project_game_creator_agent_runtime_parallel_read_batch_for_test_at
|
||||
});
|
||||
let plan = continuation.plan.clone();
|
||||
let mut observations = continuation.observations.clone();
|
||||
let mut tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
|
||||
let mut tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
|
||||
project_game_creator_agent_runtime_parallel_read_batch(
|
||||
root,
|
||||
&mut runtime,
|
||||
|
||||
+26
@@ -334,6 +334,32 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
|
||||
{
|
||||
return Err("Agent Runtime 待确认动作 Run Profile 绑定不匹配".to_string());
|
||||
}
|
||||
match pending.planning_session_binding.as_ref() {
|
||||
Some(binding) => {
|
||||
validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?;
|
||||
if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL
|
||||
|| binding.agent_id != pending.agent_id
|
||||
|| binding.task_id != pending.task_id
|
||||
|| binding.session_id != pending.session_id
|
||||
|| binding.run_id != pending.run_id
|
||||
|| binding.source != pending.source
|
||||
|| binding.run_profile != pending.run_profile
|
||||
|| binding.run_profile_binding_fingerprint
|
||||
!= pending.run_profile_binding_fingerprint
|
||||
|| binding.applied_steer_cursor != pending.planned_steer_cursor
|
||||
{
|
||||
return Err(
|
||||
"planning submit standalone pending 与 frozen binding 不一致".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
None if pending.provider_batch_plan_update.is_none() => {}
|
||||
None => {
|
||||
return Err(
|
||||
"非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
validate_agent_runtime_project_revision(root, &pending.project_revision_before)?;
|
||||
if pending.verification_gate_before.project_id
|
||||
!= game_creator_agent_runtime_context_project_id(root)?
|
||||
|
||||
@@ -430,6 +430,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked(
|
||||
gate.static_smoke_verified_revision = passed.then_some(current_revision.revision);
|
||||
gate.static_smoke_verified_game_index_sha256 =
|
||||
passed.then_some(static_smoke_game_index_sha256).flatten();
|
||||
} else if verification_tool.as_deref() == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) {
|
||||
gate.static_smoke_verified_revision = None;
|
||||
gate.static_smoke_verified_game_index_sha256 = None;
|
||||
}
|
||||
gate.last_verification_status = Some(
|
||||
if passed {
|
||||
@@ -947,11 +950,22 @@ pub(in crate::agent) fn isolated_join_barrier_has_waiting_groups(detail: &str) -
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: &str) -> bool {
|
||||
detail
|
||||
let waiting = detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("waitingDelegations="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0)
|
||||
.is_some_and(|count| count > 0);
|
||||
let unknown_contract_status = detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("unknownContractStatus="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0);
|
||||
let user_revision_pending = detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("userRevisionPending="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0);
|
||||
waiting || user_revision_pending || unknown_contract_status
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool {
|
||||
@@ -962,6 +976,22 @@ pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) ->
|
||||
.is_some_and(|count| count > 0)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn static_delegate_barrier_requires_user_input(detail: &str) -> bool {
|
||||
detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("userInputRequired="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn static_delegate_barrier_requires_user_revision(detail: &str) -> bool {
|
||||
detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("userRevisionPending="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn process_session_completion_blocker_at_locked(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -1006,6 +1036,7 @@ pub(in crate::agent) fn agent_runtime_non_verification_completion_blocker_at_loc
|
||||
run_id: &str,
|
||||
) -> Option<AgentRuntimeToolObservation> {
|
||||
provider_retry_completion_blocker_at_locked(root, agent_id, run_id)
|
||||
.or_else(|| plan_gdd_completion_blocker_at_locked(root, agent_id, run_id))
|
||||
.or_else(|| provider_action_batch_completion_blocker_at_locked(root, agent_id, run_id))
|
||||
.or_else(|| {
|
||||
supervisor_collaboration_policy_completion_blocker_at_locked(root, agent_id, run_id)
|
||||
@@ -1460,17 +1491,217 @@ pub(in crate::agent) fn project_verification_completion_blocker_at_locked(
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_artifact_verification_is_current(
|
||||
gate: &AgentRuntimeVerificationGate,
|
||||
current_revision: u64,
|
||||
) -> bool {
|
||||
gate.last_verification_tool.as_deref() == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL)
|
||||
&& gate.last_verification_status.as_deref()
|
||||
== Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|
||||
&& gate.static_smoke_verified_revision.is_none()
|
||||
&& gate.mutation_revision.is_some_and(|mutation_revision| {
|
||||
gate.verified_revision.is_some_and(|verified_revision| {
|
||||
verified_revision >= mutation_revision && verified_revision <= current_revision
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_owner_artifact_validation_audit_at_locked(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
revision: u64,
|
||||
paths: &[&str],
|
||||
) -> Result<(), String> {
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?
|
||||
.ok_or_else(|| "owner-artifact 验证审计缺少 Run Profile 绑定".to_string())?;
|
||||
let (records, _) =
|
||||
read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?;
|
||||
let audit_exists = records.iter().rev().any(|record| {
|
||||
record.get("recordType").and_then(serde_json::Value::as_str)
|
||||
== Some("agent.runtime.owner_artifacts.validated")
|
||||
&& record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id)
|
||||
&& record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id)
|
||||
&& record.get("rootRunId").and_then(serde_json::Value::as_str)
|
||||
== Some(binding.root_run_id.as_str())
|
||||
&& record
|
||||
.get("runProfileBindingFingerprint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(binding.binding_fingerprint.as_str())
|
||||
&& record.get("revision").and_then(serde_json::Value::as_u64) == Some(revision)
|
||||
&& record
|
||||
.get("verificationTool")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL)
|
||||
&& record
|
||||
.get("paths")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.len() == paths.len()
|
||||
&& items
|
||||
.iter()
|
||||
.zip(paths)
|
||||
.all(|(item, path)| item.as_str().is_some_and(|value| value == *path))
|
||||
})
|
||||
});
|
||||
if audit_exists {
|
||||
return Ok(());
|
||||
}
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.owner_artifacts.validated",
|
||||
"agentId": agent_id,
|
||||
"runId": run_id,
|
||||
"rootAgentId": binding.root_agent_id,
|
||||
"rootRunId": binding.root_run_id,
|
||||
"parentAgentId": binding.parent_agent_id,
|
||||
"parentRunId": binding.parent_run_id,
|
||||
"source": binding.source,
|
||||
"runProfile": binding.profile,
|
||||
"runProfileBindingFingerprint": binding.binding_fingerprint,
|
||||
"revision": revision,
|
||||
"verificationTool": AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL,
|
||||
"paths": paths,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn evaluate_project_verification_completion_at_locked(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
) -> Result<Option<AgentRuntimeToolObservation>, String> {
|
||||
if let Some(blocker) = project_verification_completion_blocker(observations) {
|
||||
return Ok(Some(blocker));
|
||||
}
|
||||
let revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
let gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?;
|
||||
let runtime_owner_artifact_validation_available =
|
||||
autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?;
|
||||
let mut gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?;
|
||||
let autonomous_owner_artifact_role =
|
||||
if agent_runtime_autonomous_uses_owner_artifact_validation(agent_id) {
|
||||
match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? {
|
||||
Some(binding) => binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
|
||||
None => {
|
||||
read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)?
|
||||
.is_some_and(|task| {
|
||||
task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let trusted_game_chat_canvas_delivery = autonomous_owner_artifact_role
|
||||
&& game_chat_delegated_art_asset_plan_uses_canvas_verification_at(root, agent_id, run_id)?;
|
||||
if autonomous_owner_artifact_role
|
||||
&& !runtime_owner_artifact_validation_available
|
||||
&& !trusted_game_chat_canvas_delivery
|
||||
{
|
||||
return Ok(Some(agent_runtime_verification_blocker(
|
||||
"当前 owner Run 不具备可用的验证身份,不能把任务标记为完成",
|
||||
"只有完整 GUI/CLI autonomous DAG 的当前 fixed owner 可由 Runtime 内部验证;只有可信 game-chat code-prototype 的当前 art-asset-plan 动态委派可沿用普通 Canvas 验证。",
|
||||
)));
|
||||
}
|
||||
if trusted_game_chat_canvas_delivery
|
||||
&& (gate.last_verification_tool.as_deref() != Some("canvas.asset_generate")
|
||||
|| gate.last_verification_status.as_deref()
|
||||
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|
||||
|| gate.static_smoke_verified_revision.is_some()
|
||||
|| !gate.mutation_revision.is_some_and(|mutation_revision| {
|
||||
gate.verified_revision
|
||||
.is_some_and(|verified_revision| verified_revision >= mutation_revision)
|
||||
}))
|
||||
{
|
||||
return Ok(Some(agent_runtime_verification_blocker(
|
||||
"game-chat 动态 art-asset-plan 尚未形成可信 Canvas 交付凭证",
|
||||
"当前动态美术 child 必须由本人 canvas.asset_generate 生成正式素材并通过当前 mutation revision;project.verify、game.static_smoke 或其它 Agent 的凭证均不能替代。",
|
||||
)));
|
||||
}
|
||||
if runtime_owner_artifact_validation_available && gate.requires_verification {
|
||||
let paths =
|
||||
match validate_autonomous_owner_artifacts_for_run_at_locked(root, agent_id, run_id) {
|
||||
Ok(paths) => paths,
|
||||
Err(error) => {
|
||||
return Ok(Some(agent_runtime_verification_blocker(
|
||||
"当前 owner 正式产物尚未通过 Runtime 结构验证",
|
||||
error,
|
||||
)));
|
||||
}
|
||||
};
|
||||
let owner_artifact_verification_current =
|
||||
owner_artifact_verification_is_current(&gate, revision.revision);
|
||||
let audit_revision = if owner_artifact_verification_current {
|
||||
gate.verified_revision
|
||||
.expect("current owner-artifact verification has a revision")
|
||||
} else {
|
||||
revision.revision
|
||||
};
|
||||
ensure_owner_artifact_validation_audit_at_locked(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
audit_revision,
|
||||
paths,
|
||||
)?;
|
||||
if !owner_artifact_verification_current {
|
||||
let (expected_revision, verification_gate) =
|
||||
begin_agent_runtime_project_verification_locked(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL,
|
||||
)?;
|
||||
finish_agent_runtime_project_verification_locked(
|
||||
root,
|
||||
&expected_revision,
|
||||
verification_gate,
|
||||
true,
|
||||
)?;
|
||||
gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?;
|
||||
}
|
||||
}
|
||||
let autonomous_code_prototype = if agent_id == "code-prototype" {
|
||||
match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? {
|
||||
Some(binding) => binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
|
||||
None => read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)?
|
||||
.is_some_and(|task| {
|
||||
task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if autonomous_code_prototype {
|
||||
ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id)?;
|
||||
let Some(mutation_revision) = gate.mutation_revision.filter(|revision| *revision > 0)
|
||||
else {
|
||||
return Ok(Some(agent_runtime_verification_blocker(
|
||||
"code-prototype 尚未形成本人 run 的项目修改,不能把任务标记为完成",
|
||||
"完整 autonomous DAG 的 code-prototype 必须先实际修改可玩入口,再由本人执行 game.static_smoke。",
|
||||
)));
|
||||
};
|
||||
if !gate
|
||||
.static_smoke_verified_revision
|
||||
.is_some_and(|revision| revision >= mutation_revision)
|
||||
{
|
||||
return Ok(Some(agent_runtime_verification_blocker(
|
||||
"code-prototype 尚未通过覆盖本人最后一次修改的 game.static_smoke",
|
||||
format!(
|
||||
"mutationRevision={mutation_revision}, staticSmokeVerifiedRevision={};project.verify 或其它 Agent 的验证凭证不能替代本人 run 的 game.static_smoke。",
|
||||
gate.static_smoke_verified_revision
|
||||
.map(|revision| revision.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
),
|
||||
)));
|
||||
}
|
||||
}
|
||||
let owner_artifact_verification_current = runtime_owner_artifact_validation_available
|
||||
&& owner_artifact_verification_is_current(&gate, revision.revision);
|
||||
if !owner_artifact_verification_current {
|
||||
if let Some(blocker) = project_verification_completion_blocker(observations) {
|
||||
return Ok(Some(blocker));
|
||||
}
|
||||
}
|
||||
if let Some(status) = gate.last_verification_status.as_deref() {
|
||||
if status == AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING {
|
||||
return Ok(Some(agent_runtime_verification_blocker(
|
||||
@@ -1548,18 +1779,31 @@ pub(crate) fn project_verification_completion_blocker_at(
|
||||
project_verification_completion_blocker_at_locked(root, agent_id, run_id, observations)
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
const AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5);
|
||||
const AGENT_RUNTIME_PROJECT_WRITE_LOCK_WAIT_ATTEMPTS: usize = 2_000;
|
||||
const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
|
||||
|
||||
/// Take the project write lock, riding out transient contention for at most
|
||||
/// `max_attempts` polls.
|
||||
///
|
||||
/// `项目正在被其他写操作占用:` is the one lock error that means
|
||||
/// "nothing is broken, the current holder is mid-write" — every other variant
|
||||
/// (a torn lock file, a denied path) is returned immediately. Callers pick the
|
||||
/// budget from what a lost race costs them: a one-shot user intent waits out the
|
||||
/// full window, a poll that will run again shortly waits far less.
|
||||
fn acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
const MAX_ATTEMPTS: usize = 2_000;
|
||||
for attempt in 0..MAX_ATTEMPTS {
|
||||
let max_attempts = max_attempts.max(1);
|
||||
for attempt in 0..max_attempts {
|
||||
match acquire_project_write_lock(root, command_id) {
|
||||
Err(error)
|
||||
if error.starts_with("项目正在被其他写操作占用:")
|
||||
&& attempt + 1 < MAX_ATTEMPTS =>
|
||||
&& attempt + 1 < max_attempts =>
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
@@ -1567,9 +1811,134 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
unreachable!("project write lock retry loop always returns")
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||
root,
|
||||
command_id,
|
||||
AGENT_RUNTIME_PROJECT_WRITE_LOCK_WAIT_ATTEMPTS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Same wait, sized for a caller that re-runs on its own — a GUI refresh poll
|
||||
/// rather than a user's one-shot decision. Blocking such a caller for the full
|
||||
/// window would stall the panel it feeds; losing the race only costs it the
|
||||
/// current tick.
|
||||
pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_short_wait(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||
root,
|
||||
command_id,
|
||||
AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id)
|
||||
}
|
||||
|
||||
/// M1C-1 把 `userRevisionPending` 同时加进了 `StaticDelegateCompletionBarrier::detail()`
|
||||
/// 的输出和本模块的三个解析门,两边靠一个字段名字符串隔空对齐,中间没有共享 schema。
|
||||
///
|
||||
/// 当时生产侧只有 `delegation.rs` 一条 `detail().contains("userRevisionPending=1")`,
|
||||
/// 解析侧一条测试都没有,两者之间也没有任何东西相连。字段名一改,生产侧那条照过,而这
|
||||
/// 三个门会静默返回 false——父 run 于是越过用户修订边界收束。同样,往 `has_waiting()`
|
||||
/// 里加一个计数而忘了加进解析器(或反之),也没有任何用例会报警。
|
||||
///
|
||||
/// 所以这里锁的不是拼写,是**等价关系**:对七个计数的全部组合,解析门的判定必须与
|
||||
/// barrier 自己的语义谓词逐一相等。
|
||||
#[cfg(test)]
|
||||
mod static_delegate_barrier_detail_gate_tests {
|
||||
use super::*;
|
||||
use crate::delegation::StaticDelegateCompletionBarrier;
|
||||
|
||||
fn assert_gates_agree_with_barrier(barrier: StaticDelegateCompletionBarrier) {
|
||||
let detail = barrier.detail();
|
||||
assert_eq!(
|
||||
static_delegate_barrier_has_waiting_deliveries(&detail),
|
||||
barrier.has_waiting(),
|
||||
"has_waiting() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}"
|
||||
);
|
||||
assert_eq!(
|
||||
static_delegate_barrier_requires_repair(&detail),
|
||||
barrier.repair_required_count > 0,
|
||||
"repairRequired 往返失真:{barrier:?}\ndetail={detail}"
|
||||
);
|
||||
assert_eq!(
|
||||
static_delegate_barrier_requires_user_input(&detail),
|
||||
barrier.user_input_required_count > 0,
|
||||
"userInputRequired 往返失真:{barrier:?}\ndetail={detail}"
|
||||
);
|
||||
assert_eq!(
|
||||
static_delegate_barrier_requires_user_revision(&detail),
|
||||
barrier.user_revision_pending_count > 0,
|
||||
"userRevisionPending 往返失真:{barrier:?}\ndetail={detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barrier_detail_round_trips_through_every_gate_for_all_count_combinations() {
|
||||
let mut checked = 0usize;
|
||||
for bits in 0u32..(1 << 7) {
|
||||
let present = |index: u32| usize::from(bits & (1 << index) != 0);
|
||||
assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier {
|
||||
waiting_count: present(0),
|
||||
ready_unclaimed_count: present(1),
|
||||
unobserved_claim_count: present(2),
|
||||
repair_required_count: present(3),
|
||||
user_input_required_count: present(4),
|
||||
user_revision_pending_count: present(5),
|
||||
unknown_contract_status_count: present(6),
|
||||
});
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 128, "必须覆盖七个计数的全部 0/1 组合");
|
||||
}
|
||||
|
||||
/// 门读的是计数而不是「等于 1」:`detail()` 里出现多位数时不能失配。
|
||||
#[test]
|
||||
fn barrier_detail_gates_read_multi_digit_counts() {
|
||||
assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier {
|
||||
waiting_count: 12,
|
||||
ready_unclaimed_count: 34,
|
||||
unobserved_claim_count: 56,
|
||||
repair_required_count: 78,
|
||||
user_input_required_count: 90,
|
||||
user_revision_pending_count: 123,
|
||||
unknown_contract_status_count: 456,
|
||||
});
|
||||
}
|
||||
|
||||
/// 没有任何键是另一个键的前缀——否则 `strip_prefix` 会读到隔壁字段的值。
|
||||
/// 这条是给未来改名加的护栏:等价关系测试能抓到读错值,但抓不到「读对了值却
|
||||
/// 是因为两个键碰巧不冲突」这层前提何时被打破。
|
||||
#[test]
|
||||
fn barrier_detail_keys_are_prefix_free() {
|
||||
let detail = StaticDelegateCompletionBarrier::default().detail();
|
||||
let keys = detail
|
||||
.split_whitespace()
|
||||
.filter_map(|part| part.split_once('=').map(|(key, _)| key))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
keys.len() >= 7,
|
||||
"detail 必须仍以 key=value 形式给出全部计数:{detail}"
|
||||
);
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
for (other_index, other) in keys.iter().enumerate() {
|
||||
if index != other_index {
|
||||
assert!(
|
||||
!other.starts_with(key),
|
||||
"detail 键 `{key}` 是 `{other}` 的前缀,strip_prefix 会读错字段"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+333
-18
@@ -14,6 +14,18 @@ pub(crate) struct AgentRuntimePendingToolAction {
|
||||
pub(crate) run_profile: String,
|
||||
#[serde(default)]
|
||||
pub(crate) run_profile_binding_fingerprint: String,
|
||||
/// Planning submit actions carry the exact source-session snapshot that
|
||||
/// was captured before the Provider response was accepted. Other tools
|
||||
/// leave this field absent and retain the v1-v3 batch semantics.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) planning_session_binding: Option<PlanProviderSessionBindingV1>,
|
||||
/// The v4 planning batch identity covers the complete Provider plan,
|
||||
/// including an optional structured plan update. Persist that one
|
||||
/// batch-only field on the standalone submit anchor as recovery material;
|
||||
/// otherwise a surviving pending action cannot reproduce the original
|
||||
/// batch ID after the batch sidecar is lost.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) provider_batch_plan_update: Option<AgentRuntimePlanUpdate>,
|
||||
pub(crate) task: String,
|
||||
#[serde(default)]
|
||||
pub(crate) goal_id: Option<String>,
|
||||
@@ -78,6 +90,8 @@ pub(in crate::agent) struct AgentRuntimeParallelReadBatch {
|
||||
pub(crate) struct AgentRuntimeProviderActionBatch {
|
||||
pub(crate) schema_version: String,
|
||||
pub(crate) batch_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) provider_request_id: Option<String>,
|
||||
pub(crate) project_id: String,
|
||||
pub(crate) agent_id: String,
|
||||
pub(crate) task_id: String,
|
||||
@@ -88,6 +102,8 @@ pub(crate) struct AgentRuntimeProviderActionBatch {
|
||||
pub(crate) run_profile: String,
|
||||
#[serde(default)]
|
||||
pub(crate) run_profile_binding_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) planning_session_binding: Option<PlanProviderSessionBindingV1>,
|
||||
pub(crate) loop_iteration: u32,
|
||||
pub(crate) planned_steer_cursor: u64,
|
||||
pub(crate) status: String,
|
||||
@@ -107,6 +123,8 @@ pub(crate) struct AgentRuntimeProviderActionBatch {
|
||||
struct AgentRuntimeProviderActionBatchWire {
|
||||
schema_version: String,
|
||||
batch_id: String,
|
||||
#[serde(default)]
|
||||
provider_request_id: Option<String>,
|
||||
project_id: String,
|
||||
agent_id: String,
|
||||
task_id: String,
|
||||
@@ -117,6 +135,8 @@ struct AgentRuntimeProviderActionBatchWire {
|
||||
run_profile: String,
|
||||
#[serde(default)]
|
||||
run_profile_binding_fingerprint: String,
|
||||
#[serde(default)]
|
||||
planning_session_binding: Option<PlanProviderSessionBindingV1>,
|
||||
loop_iteration: u32,
|
||||
planned_steer_cursor: u64,
|
||||
status: String,
|
||||
@@ -140,6 +160,7 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch {
|
||||
let batch = Self {
|
||||
schema_version: wire.schema_version,
|
||||
batch_id: wire.batch_id,
|
||||
provider_request_id: wire.provider_request_id,
|
||||
project_id: wire.project_id,
|
||||
agent_id: wire.agent_id,
|
||||
task_id: wire.task_id,
|
||||
@@ -148,6 +169,7 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch {
|
||||
source: wire.source,
|
||||
run_profile: wire.run_profile,
|
||||
run_profile_binding_fingerprint: wire.run_profile_binding_fingerprint,
|
||||
planning_session_binding: wire.planning_session_binding,
|
||||
loop_iteration: wire.loop_iteration,
|
||||
planned_steer_cursor: wire.planned_steer_cursor,
|
||||
status: wire.status,
|
||||
@@ -195,7 +217,7 @@ impl AgentRuntimePendingToolAction {
|
||||
pub(in crate::agent) fn tool_plan(&self) -> AgentRuntimeToolPlan {
|
||||
AgentRuntimeToolPlan {
|
||||
thinking_summary: self.thinking_summary.clone(),
|
||||
plan_update: None,
|
||||
plan_update: self.provider_batch_plan_update.clone(),
|
||||
plan: self.plan.clone(),
|
||||
actions: Vec::new(),
|
||||
response: self.fallback_response.clone(),
|
||||
@@ -253,6 +275,8 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action(
|
||||
source: runtime.source.clone(),
|
||||
run_profile: runtime.run_profile.clone(),
|
||||
run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
|
||||
planning_session_binding: None,
|
||||
provider_batch_plan_update: None,
|
||||
task,
|
||||
goal_id: runtime.goal_id.clone(),
|
||||
goal_revision: runtime.goal_revision,
|
||||
@@ -288,6 +312,110 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action(
|
||||
})
|
||||
}
|
||||
|
||||
/// `plan.submit_gdd` is a transactional planning action rather than an
|
||||
/// ordinary provider action. It must be represented by one (and only one)
|
||||
/// durable batch member so that the main loop can establish the action
|
||||
/// identity before handing control to the planning submit handler.
|
||||
///
|
||||
/// Keep this check at the batch boundary as a second line of defence behind
|
||||
/// the native-tool parser. In particular, a text/JSON tool-plan or a stale
|
||||
/// caller must not be able to smuggle a submit action through the historical
|
||||
/// `< 2 actions => NotNeeded` fast path.
|
||||
fn validate_plan_submit_gdd_batch_shape_for_identity(
|
||||
agent_id: &str,
|
||||
source: &str,
|
||||
run_profile: &str,
|
||||
plan: &AgentRuntimeToolPlan,
|
||||
) -> Result<bool, String> {
|
||||
let submit_count = plan
|
||||
.actions
|
||||
.iter()
|
||||
.filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL)
|
||||
.count();
|
||||
if submit_count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return Err("plan.submit_gdd 只能由 project-planning Agent 调用".to_string());
|
||||
}
|
||||
// The planning child is created through the ordinary delegate path. Keep
|
||||
// the source/profile check here even though the run-identity binder also
|
||||
// checks it: this prevents a forged/stale RuntimeState from turning the
|
||||
// sole-action exception into a generic batch.
|
||||
if source.trim() != "agent-delegate" || run_profile.trim() != AGENT_RUNTIME_RUN_PROFILE_STANDARD
|
||||
{
|
||||
return Err(
|
||||
"plan.submit_gdd 的 Runtime 身份必须是 source=agent-delegate、runProfile=standard"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if submit_count > 1 {
|
||||
return Err("plan.submit_gdd 在同一 Provider 响应中只能出现一次".to_string());
|
||||
}
|
||||
if plan.actions.len() != 1 {
|
||||
return Err("plan.submit_gdd 必须是 Provider 响应中的唯一 action".to_string());
|
||||
}
|
||||
if !plan.response.trim().is_empty() {
|
||||
return Err("plan.submit_gdd 不得与 respond_to_user 混批".to_string());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn validate_plan_submit_gdd_batch_shape(
|
||||
runtime: &AgentRuntimeState,
|
||||
plan: &AgentRuntimeToolPlan,
|
||||
) -> Result<bool, String> {
|
||||
validate_plan_submit_gdd_batch_shape_for_identity(
|
||||
&runtime.agent_id,
|
||||
&runtime.source,
|
||||
&runtime.run_profile,
|
||||
plan,
|
||||
)
|
||||
}
|
||||
|
||||
/// Return whether a persisted v4 provider batch is the exact planning submit
|
||||
/// shape that is allowed to contain one action. The provider-batch ledger
|
||||
/// uses this narrow predicate when applying its normal two-action minimum;
|
||||
/// all non-plan batches retain the historical minimum unchanged.
|
||||
pub(in crate::agent) fn is_plan_submit_gdd_provider_action_batch(
|
||||
batch: &AgentRuntimeProviderActionBatch,
|
||||
) -> bool {
|
||||
batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
|
||||
&& batch.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
&& batch.source.trim() == "agent-delegate"
|
||||
&& batch.run_profile.trim() == AGENT_RUNTIME_RUN_PROFILE_STANDARD
|
||||
&& batch.collaboration_contract.is_none()
|
||||
&& batch.actions.len() == 1
|
||||
&& batch.plan.actions.len() == 1
|
||||
&& batch.plan.actions[0].tool.trim() == PLAN_SUBMIT_GDD_TOOL
|
||||
&& batch.actions[0].action.tool.trim() == PLAN_SUBMIT_GDD_TOOL
|
||||
&& batch.plan.response.trim().is_empty()
|
||||
&& batch.actions[0].action == batch.plan.actions[0]
|
||||
&& batch.planning_session_binding.is_some()
|
||||
&& batch.provider_request_id.as_deref()
|
||||
== batch
|
||||
.planning_session_binding
|
||||
.as_ref()
|
||||
.map(|binding| binding.provider_request_id.as_str())
|
||||
&& batch.actions[0].planning_session_binding == batch.planning_session_binding
|
||||
}
|
||||
|
||||
fn provider_action_batch_is_not_needed(
|
||||
action_count: usize,
|
||||
force_collaboration_batch: bool,
|
||||
is_plan_submit: bool,
|
||||
) -> bool {
|
||||
action_count < 2 && !force_collaboration_batch && !is_plan_submit
|
||||
}
|
||||
|
||||
/// Backwards-compatible entry point for the historical provider-batch callers.
|
||||
///
|
||||
/// Planning submit batches now need the frozen session binding captured while
|
||||
/// building the provider request. Callers that do not build a planning
|
||||
/// request (including the older test/support helpers) retain the old API and
|
||||
/// therefore pass no binding; the planning path uses the `_with_planning_binding`
|
||||
/// variant below.
|
||||
pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
root: &Path,
|
||||
runtime: &AgentRuntimeState,
|
||||
@@ -297,6 +425,34 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
project_revision_before: &AgentRuntimeProjectRevision,
|
||||
planned_repository_context_fingerprint: &str,
|
||||
) -> Result<AgentRuntimeProviderActionBatchPreparation, String> {
|
||||
prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding(
|
||||
root,
|
||||
runtime,
|
||||
task,
|
||||
plan,
|
||||
observations,
|
||||
project_revision_before,
|
||||
planned_repository_context_fingerprint,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding(
|
||||
root: &Path,
|
||||
runtime: &AgentRuntimeState,
|
||||
task: &str,
|
||||
plan: &AgentRuntimeToolPlan,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
project_revision_before: &AgentRuntimeProjectRevision,
|
||||
planned_repository_context_fingerprint: &str,
|
||||
captured_planning_session_binding: Option<&PlanProviderSessionBindingV1>,
|
||||
) -> Result<AgentRuntimeProviderActionBatchPreparation, String> {
|
||||
// Validate against the complete provider plan before truncating the
|
||||
// historical action budget. Otherwise a mixed submit batch could hide a
|
||||
// `plan.submit_gdd` action beyond the truncation boundary and reach the
|
||||
// generic executor without a durable identity.
|
||||
let is_plan_submit = validate_plan_submit_gdd_batch_shape(runtime, plan)?;
|
||||
let mut batch_plan = plan.clone();
|
||||
batch_plan
|
||||
.actions
|
||||
@@ -450,7 +606,11 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
}
|
||||
}
|
||||
}
|
||||
if batch_plan.actions.len() < 2 && !collaboration_preflight.force_durable_batch {
|
||||
if provider_action_batch_is_not_needed(
|
||||
batch_plan.actions.len(),
|
||||
collaboration_preflight.force_durable_batch,
|
||||
is_plan_submit,
|
||||
) {
|
||||
return Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded);
|
||||
}
|
||||
|
||||
@@ -472,7 +632,23 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||||
None,
|
||||
)?;
|
||||
if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL {
|
||||
pending.planning_session_binding = captured_planning_session_binding.cloned();
|
||||
pending.provider_batch_plan_update = batch_plan.plan_update.clone();
|
||||
if pending.planning_session_binding.is_none() {
|
||||
return Err(
|
||||
"planning submit action 缺少 Provider 请求前捕获的 session binding".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim());
|
||||
let identity_block =
|
||||
agent_runtime_tool_rejected_by_agent_identity(&runtime.agent_id, action.tool.trim())
|
||||
.then(|| {
|
||||
AgentRuntimeToolPolicyBlock::Denied(
|
||||
"当前 Agent 身份不允许执行该原始工具".to_string(),
|
||||
)
|
||||
});
|
||||
let art_director_canvas_only_block =
|
||||
agent_runtime_autonomous_art_director_canvas_only_action_block(
|
||||
&runtime.agent_id,
|
||||
@@ -500,7 +676,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let local_policy_block = art_director_canvas_only_block
|
||||
let local_policy_block = identity_block
|
||||
.or(art_director_canvas_only_block)
|
||||
.or(game_chat_art_scope_block)
|
||||
.or(isolated_scope_block)
|
||||
.or_else(|| {
|
||||
@@ -573,24 +750,71 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
} else {
|
||||
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY
|
||||
};
|
||||
let batch_id = agent_runtime_provider_action_batch_id(
|
||||
&project_id,
|
||||
&runtime.agent_id,
|
||||
&runtime.task_id,
|
||||
&runtime.session_id,
|
||||
&runtime.run_id,
|
||||
runtime.loop_iteration,
|
||||
runtime.applied_steer_cursor,
|
||||
&batch_plan,
|
||||
project_revision_before,
|
||||
planned_repository_context_fingerprint,
|
||||
&actions,
|
||||
collaboration_preflight.contract.as_ref(),
|
||||
)?;
|
||||
let planning_session_binding = if is_plan_submit {
|
||||
let binding = captured_planning_session_binding
|
||||
.or_else(|| {
|
||||
actions
|
||||
.first()
|
||||
.and_then(|pending| pending.planning_session_binding.as_ref())
|
||||
})
|
||||
.ok_or_else(|| "planning submit batch 缺少 frozen session binding".to_string())?;
|
||||
validate_plan_provider_session_binding_current_at(root, binding)?;
|
||||
if let Some(pending_binding) = actions
|
||||
.first()
|
||||
.and_then(|pending| pending.planning_session_binding.as_ref())
|
||||
{
|
||||
if pending_binding != binding {
|
||||
return Err(
|
||||
"planning submit pending 与 captured session binding 不一致".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(binding.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let batch_id = if let Some(binding) = planning_session_binding.as_ref() {
|
||||
agent_runtime_plan_provider_action_batch_id(
|
||||
&project_id,
|
||||
&runtime.agent_id,
|
||||
&runtime.task_id,
|
||||
&runtime.session_id,
|
||||
&runtime.run_id,
|
||||
runtime.loop_iteration,
|
||||
runtime.applied_steer_cursor,
|
||||
&batch_plan,
|
||||
project_revision_before,
|
||||
planned_repository_context_fingerprint,
|
||||
&actions,
|
||||
binding,
|
||||
)?
|
||||
} else {
|
||||
agent_runtime_provider_action_batch_id(
|
||||
&project_id,
|
||||
&runtime.agent_id,
|
||||
&runtime.task_id,
|
||||
&runtime.session_id,
|
||||
&runtime.run_id,
|
||||
runtime.loop_iteration,
|
||||
runtime.applied_steer_cursor,
|
||||
&batch_plan,
|
||||
project_revision_before,
|
||||
planned_repository_context_fingerprint,
|
||||
&actions,
|
||||
collaboration_preflight.contract.as_ref(),
|
||||
)?
|
||||
};
|
||||
let now = unix_timestamp();
|
||||
let batch = AgentRuntimeProviderActionBatch {
|
||||
schema_version: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(),
|
||||
schema_version: if is_plan_submit {
|
||||
AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string()
|
||||
} else {
|
||||
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string()
|
||||
},
|
||||
batch_id,
|
||||
provider_request_id: planning_session_binding
|
||||
.as_ref()
|
||||
.map(|binding| binding.provider_request_id.clone()),
|
||||
project_id,
|
||||
agent_id: runtime.agent_id.clone(),
|
||||
task_id: runtime.task_id.clone(),
|
||||
@@ -599,6 +823,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
source: runtime.source.clone(),
|
||||
run_profile: runtime.run_profile.clone(),
|
||||
run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
|
||||
planning_session_binding,
|
||||
loop_iteration: runtime.loop_iteration,
|
||||
planned_steer_cursor: runtime.applied_steer_cursor,
|
||||
status: status.to_string(),
|
||||
@@ -1075,3 +1300,93 @@ pub(in crate::agent) fn update_game_creator_agent_runtime_provider_batch_paralle
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plan_submit_batch_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn action(tool: &str) -> AgentRuntimeToolAction {
|
||||
AgentRuntimeToolAction {
|
||||
tool: tool.to_string(),
|
||||
reason: Some("测试动作".to_string()),
|
||||
input: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(actions: Vec<AgentRuntimeToolAction>, response: &str) -> AgentRuntimeToolPlan {
|
||||
AgentRuntimeToolPlan {
|
||||
thinking_summary: "测试 plan.submit_gdd 批次形状".to_string(),
|
||||
plan_update: None,
|
||||
plan: Vec::new(),
|
||||
actions,
|
||||
response: response.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(plan: &AgentRuntimeToolPlan) -> Result<bool, String> {
|
||||
validate_plan_submit_gdd_batch_shape_for_identity(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"agent-delegate",
|
||||
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
||||
plan,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_submit_is_the_only_durable_action_and_allows_plan_control() {
|
||||
let mut submit = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], "");
|
||||
assert_eq!(validate(&submit), Ok(true));
|
||||
|
||||
submit.plan_update = Some(AgentRuntimePlanUpdate {
|
||||
explanation: "同步计划进度".to_string(),
|
||||
steps: Vec::new(),
|
||||
});
|
||||
assert_eq!(validate(&submit), Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_submit_rejects_mixed_or_duplicate_actions() {
|
||||
let mixed = plan(vec![action(PLAN_SUBMIT_GDD_TOOL), action("file.read")], "");
|
||||
let mixed_error = validate(&mixed).expect_err("submit + file.read must fail closed");
|
||||
assert!(mixed_error.contains("唯一 action"), "{mixed_error}");
|
||||
|
||||
let duplicate = plan(
|
||||
vec![action(PLAN_SUBMIT_GDD_TOOL), action(PLAN_SUBMIT_GDD_TOOL)],
|
||||
"",
|
||||
);
|
||||
let duplicate_error =
|
||||
validate(&duplicate).expect_err("duplicate submit actions must fail closed");
|
||||
assert!(
|
||||
duplicate_error.contains("只能出现一次"),
|
||||
"{duplicate_error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_submit_rejects_final_response_and_wrong_identity() {
|
||||
let with_response = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], "不能同时回复");
|
||||
let response_error =
|
||||
validate(&with_response).expect_err("submit + respond_to_user must fail closed");
|
||||
assert!(
|
||||
response_error.contains("respond_to_user"),
|
||||
"{response_error}"
|
||||
);
|
||||
|
||||
let wrong_agent = validate_plan_submit_gdd_batch_shape_for_identity(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"project-supervisor-plan",
|
||||
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
||||
&plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], ""),
|
||||
)
|
||||
.expect_err("non-planning identity must not receive submit exception");
|
||||
assert!(wrong_agent.contains("project-planning"), "{wrong_agent}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_single_action_keeps_not_needed_eligibility() {
|
||||
assert_eq!(validate(&plan(vec![action("file.read")], "")), Ok(false));
|
||||
assert!(provider_action_batch_is_not_needed(1, false, false));
|
||||
assert!(!provider_action_batch_is_not_needed(1, false, true));
|
||||
assert!(!provider_action_batch_is_not_needed(1, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user