lili 0ccce16aa2 feat(amodel-gen): gen.mjs --brief-file + SAA→A-model 执行版 plan(集成C)
--brief-file 容大段富化设计稿(避 argv 长度/引号坑;U3 Java shell-out 复用),design+saa 双模式生效。plan 已折叠两轮 Codex+Opus 双评审。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:45:33 -07:00

323 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* gen.mjs — agentic A-model 生成 orchestrator(L2 ReAct 循环 · spike)
* owner:amodel-gen spike
*
* 流程:scaffold(_template→amgen-<id>) → ReAct(M3 + 5 工具,done 门=check+build 绿)
* → play(循环外·权威门) → 落 verdict / run-log / run-summary。
*
* 用法(cwd=game-runtime,mini-desktop):
* spike:node tools/amodel-gen/gen.mjs --id run1 --brief "..." [--max-steps 40] [--port 4320] [--cdp 9222]
* SAA :node tools/amodel-gen/gen.mjs --mode saa --game-id <gameId> --brief "..."(__GameBundle 信封 + stage 到 _wg1-gen/ + done 门含 smoke-boot;stdout 末行 JSON)
*
* 注:本文件是 harness 侧,允许 Date.now/setTimeout(红线只约束游戏 src)。
*/
'use strict';
import { writeFileSync, readFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir } from './tools.mjs';
import { chat, MODEL } from './m3.mjs';
import { TOOLS, buildSystemPrompt, DESIGN_TOOLS, buildDesignSystemPrompt } from './prompt.mjs';
import { play, smokeBoot } from './play.mjs';
function arg(name, def) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : def;
}
/** brief 取值:优先 --brief-file(读文件,容大段富化设计稿,避 argv 长度/引号坑;U3 Java 也用)→ 否则 --brief。 */
function briefArg(def) {
const f = arg('brief-file');
if (f) { try { return readFileSync(f, 'utf8'); } catch (e) { console.error('--brief-file 读失败,回退 --brief: ' + e.message); } }
return arg('brief', def);
}
const TOOL_RESULT_CAP = 30000; // 单次工具结果注入上限(够一份 skill/api.d.ts,控 context)
/** 历史压缩:折叠旧的大 tool 结果 / write 内容,控 token(主流 context 工程:循环越长越要压)。占位 <400 字符 → 幂等。 */
function compactHistory(messages, keepLast = 8) {
const head = 2; // system + 首个 user-brief 保全
const tailStart = Math.max(head, messages.length - keepLast);
for (let i = head; i < tailStart; i++) {
const m = messages[i];
if (!m) continue;
if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > 400) {
m.content = '[旧工具结果已折叠省 token;需要可重新 read_file]';
}
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
for (const c of m.tool_calls) {
if (c.function && c.function.name === 'write_file' && c.function.arguments && c.function.arguments.length > 400) {
try {
const a = JSON.parse(c.function.arguments);
c.function.arguments = JSON.stringify({ path: a.path, content: '[旧 write 内容已折叠;文件在磁盘,需要可重新 read_file]' });
} catch { /* 坏 JSON 略过 */ }
}
}
}
}
}
/** 容错归一 gatespec(M3 偶把 assertAfterPlay 包成 {item:[…]} / expectLatch 写成字符串)→ 拍平成 {expectLatch:bool, assertAfterPlay:[]}。 */
function normGatespec(g) {
if (!g || typeof g !== 'object') return null;
let aap = g.assertAfterPlay;
if (aap && !Array.isArray(aap) && Array.isArray(aap.item)) aap = aap.item; // 拆 {item:[…]} 这层
if (!Array.isArray(aap)) aap = aap ? [aap] : [];
let latch = g.expectLatch;
if (typeof latch === 'string') latch = latch.trim().toLowerCase() === 'true';
return { expectLatch: !!latch, assertAfterPlay: aap };
}
/** design 模式:tool-using 策划 agent(read_file/list_dir/done),产玩家视角设计稿+gatespec,不写文件/不 build。
* 日志走 stderr;stdout **末行**单行 JSON {ok,designText,fail}(designNode shell-out 据此解析,同 saa 契约)。 */
async function runDesign({ brief, maxSteps, maxWallMs }) {
const t0 = Date.now();
const rec = (m) => console.error(`[+${((Date.now() - t0) / 1000).toFixed(1)}s] ${m}`); // ★ design 日志走 stderr,保 stdout 末行=JSON 契约
rec(`design model=${MODEL} brief=「${brief}」maxSteps=${maxSteps}`);
const messages = [
{ role: 'system', content: buildDesignSystemPrompt() },
{ role: 'user', content: `请按这个 brief 产一份玩家视角玩法设计稿(含 gatespec):「${brief}」。先 read_file 读 sim skill 再设计。` },
];
let designText = null, gatespec = null, steps = 0;
while (!designText && steps < maxSteps) {
if (Date.now() - t0 > maxWallMs) { rec('design wall-time cap 超时,截停'); break; }
steps++;
let resp;
try { resp = await chat(messages, DESIGN_TOOLS); } catch (e) { rec(`step${steps} M3 调用失败: ${e.message}`); break; }
const msg = resp.message; messages.push(msg);
const calls = msg.tool_calls || [];
if (calls.length === 0) {
rec(`step${steps}: 无 tool_calls(content="${(msg.content || '').slice(0, 80)}")`);
messages.push({ role: 'user', content: '请用 read_file/list_dir 读手册后,产出设计稿+gatespec 并调 done。' });
continue;
}
for (const c of calls) {
const name = c.function.name;
let a = {}; try { a = JSON.parse(c.function.arguments || '{}'); } catch { /* 坏 JSON 当空参 */ }
let result;
if (name === 'read_file') {
const r = readFileTool(a.path);
result = r.ok ? (r.truncated ? '[内容已截断]\n' : '') + r.content : 'ERROR: ' + r.error;
rec(`read ${a.path} ${r.ok ? 'ok' : 'ERR ' + r.error}`);
} else if (name === 'list_dir') {
const r = listDirTool(a.path);
result = r.ok ? r.entries.join('\n') : 'ERROR: ' + r.error;
rec(`list ${a.path} ${r.ok ? 'ok' : 'ERR'}`);
} else if (name === 'done') {
designText = (a.designText || a.design || '').trim();
gatespec = normGatespec(a.gatespec || null);
result = `DONE 收到设计稿 ${designText.length}B + gatespec ${gatespec ? 'OK' : '缺'}`;
rec(result);
messages.push({ role: 'tool', tool_call_id: c.id, content: result });
break;
} else {
result = 'ERROR: 未知工具 ' + name;
}
messages.push({ role: 'tool', tool_call_id: c.id, content: String(result).slice(0, TOOL_RESULT_CAP) });
}
}
const ok = !!(designText && designText.length > 80);
// stdout 末行单行 JSON(designNode shell-out 解析;日志全在 stderr,不污染)。
console.log(JSON.stringify({ ok, designText: ok ? designText : null, gatespec: ok ? gatespec : null, fail: ok ? null : 'design agent 未产出设计稿' }));
return ok;
}
async function main() {
const mode = arg('mode', 'spike'); // 'spike'(默认)| 'saa'(__GameBundle 信封)| 'design'(策划 agent,只产设计稿+gatespec)
// design 模式:tool-using 策划 agent,只产玩家视角设计稿+gatespec(供 designNode shell-out 喂 code agent),不 scaffold/build/play。
if (mode === 'design') {
const ok = await runDesign({
brief: briefArg('一个简单的经营小游戏'),
maxSteps: Number(arg('max-steps', '25')),
maxWallMs: Number(arg('max-wall-ms', '600000')),
});
process.exit(ok ? 0 : 1);
}
const isSaa = mode === 'saa';
const id = isSaa ? arg('game-id', arg('id', 'run1')) : arg('id', 'run1');
const brief = briefArg('一个简单的点击得分小游戏');
const maxSteps = Number(arg('max-steps', '70')); // 默认 70:raw brief 7 步(3层架构 newarch1),但 SAA design 富化 brief(juice/音效/屏震/gatespec)需更多步;留余量
const maxWallMs = Number(arg('max-wall-ms', '1200000')); // 单次 attempt 墙钟上限 20min:design 富化 brief + M3 大上下文 turn 慢,raw 102s 但富化需更久;bound thrash 而不误杀富化正常 run
const port = Number(arg('port', '4320'));
const cdpPort = Number(arg('cdp', '9222'));
// 模式分派:SAA 用 __GameBundle 信封 + buildSaa + stage + smoke;spike 用 _template 信封 + build + play。
const doScaffold = isSaa ? () => scaffoldSaa(id) : () => scaffold(id);
const doBuild = isSaa ? () => buildSaa(id) : () => build(id);
const log = [];
const t0 = Date.now();
const rec = (m) => {
const line = `[+${((Date.now() - t0) / 1000).toFixed(1)}s] ${m}`;
console.log(line);
log.push(line);
};
rec(`model=${MODEL} mode=${mode} id=${id} brief=「${brief}」maxSteps=${maxSteps}`);
rec(`scaffold amgen-${id}(clone _template${isSaa ? ' + SAA 信封' : ''})`);
doScaffold();
// 资产提供(「不让 M3 造资产、只让它决定怎么用」):--assets-dir 给定则把整目录(manifest+图+音)拷进 amgen/assets/,覆盖 _template 空清单。
// stage 会再把 assets/ 拷到 staged gameId 目录 → boot-game-host.loadHostAssets 据 manifest 载图注入 boot.assets,游戏经 boot.assets[ref].image 取图。
const assetsDir = arg('assets-dir', '');
if (assetsDir) {
if (existsSync(assetsDir)) {
cpSync(assetsDir, join(gameDir(id), 'assets'), { recursive: true });
rec(`assets 提供:${assetsDir} → amgen-${id}/assets/(M3 只决定怎么用,不造)`);
} else {
rec(`⚠ --assets-dir 不存在:${assetsDir}(跳过,游戏走程序化回退)`);
}
}
const messages = [
{ role: 'system', content: buildSystemPrompt(id) },
{ role: 'user', content: `请按这个 brief 造一款游戏:「${brief}」。先读手册再动手。` },
];
let lastCheck = null;
let lastBuild = null;
let doneSummary = null;
let finished = false;
let nudgedDone = false;
let lastSmoke = null;
let steps = 0;
let totalTokens = 0;
const callSig = new Map(); // loop-detection:同一 (name,args) 计数
while (!finished && steps < maxSteps) {
if (Date.now() - t0 > maxWallMs) { rec(`wall-time cap ${Math.round(maxWallMs / 1000)}s 超时,截停(防 thrash;失败交 repair fresh 重试)`); break; }
steps++;
compactHistory(messages); // 每轮先压旧大块,控 token
let resp;
try {
resp = await chat(messages, TOOLS);
} catch (e) {
rec(`step${steps} M3 调用失败: ${e.message}`);
break;
}
totalTokens += (resp.usage && resp.usage.total_tokens) || 0;
const msg = resp.message;
messages.push(msg);
const calls = msg.tool_calls || [];
if (calls.length === 0) {
rec(`step${steps}: 无 tool_calls(finish=${resp.finishReason}; content="${(msg.content || '').slice(0, 80)}")`);
messages.push({ role: 'user', content: '请用工具继续(read_file/write_file/check/build);全绿后调 done。' });
continue;
}
for (const c of calls) {
const name = c.function.name;
let a = {};
try { a = JSON.parse(c.function.arguments || '{}'); } catch { /* 容错:坏 JSON 当空参 */ }
const sig = name + ':' + (c.function.arguments || '');
callSig.set(sig, (callSig.get(sig) || 0) + 1);
let result;
if (name === 'read_file') {
const r = readFileTool(a.path);
result = r.ok ? (r.truncated ? '[内容已截断]\n' : '') + r.content : 'ERROR: ' + r.error;
rec(`read ${a.path} ${r.ok ? 'ok' : 'ERR ' + r.error}`);
} else if (name === 'list_dir') {
const r = listDirTool(a.path);
result = r.ok ? r.entries.join('\n') : 'ERROR: ' + r.error;
rec(`list ${a.path} ${r.ok ? 'ok' : 'ERR'}`);
} else if (name === 'write_file') {
const r = writeFileTool(id, a.path, a.content || '');
result = r.ok ? `OK ${r.bytes}B 已写` : 'ERROR: ' + r.error;
rec(`write ${a.path} ${r.ok ? r.bytes + 'B' : 'ERR ' + r.error}`);
} else if (name === 'check') {
lastCheck = check(id);
result = lastCheck.ok ? 'PASS' : 'FAIL:\n' + lastCheck.errors.join('\n');
rec(`check ${lastCheck.ok ? 'PASS' : 'FAIL(' + lastCheck.errors.length + ')'}`);
} else if (name === 'build') {
lastBuild = doBuild();
result = lastBuild.ok ? 'PASS' : 'FAIL:\n' + lastBuild.error;
rec(`build ${lastBuild.ok ? 'PASS' : 'FAIL'}`);
} else if (name === 'done') {
// done 门:复核 check+build 必须都绿;SAA 模式再加 stage + 同进程 smoke-boot(治 review HIGH-1:boot 失败挡在 generate 内,不漏到 SAA play)。
const c2 = check(id);
const b2 = c2.ok ? doBuild() : { ok: false, error: 'check 未过,先修 check' };
lastCheck = c2; if (c2.ok) lastBuild = b2;
let gate = c2.ok && b2.ok;
let gateMsg = `check=${c2.ok ? 'PASS' : 'FAIL: ' + c2.errors.join('; ')} build=${b2.ok ? 'PASS' : 'FAIL: ' + (b2.error || '')}`;
if (gate && isSaa) {
const st = stage(id);
if (!st.ok) { gate = false; gateMsg += ` stage=FAIL: ${st.error}`; }
else {
rec('done 门:stage OK → smoke-boot…');
lastSmoke = await smokeBoot(id, { port, cdpPort });
if (!lastSmoke.ok) { gate = false; gateMsg += ` smoke=FAIL(booted=${lastSmoke.booted} framesAdvanced=${lastSmoke.framesAdvanced} err=${lastSmoke.bootErr || '-'})`; }
else { gateMsg += ' stage=OK smoke=PASS'; }
}
}
if (gate) {
doneSummary = a.summary || '';
result = 'DONE 已接受' + (isSaa ? '(已 stage + smoke-boot 绿)' : '');
rec(`done 接受: ${doneSummary}`);
messages.push({ role: 'tool', tool_call_id: c.id, content: result });
finished = true;
break;
}
result = `DONE 被拒:${isSaa ? 'done 前 check+build+stage+smoke 必须都绿' : 'done 前 check+build 必须都绿'}。${gateMsg}`;
rec('done 被拒(' + gateMsg + ')');
} else {
result = 'ERROR: 未知工具 ' + name;
}
messages.push({ role: 'tool', tool_call_id: c.id, content: String(result).slice(0, TOOL_RESULT_CAP) });
}
// check+build 都绿 → 一次性强提醒 done(治"绿了还乱写测试/额外文件")
if (!finished && !nudgedDone && lastCheck && lastCheck.ok && lastBuild && lastBuild.ok) {
messages.push({ role: 'user', content: 'check 与 build 都已 PASS。若已实现 brief 的核心玩法,请立即调 done(summary 一句话);不要再写测试或额外文件。' });
nudgedDone = true;
rec('已提示 done(check+build 绿)');
}
// loop-detection:同一调用重复过多 → 截停(防卡死烧 token)
if ([...callSig.values()].some((n) => n > 6)) {
rec('loop-detection:同一工具调用重复过多,截停');
break;
}
}
// 收口:SAA 模式已在 done 门 stage+smoke(SAA play 九门是 batch B 的 Java 节点职责,本 harness 不跑);spike 模式跑循环外真玩门。
let verdict = null;
if (finished && !isSaa) {
rec('play(循环外·权威门:boot+截图+交互 = 能跑能玩)...');
try {
verdict = await play(id, { port, cdpPort });
rec(`play ${verdict.pass ? 'PASS(能跑)' : 'FAIL'} — ${verdict.notes.join(' | ')}`);
} catch (e) {
rec('play 异常: ' + (e.message || e));
}
} else if (finished && isSaa) {
rec(`SAA 模式收口:已 stage → games/_wg1-gen/${id}/ + smoke-boot 绿;SAA play 九门交 batch B Java 节点。`);
} else {
rec('未合格 done,跳过收口(本 run 未产出合格游戏)');
}
const summary = {
id, mode, brief, model: MODEL, steps, finished, doneSummary,
check: lastCheck ? { ok: lastCheck.ok, errors: lastCheck.errors } : null,
build: lastBuild ? { ok: lastBuild.ok } : null,
smoke: lastSmoke ? { ok: lastSmoke.ok, booted: lastSmoke.booted, framesAdvanced: lastSmoke.framesAdvanced, bootErr: lastSmoke.bootErr } : null,
verdict: verdict ? { pass: verdict.pass, notes: verdict.notes, error: verdict.error || null } : null,
stagedDir: isSaa && finished ? `games/_wg1-gen/${id}` : null,
approxTokens: totalTokens,
wallSec: Number(((Date.now() - t0) / 1000).toFixed(1)),
};
const evDir = join(gameDir(id), 'evidence');
mkdirSync(evDir, { recursive: true });
writeFileSync(join(evDir, 'run-log.txt'), log.join('\n'));
writeFileSync(join(evDir, 'run-summary.json'), JSON.stringify(summary, null, 2));
console.log('\n=== RUN SUMMARY ===');
console.log(JSON.stringify(summary, null, 2));
// SAA↔harness CLI 契约(§5):stdout 末行单行 JSON,SAA generate 节点专用 runner 解析。
if (isSaa) {
console.log(JSON.stringify({ ok: !!finished, gameId: id, stagedDir: finished ? `games/_wg1-gen/${id}` : null, fail: finished ? null : 'agent 未合格 done' }));
}
}
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });