Files
BlackBean/pi-coop/coop.ts
T
2026-08-14 23:41:57 +08:00

263 lines
10 KiB
TypeScript
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.
/**
* pi-coop extension:让 pi 适配 blackboard 协作协议。
*
* 做两件事:
* 1. 注册 "coop" provider —— 从环境变量 BASE_URL / API_KEY / PROTOCOL / MODEL
* 构造一个 OpenAI 兼容或 Anthropic 兼容的 provider,使 supervisor 注入的
* 模型配置(与 pigo-coop 完全相同的环境变量)能直接被 pi 使用。
* 2. 注册 "blackboard" 工具 —— 复刻 pigo 的 blackboard 工具(read / post / done),
* 让 agent 通过 action=done 原子创建 /blackboard/DONE 标记,supervisor 据此判定
* 任务完成。AGENTS.md 协议因此无需改动。
*
* 与 pigo blackboard_tool.go 行为对齐:post 用 O_APPEND 单次写(原子),
* done 用 O_CREAT|O_EXCL'wx',保证唯一创建),路径全部做 traversal 校验。
*/
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { openSync, closeSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync } from "node:fs";
import { join, resolve, sep, isAbsolute, clean, basename } from "node:path";
// 单条 post / 单文件 read 上限:放开到 128KB,复杂任务的脚本与日志能整段读写。
const MAX_POST_BYTES = 128 * 1024;
const MAX_READ_BYTES = 128 * 1024;
// provider 协议 → pi api 类型映射。PROTOCOL 由 supervisor 从外部注入
// (取值 openai | anthropic,见 agent api_config.go Provider())。
function protocolToApi(protocol: string): "openai-completions" | "anthropic-messages" {
return protocol === "anthropic" ? "anthropic-messages" : "openai-completions";
}
export default function (pi: ExtensionAPI) {
const baseUrl = process.env.BASE_URL ?? "";
const apiKey = process.env.API_KEY ?? "";
const protocol = process.env.PROTOCOL ?? "openai";
const modelId = process.env.MODEL ?? "";
const bbRoot = process.env.BB ?? "/blackboard";
if (!baseUrl || !apiKey || !modelId) {
// supervisor 已在前置校验里保证这三个变量非空,这里只做兜底:
// 若 provider 注册失败,pi 启动时会报 model 无法解析,比这里静默更好。
throw new Error(`coop extension: BASE_URL / API_KEY / MODEL 环境变量必须同时提供`);
}
// 注册 coop provider。models 数组在工厂里动态构造(id 取自 MODEL),
// 这样 --provider coop --model "$MODEL" 即可命中。
pi.registerProvider("coop", {
baseUrl,
apiKey,
api: protocolToApi(protocol),
models: [
{
id: modelId,
name: modelId,
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
// 完全放开上下文与输出限制,让复杂任务能充分展开思考与多轮工具调用。
contextWindow: 128000,
maxTokens: 65536,
},
],
});
// ---- blackboard 工具 ----
const blackboardTool = defineTool({
name: "blackboard",
label: "Blackboard",
description:
"Read and write the task blackboard used by the coop runner. This is the ONLY tool that may touch shared blackboard files atomically. " +
"Actions: read (no path: global snapshot of task.md, workspace listing, DONE state; with path like \"workspace/exploit.py\": contents of that one file); " +
"post (atomically append a progress note; file must be a bare .md name under messages/, e.g. \"round-1-a.md\"); " +
"done (atomically create the DONE marker with a final delivery summary, only when the deliverable is truly complete; fails if DONE already exists). " +
"The blackboard root, current round and your name are available in the environment as BB, ROUND, NAME.",
parameters: Type.Object({
action: Type.String({ description: "read | post | done" }),
path: Type.Optional(Type.String({ description: 'For read: a path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot.' })),
file: Type.Optional(Type.String({ description: 'For post: bare file name under messages/, must end in .md (e.g. round-1-a.md).' })),
content: Type.Optional(Type.String({ description: "For post: the message body (max 128 KiB)." })),
summary: Type.Optional(Type.String({ description: "For done: final delivery summary written into DONE." })),
}),
async execute(_toolCallId, params) {
const action = String(params.action ?? "");
try {
switch (action) {
case "read":
return read(params.path);
case "post":
return post(params.file, params.content);
case "done":
return done(params.summary);
default:
return err(`blackboard: unknown action "${action}" (want read|post|done)`);
}
} catch (e) {
return err(`blackboard ${action}: ${e instanceof Error ? e.message : String(e)}`);
}
},
});
pi.registerTool(blackboardTool);
// ---- 实现部分 ----
// safePath:把相对路径解析到 blackboard 根内,拒绝绝对路径与 .. 越界。
// 与 pigo blackboard_tool.go safePath 行为一致。
function safePath(p: string): string {
const trimmed = (p ?? "").trim();
if (trimmed === "") throw new Error("empty path");
const c = clean(trimmed);
if (isAbsolute(c)) throw new Error(`path "${p}" must be relative to the blackboard root`);
const rootClean = clean(bbRoot);
const full = join(rootClean, c);
if (full !== rootClean && !full.startsWith(rootClean + sep)) {
throw new Error(`path "${p}" escapes the blackboard root`);
}
return full;
}
function ok(text: string) {
return { content: [{ type: "text" as const, text }] };
}
function err(text: string) {
return { content: [{ type: "text" as const, text }], isError: true };
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "\n...<truncated>" : s;
}
// read:无 path → 全局快照(task.md / messages 列表 / workspace 列表 / DONE 状态);
// 有 path → 单文件内容。
function read(pathParam: unknown) {
const p = pathParam != null ? String(pathParam) : "";
if (p.trim() !== "") {
return readFile(p);
}
const b: string[] = [];
// task.md
try {
const data = readFileSync(join(bbRoot, "task.md"), "utf8");
b.push("# task.md\n" + truncate(data, MAX_READ_BYTES) + "\n");
} catch {
b.push("# task.md\n<missing>\n");
}
// messages/
b.push("\n# messages/ (" + listNames("messages").join(", ") + ")\n");
for (const name of listListing("messages")) b.push(" - " + name + "\n");
// workspace/
b.push("\n# workspace/ (your workspace)\n");
for (const name of listListing("workspace")) b.push(" - " + name + "\n");
// DONE
let doneText = "";
try {
doneText = truncate(readFileSync(join(bbRoot, "DONE"), "utf8"), MAX_READ_BYTES);
} catch {}
b.push("\n# DONE\n");
b.push(doneText ? "EXISTS:\n" + doneText + "\n" : "<not created yet — cooperation is still in progress>\n");
// environment
const env: string[] = [];
for (const k of ["BB", "ROUND", "NAME"]) {
const v = (process.env[k] ?? "").trim();
if (v) env.push(` ${k}=${v}`);
}
if (env.length) b.push("\n# environment\n" + env.join("\n") + "\n");
return ok(b.join(""));
}
function readFile(p: string) {
const full = safePath(p);
let info;
try {
info = statSync(full);
} catch (e) {
return err(`blackboard read: ${p}: ${e instanceof Error ? e.message : String(e)}`);
}
if (info.isDirectory()) return err(`blackboard read: ${p} is a directory; only files can be read`);
const data = readFileSync(full, "utf8");
return ok("# " + p + "\n" + truncate(data, MAX_READ_BYTES));
}
// post:原子追加一条消息到 messages/<file>。file 必须是裸 *.md 名。
function post(fileParam: unknown, contentParam: unknown) {
const name = String(fileParam ?? "").trim();
if (!validMessageName(name)) {
return err('blackboard post: file must be a bare name ending in .md (e.g. "round-1-a.md"), no path separators, no ".."');
}
const content = String(contentParam ?? "").trim();
if (content === "") return err("blackboard post: content must not be empty");
if (Buffer.byteLength(content) > MAX_POST_BYTES) {
return err(`blackboard post: content too large (${Buffer.byteLength(content)} bytes, max ${MAX_POST_BYTES})`);
}
const dir = join(bbRoot, "messages");
mkdirSync(dir, { recursive: true });
// O_APPEND 单次写:POSIX 下并发 post 不会交错字节。
appendFileSync(join(dir, name), content + "\n");
return ok(`Message appended to messages/${name}`);
}
// done:原子创建 DONE 标记。'wx' = O_CREAT|O_EXCL|O_WRONLY,保证唯一创建。
function done(summaryParam: unknown) {
const summary = String(summaryParam ?? "").trim();
if (summary === "") return err("blackboard done: summary must not be empty (include the final delivery summary)");
const header = "Blackboard cooperation DONE\ncreated: " + new Date().toISOString() + "\n\n";
const target = join(bbRoot, "DONE");
let fd: number;
try {
fd = openSync(target, "wx");
} catch (e) {
if (existsSync(target)) {
const existing = readFileSync(target, "utf8");
return err("blackboard done: DONE already exists — cooperation already finished:\n" + truncate(existing, MAX_READ_BYTES));
}
return err("blackboard done: " + (e instanceof Error ? e.message : String(e)));
}
try {
writeFileSync(fd, header + summary);
} finally {
closeSync(fd);
}
return ok("DONE marker created. Cooperation finished.");
}
// 校验 post 文件名:裸 *.md,无分隔符,无 . / ..
function validMessageName(name: string): boolean {
if (name === "" || !name.endsWith(".md")) return false;
if (/[\\/]/.test(name) || name === "." || name === "..") return false;
const base = name.slice(0, -3);
if (base === "" || base.startsWith(".") || base.includes("..")) return false;
return true;
}
function listNames(sub: string): string[] {
try {
return readdirSync(join(bbRoot, sub)).sort();
} catch {
return [];
}
}
function listListing(sub: string): string[] {
try {
return readdirSync(join(bbRoot, sub))
.sort()
.map((name) => {
try {
const info = statSync(join(bbRoot, sub, name));
if (info.isDirectory()) return `${name} (dir)`;
return `${name} (${info.size} bytes, ${info.mtime.toISOString().slice(11, 19)})`;
} catch {
return name;
}
});
} catch {
return [];
}
}
}