/** * gen.mjs — agentic A-model 生成 orchestrator(L2 ReAct 循环 · spike) * owner:amodel-gen spike * * 流程:scaffold(_template→amgen-) → 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 --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 { pathToFileURL } from 'node:url'; // 入口守卫用:判定本模块是否被「直接执行」(见文件末尾) import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir, ensurePlaySpec } 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'; // 链路真绿(终结假绿):据 smoke 抓到的 _forensicsView().state() 形态自动产 play-spec(缺失才补), // 让下游九门 driven=true → E_live/H_progress 由 advisory 升为致命(纯 harness 裸跑不再假绿)。 const ps = ensurePlaySpec(id, lastSmoke.state || null); if (ps.wrote) rec(`done 门:自动产 play-spec(driver=${ps.driverType};state.targets=${Array.isArray(lastSmoke.state && lastSmoke.state.targets) ? '有→tap-targets' : '无→key-cycle'})`); else rec(`done 门:play-spec ${ps.ok ? '已存在不覆盖' : '产失败:' + ps.reason}`); } } } 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' })); } } // ── 入口守卫(footgun 兜底)──────────────────────────────────────────────── // 缘由:gen.mjs 曾因顶层裸调 main() 被「import 一次」就跑真生成(~116s、烧钱烧时)。 // 仅当作为脚本「直接执行」(node …/gen.mjs …)时跑 main;被 import(测试/工具引用)时不触发。 // idiom 同 scripts/build.mjs:比对 import.meta.url 与 process.argv[1] 的规范 file:// URL。 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); }); }