lili 6e388d9bbb feat(amodel-gen): agentic A-model 生成 harness(ReAct+M3)+ skill + 四层设计
spike 验证通过:M3 经 new-api(openai+tools)agentic 自闭环产出可玩 A-model 游戏。

- harness game-runtime/tools/amodel-gen/(tools/play/m3/prompt/gen.mjs,零三方依赖):
  scaffold(clone _template)→ ReAct(read/write/list/check/build/done;done 门=check+build 绿;
  compaction + loop-detection)→ 循环外 play(headless Chrome boot+截图+交互 = 能跑能玩)。
- 3/3 玩法干净自闭环实证:打地鼠 / 陨石躲避 / 反应点击(13-22 步,175-378K token,play PASS)。
- skill .agents/skills/agentic-amodel-generation.md(链路/配方/8 坑/M3 实证/主流定性)+ README/AGENTS 索引同步。
- 设计 docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md(§0 整链路 current→new + 四层 + §0.8 主流对照;draft 待 §6.8 双评审)。

取代旧 gamedef/factory 单次产线(A-model 生产形态);production 删 gamedef 待双评审(D1=A 无退路)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:50:38 -07:00

201 lines
8.2 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):
* node tools/amodel-gen/gen.mjs --id run1 --brief "挡板接球打砖块,清屏即胜" [--max-steps 40] [--port 4320] [--cdp 9222]
*
* 注:本文件是 harness 侧,允许 Date.now/setTimeout(红线只约束游戏 src)。
*/
'use strict';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { scaffold, readFileTool, writeFileTool, listDirTool, check, build, gameDir } from './tools.mjs';
import { chat, MODEL } from './m3.mjs';
import { TOOLS, buildSystemPrompt } from './prompt.mjs';
import { play } from './play.mjs';
function arg(name, def) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : 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 略过 */ }
}
}
}
}
}
async function main() {
const id = arg('id', 'run1');
const brief = arg('brief', '一个简单的点击得分小游戏');
const maxSteps = Number(arg('max-steps', '40'));
const port = Number(arg('port', '4320'));
const cdpPort = Number(arg('cdp', '9222'));
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} id=${id} brief=「${brief}」maxSteps=${maxSteps}`);
rec(`scaffold amgen-${id}(clone _template)`);
scaffold(id);
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 steps = 0;
let totalTokens = 0;
const callSig = new Map(); // loop-detection:同一 (name,args) 计数
while (!finished && steps < maxSteps) {
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 = build(id);
result = lastBuild.ok ? 'PASS' : 'FAIL:\n' + lastBuild.error;
rec(`build ${lastBuild.ok ? 'PASS' : 'FAIL'}`);
} else if (name === 'done') {
// done 门:复核 check+build 必须都绿,否则拒绝并回喂
const c2 = check(id);
const b2 = c2.ok ? build(id) : { ok: false, error: 'check 未过,先修 check' };
lastCheck = c2; if (c2.ok) lastBuild = b2;
if (c2.ok && b2.ok) {
doneSummary = a.summary || '';
result = 'DONE 已接受';
rec(`done 接受: ${doneSummary}`);
messages.push({ role: 'tool', tool_call_id: c.id, content: result });
finished = true;
break;
}
result = `DONE 被拒:done 前 check+build 必须都绿。check=${c2.ok ? 'PASS' : 'FAIL: ' + c2.errors.join('; ')} build=${b2.ok ? 'PASS' : 'FAIL: ' + (b2.error || '')}`;
rec('done 被拒(check/build 未全绿)');
} 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;
}
}
// 循环外·权威门:play(仅在 agent 合格 done 后跑)
let verdict = null;
if (finished) {
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 {
rec('未合格 done,跳过 play(本 run 未产出可玩游戏)');
}
const summary = {
id, brief, model: MODEL, steps, finished, doneSummary,
check: lastCheck ? { ok: lastCheck.ok, errors: lastCheck.errors } : null,
build: lastBuild ? { ok: lastBuild.ok } : null,
verdict: verdict ? { pass: verdict.pass, notes: verdict.notes, error: verdict.error || null } : 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));
}
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });