feat: 任务书 07,agents 模块与真实模型测试
src/agents 4 个文件 681 行:runSession 按角色开 Pi 子会话、发工具、核对实际加载内容, 提交参数非法 JSON、必填字段交 null、Pi 形状校验不通过都经提交回调记退回。 Pi 依赖固定 0.85.1;新增 npm run test:pi(glm-5.3 真实会话)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
// agents:把一个任务交给 Pi 里的一个模型去做,等它交上来(docs/结构.md 第二部分)。
|
||||
// 程序里只有它直接跟 Pi 打交道;不碰账本,不决定给模型看什么,也不判断交上来的东西好不好。
|
||||
export { runSession } from "./session.ts";
|
||||
export type {
|
||||
KindOf,
|
||||
RoleModel,
|
||||
SessionEnding,
|
||||
SessionOutcome,
|
||||
SessionTask,
|
||||
SubmitAttempt,
|
||||
SubmitReceipt,
|
||||
ThinkingLevel,
|
||||
} from "./types.ts";
|
||||
@@ -0,0 +1,225 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
ModelRuntime,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
type CreateAgentSessionResult,
|
||||
type ToolDefinition,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ModelUsage } from "../vocab/index.ts";
|
||||
import { buildTools, newSubmitState, toolNames, watchSubmitArguments, type Host } from "./tools.ts";
|
||||
import type { SessionEnding, SessionOutcome, SessionTask } from "./types.ts";
|
||||
|
||||
// 跑一次角色会话:新建一个 Pi 子会话,按角色发工具,发第一条消息,等模型干完,交回结局(任务书 07 第一部分)。
|
||||
// 只用 Pi 0.85.1 的公开接口;用量只从公开事件 message_end、compaction_end 取。
|
||||
|
||||
/**
|
||||
* 子会话用的 Pi 设置。不读用户和工作目录里的 settings.json(本机那份改了压缩的保留量、列了一串插件包),
|
||||
* 用到的每一项都写在这里,值与 Pi 0.85.1 自己的默认相同(dist/core/settings-manager.js):
|
||||
* - 重试:模型服务报可重试的错(过载、限流、服务端错误)时由 Pi 自己重试 3 次,等待从 2 秒起每次翻倍
|
||||
* (agent-session.js _prepareRetry);服务要求等待超过 60 秒的直接算失败。HTTP 客户端那一层不再另外重试
|
||||
* (provider.maxRetries 为 0,openai-completions 走 pi-ai utils/provider-retry.js,0 即不重试)。
|
||||
* 每次失败的请求照样交一份"测不到"的用量。
|
||||
* - 压缩上下文:开着,离上下文上限不到 16384 个 token 时把较早的对话压成摘要,保留最近 20000 个 token。
|
||||
* 写摘要也是一次模型调用,用量从 compaction_end 交出。
|
||||
* - HTTP 空闲超时 300 秒。
|
||||
*/
|
||||
const PI_SETTINGS = {
|
||||
retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000, provider: { maxRetries: 0, maxRetryDelayMs: 60_000 } },
|
||||
compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
|
||||
httpIdleTimeoutMs: 300_000,
|
||||
};
|
||||
|
||||
const COST_UNKNOWN = { status: "unmeasured", reason: "Pi 的价格表不是账单,不据此记费用" } as const;
|
||||
|
||||
/**
|
||||
* 一次回复的用量。一次成功的回复至少要读系统提示词,输入不可能是 0;输入、缓存读、缓存写全是 0,
|
||||
* 说明模型服务没报用量(出错、被中止的请求就是这样),四项都记测不到,不记 0(方法.md 第七部分)。
|
||||
*/
|
||||
function toModelUsage(usage: Usage, stopReason: string): ModelUsage {
|
||||
if (usage.input + usage.cacheRead + usage.cacheWrite === 0) {
|
||||
const reason = `模型服务没有报这次回复的用量(这次回复的结束原因:${stopReason})`;
|
||||
const unmeasured = { status: "unmeasured", reason } as const;
|
||||
return { inputTokens: unmeasured, cacheReadTokens: unmeasured, cacheWriteTokens: unmeasured, outputTokens: unmeasured, cost: COST_UNKNOWN };
|
||||
}
|
||||
const measured = (value: number) => ({ status: "measured", value }) as const;
|
||||
return {
|
||||
inputTokens: measured(usage.input),
|
||||
cacheReadTokens: measured(usage.cacheRead),
|
||||
cacheWriteTokens: measured(usage.cacheWrite),
|
||||
outputTokens: measured(usage.output),
|
||||
cost: COST_UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一次会话,交回结局。回调抛出的错误是调用方自己的内部错误:会话立即停下,错误原样抛出,不变成结局。
|
||||
* 模型和凭据由 Pi 的模型服务配置给(~/.pi/agent 下的 models.json、auth.json)。
|
||||
*/
|
||||
export async function runSession(task: SessionTask): Promise<SessionOutcome> {
|
||||
const started = performance.now();
|
||||
const runtime = await ModelRuntime.create();
|
||||
const { provider, model: modelId, thinking } = task.model;
|
||||
const model = runtime.getModel(provider, modelId);
|
||||
const end = (session: AgentSession | null, ending: SessionEnding): SessionOutcome => {
|
||||
const file = session?.sessionFile;
|
||||
const sessionFile = file !== undefined && existsSync(file) ? file : null;
|
||||
return { ...ending, wallSeconds: (performance.now() - started) / 1000, sessionFile };
|
||||
};
|
||||
if (model === undefined) return end(null, { status: "failed", reason: `Pi 的模型服务配置里没有 ${provider}/${modelId},不换用别的模型` });
|
||||
|
||||
// 资源:五个自动发现全关,系统提示词直接给,.pi/APPEND_SYSTEM.md 的发现也关掉;建好后逐项核对。
|
||||
const settingsManager = SettingsManager.inMemory(PI_SETTINGS);
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: task.workDir,
|
||||
agentDir: getAgentDir(),
|
||||
settingsManager,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
systemPromptOverride: () => task.systemPrompt,
|
||||
appendSystemPromptOverride: () => [],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
let session: AgentSession | null = null;
|
||||
const failures: unknown[] = [];
|
||||
const host: Host = {
|
||||
fail: (error) => {
|
||||
failures.push(error);
|
||||
void session?.abort();
|
||||
},
|
||||
guard: (run) => {
|
||||
try {
|
||||
const result = run();
|
||||
return (result instanceof Promise ? result.catch((error) => { host.fail(error); throw error; }) : result) as typeof result;
|
||||
} catch (error) {
|
||||
host.fail(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
const state = newSubmitState();
|
||||
const customTools = buildTools(task, state, host);
|
||||
const expected = toolNames(task.role);
|
||||
const created = await createAgentSession({
|
||||
cwd: task.workDir,
|
||||
model,
|
||||
thinkingLevel: thinking,
|
||||
modelRuntime: runtime,
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.create(task.workDir, task.sessionDir),
|
||||
settingsManager,
|
||||
tools: expected,
|
||||
customTools,
|
||||
});
|
||||
session = created.session;
|
||||
const live = session;
|
||||
|
||||
let stopped = false;
|
||||
const onStop = () => {
|
||||
stopped = true;
|
||||
void live.abort();
|
||||
};
|
||||
const unsubscribe = live.subscribe((event: AgentSessionEvent) => {
|
||||
if (event.type === "message_update") watchSubmitArguments(state, event.assistantMessageEvent);
|
||||
else if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
report(toModelUsage(event.message.usage, event.message.stopReason));
|
||||
} else if (event.type === "compaction_end" && event.result?.usage !== undefined) {
|
||||
report(toModelUsage(event.result.usage, "compaction"));
|
||||
}
|
||||
});
|
||||
// 用量回调在 Pi 的事件监听里调,出错不能抛回 Pi,交给 host.fail 停下会话、事后抛出。
|
||||
function report(usage: ModelUsage): void {
|
||||
try {
|
||||
task.usage(usage);
|
||||
} catch (error) {
|
||||
host.fail(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const problems = verify(task, created, loader, expected, customTools);
|
||||
if (problems.length > 0) return end(live, { status: "failed", reason: `子会话核对不通过,没有发任何请求:${problems.join(";")}` });
|
||||
task.stop.addEventListener("abort", onStop, { once: true });
|
||||
if (task.stop.aborted) return end(live, { status: "stopped" });
|
||||
|
||||
// expandPromptTemplates: false:第一条消息以 "/" 开头时不被当成命令或提示模板展开。
|
||||
// prompt 只在发请求之前的检查里抛错(例如这个 provider 没有凭据),那是配置问题,原样抛给调用方。
|
||||
await live.prompt(task.firstMessage, { expandPromptTemplates: false });
|
||||
await Promise.all(state.pending);
|
||||
if (failures.length > 0) throw failures[0];
|
||||
if (stopped) return end(live, { status: "stopped" });
|
||||
|
||||
const last = live.messages.findLast((m) => m.role === "assistant");
|
||||
if (last?.role === "assistant" && (last.stopReason === "error" || last.stopReason === "aborted")) {
|
||||
return end(live, { status: "failed", reason: `模型服务报错(Pi 已按设置重试):${last.errorMessage ?? last.stopReason}` });
|
||||
}
|
||||
if (state.accepted > 0) return end(live, { status: "submitted" });
|
||||
const reason =
|
||||
last?.role === "assistant" && last.stopReason === "length"
|
||||
? `最后一条回复被输出上限截断(输出 ${last.usage.output} 个 token),没有被收下的提交`
|
||||
: "模型结束了回复,没有一份提交被收下";
|
||||
return end(live, { status: "notSubmitted", reason });
|
||||
} finally {
|
||||
task.stop.removeEventListener("abort", onStop);
|
||||
unsubscribe();
|
||||
live.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建好会话后、发任何请求前,逐项核对子会话里实际装了什么(CLAUDE.md"Pi 与三个角色"最后一条)。
|
||||
* 本机 ~/.pi/agent 下有扩展和技能,开关一旦失效,子会话会悄悄多出东西,没人操作也会发生。
|
||||
*/
|
||||
function verify(
|
||||
task: SessionTask,
|
||||
created: CreateAgentSessionResult,
|
||||
loader: DefaultResourceLoader,
|
||||
expected: string[],
|
||||
customTools: ToolDefinition[],
|
||||
): string[] {
|
||||
const { session } = created;
|
||||
const problems: string[] = [];
|
||||
const want = `${task.model.provider}/${task.model.model}`;
|
||||
if (created.modelFallbackMessage !== undefined) problems.push(`Pi 换了模型:${created.modelFallbackMessage}`);
|
||||
if (`${session.model?.provider}/${session.model?.id}` !== want) {
|
||||
problems.push(`会话模型是 ${session.model?.provider}/${session.model?.id},不是 ${want}`);
|
||||
}
|
||||
// Pi 会按模型能力把档位悄悄往下调(pi-ai clampThinkingLevel)。
|
||||
if (session.thinkingLevel !== task.model.thinking) {
|
||||
problems.push(`思考档位是 ${session.thinkingLevel},不是 ${task.model.thinking}(模型不支持这一档)`);
|
||||
}
|
||||
const extensions = created.extensionsResult.extensions.map((e) => e.path);
|
||||
if (extensions.length > 0) problems.push(`加载了扩展:${extensions.join(", ")}`);
|
||||
const extensionErrors = created.extensionsResult.errors.map((e) => e.path);
|
||||
if (extensionErrors.length > 0) problems.push(`扩展加载报错:${extensionErrors.join(", ")}`);
|
||||
const skills = loader.getSkills().skills.map((s) => s.name);
|
||||
if (skills.length > 0) problems.push(`加载了技能:${skills.join(", ")}`);
|
||||
const prompts = loader.getPrompts().prompts.map((p) => p.name);
|
||||
if (prompts.length > 0) problems.push(`加载了提示模板:${prompts.join(", ")}`);
|
||||
const contextFiles = loader.getAgentsFiles().agentsFiles.map((f) => f.path);
|
||||
if (contextFiles.length > 0) problems.push(`加载了上下文文件:${contextFiles.join(", ")}`);
|
||||
// 给了自定义系统提示词时,Pi 只在末尾加一行工作目录(dist/core/system-prompt.js buildSystemPrompt),
|
||||
// 追加内容、上下文文件、技能清单都会接在它后面;实际发出去的必须正好是这样。
|
||||
if (session.systemPrompt !== `${task.systemPrompt}\nCurrent working directory: ${resolve(task.workDir)}\n`) {
|
||||
problems.push("系统提示词不是给的那一份加工作目录一行,被改动或追加了内容");
|
||||
}
|
||||
const actual = [...session.getActiveToolNames()].sort();
|
||||
if (actual.join(",") !== [...expected].sort().join(",")) {
|
||||
problems.push(`工具表是 [${actual.join(", ")}],应为 [${[...expected].sort().join(", ")}]`);
|
||||
}
|
||||
// 自定义工具(含实验员换过环境的 bash)必须就是给的那个对象,没被同名的别的定义顶掉。
|
||||
for (const tool of customTools) {
|
||||
if (session.getToolDefinition(tool.name) !== tool) problems.push(`工具 ${tool.name} 不是给的实现`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { isAbsolute, sep } from "node:path";
|
||||
import { parseJsonWithRepair, validateToolArguments, type AssistantMessageEvent } from "@earendil-works/pi-ai";
|
||||
import { createBashToolDefinition, defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import { Id, SUBMISSION_KINDS, SUBMISSION_SCHEMAS, type Json, type Role } from "../vocab/index.ts";
|
||||
import type { SessionTask } from "./types.ts";
|
||||
|
||||
// 按角色发的工具(任务书 07 第三部分)。Pi 的 tools 是严格白名单,没列的工具连注册都不注册,
|
||||
// 所以"研究者没有 bash"靠的是名单里不写它;会话建好后 session.ts 再逐个核对一遍。
|
||||
|
||||
/** Pi 自带、只发给实验员的工具。 */
|
||||
const BUILTINS = ["read", "write", "edit", "bash", "grep", "find", "ls"] as const;
|
||||
|
||||
/** 每个角色应当拿到的工具名。会话建好后,实际工具表必须正好是这几个。 */
|
||||
export function toolNames(role: Role): string[] {
|
||||
switch (role) {
|
||||
case "researcher":
|
||||
return ["research_query", "read_source", "research_submit", "write_notes"];
|
||||
case "verifier":
|
||||
return ["research_query", "read_source", "research_submit"];
|
||||
case "experimenter":
|
||||
return ["research_query", "research_submit", "research_check", ...BUILTINS];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调出错时的处理,由 session.ts 给:记下错误、停下会话。
|
||||
* guard 包住一次回调,出错时处理完再把错误抛回 Pi;fail 只处理不抛,用在不能抛的地方。
|
||||
*/
|
||||
export interface Host {
|
||||
guard<T>(run: () => T): T;
|
||||
fail(error: unknown): void;
|
||||
}
|
||||
|
||||
/** 一次会话里提交工具的状态。 */
|
||||
export interface SubmitState {
|
||||
accepted: number;
|
||||
/** 参数原文不是合法 JSON 的提交调用:键是 Pi 解析出的参数对象,值是原文字数。 */
|
||||
malformed: WeakMap<object, number>;
|
||||
/** 正在流式输出的工具调用原文,按在回复里的位置累计,到 toolcall_end 取出删掉。 */
|
||||
streaming: Map<number, string>;
|
||||
/** 形状不合格、已交给提交回调记退回但还没写完的那几次。下一次提交和会话结束前都先等它们写完。 */
|
||||
pending: Promise<void>[];
|
||||
}
|
||||
|
||||
export function newSubmitState(): SubmitState {
|
||||
return { accepted: 0, malformed: new WeakMap(), streaming: new Map(), pending: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数原文不是合法 JSON 时,Pi 0.85.1 不报错,而是退到 partial-json 只取能认出的前半截
|
||||
* (pi-ai utils/json-parse.js parseStreamingJson)。最常见的是正文里有没转义的半角双引号,
|
||||
* 后半段整段丢掉、形状照样合格(参考项目 15-real-1:一份收尾被截成 214 字收下)。
|
||||
* Pi 解析完就删了原文,但流式输出时每一片原文都以 toolcall_delta 发出来;这里按位置拼回,
|
||||
* 到 toolcall_end 用 Pi 自己的严格解析(只修控制字符和非法转义,不丢内容)再解析一次,失败就登记。
|
||||
* Pi 等会话监听处理完事件、整条回复结束后才执行工具,所以登记一定在提交之前。
|
||||
*/
|
||||
export function watchSubmitArguments(state: SubmitState, event: AssistantMessageEvent): void {
|
||||
if (event.type === "toolcall_start") state.streaming.set(event.contentIndex, "");
|
||||
else if (event.type === "toolcall_delta") {
|
||||
state.streaming.set(event.contentIndex, (state.streaming.get(event.contentIndex) ?? "") + event.delta);
|
||||
} else if (event.type === "toolcall_end") {
|
||||
const raw = state.streaming.get(event.contentIndex) ?? "";
|
||||
state.streaming.delete(event.contentIndex);
|
||||
if (event.toolCall.name !== "research_submit") return;
|
||||
try {
|
||||
parseJsonWithRepair(raw);
|
||||
} catch {
|
||||
state.malformed.set(event.toolCall.arguments, [...raw].length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填的文字、数字、真假值字段交了 null 的位置(形如 payload.responses.0.reply)。
|
||||
* Pi 0.85.1 校验前先按形状转换类型(pi-ai validateToolArguments 里的 Value.Convert):文字位置上的 null
|
||||
* 变成字符串 "null",数字变成 0,真假值变成 false,之后的形状检查照单收下(参考项目 14-real-1)。
|
||||
* 所以在转换之前查原始参数。可选字段交 null 时 Pi 当没填处理;本来就收 null 的(二选一里有 null)不算。
|
||||
*/
|
||||
const SCALARS = new Set(["string", "number", "integer", "boolean"]);
|
||||
export function nullFieldPaths(schema: TSchema, value: unknown, path: string, out: string[]): void {
|
||||
const s = schema as { type?: unknown; properties?: Record<string, TSchema>; required?: string[]; items?: TSchema; anyOf?: TSchema[] };
|
||||
if (s.anyOf) {
|
||||
// 二选一:null 由它自己的成员决定收不收;交了对象就按其中的对象成员往下查。
|
||||
const isObject = value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
const member = isObject ? s.anyOf.find((m) => (m as { type?: unknown }).type === "object") : undefined;
|
||||
if (member) nullFieldPaths(member, value, path, out);
|
||||
} else if (typeof s.type === "string" && SCALARS.has(s.type)) {
|
||||
if (value === null) out.push(path);
|
||||
} else if (s.type === "array" && s.items && Array.isArray(value)) {
|
||||
value.forEach((item, i) => nullFieldPaths(s.items!, item, `${path}.${i}`, out));
|
||||
} else if (s.type === "object" && s.properties && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const required = new Set(s.required ?? []);
|
||||
for (const [key, sub] of Object.entries(s.properties)) {
|
||||
const v = (value as Record<string, unknown>)[key];
|
||||
if (v === null && !required.has(key)) continue;
|
||||
nullFieldPaths(sub, v, path === "" ? key : `${path}.${key}`, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 回执里回显结尾的字数。 */
|
||||
const TAIL_CHARS = 40;
|
||||
|
||||
/**
|
||||
* 提交回执回显程序收到了什么(方法.md 第五部分"提交回执"):文字共多少字、最后一个文字字段的结尾。
|
||||
* 截断丢的都是参数的后半段,所以按模型写的顺序取最后一个文字字段,模型对照自己写的结尾就看得出。
|
||||
* 字数按 Unicode 码点数。
|
||||
*/
|
||||
export function receiptEcho(payload: Json): string {
|
||||
const leaves: Array<{ path: string; text: string }> = [];
|
||||
const walk = (value: Json, path: string): void => {
|
||||
if (typeof value === "string") leaves.push({ path, text: value });
|
||||
else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
|
||||
else if (value !== null && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value)) walk(v, path === "" ? k : `${path}.${k}`);
|
||||
}
|
||||
};
|
||||
walk(payload, "");
|
||||
const last = leaves.at(-1);
|
||||
if (last === undefined) return "收到的内容里没有文字字段。";
|
||||
const total = leaves.reduce((n, l) => n + [...l.text].length, 0);
|
||||
const chars = [...last.text];
|
||||
const tail = chars.length <= TAIL_CHARS ? last.text : `……${chars.slice(-TAIL_CHARS).join("")}`;
|
||||
return `收到文字共 ${total} 字;最后一个文字字段 ${last.path} 共 ${chars.length} 字,结尾是:「${tail}」。跟你写的对一下,结尾不对说明没有完整收到。`;
|
||||
}
|
||||
|
||||
/** 读代码单次最多返回多少,与 Pi 自带 read 工具的上限一致(2000 行或 50KB,先到为准)。 */
|
||||
const READ_MAX_LINES = 2000;
|
||||
const READ_MAX_BYTES = 50 * 1024;
|
||||
|
||||
/**
|
||||
* 读一个文件或列一个目录。路径必须是绝对路径,按真实路径(解析 .. 和符号链接之后)判断在不在可读目录里:
|
||||
* 模型正常探索时就会去 ls 上一层,看到别人还没公开的东西。不合格就抛错,Pi 把错误回给模型。
|
||||
*/
|
||||
export function readSource(roots: readonly string[], path: string, offset: number): string {
|
||||
const allowed = roots.map((r) => realpathSync(r));
|
||||
const listing = allowed.map((r) => ` ${r}`).join("\n");
|
||||
if (!isAbsolute(path)) throw new Error(`read_source 只接受绝对路径,收到的是 "${path}"。可读的目录:\n${listing}`);
|
||||
let real: string;
|
||||
try {
|
||||
real = realpathSync(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`路径不存在:${path}`);
|
||||
throw error;
|
||||
}
|
||||
if (!allowed.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : root + sep))) {
|
||||
const via = real === path ? "" : `(真实路径是 ${real})`;
|
||||
throw new Error(`不允许读取 ${path}${via}:它不在可读的目录里。可读的目录:\n${listing}`);
|
||||
}
|
||||
let lines: string[];
|
||||
let what: string;
|
||||
if (statSync(real).isDirectory()) {
|
||||
const entries = readdirSync(real, { withFileTypes: true }).map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort();
|
||||
lines = entries.length > 0 ? entries : ["(空目录)"];
|
||||
what = `目录 ${path},共 ${entries.length} 项`;
|
||||
} else {
|
||||
const text = readFileSync(real, "utf8");
|
||||
lines = text.split("\n");
|
||||
what = `文件 ${path},共 ${lines.length} 行、${Buffer.byteLength(text)} 字节`;
|
||||
}
|
||||
if (offset > lines.length) throw new Error(`offset=${offset} 超出范围:${what},offset 取 1 到 ${lines.length}。`);
|
||||
const shown: string[] = [];
|
||||
let bytes = 0;
|
||||
for (let i = offset - 1; i < lines.length && shown.length < READ_MAX_LINES; i++) {
|
||||
const line = lines[i] as string;
|
||||
bytes += Buffer.byteLength(line) + 1;
|
||||
if (bytes > READ_MAX_BYTES) {
|
||||
// 单独一行就超过上限:只给这一行的开头,并明说。
|
||||
if (shown.length === 0) {
|
||||
const head = Buffer.from(line).subarray(0, READ_MAX_BYTES).toString("utf8");
|
||||
return `${head}\n\n[截断:第 ${offset} 行超过 ${READ_MAX_BYTES} 字节,只显示了它的开头。${what}。]`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
shown.push(line);
|
||||
}
|
||||
const last = offset + shown.length - 1;
|
||||
if (offset === 1 && last === lines.length) return shown.join("\n");
|
||||
const next = last < lines.length ? `要读后面,用 offset=${last + 1} 再调一次。` : "已读到结尾。";
|
||||
return `${shown.join("\n")}\n\n[截断:本次显示第 ${offset}–${last} 行(单次最多 ${READ_MAX_LINES} 行或 ${READ_MAX_BYTES} 字节)。${what}。${next}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实验员 shell 的环境变量:只带这几样,其余一律不给(插件进程的环境里可能有模型密钥)。
|
||||
* Pi 0.85.1 的 bash 工具先用 process.env 拼出环境(PATH 前面加上 Pi 自己的 bin 目录),
|
||||
* 再交给 spawnHook,用它的返回值去起进程(dist/core/tools/bash.js resolveSpawnContext)。
|
||||
*/
|
||||
const PASSED_THROUGH = ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR"] as const;
|
||||
export function shellEnvironment(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const key of PASSED_THROUGH) if (base[key] !== undefined) env[key] = base[key];
|
||||
return env;
|
||||
}
|
||||
|
||||
function text(value: string) {
|
||||
return { content: [{ type: "text" as const, text: value }], details: {} };
|
||||
}
|
||||
|
||||
const OFFSET = Type.Optional(Type.Integer({ minimum: 1, description: "从第几行开始读,默认 1" }));
|
||||
|
||||
/** 这个任务的自定义工具(实验员还有换过环境的 bash)。Pi 自带的其余几个由 Pi 按白名单自己装。 */
|
||||
export function buildTools(task: SessionTask, state: SubmitState, host: Host): ToolDefinition[] {
|
||||
const tools: ToolDefinition[] = [];
|
||||
|
||||
tools.push(defineTool({
|
||||
name: "research_query",
|
||||
label: "查一条记录的全文",
|
||||
description:
|
||||
"本阶段材料已经在第一条消息里。要看材料里某条记录的全文,把它的编号填进 ref。本阶段看不到的编号会返回错误。",
|
||||
parameters: Type.Object({ ref: Type.String({ description: "记录的编号" }) }, { additionalProperties: false }),
|
||||
execute: async (_id, params) => {
|
||||
const found = host.guard(() => task.query(params.ref));
|
||||
if (found === null) {
|
||||
throw new Error(
|
||||
`本阶段看不到编号为 ${params.ref} 的记录。只能查本阶段材料里出现、而且本阶段看得到的记录;` +
|
||||
"材料里只写了编号、没带状态的,可能就是本阶段看不到的。",
|
||||
);
|
||||
}
|
||||
return text(found);
|
||||
},
|
||||
}));
|
||||
|
||||
// 只有研究者的提交能替换(方法.md 第五部分);其余几种的参数里就没有 replaces。
|
||||
const replaceable = SUBMISSION_KINDS[task.kind].role === "researcher";
|
||||
const parameters = Type.Object(
|
||||
{
|
||||
payload: SUBMISSION_SCHEMAS[task.kind],
|
||||
...(replaceable
|
||||
? {
|
||||
replaces: Type.Optional({
|
||||
...Id,
|
||||
description:
|
||||
"替换你本阶段已经交过的一份时,写它的提交编号(收下时回给你的编号),只按最后一版算;" +
|
||||
"第一次交、不替换时整个字段都不要带",
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
/** 形状不合格:交给提交回调记一条退回(不等它写完,下一次提交前再等),再把原因抛给 Pi 回给模型。 */
|
||||
const refuse = (raw: unknown, reason: string): never => {
|
||||
const attempt = { shape: "invalid", raw: raw as Json, reason } as const;
|
||||
state.pending.push(task.submit(attempt).then(() => undefined, host.fail));
|
||||
throw new Error(reason);
|
||||
};
|
||||
tools.push(defineTool({
|
||||
name: "research_submit",
|
||||
label: "正式提交",
|
||||
description:
|
||||
"正式提交本任务的成果。只有调用这个工具才算提交;在回复里说\"我已提交\"不算数。payload 是内容。" +
|
||||
"文字里要加引号时用「」或中文引号“”,不要用半角双引号 \":模型服务在没转义的半角双引号处会把文字截断。" +
|
||||
"程序会检查,不合格会返回原因,改了可以再交。" +
|
||||
(replaceable ? "阶段结束前要改已经收下的一份,用 replaces 写它的提交编号来替换。" : "交上的不能替换。"),
|
||||
parameters,
|
||||
// Pi 0.85.1 在形状校验之前调 prepareArguments(pi-agent-core agent-loop.js prepareToolCall),
|
||||
// 形状不合格时根本不调 execute,提交回调就收不到。所以在这里先查:参数原文不是合法 JSON、
|
||||
// 必填字段交了 null、Pi 自己的形状校验不通过,三种都交给提交回调记退回。
|
||||
prepareArguments: (args) => {
|
||||
const rawChars = state.malformed.get(args as object);
|
||||
if (rawChars !== undefined) {
|
||||
refuse(
|
||||
args,
|
||||
`research_submit 的参数原文(${rawChars} 字)不是合法的 JSON,程序只能认出前一部分,没有收下。` +
|
||||
"最常见的原因是正文里有没转义的半角双引号 \":解析在那里就断了,后面的内容全部丢失。" +
|
||||
"整份重交:正文里的引号改用「」或中文引号“”,或写成 \\\"。",
|
||||
);
|
||||
}
|
||||
const nulls: string[] = [];
|
||||
nullFieldPaths(parameters, args, "", nulls);
|
||||
if (nulls.length > 0) {
|
||||
refuse(
|
||||
args,
|
||||
`research_submit 的这些必填字段交的是 null:${nulls.join("、")}。` +
|
||||
"这些字段要写实际内容(文字写文字,数字写数),不能交 null,文字也不能是空串或只有空白;改好后整份重交。",
|
||||
);
|
||||
}
|
||||
try {
|
||||
validateToolArguments(
|
||||
{ name: "research_submit", description: "", parameters },
|
||||
{ type: "toolCall", id: "", name: "research_submit", arguments: args as Record<string, unknown> },
|
||||
);
|
||||
} catch (error) {
|
||||
refuse(args, (error as Error).message);
|
||||
}
|
||||
return args as never;
|
||||
},
|
||||
// 同一次回复里调两次提交时,Pi 默认并行执行;声明 sequential 后整批按顺序执行,回调不会同时进来。
|
||||
executionMode: "sequential",
|
||||
execute: async (_id, params) => {
|
||||
await Promise.all(state.pending);
|
||||
const { payload, replaces } = params as { payload: Json; replaces?: string };
|
||||
const attempt = { shape: "valid", content: payload as never, replaces: replaces ?? null } as const;
|
||||
const receipt = await host.guard(() => task.submit(attempt));
|
||||
if (!receipt.accepted) throw new Error(`没有收下:${receipt.reason}`);
|
||||
state.accepted++;
|
||||
const how = replaceable ? "阶段公开前要改,用 replaces 写这个编号整份重交。" : "";
|
||||
return {
|
||||
...text(`已收下,提交编号 ${receipt.id}。${receiptEcho(payload)}${how}`),
|
||||
// 实验员、核验员交上就结束:同一批工具结果都带 terminate 时,Pi 不再发下一次请求。
|
||||
terminate: task.role !== "researcher",
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
if (task.role === "researcher" || task.role === "verifier") {
|
||||
const roots = task.readableDirs;
|
||||
tools.push(defineTool({
|
||||
name: "read_source",
|
||||
label: "读代码与资料",
|
||||
description:
|
||||
`读取代码或资料文件,或列出一个目录。path 用绝对路径,必须在这些目录里:${roots.join("、")}。` +
|
||||
`单次最多 ${READ_MAX_LINES} 行或 ${READ_MAX_BYTES} 字节,超出时会告诉你用 offset 从第几行接着读。`,
|
||||
parameters: Type.Object({ path: Type.String({ description: "绝对路径" }), offset: OFFSET }, { additionalProperties: false }),
|
||||
execute: async (_id, params) => text(readSource(roots, params.path, params.offset ?? 1)),
|
||||
}));
|
||||
}
|
||||
|
||||
if (task.role === "researcher") {
|
||||
const writeNote = task.writeNote;
|
||||
tools.push(defineTool({
|
||||
name: "write_notes",
|
||||
label: "写私人笔记",
|
||||
description:
|
||||
"把你的私人笔记整份覆盖写入(不是追加)。要改就把现有笔记连同修改一起完整写回。" +
|
||||
"笔记只有你自己看得到,不是正式提交:写在里面的计划和结论都不算数。",
|
||||
parameters: Type.Object({ content: Type.String({ pattern: "\\S", description: "笔记全文" }) }, { additionalProperties: false }),
|
||||
execute: async (_id, params) => {
|
||||
host.guard(() => writeNote(params.content));
|
||||
return text(`已写入笔记,共 ${[...params.content].length} 字。`);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (task.role === "experimenter") {
|
||||
const lightCheck = task.lightCheck;
|
||||
tools.push(defineTool({
|
||||
name: "research_check",
|
||||
label: "轻量检查",
|
||||
description:
|
||||
"检查你当前工作目录里的代码能不能被正式实验接上(只查语法和接口形状,不执行求解)。" +
|
||||
"想确认代码能不能跑,用这个,不要自己在 shell 里跑完整求解。无参数;次数有上限,用完会报错。",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
execute: async () => text(await host.guard(() => lightCheck())),
|
||||
}));
|
||||
tools.push(createBashToolDefinition(task.workDir, {
|
||||
// 不把 PI_SESSION_FILE 等会话信息放进环境:实验员不需要,会话文件路径也不该给它。
|
||||
exposeSessionEnvironment: false,
|
||||
spawnHook: (context) => ({ ...context, env: shellEnvironment(context.env) }),
|
||||
}) as unknown as ToolDefinition);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
||||
import type { Json, ModelUsage, Role, Submission, SUBMISSION_KINDS, SubmissionKind } from "../vocab/index.ts";
|
||||
|
||||
// agents 对外的约定:flow 交给它什么、拿回什么(任务书 07 第二部分)。
|
||||
// agents 不碰账本、不决定给模型看什么、不判断交上来的东西好不好,这些都经下面的回调交给 flow。
|
||||
|
||||
/** Pi 0.85.1 的思考档位,与 config 的 THINKING_LEVELS 是同一份名单。 */
|
||||
export type ThinkingLevel = NonNullable<CreateAgentSessionOptions["thinkingLevel"]>;
|
||||
|
||||
/** 用哪个模型:provider、模型名与 Pi 的模型服务配置里的写法相同;思考档位必须写明。 */
|
||||
export interface RoleModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinking: ThinkingLevel;
|
||||
}
|
||||
|
||||
/** 某个角色交的提交种类,取自 vocab 的 SUBMISSION_KINDS。 */
|
||||
export type KindOf<R extends Role> = {
|
||||
[K in SubmissionKind]: (typeof SUBMISSION_KINDS)[K]["role"] extends R ? K : never;
|
||||
}[SubmissionKind];
|
||||
|
||||
/**
|
||||
* 模型调一次提交工具,交给提交回调的东西:
|
||||
* - 形状合格:内容和"替换哪一份"(只有研究者的提交能替换,其余永远是 null);
|
||||
* - 形状不合格:Pi 解析出的参数原样、退回原因。agents 已经把这个原因回给模型,
|
||||
* 回调只需记一条退回(方法.md 第三部分:被退回的提交也记),它交回的回执不再回给模型。
|
||||
*/
|
||||
export type SubmitAttempt<K extends SubmissionKind> =
|
||||
| { shape: "valid"; content: Submission<K>; replaces: string | null }
|
||||
| { shape: "invalid"; raw: Json; reason: string };
|
||||
|
||||
/** 提交回调的回答:收下和编号,或退回和原因。agents 原样回给模型。 */
|
||||
export type SubmitReceipt = { accepted: true; id: string } | { accepted: false; reason: string };
|
||||
|
||||
/** 三种角色共有的输入。回调抛出的错误按内部错误处理:会话立即停下,错误原样抛给调用方。 */
|
||||
interface Common<K extends SubmissionKind> {
|
||||
model: RoleModel;
|
||||
/** 角色提示词、第一条消息:materials 拼好的文字。 */
|
||||
systemPrompt: string;
|
||||
firstMessage: string;
|
||||
/** 会话的工作目录、Pi 会话文件存放的目录,都由调用方建好。 */
|
||||
workDir: string;
|
||||
sessionDir: string;
|
||||
/** 这次要交的提交种类,提交工具的参数形状就是 vocab 里它的字段定义。 */
|
||||
kind: K;
|
||||
/** 查一条材料:交回文字,或 null 表示看不到(不存在和看不到故意不区分)。 */
|
||||
query(ref: string): string | null;
|
||||
submit(attempt: SubmitAttempt<K>): Promise<SubmitReceipt>;
|
||||
/** 每次模型回复(包括出错重试的那几次、压缩上下文时写摘要的那次)交一份用量。 */
|
||||
usage(usage: ModelUsage): void;
|
||||
/** 用户中途停下研究时触发。 */
|
||||
stop: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一次会话的输入,按角色区分(任务书 07 第二、三部分)。
|
||||
* 研究者交上一份被收下后会话不结束:可能要交几份,或看了回执发现被截断要替换;实验员、核验员交上就结束。
|
||||
*/
|
||||
export type SessionTask =
|
||||
| (Common<KindOf<"researcher">> & {
|
||||
role: "researcher";
|
||||
/** 读代码工具能读的目录。 */
|
||||
readableDirs: readonly string[];
|
||||
/** 写私人笔记:整份覆盖,笔记是账本记录,由回调写。 */
|
||||
writeNote(body: string): void;
|
||||
})
|
||||
| (Common<KindOf<"verifier">> & { role: "verifier"; readableDirs: readonly string[] })
|
||||
| (Common<KindOf<"experimenter">> & {
|
||||
role: "experimenter";
|
||||
/** 轻量检查:flow 用 tasks 和 runner 接好,交回给模型看的结果文字。 */
|
||||
lightCheck(): Promise<string>;
|
||||
});
|
||||
|
||||
/**
|
||||
* 会话怎么结束的:
|
||||
* - submitted:至少有一份被收下;
|
||||
* - notSubmitted:模型自己停下了,一份都没被收下;
|
||||
* - failed:模型服务报错、子会话核对不通过等;
|
||||
* - stopped:用户停下。
|
||||
*/
|
||||
export type SessionEnding =
|
||||
| { status: "submitted" }
|
||||
| { status: "notSubmitted"; reason: string }
|
||||
| { status: "failed"; reason: string }
|
||||
| { status: "stopped" };
|
||||
|
||||
/** 会话的结局,带墙钟时间(秒)和会话文件路径;Pi 在第一条回复之后才写会话文件,没写出来是 null。 */
|
||||
export type SessionOutcome = SessionEnding & { wallSeconds: number; sessionFile: string | null };
|
||||
Reference in New Issue
Block a user