first commit
This commit is contained in:
@@ -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/ | 会话 ID(supervisor 维护,供跨轮恢复) |
|
||||
| /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 创建 DONE(summary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
|
||||
|
||||
## 红线
|
||||
|
||||
- 不直接写 /blackboard/DONE——必须走 blackboard 工具 action=done,保证原子创建。
|
||||
- 不伪造实验、命令或提交结果;如实记录。
|
||||
- 不修改 /blackboard/task.md、/blackboard/AGENTS.md 与 /blackboard/result.json。
|
||||
@@ -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 源换成清华镜像并用 http(node: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 script(pi 官方推荐,dist 已预编译)。
|
||||
# 使用 npmmirror 镜像加速国内下载。
|
||||
RUN npm install -g --ignore-scripts \
|
||||
--registry=https://registry.npmmirror.com \
|
||||
@earendil-works/pi-coding-agent@latest
|
||||
|
||||
# ---- 协作组件 ----
|
||||
# extension 放 /extensions,supervisor 与 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
@@ -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 全局安装在镜像内,
|
||||
extension(coop.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 安装的 pi(TypeScript) |
|
||||
| 模型接入 | 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>` | 进程 cwd(supervisor 先 `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
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 写明「未解出」与已尝试内容,让调度方及时切换下一题。
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
# pi 协作 supervisor(单 agent 版)
|
||||
#
|
||||
# 在容器内运行一个 pi headless 进程完成 /blackboard/task.md 中的任务。
|
||||
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
|
||||
# 任务完成(agent 用 blackboard 工具创建 DONE)后,supervisor 把结果
|
||||
# 以结构化 JSON(result.json)落盘并打印一行摘要,供外部调度方直接解析。
|
||||
#
|
||||
# 与 pigo-coop supervisor 的差异(因 pi CLI 与 pigo 不同):
|
||||
# 1. cwd:pi 用进程 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" provider;CLI 用
|
||||
# --provider coop --model "$MODEL"。
|
||||
# 3. 输出格式:pi 用 --mode json(NDJSON),首行是 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/workspace,AGENTS.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
|
||||
Reference in New Issue
Block a user