feat: flow 第 1 部分(骨架、研究开始、第零轮)与第零轮检验

按任务书 12 实现 src/flow:从账本现算下一步的纯函数、主循环、恢复入口、派模型任务和跑正式运行两个共用动作、冻结配置、基线、第零轮三个研究者阶段和入口分配,走到第 1 轮时停下。materials 导出 finalEntries、加 researcherId 和 StudyRecord。

审阅采纳两条:研究编号本机已用过时冻结前报错;docker run 没启动时写完结果就停下,不在本进程里无限起新编号。

eval/ 新增第零轮检验:起研究命令行、回放假模型、检验用 Jev 客户端、六问质量评估、16-real-1 回放、杀进程与两份同时跑。tsc 与 depcruise 也检查 eval/,新增 eval 只经 index.ts 用正式代码的规则。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:58:52 -04:00
parent 8491191a2b
commit 75d7cffd27
36 changed files with 2456 additions and 14 deletions
+121
View File
@@ -0,0 +1,121 @@
import { runSession, type KindOf, type SessionEnding, type SubmitAttempt, type SubmitReceipt } from "../agents/index.ts";
import { buildMaterial, callId, queryMaterial, researcherId, type TaskMaterial } from "../materials/index.ts";
import { receive, type Incoming, type KnownOf } from "../submissions/index.ts";
import type { JevJudgment, Json, Submission } from "../vocab/index.ts";
import { checkoutVersion } from "../workspace/index.ts";
import { callPaths, freshDir, workspaceRepo } from "./paths.ts";
import type { Study } from "./study.ts";
// 共用动作一:派一个模型任务(任务书 12 第 2.6 节)。先写"模型调用开始"(材料整份存进去),再开会话,
// 最后写"模型调用结束",前后顺序由这个入口保证(方法.md 第九部分"先登记、再执行、再登记结果")。
// 提交收不收只由 submissions 判;形状不合格的由 agents 报上来,这里直接记退回("给 flow")。
/** 一个研究者任务:哪种提交、谁、哪个阶段、对象;known 是交上来之后才知道的东西(submissions 的 KnownOf)。 */
export type ModelTask<K extends KindOf<"researcher">> = {
kind: K;
actor: string;
stageId: string;
object: string | null;
known(content: Submission<K>): KnownOf<K>;
};
/** 派一次任务,等会话结束,交回调用编号和结局。 */
export async function runModelTask<K extends KindOf<"researcher">>(s: Study, t: ModelTask<K>): Promise<{ call: string; ending: SessionEnding }> {
const { ledger } = s;
const started = new Set(ledger.records().flatMap((r) => (r.kind === "callStarted" ? [r.content.call] : [])));
let attempt = 1;
while (started.has(callId(t.stageId, t.actor, t.kind, t.object, attempt))) attempt++;
const call = callId(t.stageId, t.actor, t.kind, t.object, attempt);
const paths = callPaths(s.dir, call);
const material = buildMaterial({
kind: t.kind,
actor: t.actor,
stageId: t.stageId,
object: t.object,
ledger,
study: s.facts,
prompts: s.prompts,
codeRoot: paths.code,
changes: null,
artifacts: null,
});
const model = researcherModel(s, t.actor);
// 先登记、后建目录:调用编号只在登记之后才算用掉,被杀在两者之间时下次的编号不会撞上旧目录。
ledger.append("callStarted", {
call,
actor: t.actor,
stageId: t.stageId,
model: `${model.provider}/${model.model}(思考档位 ${model.thinking})`,
materials: material as unknown as Json,
});
freshDir(paths.base);
freshDir(paths.code);
for (const r of material.readable) checkoutVersion(workspaceRepo(s.dir), r.version, freshDir(r.dir));
let reply = 0;
const outcome = await runSession({
role: "researcher",
model,
systemPrompt: s.prompts.roles.researcher.text,
firstMessage: material.firstMessage,
workDir: freshDir(paths.work),
sessionDir: freshDir(paths.sessions),
kind: t.kind,
readableDirs: material.readable.map((r) => r.dir),
query: (ref) => queryMaterial(material, ref),
submit: (attempt) => submit(s, t, material, attempt as SubmitAttempt<K>),
usage: (usage) => void ledger.append("callUsage", { call, reply: ++reply, usage }),
writeNote: (body) => void ledger.append("note", { author: t.actor, body }),
stop: s.stop,
});
// 会话文件就在这次调用的 sessions 目录里,不另记。
const { wallSeconds, sessionFile: _sessionFile, ...ending } = outcome;
ledger.append("callEnded", { call, wallSeconds: { status: "measured", value: wallSeconds }, ...ending });
return { call, ending };
}
/** 研究者用的模型:第 i 位研究者(researcherId(i))用配置里第 i 个。 */
function researcherModel(s: Study, actor: string) {
const models = s.frozen.config.models.researchers;
const i = models.findIndex((_, k) => researcherId(k) === actor);
if (i < 0) throw new Error(`${actor} 不是这份研究的研究者`);
return models[i]!;
}
/**
* 一份提交收不收。这个任务只有一份提交(第零轮每人每阶段一个任务、一份提交):
* 回给模型的提交编号就是这一份的位置,不分版本;替换时写它,接在最新一版后面。
* 第一次交以后不写 replaces 又交,或 replaces 写的不是这一份,都退回并说明该怎么写。
*/
async function submit<K extends KindOf<"researcher">>(s: Study, t: ModelTask<K>, material: TaskMaterial, attempt: SubmitAttempt<K>): Promise<SubmitReceipt> {
const { ledger } = s;
const slot = { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, item: null };
const reject = (submitted: Json, reason: string, jev: JevJudgment[] | null): SubmitReceipt => {
ledger.append("rejection", { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, submitted, reason, jev });
return { accepted: false, reason };
};
if (attempt.shape === "invalid") return reject(attempt.raw, attempt.reason, null);
const id = receiptId(t);
const latest = ledger.latestSubmission(slot)?.content.version ?? 0;
const content = attempt.content as unknown as Json;
if (attempt.replaces === null && latest > 0) {
return reject(content, `本阶段你已经交过这一份(提交编号 ${id})。要改就在 replaces 里写 ${id} 整份替换,不要另交`, null);
}
if (attempt.replaces !== null && (attempt.replaces !== id || latest === 0)) {
const which = latest === 0 ? "你本阶段还没有收下的提交,第一次交时不要带 replaces" : `要替换就写 ${id}`;
return reject(content, `replaces 写的 ${attempt.replaces} 不是你本阶段交过的提交:${which}`, null);
}
const verdict = await receive({
kind: t.kind,
content: attempt.content,
checklist: material.checklist,
known: t.known(attempt.content),
jevModel: s.frozen.config.models.jev,
} as Incoming);
if (!verdict.accepted) return reject(content, verdict.reason, verdict.jev);
ledger.append("submission", { ...slot, version: latest + 1, content: attempt.content, jev: verdict.jev } as never);
return { accepted: true, id };
}
/** 回给模型的提交编号:阶段、本人、提交种类、对象。 */
const receiptId = (t: { stageId: string; actor: string; kind: string; object: string | null }): string =>
`${t.stageId}.${t.actor}.${t.kind}.${t.object ?? "none"}`;