Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a47cdfc1b | |||
| 5550a7b28c | |||
| 7bd67f2b3a | |||
| ea71540413 | |||
| 4e0ce1902b | |||
| e2a50088c9 | |||
| 6de536ecbd | |||
| 1347b4a743 | |||
| d5411eae62 | |||
| 0a5080dbd9 | |||
| c5bbb864f5 | |||
| ccb3a3ab6a | |||
| 879e732b04 | |||
| cac3e19632 | |||
| cd17ae8123 | |||
| 41c9ee7db4 | |||
| ba718e3d4b | |||
| 65ffd4cc69 | |||
| 5bcaca7233 | |||
| 820fbfd988 | |||
| 069248b296 | |||
| 9c85a9501e | |||
| 816844065b | |||
| 8323b13fd3 | |||
| f883d4bf2e | |||
| 019b1761f4 | |||
| f339b383f5 |
@@ -16,5 +16,8 @@
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 500
|
||||
},
|
||||
"agentLlm": {}
|
||||
"agentLlm": {},
|
||||
"planning": {
|
||||
"capabilityEnabled": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,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,10 +140,15 @@ export const usage = `用法:
|
||||
--keep-project 保留自动创建的一次性项目
|
||||
--no-open 手工模式启动预览但不自动打开浏览器
|
||||
--task <需求> 通过 manual 入口非交互提交自定义需求
|
||||
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
|
||||
--plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
|
||||
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
|
||||
--dry-run 只检查目录发现和项目准备,不启动 LLM
|
||||
-h, --help 显示帮助
|
||||
`;
|
||||
|
||||
环境变量:
|
||||
AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approve;revise/reject 必须
|
||||
同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见
|
||||
AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`;
|
||||
|
||||
function readOptionValue(args, index, option) {
|
||||
const value = args[index + 1]?.trim();
|
||||
@@ -170,6 +177,7 @@ export function parseSwarmTestArguments(args) {
|
||||
keepProject: false,
|
||||
openBrowser: true,
|
||||
task: null,
|
||||
plan: false,
|
||||
timeoutMinutes: null,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
@@ -194,6 +202,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 只能指定一次');
|
||||
@@ -212,11 +222,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;
|
||||
}
|
||||
|
||||
@@ -964,12 +979,25 @@ 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'],
|
||||
@@ -981,6 +1009,25 @@ async function runTaskCargo(
|
||||
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 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`,
|
||||
);
|
||||
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);
|
||||
@@ -993,6 +1040,13 @@ async function runTaskCargo(
|
||||
reportLines.push(normalizedLine);
|
||||
settled = true;
|
||||
}
|
||||
if (
|
||||
onPlanGddApprovalWait &&
|
||||
!planGddApprovalStarted &&
|
||||
swarmOutputAwaitsPlanGddApproval(normalizedLine)
|
||||
) {
|
||||
startPlanGddApproval();
|
||||
}
|
||||
}
|
||||
if (!autoPilot || child.stdin.writableEnded) return;
|
||||
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
|
||||
@@ -1031,9 +1085,12 @@ async function runTaskCargo(
|
||||
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
|
||||
reportLines.push(normalizedPendingLine);
|
||||
}
|
||||
await planGddApprovalPromise;
|
||||
if (planGddApprovalError) throw planGddApprovalError;
|
||||
return {
|
||||
...result,
|
||||
turnReportOutput: reportLines.join('\n'),
|
||||
planGddApproval,
|
||||
};
|
||||
} finally {
|
||||
setActiveChild(null);
|
||||
@@ -1723,6 +1780,196 @@ 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 需要一段真实的修改意见,让机器编一段等于把
|
||||
// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时
|
||||
// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着,
|
||||
// 而它恰好是本进程持有的 CLI 子进程。
|
||||
export function planGddAutoApprovalIsPending(state) {
|
||||
return Boolean(state?.pendingApproval);
|
||||
}
|
||||
|
||||
export function resolvePlanGddAutoDecision(env = process.env) {
|
||||
const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim();
|
||||
if (!['approve', 'revise', 'reject'].includes(action)) {
|
||||
throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject');
|
||||
}
|
||||
const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim();
|
||||
if (action === 'approve') return { action, comment: null };
|
||||
if (!comment) {
|
||||
throw new Error(
|
||||
`${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`,
|
||||
);
|
||||
}
|
||||
return { action, comment };
|
||||
}
|
||||
|
||||
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 { action, comment } = resolvePlanGddAutoDecision();
|
||||
const decision = await runCapturedCargo(
|
||||
[
|
||||
'--config-dir',
|
||||
runtimeConfigPath,
|
||||
'--plan-gdd-decide',
|
||||
projectPath,
|
||||
action,
|
||||
...(comment === null ? [] : ['--stdin']),
|
||||
],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批决定',
|
||||
stdin: comment,
|
||||
},
|
||||
);
|
||||
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(
|
||||
` [已决定 ${approval.receipt.decisionRef.action}] 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]) {
|
||||
@@ -1875,7 +2122,13 @@ 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;
|
||||
@@ -1953,10 +2206,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';
|
||||
@@ -1966,7 +2220,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;
|
||||
@@ -1979,6 +2234,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) {
|
||||
@@ -1994,6 +2258,32 @@ 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,
|
||||
);
|
||||
|
||||
@@ -107,16 +107,12 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
'stop_game_creator_external_mcp',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -1705,16 +1701,10 @@ if (
|
||||
runtimeConfigSetupStart === -1 ||
|
||||
runtimeConfigSetupEnd === -1 ||
|
||||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
|
||||
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
|
||||
!runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') ||
|
||||
!runtimeConfigSetupSource.includes(
|
||||
'startup.appdata.configure.failed details={details}',
|
||||
) ||
|
||||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
|
||||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
|
||||
!tauriHandlerSource.includes(
|
||||
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
|
||||
) ||
|
||||
!tauriHandlerSource.includes('early_startup_log_path(')
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
|
||||
|
||||
@@ -722,7 +722,7 @@ function runAgent() {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code, signal) => {
|
||||
child.on('close', (code) => {
|
||||
const output = `${stdout}${stderr}`;
|
||||
if (previewReadError) {
|
||||
reject(previewReadError);
|
||||
@@ -740,24 +740,13 @@ function runAgent() {
|
||||
previewDom,
|
||||
});
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` +
|
||||
`stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` +
|
||||
`stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`,
|
||||
),
|
||||
);
|
||||
reject(new Error(output || `agent run exited with ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function seedLocalAsset() {
|
||||
await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, 'game/index.html'),
|
||||
'<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><body>还没有生成游戏</body></html>',
|
||||
);
|
||||
await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true });
|
||||
await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true });
|
||||
await seedConversationContext();
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
normalizeWindowsPath,
|
||||
parseWindowsProcessSnapshot,
|
||||
stopWindowsProcessTree,
|
||||
stopWindowsWorktreeProcesses,
|
||||
} from '../../../scripts/dev-windows-process.mjs';
|
||||
import {
|
||||
agcVitePortEnvKey,
|
||||
readAgcDevEndpoint,
|
||||
@@ -21,10 +15,6 @@ import {
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
||||
const apiServerExePath = resolve(
|
||||
repoRoot,
|
||||
'server-rs/target/debug/api-server.exe',
|
||||
);
|
||||
const defaultApiTarget =
|
||||
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
||||
const backendDatabase = 'genarrative-game-creator-dev';
|
||||
@@ -137,217 +127,19 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function readBackendServiceFailure(
|
||||
state,
|
||||
{
|
||||
expectedDatabase = backendDatabase,
|
||||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||
} = {},
|
||||
) {
|
||||
const targets = resolveBackendTargetsFromState(state, {
|
||||
requireAgcBackend: true,
|
||||
expectedDatabase,
|
||||
expectedSpacetimeDataDir,
|
||||
});
|
||||
if (!targets.hasMatchingBackend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
|
||||
const service = state?.services?.[serviceName];
|
||||
if (service?.status !== 'failed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
serviceName,
|
||||
failure: service.signal
|
||||
? `signal=${service.signal}`
|
||||
: `code=${service.exitCode ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function urlPort(url) {
|
||||
try {
|
||||
const port = Number(new URL(url).port);
|
||||
return Number.isInteger(port) && port > 0 ? port : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
|
||||
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
function readWindowsPortOwnerIdentities(
|
||||
ports,
|
||||
{ spawnImpl = spawnSync, env = process.env } = {},
|
||||
) {
|
||||
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
|
||||
if (uniquePorts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = [
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
|
||||
'$result = @()',
|
||||
'foreach ($port in $ports) {',
|
||||
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
' if (-not $connection) { continue }',
|
||||
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
|
||||
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
|
||||
'}',
|
||||
'ConvertTo-Json -InputObject @($result) -Compress',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnImpl(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (result?.error || result?.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const owners = new Map();
|
||||
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
|
||||
const port = Number(entry?.port);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
owners.set(port, entry);
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function isWorktreeApiServerOwner(
|
||||
owner,
|
||||
{ expectedExePath = apiServerExePath } = {},
|
||||
) {
|
||||
if (!owner) {
|
||||
return false;
|
||||
}
|
||||
const expected = normalizeWindowsPath(expectedExePath);
|
||||
const actual = normalizeWindowsPath(owner.executablePath);
|
||||
return Boolean(expected) && actual === expected;
|
||||
}
|
||||
|
||||
function isWorktreeSpacetimeOwner(
|
||||
owner,
|
||||
{ expectedDataDir = backendSpacetimeDataDir } = {},
|
||||
) {
|
||||
if (!owner) {
|
||||
return false;
|
||||
}
|
||||
const expected = normalizeWindowsPath(expectedDataDir);
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
const name = String(owner.name ?? '').toLowerCase();
|
||||
if (!name.startsWith('spacetime')) {
|
||||
return false;
|
||||
}
|
||||
return normalizeWindowsPath(owner.commandLine).includes(expected);
|
||||
}
|
||||
|
||||
// 端口健康不代表后端属于当前工作树:上个工作树 Ctrl+C 残留的 api-server 仍会
|
||||
// 应答 /healthz。复用前必须证明端口上的进程就是本工作树的可执行文件与数据目录。
|
||||
function verifyAgcBackendOwnership({
|
||||
apiUrl,
|
||||
spacetimeUrl,
|
||||
bgfilterWorkerUrl,
|
||||
platform = process.platform,
|
||||
expectedExePath = apiServerExePath,
|
||||
expectedDataDir = backendSpacetimeDataDir,
|
||||
readPortOwners = readWindowsPortOwnerIdentities,
|
||||
} = {}) {
|
||||
if (platform !== 'win32') {
|
||||
return { ok: true, reason: 'platform-unsupported', owners: new Map() };
|
||||
}
|
||||
|
||||
const ports = [
|
||||
urlPort(apiUrl),
|
||||
urlPort(bgfilterWorkerUrl),
|
||||
urlPort(spacetimeUrl),
|
||||
];
|
||||
const owners = readPortOwners(ports);
|
||||
if (!owners) {
|
||||
return { ok: true, reason: 'owner-probe-unavailable', owners: new Map() };
|
||||
}
|
||||
|
||||
const apiOwner = owners.get(urlPort(apiUrl));
|
||||
if (!isWorktreeApiServerOwner(apiOwner, { expectedExePath })) {
|
||||
return { ok: false, reason: 'api-server-owner-mismatch', owners, apiOwner };
|
||||
}
|
||||
|
||||
const workerOwner = owners.get(urlPort(bgfilterWorkerUrl));
|
||||
if (!isWorktreeApiServerOwner(workerOwner, { expectedExePath })) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bgfilter-worker-owner-mismatch',
|
||||
owners,
|
||||
workerOwner,
|
||||
};
|
||||
}
|
||||
|
||||
const spacetimeOwner = owners.get(urlPort(spacetimeUrl));
|
||||
if (!isWorktreeSpacetimeOwner(spacetimeOwner, { expectedDataDir })) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'spacetime-owner-mismatch',
|
||||
owners,
|
||||
spacetimeOwner,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, reason: 'owned', owners };
|
||||
}
|
||||
|
||||
function formatOwnerLabel(owner) {
|
||||
if (!owner) {
|
||||
return '未知进程';
|
||||
}
|
||||
const pid = Number(owner.processId);
|
||||
const label = owner.executablePath || owner.commandLine || owner.name || '';
|
||||
return `${Number.isInteger(pid) ? `pid=${pid} ` : ''}${String(label).trim()}`.trim();
|
||||
}
|
||||
|
||||
async function isBackendReady({
|
||||
state = readJson(devStackStatePath),
|
||||
isReady = isHttpReady,
|
||||
verifyOwnership = verifyAgcBackendOwnership,
|
||||
onOwnershipRejected = null,
|
||||
} = {}) {
|
||||
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
|
||||
resolveBackendTargetsFromState(state, {
|
||||
requireAgcBackend: true,
|
||||
});
|
||||
if (!hasMatchingBackend || !apiUrl || !spacetimeUrl || !bgfilterWorkerUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ownership = await verifyOwnership({
|
||||
apiUrl,
|
||||
spacetimeUrl,
|
||||
bgfilterWorkerUrl,
|
||||
});
|
||||
if (!ownership?.ok) {
|
||||
onOwnershipRejected?.(ownership);
|
||||
return false;
|
||||
}
|
||||
if (ownership.reason === 'owner-probe-unavailable') {
|
||||
console.warn(
|
||||
'[ai-game-creator-shell] 无法读取端口监听进程归属,本次按旧行为复用配套后端。',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
hasMatchingBackend &&
|
||||
Boolean(apiUrl) &&
|
||||
Boolean(spacetimeUrl) &&
|
||||
Boolean(bgfilterWorkerUrl) &&
|
||||
(await isReady(`${apiUrl}/healthz`)) &&
|
||||
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
|
||||
(await isReady(`${bgfilterWorkerUrl}/readyz`))
|
||||
@@ -660,18 +452,14 @@ async function terminateChildTree(
|
||||
return { stopped: true, forced: false };
|
||||
}
|
||||
const result = await taskkillImpl(child.pid);
|
||||
const taskkillStopped =
|
||||
!result?.timedOut &&
|
||||
!result?.error &&
|
||||
[0, 128].includes(result?.code ?? 0);
|
||||
if (taskkillStopped) {
|
||||
return { stopped: true, forced: true, result };
|
||||
}
|
||||
|
||||
// 包装层(cmd.exe / npm.cmd)先被 Ctrl+C 杀掉时 taskkill 拿不到活着的 PID,
|
||||
// 这里继续按记录下来的根 PID 遍历,尽量收掉更深的后端进程。
|
||||
const treeStopped = stopWindowsProcessTree(child.pid);
|
||||
return { stopped: treeStopped.length > 0, forced: true, result };
|
||||
return {
|
||||
stopped:
|
||||
!result?.timedOut &&
|
||||
!result?.error &&
|
||||
[0, 128].includes(result?.code ?? 0),
|
||||
forced: true,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
const processGroupId = childLifecycles.get(child)?.processGroupId;
|
||||
@@ -717,43 +505,11 @@ async function terminateChildTree(
|
||||
return { stopped, forced: true };
|
||||
}
|
||||
|
||||
async function waitForBackendReady(
|
||||
backendChild,
|
||||
timeoutMs = 600_000,
|
||||
{
|
||||
checkBackendReady = (onOwnershipRejected) =>
|
||||
isBackendReady({ onOwnershipRejected }),
|
||||
readState = () => readJson(devStackStatePath),
|
||||
resolveTargets = readBackendTargets,
|
||||
} = {},
|
||||
) {
|
||||
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
|
||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
const startedAt = Date.now();
|
||||
let lastOwnershipReason = '';
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (
|
||||
await checkBackendReady((ownership) => {
|
||||
if (ownership.reason === lastOwnershipReason) {
|
||||
return;
|
||||
}
|
||||
lastOwnershipReason = ownership.reason;
|
||||
// 本次自己拉起的后端如果归属校验一直不通过,必须把原因打出来,
|
||||
// 否则只会表现为等待 600 秒后超时。
|
||||
console.warn(
|
||||
`[ai-game-creator-shell] 等待配套后端就绪时归属校验未通过(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)})。`,
|
||||
);
|
||||
})
|
||||
) {
|
||||
return resolveTargets();
|
||||
}
|
||||
const state = readState();
|
||||
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
|
||||
const serviceFailure = readBackendServiceFailure(state);
|
||||
if (serviceFailure) {
|
||||
throw new Error(
|
||||
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
|
||||
);
|
||||
}
|
||||
if (await isBackendReady()) {
|
||||
return readBackendTargets();
|
||||
}
|
||||
const failure = readChildFailure(backendChild);
|
||||
if (failure) {
|
||||
@@ -769,14 +525,7 @@ async function waitForBackendReady(
|
||||
|
||||
async function ensureBackend({
|
||||
onBackendChild = () => {},
|
||||
checkBackendReady = () =>
|
||||
isBackendReady({
|
||||
onOwnershipRejected(ownership) {
|
||||
console.warn(
|
||||
`[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
checkBackendReady = isBackendReady,
|
||||
resolveTargets = readBackendTargets,
|
||||
spawnBackend = () =>
|
||||
spawnChild(
|
||||
@@ -856,33 +605,15 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||||
|
||||
async function main() {
|
||||
let backendChild = null;
|
||||
let startedBackend = false;
|
||||
let viteChild = null;
|
||||
let shutdownSignal = '';
|
||||
const signalHandlers = new Map();
|
||||
|
||||
// 只有本次会话真正拉起过配套后端时才做兜底清扫:复用别人后端时不能连带
|
||||
// 杀掉对方的进程。dev.mjs 的清理依赖它的 shell 包装层仍然活着,而 Ctrl+C
|
||||
// 往往先杀掉包装层,所以这里必须按本工作树 api-server.exe 的身份再收一次。
|
||||
const sweepStartedBackend = () => {
|
||||
if (!startedBackend || process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
const stopped = stopWindowsWorktreeProcesses({ apiServerExePath });
|
||||
if (stopped.length > 0) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已清理残留后端进程: ${stopped.join(', ')}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||||
sweepStartedBackend();
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
@@ -901,7 +632,6 @@ async function main() {
|
||||
},
|
||||
});
|
||||
backendChild = backend.backendChild;
|
||||
startedBackend = Boolean(backendChild);
|
||||
if (shutdownSignal) {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
|
||||
}
|
||||
@@ -935,7 +665,6 @@ async function main() {
|
||||
terminateChildTree(viteChild),
|
||||
terminateChildTree(backendChild),
|
||||
]);
|
||||
sweepStartedBackend();
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
@@ -952,25 +681,19 @@ function isDirectModuleExecution() {
|
||||
export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
formatOwnerLabel,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupAlive,
|
||||
isWorktreeApiServerOwner,
|
||||
isWorktreeSpacetimeOwner,
|
||||
preflightExistingVite,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
readLinuxProcessGroupAlive,
|
||||
readWindowsPortOwnerIdentities,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
verifyAgcBackendOwnership,
|
||||
waitForBackendReady,
|
||||
waitForChildTermination,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ struct PromptCompositions {
|
||||
/// 而是一份独立的完整清单:plan 根的工具面只有 7 个原生工具,专业组、
|
||||
/// isolated child、任务图与视觉产物合同在这条链路上全部不可执行,逐段
|
||||
/// 减法会把「plan 根到底看到什么」摊在两个函数的四个否定分支里。
|
||||
supervisor_plan: Vec<String>,
|
||||
supervisor_chat: SupervisorChatComposition,
|
||||
}
|
||||
|
||||
@@ -98,6 +99,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>,
|
||||
}
|
||||
|
||||
@@ -226,6 +231,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,
|
||||
@@ -288,6 +299,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
|
||||
@@ -336,6 +348,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<_>>();
|
||||
@@ -408,6 +421,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",
|
||||
@@ -436,9 +457,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:?}"
|
||||
@@ -675,11 +706,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 重复:{}",
|
||||
@@ -687,7 +724,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
|
||||
@@ -713,6 +753,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)?;
|
||||
}
|
||||
@@ -874,6 +920,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),
|
||||
@@ -962,6 +1012,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));
|
||||
|
||||
@@ -23,7 +23,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": [
|
||||
@@ -43,6 +47,14 @@
|
||||
"supervisorClaimGate",
|
||||
"supervisorRepair"
|
||||
],
|
||||
"supervisorPlan": [
|
||||
"$header",
|
||||
"planCommon",
|
||||
"isolatedAgentContract",
|
||||
"planSupervisorIdentity",
|
||||
"planSupervisorPlaybook",
|
||||
"supervisorRepair"
|
||||
],
|
||||
"supervisorChat": {
|
||||
"identity": "supervisorIdentityContract",
|
||||
"finalReply": "supervisorFinalReplyContract"
|
||||
@@ -58,7 +70,12 @@
|
||||
"editorUnavailable": "supervisorVisualWithoutEditor"
|
||||
}
|
||||
},
|
||||
"roleOverlays": [],
|
||||
"roleOverlays": [
|
||||
{
|
||||
"agentId": "project-planning",
|
||||
"sections": ["projectPlanningRoleBrief"]
|
||||
}
|
||||
],
|
||||
"providerFragments": {
|
||||
"isolatedToolContract": "providerIsolatedToolContract",
|
||||
"autonomousRunProfile": "providerAutonomousRunProfile",
|
||||
@@ -85,6 +102,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,7 @@
|
||||
用户只描述玩法类型、机制或相似体验时,不代表授权复刻现有游戏。所有专业 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 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。
|
||||
|
||||
【转达的规则】
|
||||
|
||||
- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似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` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),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`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。
|
||||
- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。
|
||||
- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。
|
||||
|
||||
## 低幻觉与 GDD 约束
|
||||
|
||||
- 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。
|
||||
- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending`、`answerSource=default`、`round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision`、`round=0`,并按当前意见重新填写 `topic`、`state` 和 `answerSummary`。
|
||||
- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容。
|
||||
- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。
|
||||
- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。
|
||||
- 只定义一个完整可玩闭环。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、代码围栏或内部思考过程,不要假装已经写入文件、完成审批或启动构建。
|
||||
@@ -14,8 +14,6 @@ mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -40,8 +38,6 @@ pub(crate) use codex_cli::{
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,467 +0,0 @@
|
||||
use crate::config::prepare_game_creator_private_path_for_read;
|
||||
use crate::project::{
|
||||
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
|
||||
};
|
||||
use crate::{LocalConversationMessageRecord, LocalConversationResult};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
|
||||
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
|
||||
"host_skills.instructions",
|
||||
"permissions.instructions",
|
||||
"environments.environment_context",
|
||||
];
|
||||
|
||||
const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
|
||||
("# AGENTS.md instructions", "</INSTRUCTIONS>"),
|
||||
("<environment_context>", "</environment_context>"),
|
||||
("<skill>", "</skill>"),
|
||||
("<user_shell_command>", "</user_shell_command>"),
|
||||
("<turn_aborted>", "</turn_aborted>"),
|
||||
("<subagent_notification>", "</subagent_notification>"),
|
||||
("<recommended_plugins>", "</recommended_plugins>"),
|
||||
("<goal_context>", "</goal_context>"),
|
||||
];
|
||||
|
||||
fn is_known_contextual_user_text(text: &str) -> bool {
|
||||
let text = text.trim();
|
||||
if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
|
||||
.iter()
|
||||
.any(|(start, end)| text.starts_with(start) && text.ends_with(end))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if text.starts_with("<codex_internal_context") && text.ends_with("</codex_internal_context>") {
|
||||
return true;
|
||||
}
|
||||
text.starts_with("<external_")
|
||||
&& text
|
||||
.split_once('>')
|
||||
.and_then(|(start, _)| start.strip_prefix("<external_"))
|
||||
.is_some_and(|key| text.ends_with(&format!("</external_{key}>")))
|
||||
}
|
||||
|
||||
pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
|
||||
if matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("developer" | "system")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if item
|
||||
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
item.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
let Some(text) = part.get("text").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
is_known_contextual_user_text(text)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn history_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/project.jsonl")
|
||||
}
|
||||
|
||||
fn record(item: &Value) -> Result<String, String> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
|
||||
"payload": item,
|
||||
}))
|
||||
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
|
||||
}
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
|
||||
|
||||
fn find_direct_project_history_item_by_id_at(
|
||||
path: &Path,
|
||||
item_id: &str,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let mut file = File::open(path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
let mut position = file
|
||||
.metadata()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"读取 DirectProject 历史元数据失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
.len();
|
||||
let mut pending = Vec::new();
|
||||
let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
|
||||
|
||||
loop {
|
||||
if position == 0 {
|
||||
break;
|
||||
}
|
||||
let read_len = usize::try_from(position)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(chunk.len());
|
||||
position -= read_len as u64;
|
||||
file.seek(SeekFrom::Start(position))
|
||||
.map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
file.read_exact(&mut chunk[..read_len])
|
||||
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
|
||||
let mut combined = Vec::with_capacity(read_len + pending.len());
|
||||
combined.extend_from_slice(&chunk[..read_len]);
|
||||
combined.extend_from_slice(&pending);
|
||||
let mut line_end = combined.len();
|
||||
while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
|
||||
let line = &combined[newline + 1..line_end];
|
||||
if !line.is_empty() {
|
||||
if let Some(item) = direct_project_history_item_from_line(path, line)? {
|
||||
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||
return Ok(Some(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
line_end = newline;
|
||||
}
|
||||
pending = combined[..line_end].to_vec();
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
// The append path repairs an unterminated final JSONL record before
|
||||
// writing. A duplicate scan must not reject that repairable tail.
|
||||
if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
|
||||
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||
return Ok(Some(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn direct_project_history_item_from_line(
|
||||
path: &Path,
|
||||
line: &[u8],
|
||||
) -> Result<Option<Value>, String> {
|
||||
if line.iter().all(|byte| byte.is_ascii_whitespace()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let parsed: Value = serde_json::from_slice(line)
|
||||
.map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let item = parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(item))
|
||||
}
|
||||
|
||||
pub(crate) fn append_direct_project_history_item_at(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<(), String> {
|
||||
append_direct_project_history_item_at_with_user_policy(root, item, false)
|
||||
}
|
||||
|
||||
/// Appends a user message authored by AGC itself. Codex response items use
|
||||
/// the default path above, which deliberately ignores echoed user messages;
|
||||
/// this explicit entry point keeps the two sources distinct.
|
||||
pub(crate) fn append_direct_project_user_message_at(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<(), String> {
|
||||
append_direct_project_history_item_at_with_user_policy(root, item, true)
|
||||
}
|
||||
|
||||
fn append_direct_project_history_item_at_with_user_policy(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
allow_user_item: bool,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
if is_direct_project_internal_context_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
if !allow_user_item && is_direct_project_codex_user_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
||||
let path = history_path(root);
|
||||
let history_exists =
|
||||
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
|
||||
let lock = project_append_lock_for(&path)?;
|
||||
let _append_guard = lock.lock("DirectProject 历史追加写")?;
|
||||
if history_exists {
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
|
||||
if &existing == item {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let line = record(item)?;
|
||||
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
|
||||
}
|
||||
|
||||
fn is_direct_project_codex_user_item(item: &Value) -> bool {
|
||||
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
|
||||
return true;
|
||||
}
|
||||
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
!item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_project_local_message_item(
|
||||
role: &str,
|
||||
content: &str,
|
||||
message_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let role = role.trim();
|
||||
let content = content.trim();
|
||||
if content.is_empty() || !matches!(role, "user" | "assistant") {
|
||||
return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
|
||||
}
|
||||
let mut item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": if role == "user" { "input_text" } else { "output_text" },
|
||||
"text": content,
|
||||
}],
|
||||
});
|
||||
if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
item["id"] = Value::String(message_id.to_string());
|
||||
}
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
let mut items = Vec::new();
|
||||
let mut reader = BufReader::new(file);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
let had_newline = line.ends_with('\n');
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(value) => value,
|
||||
Err(_error) if !had_newline => break,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"解析 DirectProject 历史失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let item = parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let path = history_path(root);
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
let messages = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role").and_then(Value::as_str)?;
|
||||
if !matches!(role, "user" | "assistant") {
|
||||
return None;
|
||||
}
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(!content.is_empty()).then(|| LocalConversationMessageRecord {
|
||||
schema_version: "agc-direct-project-context.v1".to_string(),
|
||||
role: role.to_string(),
|
||||
content,
|
||||
agent_id: None,
|
||||
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||
updated_at: 0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(LocalConversationResult {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
agent_id: None,
|
||||
session_id: None,
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
|
||||
is_direct_project_internal_context_item, read_direct_project_history_items_at,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn filters_host_context_but_keeps_real_user_items() {
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "<skills_instructions>"}]
|
||||
})));
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "<environment_context>"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["environments.environment_context"]
|
||||
}
|
||||
})));
|
||||
assert!(!is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "ls ui"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["user.text"]
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_codex_contextual_user_item_without_passthrough_metadata() {
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"
|
||||
}]
|
||||
})));
|
||||
assert!(!is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "请读取 <environment_context> 中的说明"
|
||||
}]
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_repairs_truncated_tail_before_idempotency_scan() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
|
||||
.expect("init project");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"type":"response_item","payload":{"type":"message"}"#,
|
||||
)
|
||||
.expect("write truncated history tail");
|
||||
let item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "tail-repair-item",
|
||||
"content": [{"type": "output_text", "text": "已修复"}]
|
||||
});
|
||||
append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
|
||||
let items =
|
||||
read_direct_project_history_items_at(root.path()).expect("read repaired history");
|
||||
assert_eq!(items, vec![item]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
|
||||
.expect("init project");
|
||||
let user = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-0001:user",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||
});
|
||||
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&serde_json::json!({
|
||||
"type": "userMessage",
|
||||
"id": "codex-user-item-1",
|
||||
"clientId": "turn-0001",
|
||||
"content": [{"type": "text", "text": "请创建菜单"}]
|
||||
}),
|
||||
)
|
||||
.expect("ignore Codex echo");
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "codex-raw-user-item-1",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||
}),
|
||||
)
|
||||
.expect("ignore raw Codex user echo");
|
||||
let items = read_direct_project_history_items_at(root.path()).expect("read history");
|
||||
assert_eq!(items, vec![user]);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use super::direct_project_history::append_direct_project_history_item_at;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DirectProjectHistoryAccumulator {
|
||||
text_by_item_id: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DirectProjectHistoryAccumulator {
|
||||
pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
|
||||
self.text_by_item_id
|
||||
.entry(item_id.to_string())
|
||||
.or_default()
|
||||
.push_str(delta);
|
||||
}
|
||||
|
||||
pub(crate) fn complete_item(&mut self, item: &Value) {
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
self.text_by_item_id.remove(item_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
|
||||
std::mem::take(&mut self.text_by_item_id)
|
||||
.into_iter()
|
||||
.filter(|(_, text)| !text.is_empty())
|
||||
.map(|(item_id, text)| {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": item_id,
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_direct_project_partial_items_at(
|
||||
root: &Path,
|
||||
accumulator: &mut DirectProjectHistoryAccumulator,
|
||||
) -> Result<(), String> {
|
||||
let items = accumulator.take_partial_items().collect::<Vec<_>>();
|
||||
let mut first_error = None;
|
||||
for item in items {
|
||||
if let Err(error) = append_direct_project_history_item_at(root, &item) {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
@@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
|
||||
if normalized.contains("permission-denied") || normalized.contains("http 403") {
|
||||
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
|
||||
}
|
||||
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
|
||||
if error.contains("项目正在被其他写操作占用") {
|
||||
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
|
||||
}
|
||||
if error.contains("身份不唯一")
|
||||
@@ -2147,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
|
||||
let Ok(validated_slices) = validated_art_slices(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if validated_slices.is_empty() {
|
||||
if validated_slices.len() != 4 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
|
||||
@@ -2718,7 +2718,6 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
asset_kind: asset_kind.to_string(),
|
||||
asset_label: asset_label.to_string(),
|
||||
replace_existing: root.join(output_path).is_file(),
|
||||
slice_count: None,
|
||||
};
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
@@ -2822,9 +2821,9 @@ fn direct_taonier_art_package_result(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if includes_spritesheet && slice_paths.is_empty() {
|
||||
if includes_spritesheet && slice_paths.len() != 4 {
|
||||
slice_warnings.push(
|
||||
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
|
||||
"当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -4407,6 +4406,48 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<Strin
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
fn persist_direct_codex_assistant_reply_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
reply: &str,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: reply.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&format!("direct-codex:{client_turn_id}:assistant"),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn persist_direct_codex_user_prompt_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: prompt.trim().to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&format!("direct-codex:{client_turn_id}:user"),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
@@ -4438,6 +4479,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
|
||||
500,
|
||||
));
|
||||
}
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
@@ -4453,6 +4503,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
|
||||
500,
|
||||
));
|
||||
}
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||
Ok(reply)
|
||||
@@ -4466,34 +4525,6 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
||||
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn persist_direct_codex_user_prompt_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(), String> {
|
||||
let item = direct_project_local_message_item(
|
||||
"user",
|
||||
prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)?;
|
||||
append_direct_project_user_message_at(root, &item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn persist_direct_codex_assistant_reply_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
reply: &str,
|
||||
) -> Result<(), String> {
|
||||
let item = direct_project_local_message_item(
|
||||
"assistant",
|
||||
reply,
|
||||
Some(&format!("direct-codex:{client_turn_id}:assistant")),
|
||||
)?;
|
||||
append_direct_project_history_item_at(root, &item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -4578,8 +4609,8 @@ mod tests {
|
||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
|
||||
.expect("the App's same-id write converges idempotently");
|
||||
|
||||
let conversation =
|
||||
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
||||
.expect("read project conversation");
|
||||
let message_id = format!("direct-codex:{turn_id}:assistant");
|
||||
let persisted = conversation
|
||||
.messages
|
||||
@@ -4592,7 +4623,7 @@ mod tests {
|
||||
assert!(
|
||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
|
||||
.expect_err("same message identity cannot be rebound")
|
||||
.contains("item id 冲突")
|
||||
.contains("messageId 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4609,8 +4640,8 @@ mod tests {
|
||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
|
||||
.expect("retry reuses the same user message identity");
|
||||
|
||||
let conversation =
|
||||
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
||||
.expect("read project conversation");
|
||||
let message_id = format!("direct-codex:{turn_id}:user");
|
||||
let persisted = conversation
|
||||
.messages
|
||||
@@ -4623,7 +4654,7 @@ mod tests {
|
||||
assert!(
|
||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
|
||||
.expect_err("same turn identity cannot be rebound")
|
||||
.contains("item id 冲突")
|
||||
.contains("messageId 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4710,10 +4741,10 @@ mod tests {
|
||||
assert!(!prompt.contains("你是 Codex"));
|
||||
assert!(prompt.contains("不要等待 Supervisor"));
|
||||
assert!(prompt.contains("提示词与技能"));
|
||||
assert!(prompt.contains("AGC 工程合同:当前 cwd 是用户选择的项目目录"));
|
||||
assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)"));
|
||||
assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP"));
|
||||
assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时"));
|
||||
assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明"));
|
||||
assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`"));
|
||||
assert!(prompt.contains("agc_write_file"));
|
||||
assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文"));
|
||||
assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装"));
|
||||
|
||||
@@ -63,6 +63,7 @@ struct DirectToolBridgeTurnAuthorization {
|
||||
|
||||
struct DirectToolBridgeActiveTurnAuthorization {
|
||||
turn_id: String,
|
||||
allows_regeneration: bool,
|
||||
brief_sha256: Option<String>,
|
||||
completed_result: Option<Value>,
|
||||
resource_request_ids: BTreeMap<String, (String, String)>,
|
||||
@@ -180,23 +181,30 @@ impl DirectToolBridge {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Arm exactly one client-owned Direct turn. Codex chooses the business
|
||||
/// operation through the reviewed MCP tool and arguments; the bridge only
|
||||
/// binds that call to the active client turn.
|
||||
pub(crate) fn begin_user_turn(&self) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
self.state.begin_user_turn()
|
||||
/// Arm exactly one client-owned Direct turn. The raw user message is used
|
||||
/// only for this synchronous decision and is never retained by the bridge.
|
||||
pub(crate) fn begin_user_turn(
|
||||
&self,
|
||||
user_prompt: &str,
|
||||
) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
self.state.begin_user_turn(user_prompt)
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectToolBridgeState {
|
||||
fn begin_user_turn(self: &Arc<Self>) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
fn begin_user_turn(
|
||||
self: &Arc<Self>,
|
||||
user_prompt: &str,
|
||||
) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
|
||||
let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt);
|
||||
let mut authorization = self
|
||||
.turn_authorization
|
||||
.lock()
|
||||
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
|
||||
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
|
||||
turn_id: turn_id.clone(),
|
||||
allows_regeneration,
|
||||
brief_sha256: None,
|
||||
completed_result: None,
|
||||
resource_request_ids: BTreeMap::new(),
|
||||
@@ -586,9 +594,12 @@ impl DirectToolBridgeState {
|
||||
.active
|
||||
.as_mut()
|
||||
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
|
||||
if !active.allows_regeneration {
|
||||
return Err("当前用户消息未显式授权重新生成或替换美术".to_string());
|
||||
}
|
||||
match active.brief_sha256.as_deref() {
|
||||
Some(expected) if expected != brief_sha256 => {
|
||||
return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string())
|
||||
return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string())
|
||||
}
|
||||
None => active.brief_sha256 = Some(brief_sha256.clone()),
|
||||
Some(_) => {}
|
||||
@@ -1473,14 +1484,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
return Err("工具参数 content 不能包含 NUL".to_string());
|
||||
}
|
||||
reject_command_output_wrapper(content)?;
|
||||
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
|
||||
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
|
||||
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
|
||||
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"direct-codex.file.write",
|
||||
)?;
|
||||
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
|
||||
let written = write_local_project_file_at(root, &path, content)?;
|
||||
let revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||
Ok::<_, String>(json!({
|
||||
@@ -1500,27 +1504,6 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async,
|
||||
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
|
||||
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
|
||||
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
|
||||
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
|
||||
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
|
||||
let task_root = root.clone();
|
||||
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => bridge_tool_result(
|
||||
redact_agent_runtime_error(
|
||||
&root,
|
||||
&format!("agc_write_file 阻塞任务未返回:{error}"),
|
||||
480,
|
||||
),
|
||||
Vec::new(),
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
|
||||
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
|
||||
if asset_id.trim().is_empty() {
|
||||
@@ -2133,7 +2116,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
asset_kind: kind.clone(),
|
||||
asset_label: asset_name.clone(),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
};
|
||||
let _generation_guard = state.image_generation_gate.lock().await;
|
||||
let generated = with_direct_editor_api_credentials(
|
||||
@@ -2335,9 +2317,7 @@ async fn handle_direct_tool_bridge(
|
||||
bridge_list_registered_assets(&state.root, &request.arguments)
|
||||
}
|
||||
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
|
||||
"agc_write_file" => {
|
||||
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
|
||||
}
|
||||
"agc_write_file" => bridge_write_file(&state.root, &request.arguments),
|
||||
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
|
||||
"agc_import_account_assets" => {
|
||||
bridge_import_account_assets(&state, &request.arguments).await
|
||||
@@ -2742,185 +2722,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
|
||||
/// 而不是互相报"项目正在被其他写操作占用"。
|
||||
#[test]
|
||||
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
|
||||
let temporary = tempfile::tempdir().expect("create parallel direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试")
|
||||
.expect("initialize parallel direct write root");
|
||||
let root = temporary.path().to_path_buf();
|
||||
let paths = (0..4)
|
||||
.map(|index| format!("game/parallel-{index}.js"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let results = std::thread::scope(|scope| {
|
||||
let handles = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let root = root.clone();
|
||||
let path = path.clone();
|
||||
scope.spawn(move || {
|
||||
let result = bridge_write_file(
|
||||
&root,
|
||||
&json!({ "path": path, "content": format!("// {path}\n") }),
|
||||
);
|
||||
(path, result)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("parallel direct write must not panic"))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
for (path, result) in &results {
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"parallel direct write of {path} must succeed: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(path)).expect("read parallel direct write"),
|
||||
format!("// {path}\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
|
||||
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
|
||||
#[test]
|
||||
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
|
||||
let temporary = tempfile::tempdir().expect("create contended direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试")
|
||||
.expect("initialize contended direct write root");
|
||||
let root = temporary.path().to_path_buf();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let holder_barrier = std::sync::Arc::clone(&barrier);
|
||||
let holder_root = root.clone();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||
.expect("acquire a short-lived project writer");
|
||||
holder_barrier.wait();
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
drop(lock);
|
||||
});
|
||||
barrier.wait();
|
||||
|
||||
let result = bridge_write_file(
|
||||
&root,
|
||||
&json!({ "path": "game/waited.js", "content": "// waited\n" }),
|
||||
);
|
||||
|
||||
holder.join().expect("join the short-lived project writer");
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"the direct write must wait out a short same-process writer: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"),
|
||||
"// waited\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池,
|
||||
/// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步
|
||||
/// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。
|
||||
#[tokio::test]
|
||||
async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create blocking pool write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试")
|
||||
.expect("initialize blocking pool write root");
|
||||
let state = direct_tool_bridge_state(temporary.path().to_path_buf());
|
||||
|
||||
// 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。
|
||||
let holder_root = temporary.path().to_path_buf();
|
||||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||
.expect("acquire the concurrent project writer");
|
||||
let holder = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
drop(lock);
|
||||
});
|
||||
|
||||
// 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。
|
||||
let heartbeat = Arc::new(AtomicBool::new(false));
|
||||
let heartbeat_writer = Arc::clone(&heartbeat);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
heartbeat_writer.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let response = handle_direct_tool_bridge(
|
||||
axum::extract::State(state),
|
||||
axum::Json(DirectToolBridgeRequest {
|
||||
tool: "agc_write_file".to_string(),
|
||||
arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.0;
|
||||
|
||||
holder.join().expect("join the concurrent project writer");
|
||||
assert!(
|
||||
heartbeat.load(Ordering::SeqCst),
|
||||
"写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker"
|
||||
);
|
||||
assert_eq!(
|
||||
response.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"the pooled direct write must still wait out the holder: {response}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(temporary.path().join("game/blocking-pool.js"))
|
||||
.expect("read pooled direct write"),
|
||||
"// pooled\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create acl direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试")
|
||||
.expect("initialize acl direct write root");
|
||||
let agent_directory = temporary.path().join(".agent");
|
||||
let original = fs::metadata(&agent_directory)
|
||||
.expect("read control directory metadata")
|
||||
.permissions();
|
||||
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
|
||||
.expect("drop write permission on the control directory");
|
||||
|
||||
let result = bridge_write_file(
|
||||
temporary.path(),
|
||||
&json!({ "path": "game/acl-denied.js", "content": "// denied\n" }),
|
||||
);
|
||||
fs::set_permissions(&agent_directory, original)
|
||||
.expect("restore control directory permission");
|
||||
|
||||
if result.get("isError").and_then(Value::as_bool) != Some(true) {
|
||||
// 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。
|
||||
return;
|
||||
}
|
||||
let text = result
|
||||
.pointer("/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!text.contains("项目正在被其他写操作占用"),
|
||||
"a permission denial must not be projected as lock contention: {text}"
|
||||
);
|
||||
assert!(!temporary.path().join("game/acl-denied.js").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
|
||||
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
|
||||
@@ -2935,20 +2736,106 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regenerate_uses_current_client_turn_and_one_stable_brief() {
|
||||
fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() {
|
||||
for prompt in [
|
||||
"继续修复布局",
|
||||
"解释一下重新生成美术是什么意思",
|
||||
"不要重新生成美术,只调整代码",
|
||||
"别换一套美术,继续用现在这套",
|
||||
"解释一下换一套美术按钮",
|
||||
"是否要改变视觉风格?",
|
||||
"Do not regenerate the art; keep the current package.",
|
||||
"I don't want to change the visual style.",
|
||||
"What does use a new art set mean?",
|
||||
"文案写着“换一套美术”",
|
||||
"Yesterday I said regenerate art, but today keep it.",
|
||||
"Please explain how to regenerate art.",
|
||||
"重新生成美术以后再说,现在只修代码",
|
||||
"重做美术先不做,先改玩法",
|
||||
"Regenerate the art maybe later; for now just fix the code.",
|
||||
"把按钮文案改成“请重新生成美术”,不要执行生成工具",
|
||||
"把按钮文案改成‘请重新生成美术’,不要执行生成工具",
|
||||
"Change the button label to 'please regenerate the art'; do not execute it.",
|
||||
"用户之前说请重新生成美术,我只是在复述",
|
||||
"Yesterday the user said please regenerate the art; I am just quoting it.",
|
||||
"以后请重新生成美术,现在先改代码",
|
||||
"你能不能帮我重新生成美术,顺便解释一下价格",
|
||||
"请重新生成美术吗",
|
||||
"请重新生成美术吗,还是只改代码",
|
||||
"请重新生成美术或者只改代码",
|
||||
"请重新生成美术以外的内容",
|
||||
"请重新生成美术,但不要执行生成工具",
|
||||
"不需要重新生成美术",
|
||||
"界面上显示:请重新生成美术",
|
||||
"界面标题是请重新生成美术",
|
||||
"产品经理让我写请重新生成美术",
|
||||
"下周请重新生成美术",
|
||||
"他说«请重新生成美术»",
|
||||
"Could you please regenerate the art",
|
||||
"Please regenerate the art? Or only fix code.",
|
||||
"Please regenerate the art except for the paid generation.",
|
||||
"Please regenerate the art, but do not execute the tool.",
|
||||
"Please regenerate the art, but don’t execute the tool.",
|
||||
"Please regenerate the art, but I don't authorize this paid generation.",
|
||||
"Please regenerate the art, but I don‘t authorize this paid generation.",
|
||||
"Please regenerate the art, but do not execute the paid tool.",
|
||||
"Please regenerate the art, but never execute the paid tool.",
|
||||
"Please regenerate the art, but avoid executing the paid tool.",
|
||||
"Please regenerate the art, but 'do not execute the tool",
|
||||
"Please regenerate the art only if it is free.",
|
||||
"Please regenerate the art only after I confirm the charge.",
|
||||
"Please regenerate the art, but do “not” execute the paid tool.",
|
||||
"请重新生成美术,三天后再执行。",
|
||||
"请重新生成美术,得到我的许可再做。",
|
||||
"请重新生成美术,地面需要无缝循环。",
|
||||
"Please regenerate the art, but skip the paid generation.",
|
||||
"请重新生成美术【生成操作跳过】",
|
||||
"请重新生成美术【仅在零元时执行】",
|
||||
"Please regenerate the art “but skip the paid generation”",
|
||||
"Please regenerate the art; alternatively, just fix the code.",
|
||||
"Please regenerate the art, but do n\u{200B}ot execute the paid tool.",
|
||||
"Please regenerate the art with a clay style.",
|
||||
"I don't need you to regenerate the art",
|
||||
"The UI shows: please regenerate the art",
|
||||
"Please regenerate the art next week",
|
||||
"He said «please regenerate the art»",
|
||||
] {
|
||||
assert!(
|
||||
!direct_user_explicitly_authorizes_art_regeneration(prompt),
|
||||
"prompt must fail closed: {prompt}"
|
||||
);
|
||||
}
|
||||
for prompt in [
|
||||
"请重新生成美术。",
|
||||
"那就请重新生成美术!",
|
||||
"换一套美术",
|
||||
"Please regenerate the art!",
|
||||
] {
|
||||
assert!(
|
||||
direct_user_explicitly_authorizes_art_regeneration(prompt),
|
||||
"prompt must explicitly authorize: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
let root = tempfile::tempdir().expect("stable client turn root");
|
||||
let state = direct_tool_bridge_state(root.path().to_path_buf());
|
||||
assert!(state.begin_user_turn().is_err());
|
||||
assert!(state.begin_user_turn("请重新生成美术").is_err());
|
||||
let client_turn_id = "client-turn-stable-0001";
|
||||
let _active_invocation =
|
||||
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
|
||||
.expect("client-owned stable invocation");
|
||||
let active_turn = state
|
||||
.begin_user_turn()
|
||||
.expect("client turn authorization state");
|
||||
let ordinary_turn = state
|
||||
.begin_user_turn("继续优化交互")
|
||||
.expect("ordinary turn authorization state");
|
||||
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
||||
drop(ordinary_turn);
|
||||
|
||||
let authorized_turn = state
|
||||
.begin_user_turn("请重新生成美术")
|
||||
.expect("authorized regeneration turn");
|
||||
let (turn_id, brief_sha256) = match state
|
||||
.authorize_regeneration_call("陶泥风格")
|
||||
.expect("MCP mode selects regeneration explicitly")
|
||||
.expect("first stable regeneration call")
|
||||
{
|
||||
DirectToolBridgeRegenerationCall::Execute {
|
||||
turn_id,
|
||||
@@ -2977,7 +2864,7 @@ mod tests {
|
||||
panic!("completed stable retry must not execute a second paid call")
|
||||
}
|
||||
}
|
||||
drop(active_turn);
|
||||
drop(authorized_turn);
|
||||
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,8 @@ pub(in crate::agent) use canvas_generation::{
|
||||
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
|
||||
generate_platform_art_asset_with_retained_runtime_options_at,
|
||||
generate_platform_art_asset_with_runtime_options_at,
|
||||
platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options,
|
||||
register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at,
|
||||
restore_platform_art_asset_bytes_at,
|
||||
platform_art_generation_error_result_unknown, register_existing_platform_art_slices_at,
|
||||
request_platform_art_asset_with_runtime_options_at, restore_platform_art_asset_bytes_at,
|
||||
retained_platform_art_generation_runtime_spritesheet_identity_at,
|
||||
retained_platform_art_generation_runtime_state_matches_direct_stage_at,
|
||||
validate_platform_art_png_bytes_with_limits,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -337,6 +337,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 one or more lines are too long
@@ -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)]
|
||||
@@ -73,6 +74,7 @@ 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_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,
|
||||
@@ -104,7 +106,6 @@ pub(crate) use project_gates::{
|
||||
prepare_agent_runtime_project_mutation_locked, process_session_completion_blocker_at,
|
||||
project_verification_completion_blocker, project_verification_completion_blocker_at,
|
||||
static_delegate_completion_blocker_at, structured_plan_completion_blocker,
|
||||
try_acquire_game_creator_agent_runtime_project_write_lock,
|
||||
validate_agent_runtime_pending_verification_gate_before,
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -115,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,
|
||||
};
|
||||
@@ -145,6 +147,9 @@ pub(crate) use tool_plan_protocol::parse_game_creator_agent_tool_plan_response;
|
||||
pub(crate) use tool_policy_snapshot::{
|
||||
agent_runtime_acceptance_evidence_tools,
|
||||
agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools,
|
||||
agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at,
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
|
||||
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,
|
||||
plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage,
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,29 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let tool = action.tool.trim();
|
||||
let relaxed_autonomous = autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false);
|
||||
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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user