first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# 单 agent 任务协议
你是独立的任务执行 agent,完成 /blackboard/task.md 中的任务。任务信息通过工作区与黑板目录交换。
## 目录结构
| 路径 | 用途 |
| --- | --- |
| /blackboard/task.md | 任务描述,每轮都要重读,勿修改 |
| /blackboard/workspace/ | 你的工作区(你的 cwd),所有产物写在这里 |
| /blackboard/AGENTS.md | 本协议(副本在你的工作区,勿修改) |
| /blackboard/DONE | 完成标记,存在即表示任务已交付完成,只用 blackboard 工具创建 |
| /blackboard/logs/ | 每轮运行日志(supervisor 维护) |
| /blackboard/sessions/ | 会话 IDsupervisor 维护,供跨轮恢复) |
| /blackboard/result.json | 任务结果(supervisor 结束时生成,勿手动修改) |
## 每轮流程
1. **读取**:读取 /blackboard/task.md,明确任务与提交规则(若为解题任务,通常含 flag 提交 API / token / unique_code)。
2. **评估**:检查工作区已有产物,判断进度与缺口,不重复已完成工作。
3. **执行**:推进任务——执行命令、读写文件、编写产物到 /blackboard/workspace/。
4. **提交**:拿到 flag 的任务,立即按 task.md 中约定的规则提交,并把提交响应与得分写入产物。
5. **判定**:全部工作完成、交付物完整后,用 blackboard 工具 action=done 创建 DONEsummary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
## 红线
- 不直接写 /blackboard/DONE——必须走 blackboard 工具 action=done,保证原子创建。
- 不伪造实验、命令或提交结果;如实记录。
- 不修改 /blackboard/task.md、/blackboard/AGENTS.md 与 /blackboard/result.json。
+58
View File
@@ -0,0 +1,58 @@
# pi 协作镜像(单 agent 版,与 blackboard 协议适配)
#
# 构建(在仓库根目录执行,构建上下文为 pi-coop/ 目录):
# cd /mnt/e/Code/Go/awesomeProject/agent
# DOCKER_BUILDKIT=0 docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/
#
# 基础镜像用 node:22-alpine(自带 node 22 + npm,满足 pi >=22.19 要求)。
# 在其上装系统工具(curl/wget/git/jq/openssl/ripgrep)和 python3 + 常用库,
# 再全局安装 pi coding agent,最后放入协作组件(extension/supervisor/prompts)。
FROM node:22-alpine
# ---- 系统工具 + Python ----
# 先把 apk 源换成清华镜像并用 httpnode:22-alpine 的 Alpine 3.24 用官方 CDN
# 拉 APKINDEX 会报 TLS: unspecified error,改 http 绕过;装上 ca-certificates 后
# TLS 即恢复正常)。之后 apk add 装系统工具 + python3。
RUN sed -i 's|https://dl-cdn.alpinelinux.org|http://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories \
&& apk add --no-cache \
bash ca-certificates git ripgrep \
curl wget jq openssl file \
python3 py3-pip
# ---- Python 常用库 ----
# 用清华 PyPI 镜像加速;--no-cache-dir 避免膨胀;--break-system-packages 放行 Alpine pip 全局安装。
RUN pip3 install --no-cache-dir --break-system-packages \
-i https://pypi.tuna.tsinghua.edu.cn/simple \
requests urllib3 certifi idna charset-normalizer \
cryptography pyOpenSSL paramiko pyjwt \
PyYAML beautifulsoup4 lxml \
python-dotenv click tqdm \
numpy pandas \
httpx aiohttp websockets dnspython \
psutil pillow
# ---- 全局安装 pi coding agent ----
# --ignore-scripts 跳过 lifecycle scriptpi 官方推荐,dist 已预编译)。
# 使用 npmmirror 镜像加速国内下载。
RUN npm install -g --ignore-scripts \
--registry=https://registry.npmmirror.com \
@earendil-works/pi-coding-agent@latest
# ---- 协作组件 ----
# extension 放 /extensionssupervisor 与 prompts 放默认路径。
RUN mkdir -p /extensions /prompts /blackboard
COPY coop.ts /extensions/coop.ts
COPY supervisor.sh /usr/local/bin/supervisor.sh
COPY AGENTS.md /prompts/AGENTS.md
COPY prompts /prompts
RUN chmod +x /usr/local/bin/supervisor.sh
# ---- 非 root 用户 ----
# node:22-alpine 自带 node 用户(uid 1000),直接复用,与 pigo-coop 的 uid 1000 对齐,
# 便于 blackboard 挂载卷权限一致。把协作目录所有权交给 node。
RUN chown -R node:node /extensions /prompts /blackboard
USER node
WORKDIR /blackboard
ENTRYPOINT ["/usr/local/bin/supervisor.sh"]
+127
View File
@@ -0,0 +1,127 @@
# pi 协作运行指南
> 只讲怎么跑。原理、架构见 [Dockerfile](./Dockerfile) 与 [supervisor.sh](./supervisor.sh)。
---
## 1. 前置检查(1 分钟)
```bash
docker --version # 确认 Docker 可用
node --version # 本机构建不需要,仅容器内 pi 需要 node>=22.19
```
与 pigo-coop 不同,pi-coop **不需要**预编译二进制:pi 通过 npm 全局安装在镜像内,
extensioncoop.ts)由 pi 的 jiti 直接加载 .ts,无需预编译。
---
## 2. 构建
在仓库根目录执行(构建上下文为 `pi-coop/` 目录,避免把 `pi/``pigo/` 等大目录发给 daemon):
```bash
docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/
```
构建会:
1. 拉取 `node:24-bookworm-slim` 基础镜像;
2. `npm install -g @earendil-works/pi-coding-agent`pi 本体);
3. 装系统工具(git/ripgrep/python3/jq/openssl…)与 Python 常用库;
4. 拷入 coop extension、supervisor、协议与提示。
> 国内构建慢时,Dockerfile 已用清华 PyPI 镜像加速 pip。npm 若慢可配 `--build-arg` 或宿主 npm 镜像。
---
## 3. 运行
### 3.1 直接运行(不保留任何数据)
```bash
docker run --rm \
-e MODEL=deepseek-chat \
-e BASE_URL=https://api.deepseek.com \
-e API_KEY=<your-key> \
-e PROTOCOL=openai \
-e TASK="请为 XX 编写设计文档并实现原型" \
-e ROUND_MAX=6 \
pi-coop
```
### 3.2 推荐运行(保留黑板产物 + 跨 run 会话)
```bash
mkdir -p blackboard
docker run --rm \
-v "$PWD/blackboard:/blackboard" \
-e MODEL=deepseek-chat \
-e BASE_URL=https://api.deepseek.com \
-e API_KEY=<your-key> \
-e PROTOCOL=openai \
-e TASK="请为 XX 编写设计文档并实现原型" \
-e ROUND_MAX=6 \
pi-coop
```
### 3.3 常用参数速查
| 参数 | 必填 | 默认 | 用途 |
| --- | --- | --- | --- |
| `MODEL` | ✅ | — | 模型名 |
| `BASE_URL` | ✅ | — | OpenAI/Anthropic 兼容 base-url |
| `API_KEY` | ✅ | — | API key |
| `PROTOCOL` | 否 | `openai` | `openai` / `anthropic` |
| `TASK` | ✅(或用 `task.md` | — | 任务描述 |
| `ROUND_MAX` | 否 | `10` | 最大轮次 |
| `TIMEOUT` | 否 | `600` | 单轮超时秒数(`0`=不限) |
> 环境变量与 pigo-coop 完全一致,`run_coop` 工具无需改动即可注入。
---
## 4. 结果怎么看
- **完成**stdout 打印 `[supervisor] status=solved`,退出码 `0`
- **未完成**:达到 `ROUND_MAX``status=unsolved`,退出码 `1`
- **参数错**:退出码 `2`
保留黑板后查看:
```bash
ls blackboard/workspace/ # agent 产物
cat blackboard/DONE # 最终交付总结
cat blackboard/result.json # 结构化结果(status/exit_code/summary/flag/artifacts
cat blackboard/logs/round-1.log # 第 1 轮运行日志(pi --mode json 的 NDJSON 流)
```
`result.json` 字段固定:`status``exit_code``summary``flag``artifacts`
与 pigo-coop 完全一致,外部调度方(如 agent-web 的 run_coop 工具)解析逻辑无需改动。
---
## 5. 与 pigo-coop 的差异
| 维度 | pigo-coop | pi-coop |
| --- | --- | --- |
| agent | Go 编译的 pigo 二进制 | npm 安装的 piTypeScript |
| 模型接入 | CLI `--base-url`/`--protocol` 直传 | coop extension 读环境变量注册 provider |
| blackboard 工具 | pigo 内置(blackboard_tool.go | coop extension 注册(coop.ts |
| 输出格式 | `-o stream-json` | `--mode json`NDJSON,首行 session header |
| session 恢复 | `--resume <id>` | `--session-id <id> --session-dir <dir>` |
| cwd | `-C <dir>` | 进程 cwdsupervisor 先 `cd` |
| 协议(task.md/DONE/result.json | 一致 | 一致 |
| 环境变量(MODEL/BASE_URL/... | 一致 | 一致 |
---
## 6. 常见运行问题
| 报错 / 现象 | 处理 |
| --- | --- |
| 退出 2`必须提供 MODEL / BASE_URL / API_KEY` | 补传环境变量 |
| `DONE already exists` | 正常,上一次运行已留标记(幂等) |
| `MissingSessionCwdError` | 跨轮 resume 时 session 记录的 cwd/blackboard/workspace)不存在;确保挂载未变 |
| extension 加载失败 | 确认镜像内 `/extensions/coop.ts` 存在;`pi -e` 用 jiti 加载 ts,无需预编译 |
| 想从头重跑 | 删除挂载目录里的 `DONE``result.json` 后重新 run |
+262
View File
@@ -0,0 +1,262 @@
/**
* 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 [];
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# 角色:agent(主执行者)
你是 pi 单 agent 任务执行者,独立完成 /blackboard/task.md 中的任务。
## 容器内可用工具
容器内预装了安全工具(nuclei、observer_ward、radare2、gdb、ROPgadget、capstone 等)。
**解题前先读取 `/opt/tools/TOOLS.md` 了解完整工具清单和用法**,选择最合适的工具。
也可用 `which <tool>``pip3 list` 确认工具是否可用。
## 你的职责
- 理解任务、制定方案、完成核心产出。
- 所有产物写入你的工作区(你的 cwd,即 /blackboard/workspace/),按需命名。
- 需要提交 flag 的任务:拿到 flag 后立即按任务中的提交规则提交,并把提交响应与得分记录在产物中。
## 工作方式
- 每轮:读取 /blackboard/task.md 与工作区已有产物 → 推进任务 → 将进展与产物写入工作区。
- 环境变量 ROUND、NAME、BB 由 supervisor 提供;需要记录进展或创建完成标记时用 blackboard 工具(BB 指向黑板根目录)。
- 不要伪造命令结果、文件内容或提交响应;如实记录。
- 全部工作完成、交付物完整时,用 blackboard 工具 action=done 创建完成标记(summary 写最终交付总结,含关键结果、flag、提交响应、产物清单)。
## 注意
- 若本轮未完成,下一轮会用 --session-id 恢复你的会话继续推进,跨轮保持上下文。
- 宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 action=done 标记,summary 写明「未解出」与已尝试内容,让调度方及时切换下一题。
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
# pi 协作 supervisor(单 agent 版)
#
# 在容器内运行一个 pi headless 进程完成 /blackboard/task.md 中的任务。
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
# 任务完成(agent 用 blackboard 工具创建 DONE)后,supervisor 把结果
# 以结构化 JSONresult.json)落盘并打印一行摘要,供外部调度方直接解析。
#
# 与 pigo-coop supervisor 的差异(因 pi CLI 与 pigo 不同):
# 1. cwdpi 用进程 cwd 作工作区(无 -C 参数),supervisor 先 cd 到
# $BB/workspace 再启动 pi。
# 2. 模型接入:pi 用 provider 体系,没有 --base-url/--protocol。
# 由 coop extension-e /extensions/coop.ts)读 BASE_URL/API_KEY/
# PROTOCOL/MODEL 环境变量注册 "coop" providerCLI 用
# --provider coop --model "$MODEL"。
# 3. 输出格式:pi 用 --mode jsonNDJSON),首行是 session header
# {"type":"session","id":"<uuid>",...}supervisor 从首行提取 id
# 供下一轮 --session-id 恢复。
# 4. session 恢复:pi 用 --session-id <id> + --session-dir <dir>
# 替代 pigo 的 --resume <id>。
#
# 环境变量(均由外部调用方传入,与 pigo-coop 完全一致):
# MODEL 模型名,如 deepseek-chat (必填)
# BASE_URL OpenAI/Anthropic 兼容 API base-url (必填)
# API_KEY API key (必填)
# PROTOCOL 协议 openai|anthropic (可选,默认按 model 推断)
# TASK 任务描述 (必填;或挂载 $BB/task.md
# BLACKBOARD 黑板目录 (默认 /blackboard
# PROMPTS 协作 prompt 目录 (默认 /prompts
# EXTENSIONS extension 目录 (默认 /extensions
# ROUND_MAX 最大轮次 (默认 20)
# TIMEOUT 单轮超时秒数,0=不超时 (默认 1800)
# FAIL_MODE agent 失败时:stop=立即退出 | continue=继续下一轮(默认)
set -u
BB="${BLACKBOARD:-/blackboard}"
PROMPTS="${PROMPTS:-/prompts}"
EXTENSIONS="${EXTENSIONS:-/extensions}"
ROUND_MAX="${ROUND_MAX:-20}"
TIMEOUT="${TIMEOUT:-1800}"
FAIL_MODE="${FAIL_MODE:-continue}"
log() { echo "[supervisor] $*"; }
# emit_result 把任务结果以结构化 JSON 写入 $BB/result.json 并打印一行摘要。
# status: solved | unsolved | error | timeout
emit_result() {
local status="$1" summary="$2" code="$3"
local flag="" artifacts_json="[]" summary_json='""' flag_json='""'
# flag 优先从 summary 提取,其次在 workspace 产物中全量检索
if [ -n "$summary" ]; then
flag=$(printf '%s' "$summary" | grep -oE 'flag\{[^}]+\}' | head -1)
fi
if [ -z "$flag" ] && [ -d "$BB/workspace" ]; then
flag=$(grep -rhoE 'flag\{[^}]+\}' "$BB/workspace" 2>/dev/null | head -1)
fi
# 产物清单:workspace 下的全部文件(最多 200 个),经 python3 转义为 JSON 数组
if [ -d "$BB/workspace" ]; then
artifacts_json=$(
cd "$BB/workspace" && find . -type f 2>/dev/null | sed 's|^\./||' | head -200 |
python3 -c 'import json,sys; print(json.dumps([l.rstrip("\n") for l in sys.stdin]))' 2>/dev/null
)
[ -z "$artifacts_json" ] && artifacts_json="[]"
fi
# summary 截断到 8192 字符并经 python3 转义,防止引号/换行破坏 JSON
summary_json=$(printf '%s' "$summary" | head -c 8192 | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
[ -z "$summary_json" ] && summary_json='""'
flag_json=$(printf '%s' "$flag" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
[ -z "$flag_json" ] && flag_json='""'
cat > "$BB/result.json" <<EOF
{
"status": "$status",
"exit_code": $code,
"summary": $summary_json,
"flag": $flag_json,
"artifacts": $artifacts_json
}
EOF
log "===== 协作结束 ====="
log "status=$status exit_code=$code"
[ -n "$flag" ] && log "flag=$flag"
log "结构化结果已写入:$BB/result.json"
}
# ---- 前置校验 ----
if [ -z "${MODEL:-}" ] || [ -z "${BASE_URL:-}" ] || [ -z "${API_KEY:-}" ]; then
echo "错误:必须提供 MODEL / BASE_URL / API_KEY 环境变量" >&2
exit 2
fi
if [ -z "${TASK:-}" ] && [ ! -f "$BB/task.md" ]; then
echo "错误:请通过 TASK 环境变量或挂载 $BB/task.md 提供任务" >&2
exit 2
fi
if [ -f "$BB/DONE" ]; then
log "黑板已有完成标记,如需重新开始请删除 $BB/DONE"
exit 0
fi
# ---- 初始化黑板 ----
mkdir -p "$BB/workspace" "$BB/logs" "$BB/sessions"
if [ -n "${TASK:-}" ]; then
printf '%s\n' "$TASK" > "$BB/task.md"
fi
# 协议注入:agent 的 cwd 是 $BB/workspaceAGENTS.md 注入链从 cwd 起,
# 因此把协议副本放进工作区(勿修改,它是每轮系统提示的一部分)
cp "$PROMPTS/AGENTS.md" "$BB/workspace/AGENTS.md"
log "黑板初始化完成:$BB"
log "任务:$(head -c 200 "$BB/task.md")"
# 从 pi --mode json 输出的首行(session header)提取 session id。
# 首行形如 {"type":"session","version":3,"id":"<uuid>","cwd":...}
extract_session_id() {
local logfile="$1"
head -1 "$logfile" 2>/dev/null | python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
if d.get("type") == "session":
print(d.get("id", ""))
except Exception:
pass
' 2>/dev/null
}
# ---- 单轮运行:一个 pi 进程,--mode json 输出 NDJSON(首行携带 session id----
run_agent() {
local round="$1"
local session_file="$BB/sessions/agent.session"
local task_prompt="轮次 $round 开始。先读取 /blackboard/task.md 中的任务,检查工作区已有产物与 DONE 状态,然后继续推进任务。全部工作完成、交付物完整时,用 blackboard 工具 action=done 创建完成标记(summary 写最终交付总结,含关键结果/flag/提交响应/产物清单)。"
local args=(-p "$task_prompt" -a --mode json \
--provider coop --model "$MODEL" \
--session-dir "$BB/sessions" \
-e "$EXTENSIONS/coop.ts" \
--append-system-prompt "$PROMPTS/agent.md")
if [ -s "$session_file" ]; then
# --session-id:精确匹配,找不到则用该 id 新建(不报错),比 --session 更健壮
args+=(--session-id "$(cat "$session_file")")
fi
export ROUND="$round" NAME="agent" BB="$BB"
local logfile="$BB/logs/round-$round.log"
log "$round 轮开始运行 agent(日志:$logfile"
# pi 用进程 cwd 作为工作区,必须先进入 $BB/workspace
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
(cd "$BB/workspace" && timeout "$TIMEOUT" pi "${args[@]}") > "$logfile" 2>&1
else
(cd "$BB/workspace" && pi "${args[@]}") > "$logfile" 2>&1
fi
local rc=$?
# 从 --mode json 首行(session header)提取 session id 供下一轮恢复
local sid
sid=$(extract_session_id "$logfile")
if [ -n "$sid" ]; then
printf '%s' "$sid" > "$session_file"
fi
return $rc
}
# ---- 主循环:逐轮运行 agent,直到 DONE 或达到轮次上限 ----
for round in $(seq 1 "$ROUND_MAX"); do
[ -f "$BB/DONE" ] && break
log "===== 第 $round 轮开始 ====="
run_agent "$round"
rc=$?
if [ "$rc" -eq 0 ]; then
log "$round 轮完成"
else
log "$round 轮失败(exit=$rc),日志见 $BB/logs/round-$round.log"
if [ "$FAIL_MODE" = "stop" ]; then
# 兜底:agent 虽超时/失败,但黑板已有提交成功证据(workspace 内 *.md 含 correct:true
# → 视为完成正常退出,避免"flag 已提交却因未写 DONE 被强杀(exit 143"。
_done=0
for _f in "$BB"/workspace/*.md "$BB"/messages/*.md; do
[ -f "$_f" ] && grep -q '"correct":true' "$_f" && _done=1 && break
done
if [ "$_done" = "1" ]; then
log "检测到提交成功证据,自动标记完成"
{ echo "Task finished (auto-detected submit success)"; } > "$BB/DONE"
emit_result solved "$(cat "$BB/DONE")" 0
exit 0
fi
if [ "$rc" -eq 124 ]; then
emit_result timeout "" "$rc"
else
emit_result error "agent 退出码 $rc,日志见 logs/round-$round.log" "$rc"
fi
exit "$rc"
fi
fi
done
if [ -f "$BB/DONE" ]; then
log "检测到完成标记,任务结束"
emit_result solved "$(cat "$BB/DONE")" 0
exit 0
fi
log "达到最大轮次 $ROUND_MAX 仍未完成,请检查黑板产物与日志"
emit_result unsolved "" 1
exit 1