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>
201 lines
8.2 KiB
JavaScript
201 lines
8.2 KiB
JavaScript
/**
|
||
* 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); });
|