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
+52
View File
@@ -0,0 +1,52 @@
# .dockerignore — 构建上下文只保留镜像必需的文件
# 构建上下文 = 项目根目录,Dockerfile = tencent/Dockerfile
# ---- 排除整个目录 ----
.git/
.gocache/
.idea/
.workbuddy/
claude code/
coop_blackboard/
data/
demo/
pi/
pigo/
internal/
# ---- 排除根目录 Go 源码与编译产物 ----
*.go
go.mod
go.sum
main.go
agent.exe
agent-linux-amd64
nul
# ---- 排除 tencent 下的非必需文件 ----
tencent/agent.exe
tencent/agent.tar.gz*
tencent/log.txt
# ---- 排除脚本与文档 ----
*.py
*.md
*.txt
deploy_*.py
log_analyzer.py
test_log_analyzer.py
sort_script.py
response.txt
pigo-coop.tar
# ---- 排除配置与密钥 ----
.env
.env.example
# ---- 排除杂项 ----
*.tar
*.tar.gz
*.log
.tmp_*
__pycache__/
node_modules/
+16
View File
@@ -0,0 +1,16 @@
SILICONFLOW_API_KEY=your_api_key_here
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1/chat/completions
AGENT_MODEL=Qwen/Qwen3-32B
AGENT_PORT=8080
# 访问授权码(默认空=关闭)。Docker 云端部署时设置该值即启用:
# 访问者必须在 Web 界面输入正确授权码才能使用(REST API 与 WebSocket 同样受保护)
AGENT_AUTH_CODE=
AGENT_MAX_CONTEXT_TOKENS=28000
AGENT_KEEP_RECENT_MESSAGES=12
AGENT_MAX_TOOL_RESULT_CHARS=12000
AGENT_MAX_ITERATIONS=12
AGENT_TOOL_TIMEOUT_SECONDS=120
AGENT_REQUEST_TIMEOUT_SECONDS=180
AGENT_STREAM_OUTPUT_TOKENS=4096
AGENT_COMPACTION_TOKENS=1024
AGENT_TEMPERATURE=0.3
+84
View File
@@ -0,0 +1,84 @@
# ============================================================
# 敏感配置(严禁入库)
# ============================================================
# 环境变量文件(含 API Key / 授权码)
.env
.env.*
!.env.example
# ============================================================
# 运行时数据(本地运行 / run_coop 产生)
# ============================================================
# 会话、api-config.json、docker-config.json、sessions.json
data/
# 协作黑板目录(run_coop / pi-coop 本地测试产生)
blackboard/
coop_blackboard/
# ============================================================
# Go 编译产物(控制层 / pigo)
# ============================================================
# go build . 在仓库根产生的二进制(Linux/macOS 无扩展名);
# 锚定 /agent,避免误伤 internal/agent/ 与 pigo/agent/ 源码目录
/agent
agent.exe
agent-linux-*
# pigo 交叉编译输出(pigo/coop/tmp/pigo-linux-amd64
pigo/coop/tmp/
# Go 缓存目录
.gocache/
.gomodcache/
# ============================================================
# Docker 镜像导出与压缩包
# ============================================================
# *.tar 不匹配 agent.tar.gz,两个都要有
*.tar
*.tar.gz
# ============================================================
# 安全工具二进制(体积大,不入库;见 DEPLOY.md 需自行准备)
# ============================================================
tencent/tools/
# ============================================================
# 日志与临时文件
# ============================================================
*.log
log.txt
.tmp_*
*.tmp
nul
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
.venv/
venv/
# ============================================================
# Nodepi-coop 本地调试可能产生)
# ============================================================
node_modules/
dist/
# ============================================================
# IDE / 编辑器
# ============================================================
.idea/
.vscode/
*.swp
# ============================================================
# 操作系统
# ============================================================
.DS_Store
Thumbs.db
Desktop.ini
# ============================================================
# AI 工具记忆目录
# ============================================================
.workbuddy/
+306
View File
@@ -0,0 +1,306 @@
# 部署文档
本文档覆盖 Local Agent 的全部部署方式:本地运行、Docker 协作引擎镜像、Tsecbench 托管模式镜像,以及完整的配置项说明与常见问题排障。
---
## 1. 架构概览
```
┌────────────────────────────────────────────────┐
│ 控制层 agent(Go 二进制,本项目根目录) │
│ - HTTP/WS 服务(Gin),serve web/static 前端 │
│ - 多轮工具调用循环(LLM 驱动) │
│ - run_coop 工具:调起协作容器异步执行子任务 │
└──────────────┬─────────────────────────────────┘
│ docker run / 子进程
┌───────────┼─────────────┬───────────────┐
▼ ▼ ▼ ▼
pi-coop pigo-coop claude-coop supervisor.sh
(pi 引擎) pigo 引擎) Claude Code) (本地子进程模式,
默认 Tsecbench 托管用)
```
| 组件 | 目录 | 说明 |
| --- | --- | --- |
| 控制层 | `main.go` + `internal/` | Agent 核心、HTTP/WS 服务、工具实现 |
| 前端 | `web/static/` | 由控制层直接 serve,无需单独部署 |
| pi 引擎 | `pi-coop/` | **默认协作引擎**,基于 npm 包 `@earendil-works/pi-coding-agent` |
| pigo 引擎 | `pigo/` | Go 自研引擎,需交叉编译二进制后构建镜像 |
| claude 引擎 | `claude code/` | 包装 Claude Code CLI,仅支持 anthropic 协议 |
| 托管镜像 | `tencent/` | Tsecbench 平台一体化镜像(控制层 + worker 本地子进程模式) |
---
## 2. 环境要求
| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| Go | ≥ 1.22(以 go.mod 为准) | 编译控制层 / pigo |
| Docker | 任意现代版本 | 构建协作镜像 / run_coop 工具 |
| Node | ≥ 22.19 | pi 引擎运行时(仅容器内需要,宿主机不用装) |
| WSLWindows | — | 在 Windows 上构建 Linux 镜像时使用 |
| Python 3 + paramiko | 可选 | 仅 `deploy_*.py` 远程部署脚本需要 |
---
## 3. 配置说明
### 3.1 配置优先级
```
环境变量 > .env 文件 > data/api-config.json > 内置默认值
```
- `.env` 放在程序工作目录,启动时自动加载(已存在的环境变量优先)。
- `api-config.json` 由 Web 设置页写入,存于数据目录(`AGENT_DATA_DIR`)。
- 容器部署时通过 `docker run -e` 注入的环境变量具有最高优先级。
### 3.2 LLM 配置(协作引擎共用)
| 环境变量 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- |
| `LLM_API_KEY` | ✅ | — | LLM API 密钥 |
| `LLM_BASE_URL` | ✅ | — | 接口地址。anthropic 协议会自动剥离 `/v1``/v1/messages``/messages` 后缀 |
| `LLM_MODEL` | ✅ | — | 模型名,需与供应商支持的格式完全一致(如 `deepseek-v4-pro` / `deepseek-v4-flash` |
| `LLM_PROVIDER` | ✅ | `openai` | `openai` / `anthropic` |
| `LLM_ENGINE` | 否 | `pi` | 协作引擎:`pi` / `pigo` / `claude` |
| `COOP_MODE` | 否 | `local` | 协作模式(`local` 为本地子进程模式) |
DeepSeek Anthropic 端点示例:
```
LLM_BASE_URL=https://api.deepseek.com/anthropic
LLM_PROVIDER=anthropic
LLM_MODEL=deepseek-v4-flash
```
### 3.3 控制层运行配置
| 环境变量 | 内置默认 | 说明 |
| --- | --- | --- |
| `AGENT_PORT` | `8080` | Web 服务端口 |
| `AGENT_WORKSPACE` | 当前目录 | Agent 可读写的根目录 |
| `AGENT_DATA_DIR` | `<workspace>/data` | 会话与配置存储目录 |
| `AGENT_AUTH_CODE` | 空(关闭) | 访问授权码,云端部署强烈建议设置 |
| `AGENT_MAX_CONTEXT_TOKENS` | `1000000` | 超过该估算值触发历史压缩 |
| `AGENT_KEEP_RECENT_MESSAGES` | `50` | 压缩时保留的最近消息数 |
| `AGENT_MAX_TOOL_RESULT_CHARS` | `100000` | 单次工具结果最大字符数 |
| `AGENT_MAX_ITERATIONS` | `50` | 单轮最多工具调用次数 |
| `AGENT_TOOL_TIMEOUT_SECONDS` | `600` | 单次工具执行超时 |
| `AGENT_REQUEST_TIMEOUT_SECONDS` | `600` | 单次 LLM 请求超时 |
| `AGENT_STREAM_OUTPUT_TOKENS` | `65536` | 流式输出 max_tokens(防截断关键项) |
| `AGENT_COMPACTION_TOKENS` | `16384` | 压缩摘要最大输出 token |
| `AGENT_TEMPERATURE` | `0.3` | 采样温度 |
| `AGENT_DOCKER_SOCKET` | 平台默认 | Docker 连接地址(本地 unix socket / npipe / 远程) |
> 注:`.env.example` 中的数值是保守示例。复杂任务(长思考、大输出)请参照上表默认值,尤其是 `AGENT_STREAM_OUTPUT_TOKENS`——过低会导致 LLM 思考阶段耗尽配额、工具调用被截断。
### 3.4 托管模式开关
| 环境变量 | 说明 |
| --- | --- |
| `BENCHMARK_TOKEN` | 非空时进入 Tsecbench 托管模式:启动即自动开跑评测,无需外部触发;为空则是普通本地/云部署 |
### 3.5 遗留变量
`SILICONFLOW_API_KEY` / `SILICONFLOW_BASE_URL` / `AGENT_MODEL` 为早期硅基流动专用配置,仍向后兼容;新部署统一使用 `LLM_*` 系列。
---
## 4. 部署方式一:本地直接运行
### 4.1 开发运行
```powershell
go run .
```
打开 http://localhost:8080 ,在 Web 设置页填入 API Key(写入 `data/api-config.json`),或提前配置 `.env`
### 4.2 生产运行(Linux + systemd
1. 交叉编译(Windows 上):
```powershell
$env:GOOS="linux"; $env:GOARCH="amd64"; $env:CGO_ENABLED="0"
go build -trimpath -ldflags="-s -w" -o agent-linux-amd64 .
```
2. 上传 `agent-linux-amd64``web/static/` 到服务器 `/opt/agent/`
```
/opt/agent/agent # 二进制(chmod 755
/opt/agent/web/static/ # 前端
/opt/agent/.env # 配置(可选,也可全部用环境变量)
```
3. systemd 单元 `/etc/systemd/system/agent-web.service`
```ini
[Unit]
Description=Local Agent Web
After=network.target
[Service]
WorkingDirectory=/opt/agent
Environment=LLM_API_KEY=sk-xxxx
Environment=LLM_BASE_URL=https://api.deepseek.com/anthropic
Environment=LLM_MODEL=deepseek-v4-flash
Environment=LLM_PROVIDER=anthropic
ExecStart=/opt/agent/agent
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload && systemctl enable --now agent-web
curl -s http://localhost:8080/api/v1/health # 验证
```
---
## 5. 部署方式二:Docker 协作引擎
控制层通过 `run_coop` 工具调起协作容器。**镜像名与容器名前缀是代码内置约定,不可更改**:
| 引擎 | 镜像名 | 容器名前缀 |
| --- | --- | --- |
| pi(默认) | `pi-coop` | `pi-coop-` |
| pigo | `pigo-coop` | `pigo-coop-` |
| claude | `claude-coop` | `claude-coop-` |
### 5.1 构建 pi-coop(默认引擎,推荐)
```bash
# 仓库根目录,构建上下文为 pi-coop/,无需预编译任何二进制
docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/
```
镜像内含 node 22 + npm 全局安装的 pi coding agent + 系统/Python 工具。
运行参数(由 run_coop 自动注入,也可手动测试):
```bash
docker run --rm \
-e MODEL=deepseek-v4-flash \
-e BASE_URL=https://api.deepseek.com/anthropic \
-e API_KEY=<your-key> \
-e PROTOCOL=anthropic \
-e TASK="任务描述" \
-e ROUND_MAX=6 \
pi-coop
```
### 5.2 构建 pigo-coop
需先交叉编译 pigo 二进制(构建上下文为 `pigo/`):
```bash
cd pigo
GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o coop/tmp/pigo-linux-amd64 ./cmd/pigo
docker build -f coop/Dockerfile -t pigo-coop .
```
### 5.3 构建 claude-coop
```bash
docker build -f "claude code/Dockerfile" -t claude-coop "claude code/"
```
仅支持 anthropic 协议;openai 协议请求会被拒绝,请切换引擎或更换端点。
### 5.4 切换引擎
Web 设置页修改协作引擎,或设置 `LLM_ENGINE=pigo` / `LLM_ENGINE=claude`。切换前确保对应镜像已构建。
---
## 6. 部署方式三:Tsecbench 托管模式(tencent/
一体化镜像 = 控制层 agent + pi worker(本地子进程模式)+ 安全工具集,适合上传到 Tsecbench 评测平台。
### 6.1 前置准备
镜像构建依赖以下文件,**均不在 git 仓库内,需自行准备**:
| 文件 | 说明 |
| --- | --- |
| `tencent/agent-linux-amd64` | 控制层交叉编译产物(见 4.2 步骤 1) |
| `tencent/tools/nuclei` | 漏洞扫描器静态二进制 |
| `tencent/tools/observer_ward` | 指纹识别静态二进制 |
| `tencent/tools/chisel` | 内网隧道静态二进制 |
| `tencent/tools/nuclei-templates/` | Nuclei 模板库 |
| `tencent/tools/FingerprintHub-defaultv4/plugins/` | observer_ward 指纹规则库 |
### 6.2 构建与导出
**WSL** 中执行(脚本内 PROJECT_ROOT 按实际路径调整):
```bash
bash tencent/build.sh
# 或自定义镜像名:IMAGE_NAME=myagent bash tencent/build.sh
```
脚本流程:检查二进制 → `docker build`(上下文为项目根,`.dockerignore` 排除无关文件)→ `docker save | gzip` 导出。
产物:`tencent/agent.tar.gz`(约 350MB),直接上传 Tsecbench 平台即可。
### 6.3 本地验证镜像
```bash
docker run --rm -p 8081:8080 \
-e LLM_API_KEY=sk-xxxx \
-e LLM_BASE_URL=https://api.deepseek.com/anthropic \
-e LLM_MODEL=deepseek-v4-flash \
-e LLM_PROVIDER=anthropic \
tsecbench-agent:latest
```
- 打开 http://localhost:8081 验证 Web 界面。
- 平台托管时由运行时注入 `BENCHMARK_TOKEN`,agent 检测到后自动启动评测流程。
- 镜像内不含 `data/` 目录与任何预置配置,全部由环境变量注入。
---
## 7. 验证与排障
### 7.1 健康检查
```bash
curl http://localhost:8080/api/v1/health # 服务存活(始终放行,不受授权码保护)
curl http://localhost:8080/api/v1/llm/config # 查看生效的 LLM 配置(需授权码)
```
### 7.2 常见问题
| 现象 | 原因与解决 |
| --- | --- |
| 侧栏显示「模型未配置」 | 未设置 `LLM_API_KEY`,也未在 Web 设置页填写。配置后重启或刷新 |
| LLM 请求 404 | Base URL 拼接问题。anthropic 协议不要手动加 `/v1`,程序会自动处理后缀;openai 协议填到根地址即可 |
| 工具调用 JSON 被截断、任务失败 | `AGENT_STREAM_OUTPUT_TOKENS` 过低,LLM 思考阶段耗尽 max_tokens。保持默认 65536 |
| run_coop 报找不到镜像 | 对应引擎镜像未构建(见第 5 节),或镜像名不符合约定(必须是 `pi-coop` / `pigo-coop` / `claude-coop` |
| run_coop 容器秒退 | 检查 `MODEL` / `BASE_URL` / `API_KEY` 是否有效;`docker logs <容器名>` 看具体报错 |
| 长命令超时 | `AGENT_TOOL_TIMEOUT_SECONDS` / `AGENT_REQUEST_TIMEOUT_SECONDS` 调大(默认 600s |
| Web 界面 401 | 设置了 `AGENT_AUTH_CODE`,先在弹窗输入授权码 |
| 历史压缩后丢上下文 | 调大 `AGENT_KEEP_RECENT_MESSAGES`(默认 50)与 `AGENT_COMPACTION_TOKENS`(默认 16384 |
### 7.3 安全检查清单
- [ ] `.env` 与 API Key 未提交到 git`.gitignore` 已包含 `.env`
- [ ] 云端公开部署已设置 `AGENT_AUTH_CODE`
- [ ] API Key 泄露后立即在供应商侧吊销并轮换
- [ ] Docker Socket 未暴露给不受信任的网络
- [ ] `AGENT_WORKSPACE` 限定在专用目录,避免指向系统根目录
---
## 8. 端口与产物速查
| 场景 | 端口 | 产物 |
| --- | --- | --- |
| 本地运行 | 8080`AGENT_PORT` | — |
| pi/pigo/claude 协作镜像 | — | `pi-coop` / `pigo-coop` / `claude-coop` 镜像 |
| Tsecbench 托管 | 8080(容器内) | `tencent/agent.tar.gz` |
+40
View File
@@ -0,0 +1,40 @@
# Local Agent
基于 GoGin)的多轮对话 AI AgentChatGPT 风格 Web 界面,可通过真实工具(Shell、文件、Python、Docker、协作容器)自主完成复杂任务。
## 核心特性
- **多轮工具调用**`run_bash` / `read_file` / `write_file` / `run_python` / `list_directory`
- **Docker 操作**`docker_ps` / `docker_exec` / `docker_start` 等,支持远程 Docker Socket
- **协作任务**`run_coop` 调起独立协作容器(pi / pigo / claude 三种引擎),异步完成大型任务
- **流式输出**:SSE 逐 token 输出,超长上下文自动压缩
- **多协议 LLM**OpenAI / Anthropic 兼容接口,Web 界面可切换模型与密钥
## 快速开始
```powershell
go run .
```
打开 http://localhost:8080 ,在 Web 设置中填入 API Key 即可使用。
## 部署
支持三种方式:本地运行、Docker 协作引擎、Tsecbench 托管镜像。
**详细步骤见 [DEPLOY.md](./DEPLOY.md)**(含全部环境变量说明、镜像构建、托管模式与排障)。
## 目录结构
| 路径 | 说明 |
| --- | --- |
| `main.go` / `internal/` | Go 控制层(Agent 核心 + HTTP/WS 服务) |
| `web/static/` | 前端静态资源 |
| `pi-coop/` | pi 协作引擎运行时(默认引擎) |
| `pigo/` | pigo 协作引擎(Go 实现) |
| `claude code/` | Claude Code 协作引擎 |
| `tencent/` | Tsecbench 托管模式镜像构建 |
## 安全提醒
本程序具备命令执行与 Docker 操作能力,云端部署务必设置 `AGENT_AUTH_CODE` 授权码;API Key 通过环境变量注入,切勿提交到仓库。
+37
View File
@@ -0,0 +1,37 @@
# 单 agent 任务协议
你是独立的任务执行 agent,完成 /blackboard/task.md 中的任务。任务信息通过工作区与黑板目录交换。
## 目录结构
| 路径 | 用途 |
| --- | --- |
| /blackboard/task.md | 任务描述,每轮都要重读,勿修改 |
| /blackboard/workspace/ | 你的工作区(你的 cwd),所有产物写在这里 |
| /blackboard/CLAUDE.md | 本协议(副本在你的工作区,勿修改) |
| /blackboard/DONE | 完成标记,存在即表示任务已交付完成,只用 blackboard 工具 done 创建 |
| /blackboard/logs/ | 每轮运行日志(supervisor 维护) |
| /blackboard/sessions/ | 会话 IDsupervisor 维护,供跨轮恢复) |
| /blackboard/result.json | 任务结果(supervisor 结束时生成,勿手动修改) |
## 工具说明
你有三个 blackboard MCP 工具(命名空间 mcp__blackboard__):
- **read**(无参数或 path):读取黑板。无 path 返回全局快照(task.md / messages / workspace / DONE 状态);有 path 返回指定文件内容。
- **post**file, content):原子追加一条进度消息到 messages/<file>。file 必须是裸 .md 名(如 round-1-a.md)。
- **done**summary):原子创建 DONE 标记,summary 写最终交付总结。只能调用一次。
## 每轮流程
1. **读取**:用 blackboard read 读取 /blackboard/task.md(或直接 read 无参看全局快照),明确任务与提交规则。
2. **评估**:检查工作区已有产物,判断进度与缺口,不重复已完成工作。
3. **执行**:推进任务——执行命令、读写文件、编写产物到 /blackboard/workspace/。
4. **提交**:拿到 flag 的任务,立即按 task.md 中约定的规则提交,并把提交响应与得分写入产物。
5. **判定**:全部工作完成、交付物完整后,用 blackboard done 创建 DONEsummary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
## 红线
- 不直接写 /blackboard/DONE——必须走 blackboard done 工具,保证原子创建。
- 不伪造实验、命令或提交结果;如实记录。
- 不修改 /blackboard/task.md、/blackboard/CLAUDE.md 与 /blackboard/result.json。
+75
View File
@@ -0,0 +1,75 @@
# Claude Code 协作镜像(单 agent 版,与 blackboard 协议适配)
#
# 构建(在仓库根目录执行,构建上下文为 claude code/ 目录):
# cd /mnt/e/Code/Go/awesomeProject/agent
# DOCKER_BUILDKIT=0 docker build -f "claude code/Dockerfile" -t claude-coop "claude code/"
#
# 基础镜像用 node:22-alpine(自带 node 22 + npm,满足 Claude Code 的 Node 要求)。
# 在其上装系统工具(curl/wget/git/jq/openssl/ripgrep)和 python3 + 常用库,
# 再全局安装 @anthropic-ai/claude-code 和 MCP SDK,最后放入协作组件
#blackboard MCP server / supervisor / prompts)。
#
# 与 pi-coop 的差异:
# - 用 claude-code 替代 pi-coding-agent
# - blackboard 工具从 pi extension 改为独立 MCP serverstdio 协议)
# - 模型配置走 ANTHROPIC_* 环境变量(不是 pi provider 体系)
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
# ---- 全局安装 Claude Code ----
# 使用 npmmirror 镜像加速国内下载。
# @anthropic-ai/claude-code:提供 claude CLIheadless 模式入口)
RUN npm install -g \
--registry=https://registry.npmmirror.com \
@anthropic-ai/claude-code@latest
# ---- 协作组件 ----
# blackboard MCP server 放 /mcpsupervisor 与 prompts 放默认路径。
RUN mkdir -p /mcp /prompts /blackboard
COPY blackboard-server.mjs /mcp/blackboard-server.mjs
COPY mcp-servers.json /mcp-servers.json
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
# ---- MCP SDK 本地安装 ----
# ESM 模块解析要求依赖在脚本所在目录的 node_modules 内(全局安装的包不会被
# ESM import 解析到,NODE_PATH 也只对 CommonJS require 生效)。因此在 /mcp
# 目录本地安装 @modelcontextprotocol/sdk,让 blackboard-server.mjs 能 import 到。
RUN cd /mcp && npm init -y >/dev/null 2>&1 \
&& npm install \
--registry=https://registry.npmmirror.com \
@modelcontextprotocol/sdk@latest
# ---- 非 root 用户 ----
# node:22-alpine 自带 node 用户(uid 1000),直接复用,与 pi-coop 的 uid 1000 对齐,
# 便于 blackboard 挂载卷权限一致。
# 注意:--dangerously-skip-permissions 不能以 root 运行,必须用非 root 用户。
RUN chown -R node:node /mcp /prompts /blackboard /mcp-servers.json
USER node
WORKDIR /blackboard
ENTRYPOINT ["/usr/local/bin/supervisor.sh"]
+70
View File
@@ -0,0 +1,70 @@
# claude-coop 镜像构建与运行说明
## 构建镜像
在仓库根目录(`agent/`)执行:
```bash
DOCKER_BUILDKIT=0 docker build -f "claude code/Dockerfile" -t claude-coop "claude code/"
```
> 构建上下文是 `claude code/` 目录(注意路径含空格,需引号包裹)。
## 镜像内容
| 组件 | 说明 |
| --- | --- |
| 基础镜像 | `node:22-alpine`node 22 + npm |
| 编码 agent | `@anthropic-ai/claude-code`claude CLIheadless 模式入口) |
| MCP runtime | `@modelcontextprotocol/sdk`blackboard MCP server 依赖) |
| 系统工具 | curl wget git jq openssl ripgrep file |
| Python | python3 + 常用库(requests/cryptography/paramiko/PyYAML/numpy 等) |
| blackboard 工具 | MCP server`/mcp/blackboard-server.mjs`),提供 read/post/done 三个工具 |
## 与 pi-coop 的差异
| 维度 | pi-coop | claude-coop |
| --- | --- | --- |
| 编码 agent | pi@earendil-works/pi-coding-agent | Claude Code@anthropic-ai/claude-code |
| 工具注册 | pi extension-e flag | MCP server--mcp-config |
| 模型配置 | provider 体系(--provider coop --model X | ANTHROPIC_* 环境变量 |
| 协议支持 | openai + anthropic | 仅 anthropicClaude Code 限制) |
| session 提取 | NDJSON 首行 session header | JSON result 的 session_id 字段 |
| session 恢复 | --session-id | --resume |
| 权限 | 无(pi 无权限弹窗) | --dangerously-skip-permissionsheadless 必需,非 root |
## 环境变量契约(与 pi-coop 一致)
| 变量 | 说明 | 必填 |
| --- | --- | --- |
| MODEL | 模型名(如 deepseek-v4-flash | 是 |
| BASE_URL | Anthropic 兼容 API base-url(不带 /v1 | 是 |
| API_KEY | API key | 是 |
| PROTOCOL | 协议(claude-coop 仅支持 anthropic | 否 |
| TASK | 任务描述(或挂载 $BB/task.md | 是 |
| BLACKBOARD | 黑板目录(默认 /blackboard | 否 |
| ROUND_MAX | 最大轮次(默认 10 | 否 |
| TIMEOUT | 单轮超时秒数(默认 600) | 否 |
| FAIL_MODE | agent 失败时:stop(默认)/ continue | 否 |
## 手动测试
```bash
# 测试镜像工具链
docker run --rm --entrypoint sh claude-coop -c 'which claude node python3 curl wget jq; claude --version'
# 测试 blackboard MCP server
docker run --rm --entrypoint sh claude-coop -c 'BB=/tmp/bb mkdir -p /tmp/bb && BB=/tmp/bb node /mcp/blackboard-server.mjs & sleep 1 && kill %1'
```
## 控制层适配
控制层 `tools_coop.go``coopImage` 常量当前为 `"pi-coop"`。如需切换到 claude-coop
```go
const coopImage = "claude-coop"
```
环境变量注入逻辑无需改动(MODEL/BASE_URL/API_KEY/PROTOCOL 契约一致),但需注意:
- Claude Code 仅支持 Anthropic 协议端点,`coopBaseURL` 对 anthropic 不再补 `/v1`(已修复)
- `PROTOCOL=anthropic` 时,`BASE_URL` 应为 `https://api.deepseek.com/anthropic`
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* blackboard MCP server:让 Claude Code 适配 blackboard 协作协议。
*
* 与 pi-coop/coop.ts 的 blackboard 工具行为对齐:
* - read 无 path → 全局快照(task.md / messages / workspace / DONE 状态)
* 有 path → 单文件内容
* - post 原子追加一条消息到 messages/<file>O_APPENDPOSIX 下并发不交错)
* - done 原子创建 DONE 标记(O_CREAT|O_EXCL,保证唯一创建)
*
* 路径安全:所有传入路径都做 traversal 校验,拒绝绝对路径与 .. 越界。
*
* 通信协议:MCP stdioJSON-RPC 2.0)。Claude Code 通过 --mcp-config 加载本 server
* 工具命名空间为 mcp__blackboard__read / mcp__blackboard__post / mcp__blackboard__done。
*
* 环境变量:
* BB 黑板根目录(默认 /blackboard,由 supervisor 注入)
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import {
openSync,
closeSync,
writeFileSync,
appendFileSync,
readFileSync,
readdirSync,
statSync,
mkdirSync,
existsSync,
} from "node:fs";
import { join, resolve, sep, isAbsolute, normalize, basename } from "node:path";
// clean 等价于 normalize:规范化路径(消除 . 和 ..,合并分隔符)。
// pi 的 coop.ts 用了内部 cleanNode 标准 path 模块对应方法是 normalize。
const clean = normalize;
// 单条 post 上限:保证单次原子写,且不撑爆上下文(与 pigo / pi-coop 一致)。
const MAX_POST_BYTES = 32 * 1024;
// 单文件 read 上限。
const MAX_READ_BYTES = 32 * 1024;
const bbRoot = process.env.BB ?? "/blackboard";
// ---- 路径安全:safePath 把相对路径解析到 blackboard 根内,拒绝绝对路径与 .. 越界 ----
function safePath(p) {
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 truncate(s, max) {
return s.length > max ? s.slice(0, max) + "\n...<truncated>" : s;
}
// ---- blackboard 工具实现 ----
// read:无 path → 全局快照;有 path → 单文件内容。
function readAction(pathParam) {
const p = pathParam != null ? String(pathParam) : "";
if (p.trim() !== "") return readFile(p);
const b = [];
// 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"), 4096);
} 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 = [];
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 b.join("");
}
function readFile(p) {
const full = safePath(p);
let info;
try {
info = statSync(full);
} catch (e) {
throw new Error(`blackboard read: ${p}: ${e instanceof Error ? e.message : String(e)}`);
}
if (info.isDirectory()) throw new Error(`blackboard read: ${p} is a directory; only files can be read`);
const data = readFileSync(full, "utf8");
return "# " + p + "\n" + truncate(data, MAX_READ_BYTES);
}
// post:原子追加一条消息到 messages/<file>。file 必须是裸 *.md 名。
function postAction(fileParam, contentParam) {
const name = String(fileParam ?? "").trim();
if (!validMessageName(name)) {
throw new Error('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 === "") throw new Error("blackboard post: content must not be empty");
if (Buffer.byteLength(content) > MAX_POST_BYTES) {
throw new Error(`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 `Message appended to messages/${name}`;
}
// done:原子创建 DONE 标记。'wx' = O_CREAT|O_EXCL|O_WRONLY,保证唯一创建。
function doneAction(summaryParam) {
const summary = String(summaryParam ?? "").trim();
if (summary === "") throw new Error("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;
try {
fd = openSync(target, "wx");
} catch (e) {
if (existsSync(target)) {
const existing = readFileSync(target, "utf8");
throw new Error("blackboard done: DONE already exists — cooperation already finished:\n" + truncate(existing, 4096));
}
throw e;
}
try {
writeFileSync(fd, header + summary);
} finally {
closeSync(fd);
}
return "DONE marker created. Cooperation finished.";
}
// 校验 post 文件名:裸 *.md,无分隔符,无 . / ..
function validMessageName(name) {
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) {
try {
return readdirSync(join(bbRoot, sub)).sort();
} catch {
return [];
}
}
function listListing(sub) {
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 [];
}
}
// ---- MCP server ----
const server = new Server(
{ name: "blackboard", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
// 列出工具:三个 action 拆成独立工具,便于 Claude Code 自动批准白名单匹配。
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "read",
description:
"Read the task blackboard used by the coop runner. " +
"Without a path: returns a global snapshot (task.md, messages listing, workspace listing, DONE state). " +
"With a path like \"workspace/exploit.py\": returns the contents of that one file. " +
"The blackboard root, current round and your name are in the environment as BB, ROUND, NAME.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: 'A path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot.',
},
},
},
},
{
name: "post",
description:
"Atomically append a progress note to the blackboard messages/ directory. " +
"The file must be a bare .md name under messages/ (e.g. round-1-a.md). Max 32 KiB per post.",
inputSchema: {
type: "object",
properties: {
file: {
type: "string",
description: 'Bare file name under messages/, must end in .md (e.g. round-1-a.md).',
},
content: {
type: "string",
description: "The message body (max 32 KiB).",
},
},
required: ["file", "content"],
},
},
{
name: "done",
description:
"Atomically create the DONE marker with a final delivery summary. " +
"Only call this when the deliverable is truly complete (or confirmed unsolvable). Fails if DONE already exists.",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Final delivery summary written into DONE (include key results / flag / submit response / artifact list).",
},
},
required: ["summary"],
},
},
],
}));
// 调用工具:根据 name 分发到对应实现,错误转为文本返回(不抛异常,保持 MCP 协议干净)。
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
let text;
switch (name) {
case "read":
text = readAction(args?.path);
break;
case "post":
text = postAction(args?.file, args?.content);
break;
case "done":
text = doneAction(args?.summary);
break;
default:
return {
content: [{ type: "text", text: `blackboard: unknown tool "${name}" (want read|post|done)` }],
isError: true,
};
}
return { content: [{ type: "text", text }] };
} catch (e) {
return {
content: [{ type: "text", text: `blackboard ${name}: ${e instanceof Error ? e.message : String(e)}` }],
isError: true,
};
}
});
// 启动 stdio transport,由 Claude Code 通过子进程方式拉起。
const transport = new StdioServerTransport();
await server.connect(transport);
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"blackboard": {
"command": "node",
"args": ["/mcp/blackboard-server.mjs"],
"env": {}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
# 角色:agent(主执行者)
你是 Claude Code 单 agent 任务执行者,独立完成 /blackboard/task.md 中的任务。
## 你的职责
- 理解任务、制定方案、完成核心产出。
- 所有产物写入你的工作区(你的 cwd,即 /blackboard/workspace/),按需命名。
- 需要提交 flag 的任务:拿到 flag 后立即按任务中的提交规则提交,并把提交响应与得分记录在产物中。
## 工作方式
- 每轮:用 blackboard read 读取 /blackboard/task.md 与工作区已有产物 → 推进任务 → 将进展与产物写入工作区。
- 环境变量 ROUND、NAME、BB 由 supervisor 提供;需要记录进展时用 blackboard post,创建完成标记时用 blackboard doneBB 指向黑板根目录)。
- 不要伪造命令结果、文件内容或提交响应;如实记录。
- 全部工作完成、交付物完整时,用 blackboard done 创建完成标记(summary 写最终交付总结,含关键结果、flag、提交响应、产物清单)。
## 注意
- 若本轮未完成,下一轮会用 --resume 恢复你的会话继续推进,跨轮保持上下文。
- 宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 blackboard done 标记,summary 写明「未解出」与已尝试内容,让调度方及时切换下一题。
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Claude Code 协作 supervisor(单 agent 版)
#
# 在容器内运行一个 Claude Code headless 进程(claude -p)完成 /blackboard/task.md 中的任务。
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
# 任务完成(agent 用 blackboard MCP 工具创建 DONE)后,supervisor 把结果
# 以结构化 JSONresult.json)落盘并打印一行摘要,供外部调度方直接解析。
#
# 与 pi-coop supervisor 的关键差异(Claude Code CLI 与 pi 不同):
# 1. 模型接入:Claude Code 用 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN /
# ANTHROPIC_MODEL 环境变量(不是 --provider/--model flag)。
# 第三方 Anthropic 兼容端点(如 DeepSeek)需 ANTHROPIC_API_KEY="" 触发
# 回退到 ANTHROPIC_AUTH_TOKEN,且 ANTHROPIC_BASE_URL 不带 /v1SDK 自己追加)。
# 2. 工具注册:Claude Code 用 MCP server--mcp-config),blackboard 工具
# 通过 blackboard-server.mjs 提供,工具命名空间为 mcp__blackboard__*。
# 3. 输出格式:claude -p --output-format json 输出单个 JSON,含 session_id
# 字段,直接用 jq 提取即可(无需像 pi 那样解析 NDJSON 首行)。
# 4. session 恢复:claude -p --resume <id>,跨轮保持上下文。
# 5. 权限:--dangerously-skip-permissions 跳过所有权限弹窗(headless 必需),
# 且必须以非 root 用户运行(node:22-alpine 自带 node 用户,uid 1000)。
#
# 环境变量(均由外部调用方传入,与 pi-coop 完全一致):
# MODEL 模型名,如 deepseek-v4-flash (必填)
# BASE_URL Anthropic 兼容 API base-url (必填)
# API_KEY API key(注入为 ANTHROPIC_AUTH_TOKEN (必填)
# PROTOCOL 协议(仅 anthropic 受 Claude Code 支持) (可选)
# TASK 任务描述 (必填;或挂载 $BB/task.md
# BLACKBOARD 黑板目录 (默认 /blackboard
# ROUND_MAX 最大轮次 (默认 10)
# TIMEOUT 单轮超时秒数,0=不超时 (默认 600)
# FAIL_MODE agent 失败时:stop=立即退出(默认)| continue=继续下一轮
set -u
BB="${BLACKBOARD:-/blackboard}"
PROMPTS="${PROMPTS:-/prompts}"
MCP_CONFIG="${MCP_CONFIG:-/mcp-servers.json}"
ROUND_MAX="${ROUND_MAX:-10}"
TIMEOUT="${TIMEOUT:-600}"
FAIL_MODE="${FAIL_MODE:-stop}"
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 下的全部文件(最多 50 个),经 python3 转义为 JSON 数组
if [ -d "$BB/workspace" ]; then
artifacts_json=$(
cd "$BB/workspace" && find . -type f 2>/dev/null | sed 's|^\./||' | head -50 |
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 截断到 4000 字符并经 python3 转义,防止引号/换行破坏 JSON
summary_json=$(printf '%s' "$summary" | head -c 4000 | 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 起,
# Claude Code 会自动加载 cwd 下的 CLAUDE.mdAGENTS.md 兼容),因此把协议
# 副本放进工作区(勿修改,它是每轮系统提示的一部分)
cp "$PROMPTS/AGENTS.md" "$BB/workspace/CLAUDE.md"
cp "$PROMPTS/AGENTS.md" "$BB/workspace/AGENTS.md"
log "黑板初始化完成:$BB"
log "任务:$(head -c 200 "$BB/task.md")"
# ---- 导出 Claude Code 模型配置环境变量 ----
# 关键:ANTHROPIC_API_KEY 必须设为空字符串(而非 unset),Claude Code 才会
# 回退到 ANTHROPIC_AUTH_TOKEN。BASE_URL 不带 /v1SDK 自动追加 /v1/messages)。
export ANTHROPIC_BASE_URL="$BASE_URL"
export ANTHROPIC_AUTH_TOKEN="$API_KEY"
export ANTHROPIC_API_KEY=""
export ANTHROPIC_MODEL="$MODEL"
# 把 opus/sonnet/haiku 三个别名都映射到目标模型,避免别名解析失败。
export ANTHROPIC_DEFAULT_OPUS_MODEL="$MODEL"
export ANTHROPIC_DEFAULT_SONNET_MODEL="$MODEL"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$MODEL"
# 禁用非必要流量(遥测/登录检测),第三方端点必需。
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# ---- 单轮运行:claude -p --output-format json,从 JSON 提取 session_id ----
run_agent() {
local round="$1"
local session_file="$BB/sessions/agent.session"
local task_prompt="轮次 $round 开始。先读取 /blackboard/task.md 中的任务,检查工作区已有产物与 DONE 状态,然后继续推进任务。全部工作完成、交付物完整时,用 blackboard 工具 done 创建完成标记(summary 写最终交付总结,含关键结果/flag/提交响应/产物清单)。"
local args=(-p "$task_prompt"
--output-format json
--mcp-config "$MCP_CONFIG"
--allowedTools "mcp__blackboard__*,Read,Write,Edit,Bash"
--append-system-prompt-file "$PROMPTS/agent.md"
--dangerously-skip-permissions)
if [ -s "$session_file" ]; then
args+=(--resume "$(cat "$session_file")")
fi
export ROUND="$round" NAME="agent" BB="$BB"
local logfile="$BB/logs/round-$round.log"
log "$round 轮开始运行 agent(日志:$logfile"
# Claude Code 用 --cwd / 进程 cwd 作工作区,先进入 $BB/workspace
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
(cd "$BB/workspace" && timeout "$TIMEOUT" claude "${args[@]}") > "$logfile" 2>&1
else
(cd "$BB/workspace" && claude "${args[@]}") > "$logfile" 2>&1
fi
local rc=$?
# 从 --output-format json 的结果提取 session_id 供下一轮 --resume
# JSON 形如 {"type":"result","session_id":"<uuid>",...}
local sid
sid=$(python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
if d.get("type") == "result":
print(d.get("session_id", ""))
except Exception:
pass
' < "$logfile" 2>/dev/null)
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
+64
View File
@@ -0,0 +1,64 @@
module agent
go 1.25.0
require (
github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.10.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.8.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
+178
View File
@@ -0,0 +1,178 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+684
View File
@@ -0,0 +1,684 @@
package agent
import (
"context"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"unicode"
)
type Agent struct {
cfg Config
llm *LLMClient
store *SessionStore
tools *Toolset
docker *DockerConfigStore
apiCfg *APIConfigStore
coop *CoopManager
locks map[string]*sync.Mutex
locksMu sync.Mutex
liveMu sync.Mutex
live map[string]*liveReg
}
// liveReg 记录某会话当前"在线"的 SSE 连接,用于推送后台任务完成通知。
type liveReg struct {
emit func(Event)
}
type Event struct {
Type string `json:"type"`
Data any `json:"data,omitempty"`
}
func NewAgent(cfg Config, store *SessionStore, dockerCfg *DockerConfigStore, apiCfg *APIConfigStore) *Agent {
agent := &Agent{
cfg: cfg,
llm: NewLLMClient(cfg, apiCfg),
store: store,
docker: dockerCfg,
apiCfg: apiCfg,
locks: make(map[string]*sync.Mutex),
live: make(map[string]*liveReg),
}
agent.tools = NewToolset(cfg, dockerCfg, apiCfg)
agent.coop = NewCoopManager()
agent.tools.coop = agent.coop
agent.coop.SetNotify(agent.notifyCoopDone)
return agent
}
func (a *Agent) Run(ctx context.Context, sessionID, userContent string, emit func(Event)) error {
if strings.TrimSpace(userContent) == "" {
emit(Event{Type: "error", Data: map[string]any{"message": "消息内容不能为空"}})
return errors.New("消息内容不能为空")
}
lock := a.sessionLock(sessionID)
lock.Lock()
defer lock.Unlock()
session, ok := a.store.Get(sessionID)
if !ok {
emit(Event{Type: "error", Data: map[string]any{"message": "会话不存在"}})
return errors.New("会话不存在")
}
// 在私有副本上操作,避免修改持久化对象与其他会话的序列化产生数据竞争
session = cloneSession(session)
if session.Title == "" || session.Title == "新对话" {
session.Title = firstRunes(userContent, 30)
}
session.Messages = append(session.Messages, Message{
Role: "user",
Content: stringPointer(userContent),
})
a.store.Save(session)
emit(Event{Type: "meta", Data: map[string]any{
"session_id": session.ID,
"title": session.Title,
}})
history := session.Messages
compressed := false
iterations := 0
emptyRetries := 0
for {
iterations++
systemMessage := Message{
Role: "system",
Content: stringPointer(a.systemPrompt(sessionID)),
}
apiMessages := append([]Message{systemMessage}, history...)
if estimateMessages(apiMessages) > a.cfg.MaxContextTokens {
if summary, err := a.compressHistory(ctx, sessionID, history); err != nil {
history = truncateHistory(history, a.cfg.KeepRecentMessages)
compressed = true
emit(Event{Type: "notice", Data: map[string]any{
"text": "上下文较长,自动压缩失败,已降级为截断早期对话: " + err.Error(),
}})
} else {
history = summary
compressed = true
emit(Event{Type: "notice", Data: map[string]any{
"text": "上下文较长,已自动压缩早期对话(分块摘要),关键信息会保留。",
}})
}
}
apiMessages = append([]Message{systemMessage}, history...)
assistant, err := a.streamAssistantReply(ctx, apiMessages, a.tools.Definitions(), emit)
if err != nil {
// 模型返回空内容:强制压缩上下文后重试,而非直接中断会话。
// 长会话(如跑分循环累积大量工具输出)时模型容易因输入过大而返回空,
// 压缩后通常可恢复;多次仍空则优雅退出,保留会话状态供用户继续。
if errors.Is(err, errEmptyResponse) {
emptyRetries++
if emptyRetries <= 2 {
emit(Event{Type: "notice", Data: map[string]any{
"text": "模型返回空内容,正在压缩上下文后重试…",
}})
if summary, serr := a.compressHistory(ctx, sessionID, history); serr == nil {
history = summary
} else {
history = truncateHistory(history, a.cfg.KeepRecentMessages)
}
session.Messages = history
a.store.Save(session)
continue
}
session.Messages = history
a.store.Save(session)
emit(Event{Type: "notice", Data: map[string]any{
"text": "模型多次返回空内容,本轮已停止。你可以继续发消息让我接着处理(程序不会退出)。",
}})
emit(Event{Type: "done", Data: map[string]any{
"compressed": compressed,
"iterations": iterations,
}})
return nil
}
session.Messages = history
a.store.Save(session)
if errors.Is(err, context.Canceled) {
// 用户主动停止(前端 stop 按钮 / Esc):是正常中断,不按错误展示
emit(Event{Type: "notice", Data: map[string]any{
"text": "已停止本次生成,已完成的对话进度已保存,可继续发送新消息。",
}})
} else {
emit(Event{Type: "error", Data: map[string]any{"message": err.Error()}})
}
return err
}
emptyRetries = 0
history = append(history, assistant)
if len(assistant.ToolCalls) == 0 {
break
}
// 本轮是否成功下发了协作任务:分发完成后本轮直接收尾,
// 避免控制层在 run_coop 之后继续空转(sleep 等待 / 反复轮询),
// coop 完成后会以 user 身份推送完成通知并启动新一轮处理。
dispatched := false
for _, call := range assistant.ToolCalls {
emit(Event{Type: "tool", Data: map[string]any{
"call_id": call.ID,
"name": call.Function.Name,
"input": call.Function.Arguments,
}})
result := a.tools.Execute(ctx, sessionID, call.Function.Name, call.Function.Arguments)
result.Output = truncateRunes(result.Output, a.cfg.MaxToolResultChars)
history = append(history, Message{
Role: "tool",
ToolCallID: call.ID,
Content: stringPointer(result.Output),
})
if call.Function.Name == "run_coop" && result.Success {
dispatched = true
}
emit(Event{Type: "tool_result", Data: map[string]any{
"call_id": call.ID,
"name": call.Function.Name,
"output": result.Output,
"success": result.Success,
}})
}
// 每轮迭代后落盘:长任务(如跑分循环)期间用户刷新页面也能看到
// 已完成轮次的消息与工具结果,而不是只能等整轮 Run 结束。
session.Messages = history
a.store.Save(session)
if dispatched {
emit(Event{Type: "notice", Data: map[string]any{
"text": "协作任务已全部下发,本轮收尾结束。任务完成后会自动以用户身份推送完成通知并启动新一轮处理,无需在等待上消耗本轮回合。",
}})
break
}
if iterations >= a.cfg.MaxIterations {
emit(Event{Type: "notice", Data: map[string]any{
"text": fmt.Sprintf("已达到 %d 次工具调用上限,本轮先基于已有结果收尾。你可以继续发消息让我接着处理(程序不会退出)。", a.cfg.MaxIterations),
}})
// 达到上限并不中断程序:用一次不带工具的收尾回答结束本回合,
// 避免在工具链中途戛然而止、用户得不到任何总结。
closingSystem := Message{
Role: "system",
Content: stringPointer(a.systemPrompt(sessionID) +
"\n\n注意:本轮已达到工具调用次数上限。现在必须直接输出最终回答:总结目前已确定的结果、未完成事项与建议的下一步,不要再调用任何工具。"),
}
if closing, err := a.streamAssistantReply(ctx, append([]Message{closingSystem}, history...), nil, emit); err == nil {
history = append(history, closing)
}
break
}
}
session.Messages = history
a.store.Save(session)
emit(Event{Type: "done", Data: map[string]any{
"compressed": compressed,
"iterations": iterations,
}})
return nil
}
// errEmptyResponse 表示模型流式返回既无文本也无工具调用。
// 区别于普通错误:调用方应压缩上下文后重试,而非直接中断会话。
var errEmptyResponse = errors.New("模型没有返回任何内容")
func (a *Agent) streamAssistantReply(ctx context.Context, messages []Message, tools []ToolDefinition, emit func(Event)) (Message, error) {
const maxRetries = 2
for attempt := 0; attempt <= maxRetries; attempt++ {
ch, err := a.llm.ChatStream(ctx, messages, tools)
if err != nil {
return Message{}, err
}
var content strings.Builder
var calls []ToolCall
for chunk := range ch {
if chunk.Error != nil {
return Message{}, chunk.Error
}
if chunk.Content != "" {
content.WriteString(chunk.Content)
emit(Event{Type: "message", Data: map[string]any{"delta": chunk.Content}})
}
for _, delta := range chunk.ToolCalls {
calls = mergeToolCall(calls, delta)
}
}
for i := range calls {
if calls[i].ID == "" {
calls[i].ID = "call_" + newID()
}
}
assistant := Message{
Role: "assistant",
Content: stringPointer(content.String()),
}
if len(calls) > 0 {
assistant.ToolCalls = calls
if content.Len() == 0 {
assistant.Content = nil
}
}
if content.Len() == 0 && len(calls) == 0 {
if attempt < maxRetries {
emit(Event{Type: "notice", Data: map[string]any{
"text": fmt.Sprintf("模型返回空内容,正在重试(第 %d/%d 次)…", attempt+1, maxRetries),
}})
continue
}
return Message{}, errEmptyResponse
}
return assistant, nil
}
return Message{}, errEmptyResponse
}
// compressHistory 压缩 history 中除最近 keep 条之外的全部早期消息。
// 早期消息可能远超单次模型输入窗口,因此按压缩预算分块、逐块摘要后再合并;
// LLM 压缩失败时用 fallbackSummary 保留最近消息要点,避免直接整体截断。
// 合并后若仍超窗口,对合并结果再压缩一层(递归收敛),极端情况才截断。
func (a *Agent) compressHistory(ctx context.Context, sessionID string, history []Message) ([]Message, error) {
keep := a.cfg.KeepRecentMessages
if keep <= 0 {
keep = 12
}
if len(history) <= keep {
return history, nil
}
old := history[:len(history)-keep]
recent := history[len(history)-keep:]
summary, err := a.summarizeBlocks(ctx, old)
if err != nil {
// LLM 压缩失败:降级为文本要点摘要(纯文本,不产生孤儿 tool 消息)
summary = fallbackSummary(old)
}
merged := append([]Message{{
Role: "system",
Content: stringPointer("[早期对话摘要]\n" + summary),
}}, recent...)
// 压缩后校验:摘要 + 最近消息仍超窗口时,对合并结果再压缩一层(递归收敛)
if estimateMessages(merged) > a.cfg.MaxContextTokens {
if inner, inerr := a.compressHistory(ctx, sessionID, merged); inerr == nil {
return inner, nil
}
merged = truncateHistory(merged, keep)
}
return merged, nil
}
// summarizeBlocks 把早期消息按单次压缩输入预算切成若干块,逐块交给 LLM 摘要,
// 最后把各块摘要按顺序合并返回。任一块失败即返回错误(由调用方降级)。
func (a *Agent) summarizeBlocks(ctx context.Context, history []Message) (string, error) {
budget := a.compactionInputBudget()
var summaries []string
block := make([]Message, 0, 32)
blockTokens := 0
flush := func() error {
if len(block) == 0 {
return nil
}
sum, cerr := a.llm.Compress(ctx, block)
block = block[:0]
blockTokens = 0
if cerr != nil {
return cerr
}
summaries = append(summaries, sum)
return nil
}
for _, msg := range history {
tokens := estimateMessageTokens(msg)
if len(block) > 0 && blockTokens+tokens > budget {
if err := flush(); err != nil {
return "", err
}
}
block = append(block, msg)
blockTokens += tokens
}
if err := flush(); err != nil {
return "", err
}
return strings.Join(summaries, "\n\n"), nil
}
// compactionInputBudget 返回单次 LLM 压缩请求的输入 token 预算:
// 总窗口减去压缩输出(CompactionTokens)与常规输出(StreamOutputTokens)的预留,
// 再留出安全余量,避免把输入塞满窗口导致请求被拒。
func (a *Agent) compactionInputBudget() int {
window := a.cfg.MaxContextTokens
output := a.cfg.CompactionTokens + a.cfg.StreamOutputTokens
budget := window - output - window/8
if budget < 4096 {
budget = 4096
}
return budget
}
// fallbackSummary 当 LLM 压缩失败时的兜底摘要:按时间顺序保留早期消息中
// 最近若干条的用户目标/助手结论/工具结果要点,输出纯文本(不产生孤立的
// tool 消息),保证后续请求协议合法、信息尽量不丢。
func fallbackSummary(history []Message) string {
const maxLines = 10
start := len(history) - maxLines
if start < 0 {
start = 0
}
labels := map[string]string{
"user": "用户",
"assistant": "助手",
"tool": "工具结果",
}
var lines []string
for i := start; i < len(history); i++ {
msg := history[i]
label, ok := labels[msg.Role]
if !ok {
label = msg.Role
}
text := ""
if msg.Content != nil {
text = strings.TrimSpace(*msg.Content)
}
if text == "" {
if len(msg.ToolCalls) > 0 {
text = "调用了 " + msg.ToolCalls[0].Function.Name + " 等工具"
} else {
continue
}
}
if countRunes(text) > 200 {
text = truncateRunes(text, 200)
}
lines = append(lines, fmt.Sprintf("[%s] %s", label, text))
}
if len(lines) == 0 {
return "(早期对话内容较多,摘要失败后仅保留最近消息。)"
}
return "(早期对话压缩失败,以下为最近消息要点,更早内容已截断)\n" + strings.Join(lines, "\n")
}
// truncateHistory 在模型压缩失败时兜底:仅保留最近消息,并丢弃开头孤立的
// tool 结果消息,避免出现没有对应 assistant 调用声明的 tool 消息导致协议错乱。
func truncateHistory(history []Message, keep int) []Message {
if len(history) <= keep {
return history
}
kept := append([]Message(nil), history[len(history)-keep:]...)
for len(kept) > 0 && kept[0].Role == "tool" {
kept = kept[1:]
}
return kept
}
func (a *Agent) systemPrompt(sessionID string) string {
return fmt.Sprintf(`你是 BlackBean,运行在用户本机上的 AI Agent,正在协助用户完成真实任务。
当前日期:%s
本会话工作目录(与其他会话隔离,相对路径与命令默认在此执行):%s
项目根目录(可读取,含 agent 自身文件、协作黑板产物等共享内容):%s
工作方式:
1. 先理解用户目标,必要时先用工具查看文件、目录和运行结果,再给出准确答案。
2. 你可以运行 Bash、读写文件、运行 Python。所有文件和命令默认都在本会话工作目录内进行,不要访问项目根目录之外的文件。
3. 使用工具时必须根据真实输出继续推理,绝不能编造命令结果、文件内容或错误信息。
4. 回答使用简洁的中文,重要代码、命令和路径用 Markdown 代码块呈现。
5. 需要执行可能造成不可逆影响的操作前,先说明风险和影响,再谨慎执行。
6. 如果一次工具调用没有解决问题,可以多次调用工具排查;不要在没有依据时下结论。
【任务调度规则】
- 解题类任务(CTF 题目、渗透测试、漏洞挖掘、Web 攻防、逆向、密码学、取证分析等)一律交由 coop 执行,你不负责具体解题,只做调度。
- 你的调度职责:
1. 从用户表述中提取足够信息:目标地址/文件、约束条件、平台提交规则(如 TSec Benchmark 的 submit API、BENCHMARK_TOKEN、unique_code 等);
2. 调用 run_coop 工具,把任务描述与提交规则写清楚,交给 pi 协作单 Agent 执行;
3. 任务完成后按完成通知中的结构化结果向用户汇报,并按约定规则提交 flag、回报得分。
- 主动并发调度(默认行为,无需用户提醒):解题跑分类任务要最大化并发,始终把在跑的靶机/协作任务维持到平台并发上限(TSec Benchmark 通常为 3 个并发容器),不要一次只开一个靶机慢慢等:
1. 开始解题时:先查看题目列表,选定多道未完成题目,用 start 接口一次性启动多个靶机,并逐一调用 run_coop 下发对应协作任务,把并发拉满;
2. 每有任务完成/失败释放名额时:立即主动 close 已结束题目 → start 下一道未完成题目 → run_coop 下发,始终补满并发,不要空出名额等待;
3. 只有平台已无可启动题目、或用户明确要求停止时才停止扩并发。
- 主动检测状态(默认行为,无需用户提醒):在收到协作完成通知、下发新任务、或推进到关键节点时,主动用短命令(≤60s)查询平台进度(GET challenges)与运行中容器(docker_ps / run_bash),并向用户简要汇报「已通关题数/总分、运行中靶机、下一步计划」,不要等用户说「检测状态」才去查。
- 调用 run_coop 成功下发目标后,本轮会话立即收尾结束:直接输出一句简短总结即可,不要原地等待、不要 sleep、不要反复轮询黑板。coop 完成后系统会自动以「用户」身份在会话中追加完成通知并启动新一轮处理,届时你再读取黑板产物、提交 flag、规划下一题。
- coop 拿到 flag 后,必须按任务中约定的平台提交规则(如 TSec Benchmark 的 submit API、BENCHMARK_TOKEN、unique_code)用 curl 提交 flag,并把提交响应与得分回报给用户;提交失败要重试并说明原因,不能只汇报不提交。
- 不要用 sleep 或超时超过 60 秒的长等待命令原地等待 coop 任务完成:coop 是异步运行的,任务完成后系统会自动推送「协作任务完成通知」并触发你继续处理,无需 sleep 阻塞。等待期间可以用短命令(≤60s)检查黑板进度,或直接推进其他不依赖该任务的工作(如规划下一题)。
- 非解题类任务(文档撰写、代码开发、日常问答等)按常规方式由你自己完成,不必交给 coop。
`,
time.Now().Format("2006-01-02"), a.tools.workspaceFor(sessionID), a.cfg.Workspace)
}
func (a *Agent) sessionLock(sessionID string) *sync.Mutex {
a.locksMu.Lock()
defer a.locksMu.Unlock()
lock := a.locks[sessionID]
if lock == nil {
lock = &sync.Mutex{}
a.locks[sessionID] = lock
}
return lock
}
// ForgetSession 释放会话持有的运行时资源(会话锁与 live 注册)。
// 在删除会话时调用,避免 locks / live map 随会话累积只增不减。
func (a *Agent) ForgetSession(sessionID string) {
a.locksMu.Lock()
delete(a.locks, sessionID)
a.locksMu.Unlock()
a.liveMu.Lock()
delete(a.live, sessionID)
a.liveMu.Unlock()
}
// RegisterLive 把某个 SSE 连接注册为会话的"在线接收者",供后台协作任务完成
func (a *Agent) RegisterLive(sessionID string, emit func(Event)) func() {
a.liveMu.Lock()
reg := &liveReg{emit: emit}
a.live[sessionID] = reg
a.liveMu.Unlock()
return func() {
a.liveMu.Lock()
if a.live[sessionID] == reg {
delete(a.live, sessionID)
}
a.liveMu.Unlock()
}
}
// CoopTasks 返回协作任务列表;sessionID 非空时只返回该会话的任务,
// 供 Web 页面实时展示容器运行状态。
func (a *Agent) CoopTasks(sessionID string) []*CoopTask {
return a.coop.List(sessionID)
}
// liveEmitter 返回动态事件发射器:每次发送时实时查询该会话当前在线的连接。
// 与启动时一次性捕获 emit 相比,即使汇报轮在页面连接之前启动,页面打开后
// 也能实时收到流式事件(修复"汇报轮事件前端收不到"的问题)。
func (a *Agent) liveEmitter(sessionID string) func(Event) {
return func(event Event) {
a.liveMu.Lock()
reg := a.live[sessionID]
a.liveMu.Unlock()
if reg != nil {
reg.emit(event)
}
}
}
// notifyCoopDone 是后台协作任务完成后的回调:给会话注入一条
// "【协作任务完成通知】"用户消息,并触发主 agent 新一轮处理
// (有在线 SSE 连接则流式推送,否则在后台静默完成并持久化结果)。
// 通知基于 supervisor 的结构化结果(CoopResult)渲染,成功时携带 flag/摘要/
// 产物清单,失败时携带明确的续跑指令(关闭靶机 → 开下一题 → 重新下发 coop)。
// 汇报轮失败会在会话中持久化错误信息,避免"通知已到但流程静默停摆"。
func (a *Agent) notifyCoopDone(task *CoopTask) {
notif := buildCoopNotification(task)
emit := a.liveEmitter(task.SessionID)
// 汇报轮加超时兜底:后台自动触发的 Run 不能永久持锁阻塞后续所有通知
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
emit(Event{Type: "turn_start", Data: map[string]any{"user": notif}})
// 使用独立后台上下文:即使前端已关闭页面,汇报轮仍会完成并持久化。
// 注意:cancel 必须在 Run 结束后调用(defer 到 goroutine 内),
// 否则本函数一返回 ctx 即被取消,汇报轮的首个 LLM 请求会立即报
// "Post .../v1/messages: context canceled"(历史 bug:会话停在完成通知处)。
go func() {
defer cancel()
if err := a.Run(ctx, task.SessionID, notif, emit); err != nil {
// 汇报轮失败兜底:把错误持久化到会话,避免静默断流
if session, ok := a.store.Get(task.SessionID); ok {
fallback := "[系统] 协作完成通知的自动处理失败(" + err.Error() +
")。请先读取黑板产物(" + task.Blackboard + ")向用户汇报,再按 TSec 流程继续推进:close 已通关题目释放名额 → start 下一题 → run_coop 下发协作。"
session.Messages = append(session.Messages, Message{Role: "user", Content: stringPointer(fallback)})
a.store.Save(session)
}
log.Printf("[coop] 汇报轮失败 session=%s task=%s err=%v", task.SessionID, task.ID, err)
}
}()
}
// buildCoopNotification 根据任务的结构化结果渲染完成通知。
// 有 result.json 时按 status 判定成功/失败并附带 flag/产物清单;
// 缺失时回退到容器 stdout(老版本兼容)。
func buildCoopNotification(task *CoopTask) string {
success := false
status := "完成"
var summary, flag string
var artifacts []string
if task.Result != nil {
res := task.Result
switch res.Status {
case "solved":
success = true
status = "完成"
case "unsolved":
status = "未解出"
case "timeout":
status = "超时"
default:
status = "失败"
}
summary, flag, artifacts = res.Summary, res.Flag, res.Artifacts
} else {
// 老版本 / result.json 缺失时回退到容器日志
detail := task.Output
if detail == "" {
detail = task.Error
}
failed := task.Error != "" || task.ExitCode != 0 ||
strings.Contains(detail, "未解出") || strings.Contains(detail, "未完成")
if failed {
status = "未解出/失败"
} else {
success = true
}
summary = detail
}
var b strings.Builder
fmt.Fprintf(&b, "【协作任务完成通知】你之前发起的 pi 协作任务(ID: %s)已%s。\n", task.ID, status)
if success && flag != "" {
fmt.Fprintf(&b, "\n【flag】%s\n", flag)
}
if strings.TrimSpace(summary) != "" {
b.WriteString("\n" + truncateRunes(strings.TrimSpace(summary), 1200) + "\n")
}
if task.Blackboard != "" {
fmt.Fprintf(&b, "\n黑板产物目录:%s", task.Blackboard)
if len(artifacts) > 0 {
b.WriteString("\n关键产物:")
for _, artifact := range artifacts {
if name := strings.TrimSpace(artifact); name != "" {
b.WriteString("\n- " + name)
}
}
}
b.WriteString("\n可选用 read_file / list_directory 查看黑板产物,向用户汇报结果与关键文件。")
}
if success {
b.WriteString("\n若产物中包含 flag 且尚未提交:请读取黑板下 task.md(内含平台提交规则 / BENCHMARK_TOKEN / unique_code),立即用 curl 向平台提交 flag 并给出提交响应与得分;不要只汇报而不提交。")
b.WriteString("\n随后按 TSec 流程持续推进以拿更高总分:close 已通关题目释放名额 → 主动补满并发(一次性 start 多道未完成题目并逐一 run_coop,把在跑靶机数拉满到平台上限)→ 不要空出名额等待;若所有题目已完成或平台任务超时,则停止并向用户汇报总分。")
} else {
b.WriteString("\n该任务未解出/失败,请按 TSec 标准流程继续推进,不要空等:")
b.WriteString("\n1) 用 POST {BENCHMARK_BASE_URL}/openapi/v1/challenges/close?unique_code=<该题编号> 关闭当前靶机容器释放名额(该题编号为 " + task.ChallengeCode + ",可核对黑板 task.md);")
b.WriteString("\n2) 用 GET {BENCHMARK_BASE_URL}/openapi/v1/challenges 查看剩余未完成题目,一次性选定多道未完成题目补满并发;")
b.WriteString("\n3) 用 POST {BENCHMARK_BASE_URL}/openapi/v1/challenges/start?unique_code=<新题> 逐个启动新靶机,把在跑靶机数拉满到平台并发上限;")
b.WriteString("\n4) 对每道新题调用 run_coop 工具下发协作解题,不要只开一个靶机空等。")
b.WriteString("\n若所有题目已完成或平台任务已结束(接口持续 invalid_state),则停止并向用户汇报总分。")
}
return b.String()
}
func mergeToolCall(calls []ToolCall, delta ToolCallDelta) []ToolCall {
index := delta.Index
if index < 0 {
index = len(calls)
}
for len(calls) <= index {
calls = append(calls, ToolCall{Type: "function"})
}
call := &calls[index]
if delta.ID != "" {
call.ID = delta.ID
}
if delta.Name != "" {
call.Function.Name = delta.Name
}
call.Function.Arguments += delta.ArgumentsDelta
return calls
}
func estimateMessages(messages []Message) int {
total := 0
for _, message := range messages {
total += estimateMessageTokens(message)
}
return total
}
// estimateMessageTokens 估算单条消息的 token 数(含 tool_calls 与消息开销)。
func estimateMessageTokens(message Message) int {
total := 0
if message.Content != nil {
total += estimateTokens(*message.Content)
}
for _, call := range message.ToolCalls {
total += estimateTokens(call.Function.Name + call.Function.Arguments)
}
total += 8
return total
}
func estimateTokens(value string) int {
runes := []rune(value)
hanCount := 0
for _, r := range runes {
if unicode.Is(unicode.Han, r) {
hanCount++
}
}
other := len(runes) - hanCount
return int(float64(other)/4.0+float64(hanCount)*0.8) + 4
}
func stringPointer(value string) *string {
return &value
}
+258
View File
@@ -0,0 +1,258 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
)
// 支持的接口类型。
const (
ProviderOpenAI = "openai"
ProviderAnthropic = "anthropic"
)
// 协作 worker 管理模式。
const (
// CoopModeDocker 通过 Docker 容器管理 worker(需本机/远程 Docker daemon)。
CoopModeDocker = "docker"
// CoopModeLocal 通过本地子进程管理 worker(无需 Docker,托管沙箱等场景)。
CoopModeLocal = "local"
)
// LLM 配置环境变量名。环境变量优先级最高,覆盖 api-config.json 用户配置与内置默认值。
// 适用于托管沙箱等场景:平台注入环境变量,Agent 启动即生效,无需通过 Web 设置页配置。
const (
EnvLLMAPIKey = "LLM_API_KEY"
EnvLLMBaseURL = "LLM_BASE_URL"
EnvLLMModel = "LLM_MODEL"
EnvLLMProvider = "LLM_PROVIDER"
EnvLLMEngine = "LLM_ENGINE"
EnvCoopMode = "COOP_MODE"
)
// APIConfig 是用户可配置的 LLM API 连接信息。
type APIConfig struct {
// APIKey 是模型服务商 API Key。
APIKey string `json:"api_key,omitempty"`
// BaseURL 是模型接口地址。
BaseURL string `json:"base_url,omitempty"`
// Model 是模型名称。
Model string `json:"model,omitempty"`
// Provider 是接口类型:openaiOpenAI 兼容)或 anthropicAnthropic Messages API)。
Provider string `json:"provider,omitempty"`
// Engine 是默认协作引擎:pi(默认)/ pigo / claude。
// 仅影响 run_coop 未显式指定 engine 时的默认值,见 tools_coop.go。
Engine string `json:"engine,omitempty"`
// CoopMode 是协作 worker 管理模式:local(默认,本地子进程)/ docker(Docker 容器)。
// local 模式无需 Docker,适用于托管沙箱等无 Docker 环境;docker 模式需本机/远程 Docker daemon。
CoopMode string `json:"coop_mode,omitempty"`
}
// APIConfigStore 持久化用户的 LLM API 配置,未配置的字段回退到默认值(环境变量)。
type APIConfigStore struct {
mu sync.RWMutex
path string
config APIConfig
defaults APIConfig
}
func NewAPIConfigStore(dataDir string, defaults APIConfig) (*APIConfigStore, error) {
store := &APIConfigStore{
path: filepath.Join(dataDir, "api-config.json"),
defaults: defaults,
}
store.load()
return store, nil
}
func (s *APIConfigStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
if len(data) == 0 {
return
}
var config APIConfig
if err := json.Unmarshal(data, &config); err != nil {
return
}
s.config = config
}
func (s *APIConfigStore) persist() error {
data, err := json.MarshalIndent(s.config, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// APIKey 返回生效的 API Key。
// 优先级:环境变量 LLM_API_KEY > api-config.json > 内置默认值。
func (s *APIConfigStore) APIKey() string {
if v := strings.TrimSpace(os.Getenv(EnvLLMAPIKey)); v != "" {
return v
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.config.APIKey != "" {
return s.config.APIKey
}
return s.defaults.APIKey
}
// BaseURL 返回生效的接口地址。
// 优先级:环境变量 LLM_BASE_URL > api-config.json > 内置默认值。
func (s *APIConfigStore) BaseURL() string {
if v := strings.TrimSpace(os.Getenv(EnvLLMBaseURL)); v != "" {
return v
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.config.BaseURL != "" {
return s.config.BaseURL
}
return s.defaults.BaseURL
}
// Model 返回生效的模型名称。
// 优先级:环境变量 LLM_MODEL > api-config.json > 内置默认值。
func (s *APIConfigStore) Model() string {
if v := strings.TrimSpace(os.Getenv(EnvLLMModel)); v != "" {
return v
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.config.Model != "" {
return s.config.Model
}
return s.defaults.Model
}
// IsAPIKeyConfigured 报告是否已配置 API Key(含环境变量、用户配置与内置默认值)。
// LLM 客户端实际会按 APIKey() 的优先级(环境变量 > api-config.json > 默认值)取用,
// 因此只要最终能拿到非空 Key 就视为已配置,避免误判导致无法对话。
func (s *APIConfigStore) IsAPIKeyConfigured() bool {
if v := strings.TrimSpace(os.Getenv(EnvLLMAPIKey)); v != "" {
return true
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.config.APIKey != "" || s.defaults.APIKey != ""
}
// Provider 返回生效的接口类型。
// 优先级:环境变量 LLM_PROVIDER > api-config.json > 内置默认值 > openai。
func (s *APIConfigStore) Provider() string {
if v := strings.TrimSpace(os.Getenv(EnvLLMProvider)); v != "" {
if v == ProviderOpenAI || v == ProviderAnthropic {
return v
}
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.config.Provider != "" {
return s.config.Provider
}
if s.defaults.Provider != "" {
return s.defaults.Provider
}
return ProviderOpenAI
}
// Engine 返回生效的默认协作引擎:pi(默认)/ pigo / claude。
// 优先级:环境变量 LLM_ENGINE > api-config.json > 内置默认值 > pi。
// 仅在 run_coop 未显式指定 engine 时使用。
func (s *APIConfigStore) Engine() string {
if v := strings.TrimSpace(os.Getenv(EnvLLMEngine)); v != "" {
switch v {
case "pi", "pigo", "claude":
return v
}
}
s.mu.RLock()
defer s.mu.RUnlock()
switch s.config.Engine {
case "pi", "pigo", "claude":
return s.config.Engine
}
if s.defaults.Engine != "" {
return s.defaults.Engine
}
return "pi"
}
// CoopMode 返回生效的协作 worker 管理模式:local(默认)/ docker。
// 优先级:环境变量 COOP_MODE > api-config.json > 内置默认值 > local。
// local 模式通过本地子进程运行 worker(无需 Docker),docker 模式通过 Docker 容器运行。
func (s *APIConfigStore) CoopMode() string {
if v := strings.TrimSpace(os.Getenv(EnvCoopMode)); v != "" {
switch v {
case CoopModeDocker, CoopModeLocal:
return v
}
}
s.mu.RLock()
defer s.mu.RUnlock()
switch s.config.CoopMode {
case CoopModeDocker, CoopModeLocal:
return s.config.CoopMode
}
if s.defaults.CoopMode != "" {
return s.defaults.CoopMode
}
return CoopModeLocal
}
// Update 更新用户配置。每个参数为 nil 表示不修改该字段;
// 非 nil(含空字符串)表示设置或清除该字段(空串=清除,回退默认)。
func (s *APIConfigStore) Update(apiKey, baseURL, model, provider, engine, coopMode *string) error {
s.mu.Lock()
defer s.mu.Unlock()
if apiKey != nil {
s.config.APIKey = *apiKey
}
if baseURL != nil {
s.config.BaseURL = *baseURL
}
if model != nil {
s.config.Model = *model
}
if provider != nil {
value := strings.TrimSpace(*provider)
if value != ProviderOpenAI && value != ProviderAnthropic {
value = ""
}
s.config.Provider = value
}
if engine != nil {
value := strings.TrimSpace(*engine)
switch value {
case "pi", "pigo", "claude":
// 合法值
default:
value = ""
}
s.config.Engine = value
}
if coopMode != nil {
value := strings.TrimSpace(*coopMode)
switch value {
case CoopModeDocker, CoopModeLocal:
// 合法值
default:
value = ""
}
s.config.CoopMode = value
}
return s.persist()
}
+71
View File
@@ -0,0 +1,71 @@
package agent
import (
"strings"
"testing"
)
// TestFallbackSummary 验证 LLM 压缩失败时的兜底摘要:
// 纯文本、保留最近消息要点、无孤立 tool 消息。
func TestFallbackSummary(t *testing.T) {
history := []Message{
{Role: "user", Content: stringPointer("第一轮任务:分析日志")},
{Role: "assistant", Content: stringPointer("已确认方案,开始执行")},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolFunctionCall{Name: "run_bash", Arguments: `{"command":"ls"}`}}}},
{Role: "tool", ToolCallID: "c1", Content: stringPointer("file.txt")},
{Role: "user", Content: stringPointer("继续,报告结果")},
}
summary := fallbackSummary(history)
if summary == "" {
t.Fatal("fallbackSummary 不应返回空")
}
if strings.Contains(summary, "tool_calls") || strings.Contains(summary, "\"id\"") {
t.Fatalf("fallbackSummary 必须为纯文本,不得包含结构化 tool_calls: %s", summary)
}
if !strings.Contains(summary, "用户") || !strings.Contains(summary, "助手") || !strings.Contains(summary, "工具结果") {
t.Fatalf("fallbackSummary 应包含用户/助手/工具结果要点: %s", summary)
}
if !strings.Contains(summary, "调用了 run_bash") {
t.Fatalf("fallbackSummary 应记录工具调用名: %s", summary)
}
}
// TestCompactionInputBudget 验证单次压缩输入预算随窗口放大而放大,
// 且始终留出输出与安全余量。
func TestCompactionInputBudget(t *testing.T) {
a := &Agent{cfg: Config{MaxContextTokens: 96000, CompactionTokens: 4096, StreamOutputTokens: 4096}}
budget := a.compactionInputBudget()
if budget <= 0 || budget >= 96000 {
t.Fatalf("预算应介于 (0, 96000)got %d", budget)
}
// 窗口放大后预算也应放大
a2 := &Agent{cfg: Config{MaxContextTokens: 48000, CompactionTokens: 4096, StreamOutputTokens: 4096}}
if a2.compactionInputBudget() >= budget {
t.Fatalf("窗口更大的预算应更大,got %d vs %d", a2.compactionInputBudget(), budget)
}
// 极端小窗口时兜底下限 4096
a3 := &Agent{cfg: Config{MaxContextTokens: 2000, CompactionTokens: 512, StreamOutputTokens: 512}}
if got := a3.compactionInputBudget(); got < 4096 {
t.Fatalf("小窗口预算应有下限,got %d", got)
}
}
// TestEstimateMessageTokens 验证单条消息估算包含 content、tool_calls 与开销。
func TestEstimateMessageTokens(t *testing.T) {
m := Message{
Role: "assistant",
Content: stringPointer("你好"),
ToolCalls: []ToolCall{{
ID: "c1", Type: "function",
Function: ToolFunctionCall{Name: "read_file", Arguments: `{"path":"a.txt"}`},
}},
}
n := estimateMessageTokens(m)
if n <= 0 {
t.Fatalf("估算应大于 0got %d", n)
}
plain := estimateMessageTokens(Message{Role: "user", Content: stringPointer("你好")})
if n <= plain {
t.Fatalf("含 tool_calls 的消息估算应大于纯文本,got %d vs %d", n, plain)
}
}
+137
View File
@@ -0,0 +1,137 @@
package agent
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
APIKey string
BaseURL string
Model string
Port string
Workspace string
DataDir string
// AuthCode 是 Web/API/WS 访问授权码。为空(默认)表示不启用授权;
// 非空(Docker 部署时通过环境变量 AGENT_AUTH_CODE 注入)表示启用:
// 访问者必须输入正确授权码才能使用本实例。
AuthCode string
MaxContextTokens int
KeepRecentMessages int
MaxToolResultChars int
MaxIterations int
ToolTimeout time.Duration
RequestTimeout time.Duration
StreamOutputTokens int
CompactionTokens int
Temperature float64
}
func LoadConfig() Config {
loadDotEnv(".env")
workspace := getenv("AGENT_WORKSPACE", "")
if workspace == "" {
workspace, _ = os.Getwd()
}
absWorkspace, err := filepath.Abs(workspace)
if err != nil {
absWorkspace = workspace
}
dataDir := getenv("AGENT_DATA_DIR", filepath.Join(absWorkspace, "data"))
if !filepath.IsAbs(dataDir) {
dataDir = filepath.Join(absWorkspace, dataDir)
}
cfg := Config{
APIKey: os.Getenv("SILICONFLOW_API_KEY"),
BaseURL: getenv("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1/chat/completions"),
Model: getenv("AGENT_MODEL", "Qwen/Qwen3-32B"),
Port: getenv("AGENT_PORT", "8080"),
Workspace: absWorkspace,
DataDir: dataDir,
// 全面放开限制以适配 DeepSeek(128K 上下文、大输出)跑复杂任务:
// - MaxContextTokens 给到 1M,确保 compactionInputBudget 公式(window - Compaction - Stream - window/8)仍有充足余量
// - StreamOutputTokens 提升到 65536:直接作为 max_tokens 传给 LLM(见 llm.go),是放开主 Agent 输出能力的关键
// - CompactionTokens 提升到 16384:压缩摘要允许足够长度,避免历史信息丢失
// - MaxToolResultChars 提升到 100000:复杂任务的脚本/日志能完整传回 LLM,不被截断
// - KeepRecentMessages 提升到 50:压缩后保留更多历史,复杂多步任务上下文不丢
// - ToolTimeout/RequestTimeout 提升到 600s:编译/长命令/深度推理有充足时间
MaxContextTokens: getenvInt("AGENT_MAX_CONTEXT_TOKENS", 1000000),
KeepRecentMessages: getenvInt("AGENT_KEEP_RECENT_MESSAGES", 50),
MaxToolResultChars: getenvInt("AGENT_MAX_TOOL_RESULT_CHARS", 100000),
MaxIterations: getenvInt("AGENT_MAX_ITERATIONS", 50),
ToolTimeout: time.Duration(getenvInt("AGENT_TOOL_TIMEOUT_SECONDS", 600)) * time.Second,
RequestTimeout: time.Duration(getenvInt("AGENT_REQUEST_TIMEOUT_SECONDS", 600)) * time.Second,
StreamOutputTokens: getenvInt("AGENT_STREAM_OUTPUT_TOKENS", 65536),
CompactionTokens: getenvInt("AGENT_COMPACTION_TOKENS", 16384),
Temperature: getenvFloat("AGENT_TEMPERATURE", 0.3),
// 授权码默认关闭:仅当 Docker 部署时显式注入 AGENT_AUTH_CODE 才启用访问授权。
AuthCode: getenv("AGENT_AUTH_CODE", ""),
}
return cfg
}
func loadDotEnv(path string) {
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func getenvFloat(key string, fallback float64) float64 {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fallback
}
return parsed
}
+180
View File
@@ -0,0 +1,180 @@
package agent
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
// CoopTask 记录一次后台运行的 pi 协作任务。
// 任务由 run_coop 工具异步启动,完成后通过 CoopManager.Complete 通知主 agent。
type CoopTask struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
ContainerID string `json:"container_id,omitempty"`
// ProcessID 是本地子进程模式的 worker 进程 PIDdocker 模式为 0)。
ProcessID int `json:"process_id,omitempty"`
Status string `json:"status"` // running | done
ExitCode int `json:"exit_code,omitempty"`
Output string `json:"output,omitempty"`
Blackboard string `json:"blackboard,omitempty"`
Error string `json:"error,omitempty"`
RoundMax int `json:"round_max"`
CreatedAt time.Time `json:"created_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
// Result 是 supervisor 写入黑板 result.json 的结构化结果(存在时优先于 Output)。
Result *CoopResult `json:"result,omitempty"`
// ChallengeCode 任务描述的题目编号(如 c-06),用于通知控制层关闭/切换靶机
ChallengeCode string `json:"challenge_code,omitempty"`
}
// CoopResult 是 pi 协作容器 supervisor 生成的标准结果($BB/result.json),
// 完成通知与主 agent 汇报轮直接使用这些结构化字段,不再依赖截断的容器 stdout。
type CoopResult struct {
Status string `json:"status"` // solved | unsolved | error | timeout
ExitCode int `json:"exit_code"`
Summary string `json:"summary"`
Flag string `json:"flag"`
Artifacts []string `json:"artifacts"`
}
// Solved 返回任务是否成功解出/交付完成。
func (r *CoopResult) Solved() bool {
return r != nil && r.Status == "solved"
}
// CoopManager 管理后台协作任务的生命周期,并在任务完成时回调 Agent,
// 由 Agent 决定是否唤醒主 agent 汇报结果。
type CoopManager struct {
mu sync.Mutex
tasks map[string]*CoopTask
notify func(task *CoopTask)
}
func NewCoopManager() *CoopManager {
return &CoopManager{tasks: make(map[string]*CoopTask)}
}
// SetNotify 注册任务完成回调。
func (m *CoopManager) SetNotify(fn func(task *CoopTask)) {
m.mu.Lock()
m.notify = fn
m.mu.Unlock()
}
// Register 登记一个正在运行的后台任务。
func (m *CoopManager) Register(task *CoopTask) {
m.mu.Lock()
m.tasks[task.ID] = task
m.mu.Unlock()
}
// Get 按任务 ID 查询任务。
func (m *CoopManager) Get(id string) (*CoopTask, bool) {
m.mu.Lock()
defer m.mu.Unlock()
task, ok := m.tasks[id]
return task, ok
}
// List 返回全部任务,按创建时间倒序;sessionID 非空时只返回该会话的任务。
func (m *CoopManager) List(sessionID string) []*CoopTask {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*CoopTask, 0, len(m.tasks))
for _, task := range m.tasks {
if sessionID == "" || task.SessionID == sessionID {
out = append(out, task)
}
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt.After(out[j].CreatedAt)
})
return out
}
// Complete 标记任务完成并触发完成回调(回调在锁外执行,避免死锁)。
func (m *CoopManager) Complete(task *CoopTask) {
m.mu.Lock()
task.Status = "done"
task.FinishedAt = time.Now()
m.tasks[task.ID] = task
notify := m.notify
m.mu.Unlock()
if notify != nil {
notify(task)
}
}
// CurrentCoopRound 统计黑板 logs 目录中已出现的最大协作轮次。
// 单 agent 版日志命名为 round-N.log;兼容旧版 a-round-N.log / b-round-N.log。
func CurrentCoopRound(blackboardDir string) int {
if blackboardDir == "" {
return 0
}
entries, err := os.ReadDir(filepath.Join(blackboardDir, "logs"))
if err != nil {
return 0
}
maxRound := 0
for _, entry := range entries {
var round int
if _, err := fmt.Sscanf(entry.Name(), "round-%d.log", &round); err == nil {
if round > maxRound {
maxRound = round
}
continue
}
if _, err := fmt.Sscanf(entry.Name(), "a-round-%d.log", &round); err == nil {
if round > maxRound {
maxRound = round
}
}
if _, err := fmt.Sscanf(entry.Name(), "b-round-%d.log", &round); err == nil {
if round > maxRound {
maxRound = round
}
}
}
return maxRound
}
// ContainerInspectStatus 查询容器当前的运行状态与人类可读状态描述
// (如 "running" + "Up 2 minutes" / "exited" + "Exited (0)"),
// 供 Web 页面实时展示协作容器状态。
func ContainerInspectStatus(socket, containerID string) (state, status string, err error) {
if containerID == "" {
return "", "", errors.New("缺少容器 ID")
}
cli, err := client.NewClientWithOpts(
client.WithHost(socket),
client.WithAPIVersionNegotiation(),
)
if err != nil {
return "", "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
list, err := cli.ContainerList(ctx, container.ListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("id", containerID)),
})
if err != nil {
return "", "", err
}
if len(list) == 0 {
return "", "", errors.New("容器不存在")
}
return list[0].State, list[0].Status, nil
}
+85
View File
@@ -0,0 +1,85 @@
package agent
import (
"context"
"testing"
"time"
)
func TestCoopBaseURL(t *testing.T) {
tests := []struct {
name string
provider string
engine string
baseURL string
want string
}{
// ---- pi / claude(用 @anthropic-ai/sdk,自带 /v1/messages,不能补 /v1----
// Anthropic:剥离 /v1,避免双重 /v1/v1/messages
{"pi/anthropic bare", "anthropic", "pi", "https://api.deepseek.com/anthropic", "https://api.deepseek.com/anthropic"},
{"pi/anthropic trailing slash", "anthropic", "pi", "https://api.deepseek.com/anthropic/", "https://api.deepseek.com/anthropic"},
{"pi/anthropic with /v1", "anthropic", "pi", "https://api.deepseek.com/anthropic/v1", "https://api.deepseek.com/anthropic"},
{"pi/anthropic with /v1/messages", "anthropic", "pi", "https://api.deepseek.com/anthropic/v1/messages", "https://api.deepseek.com/anthropic"},
{"pi/anthropic with /messages", "anthropic", "pi", "https://api.deepseek.com/anthropic/messages", "https://api.deepseek.com/anthropic"},
{"pi/anthropic anthropic.com", "anthropic", "pi", "https://api.anthropic.com", "https://api.anthropic.com"},
// claude 行为与 pi 一致
{"claude/anthropic bare", "anthropic", "claude", "https://api.deepseek.com/anthropic", "https://api.deepseek.com/anthropic"},
{"claude/anthropic with /v1", "anthropic", "claude", "https://api.deepseek.com/anthropic/v1", "https://api.deepseek.com/anthropic"},
// ---- pigoanthropicCompatDriver 只追加 /messages,需补 /v1----
{"pigo/anthropic bare", "anthropic", "pigo", "https://api.deepseek.com/anthropic", "https://api.deepseek.com/anthropic/v1"},
{"pigo/anthropic trailing slash", "anthropic", "pigo", "https://api.deepseek.com/anthropic/", "https://api.deepseek.com/anthropic/v1"},
{"pigo/anthropic with /v1", "anthropic", "pigo", "https://api.deepseek.com/anthropic/v1", "https://api.deepseek.com/anthropic/v1"},
{"pigo/anthropic with /v1/messages", "anthropic", "pigo", "https://api.deepseek.com/anthropic/v1/messages", "https://api.deepseek.com/anthropic/v1"},
{"pigo/anthropic with /messages", "anthropic", "pigo", "https://api.deepseek.com/anthropic/messages", "https://api.deepseek.com/anthropic/v1"},
{"pigo/anthropic anthropic.com", "anthropic", "pigo", "https://api.anthropic.com", "https://api.anthropic.com/v1"},
// ---- OpenAI 协议(三种 engine 行为一致:保留 /v1,剥离 /chat/completions----
{"pi/openai full url", "openai", "pi", "https://api.siliconflow.cn/v1/chat/completions", "https://api.siliconflow.cn/v1"},
{"pi/openai with /v1", "openai", "pi", "https://api.siliconflow.cn/v1", "https://api.siliconflow.cn/v1"},
{"pi/openai bare", "openai", "pi", "https://api.siliconflow.cn", "https://api.siliconflow.cn"},
{"pigo/openai full url", "openai", "pigo", "https://api.siliconflow.cn/v1/chat/completions", "https://api.siliconflow.cn/v1"},
{"pigo/openai bare", "openai", "pigo", "https://api.siliconflow.cn", "https://api.siliconflow.cn"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := coopBaseURL(tt.provider, tt.baseURL, tt.engine)
if got != tt.want {
t.Errorf("coopBaseURL(%q, %q, %q) = %q, want %q", tt.provider, tt.baseURL, tt.engine, got, tt.want)
}
})
}
}
func TestRunCoopErrorPaths(t *testing.T) {
apiCfg := &APIConfigStore{}
apiCfg.config = APIConfig{APIKey: "test-key", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-chat"}
toolset := &Toolset{
Workspace: ".",
Timeout: 30 * time.Second,
MaxOutput: 4000,
apiCfg: apiCfg,
dockerSocket: defaultDockerSocket,
coop: NewCoopManager(),
sessionRoot: t.TempDir(),
}
// 1. 缺少 task
r := toolset.runCoop(context.Background(), "test-session", map[string]any{})
if r.Success || r.Output == "" {
t.Fatalf("empty task should fail, got %#v", r)
}
// 2. 正常参数:应进入 Docker 检查阶段(本机无 Docker 时返回连接/镜像错误而非 panic)
r = toolset.runCoop(context.Background(), "test-session", map[string]any{
"task": "测试任务",
"round_max": 2,
"timeout": 30,
})
t.Logf("runCoop output: %s", r.Output)
if r.Success {
// 若真的跑成功了(有 Docker 且镜像存在),也无妨
t.Logf("coop unexpectedly succeeded")
}
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"sync"
)
// DockerConfig 是用户可配置的 Docker 连接信息。
type DockerConfig struct {
// Socket 是用户配置的 Docker 连接地址,支持
// unix:///var/run/docker.sock、npipe:////./pipe/docker_engine、tcp://host:2375 等。
// 为空时使用默认的本地 Docker。
Socket string `json:"socket,omitempty"`
}
// DockerConfigStore 持久化用户的 Docker socket 配置,并给出默认值。
type DockerConfigStore struct {
mu sync.RWMutex
path string
config DockerConfig
defaultSocket string
}
func NewDockerConfigStore(dataDir string) (*DockerConfigStore, error) {
defaultSocket := getenv("AGENT_DOCKER_SOCKET", "")
if defaultSocket == "" {
defaultSocket = defaultDockerSocket()
}
store := &DockerConfigStore{
path: filepath.Join(dataDir, "docker-config.json"),
defaultSocket: defaultSocket,
}
store.load()
return store, nil
}
func defaultDockerSocket() string {
if runtime.GOOS == "windows" {
return "npipe:////./pipe/docker_engine"
}
return "unix:///var/run/docker.sock"
}
func (s *DockerConfigStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
if len(data) == 0 {
return
}
var config DockerConfig
if err := json.Unmarshal(data, &config); err != nil {
return
}
s.config = config
}
// Socket 返回当前生效的 docker 连接地址:用户配置优先,否则使用默认本地 Docker。
func (s *DockerConfigStore) Socket() string {
s.mu.RLock()
defer s.mu.RUnlock()
if s.config.Socket != "" {
return s.config.Socket
}
return s.defaultSocket
}
// DefaultSocket 返回未配置时使用的默认地址。
func (s *DockerConfigStore) DefaultSocket() string {
return s.defaultSocket
}
// IsConfigured 报告用户是否手动配置过 socket。
func (s *DockerConfigStore) IsConfigured() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config.Socket != ""
}
// SetSocket 保存用户的 socket 配置;传入空字符串表示清除配置、恢复默认。
func (s *DockerConfigStore) SetSocket(socket string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.config.Socket = socket
data, err := json.MarshalIndent(s.config, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
+470
View File
@@ -0,0 +1,470 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
type LLMClient struct {
cfg Config
apiConfig *APIConfigStore
client *http.Client
}
type StreamChunk struct {
Content string
ToolCalls []ToolCallDelta
FinishReason string
Usage *Usage
Error error
}
type ToolCallDelta struct {
Index int
ID string
Name string
ArgumentsDelta string
}
func NewLLMClient(cfg Config, apiCfg *APIConfigStore) *LLMClient {
return &LLMClient{
cfg: cfg,
apiConfig: apiCfg,
client: &http.Client{
Timeout: cfg.RequestTimeout + 30*time.Second,
},
}
}
// isAnthropic 报告当前是否使用 Anthropic Messages API。
func (c *LLMClient) isAnthropic() bool {
return c.apiConfig.Provider() == ProviderAnthropic
}
func (c *LLMClient) ChatStream(ctx context.Context, messages []Message, tools []ToolDefinition) (<-chan StreamChunk, error) {
if c.isAnthropic() {
body := map[string]any{
"model": c.apiConfig.Model(),
"max_tokens": c.cfg.StreamOutputTokens,
"stream": true,
"temperature": c.cfg.Temperature,
"messages": toAnthropicMessages(messages),
}
if system := extractSystemPrompt(messages); system != "" {
body["system"] = system
}
if len(tools) > 0 {
body["tools"] = toAnthropicTools(tools)
}
return c.stream(ctx, body)
}
body := map[string]any{
"model": c.apiConfig.Model(),
"messages": toAPIMessages(messages),
"stream": true,
"temperature": c.cfg.Temperature,
"max_tokens": c.cfg.StreamOutputTokens,
}
if len(tools) > 0 {
body["tools"] = tools
body["tool_choice"] = "auto"
}
return c.stream(ctx, body)
}
func (c *LLMClient) Compress(ctx context.Context, history []Message) (string, error) {
systemPrompt := `你是一个高信息密度的对话压缩器。把下面的历史对话压缩成一份中文摘要,供后续继续执行同一任务。
必须保留(按重要程度排序):
1. 用户的核心目标与最新要求、尚未完成的任务与下一步计划;
2. 已确认的决策与结论、得分/进度(如 TSec 累计分数与已通关/进行中的题目编号);
3. 关键文件路径、执行过的命令与重要输出要点、代码要点;
4. 错误信息与解决方案、需要继续跟进的问题;
5. 平台提交规则(如 BENCHMARK_TOKEN、unique_code、提交接口与提交方式)。
规则:
- 按时间顺序组织,最新信息优先,可适当合并同类项;
- 若历史包含工具调用(run_bash/read_file/run_coop 等),只保留"做了什么、结果如何"的要点,不要逐字复制命令或输出;
- 不要添加历史中不存在的信息,不要臆测;
- 直接输出摘要正文,不要输出任何解释、标题或多余格式。`
messages := append([]Message{{Role: "system", Content: &systemPrompt}}, history...)
if c.isAnthropic() {
return c.compressAnthropic(ctx, messages)
}
body := map[string]any{
"model": c.apiConfig.Model(),
"messages": toAPIMessages(messages),
"stream": false,
"temperature": 0.2,
"max_tokens": c.cfg.CompactionTokens,
}
ctx, cancel := context.WithTimeout(ctx, c.cfg.RequestTimeout)
defer cancel()
payload, err := json.Marshal(body)
if err != nil {
return "", err
}
respBody, err := c.doSyncRequestWithRetry(ctx, false, c.apiConfig.BaseURL(), payload)
if err != nil {
return "", err
}
var result struct {
Choices []struct {
Message struct {
Content *string `json:"content"`
ReasoningContent *string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
if len(result.Choices) == 0 {
return "", errors.New("模型没有返回压缩摘要")
}
msg := result.Choices[0].Message
if msg.Content != nil && strings.TrimSpace(*msg.Content) != "" {
return strings.TrimSpace(*msg.Content), nil
}
// 推理模型(deepseek-reasoner 系)正文在 reasoning_contentcontent 可能为空
if msg.ReasoningContent != nil && strings.TrimSpace(*msg.ReasoningContent) != "" {
return strings.TrimSpace(*msg.ReasoningContent), nil
}
return "", errors.New("模型没有返回压缩摘要")
}
// stream 发起流式 LLM 请求,对连接失败、限流(429/5xx/551)、首 chunk 前流断开
// 做指数退避重试(3 次:1s→2s→4s)。保留 (<-chan, error) 签名,上层无需改动。
func (c *LLMClient) stream(ctx context.Context, body map[string]any) (<-chan StreamChunk, error) {
anthropic := c.isAnthropic()
url := c.apiConfig.BaseURL()
if anthropic {
url = normalizeAnthropicURL(url)
}
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
const maxRetries = 3
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
ch, reqErr := c.doStreamRequest(ctx, anthropic, url, payload)
if reqErr != nil {
lastErr = reqErr
if attempt < maxRetries && isRetriableErr(reqErr) {
log.Printf("[llm] 流式请求失败(第 %d 次),将重试: %v", attempt+1, reqErr)
continue
}
return nil, reqErr
}
// 请求成功建立,但流可能在首个 chunk 前就断开(EOF)。
// peek 第一个 chunk:如果是可重试错误,排空旧 channel 后重试。
first, ok := <-ch
if !ok {
lastErr = errors.New("stream: 空响应")
if attempt < maxRetries {
log.Printf("[llm] 流式响应为空(第 %d 次),重试", attempt+1)
continue
}
return nil, lastErr
}
if first.Error != nil && attempt < maxRetries && isRetriableErr(first.Error) {
for range ch {
}
lastErr = first.Error
log.Printf("[llm] 流式响应首 chunk 前断开(第 %d 次),重试: %v", attempt+1, first.Error)
continue
}
// 正常:转发 first + 后续 chunk
out := make(chan StreamChunk, 64)
go func() {
defer close(out)
out <- first
for chunk := range ch {
out <- chunk
}
}()
return out, nil
}
return nil, lastErr
}
// doStreamRequest 发起单次流式 LLM 请求(不含重试)。
func (c *LLMClient) doStreamRequest(ctx context.Context, anthropic bool, url string, payload []byte) (<-chan StreamChunk, error) {
ctx, cancel := context.WithTimeout(ctx, c.cfg.RequestTimeout)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
cancel()
return nil, err
}
c.setRequestHeaders(req, anthropic)
resp, err := c.client.Do(req)
if err != nil {
cancel()
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
apiErr := c.readAPIError(resp)
resp.Body.Close()
cancel()
return nil, apiErr
}
ch := make(chan StreamChunk, 64)
go func() {
defer cancel()
defer close(ch)
defer resp.Body.Close()
if anthropic {
readAnthropicSSE(ctx, resp, ch)
} else {
c.readSSE(ctx, resp, ch)
}
}()
return ch, nil
}
// doSyncRequestWithRetry 发起同步(非流式)LLM 请求,对连接失败、限流(429/5xx/551)
// 做指数退避重试(3 次:1s→2s→4s)。返回响应体字节,由调用方解析。
func (c *LLMClient) doSyncRequestWithRetry(ctx context.Context, anthropic bool, url string, payload []byte) ([]byte, error) {
const maxRetries = 3
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
body, err := c.doSyncRequest(ctx, anthropic, url, payload)
if err == nil {
return body, nil
}
lastErr = err
if attempt < maxRetries && isRetriableErr(err) {
log.Printf("[llm] 同步请求失败(第 %d 次),将重试: %v", attempt+1, err)
continue
}
return nil, err
}
return nil, lastErr
}
// doSyncRequest 发起单次同步(非流式)LLM 请求(不含重试)。
func (c *LLMClient) doSyncRequest(ctx context.Context, anthropic bool, url string, payload []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, c.cfg.RequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
c.setRequestHeaders(req, anthropic)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, c.readAPIError(resp)
}
return io.ReadAll(resp.Body)
}
// isRetriableErr 判断错误是否值得重试(网络错误、限流、服务端错误)。
func isRetriableErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
msg := err.Error()
if strings.Contains(msg, "EOF") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "timeout") ||
strings.Contains(msg, "deadline exceeded") ||
strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") {
return true
}
// HTTP 状态码错误:5xx 服务端错误、429 限流、551 网关熔断
if strings.HasPrefix(msg, "HTTP 5") ||
strings.HasPrefix(msg, "HTTP 429") ||
strings.HasPrefix(msg, "HTTP 551") {
return true
}
return false
}
func (c *LLMClient) readSSE(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content *string `json:"content"`
ToolCalls []struct {
Index *int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *Usage `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
event := StreamChunk{}
for _, choice := range chunk.Choices {
if choice.Delta.Content != nil {
event.Content += *choice.Delta.Content
}
for _, tc := range choice.Delta.ToolCalls {
index := 0
if tc.Index != nil {
index = *tc.Index
}
event.ToolCalls = append(event.ToolCalls, ToolCallDelta{
Index: index,
ID: tc.ID,
Name: tc.Function.Name,
ArgumentsDelta: tc.Function.Arguments,
})
}
if choice.FinishReason != nil {
event.FinishReason = *choice.FinishReason
}
}
if chunk.Usage != nil {
event.Usage = chunk.Usage
}
if event.Content != "" || len(event.ToolCalls) > 0 || event.FinishReason != "" || event.Usage != nil {
ch <- event
}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
ch <- StreamChunk{Error: err}
}
}
func (c *LLMClient) setRequestHeaders(req *http.Request, anthropic bool) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
if anthropic {
req.Header.Set("x-api-key", c.apiConfig.APIKey())
req.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Header.Set("Authorization", "Bearer "+c.apiConfig.APIKey())
}
}
func (c *LLMClient) readAPIError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
var apiErr struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(body, &apiErr)
message := strings.TrimSpace(apiErr.Error.Message)
if message == "" {
message = strings.TrimSpace(string(body))
}
if message == "" {
message = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return errors.New(message)
}
func toAPIMessages(messages []Message) []map[string]any {
result := make([]map[string]any, 0, len(messages))
for _, message := range messages {
item := map[string]any{
"role": message.Role,
"content": contentValue(message.Content),
}
if len(message.ToolCalls) > 0 {
item["tool_calls"] = toAPIToolCalls(message.ToolCalls)
}
if message.ToolCallID != "" {
item["tool_call_id"] = message.ToolCallID
}
result = append(result, item)
}
return result
}
func toAPIToolCalls(calls []ToolCall) []map[string]any {
result := make([]map[string]any, 0, len(calls))
for _, call := range calls {
result = append(result, map[string]any{
"id": call.ID,
"type": "function",
"function": map[string]any{
"name": call.Function.Name,
"arguments": call.Function.Arguments,
},
})
}
return result
}
func contentValue(content *string) any {
if content == nil {
return nil
}
return *content
}
+298
View File
@@ -0,0 +1,298 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
)
// normalizeAnthropicURL 把用户填写的 Base URL 规范化为 Anthropic Messages 接口地址。
// 兼容多种填写方式:https://api.anthropic.com、.../v1、.../v1/messages、.../v1/chat/completions。
func normalizeAnthropicURL(baseURL string) string {
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if trimmed == "" {
return "https://api.anthropic.com/v1/messages"
}
switch {
case strings.HasSuffix(trimmed, "/v1/chat/completions"):
return strings.TrimSuffix(trimmed, "/chat/completions") + "/messages"
case strings.HasSuffix(trimmed, "/v1/messages"):
return trimmed
case strings.HasSuffix(trimmed, "/v1"):
return trimmed + "/messages"
default:
return trimmed + "/v1/messages"
}
}
// extractSystemPrompt 汇总消息中的 system 角色内容,Anthropic 要求 system 放在顶层字段。
func extractSystemPrompt(messages []Message) string {
var parts []string
for _, message := range messages {
if message.Role == "system" && message.Content != nil && strings.TrimSpace(*message.Content) != "" {
parts = append(parts, *message.Content)
}
}
return strings.Join(parts, "\n\n")
}
// toAnthropicTools 把内部工具定义转换为 Anthropic 的 tools 数组(input_schema 替代 parameters)。
func toAnthropicTools(tools []ToolDefinition) []map[string]any {
result := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
schema := tool.Function.Parameters
if schema == nil {
schema = map[string]any{"type": "object"}
}
result = append(result, map[string]any{
"name": tool.Function.Name,
"description": tool.Function.Description,
"input_schema": schema,
})
}
return result
}
// toAnthropicMessages 把内部 Message 列表转换为 Anthropic messages 数组。
// system 消息被过滤(走顶层 system 字段);assistant 的 tool_use 与 user 的
// tool_result 都以 content block 形式表达。
// 注意:Anthropic 要求上一条 assistant 消息中所有 tool_use 的 tool_result
// 必须放在紧邻的同一条 user 消息里,因此连续的 tool 结果消息需要合并。
func toAnthropicMessages(messages []Message) []map[string]any {
result := make([]map[string]any, 0, len(messages))
for i := 0; i < len(messages); i++ {
message := messages[i]
switch message.Role {
case "system":
continue
case "assistant":
blocks := make([]map[string]any, 0, 1+len(message.ToolCalls))
if message.Content != nil && strings.TrimSpace(*message.Content) != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": *message.Content})
}
for _, call := range message.ToolCalls {
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": call.ID,
"name": call.Function.Name,
"input": parseJSONValue(call.Function.Arguments),
})
}
if len(blocks) == 0 {
blocks = append(blocks, map[string]any{"type": "text", "text": ""})
}
result = append(result, map[string]any{"role": "assistant", "content": blocks})
case "tool":
// 合并连续的 tool 消息:同一条 user 消息包含所有 tool_result 块
blocks := []map[string]any{{
"type": "tool_result",
"tool_use_id": message.ToolCallID,
"content": contentString(message.Content),
}}
for i+1 < len(messages) && messages[i+1].Role == "tool" {
i++
next := messages[i]
blocks = append(blocks, map[string]any{
"type": "tool_result",
"tool_use_id": next.ToolCallID,
"content": contentString(next.Content),
})
}
result = append(result, map[string]any{"role": "user", "content": blocks})
default: // user
blocks := make([]map[string]any, 0, 1)
if message.Content != nil && strings.TrimSpace(*message.Content) != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": *message.Content})
}
if len(blocks) == 0 {
blocks = append(blocks, map[string]any{"type": "text", "text": ""})
}
result = append(result, map[string]any{"role": "user", "content": blocks})
}
}
return result
}
// parseJSONValue 把工具参数 JSON 字符串解析为任意值;解析失败时回退为空对象。
func parseJSONValue(raw string) any {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil || value == nil {
return map[string]any{}
}
return value
}
func contentString(content *string) string {
if content == nil {
return ""
}
return *content
}
// readAnthropicSSE 解析 Anthropic Messages API 的流式响应。
// 事件格式为 `event: <type>` 与 `data: <json>` 两行一组。
func readAnthropicSSE(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
// content block index -> 正在累积的 tool_use 状态
type toolState struct {
callIndex int
id string
name string
}
tools := make(map[int]*toolState)
nextCallIndex := 0
var eventType string
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event:"):
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
continue
case strings.HasPrefix(line, "data:"):
default:
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
switch eventType {
case "content_block_start":
var ev struct {
Index int `json:"index"`
Block struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"content_block"`
}
if err := json.Unmarshal([]byte(data), &ev); err != nil {
continue
}
if ev.Block.Type == "tool_use" {
state := &toolState{callIndex: nextCallIndex, id: ev.Block.ID, name: ev.Block.Name}
nextCallIndex++
tools[ev.Index] = state
ch <- StreamChunk{ToolCalls: []ToolCallDelta{
{Index: state.callIndex, ID: state.id, Name: state.name},
}}
}
case "content_block_delta":
var ev struct {
Index int `json:"index"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
}
if err := json.Unmarshal([]byte(data), &ev); err != nil {
continue
}
switch ev.Delta.Type {
case "text_delta":
ch <- StreamChunk{Content: ev.Delta.Text}
case "input_json_delta":
if state, ok := tools[ev.Index]; ok {
ch <- StreamChunk{ToolCalls: []ToolCallDelta{
{Index: state.callIndex, ArgumentsDelta: ev.Delta.PartialJSON},
}}
}
}
case "message_delta":
var ev struct {
Delta struct {
StopReason string `json:"stop_reason"`
} `json:"delta"`
Usage *struct {
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &ev); err != nil {
continue
}
if ev.Delta.StopReason != "" {
ch <- StreamChunk{FinishReason: ev.Delta.StopReason}
}
if ev.Usage != nil {
ch <- StreamChunk{Usage: &Usage{CompletionTokens: ev.Usage.OutputTokens}}
}
case "error":
var ev struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(data), &ev); err != nil {
continue
}
msg := strings.TrimSpace(ev.Error.Message)
if msg == "" {
msg = "Anthropic API 错误"
}
ch <- StreamChunk{Error: errors.New(msg)}
}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
ch <- StreamChunk{Error: err}
}
}
// compressAnthropic 使用 Anthropic 非流式接口执行对话压缩。
func (c *LLMClient) compressAnthropic(ctx context.Context, messages []Message) (string, error) {
body := map[string]any{
"model": c.apiConfig.Model(),
"max_tokens": c.cfg.CompactionTokens,
"stream": false,
"temperature": 0.2,
// 关闭思考:推理模型(如 deepseek-v4-flash)默认先输出 thinking 块,
// 会把 max_tokens 预算耗尽而拿不到 text 块,导致压缩被判为失败。
"thinking": map[string]any{"type": "disabled"},
"messages": toAnthropicMessages(messages),
}
if system := extractSystemPrompt(messages); system != "" {
body["system"] = system
}
ctx, cancel := context.WithTimeout(ctx, c.cfg.RequestTimeout)
defer cancel()
payload, err := json.Marshal(body)
if err != nil {
return "", err
}
respBody, err := c.doSyncRequestWithRetry(ctx, true, normalizeAnthropicURL(c.apiConfig.BaseURL()), payload)
if err != nil {
return "", err
}
var result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"content"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
fallback := ""
for _, block := range result.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
return strings.TrimSpace(block.Text), nil
}
// 记录 thinking 作为兜底(仅当端点不支持 thinking:disabled 时才会出现)
if block.Type == "thinking" && fallback == "" {
fallback = strings.TrimSpace(block.Thinking)
}
}
if fallback != "" {
return fallback, nil
}
return "", errors.New("模型没有返回压缩摘要")
}
+338
View File
@@ -0,0 +1,338 @@
package agent
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
type SessionStore struct {
mu sync.RWMutex
path string
sessions map[string]*Session
order []string
}
func NewSessionStore(dataDir string) (*SessionStore, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dataDir, "sessions.json")
store := &SessionStore{
path: path,
sessions: make(map[string]*Session),
order: make([]string, 0),
}
_ = store.load()
return store, nil
}
func (s *SessionStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var sessions map[string]*Session
if err := json.Unmarshal(data, &sessions); err != nil {
return err
}
for id, session := range sessions {
if session == nil || session.ID == "" {
continue
}
s.sessions[id] = session
s.order = append(s.order, id)
}
s.sortOrder()
return nil
}
func (s *SessionStore) saveLocked() error {
data, err := json.MarshalIndent(s.sessions, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *SessionStore) List() []SessionSummary {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listLocked("", 0, 0)
}
// Search 按关键词过滤会话(标题或任意消息内容,忽略大小写)。
func (s *SessionStore) Search(query string) []SessionSummary {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listLocked(query, 0, 0)
}
// listLocked 返回会话摘要列表;query 非空时按关键词过滤,limit>0 时截断。
// 必须持读锁调用。
func (s *SessionStore) listLocked(query string, limit, offset int) []SessionSummary {
query = strings.ToLower(strings.TrimSpace(query))
out := make([]SessionSummary, 0, len(s.order))
skip := 0
for _, id := range s.order {
session, ok := s.sessions[id]
if !ok {
continue
}
if query != "" && !sessionMatches(session, query) {
continue
}
if offset > 0 && skip < offset {
skip++
continue
}
if limit > 0 && len(out) >= limit {
break
}
out = append(out, summarize(session))
}
return out
}
func sessionMatches(session *Session, query string) bool {
if strings.Contains(strings.ToLower(session.Title), query) {
return true
}
for _, message := range session.Messages {
if message.Content != nil && strings.Contains(strings.ToLower(*message.Content), query) {
return true
}
for _, call := range message.ToolCalls {
if strings.Contains(strings.ToLower(call.Function.Name), query) ||
strings.Contains(strings.ToLower(call.Function.Arguments), query) {
return true
}
}
}
return false
}
// Messages 分页返回会话消息(按时间正序),offset 从最新一条往前数,
// 即 offset=0 返回最新 limit 条;返回 has_more 表示还有更早的消息。
func (s *SessionStore) Messages(id string, limit, offset int) ([]Message, bool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
if !ok {
return nil, false, false
}
total := len(session.Messages)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
start := total - offset - limit
if start < 0 {
start = 0
}
messages := make([]Message, 0, limit)
for i := start; i < total-offset; i++ {
messages = append(messages, cloneMessage(session.Messages[i]))
}
hasMore := start > 0
return messages, true, hasMore
}
// Clear 清空会话消息(保留会话本身)。
func (s *SessionStore) Clear(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return false
}
session.Messages = make([]Message, 0)
session.UpdatedAt = time.Now()
_ = s.saveLocked()
return true
}
// TotalMessages 返回会话消息总数(用于分页计算)。
func (s *SessionStore) TotalMessages(id string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
if !ok {
return 0, false
}
return len(session.Messages), true
}
func cloneMessage(message Message) Message {
copy := message
if message.Content != nil {
content := *message.Content
copy.Content = &content
}
copy.ToolCalls = append([]ToolCall(nil), message.ToolCalls...)
return copy
}
func (s *SessionStore) Get(id string) (*Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
return session, ok
}
func (s *SessionStore) Create() *Session {
s.mu.Lock()
defer s.mu.Unlock()
session := &Session{
ID: newID(),
Title: "新对话",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Messages: make([]Message, 0),
}
s.sessions[session.ID] = session
s.order = append([]string{session.ID}, s.order...)
_ = s.saveLocked()
return session
}
func (s *SessionStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.sessions[id]; !ok {
return false
}
delete(s.sessions, id)
for i, item := range s.order {
if item == id {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
_ = s.saveLocked()
return true
}
func (s *SessionStore) Save(session *Session) {
session.UpdatedAt = time.Now()
s.mu.Lock()
// 存储克隆而非原对象:会话对象可能仍在 Agent 循环中被修改,
// 直接存指针会导致 saveLocked 序列化时与其他会话的写入产生数据竞争。
s.sessions[session.ID] = cloneSession(session)
s.order = append([]string{session.ID}, removeString(s.order, session.ID)...)
err := s.saveLocked()
s.mu.Unlock()
if err != nil {
// A failed save should not break an in-memory conversation.
return
}
}
func (s *SessionStore) sortOrder() {
sort.SliceStable(s.order, func(i, j int) bool {
a, aOK := s.sessions[s.order[i]]
b, bOK := s.sessions[s.order[j]]
if !aOK || !bOK {
return aOK && !bOK
}
return a.UpdatedAt.After(b.UpdatedAt)
})
}
func summarize(session *Session) SessionSummary {
summary := SessionSummary{
ID: session.ID,
Title: session.Title,
CreatedAt: session.CreatedAt,
UpdatedAt: session.UpdatedAt,
MessageCount: len(session.Messages),
}
for i := len(session.Messages) - 1; i >= 0; i-- {
message := session.Messages[i]
if message.Role != "user" && message.Role != "assistant" {
continue
}
if message.Content != nil {
summary.Preview = truncateRunes(*message.Content, 120)
break
}
}
return summary
}
func newID() string {
buf := make([]byte, 12)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
// cloneSession 深拷贝会话对象,保证持久化对象在存储后不再被外部修改,
// 从而消除 Agent 循环写入与 saveLocked 序列化之间的数据竞争。
func cloneSession(session *Session) *Session {
if session == nil {
return nil
}
clone := *session
clone.Messages = make([]Message, len(session.Messages))
for i, message := range session.Messages {
copy := message
if message.Content != nil {
content := *message.Content
copy.Content = &content
}
copy.ToolCalls = append([]ToolCall(nil), message.ToolCalls...)
clone.Messages[i] = copy
}
return &clone
}
func removeString(items []string, target string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if item != target {
result = append(result, item)
}
}
return result
}
func truncateRunes(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
return value
}
return string(runes[:max]) + "..."
}
func firstRunes(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
func countRunes(value string) int {
return utf8.RuneCountInString(value)
}
+559
View File
@@ -0,0 +1,559 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type Toolset struct {
Workspace string
Timeout time.Duration
MaxOutput int
shell string
shellArgs []string
python string
dockerSocket func() string
apiCfg *APIConfigStore
coop *CoopManager
// sessionRoot 是各会话独立工作目录的根(data/workspaces),
// 每个会话在其下拥有 <sessionRoot>/<sessionID> 目录,互不干扰。
sessionRoot string
}
func NewToolset(cfg Config, dockerCfg *DockerConfigStore, apiCfg *APIConfigStore) *Toolset {
toolset := &Toolset{
Workspace: cfg.Workspace,
Timeout: cfg.ToolTimeout,
MaxOutput: cfg.MaxToolResultChars,
apiCfg: apiCfg,
sessionRoot: filepath.Join(cfg.DataDir, "workspaces"),
}
if dockerCfg != nil {
toolset.dockerSocket = dockerCfg.Socket
} else {
toolset.dockerSocket = defaultDockerSocket
}
if shell, err := exec.LookPath("bash"); err == nil {
toolset.shell = shell
toolset.shellArgs = []string{"-lc"}
} else if shell, err := exec.LookPath("pwsh"); err == nil {
toolset.shell = shell
toolset.shellArgs = []string{"-NoProfile", "-NonInteractive", "-Command"}
} else if shell, err := exec.LookPath("powershell"); err == nil {
toolset.shell = shell
toolset.shellArgs = []string{"-NoProfile", "-NonInteractive", "-Command"}
} else {
toolset.shell = "cmd.exe"
toolset.shellArgs = []string{"/C"}
}
if python, err := exec.LookPath("python"); err == nil {
toolset.python = python
} else if python, err := exec.LookPath("python3"); err == nil {
toolset.python = python
} else {
toolset.python = "python"
}
return toolset
}
func (t *Toolset) Definitions() []ToolDefinition {
return []ToolDefinition{
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "run_bash",
Description: "在用户工作区目录中执行一条 Bash 命令。Windows 上没有 Bash 时自动回退到 PowerShell 或 cmd。适合查看文件、运行构建、安装依赖、搜索代码、启动程序等。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "要执行的完整命令,例如 `ls -la` 或 `go test ./...`。",
},
"timeout_seconds": map[string]any{
"type": "integer",
"description": "命令超时秒数,默认 120 秒。",
},
},
"required": []string{"command"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "read_file",
Description: "读取工作区内的文本文件并返回内容。超长文件会被截断。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "文件路径,可以是相对路径或绝对路径。必须位于工作区内。",
},
},
"required": []string{"path"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "write_file",
Description: "写入或覆盖工作区内的文件。父目录不存在时自动创建。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "目标文件路径,可以是相对路径或绝对路径。必须位于工作区内。",
},
"content": map[string]any{
"type": "string",
"description": "要写入的完整文件内容。",
},
},
"required": []string{"path", "content"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "run_python",
Description: "在工作区目录中执行一段 Python 代码。适合数据处理、批量修改、生成脚本和自动化任务。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"code": map[string]any{
"type": "string",
"description": "要执行的完整 Python 代码。",
},
},
"required": []string{"code"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "list_directory",
Description: "列出工作区内目录的内容,包括文件大小和修改时间。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "目录路径,默认为工作区根目录。",
},
},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_ps",
Description: "列出 Docker 容器。默认连接本地 Docker,可通过 Web 设置中的 Docker Socket 切换到远程 Docker 服务器。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"all": map[string]any{
"type": "boolean",
"description": "是否列出所有容器(包括已停止的),默认 false 只显示运行中。",
},
},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_images",
Description: "列出 Docker 镜像列表。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_logs",
Description: "查看 Docker 容器的最近日志。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
"tail": map[string]any{
"type": "integer",
"description": "返回最近的日志行数,默认 100。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_inspect",
Description: "查看 Docker 容器的详细配置和状态信息。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_exec",
Description: "在 Docker 容器内执行一条命令(使用 sh -c),返回执行输出。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
"command": map[string]any{
"type": "string",
"description": "要在容器内执行的命令,例如 `ls -la` 或 `cat /etc/os-release`。",
},
},
"required": []string{"container", "command"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_start",
Description: "启动一个 Docker 容器。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_stop",
Description: "停止一个 Docker 容器。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
"timeout": map[string]any{
"type": "integer",
"description": "等待优雅停止的秒数,默认由 Docker 决定。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "run_coop",
Description: "调用协作容器异步完成一个任务(容器内单个 agent 独立执行,自动注入当前 Agent 已配置的模型、Base URL、API Key 与接口协议,无需重复填写)。支持三种引擎:engine=pi(默认,pi-coding-agent,支持 openai+anthropic 协议)、engine=pigopigo,支持 openai+anthropic 协议)或 engine=claudeClaude Code,仅支持 anthropic 协议)。需先构建对应镜像(pi-coop / pigo-coop / claude-coop)。该工具是异步的:调用后立即返回任务 ID,容器在后台运行;任务完成(成功 / 失败 / 超时)后系统会自动注入一条【协作任务完成通知】消息(含结构化结果摘要),由你确认结果、必要时提交 flag 并向用户汇报,无需在此等待。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{
"type": "string",
"description": "要交给协作容器完成的任务描述,例如“请为 XX 编写设计文档并实现原型”。解题类任务需写明目标地址、平台提交规则(BENCHMARK_TOKEN / unique_code / submit API)。",
},
"engine": map[string]any{
"type": "string",
"description": "协作引擎:pi(默认,pi-coding-agent)、pigopigo)或 claudeClaude Code,仅支持 anthropic 协议)。pi 与 pigo 支持 openai+anthropic 协议;claude 仅支持 anthropic 协议。",
"enum": []string{"pi", "pigo", "claude"},
},
"round_max": map[string]any{
"type": "integer",
"description": "最大轮次,默认 1(单 agent 一次运行完成,未完成则失败并重新下发)。",
},
"timeout": map[string]any{
"type": "integer",
"description": "单轮超时秒数,默认 1800(30 分钟)。单 agent 需在此时限内完成全部工作并创建 DONE。",
},
"blackboard": map[string]any{
"type": "string",
"description": "可选:主机上的目录路径,挂载到容器 /blackboard 保留黑板产物与会话;Windows 路径会自动转换为 WSL 挂载路径(/mnt/盘符/...)。留空则自动使用本会话工作区下的独立子目录 blackboard/<任务ID>(同一会话多次协作互不污染)。",
},
},
"required": []string{"task"},
},
},
},
}
}
func (t *Toolset) Execute(ctx context.Context, sessionID, name, arguments string) ToolResult {
var params map[string]any
if err := json.Unmarshal([]byte(arguments), &params); err != nil {
return ToolResult{Success: false, Output: "无法解析工具参数: " + err.Error()}
}
switch name {
case "run_bash":
command, _ := params["command"].(string)
if strings.TrimSpace(command) == "" {
return ToolResult{Success: false, Output: "command 不能为空"}
}
return t.runShell(ctx, sessionID, command, intParam(params, "timeout_seconds"))
case "read_file":
path, _ := params["path"].(string)
return t.readFile(sessionID, path)
case "write_file":
path, _ := params["path"].(string)
content, _ := params["content"].(string)
return t.writeFile(sessionID, path, content)
case "run_python":
code, _ := params["code"].(string)
if strings.TrimSpace(code) == "" {
return ToolResult{Success: false, Output: "code 不能为空"}
}
return t.runPython(ctx, sessionID, code)
case "list_directory":
path, _ := params["path"].(string)
return t.listDirectory(sessionID, path)
case "docker_ps":
return t.dockerPS(ctx, params)
case "docker_images":
return t.dockerImages(ctx, params)
case "docker_logs":
return t.dockerLogs(ctx, params)
case "docker_inspect":
return t.dockerInspect(ctx, params)
case "docker_exec":
return t.dockerExec(ctx, params)
case "docker_start":
return t.dockerStart(ctx, params)
case "docker_stop":
return t.dockerStop(ctx, params)
case "run_coop":
return t.runCoop(ctx, sessionID, params)
default:
return ToolResult{Success: false, Output: "未知工具: " + name}
}
}
func (t *Toolset) runShell(ctx context.Context, sessionID, command string, timeoutSeconds int) ToolResult {
// 不信任 LLM 传入的超长 timeout:超过全局工具超时(默认 120s)一律截断为全局值。
// 否则 sleep / 轮询类命令会长时间阻塞主 Agent(持有会话锁),期间前端
// 长时间收不到事件会被判"任务中断",且 coop 完成通知也拿不到锁无法及时处理。
if timeoutSeconds <= 0 || timeoutSeconds > int(t.Timeout.Seconds()) {
timeoutSeconds = int(t.Timeout.Seconds())
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, t.shell, t.shellArgs...)
cmd.Args = append(cmd.Args, command)
cmd.Dir = t.workspaceFor(sessionID)
cmd.Env = append(os.Environ(), "AGENT_WORKSPACE="+t.workspaceFor(sessionID))
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var out strings.Builder
out.WriteString(strings.TrimSpace(stdout.String()))
if stderrText := strings.TrimSpace(stderr.String()); stderrText != "" {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[stderr]\n")
out.WriteString(stderrText)
}
if err != nil {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[命令失败] " + err.Error())
}
return ToolResult{Success: err == nil, Output: t.truncate(out.String())}
}
func (t *Toolset) runPython(ctx context.Context, sessionID, code string) ToolResult {
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, t.python, "-c", code)
cmd.Dir = t.workspaceFor(sessionID)
cmd.Env = append(os.Environ(), "AGENT_WORKSPACE="+t.workspaceFor(sessionID))
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var out strings.Builder
out.WriteString(strings.TrimSpace(stdout.String()))
if stderrText := strings.TrimSpace(stderr.String()); stderrText != "" {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[stderr]\n")
out.WriteString(stderrText)
}
if err != nil {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[运行失败] " + err.Error())
}
return ToolResult{Success: err == nil, Output: t.truncate(out.String())}
}
func (t *Toolset) readFile(sessionID, path string) ToolResult {
absPath, err := t.resolvePath(sessionID, path)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
data, err := os.ReadFile(absPath)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
return ToolResult{Success: true, Output: t.truncate(string(data))}
}
func (t *Toolset) writeFile(sessionID, path, content string) ToolResult {
absPath, err := t.resolvePath(sessionID, path)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
return ToolResult{Success: true, Output: fmt.Sprintf("已写入 %d 字节到 %s", len(content), absPath)}
}
func (t *Toolset) listDirectory(sessionID, path string) ToolResult {
absPath, err := t.resolvePath(sessionID, path)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
entries, err := os.ReadDir(absPath)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var out strings.Builder
for _, entry := range entries {
info, infoErr := entry.Info()
if infoErr != nil {
continue
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
fmt.Fprintf(&out, "%-40s %10d %s\n", name, info.Size(), info.ModTime().Format("2006-01-02 15:04"))
}
if out.Len() == 0 {
return ToolResult{Success: true, Output: "目录为空"}
}
return ToolResult{Success: true, Output: t.truncate(out.String())}
}
// resolvePath 把工具传入的路径解析为绝对路径:
// - 相对路径基于该会话的独立工作目录(data/workspaces/<sessionID>);
// - 绝对路径允许在项目根目录内(协作黑板产物、agent 自身文件等共享内容);
// - 项目根目录之外一律拒绝。
func (t *Toolset) resolvePath(sessionID, raw string) (string, error) {
if strings.TrimSpace(raw) == "" {
raw = "."
}
path := filepath.FromSlash(raw)
if !filepath.IsAbs(path) {
path = filepath.Join(t.workspaceFor(sessionID), path)
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
if !pathWithin(absPath, t.Workspace) {
return "", errors.New("路径超出项目根目录范围,已拒绝: " + absPath)
}
return absPath, nil
}
// workspaceFor 返回某会话的独立工作目录并确保其存在。
func (t *Toolset) workspaceFor(sessionID string) string {
root := t.sessionRoot
if root == "" {
root = t.Workspace
}
dir := filepath.Join(root, sessionID)
_ = os.MkdirAll(dir, 0o755)
return dir
}
// pathWithin 判断 child 是否位于 parent 目录内(含自身)。
func pathWithin(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func (t *Toolset) truncate(value string) string {
runes := []rune(value)
if len(runes) <= t.MaxOutput {
return value
}
return string(runes[:t.MaxOutput]) + "\n...[输出过长,已截断]"
}
func intParam(params map[string]any, key string) int {
switch value := params[key].(type) {
case float64:
return int(value)
case int:
return value
default:
return 0
}
}
+556
View File
@@ -0,0 +1,556 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
)
// 协作镜像名(构建方式见各自目录的 RUN.md)。
const (
coopImagePigo = "pigo-coop" // pigo 协作镜像(支持 openai + anthropic 协议)
coopImage = "pi-coop" // pi 协作镜像(支持 openai + anthropic 协议)
coopImageClaude = "claude-coop" // Claude Code 协作镜像(仅支持 anthropic 协议)
)
// 协作引擎到镜像 / 容器名前缀 / 展示标签 / 运行时目录的映射。
// 三种引擎共享同一套环境变量契约(MODEL/BASE_URL/API_KEY/PROTOCOL/TASK 等),
// supervisor 各自适配,控制层只需选择镜像并做协议约束 + baseURL 规范化。
// runtimeDir 是 local 模式下 supervisor.sh / prompts / extensions 所在的相对目录。
var coopEngines = map[string]struct {
image, namePrefix, label, runtimeDir string
}{
"pigo": {coopImagePigo, "pigo-coop-", "pigo", "pigo/coop"},
"pi": {coopImage, "pi-coop-", "pi", "pi-coop"},
"claude": {coopImageClaude, "claude-coop-", "claude code", "claude code"},
}
// codePattern 从任务描述中提取题目编号(如 "c-06" / "a-05" / "e1-01")。
var codePattern = regexp.MustCompile(`[A-Za-z0-9]{1,8}-\d{1,4}`)
// runCoop 异步启动 pi 协作任务:
// 校验参数与镜像后立即返回,容器创建 / 运行 / 等待 / 清理全部放到后台 goroutine。
// 任务结束后由 CoopManager 通知 Agent,主 agent 会自动收到一条
// "【协作任务完成通知】"消息并汇报结果,无需在前端长时间等待。
func (t *Toolset) runCoop(ctx context.Context, sessionID string, params map[string]any) ToolResult {
if t.coop == nil {
return ToolResult{Success: false, Output: "协作任务管理器未初始化"}
}
task, _ := params["task"].(string)
task = strings.TrimSpace(task)
if task == "" {
return ToolResult{Success: false, Output: "task 不能为空"}
}
// 完成协议:约束协作 agent 主动收尾,避免"已提交成功却没写 DONE 被强杀"、
// "解不出却空耗到超时"两类问题。supervisor 只在黑板根目录出现 DONE 标记时正常退出。
completionProtocol := `
【完成协议(务必严格遵守,决定协作能否正常收尾)】
- 找到 flag 并提交成功(平台响应 correct=true)后:用 blackboard 工具 action=done,在黑板根目录创建 DONE 标记,内容写入完成摘要(含 flag 值、提交响应、解题路径)。supervisor 检测到 DONE 即正常结束(exit 0);不创建 DONE 会空耗到超时被强杀,协作被视为失败。
- 若经充分尝试后确认本轮无法解出(目标不可达 / 无漏洞 / 试错过多):同样用 action=done 创建 DONE,内容开头写明「未解出」与已尝试内容,让调度方及时关闭靶机并切换下一题,不要空耗到超时。
- 已通关题目不要重复提交:平台对已通关题目的后续提交统一返回 correct:false(而非 duplicate),属正常现象、不影响已得分数,不要误判为失败。`
task += completionProtocol
// 从任务描述解析题目编号(如 "c-06"),供完成通知携带,便于控制层关靶机/切题
challengeCode := ""
if m := codePattern.FindString(task); m != "" {
challengeCode = m
}
roundMax := intParam(params, "round_max")
if roundMax <= 0 {
// 单 agent 默认 1 轮:一次运行完成全部工作,未完成则失败并重新下发
roundMax = 1
}
if roundMax > 30 {
roundMax = 30
}
timeoutSec := intParam(params, "timeout")
if timeoutSec <= 0 {
// 默认 1800s600s/900s 对需要写脚本+多步探测的渗透/解题任务偏紧,
// 实测多因单轮超时(exit 143)导致协作失败。
timeoutSec = 1800
}
if t.apiCfg == nil {
return ToolResult{Success: false, Output: "缺少 LLM API 配置,无法注入模型配置"}
}
// 解析协作引擎:engine=pi(默认)/ pigo / claude。
// 未显式指定时使用设置页配置的默认引擎(apiCfg.Engine())。
// 三种镜像共享同一套环境变量契约(MODEL/BASE_URL/API_KEY/PROTOCOL/TASK 等),
// supervisor 各自适配,控制层只需选择镜像并做协议约束 + baseURL 规范化。
engine, _ := params["engine"].(string)
engine = strings.TrimSpace(strings.ToLower(engine))
if engine == "" {
engine = t.apiCfg.Engine()
}
eng, ok := coopEngines[engine]
if !ok {
return ToolResult{Success: false, Output: "不支持的 engine \"" + engine + "\",可选值:pigo | pi(默认)| claude"}
}
model := t.apiCfg.Model()
apiKey := t.apiCfg.APIKey()
provider := t.apiCfg.Provider()
// coopBaseURL 根据 engine 规范化 baseURL
// pigo 的 anthropicCompatDriver 只追加 /messages,需补 /v1
// pi/claude 用官方 SDK(自带 /v1/messages),需剥离 /v1 避免双重路径。
baseURL := coopBaseURL(provider, t.apiCfg.BaseURL(), engine)
if model == "" || apiKey == "" || baseURL == "" {
return ToolResult{Success: false, Output: "LLM API 配置不完整(model / base_url / api_key 缺一不可),请先在设置页配置"}
}
// Claude Code 仅支持 Anthropic 协议端点(ANTHROPIC_BASE_URL),
// 若配置为 openai 协议则直接拒绝,避免容器启动后才报错。
if engine == "claude" && provider != ProviderAnthropic {
return ToolResult{Success: false, Output: "claude-coop 仅支持 Anthropic 协议端点,当前 provider 为 " + provider +
"。请改用 engine=pi 或 engine=pigo,或将 API 配置切换为 anthropic 协议(如 DeepSeek 的 /anthropic 端点)。"}
}
env := []string{
"MODEL=" + model,
"BASE_URL=" + baseURL,
"API_KEY=" + apiKey,
"PROTOCOL=" + provider,
"TASK=" + task,
fmt.Sprintf("ROUND_MAX=%d", roundMax),
fmt.Sprintf("TIMEOUT=%d", timeoutSec),
}
// 生成任务 ID(也用作容器名与默认黑板子目录)
taskID := newID()
// 黑板目录:用户显式指定 blackboard 时使用指定路径;
// 未指定时为该任务分配独立的会话工作区子目录(data/workspaces/<sessionID>/blackboard/<taskID>),
// 保证同一会话发起的多个 coop 任务互相隔离、互不污染。
blackboardDir := ""
if dirParam, _ := params["blackboard"].(string); strings.TrimSpace(dirParam) != "" {
dirParam = strings.TrimSpace(dirParam)
abs, err := filepath.Abs(dirParam)
if err != nil {
return ToolResult{Success: false, Output: "blackboard 路径无效: " + err.Error()}
}
// 安全约束:blackboard 会被 worker 进程/容器读写,必须限制在项目根目录内,
// 否则 LLM 可通过指定任意主机目录(如 ~/.ssh)让 worker 读写敏感文件。
if !pathWithin(abs, t.Workspace) {
return ToolResult{Success: false, Output: "blackboard 路径超出项目根目录范围,已拒绝: " + abs}
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return ToolResult{Success: false, Output: "创建 blackboard 目录失败: " + err.Error()}
}
blackboardDir = abs
} else {
dir := filepath.Join(t.workspaceFor(sessionID), "blackboard", taskID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return ToolResult{Success: false, Output: "创建 blackboard 目录失败: " + err.Error()}
}
blackboardDir = dir
}
record := &CoopTask{
ID: taskID,
SessionID: sessionID,
Status: "running",
Blackboard: blackboardDir,
RoundMax: roundMax,
CreatedAt: time.Now(),
ChallengeCode: challengeCode,
}
t.coop.Register(record)
mode := t.apiCfg.CoopMode()
log.Printf("[coop] 协作任务启动 task=%s session=%s mode=%s engine=%s challenge=%s round_max=%d timeout=%ds blackboard=%s",
record.ID, sessionID, mode, engine, challengeCode, roundMax, timeoutSec, blackboardDir)
overallSec := timeoutSec*roundMax + 300
if mode == CoopModeLocal {
// 本地子进程模式:无需 Docker,直接 exec supervisor.sh
supervisor, promptsDir, extensionsDir, err := findCoopRuntime(engine)
if err != nil {
t.coop.Complete(t.failedTask(record, err))
return ToolResult{Success: false, Output: err.Error()}
}
go t.runCoopLocalAsync(record, env, blackboardDir, supervisor, promptsDir, extensionsDir, roundMax, timeoutSec, eng.label)
} else {
// Docker 容器模式:现有逻辑
var mounts []mount.Mount
cli, err := t.dockerClient()
if err != nil {
t.coop.Complete(t.failedTask(record, err))
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
// 判断目标 daemon 系统类型:远程 Linux daemon(如 WSL)的 bind 挂载
// 只认容器侧路径,Windows 路径需转换为 /mnt/<盘符>/... 形式。
info, infoErr := cli.Info(ctx)
linuxDaemon := infoErr == nil && info.OSType == "linux"
// 检查镜像是否存在(同步快速失败,避免后台任务因镜像缺失白跑)
checkCtx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
if _, err := cli.ImageInspect(checkCtx, eng.image); err != nil {
t.coop.Complete(t.failedTask(record, fmt.Errorf("未找到镜像 %s", eng.image)))
return ToolResult{Success: false, Output: "未找到镜像 " + eng.image + "。请先在仓库根构建:\n" +
coopBuildHint(engine) + "\n(详见对应目录的 RUN.md"}
}
// 容器内以非 root 用户(uid=1000 agent)运行,主机目录必须对任何用户可写,
// 否则 supervisor 初始化(mkdir/写 task.md/复制 AGENTS.md)会失败。
_ = os.Chmod(blackboardDir, 0o777)
source := blackboardDir
if linuxDaemon {
source = wslBindPath(blackboardDir)
}
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: source,
Target: "/blackboard",
})
// 容器创建 / 启动 / 等待 / 清理放到后台,并使用独立上下文,
// 避免阻塞当前 SSE 流(此前同步等待最长可达 timeout×round_max+300 秒)。
go t.runCoopAsync(record, env, mounts, roundMax, timeoutSec, eng.image, eng.namePrefix)
}
return ToolResult{Success: true, Output: fmt.Sprintf(
"协作任务已在后台启动,任务 ID:%s。\n运行方式:%s 协作单 Agent(%s 模式),最多 %d 轮,整体上限约 %d 分钟。\n"+
"你无需在此等待,可以继续处理其他请求;任务完成后系统会自动通知你并汇报结果。",
record.ID, eng.label, mode, roundMax, overallSec/60)}
}
// runCoopAsync 在后台完成协作容器的创建、启动、等待、日志收集与清理。
// 结束(成功 / 失败 / 超时)后通过 coop.Complete 通知 Agent 唤醒主 agent。
// image / namePrefix 由 runCoop 根据 engine 选择(pi-coop 或 claude-coop)。
func (t *Toolset) runCoopAsync(record *CoopTask, env []string, mounts []mount.Mount, roundMax, timeoutSec int, image, namePrefix string) {
// 使用独立后台上下文,避免随 SSE 请求断开而中断协作任务
ctx := context.Background()
cli, err := t.dockerClient()
if err != nil {
t.coop.Complete(t.failedTask(record, err))
return
}
defer cli.Close()
name := namePrefix + record.ID[:8]
created, err := cli.ContainerCreate(ctx, &container.Config{
Image: image,
Env: env,
}, &container.HostConfig{
Mounts: mounts,
}, nil, nil, name)
if err != nil {
t.coop.Complete(t.failedTask(record, fmt.Errorf("创建容器失败: %w", err)))
return
}
record.ContainerID = created.ID
// 运行结束后无论如何清理容器(等价 docker run --rm
cleanup := func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = cli.ContainerRemove(cleanupCtx, created.ID, container.RemoveOptions{Force: true})
}
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
cleanup()
t.coop.Complete(t.failedTask(record, fmt.Errorf("启动容器失败: %w", err)))
return
}
// 等待容器退出;整体超时上限 = 轮次 × 单轮超时 + 缓冲
overall := time.Duration(timeoutSec*roundMax+300) * time.Second
waitCtx, waitCancel := context.WithTimeout(ctx, overall)
defer waitCancel()
waitCh, errCh := cli.ContainerWait(waitCtx, created.ID, container.WaitConditionNotRunning)
exitCode := -1
select {
case res := <-waitCh:
exitCode = int(res.StatusCode)
case err := <-errCh:
cleanup()
t.coop.Complete(t.failedTask(record, fmt.Errorf("等待容器退出失败: %w", err)))
return
case <-waitCtx.Done():
cleanup()
t.coop.Complete(t.failedTask(record, fmt.Errorf(
"协作运行超时(整体上限 %d 秒,约 %.0f 分钟),已强制清理容器。可增大 round_max / timeout 或缩小任务规模后重试。",
int(overall.Seconds()), overall.Minutes())))
return
}
// 读取容器日志(supervisor 输出 + DONE 总结)
logCtx, logCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer logCancel()
result := ""
if logs, err := cli.ContainerLogs(logCtx, created.ID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
}); err == nil {
if raw, readErr := io.ReadAll(logs); readErr == nil {
result = demuxDockerLogs(raw)
_ = logs.Close()
}
}
result = strings.TrimSpace(result)
switch exitCode {
case 0:
result += "\n[协作完成,退出码 0]"
case 1:
result += "\n[达到最大轮次仍未完成,退出码 1;可增大 round_max 后重试]"
default:
result += fmt.Sprintf("\n[容器退出码 %d,请检查上方日志]", exitCode)
}
if len(mounts) > 0 {
result += "\n黑板产物已保留在: " + mounts[0].Source
}
cleanup()
record.ExitCode = exitCode
record.Output = t.truncate(result)
// 解析 supervisor 生成的结构化结果 result.json(如有):
// 完成通知与汇报轮优先使用 Result 字段,容器 stdout 仅作兜底。
status := "unknown"
if data, rerr := os.ReadFile(filepath.Join(record.Blackboard, "result.json")); rerr == nil {
var res CoopResult
if json.Unmarshal(data, &res) == nil {
record.Result = &res
status = res.Status
}
}
log.Printf("[coop] 协作任务结束 task=%s session=%s exit_code=%d status=%s", record.ID, record.SessionID, exitCode, status)
t.coop.Complete(record)
}
// failedTask 生成一个失败任务记录,供 runCoopAsync 失败路径统一上报。
func (t *Toolset) failedTask(record *CoopTask, err error) *CoopTask {
record.Error = err.Error()
record.Output = "协作任务失败: " + err.Error()
return record
}
// coopBuildHint 返回指定 engine 对应镜像的构建命令提示。
func coopBuildHint(engine string) string {
switch engine {
case "pigo":
// pigo-coop 构建上下文是 pigo/ 目录,且需先交叉编译 pigo 二进制
return "cd pigo && GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags=\"-s -w\" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo && docker build -f coop/Dockerfile -t pigo-coop ."
case "claude":
return "docker build -f \"claude code/Dockerfile\" -t claude-coop \"claude code/\""
default:
return "docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/"
}
}
// coopBaseURL 把当前 Agent 配置的 Base URL 规范化为协作容器期望的"基础地址"。
// 不同 engine 的模型接入层对 baseURL 的路径处理不同,需按 engine 区分:
// - pigoanthropicCompatDriver 只追加 /messages(不含 /v1),因此 anthropic
// 端点需补 /v1(最终 /v1/messages);openai 端点需保留 /v1。
// - piprovider 使用官方 SDK@anthropic-ai/sdk / openai),SDK 自行追加完整
// 路径(anthropic: /v1/messagesopenai: /chat/completions),不能再补 /v1
// 否则产生 /v1/v1/messages 双重路径导致 404。
// - claude:同 pi,用 @anthropic-ai/sdk,不能补 /v1。
//
// 对用户误填的完整路径后缀(/v1/messages、/chat/completions 等)统一剥离,
// 再按 engine + provider 决定是否补 /v1。
func coopBaseURL(provider, baseURL, engine string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
// 先剥离用户误填的完整路径后缀,统一回退到"基础地址"
switch {
case strings.HasSuffix(base, "/v1/chat/completions"):
base = strings.TrimSuffix(base, "/chat/completions")
case strings.HasSuffix(base, "/chat/completions"):
base = strings.TrimSuffix(base, "/chat/completions")
case strings.HasSuffix(base, "/v1/messages"):
base = strings.TrimSuffix(base, "/v1/messages")
case strings.HasSuffix(base, "/messages"):
base = strings.TrimSuffix(base, "/messages")
}
if provider != ProviderAnthropic {
return base
}
// Anthropic 协议端点处理
switch engine {
case "pigo":
// pigo 的 anthropicCompatDriver 只追加 /messages,需补 /v1。
// 若用户已填 /v1 结尾则保持,否则补上。
if strings.HasSuffix(base, "/v1") {
return base
}
return base + "/v1"
default:
// pi / claude 用 @anthropic-ai/sdkSDK 自带 /v1/messages
// 不能补 /v1;若用户已填 /v1 则剥离(SDK 会补回完整的 /v1/messages)。
if strings.HasSuffix(base, "/v1") {
return strings.TrimSuffix(base, "/v1")
}
return base
}
}
// wslBindPath 把 Windows 绝对路径转换为 WSL 挂载路径(E:\path → /mnt/e/path),
// 供运行在 WSL 内的 Linux Docker daemon 做 bind 挂载。
func wslBindPath(path string) string {
if len(path) < 2 || path[1] != ':' {
return path
}
drive := strings.ToLower(path[:1])
rest := strings.ReplaceAll(path[2:], "\\", "/")
return "/mnt/" + drive + rest
}
// findCoopRuntime 查找指定 engine 的 supervisor.sh / prompts / extensions 目录。
// 查找顺序:环境变量 COOP_DIR > 相对于可执行文件 > 相对于工作目录。
// 返回的路径均为绝对路径,供本地子进程模式(runCoopLocalAsync)使用。
func findCoopRuntime(engine string) (supervisor, prompts, extensions string, err error) {
eng, ok := coopEngines[engine]
if !ok {
return "", "", "", fmt.Errorf("不支持的 engine: %s", engine)
}
dirName := eng.runtimeDir
// 候选基目录列表:COOP_DIR 环境变量 > 可执行文件同级/上级 > 当前工作目录
var candidates []string
if envDir := os.Getenv("COOP_DIR"); envDir != "" {
candidates = append(candidates, filepath.Join(envDir, dirName))
}
if exe, exeErr := os.Executable(); exeErr == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates, filepath.Join(exeDir, dirName))
candidates = append(candidates, filepath.Join(exeDir, "..", dirName))
}
if wd, wdErr := os.Getwd(); wdErr == nil {
candidates = append(candidates, filepath.Join(wd, dirName))
}
for _, base := range candidates {
sp := filepath.Join(base, "supervisor.sh")
if st, statErr := os.Stat(sp); statErr == nil && !st.IsDir() {
pp := filepath.Join(base, "prompts")
ep := filepath.Join(base, "extensions")
// prompts / extensions 可选:缺失时传空串,supervisor 用内置默认
return sp, pp, ep, nil
}
}
return "", "", "", fmt.Errorf(
"本地模式未找到 %s 的 supervisor.sh,已查找目录: %v\n"+
"请确保 %s 目录存在且包含 supervisor.sh,或设置 COOP_DIR 环境变量指向包含该目录的父目录",
engine, candidates, dirName)
}
// runCoopLocalAsync 在后台以本地子进程方式运行 supervisor.sh 完成 worker 任务。
// 与 runCoopAsyncDocker 模式)对应:无需 Docker daemon,直接 exec supervisor.sh
// 通过 BLACKBOARD/PROMPTS/EXTENSIONS 环境变量指向本地路径。
// 结束(成功 / 失败 / 超时)后通过 coop.Complete 通知 Agent。
func (t *Toolset) runCoopLocalAsync(record *CoopTask, env []string, blackboardDir, supervisor, promptsDir, extensionsDir string, roundMax, timeoutSec int, label string) {
// 使用独立后台上下文,避免随 SSE 请求断开而中断协作任务
ctx := context.Background()
overall := time.Duration(timeoutSec*roundMax+300) * time.Second
runCtx, runCancel := context.WithTimeout(ctx, overall)
defer runCancel()
// Windows 上 bash 通常是 WSL bash,不认反斜杠路径(E:\foo → E:foo 被吞)。
// 需把传给 bash 的路径转为 /mnt/<盘符>/... 格式;Go 侧文件操作仍用原始路径。
toBashPath := func(p string) string {
if len(p) >= 2 && p[1] == ':' {
return wslBindPath(p)
}
return p
}
bashSupervisor := toBashPath(supervisor)
bashBlackboard := toBashPath(blackboardDir)
bashPrompts := toBashPath(promptsDir)
bashExtensions := toBashPath(extensionsDir)
// 构建子进程环境:继承父进程环境(PATH 等)+ 注入协作环境变量
procEnv := os.Environ()
procEnv = append(procEnv, env...)
procEnv = append(procEnv, "BLACKBOARD="+bashBlackboard)
if bashPrompts != "" {
procEnv = append(procEnv, "PROMPTS="+bashPrompts)
}
if bashExtensions != "" {
procEnv = append(procEnv, "EXTENSIONS="+bashExtensions)
}
cmd := exec.CommandContext(runCtx, "bash", bashSupervisor)
cmd.Env = procEnv
// stdout+stderr 合并捕获(supervisor 的日志输出)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
log.Printf("[coop] 本地协作进程启动 task=%s pid=pending blackboard=%s supervisor=%s",
record.ID, bashBlackboard, bashSupervisor)
if err := cmd.Start(); err != nil {
t.coop.Complete(t.failedTask(record, fmt.Errorf("启动 supervisor 失败: %w", err)))
return
}
record.ProcessID = cmd.Process.Pid
log.Printf("[coop] 本地协作进程已启动 task=%s pid=%d", record.ID, record.ProcessID)
// 等待进程退出(exec.CommandContext 在 runCtx 超时时自动发送 SIGKILL
waitErr := cmd.Wait()
exitCode := 0
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
// 判断是否因超时被杀
timedOut := runCtx.Err() == context.DeadlineExceeded
result := strings.TrimSpace(buf.String())
switch {
case timedOut:
result += fmt.Sprintf("\n[协作运行超时(整体上限 %d 秒,约 %.0f 分钟),已强制终止进程]",
int(overall.Seconds()), overall.Minutes())
exitCode = 124
case exitCode == 0:
result += "\n[协作完成,退出码 0]"
case exitCode == 1:
result += "\n[达到最大轮次仍未完成,退出码 1;可增大 round_max 后重试]"
default:
result += fmt.Sprintf("\n[进程退出码 %d,请检查上方日志]", exitCode)
}
result += "\n黑板产物已保留在: " + blackboardDir
record.ExitCode = exitCode
record.Output = t.truncate(result)
// 解析 supervisor 生成的结构化结果 result.json(与 docker 模式一致)
status := "unknown"
if data, rerr := os.ReadFile(filepath.Join(blackboardDir, "result.json")); rerr == nil {
var res CoopResult
if json.Unmarshal(data, &res) == nil {
record.Result = &res
status = res.Status
}
}
log.Printf("[coop] 本地协作任务结束 task=%s pid=%d exit_code=%d status=%s",
record.ID, record.ProcessID, exitCode, status)
t.coop.Complete(record)
}
+320
View File
@@ -0,0 +1,320 @@
package agent
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
)
// Docker 工具:通过 Docker API 操作本地或远程 Docker。
// socket 地址通过 DockerConfigStore 动态读取,用户在 Web 页面配置后即时生效。
func (t *Toolset) dockerClient() (*client.Client, error) {
socket := t.dockerSocket()
cli, err := client.NewClientWithOpts(
client.WithHost(socket),
client.WithAPIVersionNegotiation(),
)
if err != nil {
return nil, fmt.Errorf("创建 Docker 客户端失败: %w", err)
}
return cli, nil
}
func (t *Toolset) dockerPS(ctx context.Context, params map[string]any) ToolResult {
all, _ := params["all"].(bool)
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
list, err := cli.ContainerList(ctx, container.ListOptions{All: all})
if err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
if len(list) == 0 {
return ToolResult{Success: true, Output: "没有找到容器"}
}
sort.Slice(list, func(i, j int) bool {
return list[i].Created > list[j].Created
})
var out strings.Builder
fmt.Fprintf(&out, "%-14s %-26s %-20s %-12s %s\n", "CONTAINER ID", "NAME", "IMAGE", "STATUS", "PORTS")
for _, c := range list {
name := strings.TrimPrefix(strings.Join(c.Names, ","), "/")
fmt.Fprintf(&out, "%-14s %-26s %-20s %-12s %s\n",
c.ID[:min(12, len(c.ID))], name, c.Image, c.Status, dockerPorts(c.Ports))
}
return ToolResult{Success: true, Output: t.truncate(out.String())}
}
func (t *Toolset) dockerImages(ctx context.Context, params map[string]any) ToolResult {
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
list, err := cli.ImageList(ctx, image.ListOptions{All: true})
if err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
if len(list) == 0 {
return ToolResult{Success: true, Output: "没有找到镜像"}
}
sort.Slice(list, func(i, j int) bool {
return list[i].Created > list[j].Created
})
var out strings.Builder
fmt.Fprintf(&out, "%-22s %-28s %-14s %s\n", "REPOSITORY", "TAG", "IMAGE ID", "SIZE")
for _, img := range list {
if len(img.RepoTags) == 0 {
continue
}
for _, tag := range img.RepoTags {
repo, imageTag, _ := strings.Cut(tag, ":")
id := img.ID
if len(id) > 12 {
id = id[7:19]
}
fmt.Fprintf(&out, "%-22s %-28s %-14s %s\n", repo, imageTag, id, formatBytes(img.Size))
}
}
return ToolResult{Success: true, Output: t.truncate(out.String())}
}
func (t *Toolset) dockerLogs(ctx context.Context, params map[string]any) ToolResult {
containerID, _ := params["container"].(string)
containerID = strings.TrimSpace(containerID)
if containerID == "" {
return ToolResult{Success: false, Output: "container 不能为空"}
}
tail := intParam(params, "tail")
if tail <= 0 {
tail = 100
}
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
resp, err := cli.ContainerLogs(ctx, containerID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: strconv.Itoa(tail),
})
if err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
defer resp.Close()
raw, err := io.ReadAll(resp)
if err != nil {
return ToolResult{Success: false, Output: "读取 Docker 日志失败: " + err.Error()}
}
return ToolResult{Success: true, Output: t.truncate(demuxDockerLogs(raw))}
}
// demuxDockerLogs 解析 Docker 日志流:多路复用流(stdout/stderr 分帧)按帧解出,
// 普通文本流(TTY 容器)直接返回。
func demuxDockerLogs(data []byte) string {
if len(data) >= 8 && isMultiplexedFrame(data[0:8]) {
var out strings.Builder
for i := 0; i+8 <= len(data); {
frameSize := int(binary.BigEndian.Uint32(data[i+4 : i+8]))
i += 8
if frameSize < 0 || i+frameSize > len(data) {
break
}
out.Write(data[i : i+frameSize])
i += frameSize
}
return out.String()
}
return string(data)
}
func isMultiplexedFrame(header []byte) bool {
streamType := header[0]
if streamType != 0 && streamType != 1 && streamType != 2 {
return false
}
return header[1] == 0 && header[2] == 0 && header[3] == 0
}
func (t *Toolset) dockerInspect(ctx context.Context, params map[string]any) ToolResult {
containerID, _ := params["container"].(string)
containerID = strings.TrimSpace(containerID)
if containerID == "" {
return ToolResult{Success: false, Output: "container 不能为空"}
}
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
inspect, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
state := inspect.State
if state == nil {
state = &container.State{}
}
summary := map[string]any{
"id": inspect.ID,
"name": strings.TrimPrefix(inspect.Name, "/"),
"image": inspect.Config.Image,
"status": state.Status,
"running": state.Running,
"exit_code": state.ExitCode,
"restart_count": inspect.RestartCount,
"created": inspect.Created,
"command": inspect.Config.Cmd,
"env_count": len(inspect.Config.Env),
"ports": inspect.NetworkSettings.Ports,
"network_mode": string(inspect.HostConfig.NetworkMode),
"ip_address": inspect.NetworkSettings.IPAddress,
}
data, _ := json.MarshalIndent(summary, "", " ")
return ToolResult{Success: true, Output: t.truncate(string(data))}
}
func (t *Toolset) dockerExec(ctx context.Context, params map[string]any) ToolResult {
containerID, _ := params["container"].(string)
containerID = strings.TrimSpace(containerID)
command, _ := params["command"].(string)
command = strings.TrimSpace(command)
if containerID == "" || command == "" {
return ToolResult{Success: false, Output: "container 和 command 不能为空"}
}
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
execCfg := container.ExecOptions{
AttachStdout: true,
AttachStderr: true,
Cmd: []string{"sh", "-c", command},
}
created, err := cli.ContainerExecCreate(ctx, containerID, execCfg)
if err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
attach, err := cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
if err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
defer attach.Close()
raw, err := io.ReadAll(attach.Reader)
if err != nil {
return ToolResult{Success: false, Output: "读取命令输出失败: " + err.Error()}
}
return ToolResult{Success: true, Output: t.truncate(demuxDockerLogs(raw))}
}
func (t *Toolset) dockerStart(ctx context.Context, params map[string]any) ToolResult {
containerID, _ := params["container"].(string)
containerID = strings.TrimSpace(containerID)
if containerID == "" {
return ToolResult{Success: false, Output: "container 不能为空"}
}
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
if err := cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
return ToolResult{Success: true, Output: "容器 " + containerID + " 已启动"}
}
func (t *Toolset) dockerStop(ctx context.Context, params map[string]any) ToolResult {
containerID, _ := params["container"].(string)
containerID = strings.TrimSpace(containerID)
if containerID == "" {
return ToolResult{Success: false, Output: "container 不能为空"}
}
timeoutSeconds := intParam(params, "timeout")
cli, err := t.dockerClient()
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
var timeout *int
if timeoutSeconds > 0 {
timeout = &timeoutSeconds
}
if err := cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: timeout}); err != nil {
return ToolResult{Success: false, Output: "Docker 操作失败: " + err.Error()}
}
return ToolResult{Success: true, Output: "容器 " + containerID + " 已停止"}
}
func dockerPorts(ports []container.Port) string {
if len(ports) == 0 {
return ""
}
var parts []string
for _, p := range ports {
host := ""
if p.PublicPort > 0 {
host = fmt.Sprintf("%d->", p.PublicPort)
}
parts = append(parts, fmt.Sprintf("%s%d/%s", host, p.PrivatePort, p.Type))
}
return strings.Join(parts, ", ")
}
func formatBytes(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%dB", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%cB", float64(size)/float64(div), "KMGTPE"[exp])
}
+76
View File
@@ -0,0 +1,76 @@
package agent
import (
"path/filepath"
"runtime"
"testing"
)
// TestPathWithin 验证路径越界校验:read_file / write_file / run_coop 的 blackboard
// 都依赖该函数拦截指向项目根目录之外的路径,是核心安全边界。
func TestPathWithin(t *testing.T) {
var parent string
if runtime.GOOS == "windows" {
parent = `e:\proj`
} else {
parent = "/proj"
}
cases := []struct {
name string
child string
expect bool
}{
{"自身", parent, true},
{"直接子文件", filepath.Join(parent, "a.txt"), true},
{"嵌套子目录", filepath.Join(parent, "sub", "deep", "f.txt"), true},
{"上级目录", filepath.Join(parent, "..", "secret"), false},
{"同级兄弟目录", filepath.Join(filepath.Dir(parent), "other"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := pathWithin(tc.child, parent); got != tc.expect {
t.Fatalf("pathWithin(%q, %q) = %v, want %v", tc.child, parent, got, tc.expect)
}
})
}
}
// TestMergeToolCall 验证 SSE 流式 tool_call 增量合并:
// 跨多个 chunk 按 index 聚合 id / name / arguments,是 LLM 工具调用协议正确性的关键。
func TestMergeToolCall(t *testing.T) {
var calls []ToolCall
// 第一个 chunk:声明 index 0 的调用(id + name
calls = mergeToolCall(calls, ToolCallDelta{Index: 0, ID: "call_1", Name: "run_bash"})
// 同一调用的参数分片到达
calls = mergeToolCall(calls, ToolCallDelta{Index: 0, ArgumentsDelta: `{"comm`})
calls = mergeToolCall(calls, ToolCallDelta{Index: 0, ArgumentsDelta: `and":"ls"}`})
if len(calls) != 1 {
t.Fatalf("应合并为 1 个调用,got %d", len(calls))
}
c := calls[0]
if c.ID != "call_1" || c.Function.Name != "run_bash" {
t.Fatalf("id/name 不匹配: %+v", c)
}
if c.Function.Arguments != `{"command":"ls"}` {
t.Fatalf("arguments 拼接错误: %q", c.Function.Arguments)
}
// 第二个调用在 index 1
calls = mergeToolCall(calls, ToolCallDelta{Index: 1, ID: "call_2", Name: "read_file"})
calls = mergeToolCall(calls, ToolCallDelta{Index: 1, ArgumentsDelta: `{"path":"a"}`})
if len(calls) != 2 {
t.Fatalf("应有 2 个调用,got %d", len(calls))
}
if calls[1].Function.Name != "read_file" {
t.Fatalf("第二个调用名错误: %+v", calls[1])
}
// index 为负时回退为追加新调用
prev := len(calls)
calls = mergeToolCall(calls, ToolCallDelta{Index: -1, ID: "call_3", Name: "list_directory"})
if len(calls) != prev+1 {
t.Fatalf("负 index 应追加新调用,got %d", len(calls))
}
}
+60
View File
@@ -0,0 +1,60 @@
package agent
import "time"
type Message struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolFunctionCall `json:"function"`
}
type ToolFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type Session struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Messages []Message `json:"messages"`
}
type SessionSummary struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageCount int `json:"message_count"`
Preview string `json:"preview"`
}
type ToolDefinition struct {
Type string `json:"type"`
Function ToolDefinitionFunction `json:"function"`
}
type ToolDefinitionFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
type ToolResult struct {
Success bool `json:"success"`
Output string `json:"output"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
}
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// authCookieName 保存已通过授权校验的凭证。值为授权码的 SHA-256 十六进制摘要,
// 不落明文;HttpOnly 使前端 JS 无法读取,降低泄露面。
const authCookieName = "blackbean_auth"
// authCookieValue 计算授权码的稳定凭证值(SHA-256 摘要)。
func authCookieValue(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// authRequired 是访问授权中间件:cfg.AuthCode 为空(未启用授权)时直接放行;
// 否则要求请求携带与授权码匹配的 Cookie,不匹配则 401 并中止后续处理。
func (s *Server) authRequired(c *gin.Context) {
if s.cfg.AuthCode == "" {
c.Next()
return
}
if cookie, err := c.Cookie(authCookieName); err == nil && cookie == authCookieValue(s.cfg.AuthCode) {
c.Next()
return
}
fail(c, http.StatusUnauthorized, "需要授权码才能访问,请先在首页输入授权码")
c.Abort()
}
// authStatus 返回授权状态:required=是否启用了授权,authorized=当前请求是否已通过授权。
// 该接口始终放行(不挂授权中间件),前端据此决定是否展示授权界面。
func (s *Server) authStatus(c *gin.Context) {
required := s.cfg.AuthCode != ""
authorized := false
if required {
if cookie, err := c.Cookie(authCookieName); err == nil {
authorized = cookie == authCookieValue(s.cfg.AuthCode)
}
}
ok(c, gin.H{"required": required, "authorized": authorized})
}
// authLogin 校验授权码并签发 Cookie。
// 该接口始终放行(不挂授权中间件);未启用授权时返回 400。
func (s *Server) authLogin(c *gin.Context) {
if s.cfg.AuthCode == "" {
fail(c, http.StatusBadRequest, "本实例未启用访问授权")
return
}
var req struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求体格式错误")
return
}
// 常量时间比较,避免通过响应时差探测授权码
want := []byte(s.cfg.AuthCode)
got := []byte(strings.TrimSpace(req.Code))
if len(want) != len(got) || subtle.ConstantTimeCompare(want, got) != 1 {
fail(c, http.StatusUnauthorized, "授权码错误")
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: authCookieName,
Value: authCookieValue(s.cfg.AuthCode),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
ok(c, gin.H{"authorized": true})
}
+45
View File
@@ -0,0 +1,45 @@
package server
import (
"context"
"log"
"agent/internal/agent"
)
// benchmarkInitialTask 是托管模式启动时发给 Agent 的初始任务消息。
// 系统提示中已包含完整的 Tsecbench 调度规则(并发管理、flag 提交、靶机开关),
// 此处只需一条简短触发指令让 Agent 开始拉取题目并解题。
const benchmarkInitialTask = `Tsecbench 托管评测已启动。平台已通过环境变量注入 BENCHMARK_TOKEN 和 BENCHMARK_BASE_URL。
请立即开始评测:
1. 用 shell 工具读取环境变量:echo $BENCHMARK_BASE_URL && echo $BENCHMARK_TOKEN
2. 用 curl GET $BENCHMARK_BASE_URL/openapi/v1/challenges 获取题目列表
3. 选定多道未完成题目,逐一 POST $BENCHMARK_BASE_URL/openapi/v1/challenges/start?unique_code=<题号> 启动靶机,把并发拉满(默认上限 3)
4. 对每道题调用 run_coop 下发解题任务(task 中写明目标地址、unique_code、BENCHMARK_TOKEN、提交规则)
5. worker 返回 flag 后用 curl 向平台 submit API 提交,关闭已通关靶机,继续补满并发
6. 直到所有题目完成或时限到达
目标:在时限内尽可能多地完成题目、拿到更高总分。`
// StartBenchmark 在托管模式下自动创建会话并触发 Agent 开始解题。
// 由 main.go 在检测到 BENCHMARK_TOKEN 环境变量时以 goroutine 调用。
// 不依赖 HTTP 服务就绪 —— agent.Run 直接操作 Agent 实例,不经 HTTP 路由。
//
// 关键:必须注册持久化的 live emitter。coop 完成后 notifyCoopDone 通过
// liveEmitter 获取 emit 回调来触发汇报轮,若未注册则汇报轮无日志输出、
// 事件丢失,表现为"评测流程结束"后无任何后续日志(实际仍在运行)。
// 注册后不注销:整个评测期间(含所有后续汇报轮)保持日志通道畅通。
func (s *Server) StartBenchmark(ctx context.Context) error {
session := s.store.Create()
sessionID := session.ID
emit := func(event agent.Event) {
log.Printf("[benchmark] [%s] %v", event.Type, event.Data)
}
// 注册 live emitter,让 notifyCoopDone 触发的汇报轮也能输出日志
s.agent.RegisterLive(sessionID, emit)
log.Printf("[benchmark] 自动启动评测会话 %s", sessionID)
return s.agent.Run(ctx, sessionID, benchmarkInitialTask, emit)
}
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) health(c *gin.Context) {
ok(c, gin.H{"status": "ok"})
}
func (s *Server) config(c *gin.Context) {
ok(c, gin.H{
"model": s.cfg.Model,
"base_url": s.cfg.BaseURL,
"workspace": s.cfg.Workspace,
"max_context_tokens": s.cfg.MaxContextTokens,
"max_tool_result_chars": s.cfg.MaxToolResultChars,
"max_iterations": s.cfg.MaxIterations,
"keep_recent_messages": s.cfg.KeepRecentMessages,
"api_key_configured": s.cfg.APIKey != "",
})
}
func (s *Server) dockerConfig(c *gin.Context) {
ok(c, gin.H{
"docker_socket": s.dockerCfg.Socket(),
"default_socket": s.dockerCfg.DefaultSocket(),
"configured": s.dockerCfg.IsConfigured(),
})
}
func (s *Server) setDockerConfig(c *gin.Context) {
var req struct {
Socket string `json:"socket"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求体格式错误")
return
}
req.Socket = strings.TrimSpace(req.Socket)
// 空字符串表示清除配置、恢复默认本地 Docker
if err := s.dockerCfg.SetSocket(req.Socket); err != nil {
fail(c, http.StatusInternalServerError, "保存 Docker 配置失败: "+err.Error())
return
}
ok(c, gin.H{
"docker_socket": s.dockerCfg.Socket(),
"default_socket": s.dockerCfg.DefaultSocket(),
"configured": s.dockerCfg.IsConfigured(),
})
}
func (s *Server) llmConfig(c *gin.Context) {
apiKey := s.apiCfg.APIKey()
ok(c, gin.H{
"provider": s.apiCfg.Provider(),
"api_key_configured": apiKey != "",
"api_key_masked": maskSecret(apiKey),
"base_url": s.apiCfg.BaseURL(),
"model": s.apiCfg.Model(),
"engine": s.apiCfg.Engine(),
"coop_mode": s.apiCfg.CoopMode(),
"default_base_url": s.cfg.BaseURL,
"default_model": s.cfg.Model,
})
}
func (s *Server) setLLMConfig(c *gin.Context) {
var req struct {
APIKey *string `json:"api_key"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Provider *string `json:"provider"`
Engine *string `json:"engine"`
CoopMode *string `json:"coop_mode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求体格式错误")
return
}
// 字段为 nil 表示不修改;空字符串表示清除该字段、回退默认
if err := s.apiCfg.Update(req.APIKey, req.BaseURL, req.Model, req.Provider, req.Engine, req.CoopMode); err != nil {
fail(c, http.StatusInternalServerError, "保存 LLM 配置失败: "+err.Error())
return
}
apiKey := s.apiCfg.APIKey()
ok(c, gin.H{
"provider": s.apiCfg.Provider(),
"api_key_configured": apiKey != "",
"api_key_masked": maskSecret(apiKey),
"base_url": s.apiCfg.BaseURL(),
"model": s.apiCfg.Model(),
"engine": s.apiCfg.Engine(),
"coop_mode": s.apiCfg.CoopMode(),
})
}
// maskSecret 对密钥做掩码展示,避免在 Web 页面泄露完整值。
func maskSecret(value string) string {
runes := []rune(value)
if len(runes) <= 6 {
if len(runes) == 0 {
return ""
}
return "****"
}
return string(runes[:3]) + "****" + string(runes[len(runes)-3:])
}
+63
View File
@@ -0,0 +1,63 @@
package server
import (
"strings"
"time"
"agent/internal/agent"
"github.com/gin-gonic/gin"
)
// coopTaskView 是返回给前端的协作任务视图,附带容器的实时状态与协作轮次进度。
type coopTaskView struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Status string `json:"status"` // running | done
Error string `json:"error,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
Blackboard string `json:"blackboard,omitempty"`
RoundMax int `json:"round_max"`
CurrentRound int `json:"current_round"`
ContainerState string `json:"container_state,omitempty"`
ContainerStatus string `json:"container_status,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
}
// coopTasks 返回协作任务列表,支持 ?session=<id> 过滤到某个会话。
func (s *Server) coopTasks(c *gin.Context) {
tasks := s.agent.CoopTasks(strings.TrimSpace(c.Query("session")))
views := make([]coopTaskView, 0, len(tasks))
for _, task := range tasks {
views = append(views, s.coopTaskView(task))
}
ok(c, gin.H{"tasks": views})
}
func (s *Server) coopTaskView(task *agent.CoopTask) coopTaskView {
view := coopTaskView{
ID: task.ID,
SessionID: task.SessionID,
Status: task.Status,
Error: task.Error,
ExitCode: task.ExitCode,
Blackboard: task.Blackboard,
RoundMax: task.RoundMax,
CreatedAt: task.CreatedAt,
FinishedAt: task.FinishedAt,
}
// 轮次进度以黑板 logs 目录实际出现的轮次为准(容器清理后日志仍在)
view.CurrentRound = agent.CurrentCoopRound(task.Blackboard)
if view.CurrentRound == 0 {
view.CurrentRound = view.RoundMax
}
// 运行中的任务实时查询容器状态;已完成任务容器已被清理,直接读记录即可
if task.Status == "running" && task.ContainerID != "" {
if state, status, err := agent.ContainerInspectStatus(s.dockerCfg.Socket(), task.ContainerID); err == nil {
view.ContainerState = state
view.ContainerStatus = status
}
}
return view
}
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"net/http"
"strings"
"agent/internal/agent"
"github.com/gin-gonic/gin"
)
// listSessions 会话列表,支持 ?q=<关键词> 搜索、?limit=&offset= 分页。
func (s *Server) listSessions(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
var sessions []agent.SessionSummary
if query != "" {
sessions = s.store.Search(query)
} else {
sessions = s.store.List()
}
limit, offset := parsePagination(c)
if offset > 0 || limit > 0 {
start := offset
if start > len(sessions) {
start = len(sessions)
}
end := len(sessions)
if limit > 0 && start+limit < end {
end = start + limit
}
sessions = sessions[start:end]
}
ok(c, gin.H{"sessions": sessions})
}
func (s *Server) createSession(c *gin.Context) {
session := s.store.Create()
c.JSON(http.StatusCreated, session)
}
func (s *Server) getSession(c *gin.Context) {
session, found := s.store.Get(c.Param("id"))
if !found {
fail(c, http.StatusNotFound, "会话不存在")
return
}
ok(c, gin.H{"session": session})
}
// sessionMessages 分页返回会话消息(offset 从最新一条往前数),
// 响应含 has_more 与 total 供前端"加载更早"。
func (s *Server) sessionMessages(c *gin.Context) {
id := c.Param("id")
total, exists := s.store.TotalMessages(id)
if !exists {
fail(c, http.StatusNotFound, "会话不存在")
return
}
limit, offset := parsePagination(c)
if limit <= 0 || limit > 200 {
limit = 50
}
messages, _, hasMore := s.store.Messages(id, limit, offset)
ok(c, gin.H{
"messages": messages,
"has_more": hasMore,
"total": total,
})
}
func (s *Server) deleteSession(c *gin.Context) {
id := c.Param("id")
if !s.store.Delete(id) {
fail(c, http.StatusNotFound, "会话不存在")
return
}
// 释放会话持有的运行时资源(会话锁与 live 注册),避免内存泄漏
s.agent.ForgetSession(id)
c.Status(http.StatusNoContent)
}
// clearSession 清空会话历史消息(保留会话)。
func (s *Server) clearSession(c *gin.Context) {
if !s.store.Clear(c.Param("id")) {
fail(c, http.StatusNotFound, "会话不存在")
return
}
c.Status(http.StatusNoContent)
}
+31
View File
@@ -0,0 +1,31 @@
package server
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// fail 统一错误响应:{ "error": "..." }
func fail(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
// ok 统一成功响应。
func ok(c *gin.Context, data gin.H) {
c.JSON(http.StatusOK, data)
}
// parsePagination 解析 limit / offset 查询参数(非法值归零)。
func parsePagination(c *gin.Context) (limit, offset int) {
limit, _ = strconv.Atoi(c.Query("limit"))
offset, _ = strconv.Atoi(c.Query("offset"))
if limit < 0 {
limit = 0
}
if offset < 0 {
offset = 0
}
return
}
+86
View File
@@ -0,0 +1,86 @@
package server
import (
"fmt"
"net/http"
"path/filepath"
"time"
"agent/internal/agent"
"github.com/gin-gonic/gin"
)
// NewRouter 组装全部路由:静态资源、会话直达、/api/v1 REST、/ws WebSocket。
// 返回 (gin.Engine, *Server)*Server 供 main.go 在托管模式下调用 StartBenchmark 自动触发评测。
func NewRouter(cfg agent.Config, store *agent.SessionStore, dockerCfg *agent.DockerConfigStore, apiCfg *agent.APIConfigStore) (*gin.Engine, *Server) {
s := &Server{
cfg: cfg,
store: store,
agent: agent.NewAgent(cfg, store, dockerCfg, apiCfg),
dockerCfg: dockerCfg,
apiCfg: apiCfg,
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), requestLogger())
staticDir := filepath.Join(cfg.Workspace, "web", "static")
indexFile := filepath.Join(staticDir, "index.html")
r.Static("/static", staticDir)
r.StaticFile("/", indexFile)
// 会话直达:/<sessionId> 返回前端页面,由前端 JS 从路径解析会话 ID
r.GET("/:sessionId", func(c *gin.Context) {
if !sessionIDPattern.MatchString(c.Param("sessionId")) {
c.Status(http.StatusNotFound)
return
}
c.File(indexFile)
})
v1 := r.Group("/api/v1")
v1.GET("/health", s.health)
// 授权相关接口始终放行:前端据此判断是否需要授权 / 提交授权码。
// 其余 API 与 WebSocket 统一挂 authRequired(未启用授权时自动放行)。
auth := v1.Group("/auth")
auth.GET("/status", s.authStatus)
auth.POST("/login", s.authLogin)
authed := v1.Group("")
authed.Use(s.authRequired)
authed.GET("/config", s.config)
authed.GET("/docker/config", s.dockerConfig)
authed.PUT("/docker/config", s.setDockerConfig)
authed.GET("/llm/config", s.llmConfig)
authed.PUT("/llm/config", s.setLLMConfig)
authed.GET("/sessions", s.listSessions)
authed.POST("/sessions", s.createSession)
authed.GET("/sessions/:id", s.getSession)
authed.GET("/sessions/:id/messages", s.sessionMessages)
authed.DELETE("/sessions/:id", s.deleteSession)
authed.DELETE("/sessions/:id/messages", s.clearSession)
authed.GET("/coop/tasks", s.coopTasks)
// WebSocket 实时通道:Agent 流式输出 / 状态 / 协作通知(同样受授权保护)
r.GET("/ws", s.authRequired, s.ws)
return r, s
}
// requestLogger 轻量请求日志中间件:统一 [http] 前缀,便于与 [coop] 等日志区分。
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
gin.DefaultWriter.Write([]byte(fmt.Sprintf(
"[http] %s | %s %s | %s | %d\n",
time.Now().Format("2006/01/02 15:04:05"),
c.Request.Method,
c.Request.URL.Path,
time.Since(start).Round(time.Millisecond).String(),
c.Writer.Status(),
)))
}
}
+19
View File
@@ -0,0 +1,19 @@
package server
import (
"regexp"
"agent/internal/agent"
)
// Server 持有全部依赖,各 handler 文件通过它访问共享状态。
type Server struct {
cfg agent.Config
store *agent.SessionStore
agent *agent.Agent
dockerCfg *agent.DockerConfigStore
apiCfg *agent.APIConfigStore
}
// sessionIDPattern 匹配会话直达路径 / WebSocket 绑定的会话 ID 格式(24 位十六进制)。
var sessionIDPattern = regexp.MustCompile(`^[0-9a-f]{24}$`)
+177
View File
@@ -0,0 +1,177 @@
package server
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
"agent/internal/agent"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
// 本地单用户 Web,允许任意 Origin
CheckOrigin: func(r *http.Request) bool { return true },
}
// wsInbound 客户端 → 服务端消息。
// chat:在绑定会话上运行 Agent;switch:切换绑定会话;stop:停止当前生成;ping:保活。
type wsInbound struct {
Type string `json:"type"`
Content string `json:"content"`
SessionID string `json:"session_id"`
}
// wsConn 封装一个 WebSocket 连接:绑定一个会话、串行处理消息。
type wsConn struct {
server *Server
conn *websocket.Conn
sendMu sync.Mutex // 串行化 WriteJSON
sessionID string
unreg func() // 当前会话的 live 注销函数
runMu sync.Mutex
runCancel context.CancelFunc
}
func (s *Server) ws(c *gin.Context) {
sessionID := strings.TrimSpace(c.Query("session"))
if sessionID == "" || !sessionIDPattern.MatchString(sessionID) {
log.Printf("ws: 连接被拒(session 参数无效)session=%q", sessionID)
fail(c, http.StatusBadRequest, "缺少有效的 session 参数")
return
}
if _, ok := s.store.Get(sessionID); !ok {
log.Printf("ws: 连接被拒(会话不存在)session=%q", sessionID)
fail(c, http.StatusNotFound, "会话不存在")
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
client := &wsConn{server: s, conn: conn}
client.bind(sessionID)
client.readPump()
}
// sendJSON 安全地推送一个事件帧 {type, data}。
func (cl *wsConn) sendJSON(eventType string, data any) {
cl.sendMu.Lock()
defer cl.sendMu.Unlock()
_ = cl.conn.WriteJSON(map[string]any{"type": eventType, "data": data})
}
// bind 切换绑定会话:注销旧会话的 live 注册,注册新会话。
// 传入空串表示仅注销。
func (cl *wsConn) bind(sessionID string) {
if cl.unreg != nil {
cl.unreg()
cl.unreg = nil
}
cl.sessionID = sessionID
if sessionID == "" {
return
}
cl.unreg = cl.server.agent.RegisterLive(sessionID, func(event agent.Event) {
cl.sendJSON(event.Type, event.Data)
})
}
func (cl *wsConn) handleChat(content string) {
content = strings.TrimSpace(content)
if content == "" {
cl.sendJSON("error", map[string]any{"message": "消息内容不能为空"})
return
}
// 未配置 LLM API Key 时直接提示去配置,避免进入无效的模型调用。
// 环境变量 LLM_API_KEY(托管模式)与设置页 api-config.json 都计入已配置。
if !cl.server.apiCfg.IsAPIKeyConfigured() {
cl.sendJSON("error", map[string]any{"message": "尚未配置 LLM API,请先点击左下角设置按钮配置 API Key 与模型"})
return
}
cl.runMu.Lock()
if cl.runCancel != nil {
cl.runMu.Unlock()
cl.sendJSON("error", map[string]any{"message": "Agent 正在执行,请等待完成或先停止"})
return
}
ctx, cancel := context.WithCancel(context.Background())
cl.runCancel = cancel
cl.runMu.Unlock()
done := make(chan struct{})
go func() {
defer close(done)
_ = cl.server.agent.Run(ctx, cl.sessionID, content, func(event agent.Event) {
cl.sendJSON(event.Type, event.Data)
})
}()
go func() {
<-done
cl.runMu.Lock()
cl.runCancel = nil
cl.runMu.Unlock()
}()
}
func (cl *wsConn) handleStop() {
cl.runMu.Lock()
if cl.runCancel != nil {
cl.runCancel()
}
cl.runMu.Unlock()
}
func (cl *wsConn) readPump() {
defer func() {
cl.bind("") // 注销 live
_ = cl.conn.Close()
}()
cl.conn.SetReadLimit(1 << 20) // 1MB
_ = cl.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
cl.conn.SetPongHandler(func(string) error {
_ = cl.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
return nil
})
for {
// 任何收到消息(含客户端心跳 {type:"ping"})都刷新 90s 读超时,
// 否则浏览器只能发协议层 Pong,应用层 ping 不会触发 SetPongHandler,连接必死在第 90 秒
_ = cl.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
_, raw, err := cl.conn.ReadMessage()
if err != nil {
return
}
var msg wsInbound
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "chat":
cl.handleChat(msg.Content)
case "stop":
cl.handleStop()
case "switch":
if msg.SessionID != "" && sessionIDPattern.MatchString(msg.SessionID) {
if _, ok := cl.server.store.Get(msg.SessionID); ok {
cl.bind(msg.SessionID)
}
}
case "ping":
cl.sendJSON("pong", map[string]any{"time": time.Now().UnixMilli()})
default:
log.Printf("ws: unknown message type %q", msg.Type)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"context"
"log"
"os"
"agent/internal/agent"
"agent/internal/server"
)
func main() {
cfg := agent.LoadConfig()
store, err := agent.NewSessionStore(cfg.DataDir)
if err != nil {
log.Fatalf("failed to initialize session store: %v", err)
}
dockerCfg, err := agent.NewDockerConfigStore(cfg.DataDir)
if err != nil {
log.Fatalf("failed to initialize docker config store: %v", err)
}
apiCfg, err := agent.NewAPIConfigStore(cfg.DataDir, agent.APIConfig{
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Provider: agent.ProviderOpenAI,
})
if err != nil {
log.Fatalf("failed to initialize api config store: %v", err)
}
r, srv := server.NewRouter(cfg, store, dockerCfg, apiCfg)
// 托管模式:检测到 BENCHMARK_TOKEN 时自动启动评测,无需外部触发。
// agent.Run 不依赖 HTTP 服务,可直接在 goroutine 中调用。
if os.Getenv("BENCHMARK_TOKEN") != "" {
go func() {
log.Printf("[benchmark] 检测到托管模式(BENCHMARK_TOKEN 已注入),自动启动评测")
if err := srv.StartBenchmark(context.Background()); err != nil {
log.Printf("[benchmark] 初始轮异常: %v", err)
}
log.Printf("[benchmark] 初始轮结束,后续 coop 完成通知将自动触发新一轮处理")
}()
}
log.Printf("agent is running at http://localhost:%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server stopped: %v", err)
}
}
+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
+10
View File
@@ -0,0 +1,10 @@
# 精简 Docker 构建上下文(用于 coop/Dockerfile
.git
book
docs
examples
tasks
*.exe
pigo
install.sh
vm_vpnstart.py
+62
View File
@@ -0,0 +1,62 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# pigo config files (may contain plaintext API keys)
config.toml
**/config.toml
.pigo/
# Editor/IDE
# .idea/
# .vscode/
.loop-state.json
# Compiled binary (go build ./cmd/pigo produces ./pigo)
/pigo
# goreleaser output
/dist/
.graph_state
graph.html
# Book build products (source in, products out — see book/README.md)
book/*.pdf
book/book.tex
book/.build-bin/
book/*.aux
book/*.log
book/*.toc
book/*.out
book/*.bcf
book/*.bbl
book/*.blg
book/*.run.xml
# Figures pre-rendered from SVG by build_pdf.sh (rsvg-convert); source is the .svg
book/images/*.pdf
+86
View File
@@ -0,0 +1,86 @@
# goreleaser 配置:构建跨平台二进制、打包、生成 checksums、发布到 GitHub Release。
#
# 本地校验: goreleaser check
# 本地试跑(不发布): goreleaser release --snapshot --clean
# 正式发布: 打 tag 后 `git push --tags`CI 触发 `goreleaser release --clean`
version: 2
project_name: pigo
before:
hooks:
- go mod tidy
builds:
- id: pigo
main: ./cmd/pigo
binary: pigo
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
# version/commit/date 变量注入 main 包(见 cmd/pigo/main.go)。
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
archives:
- id: pigo
ids:
- pigo
name_template: >-
{{ .ProjectName }}_{{ .Version }}_
{{- if eq .Os "darwin" }}Darwin
{{- else if eq .Os "linux" }}Linux
{{- else if eq .Os "windows" }}Windows
{{- else }}{{ .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- LICENSE
checksum:
name_template: "checksums.txt"
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
use: github
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "Merge pull request"
- "Merge branch"
groups:
- title: Features
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 0
- title: Bug fixes
regexp: '^.*?fix(\(.+\))??!?:.+$'
order: 1
- title: Others
order: 999
release:
github:
owner: smallnest
name: pigo
draft: false
prerelease: auto
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 smallnest
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+165
View File
@@ -0,0 +1,165 @@
package agent
import (
"context"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
)
// Session is a single, stateful pigo agent conversation. Create one with New,
// drive it with Prompt or Stream, and release its resources with Close. The
// conversation history accumulates across calls, so follow-up prompts see the
// earlier exchange; call Reset to start over on the same session.
//
// A Session is not safe for concurrent use. Drive it from a single goroutine, or
// give each goroutine its own Session.
type Session struct {
env run.Env
runCfg runtime.RunConfig
agentCtx *agentcore.AgentContext
model string
}
// New builds a Session from the given options. It resolves the provider and
// credentials, assembles the tool set, and validates the tool policy and
// thinking level up front, so a configuration mistake (an unknown tool name, an
// invalid thinking level, an unresolvable provider) is returned here rather than
// surfacing on the first Prompt.
//
// No network call is made by New: the provider is only contacted when you call
// Prompt or Stream. This makes New cheap and safe to use in tests.
//
// See the package documentation for the default tool, skill, and memory
// behavior — in particular, that tools are enabled and auto-executed by default.
func New(opts ...Option) (*Session, error) {
c := config{model: "openrouter/free"}
for _, o := range opts {
o(&c)
}
// Validate the reasoning-effort level through the same layered config chain
// the CLI uses, so an invalid WithThinkingLevel value fails fast here.
thinking, err := run.ResolveThinkingLevel(c.thinking)
if err != nil {
return nil, err
}
// One ToolPolicy value carries both lists so they cannot be swapped; deny
// always wins over allow inside run.ApplyToolPolicy.
policy := run.NewToolPolicy(c.allowedTools, c.disallowedTools)
// SetupEnv resolves the provider, assembles the (policy-filtered) tool set,
// builds the system prompt, and — because skills/memory are opt-in here —
// leaves the machine's shared state untouched unless WithSkills/WithMemory
// were passed. It also validates the tool policy against the real tool set,
// so an unknown tool name is reported as an error.
env, err := run.SetupEnv(
c.model, c.baseURL, c.protocol, c.provider, c.apiKey,
c.noTools, !c.skills, c.systemPrompt, c.appendSystemPrompt, c.memory, policy,
)
if err != nil {
return nil, err
}
// Resolve the API key by provider name: an explicit WithAPIKey overrides the
// provider's environment variable. The key is held only in the credential
// store and never logged.
creds := provider.NewCredentialStore(nil)
if c.apiKey != "" {
creds.SetOverride(env.ProviderName, c.apiKey)
}
runCfg := run.NewConfig(
c.model, env.ProviderName, thinking, env.Provider, creds,
run.ToolRegistry(env.Tools), run.TodoReminders(env.Tools),
)
return &Session{
env: env,
runCfg: runCfg,
agentCtx: &agentcore.AgentContext{
SystemPrompt: env.SysPrompt,
Tools: env.Tools,
},
model: c.model,
}, nil
}
// Prompt sends one user message, runs the agent loop to completion (executing
// any tool calls the model makes along the way), and returns the assistant's
// final text. The exchange is appended to the session history so later prompts
// have this context.
func (s *Session) Prompt(ctx context.Context, prompt string) (string, error) {
return s.Stream(ctx, prompt, nil)
}
// Stream is Prompt with incremental output: onText, if non-nil, is called with
// each chunk of assistant text as it arrives, and the complete final text is
// also returned. Tool calls still run automatically between text chunks. A nil
// onText makes Stream behave exactly like Prompt.
func (s *Session) Stream(ctx context.Context, prompt string, onText func(string)) (string, error) {
// The loop expects the initiating user message already appended; it then
// mutates agentCtx.Messages in place (assistant + tool results), which is
// what carries the conversation forward across calls.
s.agentCtx.Messages = append(s.agentCtx.Messages, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(prompt)},
})
stream := runtime.StartRun(ctx, s.agentCtx, s.runCfg)
final, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{OnText: onText})
if err != nil {
return "", err
}
if final == nil {
return "", nil
}
return agentcore.ContentToText(final.Content), nil
}
// Reset clears the conversation history, so the next Prompt starts a fresh
// exchange. The provider, tool set, and system prompt are unchanged.
func (s *Session) Reset() {
s.agentCtx.Messages = nil
}
// ToolNames returns the names of the tools available to this session, in the
// order they are advertised to the model. It reflects the applied tool policy,
// so it is a convenient way to confirm WithTools/WithDisallowedTools did what
// you intended. The result is empty for a WithoutTools session.
func (s *Session) ToolNames() []string {
names := make([]string, len(s.env.Tools))
for i, t := range s.env.Tools {
names[i] = t.Name()
}
return names
}
// Model returns the model id the session was created with.
func (s *Session) Model() string { return s.model }
// Provider returns the resolved provider name (e.g. "anthropic", "openrouter"),
// which is inferred from the model id unless WithProvider was set.
func (s *Session) Provider() string { return s.env.ProviderName }
// Close releases resources held by the session: any loaded plugin manager and
// the persistent memory store (when WithMemory was used). It is safe to call
// once, and safe to call on a session that holds neither. After Close the
// session must not be used again.
func (s *Session) Close() error {
var firstErr error
if s.env.Plugins != nil {
if err := s.env.Plugins.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if s.env.Memory != nil {
if err := s.env.Memory.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+160
View File
@@ -0,0 +1,160 @@
package agent_test
import (
"strings"
"testing"
"github.com/smallnest/pigo/agent"
)
// hermetic points provider/skill/plugin discovery at throwaway dirs and supplies
// a dummy key so New resolves fully without ever contacting a network. None of
// these tests call Prompt/Stream, so no real request is made.
func hermetic(t *testing.T) {
t.Helper()
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
}
func contains(set []string, name string) bool {
for _, n := range set {
if n == name {
return true
}
}
return false
}
// TestNewDefaults is the zero-config path: the full built-in tool set is
// advertised and the model id resolves to the openrouter provider.
func TestNewDefaults(t *testing.T) {
hermetic(t)
sess, err := agent.New(agent.WithModel("openrouter/free"))
if err != nil {
t.Fatalf("New: %v", err)
}
defer sess.Close()
if got := sess.Model(); got != "openrouter/free" {
t.Errorf("Model() = %q, want %q", got, "openrouter/free")
}
if got := sess.Provider(); got != "openrouter" {
t.Errorf("Provider() = %q, want %q", got, "openrouter")
}
for _, want := range []string{"read", "write", "edit", "grep", "find", "bash", "task"} {
if !contains(sess.ToolNames(), want) {
t.Errorf("default tool set missing %q: %q", want, sess.ToolNames())
}
}
}
// TestWithToolsAllowlist confirms an allowlist narrows the set to exactly the
// named tools, in order.
func TestWithToolsAllowlist(t *testing.T) {
hermetic(t)
sess, err := agent.New(
agent.WithModel("openrouter/free"),
agent.WithTools("read", "grep"),
)
if err != nil {
t.Fatalf("New: %v", err)
}
defer sess.Close()
if got := strings.Join(sess.ToolNames(), ","); got != "read,grep" {
t.Errorf("ToolNames() = %q, want %q", got, "read,grep")
}
}
// TestDenyWinsOverAllow is the fail-closed guarantee at the SDK layer: a tool on
// both lists is removed.
func TestDenyWinsOverAllow(t *testing.T) {
hermetic(t)
sess, err := agent.New(
agent.WithModel("openrouter/free"),
agent.WithTools("read", "bash"),
agent.WithDisallowedTools("bash"),
)
if err != nil {
t.Fatalf("New: %v", err)
}
defer sess.Close()
if contains(sess.ToolNames(), "bash") {
t.Errorf("bash was on both lists and must be removed: %q", sess.ToolNames())
}
if !contains(sess.ToolNames(), "read") {
t.Errorf("read was allowed and not denied, so must survive: %q", sess.ToolNames())
}
}
// TestWithoutTools yields an empty set — a pure text completion.
func TestWithoutTools(t *testing.T) {
hermetic(t)
sess, err := agent.New(
agent.WithModel("openrouter/free"),
agent.WithoutTools(),
)
if err != nil {
t.Fatalf("New: %v", err)
}
defer sess.Close()
if len(sess.ToolNames()) != 0 {
t.Errorf("WithoutTools must leave no tools, got %q", sess.ToolNames())
}
}
// TestUnknownToolIsError confirms a misspelled tool name fails construction
// rather than silently dropping the boundary.
func TestUnknownToolIsError(t *testing.T) {
hermetic(t)
_, err := agent.New(
agent.WithModel("openrouter/free"),
agent.WithTools("raed"),
)
if err == nil {
t.Fatal("New = nil error, want a failure for the misspelled tool name")
}
}
// TestInvalidThinkingLevelIsError confirms the level is validated up front.
func TestInvalidThinkingLevelIsError(t *testing.T) {
hermetic(t)
_, err := agent.New(
agent.WithModel("openrouter/free"),
agent.WithThinkingLevel("supersonic"),
)
if err == nil {
t.Fatal("New = nil error, want a failure for the invalid thinking level")
}
}
// TestValidThinkingLevels accepts every documented level.
func TestValidThinkingLevels(t *testing.T) {
hermetic(t)
for _, level := range []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"} {
sess, err := agent.New(
agent.WithModel("openrouter/free"),
agent.WithThinkingLevel(level),
)
if err != nil {
t.Errorf("WithThinkingLevel(%q): %v", level, err)
continue
}
sess.Close()
}
}
// TestCloseHermetic confirms Close is a no-op (nil) when the session holds no
// plugin manager or memory store, and is safe to call.
func TestCloseHermetic(t *testing.T) {
hermetic(t)
sess, err := agent.New(agent.WithModel("openrouter/free"), agent.WithoutTools())
if err != nil {
t.Fatalf("New: %v", err)
}
if err := sess.Close(); err != nil {
t.Errorf("Close() = %v, want nil", err)
}
}
+63
View File
@@ -0,0 +1,63 @@
// Package agent is the public, embeddable SDK for driving a pigo agent from
// your own Go program. It wraps pigo's internal run-assembly, provider, and
// agent-loop packages behind a small surface whose every exported type is a Go
// primitive (string, []string, bool, func) — so importing this package never
// pulls an internal type into your code, and pigo can evolve its internals
// without breaking you.
//
// # Quick start
//
// sess, err := agent.New(
// agent.WithModel("claude-opus-4-8"),
// agent.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
// )
// if err != nil {
// log.Fatal(err)
// }
// defer sess.Close()
//
// reply, err := sess.Prompt(context.Background(), "Say hello in one word.")
// fmt.Println(reply)
//
// # Model, provider, credentials
//
// The model id selects the provider the same way the pigo CLI does:
// "claude-opus-4-8" resolves to Anthropic, "openrouter/free" to OpenRouter,
// and so on. Point at any OpenAI- or Anthropic-compatible endpoint with
// [WithBaseURL] + [WithProtocol], or a named provider from your config with
// [WithProvider]. The API key comes from [WithAPIKey] or, if unset, the
// provider's usual environment variable (e.g. ANTHROPIC_API_KEY). Keys are
// never logged.
//
// # Tools run automatically — read this
//
// By default a session is created with pigo's full built-in tool set (read,
// write, edit, bash, find, grep, and more) and those tools are executed WITHOUT
// any per-call confirmation prompt — equivalent to running the CLI with
// --approve. An agent can therefore read, modify, and delete files under its
// working directory and run shell commands on the host. This is the right
// default for an automated SDK, but it means you should only send prompts you
// trust, and run in a directory (and, ideally, a sandbox) you are willing to let
// the agent modify. To constrain or remove that capability use [WithTools] (an
// allowlist), [WithDisallowedTools] (a denylist, which always wins), or
// [WithoutTools] (a pure text completion with no tools at all).
//
// # Conversation state
//
// A [Session] keeps the running conversation: each [Session.Prompt] or
// [Session.Stream] call appends to the same history, so follow-up prompts see
// what came before. Call [Session.Reset] to start a fresh conversation on the
// same session, or [Session.Close] when you are done. A Session is NOT safe for
// concurrent use — drive it from one goroutine, or create one Session per
// goroutine.
//
// # Defaults
//
// - Tools: on (full built-in set, auto-executed; see the safety note above).
// - Skills: off — enable discovery of on-disk skills with [WithSkills].
// - Memory: off — enable the persistent memory store with [WithMemory].
// - Thinking: "medium" — override with [WithThinkingLevel].
//
// Skills and memory are off by default so an embedded session is hermetic: it
// does not read or write the machine's shared pigo state unless you ask it to.
package agent
+123
View File
@@ -0,0 +1,123 @@
package agent
// config is the resolved, unexported construction state for a Session. It is
// populated only through Option values, so callers never name or mutate it
// directly — the exported surface stays limited to With* constructors and the
// Session methods.
type config struct {
model string
baseURL string
protocol string
provider string
apiKey string
systemPrompt string
appendSystemPrompt []string
thinking string
noTools bool
allowedTools []string
disallowedTools []string
skills bool
memory bool
}
// Option configures a Session at construction time. Options are applied in the
// order passed to New, so a later option overrides an earlier one that sets the
// same field. Because config is unexported, the only way to produce an Option is
// through the With* constructors below — which keeps the public surface free of
// internal types.
type Option func(*config)
// WithModel sets the model id, which also selects the provider the way the pigo
// CLI does (e.g. "claude-opus-4-8" → Anthropic, "openrouter/free" → OpenRouter).
// The default is "openrouter/free".
func WithModel(model string) Option {
return func(c *config) { c.model = model }
}
// WithBaseURL points the session at a custom endpoint. Pair it with
// [WithProtocol] to say whether that endpoint speaks the OpenAI or Anthropic
// wire format.
func WithBaseURL(baseURL string) Option {
return func(c *config) { c.baseURL = baseURL }
}
// WithProtocol selects the wire protocol for a custom endpoint: "openai" or
// "anthropic". It is only consulted when [WithBaseURL] is set.
func WithProtocol(protocol string) Option {
return func(c *config) { c.protocol = protocol }
}
// WithProvider selects a named provider from your pigo configuration instead of
// inferring one from the model id.
func WithProvider(name string) Option {
return func(c *config) { c.provider = name }
}
// WithAPIKey sets the API key for the resolved provider, overriding the
// provider's environment variable. When unset, the provider's usual environment
// variable is used (e.g. ANTHROPIC_API_KEY, OPENROUTER_API_KEY).
func WithAPIKey(key string) Option {
return func(c *config) { c.apiKey = key }
}
// WithSystemPrompt replaces pigo's built-in base instruction with prompt. Use
// this for full control over the agent's persona and rules; use
// [WithAppendSystemPrompt] instead to keep the built-in instruction and add to
// it.
func WithSystemPrompt(prompt string) Option {
return func(c *config) { c.systemPrompt = prompt }
}
// WithAppendSystemPrompt appends one or more blocks to the system prompt,
// leaving pigo's built-in instruction in place. Repeated calls accumulate.
func WithAppendSystemPrompt(blocks ...string) Option {
return func(c *config) {
c.appendSystemPrompt = append(c.appendSystemPrompt, blocks...)
}
}
// WithThinkingLevel sets the reasoning-effort level. Valid values are "off",
// "minimal", "low", "medium", "high", "xhigh", and "max". The default is
// "medium". An invalid value makes New return an error.
func WithThinkingLevel(level string) Option {
return func(c *config) { c.thinking = level }
}
// WithTools restricts the session to the named built-in tools (an allowlist,
// e.g. WithTools("read", "grep")). Names are matched case-insensitively, so
// "Read" and "read" are equivalent. A name that matches no tool makes New
// return an error rather than silently ignoring it. Combine with
// [WithDisallowedTools]; deny always wins over allow.
func WithTools(names ...string) Option {
return func(c *config) { c.allowedTools = append(c.allowedTools, names...) }
}
// WithDisallowedTools removes the named built-in tools (a denylist, e.g.
// WithDisallowedTools("bash")). Deny always wins: a tool named here is removed
// even if it also appears in [WithTools]. As with WithTools, an unknown name
// makes New return an error.
func WithDisallowedTools(names ...string) Option {
return func(c *config) { c.disallowedTools = append(c.disallowedTools, names...) }
}
// WithoutTools removes every tool, producing a pure text-completion session that
// cannot touch the filesystem or run commands. It overrides [WithTools] and
// [WithDisallowedTools], which become inert once the tool set is empty.
func WithoutTools() Option {
return func(c *config) { c.noTools = true }
}
// WithSkills enables discovery of on-disk skills, which are advertised to the
// model and loadable during a run. Skills are off by default so an embedded
// session stays independent of the machine's shared skills directory.
func WithSkills() Option {
return func(c *config) { c.skills = true }
}
// WithMemory enables pigo's persistent memory store, letting the agent recall
// context saved by earlier runs and record new memories. Memory is off by
// default so an embedded session does not read or write shared state unless
// asked.
func WithMemory() Option {
return func(c *config) { c.memory = true }
}
+548
View File
@@ -0,0 +1,548 @@
// Command pigo is the CLI entry point for the pigo agent. It parses flags,
// overlays config.toml, and dispatches to one of the run modes — interactive
// REPL, headless print, session listing, or the internal sub-agent RPC server:
//
// pigo # interactive REPL (on a TTY)
// pigo -p "read README and summarize" # print mode: final text
// pigo -p "..." --output-format stream-json # line-delimited JSON events
// pigo install <pkg> | list | uninstall | update # package management
//
// The provider is resolved from --model against the built-in OpenAI-compatible
// gateways (OpenRouter by default, Ollama for local models), with the API key
// taken from the environment. The process exit code reflects success (0) or
// failure (1), so the command composes cleanly in pipelines. All run-assembly,
// REPL, headless, and config logic lives under internal/cli/*; this file keeps
// only flag parsing (cliOptions), config overlay (applyFileConfig), and the
// dispatch seam that wires those subpackages together.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
flag "github.com/spf13/pflag"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/config"
"github.com/smallnest/pigo/internal/cli/headless"
"github.com/smallnest/pigo/internal/cli/pkgcmd"
"github.com/smallnest/pigo/internal/cli/repl"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/tui"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/dream"
"github.com/smallnest/pigo/internal/selfupdate"
)
// Build metadata, injected at release time via -ldflags by goreleaser
// (see .goreleaser.yaml). They keep their default values for `go build`/
// `go run` from source, so `pigo --version` still works without a release build.
var (
version = "dev"
commit = "none"
date = "unknown"
)
// cliOptions is the parsed command line, produced by main() and consumed by
// dispatch. Separating parse from dispatch makes the dispatch logic testable
// without touching the global flag set.
type cliOptions struct {
prompt string
model string
baseURL string
apiKey string
protocol string
// provider, when non-empty, selects a built-in provider by name from the
// registry (mirrors pi's provider selection): provider.ResolveProvider then builds the
// matching wire driver using the provider's default base URL, protocol, and
// API-key env var, ignoring the model-id heuristics.
provider string
outputFmt string
noTools bool
listSessions bool
resumeID string
continueLast bool
// approve grants the launch directory session-level trust up front (mirrors pi's
// --approve/-a): the first-launch trust prompt is skipped and side-effect
// tools (bash/write/edit) run without per-call confirmation for this run.
approve bool
// noSkills disables skill discovery (mirrors pi's --no-skills): skills under
// ~/.agents/skills are not loaded as /skill-name commands.
noSkills bool
// systemPrompt, when non-empty, replaces the default coding-assistant base
// instruction (mirrors pi's --system-prompt). The environment block and
// AGENTS.md injection still apply on top of it.
systemPrompt string
// appendSystemPrompt holds --append-system-prompt values (mirrors pi, repeatable):
// each is a path to a file whose contents are appended, or literal text when
// it is not an existing file. Appended after the base prompt and AGENTS.md.
appendSystemPrompt []string
// configPrompts holds prompt-template paths from the config.toml `prompts`
// array (settings tier); each is a file or directory loaded non-recursively.
// Populated by applyFileConfig; empty when the config omits `prompts`.
configPrompts []string
// promptTemplates holds --prompt-template paths (CLI tier, repeatable); each
// is a file or directory loaded non-recursively.
promptTemplates []string
// noPromptTemplates disables all prompt-template discovery (global, project,
// settings, CLI); built-in slash commands are unaffected. Independent of
// --no-skills.
noPromptTemplates bool
// subagentRPC selects the process-isolated sub-agent server mode (US-019,
// #135): pigo reads JSON-RPC sub-agent run requests from stdin and writes
// results to stdout. Internal, used by SubAgentTool's process mode.
subagentRPC bool
// dream, when set, runs the process-isolated memory-consolidation pass and
// exits: pigo enumerates + consolidates the global/project memory scope, emits
// a single-line Report JSON on stdout, and exits 0/1. Internal, spawned by the
// dream scheduler (and usable headlessly by scripts). See internal/dream and
// SPEC §4.1/§4.2.
dream bool
// dreamDryRun pairs with --dream: analyze and report without writing files or
// updating dream state (the lock is still taken). SPEC §5.5 dry-run row.
dreamDryRun bool
// thinkingLevel, when non-empty, is the --thinking-level flag: the reasoning
// effort for requests (off|minimal|low|medium|high|xhigh|max). It is the highest-
// precedence layer in resolveThinkingLevel, overriding PIGO_THINKING_LEVEL, the
// config files, and the built-in default (medium).
thinkingLevel string
// showVersion prints build metadata (version/commit/date, injected at release
// time by goreleaser) and exits, without running the agent.
showVersion bool
// noTUI forces the line-based REPL instead of the full-screen TUI (US-001).
// When set — or when stdout is not a TTY — the no-prompt path falls back to
// repl.Run rather than launching tui.Run.
noTUI bool
// cwd, when non-empty, is the working directory pigo switches to before doing
// anything else (matches the Claude Agent SDK's cwd option / git -C). Every
// cwd-derived resolution — built-in tool file roots, project trust, hooks
// project dir, .pigo/ project config, git info, the status-bar path — reads
// os.Getwd(), so a single os.Chdir here makes all of them operate in the
// given directory. This is what makes pigo usable as an SDK backend that can
// be pointed at an arbitrary project root.
cwd string
// memory holds the resolved [memory]/[checkpoint]/[compaction] config tables
// (defaults applied, string forms parsed). These have no CLI flags — the
// config file is their only source — so applyFileConfig always populates this
// (defaults when the tables are absent) for downstream memory/checkpoint/
// compaction wiring to consume. See config.MemorySettings.
memory config.MemorySettings
// dreamCfg is the resolved [dream] configuration (enabled / interval /
// recent-sessions), populated by applyFileConfig from the [dream] table with
// defaults applied. The interactive REPL consumes it to decide the startup
// background auto-consolidation (US-008). Like memory it has no CLI flags.
dreamCfg dream.Config
// allowedTools and disallowedTools are the --allowed-tools/--disallowed-tools
// values: the tool-level admission boundary for the run, filling the gap
// between "all tools" and --no-tools. Each is repeatable and each value may be
// comma-separated. Names match case-insensitively, and deny wins over allow
// when a name appears on both sides (fail-closed). The boundary is enforced at
// the tool-registration layer in run.SetupEnv, strictly before the
// BeforeToolCall confirmation gate, so --approve waives confirmation prompts
// but can never widen the boundary.
allowedTools []string
disallowedTools []string
}
func main() {
// Package-management subcommands (pigo install|list|uninstall|update ...) are
// positional and distinct from the flag-driven agent modes, so peel them off
// before pflag parsing — the agent flags don't apply to them.
if len(os.Args) > 1 && pkgcmd.Subcommands[os.Args[1]] {
// `pigo update` routes by whether a positional package name follows it:
// none — or flags-only, e.g. `pigo update --check` — is binary self-update
// (#466: download the latest release and replace this binary); a package
// name stays package-update (handled by pkgcmd). This is the US-003 dispatch
// split, with updateIsSelfUpdate as the pure classifier so routing is
// unit-testable (TestUpdateIsSelfUpdate).
if os.Args[1] == "update" && updateIsSelfUpdate(os.Args[2:]) {
os.Exit(selfupdate.Run(context.Background(), version, os.Stdout, os.Stderr))
}
os.Exit(pkgcmd.Run(os.Args[1], os.Args[2:], os.Stdout, os.Stderr))
}
var opts cliOptions
flag.StringVarP(&opts.prompt, "print", "p", "", "prompt to run in headless print mode")
flag.StringVarP(&opts.model, "model", "m", "openrouter/free", "model id to run against (a well-known model name like claude-opus-4-8 or deepseek-chat auto-selects its provider when --provider/--protocol/--base-url are unset)")
flag.StringVarP(&opts.baseURL, "base-url", "u", "", "override provider base URL (e.g. local Ollama)")
flag.StringVarP(&opts.apiKey, "api-key", "k", "", "API key for the resolved provider (overrides env/config; else <PROVIDER>_API_KEY)")
flag.StringVarP(&opts.protocol, "protocol", "P", "", "force wire protocol for a custom endpoint: openai | anthropic (default: inferred from model id)")
flag.StringVar(&opts.provider, "provider", "", "select a built-in provider by name (e.g. deepseek, minimax); uses its default base URL, protocol, and API-key env var (see --help provider list)")
flag.StringVarP(&opts.outputFmt, "output-format", "o", "text", "output format: text | stream-json")
flag.BoolVarP(&opts.noTools, "no-tools", "n", false, "disable the built-in file/shell tools")
flag.StringArrayVar(&opts.allowedTools, "allowed-tools", nil, "restrict the model to these tools (repeatable, comma-separated, case-insensitive); empty means no restriction and --disallowed-tools wins on conflict")
flag.StringArrayVar(&opts.disallowedTools, "disallowed-tools", nil, "remove these tools from the model's set (repeatable, comma-separated, case-insensitive); takes precedence over --allowed-tools")
flag.BoolVarP(&opts.listSessions, "list-sessions", "l", false, "list stored interactive sessions and exit")
flag.StringVarP(&opts.resumeID, "resume", "r", "", "resume the interactive session with this id")
flag.BoolVarP(&opts.continueLast, "continue", "c", false, "resume the most recent interactive session")
flag.BoolVarP(&opts.approve, "approve", "a", false, "trust the working directory for this run: skip the first-launch trust prompt and run side-effect tools without per-call confirmation")
flag.BoolVar(&opts.noSkills, "no-skills", false, "disable skill discovery (do not load skills under ~/.agents/skills as /skill-name commands)")
flag.BoolVar(&opts.noPromptTemplates, "no-prompt-templates", false, "disable prompt-template discovery (do not load ~/.pigo/{commands,prompts}, .pigo/prompts, config prompts, or --prompt-template); built-in slash commands are unaffected")
flag.StringVar(&opts.systemPrompt, "system-prompt", "", "system prompt to use instead of the default coding-assistant prompt (mirrors pi --system-prompt)")
flag.StringArrayVar(&opts.appendSystemPrompt, "append-system-prompt", nil, "append text or file contents to the system prompt; repeatable (mirrors pi --append-system-prompt)")
flag.StringArrayVar(&opts.promptTemplates, "prompt-template", nil, "load a prompt template from a file or directory (non-recursive); repeatable (mirrors pi --prompt-template)")
flag.StringVar(&opts.thinkingLevel, "thinking-level", "", "reasoning effort: off|minimal|low|medium|high|xhigh|max (overrides PIGO_THINKING_LEVEL and config; default medium)")
flag.BoolVar(&opts.subagentRPC, "subagent-rpc", false, "internal: run as a process-isolated sub-agent JSON-RPC server over stdio (US-019)")
flag.BoolVar(&opts.dream, "dream", false, "internal: run a memory-consolidation pass over the global/project memory scope, emit a Report JSON on stdout, and exit (SPEC §4.1)")
flag.BoolVar(&opts.dreamDryRun, "dream-dry-run", false, "internal: with --dream, analyze and report without writing files or updating dream state (SPEC §5.5)")
flag.BoolVar(&opts.noTUI, "no-tui", false, "use the line-based REPL instead of the full-screen TUI")
flag.StringVarP(&opts.cwd, "cwd", "C", "", "run as if pigo was started in this directory (matches the Claude Agent SDK's cwd; like git -C): tool file access, trust, hooks, and project config all resolve against it")
flag.BoolVarP(&opts.showVersion, "version", "v", false, "print version information and exit")
// Extend the default pflag usage with a "Supported providers" block so
// `--help` documents the values accepted by --provider (name → env var →
// default base URL → protocol). The list is derived from the provider
// registry, so it never drifts from the code.
flag.Usage = func() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
cli.PrintProviderHelp(out)
}
flag.Parse()
// --cwd switches the process working directory before anything cwd-derived is
// resolved (tool roots, trust, hooks, project config, git info). Doing it here
// — after parse, before config overlay and dispatch — means every downstream
// os.Getwd() sees the requested directory, so pigo behaves as if it had been
// launched there. A bad path is a usage error (exit 2) rather than a silent
// fall-through to the original directory.
if opts.cwd != "" {
if err := os.Chdir(opts.cwd); err != nil {
fmt.Fprintf(os.Stderr, "pigo: --cwd: %v\n", err)
os.Exit(2)
}
}
// Overlay ~/.config/pigo/config.toml: file values replace built-in defaults,
// but any flag the user set on the command line still wins (CLI > file >
// default). A malformed file warns but does not abort — defaults apply.
if cfg, err := config.LoadFileConfig(config.FileConfigPath()); err != nil {
fmt.Fprintf(os.Stderr, "pigo: %v\n", err)
} else {
applyFileConfig(&opts, cfg, flag.CommandLine.Changed)
}
// --version is a standalone action: print build metadata and exit.
if opts.showVersion {
fmt.Printf("pigo %s (commit %s, built %s)\n", version, commit, date)
os.Exit(0)
}
// A prompt may also be supplied as positional args.
if opts.prompt == "" {
opts.prompt = strings.TrimSpace(strings.Join(flag.Args(), " "))
}
os.Exit(dispatch(context.Background(), opts, os.Stdout, os.Stderr))
}
// applyFileConfig overlays config.toml values onto opts, but only for flags the
// user did not set on the command line (changed reports whether a flag name was
// explicitly passed). This yields the precedence: CLI flag > config file >
// default. Zero-valued config fields never override.
func applyFileConfig(opts *cliOptions, cfg config.FileConfig, changed func(string) bool) {
if cfg.Model != "" && !changed("model") {
opts.model = cfg.Model
}
if cfg.BaseURL != "" && !changed("base-url") {
opts.baseURL = cfg.BaseURL
}
if cfg.APIKey != "" && !changed("api-key") {
opts.apiKey = cfg.APIKey
}
if cfg.Protocol != "" && !changed("protocol") {
opts.protocol = cfg.Protocol
}
if cfg.Provider != "" && !changed("provider") {
opts.provider = cfg.Provider
}
if cfg.ThinkingLevel != "" && !changed("thinking-level") {
opts.thinkingLevel = cfg.ThinkingLevel
}
if cfg.OutputFormat != "" && !changed("output-format") {
opts.outputFmt = cfg.OutputFormat
}
if cfg.NoTools && !changed("no-tools") {
opts.noTools = true
}
if cfg.NoSkills && !changed("no-skills") {
opts.noSkills = true
}
if cfg.Approve && !changed("approve") {
opts.approve = true
}
if cfg.SystemPrompt != "" && !changed("system-prompt") {
opts.systemPrompt = cfg.SystemPrompt
}
// The tool boundary follows the standard precedence (CLI > file > default)
// rather than the additive treatment prompts get below. Merging would be the
// wrong semantics for a security boundary: a user passing --allowed-tools to
// widen what the file's allowed_tools narrowed must actually get the wider
// set, not the intersection. Each flag overrides its own key independently:
// --allowed-tools does not clear a file-level disallowed_tools, and because
// deny wins on conflict a file deny survives a CLI allow — re-admitting a
// file-denied tool requires overriding --disallowed-tools on the CLI.
if len(cfg.AllowedTools) > 0 && !changed("allowed-tools") {
opts.allowedTools = cfg.AllowedTools
}
if len(cfg.DisallowedTools) > 0 && !changed("disallowed-tools") {
opts.disallowedTools = cfg.DisallowedTools
}
// prompts (settings tier) are additive with --prompt-template (CLI tier,
// wired in #339), so they are always passed through when present.
if len(cfg.Prompts) > 0 {
opts.configPrompts = cfg.Prompts
}
// The [memory]/[checkpoint]/[compaction] tables have no CLI flags, so they
// are resolved (with defaults) and overlaid unconditionally — an absent set
// of tables yields the default-safe MemorySettings.
opts.memory = cfg.ResolveMemorySettings()
// The [dream] table also has no CLI flags; normalize it (defaults applied when
// the table is absent) so the interactive startup trigger has a resolved
// Config. NewConfig treats a nil enabled as true, so dream is on by default.
opts.dreamCfg = dream.NewConfig(cfg.Dream.Enabled, cfg.Dream.IntervalDays, cfg.Dream.RecentSessions)
}
// dispatch runs the resolved command and returns a process exit code, writing
// diagnostics to errOut. It is the run-assembly seam: every path (list, REPL,
// headless, subagent-rpc) is reached from here, so the CLI's behavior can be
// exercised without re-parsing flags. A returned code of 0 is success.
func dispatch(ctx context.Context, opts cliOptions, out, errOut io.Writer) int {
// --subagent-rpc is a fully separate mode: speak the sub-agent JSON-RPC
// protocol over stdio and exit. It is the subprocess end of process-isolated
// sub-agents and shares nothing with the interactive/headless paths.
if opts.subagentRPC {
return headless.RunSubAgentRPC(ctx, os.Stdin, out, errOut)
}
// --dream is the subprocess consolidation mode (SPEC §4.1/§4.2): run one
// memory-consolidation pass to completion, emit a single-line Report JSON on
// stdout (progress/logs go to stderr), and exit 0 on success / 1 on failure.
// It runs before any interactive/headless session assembly and honors -C/--cwd
// for the project scope (applied above via os.Chdir). It shares nothing with
// the REPL/headless paths.
if opts.dream {
return runDream(ctx, opts, out, errOut)
}
// --list-sessions is a standalone action: print and exit.
if opts.listSessions {
if err := headless.PrintSessions(out); err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
return 0
}
// --continue resolves to the most recently updated session id.
resumeID := opts.resumeID
if opts.continueLast && resumeID == "" {
id, err := headless.MostRecentSessionID()
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
if id == "" {
fmt.Fprintln(errOut, "pigo: no sessions to continue")
return 1
}
resumeID = id
}
// No prompt + an interactive terminal → start the interactive UI. By default
// this is the full-screen TUI (US-001); --no-tui (or a non-terminal stdout)
// forces the line-based REPL (US-003). A --resume id also enters the
// interactive UI to continue an existing session. No prompt with a
// non-terminal stdout (pipe/CI) and no resume is an error, since there is
// nothing to run and nothing to interact with.
if opts.prompt == "" {
isTTY := ui.StdoutIsTerminal()
if resumeID == "" && !isTTY {
fmt.Fprintln(errOut, "pigo: no prompt (use -p \"...\" or positional args)")
return 2
}
env, err := run.SetupEnv(opts.model, opts.baseURL, opts.protocol, opts.provider, opts.apiKey, opts.noTools, opts.noSkills, opts.systemPrompt, opts.appendSystemPrompt, opts.memory.Memory.Enabled, run.NewToolPolicy(opts.allowedTools, opts.disallowedTools))
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return setupExitCode(err)
}
if env.Plugins != nil {
defer env.Plugins.Close()
}
if env.Memory != nil {
defer env.Memory.Close()
}
thinking, err := run.ResolveThinkingLevel(opts.thinkingLevel)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 2
}
if shouldUseTUI(opts, isTTY) {
// Refresh the cached latest-release check off the hot path so the banner
// can show an upgrade hint on this or the next launch without blocking
// startup (US-004). No-ops for dev builds or a fresh cache.
selfupdate.StartBackgroundCheck(version)
if err := tui.Run(tui.Options{
Model: opts.model,
ProviderName: env.ProviderName,
Provider: env.Provider,
BaseURL: opts.baseURL,
APIKey: opts.apiKey,
Protocol: opts.protocol,
Version: version,
ThinkingLevel: thinking,
Tools: env.Tools,
SysPrompt: env.SysPrompt,
ResumeID: resumeID,
Approve: opts.approve,
Skills: env.Skills,
Plugins: env.Plugins,
ConfigPrompts: opts.configPrompts,
CliPrompts: opts.promptTemplates,
NoPromptTemplates: opts.noPromptTemplates,
}); err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
return 0
}
if err := repl.Run(repl.Options{
Model: opts.model,
ProviderName: env.ProviderName,
Provider: env.Provider,
BaseURL: opts.baseURL,
APIKey: opts.apiKey,
Protocol: opts.protocol,
ThinkingLevel: thinking,
Tools: env.Tools,
SysPrompt: env.SysPrompt,
ResumeID: resumeID,
Approve: opts.approve,
Skills: env.Skills,
Plugins: env.Plugins,
ConfigPrompts: opts.configPrompts,
CliPrompts: opts.promptTemplates,
NoPromptTemplates: opts.noPromptTemplates,
Dream: opts.dreamCfg,
}); err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
return 0
}
mode, err := headless.ParseOutputMode(opts.outputFmt)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 2
}
env, err := run.SetupEnv(opts.model, opts.baseURL, opts.protocol, opts.provider, opts.apiKey, opts.noTools, opts.noSkills, opts.systemPrompt, opts.appendSystemPrompt, opts.memory.Memory.Enabled, run.NewToolPolicy(opts.allowedTools, opts.disallowedTools))
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return setupExitCode(err)
}
if env.Plugins != nil {
defer env.Plugins.Close()
}
if env.Memory != nil {
defer env.Memory.Close()
}
return headless.Run(ctx, headless.RunParams{
Mode: mode,
Env: env,
Prompt: opts.prompt,
Model: opts.model,
APIKey: opts.apiKey,
ThinkingLevel: opts.thinkingLevel,
ResumeID: resumeID,
}, out, errOut)
}
// setupExitCode maps a run.SetupEnv failure to a process exit code. A bad tool
// policy is a usage error (2), matching --cwd and --output-format; everything
// else — provider resolution, prompt assembly — is a runtime failure (1).
func setupExitCode(err error) int {
var policyErr *run.ToolPolicyError
if errors.As(err, &policyErr) {
return 2
}
return 1
}
// runDream executes the subprocess memory-consolidation pass (SPEC §4.1/§4.2).
// It runs dream.Runner to completion, marshals the resulting Report as a single
// line of JSON on stdout (the parent/scheduler parses this), and returns the
// process exit code: 0 on success (including a "skipped" run when another dream
// holds the lock) or 1 on failure. Progress and diagnostics go to errOut. The
// project scope comes from the working directory, which -C/--cwd already applied
// via os.Chdir before dispatch, so an empty ProjectDir here resolves to cwd.
func runDream(ctx context.Context, opts cliOptions, out, errOut io.Writer) int {
projectDir, err := os.Getwd()
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
// The dream pass reuses the main-session model (SPEC Q3): resolve the same
// model/provider/api-key tuple cmd/pigo already overlaid from flags+config,
// and inject a real LLM-backed Consolidator so `pigo --dream` performs the
// semantic merge/prune step (not just the deterministic dedup/path-clean).
thinking, err := run.ResolveThinkingLevel(opts.thinkingLevel)
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
cons, err := dream.NewLLMConsolidator(opts.model, opts.baseURL, opts.protocol, opts.provider, opts.apiKey, thinking)
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
r := &dream.Runner{Consolidator: cons}
report, err := r.Run(ctx, dream.RunOptions{
DryRun: opts.dreamDryRun,
ProjectDir: projectDir,
})
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
// Single-line JSON on stdout is the stdout contract (SPEC §4.2). Encoder
// writes a trailing newline, keeping the report one line.
if err := json.NewEncoder(out).Encode(report); err != nil {
fmt.Fprintf(errOut, "pigo: dream: encode report: %v\n", err)
return 1
}
return 0
}
// shouldUseTUI is the pure entry-gating predicate for the no-prompt path
// (US-001, SPEC 4.2/5.2): the full-screen TUI is used only when stdout is a TTY
// and --no-tui was not set. --no-tui or a non-terminal stdout always forces the
// line-based REPL. Keeping the decision in a side-effect-free function lets the
// gating be unit-tested without a real terminal or spawning Bubble Tea (see
// TestDispatchTUIGating); dispatch handles the non-TTY/no-resume usage error
// before calling this, so it only decides TUI-vs-REPL for the interactive case.
func shouldUseTUI(opts cliOptions, isTTY bool) bool {
return isTTY && !opts.noTUI
}
// updateIsSelfUpdate classifies the arguments that follow `pigo update` (US-003)
// to route between binary self-update and pkgmgr package-update. It returns true
// — self-update — when no positional package name is present: any argument that
// does not begin with '-' is treated as a package name and routes to
// package-update, while flags-only invocations (e.g. `pigo update --check`) stay
// on the self-update path. Keeping the decision side-effect-free lets the routing
// be unit-tested without spawning either update path (see TestUpdateIsSelfUpdate).
func updateIsSelfUpdate(rest []string) bool {
for _, a := range rest {
if !strings.HasPrefix(a, "-") {
return false
}
}
return true
}
+350
View File
@@ -0,0 +1,350 @@
package main
// Tests for the thin CLI entry point: the dispatch seam (options+writers →
// exit code), the config.toml overlay (applyFileConfig precedence), and the
// settings-tier prompts pass-through. These exercise the branching without
// spawning a provider or re-parsing the global flag set.
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/cli/config"
"github.com/smallnest/pigo/internal/cli/run"
)
// --- dispatch seam ---
// TestDispatchListSessionsEmpty verifies --list-sessions is a standalone action
// that succeeds (exit 0) and prints the empty-store message, using an isolated
// PIGO_HOME so it never touches the real session store.
func TestDispatchListSessionsEmpty(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{listSessions: true}, &out, &errOut)
if code != 0 {
t.Fatalf("exit code = %d, want 0 (errOut=%q)", code, errOut.String())
}
if !strings.Contains(out.String(), "no sessions") {
t.Errorf("out = %q, want the empty-store message", out.String())
}
}
// TestDispatchContinueNoSessions verifies --continue with an empty store is an
// error (exit 1) that says there is nothing to continue, rather than starting a
// blank REPL.
func TestDispatchContinueNoSessions(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
// Ensure a non-terminal path is not taken before the continue guard: continue
// resolves the id first and errors when the store is empty.
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{continueLast: true}, &out, &errOut)
if code != 1 {
t.Fatalf("exit code = %d, want 1 (errOut=%q)", code, errOut.String())
}
if !strings.Contains(errOut.String(), "no sessions to continue") {
t.Errorf("errOut = %q, want the no-sessions-to-continue message", errOut.String())
}
}
// TestDispatchNoPromptNonTerminal verifies the CI/pipe guard: no prompt, no
// resume, and a non-terminal stdout is a usage error (exit 2) with a diagnostic
// on errOut — reachable now that dispatch takes its writers as parameters.
func TestDispatchNoPromptNonTerminal(t *testing.T) {
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{}, &out, &errOut)
if code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(errOut.String(), "no prompt") {
t.Errorf("errOut = %q, want it to mention the missing prompt", errOut.String())
}
}
// TestDispatchBadOutputFormat verifies an unknown --output-format is rejected
// (exit 2) before any provider work, naming the offending value.
func TestDispatchBadOutputFormat(t *testing.T) {
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{prompt: "hi", outputFmt: "yaml"}, &out, &errOut)
if code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(errOut.String(), "yaml") {
t.Errorf("errOut = %q, want it to name the bad format", errOut.String())
}
}
// TestDispatchTUIGating verifies the pure entry-gating predicate shouldUseTUI
// (US-001, SPEC 4.2/5.2) that dispatch uses to choose the full-screen TUI vs the
// line-based REPL on the no-prompt path. The decision is tested directly so it
// needs no real TTY and never spawns Bubble Tea: TUI only when stdout is a TTY
// and --no-tui is unset; --no-tui or a non-terminal stdout always forces REPL.
func TestDispatchTUIGating(t *testing.T) {
tests := []struct {
name string
opts cliOptions
isTTY bool
want bool
}{
{name: "TTY and no flag uses TUI", opts: cliOptions{}, isTTY: true, want: true},
{name: "--no-tui forces REPL on a TTY", opts: cliOptions{noTUI: true}, isTTY: true, want: false},
{name: "non-TTY never uses TUI", opts: cliOptions{}, isTTY: false, want: false},
{name: "non-TTY with --no-tui stays REPL", opts: cliOptions{noTUI: true}, isTTY: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldUseTUI(tt.opts, tt.isTTY); got != tt.want {
t.Errorf("shouldUseTUI(%+v, %v) = %v, want %v", tt.opts, tt.isTTY, got, tt.want)
}
})
}
}
// TestCwdChdirRootsEnv verifies the guarantee --cwd relies on: after the
// process working directory is switched (what the --cwd flag does via os.Chdir),
// run.SetupEnv roots the run — and thus the built-in file tools — at that
// directory. This is the contract that lets pigo be pointed at an arbitrary
// project root as an SDK backend. It exercises the downstream effect rather than
// re-parsing flags, since the chdir itself lives in main().
func TestCwdChdirRootsEnv(t *testing.T) {
orig, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir: %v", err)
}
// macOS temp dirs are symlinks (/tmp → /private/tmp); os.Getwd resolves them,
// so compare against the resolved form.
want, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
env, err := run.SetupEnv("openrouter/free", "", "", "", "", true /*noTools*/, true /*noSkills*/, "", nil, false /*memEnabled*/, run.ToolPolicy{})
if err != nil {
t.Fatalf("SetupEnv: %v", err)
}
if env.Cwd != want {
t.Errorf("env.Cwd = %q, want %q (the chdir'd directory)", env.Cwd, want)
}
}
// TestUpdateIsSelfUpdate verifies the US-003 `pigo update` routing classifier:
// no positional package name (including flags-only invocations like
// `pigo update --check`) routes to binary self-update (true); any positional
// package name routes to pkgmgr package-update (false). Tested directly so the
// dispatch split needs no argv parsing or spawning either update path.
func TestUpdateIsSelfUpdate(t *testing.T) {
tests := []struct {
name string
rest []string
want bool
}{
{name: "no args is self-update", rest: nil, want: true},
{name: "empty slice is self-update", rest: []string{}, want: true},
{name: "flags-only is self-update", rest: []string{"--check"}, want: true},
{name: "multiple flags is self-update", rest: []string{"--check", "-v"}, want: true},
{name: "single package name is package-update", rest: []string{"pi-mcp-adapter"}, want: false},
{name: "multiple package names is package-update", rest: []string{"a", "b"}, want: false},
{name: "flag then package name is package-update", rest: []string{"--check", "pkg"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := updateIsSelfUpdate(tt.rest); got != tt.want {
t.Errorf("updateIsSelfUpdate(%v) = %v, want %v", tt.rest, got, tt.want)
}
})
}
}
// --- config.toml overlay ---
// changedSet turns a set of flag names into a lookup func for applyFileConfig.
func changedSet(names ...string) func(string) bool {
set := make(map[string]bool, len(names))
for _, n := range names {
set[n] = true
}
return func(name string) bool { return set[name] }
}
func TestApplyFileConfig_FillsUnsetFlags(t *testing.T) {
opts := cliOptions{model: "openrouter/free", outputFmt: "text"}
cfg := config.FileConfig{
Model: "claude-opus-4-8",
BaseURL: "https://example.com",
APIKey: "sk-test",
Protocol: "anthropic",
Provider: "deepseek",
ThinkingLevel: "high",
OutputFormat: "stream-json",
NoTools: true,
NoSkills: true,
Approve: true,
SystemPrompt: "be terse",
}
applyFileConfig(&opts, cfg, changedSet())
if opts.model != "claude-opus-4-8" {
t.Errorf("model = %q, want claude-opus-4-8", opts.model)
}
if opts.baseURL != "https://example.com" {
t.Errorf("baseURL = %q", opts.baseURL)
}
if opts.apiKey != "sk-test" {
t.Errorf("apiKey = %q", opts.apiKey)
}
if opts.protocol != "anthropic" {
t.Errorf("protocol = %q", opts.protocol)
}
if opts.provider != "deepseek" {
t.Errorf("provider = %q", opts.provider)
}
if opts.thinkingLevel != "high" {
t.Errorf("thinkingLevel = %q", opts.thinkingLevel)
}
if opts.outputFmt != "stream-json" {
t.Errorf("outputFmt = %q", opts.outputFmt)
}
if !opts.noTools || !opts.noSkills || !opts.approve {
t.Errorf("bool flags not applied: %+v", opts)
}
if opts.systemPrompt != "be terse" {
t.Errorf("systemPrompt = %q", opts.systemPrompt)
}
}
func TestApplyFileConfig_CLIWins(t *testing.T) {
opts := cliOptions{model: "cli-model", outputFmt: "text"}
cfg := config.FileConfig{Model: "config-model", OutputFormat: "stream-json"}
// --model was set on the command line; --output-format was not.
applyFileConfig(&opts, cfg, changedSet("model"))
if opts.model != "cli-model" {
t.Errorf("CLI model should win, got %q", opts.model)
}
if opts.outputFmt != "stream-json" {
t.Errorf("unset output-format should take config value, got %q", opts.outputFmt)
}
}
func TestApplyFileConfig_EmptyConfigNoChange(t *testing.T) {
opts := cliOptions{model: "openrouter/free", outputFmt: "text"}
applyFileConfig(&opts, config.FileConfig{}, changedSet())
if opts.model != "openrouter/free" || opts.outputFmt != "text" {
t.Fatalf("empty config should not change opts, got %+v", opts)
}
if opts.baseURL != "" || opts.provider != "" || opts.noTools {
t.Fatalf("empty config should leave unset fields empty, got %+v", opts)
}
}
func TestApplyFileConfigPrompts(t *testing.T) {
var opts cliOptions
cfg := config.FileConfig{Prompts: []string{"./my-prompts", "/abs/x.md"}}
applyFileConfig(&opts, cfg, func(string) bool { return false })
if len(opts.configPrompts) != 2 || opts.configPrompts[0] != "./my-prompts" || opts.configPrompts[1] != "/abs/x.md" {
t.Errorf("opts.configPrompts = %v, want [./my-prompts /abs/x.md]", opts.configPrompts)
}
}
// The tool boundary follows CLI > file > default like the other scalar flags,
// rather than the additive treatment `prompts` gets: merging would prevent a CLI
// flag from widening a boundary the config file narrowed.
func TestApplyFileConfigToolPolicy(t *testing.T) {
t.Run("fills unset flags", func(t *testing.T) {
var opts cliOptions
cfg := config.FileConfig{
AllowedTools: []string{"read", "grep"},
DisallowedTools: []string{"bash"},
}
applyFileConfig(&opts, cfg, changedSet())
if len(opts.allowedTools) != 2 || opts.allowedTools[0] != "read" {
t.Errorf("allowedTools = %v, want [read grep]", opts.allowedTools)
}
if len(opts.disallowedTools) != 1 || opts.disallowedTools[0] != "bash" {
t.Errorf("disallowedTools = %v, want [bash]", opts.disallowedTools)
}
})
t.Run("CLI replaces file value wholesale", func(t *testing.T) {
opts := cliOptions{allowedTools: []string{"bash"}}
cfg := config.FileConfig{AllowedTools: []string{"read"}, DisallowedTools: []string{"write"}}
applyFileConfig(&opts, cfg, changedSet("allowed-tools"))
if len(opts.allowedTools) != 1 || opts.allowedTools[0] != "bash" {
t.Errorf("CLI --allowed-tools must win outright, got %v", opts.allowedTools)
}
if len(opts.disallowedTools) != 1 || opts.disallowedTools[0] != "write" {
t.Errorf("unset --disallowed-tools should take the config value, got %v", opts.disallowedTools)
}
})
t.Run("absent config leaves the boundary open", func(t *testing.T) {
var opts cliOptions
applyFileConfig(&opts, config.FileConfig{}, changedSet())
if opts.allowedTools != nil || opts.disallowedTools != nil {
t.Errorf("empty config must not constrain tools, got %v / %v", opts.allowedTools, opts.disallowedTools)
}
})
}
// setupExitCode maps a bad tool policy to the usage exit code (2) and everything
// else to a runtime failure (1), so a typo is distinguishable from e.g. a
// provider-resolution error.
func TestSetupExitCode(t *testing.T) {
if got := setupExitCode(&run.ToolPolicyError{UnknownAllowed: []string{"raed"}}); got != 2 {
t.Errorf("setupExitCode(ToolPolicyError) = %d, want 2 (usage error)", got)
}
if got := setupExitCode(errors.New("provider boom")); got != 1 {
t.Errorf("setupExitCode(generic) = %d, want 1", got)
}
if got := setupExitCode(fmt.Errorf("wrapped: %w", &run.ToolPolicyError{})); got != 2 {
t.Errorf("setupExitCode must unwrap, got %d, want 2", got)
}
}
// applyFileConfig always resolves the [memory]/[checkpoint]/[compaction] tables
// into opts.memory, applying defaults when they are absent.
func TestApplyFileConfig_MemoryDefaults(t *testing.T) {
var opts cliOptions
applyFileConfig(&opts, config.FileConfig{}, changedSet())
if !opts.memory.Memory.Enabled || !opts.memory.Memory.ReconcileOnSearch {
t.Errorf("memory defaults not applied: %+v", opts.memory.Memory)
}
if opts.memory.Memory.SearchScoreFloor != 0.15 {
t.Errorf("search_score_floor default = %v, want 0.15", opts.memory.Memory.SearchScoreFloor)
}
if len(opts.memory.CheckpointThresholds) != 3 {
t.Errorf("checkpoint thresholds default = %v, want 3 entries", opts.memory.CheckpointThresholds)
}
if opts.memory.MaxContext.IsSet() {
t.Errorf("max_context should be unset by default")
}
}
// A configured [memory]/[compaction] set overlays into opts.memory.
func TestApplyFileConfig_MemoryOverride(t *testing.T) {
var opts cliOptions
enabled := false
cfg := config.FileConfig{
Memory: config.MemoryConfig{Enabled: &enabled},
Compaction: config.CompactionConfig{MaxContext: "50%"},
}
applyFileConfig(&opts, cfg, changedSet())
if opts.memory.Memory.Enabled {
t.Errorf("memory.enabled=false should overlay, got true")
}
if got := opts.memory.MaxContext.Resolve(200000); got != 100000 {
t.Errorf("max_context 50%% of 200000 = %d, want 100000", got)
}
}
+92
View File
@@ -0,0 +1,92 @@
# Pigo global configuration (example template)
# Copy this file to ~/.config/pigo/config.toml and fill in your own values.
# https://github.com/smallnest/pigo
# Provider base URL (overrides the provider's default endpoint).
# Leave unset to use the selected provider's built-in default.
# base_url = "https://api.anthropic.com/v1"
# Default model to use.
model = "claude-opus-4-8"
# API key for authentication.
# Prefer supplying this via the <PROVIDER>_API_KEY environment variable
# (e.g. ANTHROPIC_API_KEY) instead of committing it to a config file.
# api_key = "your-api-key-here"
# Output format: text | stream-json
output_format = "text"
# Trust the working directory for this run: skip the first-launch trust prompt.
approve = false
# Disable the built-in file/shell tools.
no_tools = false
# Tool-level admission control (the middle ground between "all tools" and
# no_tools). Names match case-insensitively, so "Read" hits the built-in "read".
# disallowed_tools wins when a name appears in both lists (fail-closed).
#
# A boundary declared here is a HARD boundary: filtering happens at the tool-
# registration layer, so `approve = true` waives per-call confirmation but can
# never let the model reach a tool outside the boundary. Task sub-agents inherit
# it too. An unknown tool name aborts startup with exit code 2 rather than being
# silently ignored.
#
# Each CLI flag REPLACES its own file value wholesale (it does not merge):
# --allowed-tools overrides allowed_tools, --disallowed-tools overrides
# disallowed_tools, independently. So a CLI --allowed-tools can widen what
# allowed_tools narrowed. Note the two lists stay independent and deny still
# wins: a file-level disallowed_tools is NOT lifted by a CLI --allowed-tools —
# to re-admit a tool the file denied, override --disallowed-tools on the CLI.
# Parameter-level forms such as Bash(git log:*) are not supported yet.
# allowed_tools = ["read", "grep"]
# disallowed_tools = ["bash", "bash_output", "kill_bash"]
# Disable skill discovery (do not load skills under ~/.agents/skills as /skill-name commands).
no_skills = false
# Force wire protocol for a custom endpoint: openai | anthropic
# protocol = "anthropic"
# Run in headless print mode with the given prompt.
# print = ""
# Resume the most recent interactive session.
# continue = false
# List stored interactive sessions and exit.
# list_sessions = false
# Resume the interactive session with this id.
# resume = ""
# Internal: run as a process-isolated sub-agent JSON-RPC server over stdio (US-019).
# subagent_rpc = false
# --- Persistent memory + infinite context (nested tables) ---
# [memory] controls the persistent memory system. All keys are optional and
# default to the values shown; set memory.enabled = false to fully disable it.
# [memory]
# enabled = true # master switch for the memory system
# reconcile_on_search = true # lazily re-index memory files before each search
# search_score_floor = 0.15 # drop search hits below this relevance score (0..1)
# cc_index = false # also index the Claude Code memory directory (read-only)
# [checkpoint] controls infinite-context checkpointing.
# [checkpoint]
# thresholds = ["40%", "60%", "80%"] # context-window fill levels that trigger compaction
# reserved = 4096 # optional: tokens to reserve (int) or a percentage ("10%")
# [checkpoint.push_caps] caps per-section injection token budgets.
# [checkpoint.push_caps]
# memory = 800
# recall = 1200
# [compaction] tunes auto-compaction.
# [compaction]
# max_context accepts a token count (300000), a K/M suffix ("300K", "1M"), or a
# percentage of the provider window ("50%"). It only lowers the trigger point and
# is always clamped by the provider limit.
# max_context = "300K"
+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 维护,供 --resume |
| /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。
+56
View File
@@ -0,0 +1,56 @@
# pigo 协作镜像(单 agent 版)
#
# 构建(在仓库根目录执行,先本地交叉编译 pigo,再 docker build):
# $env:GOOS="linux"; $env:CGO_ENABLED="0"
# go build -trimpath -ldflags="-s -w" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo
# docker build -f coop/Dockerfile -t pigo-coop .
# 或直接用 Linux 的 Go 工具链时跳过交叉编译步骤。
#
# 运行示例(模型后端由外部调用方通过环境变量传入):
# docker run --rm \
# -e MODEL=deepseek-chat \
# -e BASE_URL=https://api.deepseek.com \
# -e API_KEY=<your-key> \
# -e TASK="请为 XX 编写设计文档并实现原型" \
# -e ROUND_MAX=6 \
# pigo-coop
#
# 协作方式:单个 pigo 进程在工作区 /blackboard/workspace 内完成 task.md 的任务,
# 用 --resume 保持跨轮连续上下文;完成时通过 blackboard 工具创建 DONE
# supervisor 把结果以结构化 JSON/blackboard/result.json)落盘。
#
# 基础镜像复用本机已有的 pigo-worker:latestAlpine,已含 bash/git/wget/flock/
# timeout/CA 证书与静态 pigo 二进制),这里仅覆盖 pigo 二进制为新代码(含
# blackboard 工具)并加入 supervisor 与协作 prompt,避免重新拉取 golang/alpine。
FROM pigo-worker:latest
# solve/pentest tools: base image only ships busybox (no curl/python3), add them here.
# pigo-worker 镜像 USER=agentRUN 层以非 root 执行,apk 无法锁库,先切 root 再恢复。
USER root
RUN apk add --no-cache curl wget python3 py3-pip jq openssl
# Python 常用库:网络/加密/数据/解析等,覆盖多数协作与 CTF 场景。
# 使用清华 PyPI 镜像加速国内构建;--no-cache-dir 避免膨胀镜像。
# --break-system-packagesAlpine 3.20+ 标记 externally-managedpip 全局安装需显式放行。
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
USER agent
# 覆盖 pigo 二进制:coop/tmp/pigo-linux-amd64 由本地 Go 交叉编译(GOOS=linux CGO_ENABLED=0)。
# legacy builder 不支持 COPY --chmod(需 BuildKit)。权限位由主机侧 chmod 提供(构建前先 chmod 755)。
COPY coop/tmp/pigo-linux-amd64 /usr/local/bin/pigo
COPY coop/supervisor.sh /usr/local/bin/supervisor.sh
COPY coop/prompts /prompts
COPY coop/AGENTS.md /prompts/AGENTS.md
WORKDIR /blackboard
ENTRYPOINT ["/usr/local/bin/supervisor.sh"]
+196
View File
@@ -0,0 +1,196 @@
# pigo 协作 —— 调用文档
> 在 Docker 容器内运行一个 pigo agent(单 agent)完成 /blackboard/task.md 中的任务,工作区为 /blackboard/workspace,完成时通过 blackboard 工具创建 DONEsupervisor 把结果以结构化 JSONresult.json)落盘,供外部调度方直接解析。
---
## 1. 原理与架构
```
+------------------ docker 容器 pigo-coop ----------------+
| |
TASK/MODEL/ | +---------- supervisor.sh(单 agent 编排)-------+ |
API_KEY ──────┼─▶ | 每轮运行一个 pigo 进程: | |
| | └─ agent ──▶ cwd = /blackboard/workspace | |
| | (--resume 会话 · 角色 prompt agent.md) | |
| +──────┬────────────────────────────────----------+ |
| ▼ |
| /blackboard(任务黑板:task.md + workspace + DONE |
+-----------------------------------------------------------+
```
- **单 agent**:每轮一个 pigo 进程串行推进,无评审者;工作区 `/blackboard/workspace` 是普通文件工具(read/write/bash)的根目录。
- **原子完成标记**`DONE` 只能通过 `blackboard` 工具 `done``O_CREATE|O_EXCL`)创建,supervisor 检测到 `DONE` 即收尾。
- **连续上下文**:headless 运行自动持久化会话,supervisor 从 stream-json 首事件提取 `sessionId`,下一轮用 `--resume` 恢复,agent 跨轮记住自己的思考。
- **协议注入**:任务协议(AGENTS.md)副本放入工作区,pigo 自动注入系统提示;角色 prompt 通过 `--append-system-prompt` 注入。
- **结构化结果**:任务结束(成功/未解出/超时/失败)统一写 `$BB/result.json`,字段:`status``exit_code``summary``flag``artifacts`
---
## 2. 前置条件
| 依赖 | 说明 |
| --- | --- |
| Go 工具链 | 版本与 `go.mod` 一致(当前 `go 1.27rc1`),用于交叉编译 pigo 二进制 |
| Docker | 本机已有基础镜像 `pigo-worker:latest`Alpine,含 bash/git/wget/flock/timeout/CA 证书) |
| 模型后端 | 任意 OpenAI 兼容 API(如 DeepSeek、Ollama、OpenRouter),需可访问的 base-url 与 API key |
---
## 3. 构建
### 3.1 交叉编译 pigo(含 blackboard 工具)
Windows PowerShell
```powershell
$env:GOOS="linux"; $env:CGO_ENABLED="0"
go build -trimpath -ldflags="-s -w" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo
```
Linux/macOS
```bash
GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo
```
产物 `coop/tmp/pigo-linux-amd64` 是纯静态 Linux 二进制(约 30MB)。
### 3.2 构建镜像
```bash
docker build -f coop/Dockerfile -t pigo-coop .
```
> 镜像直接 `FROM pigo-worker:latest`(复用本机已有镜像,不拉取 golang/alpine),只覆盖 pigo 二进制并加入 supervisor 与协作 prompt,构建通常秒级完成。
---
## 4. 环境变量(运行参数)
| 变量 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `MODEL` | ✅ | — | 模型名,如 `deepseek-chat` |
| `BASE_URL` | ✅ | — | OpenAI 兼容 API base-url |
| `API_KEY` | ✅ | — | API key |
| `PROTOCOL` | 否 | 按 model 推断 | 协议 `openai` / `anthropic` |
| `TASK` | 二选一 | — | 任务描述;不填则需挂载 `$BB/task.md` |
| `BLACKBOARD` | 否 | `/blackboard` | 黑板根目录(即 `BB` |
| `PROMPTS` | 否 | `/prompts` | 协作 prompt 目录(镜像内固定,一般不用改) |
| `ROUND_MAX` | 否 | `10` | 最大轮次 |
| `TIMEOUT` | 否 | `600` | 单轮超时秒数,`0`=不超时 |
| `FAIL_MODE` | 否 | `stop` | agent 失败时:`stop`=立即退出(默认)\| `continue`=继续下一轮 |
---
## 5. 运行示例
### 5.1 最小运行(真实模型)
```bash
docker run --rm \
-e MODEL=deepseek-chat \
-e BASE_URL=https://api.deepseek.com \
-e API_KEY=<your-key> \
-e TASK="请为 XX 编写设计文档并实现原型" \
-e ROUND_MAX=6 \
pigo-coop
```
### 5.2 保留黑板产物与连续会话(推荐)
```bash
mkdir -p blackboard
docker run --rm \
-v $PWD/blackboard:/blackboard \ # 黑板产物落盘
-v $HOME/.pigo:/root/.pigo \ # 保留 agent 会话(--resume 依赖)
-e MODEL=deepseek-chat \
-e BASE_URL=https://api.deepseek.com \
-e API_KEY=<your-key> \
-e TASK="..." \
pigo-coop
```
> 会话持久化在 `~/.pigo/sessions`(容器内为 `/root/.pigo`)。**不挂载 `$HOME` 时,会话只存在于容器生命周期内**——跨 `docker run` 不保留;每次 `run` 内部跨轮仍有效。
### 5.3 结果与退出码
- 任务完成(检测到 `DONE`):退出码 `0``result.json``status=solved`
- 达到 `ROUND_MAX` 仍未完成:退出码 `1``status=unsolved`,检查黑板与日志。
- 单轮超时被强杀:`status=timeout`(退出码 124)。
- 参数缺失 / 非法:退出码 `2`
---
## 6. 黑板目录结构
```
/blackboard/
├── task.md # 任务描述(勿修改)
├── AGENTS.md # 任务协议(工作区有副本)
├── workspace/ # agent 工作区 = 其 cwd(产物全部在这里)
├── DONE # 完成标记(只用 blackboard done 创建)
├── result.json # 结构化结果(supervisor 结束时生成)
├── sessions/ # agent 的 session idsupervisor 内部维护,供 --resume
└── logs/ # 每轮运行日志:round-N.log
```
---
## 7. blackboard 工具(agent 侧 API
pigo 在 `BB` 环境变量存在时自动注册 `blackboard` 工具;普通运行不受影响。agent 通过该工具原子读写黑板:
| 操作 | 参数 | 行为 |
| --- | --- | --- |
| `read` | (可选 `path` | 无 `path`:返回黑板全局快照(task.md、workspace 清单、DONE 状态、环境);有 `path`:返回黑板内单文件内容(如 `workspace/exploit.py` |
| `post` | `file``content` | 原子追加消息到 `messages/<file>``file` 必须是裸 `*.md` 名(防目录穿越),内容 ≤ 32 KiB |
| `done` | `summary` | 原子创建 `DONE``O_CREATE|O_EXCL`);已存在则报错且不覆盖。仅交付完整时调用 |
环境变量 `BB` / `ROUND` / `NAME` 由 supervisor 注入。
---
## 8. 验证与调试
### 8.1 无模型端到端验证(mock)
`coop/tmp/mock_openai.py` 是一个 OpenAI 兼容 mock server,驱动 agent 依次调用 `blackboard done`,用于验证单 agent 编排与 DONE 原子性:
```bash
# 终端 1:启动 mock(本机)
python coop/tmp/mock_openai.py 8899
# 终端 2:运行容器,base-url 指向 mock(按环境替换宿主地址)
docker run --rm \
-v $PWD/blackboard:/blackboard \
-e MODEL=mock -e BASE_URL=http://<host-ip>:8899 -e API_KEY=test \
-e TASK="验证任务" -e ROUND_MAX=3 -e TIMEOUT=120 \
pigo-coop
```
预期:agent 完成并创建 `DONE`,容器退出 0`blackboard/result.json``status=solved`
### 8.2 查看日志
```bash
# 黑板挂载后:
cat blackboard/logs/round-1.log # 第 1 轮的 stream-json 事件
cat blackboard/sessions/agent.session # 下一轮 --resume 用的 session id
```
### 8.3 常见问题
| 现象 | 原因 / 处理 |
| --- | --- |
| 退出码 `2` 且提示缺 MODEL/BASE_URL/API_KEY | 未传必填环境变量 |
| 容器内报 `DONE already exists` | 正常:DONE 幂等,上一次运行已留标记 |
| 跨 run 后 agent 不记得之前轮次 | 未挂载 `$HOME/.pigo`;会话在容器内 `~/.pigo/sessions``--rm` 后丢失 |
| `docker build` 报找不到 `pigo-worker:latest` | 先 `docker pull` 或在目标机导入该基础镜像 |
| 构建期 `chmod` 被拒 | 已用 `COPY --chmod=755` 规避(NTFS 挂载构建上下文会丢可执行位) |
---
## 9. 任务协议要点(详见 `/blackboard/AGENTS.md`
每轮:**读取 task.md → 检查已有产物 → 推进任务 → 完成后用 blackboard done 创建 DONE**。红线:不直接写 DONE;不伪造命令/提交结果;不修改 task.md 与 AGENTS.md。
+107
View File
@@ -0,0 +1,107 @@
# pigo 协作运行指南
> 只讲怎么跑。原理、架构、完整参数见 [README.md](./README.md)。
---
## 1. 前置检查(1 分钟)
```bash
go version # 与 go.mod 一致(当前 go 1.27rc1
docker images # 确认已有 pigo-worker:latest
```
没有 `pigo-worker:latest` 时先导入它,否则构建会失败。
---
## 2. 构建
```bash
# Windows PowerShell
$env:GOOS="linux"; $env:CGO_ENABLED="0"
go build -trimpath -ldflags="-s -w" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo
# Linux/macOS
GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o coop/tmp/pigo-linux-amd64 ./cmd/pigo
```
```bash
docker build -f coop/Dockerfile -t pigo-coop .
```
---
## 3. 运行
### 3.1 直接运行(不保留任何数据)
```bash
docker run --rm \
-e MODEL=deepseek-chat \
-e BASE_URL=https://api.deepseek.com \
-e API_KEY=<your-key> \
-e TASK="请为 XX 编写设计文档并实现原型" \
-e ROUND_MAX=6 \
pigo-coop
```
### 3.2 推荐运行(保留黑板产物 + 跨 run 会话)
```bash
mkdir -p blackboard
docker run --rm \
-v $PWD/blackboard:/blackboard \
-v $HOME/.pigo:/root/.pigo \
-e MODEL=deepseek-chat \
-e BASE_URL=https://api.deepseek.com \
-e API_KEY=<your-key> \
-e TASK="请为 XX 编写设计文档并实现原型" \
-e ROUND_MAX=6 \
pigo-coop
```
> 不挂载 `$HOME/.pigo` 时,agent 会话只在容器生命周期内有效(单次 run 内跨轮不受影响)。
### 3.3 常用参数速查
| 参数 | 必填 | 默认 | 用途 |
| --- | --- | --- | --- |
| `MODEL` | ✅ | — | 模型名 |
| `BASE_URL` | ✅ | — | OpenAI 兼容 base-url |
| `API_KEY` | ✅ | — | API key |
| `TASK` | ✅(或用 `task.md` | — | 任务描述 |
| `ROUND_MAX` | 否 | `10` | 最大轮次 |
| `TIMEOUT` | 否 | `600` | 单轮超时秒数(`0`=不限) |
| `PROTOCOL` | 否 | 按模型推断 | `openai` / `anthropic` |
---
## 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 轮运行日志
```
`result.json` 是给外部调度方(如 agent-web 的 run_coop 工具)直接解析的标准结果文件,字段固定:`status``exit_code``summary``flag``artifacts`
---
## 5. 常见运行问题
| 报错 / 现象 | 处理 |
| --- | --- |
| 退出 2`必须提供 MODEL / BASE_URL / API_KEY` | 补传环境变量 |
| `DONE already exists` | 正常,上一次运行已留标记(幂等) |
| 找不到 `pigo-worker:latest` | 先导入基础镜像再 build |
| 想从头重跑 | 删除挂载目录里的 `DONE``result.json` 后重新 run |
+19
View File
@@ -0,0 +1,19 @@
# 构建 pigo 协作镜像(单 agent 版:一个 pigo 进程完成任务,blackboard 工具负责原子创建 DONE
# 用法(仓库根目录执行): powershell -File coop/build.ps1
$ErrorActionPreference = "Stop"
docker build -f coop/Dockerfile -t pigo-coop .
Write-Host ""
Write-Host "构建完成: pigo-coop"
Write-Host ""
Write-Host "运行示例(外部调用方传入模型/API 信息与任务):"
Write-Host ' docker run --rm \'
Write-Host ' -e MODEL=deepseek-chat \'
Write-Host ' -e BASE_URL=https://api.deepseek.com \'
Write-Host ' -e API_KEY=<your-key> \'
Write-Host ' -e TASK="请为 XX 编写设计文档并实现原型" \'
Write-Host ' -e ROUND_MAX=6 \'
Write-Host ' pigo-coop'
Write-Host ""
Write-Host "黑板产物默认在容器内 /blackboard,如需保留可挂载卷: -v $PWD/blackboard:/blackboard"
+18
View File
@@ -0,0 +1,18 @@
# 角色:agent(主执行者)
你是 pigo 单 agent 任务执行者,独立完成 /blackboard/task.md 中的任务。
## 你的职责
- 理解任务、制定方案、完成核心产出。
- 所有产物写入你的工作区(你的 cwd,即 /blackboard/workspace/),按需命名。
- 需要提交 flag 的任务:拿到 flag 后立即按任务中的提交规则提交,并把提交响应与得分记录在产物中。
## 工作方式
- 每轮:读取 /blackboard/task.md 与工作区已有产物 → 推进任务 → 将进展与产物写入工作区。
- 环境变量 ROUND、NAME、BB 由 supervisor 提供;需要记录进展或创建完成标记时用 blackboard 工具(BB 指向黑板根目录)。
- 不要伪造命令结果、文件内容或提交响应;如实记录。
- 全部工作完成、交付物完整时,用 blackboard 工具 action=done 创建完成标记(summary 写最终交付总结,含关键结果、flag、提交响应、产物清单)。
## 注意
- 若本轮未完成,下一轮会用 --resume 恢复你的会话继续推进,跨轮保持上下文。
- 宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 action=done 标记,summary 写明「未解出」与已尝试内容,让调度方及时切换下一题。
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# pigo 协作 supervisor(单 agent 版)
#
# 在容器内运行一个 pigo headless 进程完成 /blackboard/task.md 中的任务。
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
# 任务完成(agent 用 blackboard 工具创建 DONE)后,supervisor 把结果
# 以结构化 JSONresult.json)落盘并打印一行摘要,供外部调度方直接解析。
#
# 与旧双 agent 版的关键差异:
# 1. 单 agent:每轮只运行一个 pigo 进程,无评审者,串行推进直至 DONE。
# 2. 结构化结果:无论成功/未解出/超时/失败,统一写 $BB/result.json
# status / exit_code / summary / flag / artifacts),主 agent 无需再
# 漫游黑板文件逐段猜测结果。
# 3. 日志精简:supervisor 只输出带 [supervisor] 前缀的状态行;agent 的
# stream-json 事件只写日志文件,不再混入 stdout。
#
# 环境变量(均由外部调用方传入):
# MODEL 模型名,如 deepseek-chat (必填)
# BASE_URL OpenAI 兼容 API base-url (必填)
# API_KEY API key (必填)
# PROTOCOL 协议 openai|anthropic (可选,默认按 model 推断)
# TASK 任务描述 (必填;或挂载 $BB/task.md
# BLACKBOARD 黑板目录 (默认 /blackboard
# PROMPTS 协作 prompt 目录 (默认 /prompts
# ROUND_MAX 最大轮次 (默认 10)
# TIMEOUT 单轮超时秒数,0=不超时 (默认 600)
# FAIL_MODE agent 失败时:stop=立即退出(默认)| continue=继续下一轮
set -u
BB="${BLACKBOARD:-/blackboard}"
PROMPTS="${PROMPTS:-/prompts}"
ROUND_MAX="${ROUND_MAX:-10}"
TIMEOUT="${TIMEOUT:-600}"
FAIL_MODE="${FAIL_MODE:-stop}"
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 下的全部文件(最多 50 个),经 python3 转义为 JSON 数组
if [ -d "$BB/workspace" ]; then
artifacts_json=$(
cd "$BB/workspace" && find . -type f 2>/dev/null | sed 's|^\./||' | head -50 |
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 截断到 4000 字符并经 python3 转义,防止引号/换行破坏 JSON
summary_json=$(printf '%s' "$summary" | head -c 4000 | 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")"
# ---- 单轮运行:一个 pigo 进程,输出 stream-json(首事件携带 sessionId)供 --resume ----
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 -C "$BB/workspace" -o stream-json \
--model "$MODEL" --base-url "$BASE_URL" --api-key "$API_KEY" \
--append-system-prompt "$PROMPTS/agent.md")
[ -n "${PROTOCOL:-}" ] && args+=(--protocol "$PROTOCOL")
if [ -s "$session_file" ]; then
args+=(--resume "$(cat "$session_file")")
fi
export ROUND="$round" NAME="agent" BB="$BB"
local logfile="$BB/logs/round-$round.log"
log "$round 轮开始运行 agent(日志:$logfile"
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
timeout "$TIMEOUT" pigo "${args[@]}" > "$logfile" 2>&1
else
pigo "${args[@]}" > "$logfile" 2>&1
fi
local rc=$?
# 从 stream-json 首事件提取 sessionId 供下一轮 --resumeagent_start 事件带 env.sessionId
local sid
sid=$(grep -o '"sessionId":"[^"]*"' "$logfile" 2>/dev/null | head -1 | cut -d'"' -f4)
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
+68
View File
@@ -0,0 +1,68 @@
module github.com/smallnest/pigo
go 1.27rc1
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/BurntSushi/toml v1.6.0
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/coder/websocket v1.8.13
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/openai/openai-go v1.12.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/pflag v1.0.10
golang.org/x/net v0.57.0
golang.org/x/text v0.40.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.55.0
)
require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+173
View File
@@ -0,0 +1,173 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+117
View File
@@ -0,0 +1,117 @@
#!/bin/sh
# pigo 安装脚本:检测当前操作系统 / 架构,从 GitHub Releases 下载最新的
# 预编译二进制,并安装到常用的 PATH 目录。
#
# 用法:
# curl -fsSL https://raw.githubusercontent.com/smallnest/pigo/master/install.sh | sh
#
# 可用环境变量覆盖默认行为:
# PIGO_VERSION 指定版本(形如 v0.2.0),默认取最新 release
# PIGO_INSTALL_DIR 安装目录,默认 /usr/local/bin(无写权限时回退到 ~/.local/bin
# GITHUB_TOKEN 可选,用于提高 GitHub API 速率限制
set -eu
REPO="smallnest/pigo"
BINARY="pigo"
info() { printf '%s\n' "pigo-install: $*" >&2; }
err() { printf '%s\n' "pigo-install: error: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || err "缺少依赖命令: $1"; }
# 1. 检测下载器(curl 或 wget)。
if command -v curl >/dev/null 2>&1; then
DL="curl -fsSL"
DLO="curl -fsSL -o"
elif command -v wget >/dev/null 2>&1; then
DL="wget -qO-"
DLO="wget -qO"
else
err "需要 curl 或 wget"
fi
need tar
need uname
# 2. 检测 OS,映射到 goreleaser 的归档命名(见 .goreleaser.yaml)。
os_raw=$(uname -s)
case "$os_raw" in
Linux) OS="Linux" ;;
Darwin) OS="Darwin" ;;
MINGW* | MSYS* | CYGWIN* | Windows_NT)
err "Windows 请从 Releases 页面下载 .ziphttps://github.com/$REPO/releases" ;;
*) err "不支持的操作系统: $os_raw" ;;
esac
# 3. 检测架构,映射到归档命名(amd64→x86_64386→i386arm64 保持)。
arch_raw=$(uname -m)
case "$arch_raw" in
x86_64 | amd64) ARCH="x86_64" ;;
arm64 | aarch64) ARCH="arm64" ;;
i386 | i686) ARCH="i386" ;;
*) err "不支持的架构: $arch_raw" ;;
esac
# 4. 解析目标版本:优先 PIGO_VERSION,否则查询最新 release 的 tag。
VERSION="${PIGO_VERSION:-}"
api_auth=""
[ -n "${GITHUB_TOKEN:-}" ] && api_auth="-H Authorization:\ Bearer\ $GITHUB_TOKEN"
if [ -z "$VERSION" ]; then
info "查询最新 release ..."
# 从 GitHub API 的 latest 端点提取 tag_name。
latest_json=$($DL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null) || \
err "无法访问 GitHub API,请检查网络或用 PIGO_VERSION 指定版本"
VERSION=$(printf '%s' "$latest_json" | grep -o '"tag_name"[ ]*:[ ]*"[^"]*"' | head -n1 | sed 's/.*"tag_name"[ ]*:[ ]*"\([^"]*\)".*/\1/')
[ -n "$VERSION" ] || err "无法解析最新版本号,请用 PIGO_VERSION 指定"
fi
# 归档名里的版本号不带前导 vgoreleaser 的 .Version)。
VER_NUM=$(printf '%s' "$VERSION" | sed 's/^v//')
ARCHIVE="${BINARY}_${VER_NUM}_${OS}_${ARCH}.tar.gz"
URL="https://github.com/$REPO/releases/download/$VERSION/$ARCHIVE"
info "版本: $VERSION"
info "平台: ${OS}/${ARCH}"
info "下载: $URL"
# 5. 下载并解压到临时目录。
TMP=$(mktemp -d 2>/dev/null || mktemp -d -t pigo-install)
trap 'rm -rf "$TMP"' EXIT INT TERM
$DLO "$TMP/$ARCHIVE" "$URL" || err "下载失败: $URL"
tar -xzf "$TMP/$ARCHIVE" -C "$TMP" || err "解压失败: $ARCHIVE"
[ -f "$TMP/$BINARY" ] || err "归档中未找到二进制 $BINARY"
chmod +x "$TMP/$BINARY"
# 6. 选择安装目录:PIGO_INSTALL_DIR > /usr/local/bin > ~/.local/bin。
DIR="${PIGO_INSTALL_DIR:-}"
if [ -z "$DIR" ]; then
if [ -w /usr/local/bin ] 2>/dev/null; then
DIR="/usr/local/bin"
elif [ "$(id -u)" = "0" ]; then
DIR="/usr/local/bin"
else
DIR="$HOME/.local/bin"
fi
fi
mkdir -p "$DIR" 2>/dev/null || err "无法创建安装目录: $DIR"
# 7. 安装。若目录不可写但可 sudo,尝试用 sudo。
DEST="$DIR/$BINARY"
if [ -w "$DIR" ]; then
mv "$TMP/$BINARY" "$DEST"
elif command -v sudo >/dev/null 2>&1; then
info "$DIR 需要提升权限,使用 sudo 安装 ..."
sudo mv "$TMP/$BINARY" "$DEST"
else
err "$DIR 不可写且无 sudo,请设置 PIGO_INSTALL_DIR 指向可写目录"
fi
info "已安装: $DEST"
# 8. 提示 PATH 是否包含安装目录。
case ":$PATH:" in
*":$DIR:"*) : ;;
*) info "注意: $DIR 不在 PATH 中,请将其加入 PATH,例如:" >&2
info " echo 'export PATH=\"$DIR:\$PATH\"' >> ~/.profile" >&2 ;;
esac
"$DEST" --version || true
+195
View File
@@ -0,0 +1,195 @@
// Package agentcore defines the core "leaf" data types and control flow for the
// pigo agent harness, a Go reimplementation of the pi agent loop. It is the
// foundation package that every other agent sub-package depends on and imports
// nothing from them.
//
// This file defines the Content model: a sealed interface implemented by the
// four content block kinds (text, thinking, toolCall, image). Because Go's
// encoding/json cannot dispatch to an interface based on a discriminant field,
// containers holding []Content implement custom UnmarshalJSON that peeks at the
// "type" field and decodes into the concrete struct. Mirrors pi's discriminated
// union (packages/ai/src/types.ts) as interface + type switch.
package agentcore
import (
"bytes"
"encoding/json"
"fmt"
)
// Content is a sealed interface implemented by every content block kind.
// Consumers dispatch with a type switch. The interface is sealed via the
// unexported isContent marker so no type outside this package can satisfy it.
type Content interface {
isContent()
}
// Content type discriminants, matching pi's wire format.
const (
ContentTypeText = "text"
ContentTypeThinking = "thinking"
ContentTypeToolCall = "toolCall"
ContentTypeImage = "image"
)
// TextContent is a plain text block.
type TextContent struct {
Type string `json:"type"`
Text string `json:"text"`
TextSignature string `json:"textSignature,omitempty"`
}
// ThinkingContent is a reasoning/thinking block. Never folded into text.
type ThinkingContent struct {
Type string `json:"type"`
Thinking string `json:"thinking"`
ThinkingSignature string `json:"thinkingSignature,omitempty"`
Redacted bool `json:"redacted,omitempty"`
}
// ToolCallContent is a request from the model to invoke a tool. Arguments are
// kept as raw JSON so validation (JSON Schema) and shaping happen downstream.
type ToolCallContent struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
}
// ImageContent is an image block (base64 data + mime type).
type ImageContent struct {
Type string `json:"type"`
Data string `json:"data"`
MimeType string `json:"mimeType"`
}
func (TextContent) isContent() {}
func (ThinkingContent) isContent() {}
func (ToolCallContent) isContent() {}
func (ImageContent) isContent() {}
// MarshalJSON encodes a ToolCallContent, tolerating malformed Arguments. A
// model can stream syntactically invalid tool-call JSON (a truncated or
// duplicated key, e.g. `{"todos": []{}...`); such bytes are kept verbatim in
// Arguments so schema validation can report "not valid JSON" to the model, but
// json.RawMessage.MarshalJSON rejects them, which would otherwise abort every
// downstream serialization (session persistence, provider re-serialization) and
// take the whole turn down. To keep those paths crash-free we emit invalid
// arguments as a JSON string of the raw bytes: valid JSON that round-trips the
// original text. Well-formed arguments are emitted unchanged.
func (t ToolCallContent) MarshalJSON() ([]byte, error) {
args := t.Arguments
if len(bytes.TrimSpace(args)) == 0 {
args = json.RawMessage("{}")
} else if !json.Valid(args) {
s, err := json.Marshal(string(args))
if err != nil {
return nil, fmt.Errorf("content: encode invalid tool arguments: %w", err)
}
args = s
}
// A named alias avoids recursing into this MarshalJSON.
type wire struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
}
return json.Marshal(wire{
Type: t.Type,
ID: t.ID,
Name: t.Name,
Arguments: args,
ThoughtSignature: t.ThoughtSignature,
})
}
// Constructors set the Type discriminant so callers never desync it.
// NewTextContent returns a TextContent with the type discriminant set.
func NewTextContent(text string) TextContent {
return TextContent{Type: ContentTypeText, Text: text}
}
// NewThinkingContent returns a ThinkingContent with the type discriminant set.
func NewThinkingContent(thinking string) ThinkingContent {
return ThinkingContent{Type: ContentTypeThinking, Thinking: thinking}
}
// NewToolCallContent returns a ToolCallContent with the type discriminant set.
func NewToolCallContent(id, name string, arguments json.RawMessage) ToolCallContent {
return ToolCallContent{Type: ContentTypeToolCall, ID: id, Name: name, Arguments: arguments}
}
// NewImageContent returns an ImageContent with the type discriminant set.
func NewImageContent(data, mimeType string) ImageContent {
return ImageContent{Type: ContentTypeImage, Data: data, MimeType: mimeType}
}
// decodeContent peeks at the "type" field of a JSON object and decodes it into
// the matching concrete Content struct. This is the single dispatch point used
// by every container that holds Content (messages, tool results, session
// entries, provider parsing).
func decodeContent(raw json.RawMessage) (Content, error) {
var probe struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, fmt.Errorf("content: peek type: %w", err)
}
switch probe.Type {
case ContentTypeText:
var c TextContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case ContentTypeThinking:
var c ThinkingContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case ContentTypeToolCall:
var c ToolCallContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case ContentTypeImage:
var c ImageContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case "":
return nil, fmt.Errorf("content: missing type discriminant")
default:
return nil, fmt.Errorf("content: unknown type %q", probe.Type)
}
}
// ContentList is a slice of Content with discriminated JSON (un)marshalling.
// Fields typed []Content in messages use this so decoding dispatches on "type".
type ContentList []Content
// UnmarshalJSON decodes a JSON array of content blocks, dispatching each element
// on its "type" discriminant.
func (cl *ContentList) UnmarshalJSON(data []byte) error {
var raws []json.RawMessage
if err := json.Unmarshal(data, &raws); err != nil {
return err
}
out := make(ContentList, 0, len(raws))
for i, raw := range raws {
c, err := decodeContent(raw)
if err != nil {
return fmt.Errorf("content[%d]: %w", i, err)
}
out = append(out, c)
}
*cl = out
return nil
}
+217
View File
@@ -0,0 +1,217 @@
package agentcore
// AgentEvent is the sealed interface implemented by every event the loop emits.
// Consumers dispatch with a type switch, consistent with Content. pigo covers
// all 10 of pi's event types (PRD FR-24).
type AgentEvent interface {
isAgentEvent()
// EventType returns the discriminant string, useful for logging and for
// serialising events to the stream-json/stdio protocol (US-020).
EventType() string
}
// Event type discriminants.
const (
EventAgentStart = "agent_start"
EventAgentEnd = "agent_end"
EventTurnStart = "turn_start"
EventTurnEnd = "turn_end"
EventMessageStart = "message_start"
EventMessageUpdate = "message_update"
EventMessageEnd = "message_end"
EventToolExecutionStart = "tool_execution_start"
EventToolExecutionUpdate = "tool_execution_update"
EventToolExecutionEnd = "tool_execution_end"
EventCompaction = "compaction"
EventCompactionStart = "compaction_start"
EventTelemetry = "telemetry"
EventSubAgentProgress = "subagent_progress"
)
// AgentStartEvent is emitted once when a loop run begins. SessionID, when set,
// is the id of the session backing this run; it is carried in the first
// stream-json event so a caller can associate output with a session and resume
// it later (mirrors pi/Claude Code, which put a session id in the first event).
type AgentStartEvent struct {
SessionID string
}
// AgentEndEvent is emitted once when a loop run ends, carrying the messages
// newly produced during this run (the EventStream result).
type AgentEndEvent struct {
Messages []AgentMessage
}
// TurnStartEvent marks the start of a turn (a single assistant response cycle).
type TurnStartEvent struct{}
// TurnEndEvent marks the end of a turn, with the assistant message and any tool
// results produced during it.
type TurnEndEvent struct {
Message AssistantMessage
ToolResults []ToolResultMessage
}
// MessageStartEvent is emitted when a message begins streaming.
type MessageStartEvent struct {
Message AgentMessage
}
// MessageUpdateEvent is emitted for each streaming delta, carrying the current
// partial message and the raw provider-level event that produced it.
type MessageUpdateEvent struct {
Message AgentMessage
AssistantMessageEvent any
}
// MessageEndEvent is emitted when a message finishes streaming.
type MessageEndEvent struct {
Message AgentMessage
}
// ToolExecutionStartEvent is emitted before a tool runs.
type ToolExecutionStartEvent struct {
ToolCallID string
ToolName string
Args any
}
// ToolExecutionUpdateEvent carries a partial result during tool execution.
type ToolExecutionUpdateEvent struct {
ToolCallID string
ToolName string
PartialResult AgentToolResult
}
// ToolExecutionEndEvent is emitted when a tool finishes.
type ToolExecutionEndEvent struct {
ToolCallID string
ToolName string
Result AgentToolResult
IsError bool
}
// CompactionEvent is emitted when the loop compacts the context window, either
// automatically (threshold/overflow) or on an explicit /compact request. It
// carries before/after token counts and how many messages were summarized vs.
// retained. When compaction fails it is still emitted with ErrorMessage set and
// the token/count fields describing the unchanged context, so consumers can
// surface the failure without the session aborting (US-004).
type CompactionEvent struct {
// Reason is why compaction ran: "manual", "threshold", or "overflow".
Reason string
// TokensBefore is the estimated context tokens prior to compaction.
TokensBefore int
// TokensAfter is the estimated context tokens after compaction (equals
// TokensBefore when compaction failed or was a no-op).
TokensAfter int
// SummarizedCount is the number of messages folded into the summary.
SummarizedCount int
// KeptCount is the number of recent messages retained verbatim.
KeptCount int
// ErrorMessage is non-empty when compaction failed; the original context is
// preserved in that case.
ErrorMessage string
}
// CompactionStartEvent is emitted immediately before the loop runs compaction,
// so a front-end can show an in-progress "Compacting conversation…" indicator
// while the summarization request is in flight. The matching CompactionEvent is
// emitted when it completes (or fails). Reason mirrors CompactionEvent.Reason.
type CompactionStartEvent struct {
// Reason is why compaction is running: "manual", "threshold", or "overflow".
Reason string
// TokensBefore is the estimated context tokens that triggered compaction.
TokensBefore int
}
// SubAgentProgressEvent carries structured progress from a running sub-agent
// (dispatched by the task tool). It is reported at the sub-agent's tool
// execution / turn boundaries so a TUI (multi-line status panel) or headless
// mode (stderr line) can display live progress. Elapsed time is intentionally
// omitted: consumers compute it themselves (TUI from tool-start time, headless
// from when the id was first seen) to avoid emitting an event per frame.
type SubAgentProgressEvent struct {
// ToolCallID is the parent task call's tool-call id, used as the key for
// the status line.
ToolCallID string
// Description is the task call's description, for display (may be empty).
Description string
// Activity is the current activity: tool name / phase, e.g. "Editing",
// "Running bash", "Thinking".
Activity string
// Tokens is the estimated sub-agent output token count (0 = unknown).
Tokens int
}
// ToolTiming records how long one tool invocation took, keyed by tool name in
// TelemetryEvent.ToolDurationsMs. It aggregates repeated calls of the same tool
// so a summary stays compact regardless of turn count.
type ToolTiming struct {
// Count is how many times the tool was invoked over the run.
Count int
// TotalMs is the summed wall-clock duration of every invocation, in
// milliseconds.
TotalMs int64
}
// TelemetryEvent is a lightweight, additive observability summary emitted once
// at run end (just before agent_end) so scripts consuming the stream-json
// output can read structured metrics without a new dependency (no
// Prometheus/OTLP). It is purely observational: consumers that ignore it behave
// exactly as before. Metrics covered (observability — structured telemetry collection):
// - per-tool wall-clock durations (ToolDurationsMs, aggregated by tool name),
// - how many turns ran (Turns),
// - how many assistant responses were truncated by the output cap
// (TruncationCount),
// - how many times the context was compacted (CompactionCount),
// - the latest context-utilization ratio (ContextUtilization = used tokens /
// ContextWindow) and the raw numbers behind it.
type TelemetryEvent struct {
// Turns is the number of turns (turn_start events) the run executed.
Turns int
// ToolDurationsMs maps a tool name to its aggregated timing over the run.
ToolDurationsMs map[string]ToolTiming
// TruncationCount is how many assistant responses stopped with reason
// "length" (truncated by the output token cap), each triggering a resend.
TruncationCount int
// CompactionCount is how many successful context compactions occurred.
CompactionCount int
// ContextUtilization is the latest used/window ratio in [0,1], or 0 when the
// context window is unknown. Computed as ContextTokens / ContextWindow.
ContextUtilization float64
// ContextTokens is the most recently observed estimated context-token usage.
ContextTokens int
// ContextWindow is the model's total context-token budget (0 when unknown).
ContextWindow int
}
func (AgentStartEvent) isAgentEvent() {}
func (AgentEndEvent) isAgentEvent() {}
func (TurnStartEvent) isAgentEvent() {}
func (TurnEndEvent) isAgentEvent() {}
func (MessageStartEvent) isAgentEvent() {}
func (MessageUpdateEvent) isAgentEvent() {}
func (MessageEndEvent) isAgentEvent() {}
func (ToolExecutionStartEvent) isAgentEvent() {}
func (ToolExecutionUpdateEvent) isAgentEvent() {}
func (ToolExecutionEndEvent) isAgentEvent() {}
func (CompactionEvent) isAgentEvent() {}
func (CompactionStartEvent) isAgentEvent() {}
func (TelemetryEvent) isAgentEvent() {}
func (SubAgentProgressEvent) isAgentEvent() {}
func (AgentStartEvent) EventType() string { return EventAgentStart }
func (AgentEndEvent) EventType() string { return EventAgentEnd }
func (TurnStartEvent) EventType() string { return EventTurnStart }
func (TurnEndEvent) EventType() string { return EventTurnEnd }
func (MessageStartEvent) EventType() string { return EventMessageStart }
func (MessageUpdateEvent) EventType() string { return EventMessageUpdate }
func (MessageEndEvent) EventType() string { return EventMessageEnd }
func (ToolExecutionStartEvent) EventType() string { return EventToolExecutionStart }
func (ToolExecutionUpdateEvent) EventType() string { return EventToolExecutionUpdate }
func (ToolExecutionEndEvent) EventType() string { return EventToolExecutionEnd }
func (CompactionEvent) EventType() string { return EventCompaction }
func (CompactionStartEvent) EventType() string { return EventCompactionStart }
func (TelemetryEvent) EventType() string { return EventTelemetry }
func (SubAgentProgressEvent) EventType() string { return EventSubAgentProgress }
+121
View File
@@ -0,0 +1,121 @@
package agentcore
import (
"context"
"errors"
"sync"
)
// EventStream is the Go equivalent of pi's EventStream<T,R>: a producer pushes
// events onto a channel while a consumer ranges over them, and a terminal event
// yields a final result R. It replaces pi's async generator (event-stream.ts).
//
// Design (research §2.2):
// - Iteration: Events() returns <-chan T for `for ev := range s.Events()`.
// - Result: Result(ctx) blocks until the producer sets a result (or the
// stream fails/cancels). The result is NOT sent on the event channel, so a
// consumer that stops reading events can still obtain it.
// - Cancellation: the producer selects on ctx.Done() when sending, so a
// consumer that stops reading never leaks the producer goroutine.
//
// pi's isComplete/extractResult callbacks are retained as optional fields so a
// producer can let the stream detect the terminal event itself; a producer may
// instead call SetResult explicitly (more Go-idiomatic). Either path resolves
// Result exactly once.
type EventStream[T any, R any] struct {
ch chan T
// IsComplete reports whether an event is the terminal one. Optional: if
// set, Emit auto-captures the result via ExtractResult when it returns true.
IsComplete func(event T) bool
// ExtractResult derives the final result from the terminal event. Required
// when IsComplete is set.
ExtractResult func(event T) R
resultOnce sync.Once
result R
resultErr error
resultCh chan struct{} // closed once result (or resultErr) is set
}
// ErrStreamIncomplete is returned by Result when the event channel closed
// without any result being set (the producer ended abnormally without a
// terminal event).
var ErrStreamIncomplete = errors.New("agent: event stream ended without a result")
// NewEventStream constructs an EventStream with the given channel buffer size.
// A buffer of 0 gives fully synchronous back-pressure (each Emit blocks until a
// consumer receives), matching pi's sequential `await emit(...)`.
func NewEventStream[T any, R any](buffer int) *EventStream[T, R] {
if buffer < 0 {
buffer = 0
}
return &EventStream[T, R]{
ch: make(chan T, buffer),
resultCh: make(chan struct{}),
}
}
// Events returns the receive-only event channel. Ranging over it terminates
// when the producer calls Close.
func (s *EventStream[T, R]) Events() <-chan T { return s.ch }
// Emit sends an event to consumers, honoring cancellation. If ctx is cancelled
// before the event is received, Emit returns ctx.Err() and the event is
// dropped. When IsComplete is configured and reports true for the event, the
// result is captured (once) before the send.
func (s *EventStream[T, R]) Emit(ctx context.Context, event T) error {
if s.IsComplete != nil && s.IsComplete(event) && s.ExtractResult != nil {
s.SetResult(s.ExtractResult(event))
}
select {
case s.ch <- event:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// SetResult records the final result. Only the first call wins; later calls
// (including SetError) are no-ops. Safe to call before Close.
func (s *EventStream[T, R]) SetResult(result R) {
s.resultOnce.Do(func() {
s.result = result
close(s.resultCh)
})
}
// SetError records a terminal error as the stream's outcome. Only the first
// call among SetResult/SetError wins.
func (s *EventStream[T, R]) SetError(err error) {
s.resultOnce.Do(func() {
s.resultErr = err
close(s.resultCh)
})
}
// Close closes the event channel, ending consumer iteration. If no result was
// set, Result will report ErrStreamIncomplete. Call exactly once from the
// producer after the last Emit.
func (s *EventStream[T, R]) Close() {
// Ensure a waiting Result never blocks forever if the producer forgot to
// set a result.
s.resultOnce.Do(func() {
s.resultErr = ErrStreamIncomplete
close(s.resultCh)
})
close(s.ch)
}
// Result blocks until the producer sets a result/error, ctx is cancelled, or
// the stream closes without a result. It is safe to call concurrently and
// returns the same outcome on every call.
func (s *EventStream[T, R]) Result(ctx context.Context) (R, error) {
select {
case <-s.resultCh:
return s.result, s.resultErr
case <-ctx.Done():
var zero R
return zero, ctx.Err()
}
}
@@ -0,0 +1,121 @@
package agentcore
import (
"context"
"testing"
"time"
)
// TestEventStreamNormalCompletion drives a producer that emits events and sets
// a result, then verifies the consumer sees every event and Result yields the
// captured value.
func TestEventStreamNormalCompletion(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](0)
want := []AgentMessage{
UserMessage{RoleField: RoleUser, Content: ContentList{NewTextContent("hi")}},
}
go func() {
ctx := context.Background()
_ = s.Emit(ctx, TurnStartEvent{})
_ = s.Emit(ctx, AgentEndEvent{Messages: want})
s.SetResult(want)
s.Close()
}()
var got int
for range s.Events() {
got++
}
if got != 2 {
t.Fatalf("want 2 events, got %d", got)
}
res, err := s.Result(context.Background())
if err != nil {
t.Fatalf("result: %v", err)
}
if len(res) != 1 || res[0].Role() != RoleUser {
t.Fatalf("result payload wrong: %+v", res)
}
}
// TestEventStreamIsCompleteCallback verifies the isComplete/extractResult
// callbacks auto-capture the result on the terminal event.
func TestEventStreamIsCompleteCallback(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](4)
s.IsComplete = func(e AgentEvent) bool { return e.EventType() == EventAgentEnd }
s.ExtractResult = func(e AgentEvent) []AgentMessage { return e.(AgentEndEvent).Messages }
msgs := []AgentMessage{AssistantMessage{RoleField: RoleAssistant}}
go func() {
ctx := context.Background()
_ = s.Emit(ctx, MessageStartEvent{})
_ = s.Emit(ctx, AgentEndEvent{Messages: msgs})
s.Close()
}()
for range s.Events() {
}
res, err := s.Result(context.Background())
if err != nil {
t.Fatalf("result: %v", err)
}
if len(res) != 1 {
t.Fatalf("want 1 msg from extractResult, got %d", len(res))
}
}
// TestEventStreamCancellation verifies that a cancelled context unblocks a
// producer stuck on Emit (consumer stopped reading) and that Result returns the
// context error.
func TestEventStreamCancellation(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](0)
ctx, cancel := context.WithCancel(context.Background())
emitErr := make(chan error, 1)
go func() {
// First emit has no consumer; it blocks until cancel.
emitErr <- s.Emit(ctx, TurnStartEvent{})
}()
// Give the producer a moment to block on the send, then cancel.
time.Sleep(20 * time.Millisecond)
cancel()
select {
case err := <-emitErr:
if err == nil {
t.Fatal("expected Emit to return ctx error on cancellation")
}
case <-time.After(time.Second):
t.Fatal("Emit did not unblock after cancel (goroutine leak)")
}
// Result with a cancelled context returns promptly with the ctx error.
if _, err := s.Result(ctx); err == nil {
t.Fatal("expected Result to return ctx error")
}
}
// TestEventStreamIncompleteClose verifies Close without a result yields
// ErrStreamIncomplete.
func TestEventStreamIncompleteClose(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](1)
s.Close()
if _, err := s.Result(context.Background()); err != ErrStreamIncomplete {
t.Fatalf("want ErrStreamIncomplete, got %v", err)
}
}
// TestEventStreamSetErrorWins verifies SetError is reported and later SetResult
// is ignored (first outcome wins).
func TestEventStreamSetErrorWins(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](1)
sentinel := context.Canceled
s.SetError(sentinel)
s.SetResult(nil)
s.Close()
if _, err := s.Result(context.Background()); err != sentinel {
t.Fatalf("want sentinel error, got %v", err)
}
}
+60
View File
@@ -0,0 +1,60 @@
package agentcore
import (
"context"
"encoding/json"
"strings"
)
// ContentToText flattens text blocks of a content list into a single string,
// the lowest-common-denominator representation accepted by every OpenAI-
// compatible gateway. Non-text blocks (thinking, tool calls) are surfaced
// through their own fields, so they are skipped here.
func ContentToText(list ContentList) string {
var b strings.Builder
for _, c := range list {
if tc, ok := c.(TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
// LastAssistantOf returns a pointer to the last AssistantMessage in msgs, or nil.
func LastAssistantOf(msgs []AgentMessage) *AssistantMessage {
for i := len(msgs) - 1; i >= 0; i-- {
if a, ok := msgs[i].(AssistantMessage); ok {
return &a
}
}
return nil
}
// EmitFunc emits a loop-level AgentEvent honoring cancellation.
type EmitFunc func(ctx context.Context, ev AgentEvent) error
// PrepareArgumentsFunc optionally rewrites a tool's raw arguments before schema
// validation (e.g. injecting defaults). An error aborts the call with an error
// result. Optional (nil = identity).
type PrepareArgumentsFunc func(ctx context.Context, toolName string, args json.RawMessage) (json.RawMessage, error)
// BeforeToolCallDecision is the optional result of the beforeToolCall hook. When
// Block is true the tool is not executed and an error result is produced;
// Content/Details override the default block message when set. When Block is
// false and UpdatedInput is non-empty, it replaces the tool's raw arguments
// before execution (PreToolUse rewrite, FR-8); the replacement is re-validated
// against the tool schema.
type BeforeToolCallDecision struct {
Block bool
Content *ContentList
Details *any
UpdatedInput json.RawMessage
}
// BeforeToolCallFunc runs after validation and may block the call (permission /
// sandbox checks, FR-4/FR-26). Returning nil allows the call. Optional.
type BeforeToolCallFunc func(ctx context.Context, call AgentToolCall) *BeforeToolCallDecision
// AfterToolCallFunc runs after execution and may override the result
// field-by-field via AfterToolCallResult (FR-5, no deep merge). Optional.
type AfterToolCallFunc func(ctx context.Context, call AgentToolCall, result AgentToolResult, isError bool) *AfterToolCallResult
+141
View File
@@ -0,0 +1,141 @@
package agentcore
import (
"encoding/json"
"testing"
)
func TestContentToText(t *testing.T) {
cases := []struct {
name string
list ContentList
want string
}{
{"empty", nil, ""},
{"single text", ContentList{NewTextContent("hello")}, "hello"},
{
"skips non-text blocks",
ContentList{
NewTextContent("a"),
NewThinkingContent("ignored"),
NewToolCallContent("c1", "ls", json.RawMessage(`{}`)),
NewTextContent("b"),
NewImageContent("data", "image/png"),
},
"ab",
},
{
"only non-text",
ContentList{NewThinkingContent("x"), NewImageContent("d", "image/png")},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ContentToText(tc.list); got != tc.want {
t.Errorf("ContentToText = %q, want %q", got, tc.want)
}
})
}
}
func TestLastAssistantOf(t *testing.T) {
t.Run("nil when absent", func(t *testing.T) {
msgs := []AgentMessage{
UserMessage{RoleField: RoleUser, Content: ContentList{NewTextContent("hi")}},
ToolResultMessage{RoleField: RoleToolResult, ToolCallID: "c1"},
}
if got := LastAssistantOf(msgs); got != nil {
t.Errorf("want nil, got %+v", got)
}
})
t.Run("nil for empty slice", func(t *testing.T) {
if got := LastAssistantOf(nil); got != nil {
t.Errorf("want nil, got %+v", got)
}
})
t.Run("returns last assistant", func(t *testing.T) {
msgs := []AgentMessage{
AssistantMessage{RoleField: RoleAssistant, Model: "first"},
UserMessage{RoleField: RoleUser},
AssistantMessage{RoleField: RoleAssistant, Model: "last"},
ToolResultMessage{RoleField: RoleToolResult},
}
got := LastAssistantOf(msgs)
if got == nil {
t.Fatal("want an assistant message, got nil")
}
if got.Model != "last" {
t.Errorf("want the last assistant (model %q), got %q", "last", got.Model)
}
})
}
func TestToolCallsEmpty(t *testing.T) {
m := AssistantMessage{
RoleField: RoleAssistant,
Content: ContentList{NewTextContent("no tools here"), NewThinkingContent("hmm")},
}
if calls := m.ToolCalls(); calls != nil {
t.Errorf("want nil for a message with no tool calls, got %+v", calls)
}
}
func TestToolCallsPreservesOrder(t *testing.T) {
m := AssistantMessage{
RoleField: RoleAssistant,
Content: ContentList{
NewToolCallContent("c1", "read", json.RawMessage(`{}`)),
NewTextContent("between"),
NewToolCallContent("c2", "write", json.RawMessage(`{}`)),
},
}
calls := m.ToolCalls()
if len(calls) != 2 {
t.Fatalf("want 2 tool calls, got %d", len(calls))
}
if calls[0].ID != "c1" || calls[1].ID != "c2" {
t.Errorf("tool call order lost: %+v", calls)
}
}
// TestNewContentConstructorsSetType guards the invariant that every constructor
// sets its type discriminant, so a marshalled block always carries a "type".
func TestNewContentConstructorsSetType(t *testing.T) {
cases := []struct {
got Content
want string
}{
{NewTextContent("t"), ContentTypeText},
{NewThinkingContent("th"), ContentTypeThinking},
{NewToolCallContent("id", "n", json.RawMessage(`{}`)), ContentTypeToolCall},
{NewImageContent("d", "image/png"), ContentTypeImage},
}
for _, tc := range cases {
data, err := json.Marshal(tc.got)
if err != nil {
t.Fatalf("marshal %T: %v", tc.got, err)
}
var probe struct {
Type string `json:"type"`
}
if err := json.Unmarshal(data, &probe); err != nil {
t.Fatalf("unmarshal probe %T: %v", tc.got, err)
}
if probe.Type != tc.want {
t.Errorf("%T type = %q, want %q", tc.got, probe.Type, tc.want)
}
}
}
func TestRoleAccessors(t *testing.T) {
if got := (UserMessage{}).Role(); got != RoleUser {
t.Errorf("UserMessage.Role = %q, want %q", got, RoleUser)
}
if got := (AssistantMessage{}).Role(); got != RoleAssistant {
t.Errorf("AssistantMessage.Role = %q, want %q", got, RoleAssistant)
}
if got := (ToolResultMessage{}).Role(); got != RoleToolResult {
t.Errorf("ToolResultMessage.Role = %q, want %q", got, RoleToolResult)
}
}
+44
View File
@@ -0,0 +1,44 @@
package agentcore
// ThinkingLevel is the unified reasoning-effort enum (agent layer). It keeps
// pi's full 6 levels; providers map it to their own wire format via a
// per-model ThinkingLevelMap (decision #10).
type ThinkingLevel string
const (
ThinkingOff ThinkingLevel = "off"
ThinkingMinimal ThinkingLevel = "minimal"
ThinkingLow ThinkingLevel = "low"
ThinkingMedium ThinkingLevel = "medium"
ThinkingHigh ThinkingLevel = "high"
ThinkingXHigh ThinkingLevel = "xhigh"
ThinkingMax ThinkingLevel = "max"
)
// ThinkingLevelMap maps a unified level to a provider-specific wire value.
// A nil value means "supported but disabled at this level"; an absent key means
// "this level is not supported by the model". The pointer is what distinguishes
// those two cases, so it must stay *string.
type ThinkingLevelMap map[ThinkingLevel]*string
// AfterToolCallResult is the optional override returned by the afterToolCall
// hook. Every field is a pointer so the loop can distinguish "not provided"
// (nil) from "provided, possibly zero" — pi expresses this with `??`, Go needs
// pointers. Fields are applied with field-level replacement, no deep merge
// (FR-5).
type AfterToolCallResult struct {
Content *ContentList
Details *any
Terminate *bool
IsError *bool
}
// AgentLoopTurnUpdate is the optional result of the prepareNextTurn hook: it can
// swap the context, model, or thinking level for the next turn. Pointer fields
// distinguish "not provided" from an explicit value; ThinkingLevel is
// three-state (nil = keep, &"off" = disable, &level = set).
type AgentLoopTurnUpdate struct {
Context *AgentContext
Model *string
ThinkingLevel *ThinkingLevel
}
+210
View File
@@ -0,0 +1,210 @@
package agentcore
import (
"encoding/json"
"fmt"
)
// Message roles, matching pi's wire format.
const (
RoleUser = "user"
RoleAssistant = "assistant"
RoleToolResult = "toolResult"
// RoleCompaction marks a compaction checkpoint persisted inline in the
// message list: it replaces the history summarized before it (pi's
// "compactionSummary"). It is not sent to the model verbatim; the LLM
// conversion turns it into a user text block.
RoleCompaction = "compaction"
)
// Message is the sealed interface implemented by the three message roles.
// AgentMessage (the loop's message abstraction) is simply Message: custom
// message kinds implement the same interface and convertToLlm filters out any
// that are not LLM-bound. This deliberately replaces pi's declaration merging,
// which has no Go equivalent.
type Message interface {
isMessage()
// Role returns the discriminant ("user" | "assistant" | "toolResult").
Role() string
}
// AgentMessage is the loop-level message type. It is the same as Message; the
// alias documents intent at call sites that deal with the loop rather than raw
// LLM messages.
type AgentMessage = Message
// Usage reports token accounting for an assistant response.
type Usage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
}
// UserMessage is input from the user. Content is restricted at construction to
// text/image blocks (runtime constraint, not a separate interface).
type UserMessage struct {
RoleField string `json:"role"`
Content ContentList `json:"content"`
Timestamp int64 `json:"timestamp"`
}
func (UserMessage) isMessage() {}
func (m UserMessage) Role() string { return RoleUser }
// AssistantMessage is a model response. Content may hold text/thinking/toolCall
// blocks. StopReason follows pi's set (end_turn/tool_use/length/error/aborted).
type AssistantMessage struct {
RoleField string `json:"role"`
Content ContentList `json:"content"`
API string `json:"api,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Usage *Usage `json:"usage,omitempty"`
StopReason string `json:"stopReason,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
// Optional diagnostics, kept for cross-provider replay/observability.
ResponseModel string `json:"responseModel,omitempty"`
ResponseID string `json:"responseId,omitempty"`
}
func (AssistantMessage) isMessage() {}
func (m AssistantMessage) Role() string { return RoleAssistant }
// ToolCalls returns the tool call blocks in this assistant message, in order.
func (m AssistantMessage) ToolCalls() []ToolCallContent {
var calls []ToolCallContent
for _, c := range m.Content {
if tc, ok := c.(ToolCallContent); ok {
calls = append(calls, tc)
}
}
return calls
}
// ToolResultMessage carries the outcome of executing a single tool call.
// Content is restricted to text/image blocks at construction.
type ToolResultMessage struct {
RoleField string `json:"role"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"toolName"`
Content ContentList `json:"content"`
Details any `json:"details,omitempty"`
IsError bool `json:"isError"`
Timestamp int64 `json:"timestamp"`
}
func (ToolResultMessage) isMessage() {}
func (m ToolResultMessage) Role() string { return RoleToolResult }
// CompactionMessage is a summarization checkpoint persisted inline in the
// message list. It stands in for the history compacted before it: Summary is
// the structured checkpoint text and TokensBefore records the estimated context
// size at compaction time (for observability). Details optionally holds the
// file operations extracted from the compacted range. Mirrors pi's
// CompactionSummaryMessage + CompactionEntry.
type CompactionMessage struct {
RoleField string `json:"role"`
Summary string `json:"summary"`
TokensBefore int `json:"tokensBefore,omitempty"`
// Details is opaque at this layer (the compaction package owns its shape);
// kept as raw JSON so agentcore stays free of a compaction dependency.
Details json.RawMessage `json:"details,omitempty"`
Timestamp int64 `json:"timestamp"`
}
func (CompactionMessage) isMessage() {}
func (m CompactionMessage) Role() string { return RoleCompaction }
// compactionSummaryPrefix / compactionSummarySuffix wrap a compaction summary
// when it is rendered into an LLM user message, matching pi's
// COMPACTION_SUMMARY_PREFIX / COMPACTION_SUMMARY_SUFFIX.
const (
compactionSummaryPrefix = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n"
compactionSummarySuffix = "\n</summary>"
)
// AsUserMessage renders a compaction checkpoint as the user text message that
// stands in for the compacted history when building the LLM request. The
// provider encoders call this so a persisted compaction line replays as
// context rather than being dropped.
func (m CompactionMessage) AsUserMessage() UserMessage {
return UserMessage{
RoleField: RoleUser,
Content: ContentList{NewTextContent(compactionSummaryPrefix + m.Summary + compactionSummarySuffix)},
Timestamp: m.Timestamp,
}
}
// StopReason values, matching pi.
const (
StopReasonEndTurn = "end_turn"
StopReasonToolUse = "tool_use"
StopReasonLength = "length"
StopReasonError = "error"
StopReasonAborted = "aborted"
)
// MessageList is a slice of Message with discriminated JSON (un)marshalling,
// dispatching on the "role" field. Used by AgentContext and session persistence.
type MessageList []Message
// UnmarshalJSON decodes a JSON array of messages, dispatching each element on
// its "role" discriminant.
func (ml *MessageList) UnmarshalJSON(data []byte) error {
var raws []json.RawMessage
if err := json.Unmarshal(data, &raws); err != nil {
return err
}
out := make(MessageList, 0, len(raws))
for i, raw := range raws {
m, err := decodeMessage(raw)
if err != nil {
return fmt.Errorf("message[%d]: %w", i, err)
}
out = append(out, m)
}
*ml = out
return nil
}
// decodeMessage peeks at the "role" field and decodes into the matching
// concrete message struct.
func decodeMessage(raw json.RawMessage) (Message, error) {
var probe struct {
Role string `json:"role"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, fmt.Errorf("peek role: %w", err)
}
switch probe.Role {
case RoleUser:
var m UserMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case RoleAssistant:
var m AssistantMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case RoleToolResult:
var m ToolResultMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case RoleCompaction:
var m CompactionMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case "":
return nil, fmt.Errorf("missing role discriminant")
default:
return nil, fmt.Errorf("unknown role %q", probe.Role)
}
}
+22
View File
@@ -0,0 +1,22 @@
package agentcore
import "context"
// progressEmitterKey is the unexported context key under which a run-level
// progress EmitFunc is stored.
type progressEmitterKey struct{}
// WithProgressEmitter returns a child context carrying emit as the run-level
// progress emitter. The task tool injects the parent loop's EmitFunc here so a
// dispatched sub-agent can surface SubAgentProgressEvent up the parent stream.
func WithProgressEmitter(ctx context.Context, emit EmitFunc) context.Context {
return context.WithValue(ctx, progressEmitterKey{}, emit)
}
// ProgressEmitterFromContext returns the run-level progress emitter carried by
// ctx, or nil if none was set (in which case callers should skip progress
// reporting rather than panic).
func ProgressEmitterFromContext(ctx context.Context) EmitFunc {
emit, _ := ctx.Value(progressEmitterKey{}).(EmitFunc)
return emit
}
@@ -0,0 +1,51 @@
package agentcore
import (
"context"
"errors"
"testing"
)
func TestSubAgentProgressEventImplementsAgentEvent(t *testing.T) {
var ev AgentEvent = SubAgentProgressEvent{
ToolCallID: "call-1",
Description: "do a thing",
Activity: "Editing",
Tokens: 42,
}
if got := ev.EventType(); got != EventSubAgentProgress {
t.Fatalf("EventType() = %q, want %q", got, EventSubAgentProgress)
}
if EventSubAgentProgress != "subagent_progress" {
t.Fatalf("EventSubAgentProgress = %q, want %q", EventSubAgentProgress, "subagent_progress")
}
}
func TestProgressEmitterRoundTrip(t *testing.T) {
var seen AgentEvent
sentinel := errors.New("sentinel")
emit := func(ctx context.Context, ev AgentEvent) error {
seen = ev
return sentinel
}
ctx := WithProgressEmitter(context.Background(), emit)
got := ProgressEmitterFromContext(ctx)
if got == nil {
t.Fatal("ProgressEmitterFromContext returned nil after WithProgressEmitter")
}
want := SubAgentProgressEvent{ToolCallID: "call-2", Activity: "Thinking"}
if err := got(ctx, want); !errors.Is(err, sentinel) {
t.Fatalf("emitter returned err = %v, want sentinel", err)
}
if seen != want {
t.Fatalf("emitter received %#v, want %#v", seen, want)
}
}
func TestProgressEmitterFromBareContextIsNil(t *testing.T) {
if got := ProgressEmitterFromContext(context.Background()); got != nil {
t.Fatalf("ProgressEmitterFromContext on bare ctx = %v, want nil", got)
}
}
+61
View File
@@ -0,0 +1,61 @@
package agentcore
import (
"context"
"encoding/json"
)
// AgentContext is the input state for a loop run: system prompt, conversation
// messages, and the tools available to the model.
type AgentContext struct {
SystemPrompt string `json:"systemPrompt"`
Messages MessageList `json:"messages"`
Tools []AgentTool `json:"-"`
}
// ToolExecutionMode selects how a tool is executed relative to others in a batch.
type ToolExecutionMode string
const (
// ToolExecutionParallel allows the tool to run concurrently with others.
ToolExecutionParallel ToolExecutionMode = "parallel"
// ToolExecutionSequential forces the whole batch to run serially.
ToolExecutionSequential ToolExecutionMode = "sequential"
)
// ToolUpdateFunc receives a partial result during tool execution; the loop
// turns each call into a tool_execution_update event.
type ToolUpdateFunc func(partial AgentToolResult)
// AgentTool is a tool the model can invoke. Schema is the JSON Schema used to
// validate arguments before execution (US-014).
type AgentTool interface {
Name() string
Description() string
// Schema returns the JSON Schema (as raw JSON) for the tool's arguments.
Schema() json.RawMessage
// ExecutionMode reports whether this tool forces sequential execution.
ExecutionMode() ToolExecutionMode
// Execute runs the tool. onUpdate may be nil.
Execute(ctx context.Context, id string, args json.RawMessage, onUpdate ToolUpdateFunc) (AgentToolResult, error)
}
// AgentToolCall is a decoded request to invoke a tool (the loop-level view of a
// ToolCallContent block).
type AgentToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// AgentToolResult is the outcome of executing a tool.
//
// Details uses `any` in the first version (matching pi's internal
// AgentToolResult<any>); a generic form can be added later. Terminate is a
// *bool so "not set" is distinguishable from an explicit false — the loop only
// signals early termination when every result in a batch has Terminate=true.
type AgentToolResult struct {
Content ContentList `json:"content"`
Details any `json:"details,omitempty"`
Terminate *bool `json:"terminate,omitempty"`
}
+182
View File
@@ -0,0 +1,182 @@
package agentcore
import (
"encoding/json"
"testing"
)
func TestContentListRoundTrip(t *testing.T) {
in := ContentList{
NewTextContent("hello"),
NewThinkingContent("pondering"),
NewToolCallContent("call_1", "read", json.RawMessage(`{"path":"a.go"}`)),
NewImageContent("YmFzZTY0", "image/png"),
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out ContentList
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out) != 4 {
t.Fatalf("want 4 blocks, got %d", len(out))
}
if _, ok := out[0].(TextContent); !ok {
t.Errorf("block 0: want TextContent, got %T", out[0])
}
if _, ok := out[1].(ThinkingContent); !ok {
t.Errorf("block 1: want ThinkingContent, got %T", out[1])
}
tc, ok := out[2].(ToolCallContent)
if !ok {
t.Fatalf("block 2: want ToolCallContent, got %T", out[2])
}
if tc.ID != "call_1" || tc.Name != "read" {
t.Errorf("toolCall fields lost: %+v", tc)
}
if string(tc.Arguments) != `{"path":"a.go"}` {
t.Errorf("arguments lost: %s", tc.Arguments)
}
if _, ok := out[3].(ImageContent); !ok {
t.Errorf("block 3: want ImageContent, got %T", out[3])
}
}
func TestContentUnknownTypeRejected(t *testing.T) {
var out ContentList
err := json.Unmarshal([]byte(`[{"type":"bogus"}]`), &out)
if err == nil {
t.Fatal("expected error for unknown content type")
}
}
// TestToolCallInvalidArgumentsMarshal verifies a ToolCallContent whose
// Arguments are syntactically invalid JSON (as a model can stream) still
// marshals — as a JSON string of the raw bytes — rather than aborting the
// encode. Without this, session persistence and provider re-serialization would
// crash the whole turn on a single malformed tool call.
func TestToolCallInvalidArgumentsMarshal(t *testing.T) {
bad := NewToolCallContent("c1", "todo", json.RawMessage(`{"todos": []{}"content": ""x"}`))
data, err := json.Marshal(bad)
if err != nil {
t.Fatalf("marshal invalid tool args: %v", err)
}
if !json.Valid(data) {
t.Fatalf("marshaled output is not valid JSON: %s", data)
}
// It must round-trip back through the discriminated decoder without error.
var out ContentList
if err := json.Unmarshal([]byte("["+string(data)+"]"), &out); err != nil {
t.Fatalf("round-trip unmarshal: %v", err)
}
tc, ok := out[0].(ToolCallContent)
if !ok {
t.Fatalf("want ToolCallContent, got %T", out[0])
}
// The raw invalid text is preserved (as the decoded string).
var recovered string
if err := json.Unmarshal(tc.Arguments, &recovered); err != nil {
t.Fatalf("arguments not a JSON string: %v", err)
}
if recovered != `{"todos": []{}"content": ""x"}` {
t.Errorf("raw arguments lost: %q", recovered)
}
}
// TestToolCallValidArgumentsUnchanged verifies well-formed arguments are emitted
// verbatim (not string-wrapped), preserving the object shape providers expect.
func TestToolCallValidArgumentsUnchanged(t *testing.T) {
tc := NewToolCallContent("c1", "read", json.RawMessage(`{"path":"a.go"}`))
data, err := json.Marshal(tc)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out ContentList
if err := json.Unmarshal([]byte("["+string(data)+"]"), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := out[0].(ToolCallContent)
if string(got.Arguments) != `{"path":"a.go"}` {
t.Errorf("arguments = %s, want the object unchanged", got.Arguments)
}
}
func TestContentMissingTypeRejected(t *testing.T) {
var out ContentList
err := json.Unmarshal([]byte(`[{"text":"no type"}]`), &out)
if err == nil {
t.Fatal("expected error for missing type discriminant")
}
}
func TestMessageListRoundTrip(t *testing.T) {
term := true
_ = term
in := MessageList{
UserMessage{RoleField: RoleUser, Content: ContentList{NewTextContent("hi")}, Timestamp: 1},
AssistantMessage{
RoleField: RoleAssistant,
Content: ContentList{NewTextContent("ok"), NewToolCallContent("c1", "ls", json.RawMessage(`{}`))},
StopReason: StopReasonToolUse,
Timestamp: 2,
},
ToolResultMessage{RoleField: RoleToolResult, ToolCallID: "c1", ToolName: "ls", Content: ContentList{NewTextContent("file.go")}, Timestamp: 3},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out MessageList
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out) != 3 {
t.Fatalf("want 3 messages, got %d", len(out))
}
if out[0].Role() != RoleUser {
t.Errorf("msg 0: want user, got %s", out[0].Role())
}
am, ok := out[1].(AssistantMessage)
if !ok {
t.Fatalf("msg 1: want AssistantMessage, got %T", out[1])
}
if calls := am.ToolCalls(); len(calls) != 1 || calls[0].Name != "ls" {
t.Errorf("assistant ToolCalls wrong: %+v", calls)
}
if out[2].Role() != RoleToolResult {
t.Errorf("msg 2: want toolResult, got %s", out[2].Role())
}
}
func TestMessageUnknownRoleRejected(t *testing.T) {
var out MessageList
if err := json.Unmarshal([]byte(`[{"role":"system"}]`), &out); err == nil {
t.Fatal("expected error for unknown role")
}
}
// TestAgentEventCoverage asserts all 10 event types report a distinct,
// non-empty discriminant (PRD FR-24).
func TestAgentEventCoverage(t *testing.T) {
events := []AgentEvent{
AgentStartEvent{}, AgentEndEvent{}, TurnStartEvent{}, TurnEndEvent{},
MessageStartEvent{}, MessageUpdateEvent{}, MessageEndEvent{},
ToolExecutionStartEvent{}, ToolExecutionUpdateEvent{}, ToolExecutionEndEvent{},
}
seen := map[string]bool{}
for _, e := range events {
et := e.EventType()
if et == "" {
t.Errorf("%T has empty EventType", e)
}
if seen[et] {
t.Errorf("duplicate event type %q", et)
}
seen[et] = true
}
if len(seen) != 10 {
t.Fatalf("want 10 distinct event types, got %d", len(seen))
}
}
+167
View File
@@ -0,0 +1,167 @@
// This file implements background bash execution: a BashJobStore holding the
// commands launched with run_in_background, and the BashJob state each one
// carries. A background job is detached from the turn context (which is canceled
// when the turn ends) and runs under its own cancelable context until it exits or
// kill_bash stops it. Its combined stdout/stderr accumulates in a buffer that
// bash_output drains incrementally, mirroring Claude Code's background shells +
// BashOutput/KillShell.
package agenttool
import (
"bytes"
"context"
"fmt"
"sync"
"time"
)
// BashJobStatus is the lifecycle state of a background bash job.
type BashJobStatus string
const (
// BashRunning means the command is still executing.
BashRunning BashJobStatus = "running"
// BashExited means the command finished (successfully or not) or was killed.
BashExited BashJobStatus = "exited"
)
// BashJob is a single background command: its identity, growing combined output,
// and terminal status. All fields are guarded by mu so the running command's
// writer, bash_output reads, and kill_bash can touch it concurrently.
type BashJob struct {
// ID is the stable handle (e.g. "bash_1") bash_output/kill_bash address.
ID string
// Command is the shell command line, kept for listing/display.
Command string
// StartedAt is when the command was launched.
StartedAt time.Time
mu sync.Mutex
buf bytes.Buffer
cursor int // bytes of buf already returned by bash_output
status BashJobStatus
exitCode int
errMsg string
finished time.Time
cancel context.CancelFunc
}
// jobWriter adapts a BashJob to io.Writer so it can be a command's Stdout/Stderr;
// each write appends to the job's combined buffer under its lock.
type jobWriter struct{ job *BashJob }
func (w jobWriter) Write(p []byte) (int, error) {
w.job.mu.Lock()
w.job.buf.Write(p)
w.job.mu.Unlock()
return len(p), nil
}
// writer returns an io.Writer that appends to the job's output buffer.
func (j *BashJob) writer() jobWriter { return jobWriter{job: j} }
// finish records the command's terminal state from the error returned by
// cmd.Wait (nil = success). It is idempotent-safe to call once per job.
func (j *BashJob) finish(exitCode int, errMsg string) {
j.mu.Lock()
defer j.mu.Unlock()
j.status = BashExited
j.exitCode = exitCode
j.errMsg = errMsg
j.finished = time.Now()
}
// kill cancels the job's context (terminating the process) and marks it exited if
// it was still running. It reports whether the job was running when called.
func (j *BashJob) kill() bool {
j.mu.Lock()
running := j.status == BashRunning
j.mu.Unlock()
if j.cancel != nil {
j.cancel()
}
return running
}
// readNew returns the output accumulated since the last read and advances the
// cursor, so successive bash_output calls stream the command's output without
// repeating what was already seen.
func (j *BashJob) readNew() string {
j.mu.Lock()
defer j.mu.Unlock()
all := j.buf.Bytes()
if j.cursor > len(all) {
j.cursor = len(all)
}
out := string(all[j.cursor:])
j.cursor = len(all)
return out
}
// snapshot returns the job's current status fields for reporting without exposing
// the mutex-guarded internals.
func (j *BashJob) snapshot() (status BashJobStatus, exitCode int, errMsg string) {
j.mu.Lock()
defer j.mu.Unlock()
return j.status, j.exitCode, j.errMsg
}
// BashJobStore holds a session's background bash jobs. A single store is shared by
// the bash, bash_output and kill_bash tools so a job launched by one is visible to
// the others. It is safe for concurrent use.
type BashJobStore struct {
mu sync.Mutex
jobs map[string]*BashJob
seq int
}
// NewBashJobStore returns an empty store.
func NewBashJobStore() *BashJobStore {
return &BashJobStore{jobs: map[string]*BashJob{}}
}
// create registers a new running job for command with its cancel func, assigning
// a readable sequential id.
func (s *BashJobStore) create(command string, cancel context.CancelFunc) *BashJob {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
job := &BashJob{
ID: fmt.Sprintf("bash_%d", s.seq),
Command: command,
StartedAt: time.Now(),
status: BashRunning,
cancel: cancel,
}
s.jobs[job.ID] = job
return job
}
// Get returns the job with the given id, or (nil, false).
func (s *BashJobStore) Get(id string) (*BashJob, bool) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
return j, ok
}
// List returns the jobs in creation order.
func (s *BashJobStore) List() []*BashJob {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*BashJob, 0, len(s.jobs))
for i := 1; i <= s.seq; i++ {
if j, ok := s.jobs[fmt.Sprintf("bash_%d", i)]; ok {
out = append(out, j)
}
}
return out
}
// KillAll cancels every still-running job. It is intended for session shutdown so
// background processes are not orphaned.
func (s *BashJobStore) KillAll() {
for _, j := range s.List() {
j.kill()
}
}
@@ -0,0 +1,134 @@
// Tests for background bash execution: launching a detached job, draining its
// output incrementally via bash_output, and terminating a long-running job with
// kill_bash. These exercise the shared BashJobStore wiring end to end.
package agenttool
import (
"context"
"encoding/json"
"runtime"
"strings"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
func runBGTool(t *testing.T, tool agentcore.AgentTool, args map[string]any) (agentcore.AgentToolResult, error) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
return tool.Execute(context.Background(), "call-1", raw, nil)
}
// A background command returns immediately with a bash_id, then bash_output
// drains its output and reports it exited.
func TestBashBackgroundRunAndOutput(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
jobs := NewBashJobStore()
bash := &BashTool{Jobs: jobs}
out := &BashOutputTool{Jobs: jobs}
res, gerr := runBGTool(t, bash, map[string]any{"command": "echo bg-hello", "run_in_background": true})
if gerr != nil {
t.Fatalf("unexpected error: %v", gerr)
}
details, ok := res.Details.(map[string]any)
if !ok || details["background"] != true {
t.Fatalf("expected background details, got %+v", res.Details)
}
id, _ := details["bash_id"].(string)
if id == "" {
t.Fatalf("no bash_id returned")
}
// Poll bash_output until the job exits and produced its line.
var text string
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
r, err := runBGTool(t, out, map[string]any{"bash_id": id})
if err != nil {
t.Fatalf("bash_output: %v", err)
}
text += resultText(r)
d, _ := r.Details.(map[string]any)
if d["status"] == string(BashExited) {
break
}
time.Sleep(20 * time.Millisecond)
}
if !strings.Contains(text, "bg-hello") {
t.Errorf("output = %q, want to contain bg-hello", text)
}
if !strings.Contains(text, "exited") {
t.Errorf("status never reported exited: %q", text)
}
}
// kill_bash terminates a long-running background job and it stops running.
func TestBashBackgroundKill(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
jobs := NewBashJobStore()
bash := &BashTool{Jobs: jobs}
kill := &BashKillTool{Jobs: jobs}
out := &BashOutputTool{Jobs: jobs}
res, gerr := runBGTool(t, bash, map[string]any{"command": "sleep 30", "run_in_background": true})
if gerr != nil {
t.Fatalf("unexpected error: %v", gerr)
}
id := res.Details.(map[string]any)["bash_id"].(string)
kr, err := runBGTool(t, kill, map[string]any{"bash_id": id})
if err != nil {
t.Fatalf("kill_bash: %v", err)
}
if killed, _ := kr.Details.(map[string]any)["killed"].(bool); !killed {
t.Errorf("expected killed=true, got %+v", kr.Details)
}
// After the kill, the job should report exited within a short window.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
r, _ := runBGTool(t, out, map[string]any{"bash_id": id})
if r.Details.(map[string]any)["status"] == string(BashExited) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("job still running after kill")
}
// bash_output and kill_bash report a clear error for an unknown id.
func TestBashControlUnknownID(t *testing.T) {
jobs := NewBashJobStore()
out := &BashOutputTool{Jobs: jobs}
kill := &BashKillTool{Jobs: jobs}
r, _ := runBGTool(t, out, map[string]any{"bash_id": "bash_99"})
if !strings.Contains(resultText(r), "no background command") {
t.Errorf("bash_output on unknown id should error, got %q", resultText(r))
}
r, _ = runBGTool(t, kill, map[string]any{"bash_id": "bash_99"})
if !strings.Contains(resultText(r), "no background command") {
t.Errorf("kill_bash on unknown id should error, got %q", resultText(r))
}
}
// run_in_background without a store wired reports it is unavailable.
func TestBashBackgroundNoStore(t *testing.T) {
bash := &BashTool{}
r, err := runBGTool(t, bash, map[string]any{"command": "echo x", "run_in_background": true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(resultText(r), "not available") {
t.Errorf("expected unavailable message when no store is wired, got %q", resultText(r))
}
}
+158
View File
@@ -0,0 +1,158 @@
// This file implements the two companion tools for background bash jobs:
// bash_output drains a job's new output and reports its status, and kill_bash
// terminates a running job. Both address a job by the bash_id returned from a
// `bash` call with run_in_background=true, sharing the same BashJobStore so a
// job launched by the bash tool is visible here. This mirrors Claude Code's
// BashOutput/KillShell tools.
package agenttool
import (
"context"
"encoding/json"
"fmt"
"github.com/smallnest/pigo/internal/agentcore"
)
// BashOutputTool reads the output a background job has produced since the last
// read and reports whether it is still running or has exited. Jobs is the shared
// store the bash tool populates.
type BashOutputTool struct {
Jobs *BashJobStore
}
// bashOutputArgs is the decoded argument shape for BashOutputTool.
type bashOutputArgs struct {
// BashID is the job handle returned by a background `bash` call.
BashID string `json:"bash_id"`
}
// Name implements AgentTool.
func (t *BashOutputTool) Name() string { return "bash_output" }
// Description implements AgentTool.
func (t *BashOutputTool) Description() string {
return "Read new output from a background command started with bash " +
"run_in_background=true, addressed by its bash_id. Returns output " +
"accumulated since the last read plus the command's status (running or " +
"exited, with exit code). Call repeatedly to stream a long job's output."
}
// Schema implements AgentTool.
func (t *BashOutputTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"bash_id": {"type": "string", "description": "The bash_id returned by a background bash call."}
},
"required": ["bash_id"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. Reading output has no side effects.
func (t *BashOutputTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
// Execute implements AgentTool. It returns the job's new output and status.
func (t *BashOutputTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[bashOutputArgs](args, "bash_output")
if bad != nil {
return *bad, nil
}
if t.Jobs == nil {
return errorResult("bash_output: background jobs are not available in this environment"), nil
}
job, ok := t.Jobs.Get(a.BashID)
if !ok {
return errorResult(fmt.Sprintf("bash_output: no background command with id %q", a.BashID)), nil
}
out := truncateBashOutput(job.readNew())
status, exitCode, errMsg := job.snapshot()
var statusLine string
if status == BashRunning {
statusLine = fmt.Sprintf("[%s: running]", a.BashID)
} else if errMsg != "" && exitCode != 0 {
statusLine = fmt.Sprintf("[%s: exited code %d: %s]", a.BashID, exitCode, errMsg)
} else {
statusLine = fmt.Sprintf("[%s: exited code %d]", a.BashID, exitCode)
}
text := statusLine
if out != "" {
text = out + "\n" + statusLine
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
Details: map[string]any{"bash_id": a.BashID, "status": string(status), "exitCode": exitCode},
}, nil
}
// BashKillTool terminates a running background job by canceling its context.
// Jobs is the shared store the bash tool populates.
type BashKillTool struct {
Jobs *BashJobStore
}
// bashKillArgs is the decoded argument shape for BashKillTool.
type bashKillArgs struct {
// BashID is the job handle returned by a background `bash` call.
BashID string `json:"bash_id"`
}
// Name implements AgentTool.
func (t *BashKillTool) Name() string { return "kill_bash" }
// Description implements AgentTool.
func (t *BashKillTool) Description() string {
return "Terminate a background command started with bash " +
"run_in_background=true, addressed by its bash_id. The command's " +
"process is killed; already-exited jobs report that they were not running."
}
// Schema implements AgentTool.
func (t *BashKillTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"bash_id": {"type": "string", "description": "The bash_id returned by a background bash call."}
},
"required": ["bash_id"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. Killing a process is a side effect.
func (t *BashKillTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
// Execute implements AgentTool. It kills the job and reports whether it had been
// running.
func (t *BashKillTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[bashKillArgs](args, "kill_bash")
if bad != nil {
return *bad, nil
}
if t.Jobs == nil {
return errorResult("kill_bash: background jobs are not available in this environment"), nil
}
job, ok := t.Jobs.Get(a.BashID)
if !ok {
return errorResult(fmt.Sprintf("kill_bash: no background command with id %q", a.BashID)), nil
}
if job.kill() {
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("killed background command %s", a.BashID))},
Details: map[string]any{"bash_id": a.BashID, "killed": true},
}, nil
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("background command %s was not running", a.BashID))},
Details: map[string]any{"bash_id": a.BashID, "killed": false},
}, nil
}
+328
View File
@@ -0,0 +1,328 @@
// This file implements the bash tool (US-018): run a shell command, streaming
// stdout/stderr back as tool_execution_update partials, honoring a timeout and
// context cancellation (which kills the child process group). A non-zero exit
// is surfaced as an error (isError) whose message carries the captured output.
package agenttool
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"runtime"
"sync"
"time"
"unicode/utf8"
"github.com/smallnest/pigo/internal/agentcore"
)
// bashDefaultTimeout bounds a command that does not specify one.
const bashDefaultTimeout = 2 * time.Minute
// bashMaxTimeout caps any requested timeout.
const bashMaxTimeout = 10 * time.Minute
// bashMaxOutputBytes caps how many bytes of combined stdout/stderr the bash tool
// returns to the model. A single command can emit megabytes (build logs, a big
// cat), which — unlike the timeout cap — would otherwise flow into context whole
// and blow the window. Output past this size is truncated to a head + tail
// preview (see truncateBashOutput), mirroring search's searchMaxResults/"[truncated
// …]" convention. This is the tool's own inner cap; a later executor-layer budget
// may impose a stricter outer limit.
const bashMaxOutputBytes = 30_000
// truncateBashOutput caps s at bashMaxOutputBytes using the shared
// truncateToBudget idiom (head + "[truncated N bytes]" marker + tail, cut on
// UTF-8 rune boundaries). It is the bash tool's own inner cap; the executor
// layer applies a separate, uniform outer budget afterward.
func truncateBashOutput(s string) string {
return truncateToBudget(s, bashMaxOutputBytes)
}
// trimUTF8Prefix drops trailing bytes of s that form an incomplete rune, so the
// returned prefix ends on a rune boundary.
func trimUTF8Prefix(s string) string {
for len(s) > 0 {
if r, size := utf8.DecodeLastRuneInString(s); r == utf8.RuneError && size <= 1 {
s = s[:len(s)-1]
continue
}
break
}
return s
}
// trimUTF8Suffix drops leading bytes of s that form an incomplete rune, so the
// returned suffix starts on a rune boundary.
func trimUTF8Suffix(s string) string {
for len(s) > 0 {
if r, size := utf8.DecodeRuneInString(s); r == utf8.RuneError && size <= 1 {
s = s[1:]
continue
}
break
}
return s
}
// BashTool runs shell commands. Dir bounds the working directory (empty = the
// process CWD). Shell selects the interpreter (empty = "bash -c").
type BashTool struct {
// Dir is the working directory for commands. Empty uses the process CWD.
Dir string
// Shell is the interpreter path. Empty defaults to "bash".
Shell string
// Jobs holds background jobs launched with run_in_background. When nil,
// run_in_background is rejected (the front-end did not wire a store).
Jobs *BashJobStore
}
// bashToolArgs is the decoded argument shape for BashTool.
type bashToolArgs struct {
// Command is the shell command line to run.
Command string `json:"command"`
// TimeoutMs optionally overrides the default timeout (milliseconds).
TimeoutMs int `json:"timeout_ms,omitempty"`
// RunInBackground detaches the command from the turn: it keeps running after
// Execute returns, and its output is drained later via bash_output. A
// background command has no default timeout (so dev servers/watchers run
// indefinitely); timeout_ms still caps it if given.
RunInBackground bool `json:"run_in_background,omitempty"`
}
// Name implements AgentTool.
func (t *BashTool) Name() string { return "bash" }
// Description implements AgentTool.
func (t *BashTool) Description() string {
return "Run a shell command, streaming stdout/stderr. Supports a timeout " +
"and cancellation. A non-zero exit code is reported as an error. " +
"Set run_in_background=true for long-running commands (dev servers, " +
"watchers): it returns immediately with a bash_id you drain with " +
"bash_output and stop with kill_bash. " +
"On Windows the command runs under bash if available (Git Bash/WSL), " +
"else PowerShell, else cmd — prefer portable commands."
}
// Schema implements AgentTool.
func (t *BashTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command line to run."},
"timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (capped at 10 minutes). Ignored in background unless set.", "minimum": 0},
"run_in_background": {"type": "boolean", "description": "Run detached and return immediately with a bash_id; drain output with bash_output, stop with kill_bash."}
},
"required": ["command"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. Commands can have side effects → sequential.
func (t *BashTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
// shellLookPath resolves a program on PATH. It is a package var so tests can
// simulate a Windows box with or without bash installed.
var shellLookPath = exec.LookPath
// resolveShell picks the interpreter and the flag that makes it read the command
// from the next argument. An explicit shell (BashTool.Shell) is always honored as
// a POSIX-style "<shell> -c <command>".
//
// On Windows with no explicit shell, the naive "bash -c" hardcode fails on stock
// machines that have no bash on PATH — the model then retries bash blindly and
// every call errors (issue #518). So we prefer a real bash when one is present
// (Git Bash / WSL / MSYS), since commands are authored in bash syntax, and fall
// back to PowerShell, then cmd, so a command still runs on a bare Windows box.
func resolveShell(explicit, goos string, lookPath func(string) (string, error)) (shell, flag string) {
if explicit != "" {
return explicit, "-c"
}
if goos == "windows" {
if p, err := lookPath("bash"); err == nil {
return p, "-c"
}
if p, err := lookPath("powershell"); err == nil {
return p, "-Command"
}
return "cmd", "/C"
}
return "bash", "-c"
}
// streamWriter forwards each written chunk to onUpdate as a growing partial
// result while accumulating the full output. It is safe for concurrent use so
// stdout and stderr can share the same combined buffer.
type streamWriter struct {
mu *sync.Mutex
buf *bytes.Buffer
onUpdate agentcore.ToolUpdateFunc
}
func (w streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
w.buf.Write(p)
snapshot := w.buf.String()
w.mu.Unlock()
if w.onUpdate != nil {
w.onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(snapshot)}})
}
return len(p), nil
}
// Execute implements AgentTool. It streams combined stdout/stderr via onUpdate,
// enforces a timeout, and kills the process on context cancellation. A non-zero
// exit returns a Go error (→ isError) carrying the exit code and output.
func (t *BashTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[bashToolArgs](args, "bash")
if bad != nil {
return *bad, nil
}
if a.Command == "" {
return errorResult("bash: command is required"), nil
}
if a.RunInBackground {
return t.startBackground(a)
}
timeout := bashDefaultTimeout
if a.TimeoutMs > 0 {
timeout = time.Duration(a.TimeoutMs) * time.Millisecond
}
if timeout > bashMaxTimeout {
timeout = bashMaxTimeout
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
shell, flag := resolveShell(t.Shell, runtime.GOOS, shellLookPath)
cmd := exec.CommandContext(runCtx, shell, flag, a.Command)
if t.Dir != "" {
cmd.Dir = t.Dir
}
var mu sync.Mutex
var combined bytes.Buffer
sw := streamWriter{mu: &mu, buf: &combined, onUpdate: onUpdate}
cmd.Stdout = sw
cmd.Stderr = sw
err := cmd.Run()
mu.Lock()
output := combined.String()
mu.Unlock()
// Cap the output before it enters any ToolResult / error message, so a single
// command's huge output cannot blow the model's context. Truncation keeps a
// head + tail preview with a "[truncated N bytes]" marker in the middle.
output = truncateBashOutput(output)
// Context cancellation / timeout takes precedence in the message.
if runCtx.Err() == context.DeadlineExceeded {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
fmt.Errorf("bash: command timed out after %s\n%s", timeout, output)
}
if ctx.Err() == context.Canceled {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
fmt.Errorf("bash: command canceled\n%s", output)
}
// A missing interpreter (no bash/powershell/cmd on PATH) surfaces as an
// *exec.Error before the command ever runs. Report it with actionable
// guidance instead of a bare "code -1", so the model stops retrying blindly.
var execErr *exec.Error
if errors.As(err, &execErr) {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
fmt.Errorf("bash: could not start shell %q: %v. On Windows install Git Bash or WSL (or configure a shell); commands are bash syntax", shell, execErr.Err)
}
if err != nil {
exitCode := -1
var ee *exec.ExitError
if errors.As(err, &ee) {
exitCode = ee.ExitCode()
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(output)},
Details: map[string]any{"exitCode": exitCode},
},
fmt.Errorf("bash: command exited with code %d\n%s", exitCode, output)
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(output)},
Details: map[string]any{"exitCode": 0},
}, nil
}
// startBackground launches the command detached from the turn context and
// returns immediately with a bash_id. The job runs under its own cancelable
// context (rooted at context.Background(), not the turn ctx which is canceled
// when the turn ends), so it survives past Execute. A background command has no
// default timeout — a dev server or watcher is expected to run indefinitely —
// but an explicit timeout_ms still caps it. Its combined output accumulates in
// the job's buffer for bash_output to drain; kill_bash cancels its context.
func (t *BashTool) startBackground(a bashToolArgs) (agentcore.AgentToolResult, error) {
if t.Jobs == nil {
return errorResult("bash: run_in_background is not available in this environment"), nil
}
var jobCtx context.Context
var cancel context.CancelFunc
if a.TimeoutMs > 0 {
timeout := time.Duration(a.TimeoutMs) * time.Millisecond
if timeout > bashMaxTimeout {
timeout = bashMaxTimeout
}
jobCtx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
jobCtx, cancel = context.WithCancel(context.Background())
}
shell, flag := resolveShell(t.Shell, runtime.GOOS, shellLookPath)
cmd := exec.CommandContext(jobCtx, shell, flag, a.Command)
if t.Dir != "" {
cmd.Dir = t.Dir
}
job := t.Jobs.create(a.Command, cancel)
w := job.writer()
cmd.Stdout = w
cmd.Stderr = w
if err := cmd.Start(); err != nil {
cancel()
job.finish(-1, err.Error())
return errorResult(fmt.Sprintf("bash: could not start background command: %v", err)), nil
}
go func() {
err := cmd.Wait()
cancel()
exitCode := 0
errMsg := ""
if err != nil {
exitCode = -1
var ee *exec.ExitError
if errors.As(err, &ee) {
exitCode = ee.ExitCode()
}
errMsg = err.Error()
}
job.finish(exitCode, errMsg)
}()
msg := fmt.Sprintf("started background command %s: %s\nuse bash_output %q to read its output, kill_bash %q to stop it", job.ID, a.Command, job.ID, job.ID)
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
Details: map[string]any{"bash_id": job.ID, "background": true},
}, nil
}
+272
View File
@@ -0,0 +1,272 @@
package agenttool
import (
"context"
"encoding/json"
"fmt"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
func runBash(t *testing.T, tool *BashTool, args map[string]any, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
return tool.Execute(context.Background(), "call-1", raw, onUpdate)
}
func TestBashToolSuccess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
res, gerr := runBash(t, tool, map[string]any{"command": "echo hello"}, nil)
if gerr != nil {
t.Fatalf("unexpected go error: %v", gerr)
}
if !strings.Contains(resultText(res), "hello") {
t.Errorf("output = %q, want to contain hello", resultText(res))
}
details, ok := res.Details.(map[string]any)
if !ok || details["exitCode"] != 0 {
t.Errorf("expected exitCode 0, details = %+v", res.Details)
}
}
func TestBashToolNonZeroExitIsError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
res, gerr := runBash(t, tool, map[string]any{"command": "echo oops >&2; exit 3"}, nil)
// A non-zero exit must surface as a Go error so the executor flags isError.
if gerr == nil {
t.Fatalf("expected go error for non-zero exit, got nil")
}
if !strings.Contains(gerr.Error(), "code 3") {
t.Errorf("error = %q, want to mention code 3", gerr.Error())
}
// The captured output must ride along.
if !strings.Contains(gerr.Error(), "oops") {
t.Errorf("error = %q, want to carry output", gerr.Error())
}
details, ok := res.Details.(map[string]any)
if !ok || details["exitCode"] != 3 {
t.Errorf("expected exitCode 3, details = %+v", res.Details)
}
}
func TestBashToolStreaming(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
var mu sync.Mutex
var updates []string
onUpdate := func(r agentcore.AgentToolResult) {
mu.Lock()
updates = append(updates, resultText(r))
mu.Unlock()
}
_, gerr := runBash(t, tool, map[string]any{"command": "printf 'a'; printf 'b'"}, onUpdate)
if gerr != nil {
t.Fatalf("unexpected go error: %v", gerr)
}
mu.Lock()
defer mu.Unlock()
if len(updates) == 0 {
t.Fatalf("expected streaming updates, got none")
}
// The final partial should be the full accumulated output.
if last := updates[len(updates)-1]; !strings.Contains(last, "ab") {
t.Errorf("final update = %q, want to contain ab", last)
}
}
func TestBashToolTimeout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
start := time.Now()
res, gerr := runBash(t, tool, map[string]any{"command": "sleep 5", "timeout_ms": 100}, nil)
if gerr == nil {
t.Fatalf("expected timeout error, got nil")
}
if !strings.Contains(gerr.Error(), "timed out") {
t.Errorf("error = %q, want to mention timed out", gerr.Error())
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Errorf("timeout took too long: %s (process not killed?)", elapsed)
}
_ = res
}
func TestBashToolCancel(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
ctx, cancel := context.WithCancel(context.Background())
raw, _ := json.Marshal(map[string]any{"command": "sleep 5"})
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
start := time.Now()
_, gerr := tool.Execute(ctx, "call-1", raw, nil)
if gerr == nil {
t.Fatalf("expected cancellation error, got nil")
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Errorf("cancel took too long: %s (process not killed?)", elapsed)
}
}
func TestBashToolMissingCommand(t *testing.T) {
tool := &BashTool{}
res, gerr := runBash(t, tool, map[string]any{"command": ""}, nil)
if gerr != nil {
t.Fatalf("unexpected go error: %v", gerr)
}
if !strings.Contains(resultText(res), "command is required") {
t.Errorf("expected command-required error, got %q", resultText(res))
}
}
func TestBashToolMode(t *testing.T) {
tool := &BashTool{}
if tool.Name() != "bash" {
t.Errorf("name = %q", tool.Name())
}
if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
t.Error("bash should be sequential")
}
var schema map[string]any
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
t.Errorf("schema not valid JSON: %v", err)
}
}
func TestBashToolSmallOutputNotTruncated(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
res, gerr := runBash(t, tool, map[string]any{"command": "echo hello world"}, nil)
if gerr != nil {
t.Fatalf("unexpected go error: %v", gerr)
}
out := resultText(res)
if strings.Contains(out, "truncated") {
t.Errorf("small output should not be truncated, got %q", out)
}
if strings.TrimSpace(out) != "hello world" {
t.Errorf("output = %q, want %q", out, "hello world")
}
}
func TestBashToolLargeOutputTruncatedHeadTail(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash not available on windows")
}
tool := &BashTool{}
// Emit a marker at the very start and very end, with a large filler between,
// so we can prove both the head and the tail survive truncation.
total := bashMaxOutputBytes * 3
filler := bashMaxOutputBytes // bytes of 'x' between the two markers
cmd := fmt.Sprintf("printf 'HEADMARK'; head -c %d /dev/zero | tr '\\0' 'x'; printf 'TAILMARK'", filler)
_ = total
res, gerr := runBash(t, tool, map[string]any{"command": cmd}, nil)
if gerr != nil {
t.Fatalf("unexpected go error: %v", gerr)
}
out := resultText(res)
if len(out) > bashMaxOutputBytes+128 {
t.Errorf("truncated output too long: %d bytes (cap %d)", len(out), bashMaxOutputBytes)
}
if !strings.HasPrefix(out, "HEADMARK") {
t.Errorf("head not preserved; output starts with %q", out[:min(16, len(out))])
}
if !strings.HasSuffix(out, "TAILMARK") {
t.Errorf("tail not preserved; output ends with %q", out[max(0, len(out)-16):])
}
if !strings.Contains(out, "[truncated ") || !strings.Contains(out, " bytes]") {
t.Errorf("missing truncation marker in %q", out)
}
}
func TestTruncateBashOutputByteCount(t *testing.T) {
// A pure-ASCII input of a known size: the marker's N must equal the exact
// number of middle bytes dropped, i.e. total - head - tail.
total := bashMaxOutputBytes * 2
in := strings.Repeat("z", total)
out := truncateBashOutput(in)
half := bashMaxOutputBytes / 2
// For all-ASCII input no rune-boundary trimming happens, so head/tail are
// each exactly half and N = total - 2*half.
wantRemoved := total - 2*half
wantMarker := fmt.Sprintf("[truncated %d bytes]", wantRemoved)
if !strings.Contains(out, wantMarker) {
t.Errorf("marker = ...%q..., want to contain %q", out, wantMarker)
}
if got := strings.Count(out, "z"); got != 2*half {
t.Errorf("preserved %d content bytes, want %d (head+tail)", got, 2*half)
}
// Input at or below the cap is returned verbatim.
small := strings.Repeat("b", bashMaxOutputBytes)
if got := truncateBashOutput(small); got != small {
t.Errorf("input at cap should be unchanged")
}
}
// TestResolveShell covers the platform-aware interpreter selection (issue #518).
// It injects goos + a lookPath stub so every branch runs regardless of the host.
func TestResolveShell(t *testing.T) {
found := func(name string) func(string) (string, error) {
return func(s string) (string, error) {
if s == name {
return `C:\bin\` + s, nil
}
return "", fmt.Errorf("not found")
}
}
none := func(string) (string, error) { return "", fmt.Errorf("not found") }
tests := []struct {
name string
explicit, goos string
lookPath func(string) (string, error)
wantFlag string
wantShellHas string // substring the resolved shell must contain
}{
{"explicit honored on windows", "zsh", "windows", none, "-c", "zsh"},
{"explicit honored on linux", "fish", "linux", none, "-c", "fish"},
{"non-windows always bash", "", "linux", none, "-c", "bash"},
{"darwin always bash", "", "darwin", none, "-c", "bash"},
{"windows with bash", "", "windows", found("bash"), "-c", "bash"},
{"windows falls back to powershell", "", "windows", found("powershell"), "-Command", "powershell"},
{"windows falls back to cmd", "", "windows", none, "/C", "cmd"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
shell, flag := resolveShell(tc.explicit, tc.goos, tc.lookPath)
if flag != tc.wantFlag {
t.Errorf("flag = %q, want %q", flag, tc.wantFlag)
}
if !strings.Contains(shell, tc.wantShellHas) {
t.Errorf("shell = %q, want to contain %q", shell, tc.wantShellHas)
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
// This file implements batch tool execution (US-005): a batch of tool calls
// from one assistant message is run either sequentially or in parallel, mirroring
// pi's semantics.
//
// - sequential mode runs each call prepare→execute→finalize in order and stops
// early if the context is aborted.
// - parallel mode preserves ordering by index-backfilling results, running the
// allowed calls in goroutines. (prepare is not separately staged here because
// executeToolCall keeps prepare+execute together per call; ordering is still
// guaranteed by writing each result to its source index.)
//
// The whole batch signals termination only when every finalized result has
// terminate=true, matching pi.
package agenttool
import (
"context"
"sync"
"github.com/smallnest/pigo/internal/agentcore"
)
// ForceSequential, when true, makes the whole batch run serially regardless of
// per-tool ExecutionMode.
type BatchConfig struct {
ToolExecutorConfig
ForceSequential bool
}
// ExecuteToolCalls runs a batch of tool calls belonging to one assistant
// message. It returns the tool-result messages in source order and whether the
// whole batch requests termination (only when every result terminates).
func ExecuteToolCalls(ctx context.Context, cfg BatchConfig, calls []agentcore.AgentToolCall, emit agentcore.EmitFunc) ([]agentcore.ToolResultMessage, bool) {
if len(calls) == 0 {
return nil, false
}
results := make([]agentcore.ToolResultMessage, len(calls))
terminates := make([]bool, len(calls))
if cfg.ForceSequential || batchRequiresSequential(cfg.Registry, calls) {
for i, call := range calls {
if ctx.Err() != nil {
// Abort: fill the remaining calls with aborted error results so
// every tool call still gets a result message.
for j := i; j < len(calls); j++ {
results[j] = errorToolResult(calls[j], "tool call aborted")
terminates[j] = false
}
break
}
results[i], terminates[i] = executeToolCall(ctx, cfg.ToolExecutorConfig, call, emit)
}
} else {
var wg sync.WaitGroup
for i, call := range calls {
wg.Add(1)
go func(i int, call agentcore.AgentToolCall) {
defer wg.Done()
results[i], terminates[i] = executeToolCall(ctx, cfg.ToolExecutorConfig, call, emit)
}(i, call)
}
wg.Wait()
}
// Whole batch terminates only when every result terminates (pi semantics).
allTerminate := true
for _, t := range terminates {
if !t {
allTerminate = false
break
}
}
return results, allTerminate
}
// batchRequiresSequential reports whether any tool in the batch declares
// ExecutionMode sequential, which forces the whole batch to run serially.
func batchRequiresSequential(reg *ToolRegistry, calls []agentcore.AgentToolCall) bool {
for _, call := range calls {
if tool, ok := reg.Get(call.Name); ok && tool.ExecutionMode() == agentcore.ToolExecutionSequential {
return true
}
}
return false
}
@@ -0,0 +1,227 @@
package agenttool
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
// registerAll builds a registry containing every tool given.
func registerAll(t *testing.T, tools ...agentcore.AgentTool) *ToolRegistry {
t.Helper()
r := NewToolRegistry()
for _, tool := range tools {
if err := r.Register(tool); err != nil {
t.Fatalf("register %s: %v", tool.Name(), err)
}
}
return r
}
// echoTool returns its name as text; optionally terminates.
func echoTool(name string, mode agentcore.ToolExecutionMode, terminate bool) execTool {
return execTool{
name: name,
mode: mode,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
term := terminate
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}, Terminate: &term}, nil
},
}
}
func callsFor(names ...string) []agentcore.AgentToolCall {
calls := make([]agentcore.AgentToolCall, len(names))
for i, n := range names {
calls[i] = agentcore.AgentToolCall{ID: fmt.Sprintf("c%d", i), Name: n, Arguments: json.RawMessage(`{}`)}
}
return calls
}
// TestBatchParallelPreservesOrder verifies that parallel execution backfills
// results at their source index regardless of completion order.
func TestBatchParallelPreservesOrder(t *testing.T) {
// t0 sleeps longest, t2 shortest — so completion order is reversed, but the
// result slice must still be [t0, t1, t2].
mk := func(name string, delay time.Duration) execTool {
return execTool{
name: name,
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
time.Sleep(delay)
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
reg := registerAll(t, mk("t0", 30*time.Millisecond), mk("t1", 15*time.Millisecond), mk("t2", 1*time.Millisecond))
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
results, term := ExecuteToolCalls(context.Background(), cfg, callsFor("t0", "t1", "t2"), nil)
if term {
t.Errorf("no tool terminates; batch must not terminate")
}
want := []string{"t0", "t1", "t2"}
for i, w := range want {
if got := textOf(results[i]); got != w {
t.Errorf("result[%d] = %q, want %q (order not preserved)", i, got, w)
}
}
}
// TestBatchParallelRunsConcurrently confirms parallel tools overlap in time.
func TestBatchParallelRunsConcurrently(t *testing.T) {
var mu sync.Mutex
running := 0
maxConcurrent := 0
block := make(chan struct{})
var started sync.WaitGroup
started.Add(3)
mk := func(name string) execTool {
return execTool{
name: name,
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
mu.Lock()
running++
if running > maxConcurrent {
maxConcurrent = running
}
mu.Unlock()
started.Done()
<-block // hold until all have started
mu.Lock()
running--
mu.Unlock()
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
reg := registerAll(t, mk("a"), mk("b"), mk("c"))
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
go func() {
started.Wait()
close(block)
}()
ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b", "c"), nil)
if maxConcurrent < 3 {
t.Errorf("expected 3 concurrent tools, saw max %d", maxConcurrent)
}
}
// TestBatchSequentialWhenAnyToolSequential forces serial execution and records
// the order tools actually ran in.
func TestBatchSequentialWhenAnyToolSequential(t *testing.T) {
var mu sync.Mutex
var order []string
mk := func(name string, mode agentcore.ToolExecutionMode) execTool {
return execTool{
name: name,
mode: mode,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
mu.Lock()
order = append(order, name)
mu.Unlock()
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
// "b" is sequential → whole batch runs serially in source order.
reg := registerAll(t, mk("a", agentcore.ToolExecutionParallel), mk("b", agentcore.ToolExecutionSequential), mk("c", agentcore.ToolExecutionParallel))
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b", "c"), nil)
want := []string{"a", "b", "c"}
for i, w := range want {
if order[i] != w {
t.Fatalf("sequential order = %v, want %v", order, want)
}
}
}
// TestBatchForceSequential verifies the global ForceSequential flag serializes
// even all-parallel tools.
func TestBatchForceSequential(t *testing.T) {
var mu sync.Mutex
var order []string
mk := func(name string) execTool {
return execTool{
name: name,
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
mu.Lock()
order = append(order, name)
mu.Unlock()
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
reg := registerAll(t, mk("a"), mk("b"))
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}, ForceSequential: true}
ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b"), nil)
if len(order) != 2 || order[0] != "a" || order[1] != "b" {
t.Errorf("force-sequential order = %v, want [a b]", order)
}
}
// TestBatchTerminateOnlyWhenAll checks the whole-batch terminate semantics.
func TestBatchTerminateOnlyWhenAll(t *testing.T) {
// Mixed: one terminates, one does not → batch must NOT terminate.
reg := registerAll(t, echoTool("term", agentcore.ToolExecutionParallel, true), echoTool("noterm", agentcore.ToolExecutionParallel, false))
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
_, term := ExecuteToolCalls(context.Background(), cfg, callsFor("term", "noterm"), nil)
if term {
t.Errorf("batch with one non-terminating tool must not terminate")
}
// All terminate → batch terminates.
reg2 := registerAll(t, echoTool("t1", agentcore.ToolExecutionParallel, true), echoTool("t2", agentcore.ToolExecutionParallel, true))
cfg2 := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg2}}
_, term2 := ExecuteToolCalls(context.Background(), cfg2, callsFor("t1", "t2"), nil)
if !term2 {
t.Errorf("batch with all terminating tools must terminate")
}
}
// TestBatchSequentialAbort verifies that aborting mid-batch fills the remaining
// calls with aborted error results.
func TestBatchSequentialAbort(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
mk := func(name string) execTool {
return execTool{
name: name,
mode: agentcore.ToolExecutionSequential,
run: func(c context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
cancel() // abort after the first tool starts
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
},
}
}
reg := registerAll(t, mk("first"), echoTool("second", agentcore.ToolExecutionSequential, false))
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
results, _ := ExecuteToolCalls(ctx, cfg, callsFor("first", "second"), nil)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if !results[1].IsError {
t.Errorf("second (post-abort) result must be an error result")
}
}
// TestBatchEmpty covers the empty-batch fast path.
func TestBatchEmpty(t *testing.T) {
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: NewToolRegistry()}}
results, term := ExecuteToolCalls(context.Background(), cfg, nil, nil)
if results != nil || term {
t.Errorf("empty batch must return (nil, false), got (%v, %v)", results, term)
}
}
+340
View File
@@ -0,0 +1,340 @@
// This file implements the blackboard tool (US-coop): a shared-file-system
// coordination primitive for the pigo coop runner (see coop/). A single pigo
// agent works on the task in $BB: its workspace lives at $BB/workspace, and it
// creates the DONE marker through this tool. The blackboard ($BB) holds
// task.md, the workspace/, and a DONE marker.
//
// Raw file tools cannot safely create the DONE marker: the agent's file tools
// are rooted at its own workspace, and a plain write could race the supervisor.
// So the blackboard is a dedicated tool with three atomic operations:
//
// blackboard action=read [path=...] global snapshot, or one file's contents
// blackboard action=post file=... content=... atomically append a message
// blackboard action=done summary=... atomically create the DONE marker
//
// Append and marker creation are atomic at the OS level (O_APPEND single-write
// for messages, O_CREATE|O_EXCL for DONE), so writes never interleave or
// clobber each other. Every path is validated against the blackboard root to
// forbid traversal.
package agenttool
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
// maxBlackboardMessageBytes caps a single post. The cap serves two purposes:
// keeps each append a single atomic write, and keeps the tool result from
// ballooning the context.
const maxBlackboardMessageBytes = 32 * 1024
// maxBlackboardReadBytes caps how much of one file blackboard read returns.
const maxBlackboardReadBytes = 32 * 1024
// BlackboardTool is the task blackboard for the pigo coop runner. It is
// wired into the tool set only when the BB environment variable points at a
// blackboard root (see run.SetupEnv), so ordinary pigo runs never see it.
type BlackboardTool struct {
// Root is the blackboard root directory (the value of $BB). Must be set.
Root string
}
// Name implements AgentTool.
func (t *BlackboardTool) Name() string { return "blackboard" }
// Description implements AgentTool.
func (t *BlackboardTool) Description() string {
return "Read and write the task blackboard used by the pigo coop runner (see " +
"coop/). 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-<ROUND>-<NAME>.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."
}
// Schema implements AgentTool.
func (t *BlackboardTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["read", "post", "done"], "description": "read | post | done"},
"path": {"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": "string", "description": "For post: bare file name under messages/, must end in .md (e.g. round-1-a.md)."},
"content": {"type": "string", "description": "For post: the message body (max 32 KiB)."},
"summary": {"type": "string", "description": "For done: final delivery summary written into DONE."}
},
"required": ["action"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. It mutates shared files → sequential.
func (t *BlackboardTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
type blackboardArgs struct {
Action string `json:"action"`
Path string `json:"path"`
File string `json:"file"`
Content string `json:"content"`
Summary string `json:"summary"`
}
// Execute implements AgentTool.
func (t *BlackboardTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[blackboardArgs](args, "blackboard")
if bad != nil {
return *bad, nil
}
if t.Root == "" {
return errorResult("blackboard: no blackboard root configured"), nil
}
switch a.Action {
case "read":
return t.read(a)
case "post":
return t.post(a)
case "done":
return t.done(a)
default:
return errorResult(fmt.Sprintf("blackboard: unknown action %q (want read|post|done)", a.Action)), nil
}
}
// read returns either one file's contents (when a.Path is set) or a global
// snapshot of the blackboard: task.md, message list, both workspace listings,
// and the DONE state.
func (t *BlackboardTool) read(a blackboardArgs) (agentcore.AgentToolResult, error) {
if strings.TrimSpace(a.Path) != "" {
return t.readFile(a.Path)
}
var b strings.Builder
if data, err := os.ReadFile(filepath.Join(t.Root, "task.md")); err == nil {
b.WriteString("# task.md\n")
b.WriteString(truncateToBudget(string(data), maxBlackboardReadBytes))
b.WriteString("\n")
} else {
b.WriteString("# task.md\n<missing>\n")
}
b.WriteString("\n# messages/ (" + strings.Join(readDirNames(t.Root, "messages"), ", ") + ")\n")
for _, name := range readDirListing(t.Root, "messages") {
b.WriteString(" - " + name + "\n")
}
b.WriteString("\n# workspace/ (your workspace)\n")
for _, name := range readDirListing(t.Root, "workspace") {
b.WriteString(" - " + name + "\n")
}
done := ""
if data, err := os.ReadFile(filepath.Join(t.Root, "DONE")); err == nil {
done = truncateToBudget(string(data), 4096)
}
b.WriteString("\n# DONE\n")
if done == "" {
b.WriteString("<not created yet — cooperation is still in progress>\n")
} else {
b.WriteString("EXISTS:\n" + done + "\n")
}
if env := blackboardEnvSummary(); env != "" {
b.WriteString("\n# environment\n" + env)
}
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(b.String())}}, nil
}
// readFile returns the contents of one file under the blackboard root, capped at
// maxBlackboardReadBytes. Paths are validated (no traversal) and must point
// inside the root.
func (t *BlackboardTool) readFile(p string) (agentcore.AgentToolResult, error) {
full, err := t.safePath(p)
if err != nil {
return errorResult("blackboard read: " + err.Error()), nil
}
info, err := os.Stat(full)
if err != nil {
return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil
}
if info.IsDir() {
return errorResult(fmt.Sprintf("blackboard read: %s is a directory; only files can be read", p)), nil
}
data, err := os.ReadFile(full)
if err != nil {
return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent("# " + p + "\n" + truncateToBudget(string(data), maxBlackboardReadBytes))},
}, nil
}
// post atomically appends a message to $BB/messages/<file>. The file name must
// be a bare *.md name (no separators) so a message can never escape the
// messages directory. Appending is a single O_APPEND write → atomic under
// concurrent agents.
func (t *BlackboardTool) post(a blackboardArgs) (agentcore.AgentToolResult, error) {
name := strings.TrimSpace(a.File)
if !validMessageName(name) {
return errorResult("blackboard post: file must be a bare name ending in .md (e.g. \"round-1-a.md\"), no path separators, no \"..\""), nil
}
content := strings.TrimSpace(a.Content)
if content == "" {
return errorResult("blackboard post: content must not be empty"), nil
}
if len(content) > maxBlackboardMessageBytes {
return errorResult(fmt.Sprintf("blackboard post: content too large (%d bytes, max %d)", len(content), maxBlackboardMessageBytes)), nil
}
dir := filepath.Join(t.Root, "messages")
if err := os.MkdirAll(dir, 0o755); err != nil {
return errorResult("blackboard post: " + err.Error()), nil
}
// O_APPEND + a single Write is atomic on POSIX: concurrent posts never
// interleave bytes. The newline separates this message from the previous one.
f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return errorResult("blackboard post: " + err.Error()), nil
}
_, werr := f.WriteString(content)
cerr := f.Close()
if werr != nil {
return errorResult("blackboard post: write: " + werr.Error()), nil
}
if cerr != nil {
return errorResult("blackboard post: close: " + cerr.Error()), nil
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("Message appended to messages/%s", name))},
}, nil
}
// done atomically creates the DONE marker with a delivery summary. O_CREATE|O_EXCL
// guarantees exactly one agent can create it; a second attempt reports the
// existing marker rather than overwriting it.
func (t *BlackboardTool) done(a blackboardArgs) (agentcore.AgentToolResult, error) {
summary := strings.TrimSpace(a.Summary)
if summary == "" {
return errorResult("blackboard done: summary must not be empty (include the final delivery summary)"), nil
}
header := "Blackboard cooperation DONE\n"
header += "created: " + time.Now().UTC().Format(time.RFC3339) + "\n\n"
content := header + summary
target := filepath.Join(t.Root, "DONE")
f, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if os.IsExist(err) {
existing, _ := os.ReadFile(target)
return errorResult("blackboard done: DONE already exists — cooperation already finished:\n" + truncateToBudget(string(existing), 4096)), nil
}
return errorResult("blackboard done: " + err.Error()), nil
}
if _, werr := f.WriteString(content); werr != nil {
f.Close()
return errorResult("blackboard done: write: " + werr.Error()), nil
}
if cerr := f.Close(); cerr != nil {
return errorResult("blackboard done: close: " + cerr.Error()), nil
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent("DONE marker created. Cooperation finished.")},
Terminate: terminatePtr(),
}, nil
}
// safePath resolves p against the blackboard root and rejects anything that
// escapes it (.., absolute paths, symlinks are not followed beyond validation of
// the lexical path).
func (t *BlackboardTool) safePath(p string) (string, error) {
if strings.TrimSpace(p) == "" {
return "", fmt.Errorf("empty path")
}
clean := filepath.Clean(p)
if filepath.IsAbs(clean) {
return "", fmt.Errorf("path %q must be relative to the blackboard root", p)
}
rootClean := filepath.Clean(t.Root)
full := filepath.Join(rootClean, clean)
if full != rootClean && !strings.HasPrefix(full, rootClean+string(filepath.Separator)) {
return "", fmt.Errorf("path %q escapes the blackboard root", p)
}
return full, nil
}
// validMessageName checks a post file name: a bare *.md name with no directory
// components and no "." or ".." tricks.
func validMessageName(name string) bool {
if name == "" || !strings.HasSuffix(name, ".md") {
return false
}
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return false
}
base := strings.TrimSuffix(name, ".md")
if base == "" || strings.HasPrefix(base, ".") || strings.Contains(base, "..") {
return false
}
return true
}
// readDirNames lists direct child names of a directory under the root (missing
// or unreadable → empty slice).
func readDirNames(root, sub string) []string {
entries, err := os.ReadDir(filepath.Join(root, sub))
if err != nil {
return nil
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
return names
}
// readDirListing lists direct children of a directory under the root with size
// and modification time, sorted by name (missing or unreadable → empty slice).
func readDirListing(root, sub string) []string {
entries, err := os.ReadDir(filepath.Join(root, sub))
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, e := range entries {
var size string
if info, err := e.Info(); err == nil && !info.IsDir() {
size = fmt.Sprintf(" (%d bytes, %s)", info.Size(), info.ModTime().UTC().Format("15:04:05"))
} else if err == nil {
size = " (dir)"
}
out = append(out, e.Name()+size)
}
sort.Strings(out)
return out
}
// blackboardEnvSummary renders the BB/ROUND/NAME environment values so the model
// can address messages and understand the round. Returns "" when none are set.
func blackboardEnvSummary() string {
var b strings.Builder
for _, k := range []string{"BB", "ROUND", "NAME"} {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
fmt.Fprintf(&b, " %s=%s\n", k, v)
}
}
return strings.TrimRight(b.String(), "\n")
}
@@ -0,0 +1,200 @@
package agenttool
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// newTestBlackboard builds a BlackboardTool over a fresh temp dir and a helper
// to run an action and return the text result.
func newTestBlackboard(t *testing.T) (*BlackboardTool, func(args string) string) {
t.Helper()
root := t.TempDir()
tool := &BlackboardTool{Root: root}
run := func(args string) string {
t.Helper()
res, err := tool.Execute(context.Background(), "t1", json.RawMessage(args), nil)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var sb strings.Builder
for _, c := range res.Content {
if txt, ok := c.(agentcore.TextContent); ok {
sb.WriteString(txt.Text)
}
}
return sb.String()
}
return tool, run
}
func TestBlackboardPostAndRead(t *testing.T) {
tool, run := newTestBlackboard(t)
got := run(`{"action":"post","file":"round-1-a.md","content":"hello from a"}`)
if !strings.Contains(got, "appended") {
t.Fatalf("post result = %q, want appended confirmation", got)
}
data, err := os.ReadFile(filepath.Join(tool.Root, "messages", "round-1-a.md"))
if err != nil {
t.Fatalf("message file not written: %v", err)
}
if string(data) != "hello from a" {
t.Fatalf("message content = %q, want %q", data, "hello from a")
}
// The snapshot lists the message; readFile returns its contents.
snap := run(`{"action":"read"}`)
if !strings.Contains(snap, "round-1-a.md") {
t.Fatalf("snapshot missing the message name:\n%s", snap)
}
if !strings.Contains(snap, "DONE") || !strings.Contains(snap, "not created") {
t.Fatalf("snapshot missing DONE status:\n%s", snap)
}
one := run(`{"action":"read","path":"messages/round-1-a.md"}`)
if !strings.Contains(one, "hello from a") {
t.Fatalf("readFile result missing content:\n%s", one)
}
}
func TestBlackboardPostRejectsTraversal(t *testing.T) {
tool, run := newTestBlackboard(t)
for _, bad := range []string{
`{"action":"post","file":"../escape.md","content":"x"}`,
`{"action":"post","file":"a/b.md","content":"x"}`,
`{"action":"post","file":"..","content":"x"}`,
`{"action":"post","file":"notes.txt","content":"x"}`,
`{"action":"post","file":"round.md","content":""}`,
} {
got := run(bad)
if strings.Contains(got, "appended") {
t.Fatalf("post with %s must be rejected, got %q", bad, got)
}
}
if _, err := os.Stat(filepath.Join(tool.Root, "escape.md")); !os.IsNotExist(err) {
t.Fatalf("traversal escaped the root: %v", err)
}
}
func TestBlackboardReadRejectsTraversal(t *testing.T) {
_, run := newTestBlackboard(t)
for _, bad := range []string{
`{"action":"read","path":"../outside.md"}`,
`{"action":"read","path":"/etc/passwd"}`,
`{"action":"read","path":"messages"}`,
} {
got := run(bad)
if !strings.Contains(got, "blackboard read:") {
t.Fatalf("read with %s must error, got %q", bad, got)
}
}
}
func TestBlackboardDoneIsExclusive(t *testing.T) {
tool, run := newTestBlackboard(t)
got := run(`{"action":"done","summary":"delivered: flag=abc"}`)
if !strings.Contains(got, "DONE marker created") {
t.Fatalf("first done failed: %q", got)
}
data, err := os.ReadFile(filepath.Join(tool.Root, "DONE"))
if err != nil {
t.Fatalf("DONE not written: %v", err)
}
if !strings.Contains(string(data), "flag=abc") {
t.Fatalf("DONE content missing summary: %q", data)
}
// A second done must report the existing marker, not overwrite it.
got2 := run(`{"action":"done","summary":"another summary"}`)
if !strings.Contains(got2, "already exists") {
t.Fatalf("second done must report existing marker, got %q", got2)
}
data2, _ := os.ReadFile(filepath.Join(tool.Root, "DONE"))
if strings.Contains(string(data2), "another summary") {
t.Fatalf("second done overwrote the marker: %q", data2)
}
// done with an empty summary is rejected.
if got := run(`{"action":"done","summary":""}`); !strings.Contains(got, "summary must not be empty") {
t.Fatalf("empty-summary done must error, got %q", got)
}
}
// TestBlackboardPostConcurrentAtomic verifies that parallel posts to the same
// message file never interleave or lose bytes: each message survives whole.
func TestBlackboardPostConcurrentAtomic(t *testing.T) {
tool, _ := newTestBlackboard(t)
const n = 16
msgs := make([]string, n)
for i := range msgs {
msgs[i] = strings.Repeat("M", 100) + string(rune('A'+i)) + strings.Repeat("N", 100)
}
var wg sync.WaitGroup
for i := 0; i < n; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
args := json.RawMessage(`{"action":"post","file":"round-1-a.md","content":"` + msgs[i] + `"}`)
if _, err := tool.Execute(context.Background(), "t", args, nil); err != nil {
t.Errorf("concurrent post %d: %v", i, err)
}
}()
}
wg.Wait()
data, err := os.ReadFile(filepath.Join(tool.Root, "messages", "round-1-a.md"))
if err != nil {
t.Fatalf("read messages: %v", err)
}
got := string(data)
for i, m := range msgs {
if !strings.Contains(got, m) {
t.Fatalf("message %d lost/interleaved in concurrent append:\n%s", i, got)
}
}
// Every message must appear exactly once (no duplication from re-read+write).
for _, m := range msgs {
if strings.Count(got, m) != 1 {
t.Fatalf("message %q appears %d times:\n%s", m, strings.Count(got, m), got)
}
}
}
func TestBlackboardPostSizeCap(t *testing.T) {
_, run := newTestBlackboard(t)
huge := strings.Repeat("x", maxBlackboardMessageBytes+1)
got := run(`{"action":"post","file":"big.md","content":"` + huge + `"}`)
if !strings.Contains(got, "too large") {
t.Fatalf("oversized post must error, got %q", got)
}
}
func TestBlackboardUnknownAction(t *testing.T) {
_, run := newTestBlackboard(t)
if got := run(`{"action":"bogus"}`); !strings.Contains(got, "unknown action") {
t.Fatalf("unknown action must error, got %q", got)
}
}
func TestBlackboardNoRoot(t *testing.T) {
tool := &BlackboardTool{}
res, err := tool.Execute(context.Background(), "t", json.RawMessage(`{"action":"read"}`), nil)
if err != nil {
t.Fatalf("Execute: %v", err)
}
txt := res.Content[0].(agentcore.TextContent).Text
if !strings.Contains(txt, "no blackboard root") {
t.Fatalf("no-root read must error, got %q", txt)
}
}
+232
View File
@@ -0,0 +1,232 @@
// This file implements the edit tool (US-017): exact string replacement within
// a file. old_string must match exactly; if it is not unique (and replace_all
// is false) the edit is rejected. A unified-style diff of the change is returned
// for the UI to render. Paths resolve against a Root with the same traversal
// guard as the read/write tools.
package agenttool
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// EditTool performs exact string replacements in files under Root.
type EditTool struct {
// Root bounds all edits; a path resolving outside Root is rejected. Empty
// Root defaults to the current working directory.
Root string
// ExtraRoots are additional trusted directories an edit may target even though
// they lie outside Root. It exists for the skills directory so the model can
// modify existing skills that live outside the workspace.
ExtraRoots []string
// Snap, when non-nil, records the file's prior content before it is edited so
// the /rewind command can roll the change back. It is shared with the write tool.
Snap *FileSnapshotRecorder
}
// editToolArgs is the decoded argument shape for EditTool.
type editToolArgs struct {
Path string `json:"path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all,omitempty"`
}
// Name implements AgentTool.
func (t *EditTool) Name() string { return "edit" }
// Description implements AgentTool.
func (t *EditTool) Description() string {
return "Replace an exact string in a file. old_string must be unique unless " +
"replace_all is set. Returns a diff of the change."
}
// Schema implements AgentTool.
func (t *EditTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to edit, relative to the workspace root."},
"old_string": {"type": "string", "description": "Exact text to replace."},
"new_string": {"type": "string", "description": "Replacement text."},
"replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring a unique match."}
},
"required": ["path", "old_string", "new_string"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. Edits mutate the filesystem → sequential.
func (t *EditTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
// resolveWithin boundary policy, so every file tool enforces the same
// workspace-escape guard while edits can also reach trusted extra roots.
func (t *EditTool) resolvePath(p string) (string, error) {
if len(t.ExtraRoots) == 0 {
return resolveWithin(t.Root, p)
}
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
}
// Execute implements AgentTool. Edit failures (no match, non-unique match,
// missing file, out-of-root) are encoded as error results.
func (t *EditTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[editToolArgs](args, "edit")
if bad != nil {
return *bad, nil
}
if a.Path == "" {
return errorResult("edit: path is required"), nil
}
if a.OldString == a.NewString {
return errorResult("edit: old_string and new_string are identical; nothing to change"), nil
}
full, err := t.resolvePath(a.Path)
if err != nil {
return errorResult("edit: " + err.Error()), nil
}
data, err := os.ReadFile(full)
if err != nil {
if os.IsNotExist(err) {
return errorResult(fmt.Sprintf("edit: file %q does not exist", a.Path)), nil
}
return errorResult(fmt.Sprintf("edit: cannot read %q: %v", a.Path, err)), nil
}
original := string(data)
count := strings.Count(original, a.OldString)
if count == 0 {
return errorResult(fmt.Sprintf("edit: old_string not found in %q", a.Path)), nil
}
if count > 1 && !a.ReplaceAll {
return errorResult(fmt.Sprintf("edit: old_string is not unique in %q (%d matches); provide more context or set replace_all", a.Path, count)), nil
}
var updated string
if a.ReplaceAll {
updated = strings.ReplaceAll(original, a.OldString, a.NewString)
} else {
updated = strings.Replace(original, a.OldString, a.NewString, 1)
}
// Snapshot the prior state before mutating so /rewind can restore it.
t.Snap.Record(full)
if err := os.WriteFile(full, []byte(updated), filePerm); err != nil {
return errorResult(fmt.Sprintf("edit: cannot write %q: %v", a.Path, err)), nil
}
diff := unifiedDiff(a.Path, original, updated)
replaced := 1
if a.ReplaceAll {
replaced = count
}
msg := fmt.Sprintf("Edited %s (%d replacement(s))\n%s", a.Path, replaced, diff)
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
Details: map[string]any{"path": a.Path, "replacements": replaced, "diff": diff},
}, nil
}
// unifiedDiff produces a minimal line-based diff between old and new content.
// It is not a full unified-diff implementation (no hunk coalescing); it emits a
// header plus per-line -/+ markers, which is enough for a UI to render the
// change. Unchanged lines are shown with a leading space for context.
func unifiedDiff(path, oldContent, newContent string) string {
oldLines := splitLinesKeep(oldContent)
newLines := splitLinesKeep(newContent)
var b strings.Builder
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)
// Longest common subsequence over lines drives the -/+ markers.
ops := diffLines(oldLines, newLines)
for _, op := range ops {
switch op.kind {
case diffEqual:
fmt.Fprintf(&b, " %s\n", op.text)
case diffDelete:
fmt.Fprintf(&b, "-%s\n", op.text)
case diffInsert:
fmt.Fprintf(&b, "+%s\n", op.text)
}
}
return b.String()
}
// splitLinesKeep splits s into lines, dropping a single trailing newline so an
// empty final element is not produced for the common "ends with \n" case.
func splitLinesKeep(s string) []string {
if s == "" {
return nil
}
lines := strings.Split(s, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
type diffKind int
const (
diffEqual diffKind = iota
diffDelete
diffInsert
)
type diffOp struct {
kind diffKind
text string
}
// diffLines computes a line diff via a standard LCS dynamic-programming table,
// then backtracks to emit equal/delete/insert ops in order.
func diffLines(a, b []string) []diffOp {
n, m := len(a), len(b)
// lcs[i][j] = length of LCS of a[i:] and b[j:].
lcs := make([][]int, n+1)
for i := range lcs {
lcs[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var ops []diffOp
i, j := 0, 0
for i < n && j < m {
if a[i] == b[j] {
ops = append(ops, diffOp{diffEqual, a[i]})
i++
j++
} else if lcs[i+1][j] >= lcs[i][j+1] {
ops = append(ops, diffOp{diffDelete, a[i]})
i++
} else {
ops = append(ops, diffOp{diffInsert, b[j]})
j++
}
}
for ; i < n; i++ {
ops = append(ops, diffOp{diffDelete, a[i]})
}
for ; j < m; j++ {
ops = append(ops, diffOp{diffInsert, b[j]})
}
return ops
}
+177
View File
@@ -0,0 +1,177 @@
package agenttool
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
func runEdit(t *testing.T, tool *EditTool, args map[string]any) agentcore.AgentToolResult {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
if gerr != nil {
t.Fatalf("execute returned go error: %v", gerr)
}
return res
}
func seedFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
return p
}
func TestEditToolUniqueMatch(t *testing.T) {
dir := t.TempDir()
p := seedFile(t, dir, "f.txt", "alpha\nbeta\ngamma\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "beta", "new_string": "BETA"})
if strings.Contains(resultText(res), "not found") || strings.Contains(resultText(res), "not unique") {
t.Fatalf("unexpected error: %q", resultText(res))
}
got, _ := os.ReadFile(p)
if string(got) != "alpha\nBETA\ngamma\n" {
t.Errorf("content = %q", got)
}
// Diff present.
if !strings.Contains(resultText(res), "-beta") || !strings.Contains(resultText(res), "+BETA") {
t.Errorf("diff missing markers: %q", resultText(res))
}
}
func TestEditToolNonUniqueErrors(t *testing.T) {
dir := t.TempDir()
seedFile(t, dir, "f.txt", "x\nx\nx\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "x", "new_string": "y"})
if !strings.Contains(resultText(res), "not unique") {
t.Errorf("expected non-unique error, got %q", resultText(res))
}
// File unchanged.
got, _ := os.ReadFile(filepath.Join(dir, "f.txt"))
if string(got) != "x\nx\nx\n" {
t.Errorf("file should be unchanged, got %q", got)
}
}
func TestEditToolReplaceAll(t *testing.T) {
dir := t.TempDir()
p := seedFile(t, dir, "f.txt", "x\nx\nx\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "x", "new_string": "y", "replace_all": true})
got, _ := os.ReadFile(p)
if string(got) != "y\ny\ny\n" {
t.Errorf("content = %q, want all replaced", got)
}
details, ok := res.Details.(map[string]any)
if !ok || details["replacements"] != 3 {
t.Errorf("expected 3 replacements, details = %+v", res.Details)
}
}
func TestEditToolNotFound(t *testing.T) {
dir := t.TempDir()
seedFile(t, dir, "f.txt", "hello\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "missing", "new_string": "x"})
if !strings.Contains(resultText(res), "not found") {
t.Errorf("expected not-found error, got %q", resultText(res))
}
}
func TestEditToolMissingFile(t *testing.T) {
tool := &EditTool{Root: t.TempDir()}
res := runEdit(t, tool, map[string]any{"path": "nope.txt", "old_string": "a", "new_string": "b"})
if !strings.Contains(resultText(res), "does not exist") {
t.Errorf("expected does-not-exist, got %q", resultText(res))
}
}
func TestEditToolIdenticalStrings(t *testing.T) {
dir := t.TempDir()
seedFile(t, dir, "f.txt", "a\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "a", "new_string": "a"})
if !strings.Contains(resultText(res), "identical") {
t.Errorf("expected identical error, got %q", resultText(res))
}
}
func TestEditToolPathTraversal(t *testing.T) {
dir := t.TempDir()
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "../x.txt", "old_string": "a", "new_string": "b"})
if !strings.Contains(resultText(res), "outside the workspace root") {
t.Errorf("expected boundary error, got %q", resultText(res))
}
}
func TestEditToolExtraRootsAllowsSkillModification(t *testing.T) {
work := t.TempDir()
skills := t.TempDir()
skillFile := filepath.Join(skills, "weather", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(skillFile, []byte("old body\n"), 0o644); err != nil {
t.Fatalf("seed skill: %v", err)
}
// Without ExtraRoots the out-of-workspace skill edit is rejected.
bounded := &EditTool{Root: work}
res := runEdit(t, bounded, map[string]any{"path": skillFile, "old_string": "old body", "new_string": "new body"})
if !strings.Contains(resultText(res), "outside the workspace root") {
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
}
// With the skills dir as an extra root the edit applies.
tool := &EditTool{Root: work, ExtraRoots: []string{skills}}
res = runEdit(t, tool, map[string]any{"path": skillFile, "old_string": "old body", "new_string": "new body"})
if strings.Contains(resultText(res), "outside the workspace root") {
t.Fatalf("edit still blocked with ExtraRoots: %q", resultText(res))
}
got, _ := os.ReadFile(skillFile)
if !strings.Contains(string(got), "new body") {
t.Fatalf("skill not modified, content = %q", got)
}
}
func TestEditToolMode(t *testing.T) {
tool := &EditTool{}
if tool.Name() != "edit" {
t.Errorf("name = %q", tool.Name())
}
if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
t.Error("edit should be sequential")
}
var schema map[string]any
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
t.Errorf("schema not valid JSON: %v", err)
}
}
func TestUnifiedDiff(t *testing.T) {
diff := unifiedDiff("f.txt", "a\nb\nc\n", "a\nB\nc\n")
if !strings.Contains(diff, "--- a/f.txt") || !strings.Contains(diff, "+++ b/f.txt") {
t.Errorf("missing header: %q", diff)
}
if !strings.Contains(diff, "-b") || !strings.Contains(diff, "+B") {
t.Errorf("missing change lines: %q", diff)
}
// Unchanged context lines carry a leading space.
if !strings.Contains(diff, " a") || !strings.Contains(diff, " c") {
t.Errorf("missing context lines: %q", diff)
}
}
+213
View File
@@ -0,0 +1,213 @@
// This file implements FileSnapshotRecorder, the edit-rewind journal backing the
// /rewind command. Before the write and edit tools mutate a file they call
// Record(absPath), which captures the file's prior content (or notes that it did
// not exist). Snapshots accumulate per turn; Commit groups the turn's snapshots
// into a RestorePoint tagged with the conversation leaf that preceded the turn.
// Restore replays a suffix of the restore points in reverse to roll the working
// tree back to an earlier state, mirroring Claude Code's Esc-Esc rewind. The
// journal is in-memory and scoped to the running session; only pigo's own
// write/edit tools are captured (arbitrary bash edits are not).
package agenttool
import (
"fmt"
"os"
"sync"
"time"
)
// snapshotMaxBytes caps how large a file may be for its prior content to be held
// in the rewind journal. A file above this is still recorded (so rewind knows it
// changed) but its content is not retained, and rewind reports it as skipped
// rather than clobbering it with stale bytes.
const snapshotMaxBytes = 16 * 1024 * 1024
// fileSnapshot is the pre-mutation state of a single file: its content before the
// first write/edit of a turn, or a marker that it did not yet exist (so rewind
// deletes it). TooLarge marks a file that exceeded snapshotMaxBytes, whose
// content was not retained.
type fileSnapshot struct {
Path string
Existed bool
TooLarge bool
Content []byte
}
// RestorePoint is one turn's worth of file snapshots plus the conversation leaf
// that preceded the turn. Rewinding to it restores every file to its Snapshots
// state and moves the active conversation leaf back to LeafID.
type RestorePoint struct {
Seq int
Time time.Time
LeafID string
Label string
Snapshots []fileSnapshot
}
// FileSnapshotRecorder captures prior file content before write/edit mutations
// and groups it into per-turn RestorePoints. Its methods are safe for concurrent
// use so parallel tool calls within a turn can record without racing.
type FileSnapshotRecorder struct {
mu sync.Mutex
pending map[string]fileSnapshot // absolute path -> first snapshot this turn
order []string // first-touch order within the turn
points []RestorePoint
nextSeq int
}
// NewFileSnapshotRecorder returns an empty recorder ready to record the first
// turn's mutations.
func NewFileSnapshotRecorder() *FileSnapshotRecorder {
return &FileSnapshotRecorder{pending: map[string]fileSnapshot{}, nextSeq: 1}
}
// Record captures the current on-disk state of absPath before it is mutated. Only
// the first call for a given path within a turn is retained, so the snapshot
// reflects the state at the turn's start (later mutations in the same turn are
// rolled back to that same baseline). A nil recorder is a no-op, so tools can
// hold an always-safe optional handle.
func (r *FileSnapshotRecorder) Record(absPath string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if _, seen := r.pending[absPath]; seen {
return
}
snap := fileSnapshot{Path: absPath}
info, err := os.Stat(absPath)
switch {
case err != nil:
// Treat any stat error (including not-exist) as "did not exist": rewind will
// delete the file created this turn.
snap.Existed = false
case info.IsDir():
// A directory is never written by the file tools; skip content capture.
snap.Existed = true
snap.TooLarge = true
case info.Size() > snapshotMaxBytes:
snap.Existed = true
snap.TooLarge = true
default:
data, readErr := os.ReadFile(absPath)
if readErr != nil {
snap.Existed = true
snap.TooLarge = true
} else {
snap.Existed = true
snap.Content = data
}
}
r.pending[absPath] = snap
r.order = append(r.order, absPath)
}
// Commit closes the current turn: if any files were recorded it appends a
// RestorePoint tagged with leafID (the conversation leaf before the turn) and
// label (a short description, e.g. the user prompt), then clears the pending
// buffer. A turn that mutated no files creates no restore point. It reports
// whether a restore point was created.
func (r *FileSnapshotRecorder) Commit(leafID, label string) bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.order) == 0 {
return false
}
snaps := make([]fileSnapshot, 0, len(r.order))
for _, p := range r.order {
snaps = append(snaps, r.pending[p])
}
r.points = append(r.points, RestorePoint{
Seq: r.nextSeq,
Time: time.Now().UTC(),
LeafID: leafID,
Label: label,
Snapshots: snaps,
})
r.nextSeq++
r.pending = map[string]fileSnapshot{}
r.order = nil
return true
}
// Points returns a copy of the committed restore points, oldest first.
func (r *FileSnapshotRecorder) Points() []RestorePoint {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
out := make([]RestorePoint, len(r.points))
copy(out, r.points)
return out
}
// Restore rolls the working tree back to the state before the restore point at
// index idx (0-based into the Points slice). It replays that point and every
// later point in reverse, restoring each file's prior content (or deleting files
// that did not exist), then drops those points from the journal so the next
// rewind starts from the new tip. It returns the conversation leaf to switch to
// (the target point's LeafID), the list of restored file paths, and any
// non-fatal warnings (e.g. files skipped because they were too large or a
// restore write failed).
func (r *FileSnapshotRecorder) Restore(idx int) (leafID string, restored []string, warnings []string, err error) {
if r == nil {
return "", nil, nil, fmt.Errorf("no restore points")
}
r.mu.Lock()
defer r.mu.Unlock()
if idx < 0 || idx >= len(r.points) {
return "", nil, nil, fmt.Errorf("restore point %d out of range (have %d)", idx+1, len(r.points))
}
leafID = r.points[idx].LeafID
// A file touched across several turns must end at its OLDEST (pre-target)
// baseline. Iterate points oldest→newest and keep only the first snapshot seen
// for each path, so the earliest baseline is the one applied.
applied := map[string]bool{}
for i := idx; i < len(r.points); i++ {
for _, s := range r.points[i].Snapshots {
if applied[s.Path] {
continue
}
applied[s.Path] = true
if w := applySnapshot(s); w != "" {
warnings = append(warnings, w)
continue
}
restored = append(restored, s.Path)
}
}
r.points = r.points[:idx]
if len(r.points) > 0 {
r.nextSeq = r.points[len(r.points)-1].Seq + 1
} else {
r.nextSeq = 1
}
return leafID, restored, warnings, nil
}
// applySnapshot restores one file to its recorded prior state: rewrite the prior
// content, or delete the file if it did not exist before. It returns a warning
// string when the file cannot be safely restored (too large to have retained
// content, or a filesystem error), or "" on success.
func applySnapshot(s fileSnapshot) string {
if s.TooLarge {
return fmt.Sprintf("%s: skipped (too large to snapshot; left unchanged)", s.Path)
}
if !s.Existed {
if err := os.Remove(s.Path); err != nil && !os.IsNotExist(err) {
return fmt.Sprintf("%s: could not delete: %v", s.Path, err)
}
return ""
}
if err := os.WriteFile(s.Path, s.Content, filePerm); err != nil {
return fmt.Sprintf("%s: could not restore: %v", s.Path, err)
}
return ""
}
@@ -0,0 +1,152 @@
// Tests for the file-snapshot rewind journal: per-turn dedup of recorded paths,
// commit grouping (an untouched turn produces no restore point), and restore
// semantics — reverse replay across turns rolls a file to its oldest baseline,
// files that did not exist are deleted, and the journal is truncated to the tip.
package agenttool
import (
"os"
"path/filepath"
"testing"
)
func writeFileT(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func readFileT(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}
// A turn's first Record for a path is the baseline; later Records that turn are
// ignored, and Commit groups the turn's files into one point.
func TestRecorderRecordDedupAndCommit(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "a.txt")
writeFileT(t, f, "v0")
r := NewFileSnapshotRecorder()
r.Record(f) // baseline "v0"
writeFileT(t, f, "v1") // model's first edit
r.Record(f) // second edit same turn — must be ignored
writeFileT(t, f, "v2")
if !r.Commit("leaf0", "edit a") {
t.Fatal("Commit reported no restore point despite a recorded file")
}
// An untouched turn creates nothing.
if r.Commit("leaf1", "no edits") {
t.Fatal("Commit created a restore point for a turn with no records")
}
points := r.Points()
if len(points) != 1 || len(points[0].Snapshots) != 1 {
t.Fatalf("want 1 point with 1 snapshot, got %+v", points)
}
if got := string(points[0].Snapshots[0].Content); got != "v0" {
t.Errorf("baseline content = %q, want v0", got)
}
}
// Restoring rolls files back and deletes ones that did not exist before, and
// returns the pre-turn leaf id.
func TestRecorderRestore(t *testing.T) {
dir := t.TempDir()
existing := filepath.Join(dir, "keep.txt")
created := filepath.Join(dir, "new.txt")
writeFileT(t, existing, "orig")
r := NewFileSnapshotRecorder()
// Turn 1: edit an existing file.
r.Record(existing)
writeFileT(t, existing, "edited")
r.Commit("leafA", "turn1")
// Turn 2: create a brand-new file.
r.Record(created)
writeFileT(t, created, "brand new")
r.Commit("leafB", "turn2")
// Rewind to before turn 1 (index 0): both turns roll back.
leaf, restored, warnings, err := r.Restore(0)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if leaf != "leafA" {
t.Errorf("target leaf = %q, want leafA", leaf)
}
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if len(restored) != 2 {
t.Errorf("restored %d files, want 2", len(restored))
}
if got := readFileT(t, existing); got != "orig" {
t.Errorf("existing file = %q, want orig", got)
}
if _, err := os.Stat(created); !os.IsNotExist(err) {
t.Errorf("created file should have been deleted, stat err = %v", err)
}
if len(r.Points()) != 0 {
t.Errorf("journal should be empty after restoring from index 0")
}
}
// When a file is edited across several turns, restoring to before the earliest
// of them lands it at its oldest baseline (not an intermediate version).
func TestRecorderRestoreOldestBaselineWins(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "a.txt")
writeFileT(t, f, "v0")
r := NewFileSnapshotRecorder()
r.Record(f) // baseline v0
writeFileT(t, f, "v1")
r.Commit("leaf0", "t1")
r.Record(f) // baseline v1
writeFileT(t, f, "v2")
r.Commit("leaf1", "t2")
// Rewind to before t2 only (index 1): file returns to v1.
if _, _, _, err := r.Restore(1); err != nil {
t.Fatalf("Restore(1): %v", err)
}
if got := readFileT(t, f); got != "v1" {
t.Errorf("after rewind to before t2, file = %q, want v1", got)
}
// One point remains (t1); rewind it too → v0.
if _, _, _, err := r.Restore(0); err != nil {
t.Fatalf("Restore(0): %v", err)
}
if got := readFileT(t, f); got != "v0" {
t.Errorf("after full rewind, file = %q, want v0", got)
}
}
func TestRecorderRestoreOutOfRange(t *testing.T) {
r := NewFileSnapshotRecorder()
if _, _, _, err := r.Restore(0); err == nil {
t.Error("Restore on empty journal should error")
}
}
// A nil recorder is a safe no-op so tools can hold an optional handle.
func TestRecorderNilSafe(t *testing.T) {
var r *FileSnapshotRecorder
r.Record("/nonexistent")
if r.Commit("x", "y") {
t.Error("nil Commit should report no point")
}
if r.Points() != nil {
t.Error("nil Points should be nil")
}
}
+374
View File
@@ -0,0 +1,374 @@
// This file implements the goal state and the two goal-control tools that power
// the /goal command (mirrors pi-goal / Claude Code's goal mode): given a high-level
// objective, the agent runs autonomously — re-prompted turn after turn — until
// it either declares the goal done (goal_complete), hits a true impasse
// (goal_blocked), or a safety guard / token budget stops it.
//
// The tools live here (rather than in the REPL) because they are ordinary
// AgentTools the model invokes, and because the runtime's GoalReminderProvider
// needs to read the same state — mirroring how TodoTool/TodoStore pairs with
// TodoReminderProvider. GoalState is the shared, concurrency-safe handle both
// the tools (which may run in a batch) and the REPL/reminder (which reads it
// each turn) touch.
package agenttool
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
// GoalStatus is the lifecycle state of the active goal.
type GoalStatus string
const (
// GoalIdle means no goal is set (the zero value).
GoalIdle GoalStatus = ""
// GoalActive means the agent is autonomously working toward the goal.
GoalActive GoalStatus = "active"
// GoalPaused means autonomous continuation stopped (a safety guard fired, or
// the user paused it); it can be resumed.
GoalPaused GoalStatus = "paused"
// GoalBlocked means the agent hit a true impasse (goal_blocked was called).
GoalBlocked GoalStatus = "blocked"
// GoalComplete means the agent declared the goal done (goal_complete).
GoalComplete GoalStatus = "complete"
// GoalBudgetLimited means the token budget was exhausted before completion.
GoalBudgetLimited GoalStatus = "budget_limited"
)
// GoalState holds the current goal for a REPL session. It is safe for concurrent
// use so the goal tools (which may run in a batch) and the REPL/reminder reader
// can touch it without racing. A single state is shared for a session's
// lifetime; /goal clear resets it to the idle zero value.
type GoalState struct {
mu sync.RWMutex
id string
objective string
summary string // set by goal_complete
blockReason string // set by goal_blocked
status GoalStatus
iterations int // autonomous continuations issued so far
noProgress int // consecutive settles with no tool activity
tokenBudget int // 0 = unlimited
tokensUsed int
startedAt time.Time
}
// NewGoalState returns an empty (idle) goal state.
func NewGoalState() *GoalState { return &GoalState{} }
// GoalSnapshot is an immutable copy of the goal state for display/decisions.
type GoalSnapshot struct {
ID string
Objective string
Summary string
BlockReason string
Status GoalStatus
Iterations int
NoProgress int
TokenBudget int
TokensUsed int
StartedAt time.Time
}
// Start (re)initializes the state for a new objective, moving it to active. It
// resets all counters so a fresh goal never inherits a prior goal's tallies.
func (s *GoalState) Start(id, objective string, tokenBudget int) {
s.mu.Lock()
defer s.mu.Unlock()
s.id = id
s.objective = objective
s.summary = ""
s.blockReason = ""
s.status = GoalActive
s.iterations = 0
s.noProgress = 0
s.tokenBudget = tokenBudget
s.tokensUsed = 0
s.startedAt = time.Now()
}
// Snapshot returns a copy of the current state, safe to read without a lock.
func (s *GoalState) Snapshot() GoalSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return GoalSnapshot{
ID: s.id,
Objective: s.objective,
Summary: s.summary,
BlockReason: s.blockReason,
Status: s.status,
Iterations: s.iterations,
NoProgress: s.noProgress,
TokenBudget: s.tokenBudget,
TokensUsed: s.tokensUsed,
StartedAt: s.startedAt,
}
}
// Clear resets the state to idle (no goal). It zeroes the fields individually
// rather than replacing the whole struct so the embedded mutex (currently held)
// is preserved — overwriting it while locked would corrupt the lock.
func (s *GoalState) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.id = ""
s.objective = ""
s.summary = ""
s.blockReason = ""
s.status = GoalIdle
s.iterations = 0
s.noProgress = 0
s.tokenBudget = 0
s.tokensUsed = 0
s.startedAt = time.Time{}
}
// SetStatus transitions the goal to a new status (used by the REPL when a safety
// guard fires or the user pauses/resumes).
func (s *GoalState) SetStatus(status GoalStatus) {
s.mu.Lock()
defer s.mu.Unlock()
s.status = status
}
// Resume reactivates a paused or budget-limited goal and clears the transient
// safety-guard counters (iterations, no-progress) that stopped it, so the run
// gets a fresh allowance rather than immediately re-tripping the same guard. The
// token budget is intentionally reset too (tokensUsed → 0): resuming past an
// exhausted budget is an explicit user decision to grant another window. The
// objective and id are preserved. It is a no-op-safe wrapper — callers gate on
// the current status before invoking it.
func (s *GoalState) Resume() {
s.mu.Lock()
defer s.mu.Unlock()
s.status = GoalActive
s.iterations = 0
s.noProgress = 0
s.tokensUsed = 0
}
// ID returns the current goal id (empty when idle).
func (s *GoalState) ID() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.id
}
// RecordIteration increments the autonomous-continuation counter and folds the
// given output-token delta into the running total. hadToolActivity resets the
// no-progress counter when true, else increments it — so a run of tool-free
// turns can trip the no-progress guard.
func (s *GoalState) RecordIteration(outputTokens int, hadToolActivity bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.iterations++
s.tokensUsed += outputTokens
if hadToolActivity {
s.noProgress = 0
} else {
s.noProgress++
}
}
// MarkComplete records the completion summary and moves the goal to complete.
func (s *GoalState) MarkComplete(summary string) {
s.mu.Lock()
defer s.mu.Unlock()
s.summary = summary
s.status = GoalComplete
}
// MarkBlocked records the block reason and moves the goal to blocked.
func (s *GoalState) MarkBlocked(reason string) {
s.mu.Lock()
defer s.mu.Unlock()
s.blockReason = reason
s.status = GoalBlocked
}
// terminate is the shared *bool=true value returned by both goal tools to end
// the run immediately (the loop terminates when every result in a batch has
// Terminate=true; a goal tool is expected to be the sole call in its turn).
func terminatePtr() *bool { b := true; return &b }
// contradictorySummary reports whether a goal_complete summary plainly claims the
// goal is NOT done — a guard against the model closing a goal it just admitted
// is unfinished. The check is a conservative substring match on well-known
// negative phrasings (English), matching pi-goal's "plainly contradictory
// summary" rejection.
func contradictorySummary(summary string) bool {
lower := strings.ToLower(summary)
for _, bad := range []string{
"not complete",
"not done",
"incomplete",
"tests still fail",
"tests fail",
"still failing",
"could not",
"couldn't",
"unable to",
"unfinished",
"did not finish",
"failed to",
"cannot complete",
"still fails",
"test failure",
} {
if strings.Contains(lower, bad) || strings.Contains(summary, bad) {
return true
}
}
return false
}
// GoalCompleteTool lets the model declare the active goal finished. It records
// the summary, moves the state to complete, and terminates the run.
type GoalCompleteTool struct {
// State is the session goal state. Must be non-nil.
State *GoalState
}
type goalCompleteArgs struct {
Summary string `json:"summary"`
}
// Name implements AgentTool.
func (t *GoalCompleteTool) Name() string { return "goal_complete" }
// Description implements AgentTool.
func (t *GoalCompleteTool) Description() string {
return "Declare the current goal COMPLETE. Call this ONLY after verifying, " +
"requirement by requirement, that the objective is fully met — treat the " +
"working tree, tests, and actual runtime behavior as authoritative, not " +
"the prior conversation. Provide a concise summary of what was accomplished. " +
"Do NOT call this if any requirement is unmet, tests fail, or work remains; " +
"use goal_blocked for a true impasse instead. Calling this ends the run."
}
// Schema implements AgentTool.
func (t *GoalCompleteTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"summary": {"type": "string", "description": "Concise summary of what was accomplished to satisfy the goal."}
},
"required": ["summary"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. It mutates shared goal state → sequential.
func (t *GoalCompleteTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
// Execute implements AgentTool. It validates the summary (non-empty and not
// plainly contradictory), records completion, and terminates the run. Invalid
// input degrades to an error result (not a Go error) so the model can retry.
func (t *GoalCompleteTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[goalCompleteArgs](args, "goal_complete")
if bad != nil {
return *bad, nil
}
if t.State == nil {
return errorResult("goal_complete: no active goal"), nil
}
summary := strings.TrimSpace(a.Summary)
if summary == "" {
return errorResult("goal_complete: summary must not be empty"), nil
}
if contradictorySummary(summary) {
return errorResult("goal_complete: summary indicates the goal is NOT complete; " +
"keep working, or call goal_blocked with evidence if truly stuck"), nil
}
snap := t.State.Snapshot()
if snap.Status == GoalIdle {
return errorResult("goal_complete: no active goal"), nil
}
t.State.MarkComplete(summary)
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent("Goal marked complete: " + summary)},
Terminate: terminatePtr(),
}, nil
}
// GoalBlockedTool lets the model report a true impasse it cannot work around. It
// records the reason, moves the state to blocked, and terminates the run.
type GoalBlockedTool struct {
// State is the session goal state. Must be non-nil.
State *GoalState
}
type goalBlockedArgs struct {
Reason string `json:"reason"`
Evidence string `json:"evidence"`
}
// Name implements AgentTool.
func (t *GoalBlockedTool) Name() string { return "goal_blocked" }
// Description implements AgentTool.
func (t *GoalBlockedTool) Description() string {
return "Report that the current goal is BLOCKED by a true impasse you cannot " +
"resolve (e.g. missing credentials, an external dependency you cannot " +
"install, contradictory requirements). Provide a concrete reason and the " +
"evidence that establishes the blocker. Use this only as a last resort — " +
"prefer trying a different approach first. Calling this ends the run."
}
// Schema implements AgentTool.
func (t *GoalBlockedTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"reason": {"type": "string", "description": "Concise statement of what blocks the goal."},
"evidence": {"type": "string", "description": "Concrete evidence establishing the blocker (error output, missing file, etc.)."}
},
"required": ["reason", "evidence"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. It mutates shared goal state → sequential.
func (t *GoalBlockedTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
// Execute implements AgentTool. It validates the reason/evidence, records the
// block, and terminates the run.
func (t *GoalBlockedTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[goalBlockedArgs](args, "goal_blocked")
if bad != nil {
return *bad, nil
}
if t.State == nil {
return errorResult("goal_blocked: no active goal"), nil
}
reason := strings.TrimSpace(a.Reason)
if reason == "" {
return errorResult("goal_blocked: reason must not be empty"), nil
}
if strings.TrimSpace(a.Evidence) == "" {
return errorResult("goal_blocked: evidence must not be empty"), nil
}
snap := t.State.Snapshot()
if snap.Status == GoalIdle {
return errorResult("goal_blocked: no active goal"), nil
}
t.State.MarkBlocked(reason)
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent("Goal marked blocked: " + reason)},
Terminate: terminatePtr(),
}, nil
}
+188
View File
@@ -0,0 +1,188 @@
// Tests for the goal tools (mirrors pi-goal): goal_complete validation (empty and
// contradictory summaries are rejected, a valid summary marks complete and
// terminates the run), goal_blocked validation, and GoalState counter/lifecycle
// behavior. Mirrors todo_tool_test.go's structure.
package agenttool
import (
"context"
"encoding/json"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// execGoalComplete runs the goal_complete tool with the given JSON args.
func execGoalComplete(t *testing.T, tool *GoalCompleteTool, args string) agentcore.AgentToolResult {
t.Helper()
res, err := tool.Execute(context.Background(), "call-1", json.RawMessage(args), nil)
if err != nil {
t.Fatalf("Execute returned Go error: %v", err)
}
return res
}
func isErrorResult(res agentcore.AgentToolResult) bool {
// An error result carries no Terminate and its text is the error message;
// the tools return errorResult(...) which has Terminate=nil.
return res.Terminate == nil
}
func TestGoalToolsRegister(t *testing.T) {
reg := NewToolRegistry()
st := NewGoalState()
if err := reg.Register(&GoalCompleteTool{State: st}); err != nil {
t.Fatalf("Register goal_complete: %v", err)
}
if err := reg.Register(&GoalBlockedTool{State: st}); err != nil {
t.Fatalf("Register goal_blocked: %v", err)
}
if _, ok := reg.Get("goal_complete"); !ok {
t.Fatal("goal_complete not found after Register")
}
if _, ok := reg.Get("goal_blocked"); !ok {
t.Fatal("goal_blocked not found after Register")
}
}
func TestGoalCompleteValidSummary(t *testing.T) {
st := NewGoalState()
st.Start("g1", "do the thing", 0)
tool := &GoalCompleteTool{State: st}
res := execGoalComplete(t, tool, `{"summary":"created hello.txt with the requested contents"}`)
if res.Terminate == nil || !*res.Terminate {
t.Fatalf("expected Terminate=true, got %v", res.Terminate)
}
if snap := st.Snapshot(); snap.Status != GoalComplete {
t.Errorf("status = %q, want complete", snap.Status)
}
}
func TestGoalCompleteRejectsEmpty(t *testing.T) {
st := NewGoalState()
st.Start("g1", "do the thing", 0)
tool := &GoalCompleteTool{State: st}
res := execGoalComplete(t, tool, `{"summary":" "}`)
if !isErrorResult(res) {
t.Fatal("expected error result for empty summary")
}
if snap := st.Snapshot(); snap.Status != GoalActive {
t.Errorf("status = %q, want still active after rejected summary", snap.Status)
}
}
func TestGoalCompleteRejectsContradictory(t *testing.T) {
st := NewGoalState()
st.Start("g1", "do the thing", 0)
tool := &GoalCompleteTool{State: st}
for _, summary := range []string{
`{"summary":"the goal is not complete yet"}`,
`{"summary":"tests still fail but I stopped"}`,
`{"summary":"the task is unfinished"}`,
} {
res := execGoalComplete(t, tool, summary)
if !isErrorResult(res) {
t.Fatalf("expected error result for contradictory summary %s", summary)
}
}
if snap := st.Snapshot(); snap.Status != GoalActive {
t.Errorf("status = %q, want still active", snap.Status)
}
}
func TestGoalBlockedRecordsReason(t *testing.T) {
st := NewGoalState()
st.Start("g1", "do the thing", 0)
tool := &GoalBlockedTool{State: st}
res, err := tool.Execute(context.Background(), "c1",
json.RawMessage(`{"reason":"missing API key","evidence":"env AUTH_TOKEN is empty"}`), nil)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.Terminate == nil || !*res.Terminate {
t.Fatalf("expected Terminate=true, got %v", res.Terminate)
}
snap := st.Snapshot()
if snap.Status != GoalBlocked {
t.Errorf("status = %q, want blocked", snap.Status)
}
if snap.BlockReason != "missing API key" {
t.Errorf("block reason = %q", snap.BlockReason)
}
}
func TestGoalBlockedRequiresEvidence(t *testing.T) {
st := NewGoalState()
st.Start("g1", "do the thing", 0)
tool := &GoalBlockedTool{State: st}
res, err := tool.Execute(context.Background(), "c1",
json.RawMessage(`{"reason":"stuck","evidence":""}`), nil)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !isErrorResult(res) {
t.Fatal("expected error result when evidence is empty")
}
if snap := st.Snapshot(); snap.Status != GoalActive {
t.Errorf("status = %q, want still active", snap.Status)
}
}
func TestGoalStateRecordIteration(t *testing.T) {
st := NewGoalState()
st.Start("g1", "obj", 1000)
st.RecordIteration(100, true) // tool activity resets no-progress
st.RecordIteration(50, false) // no tool activity
st.RecordIteration(50, false)
snap := st.Snapshot()
if snap.Iterations != 3 {
t.Errorf("iterations = %d, want 3", snap.Iterations)
}
if snap.TokensUsed != 200 {
t.Errorf("tokensUsed = %d, want 200", snap.TokensUsed)
}
if snap.NoProgress != 2 {
t.Errorf("noProgress = %d, want 2", snap.NoProgress)
}
if snap.TokenBudget != 1000 {
t.Errorf("tokenBudget = %d, want 1000", snap.TokenBudget)
}
}
func TestGoalStateClear(t *testing.T) {
st := NewGoalState()
st.Start("g1", "obj", 0)
st.Clear()
if snap := st.Snapshot(); snap.Status != GoalIdle || snap.Objective != "" {
t.Errorf("after Clear: status=%q objective=%q, want idle/empty", snap.Status, snap.Objective)
}
}
func TestGoalStateResume(t *testing.T) {
st := NewGoalState()
st.Start("g1", "obj", 1000)
// Simulate a run that tripped a safety guard.
st.RecordIteration(500, false)
st.RecordIteration(500, false)
st.SetStatus(GoalPaused)
st.Resume()
snap := st.Snapshot()
if snap.Status != GoalActive {
t.Errorf("status = %q, want active after Resume", snap.Status)
}
if snap.Iterations != 0 || snap.NoProgress != 0 || snap.TokensUsed != 0 {
t.Errorf("Resume should clear transient counters: iterations=%d noProgress=%d tokensUsed=%d",
snap.Iterations, snap.NoProgress, snap.TokensUsed)
}
if snap.Objective != "obj" || snap.TokenBudget != 1000 {
t.Errorf("Resume should preserve objective/budget: objective=%q budget=%d", snap.Objective, snap.TokenBudget)
}
}
+48
View File
@@ -0,0 +1,48 @@
// This file implements the HTML→Markdown reduction used by the webfetch tool
// (US-012, #128). It wraps the JohannesKaufmann/html-to-markdown/v2 library: the
// base plugin already strips head/script/style/link/meta/iframe/noscript/input,
// and we additionally register the remaining page chrome (nav/footer/header/
// aside/form/svg/template) for removal so only readable content survives. The
// commonmark plugin renders headings, links, lists, code, emphasis, and tables.
package agenttool
import (
"sync"
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/base"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/commonmark"
)
// chromeElements are dropped whole (element + subtree) in addition to the base
// plugin's defaults: they carry no readable body content for a text extraction.
var chromeElements = []string{
"nav", "footer", "header", "aside", "svg", "form", "template",
}
// mdConverter is the shared, configured converter. It is built once — NewConverter
// registers plugins and tag handlers, which is wasteful to repeat per call, and
// the converter is safe for concurrent ConvertString use.
var mdConverter = sync.OnceValue(func() *converter.Converter {
conv := converter.NewConverter(
converter.WithPlugins(
base.NewBasePlugin(),
commonmark.NewCommonmarkPlugin(),
),
)
for _, tag := range chromeElements {
conv.Register.TagType(tag, converter.TagTypeRemove, converter.PriorityStandard)
}
return conv
})
// htmlToMarkdown converts body (HTML) to a simplified Markdown string. On a
// conversion error (rare — the parser is lenient) it falls back to the raw bytes
// so the caller always gets usable text.
func htmlToMarkdown(body []byte) string {
md, err := mdConverter().ConvertString(string(body))
if err != nil {
return string(body)
}
return md
}
@@ -0,0 +1,71 @@
// Tests for the HTML→Markdown reduction (US-012, #128): headings, links, lists,
// code, and dropped chrome/script elements. The conversion is delegated to the
// html-to-markdown/v2 library; these tests pin the behavior webfetch relies on.
package agenttool
import (
"strings"
"testing"
)
// TestHTMLToMarkdownBasics checks headings, emphasis, and links render.
func TestHTMLToMarkdownBasics(t *testing.T) {
html := `<html><body>
<h2>Section</h2>
<p>Some <strong>bold</strong> and a <a href="https://go.dev">link</a>.</p>
</body></html>`
md := htmlToMarkdown([]byte(html))
for _, want := range []string{"## Section", "**bold**", "[link](https://go.dev)"} {
if !strings.Contains(md, want) {
t.Errorf("markdown missing %q in:\n%s", want, md)
}
}
}
// TestHTMLToMarkdownDropsChrome checks script/style/nav/footer content is removed
// while real body content survives.
func TestHTMLToMarkdownDropsChrome(t *testing.T) {
html := `<html><head><style>.x{}</style></head><body>
<nav>menu links</nav>
<script>tracker()</script>
<p>real content</p>
<footer>copyright notice</footer>
</body></html>`
md := htmlToMarkdown([]byte(html))
if !strings.Contains(md, "real content") {
t.Errorf("body content dropped: %q", md)
}
for _, gone := range []string{"tracker()", ".x{}", "menu links", "copyright notice"} {
if strings.Contains(md, gone) {
t.Errorf("chrome/noise %q leaked into: %q", gone, md)
}
}
}
// TestHTMLToMarkdownLists checks list items become dashes.
func TestHTMLToMarkdownLists(t *testing.T) {
html := `<ul><li>one</li><li>two</li></ul>`
md := htmlToMarkdown([]byte(html))
if !strings.Contains(md, "one") || !strings.Contains(md, "two") {
t.Errorf("list not rendered: %q", md)
}
if !strings.Contains(md, "- ") {
t.Errorf("list markers missing: %q", md)
}
}
// TestHTMLToMarkdownInlineSpacing checks spaces around inline elements survive
// (regression: "A <a>link</a> here" must not become "Alinkhere").
func TestHTMLToMarkdownInlineSpacing(t *testing.T) {
md := htmlToMarkdown([]byte(`<p>A <a href="https://x.io">link</a> here.</p>`))
if !strings.Contains(md, "A [link](https://x.io) here.") {
t.Errorf("inline spacing lost: %q", md)
}
}
// TestHTMLToMarkdownEmptyFallback checks empty input does not panic.
func TestHTMLToMarkdownEmptyFallback(t *testing.T) {
if got := htmlToMarkdown([]byte("")); strings.TrimSpace(got) != "" {
t.Errorf("empty input = %q, want empty", got)
}
}
+170
View File
@@ -0,0 +1,170 @@
// This file implements the memory_search AgentTool (issue #477): a read-only
// tool that queries the persistent memory library (internal/memory) by relevance
// using its BM25 full-text index and returns ranked snippets to the model.
//
// Writes are intentionally NOT a separate tool here. Per the SPEC (§4.1/§4.2),
// memory writes reuse the existing Write/Edit file tools, constrained to the
// memory root and carrying the canonical frontmatter (name/description/
// metadata.type). Traversal protection for those writes lives in the memory
// package (memory.assertSafeComponent) and the write-path plumbing; a dedicated
// memory_write tool would duplicate that. After an off-tool write, the next
// memory_search picks it up automatically because Execute searches with
// ReconcileFirst=true (lazy reconcile).
package agenttool
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/memory"
)
// memorySearchDefaultLimit is the result cap used when the caller omits limit or
// passes a non-positive value; memorySearchMaxLimit is the hard upper bound.
const (
memorySearchDefaultLimit = 10
memorySearchMaxLimit = 50
)
// MemorySearchTool searches the persistent memory library by relevance. Store is
// exported so the loop-integration node (#481) can construct the tool with a
// live *memory.Store, mirroring how TodoTool exposes its Store.
type MemorySearchTool struct {
// Store is the persistent memory store. When nil, Execute degrades to a
// friendly no-op result rather than erroring, so a session without memory
// configured still runs.
Store *memory.Store
}
// memorySearchArgs is the decoded argument shape for memory_search.
type memorySearchArgs struct {
Query string `json:"query"`
Scope string `json:"scope"`
Type string `json:"type"`
Limit int `json:"limit"`
}
// Name implements AgentTool.
func (t *MemorySearchTool) Name() string { return "memory_search" }
// Description implements AgentTool.
func (t *MemorySearchTool) Description() string {
return "Search the persistent memory library by relevance (BM25 full-text) " +
"and return ranked snippets from previously saved notes, checkpoints, and " +
"references. Use it to recall context from earlier sessions before " +
"answering or acting. Optional filters: scope (global|projects|sessions|cc), " +
"type (user|feedback|project|reference|checkpoint|progress|notes|free), and " +
"limit (default 10, max 50). Results are ordered most-relevant first."
}
// Schema implements AgentTool.
func (t *MemorySearchTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"query": {"type": "string", "description": "Free-text query; tokenized and matched against memory bodies (BM25)."},
"scope": {"type": "string", "description": "Optional scope filter: global, projects, sessions, or cc."},
"type": {"type": "string", "description": "Optional type filter: user, feedback, project, reference, checkpoint, progress, notes, or free."},
"limit": {"type": "integer", "description": "Max results to return (default 10, capped at 50)."}
},
"required": ["query"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. memory_search is read-only, so it runs in
// the default parallel mode.
func (t *MemorySearchTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
// Execute implements AgentTool. It decodes the args, runs a lazily-reconciled
// BM25 search, and formats the ranked hits as text (one line each:
// "[type/scope] path (score) — snippet") with the structured []SearchResult in
// Details. A nil Store or empty query degrades to a friendly no-op result rather
// than a Go error so the loop keeps running.
func (t *MemorySearchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[memorySearchArgs](args, "memory_search")
if bad != nil {
return *bad, nil
}
query := strings.TrimSpace(a.Query)
if t.Store == nil {
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(
"memory_search: no memory store configured; nothing to search.")},
}, nil
}
if query == "" {
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(
"memory_search: empty query; provide a search string.")},
}, nil
}
limit := a.Limit
if limit <= 0 {
limit = memorySearchDefaultLimit
}
if limit > memorySearchMaxLimit {
limit = memorySearchMaxLimit
}
results, err := t.Store.Search(query, memory.SearchOptions{
Scope: strings.TrimSpace(a.Scope),
Type: strings.TrimSpace(a.Type),
Limit: limit,
ReconcileFirst: true, // lazy reconcile so off-tool writes are indexed
// ScoreFloor left at its zero value → package default (0.15).
})
if err != nil {
return errorResult(fmt.Sprintf("memory_search: %v", err)), nil
}
if len(results) == 0 {
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(
fmt.Sprintf("memory_search: no results for %q.", query))},
Details: results,
}, nil
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(formatMemoryResults(query, results))},
Details: results,
}, nil
}
// formatMemoryResults renders ranked hits as a header line plus one line per
// result: "[type/scope] path (score) — snippet". Snippets are whitespace-
// collapsed so a multi-line body stays on a single row.
func formatMemoryResults(query string, results []memory.SearchResult) string {
var b strings.Builder
fmt.Fprintf(&b, "memory_search: %d result(s) for %q, most relevant first:", len(results), query)
for _, r := range results {
typ := string(r.Type)
if typ == "" {
typ = "free"
}
scope := string(r.Scope)
if r.ScopeID != "" {
scope = scope + "/" + r.ScopeID
}
line := fmt.Sprintf("\n[%s/%s] %s (%.3f)", typ, scope, r.Path, r.Score)
if snip := collapseWhitespace(r.Snippet); snip != "" {
line += " — " + snip
}
b.WriteString(line)
}
return b.String()
}
// collapseWhitespace folds any run of whitespace (including newlines) into a
// single space and trims the ends, keeping a snippet to one line.
func collapseWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
+179
View File
@@ -0,0 +1,179 @@
package agenttool
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/memory"
)
// newMemoryStoreWithCorpus opens a *memory.Store over a temp DB + temp mimo root
// and writes a couple of .md files under the layout. It does NOT reconcile — the
// tool's ReconcileFirst=true is expected to index them lazily on first search.
func newMemoryStoreWithCorpus(t *testing.T) *memory.Store {
t.Helper()
base := t.TempDir()
root := filepath.Join(base, "mimo")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
writeMemFile(t, root, "permission deadlock encountered during checkpoint save then retry succeeded",
"projects", "proj1", "notes", "rare.md")
writeMemFile(t, root, "unrelated grocery shopping list",
"global", "user", "u1.md")
dbPath := filepath.Join(base, "sub", "memory.db")
st, err := memory.Open(dbPath, root, "")
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func writeMemFile(t *testing.T, root, body string, segs ...string) string {
t.Helper()
full := filepath.Join(append([]string{root}, segs...)...)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %q: %v", full, err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatalf("write %q: %v", full, err)
}
return filepath.Clean(full)
}
func runMemorySearch(t *testing.T, tool *MemorySearchTool, args map[string]any) (string, any) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
res, err := tool.Execute(context.Background(), "call-1", raw, nil)
if err != nil {
t.Fatalf("Execute returned Go error: %v", err)
}
return contentText(res.Content), res.Details
}
// contentText concatenates the text of every TextContent block in a result.
func contentText(content agentcore.ContentList) string {
var b strings.Builder
for _, c := range content {
if tc, ok := c.(agentcore.TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
func TestMemorySearchToolInterface(t *testing.T) {
tool := &MemorySearchTool{}
if tool.Name() != "memory_search" {
t.Fatalf("Name = %q, want memory_search", tool.Name())
}
if tool.Description() == "" {
t.Fatal("Description must not be empty")
}
// Schema must be valid JSON declaring query as required.
var schema struct {
Required []string `json:"required"`
}
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
t.Fatalf("Schema is not valid JSON: %v", err)
}
if len(schema.Required) != 1 || schema.Required[0] != "query" {
t.Fatalf("Schema required = %v, want [query]", schema.Required)
}
}
func TestMemorySearchFindsSnippet(t *testing.T) {
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
text, details := runMemorySearch(t, tool, map[string]any{"query": "permission deadlock"})
if !strings.Contains(text, "rare.md") {
t.Fatalf("expected result text to reference rare.md, got:\n%s", text)
}
if !strings.Contains(strings.ToLower(text), "permission") {
t.Fatalf("expected snippet to mention 'permission', got:\n%s", text)
}
// Details must carry the structured results (lazy reconcile indexed the file).
results, ok := details.([]memory.SearchResult)
if !ok {
t.Fatalf("Details type = %T, want []memory.SearchResult", details)
}
if len(results) == 0 {
t.Fatal("expected at least one structured result")
}
found := false
for _, r := range results {
if strings.HasSuffix(r.Path, filepath.Join("notes", "rare.md")) {
found = true
}
}
if !found {
t.Fatalf("expected rare.md among structured results, got %+v", results)
}
}
func TestMemorySearchScopeAndTypeFilter(t *testing.T) {
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
// Filter to the global/user doc; the projects/notes doc must be excluded even
// though it also matches the shared word.
text, _ := runMemorySearch(t, tool, map[string]any{
"query": "grocery permission",
"scope": "global",
"type": "user",
})
if strings.Contains(text, "rare.md") {
t.Fatalf("scope/type filter should exclude rare.md, got:\n%s", text)
}
if !strings.Contains(text, "u1.md") {
t.Fatalf("expected u1.md to match global/user filter, got:\n%s", text)
}
}
func TestMemorySearchNoResults(t *testing.T) {
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
text, _ := runMemorySearch(t, tool, map[string]any{"query": "zzzznonexistenttoken"})
if !strings.Contains(text, "no results") {
t.Fatalf("expected a clear empty message, got:\n%s", text)
}
}
func TestMemorySearchEmptyQueryNoOp(t *testing.T) {
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
text, _ := runMemorySearch(t, tool, map[string]any{"query": " "})
if !strings.Contains(text, "empty query") {
t.Fatalf("expected empty-query no-op message, got:\n%s", text)
}
}
func TestMemorySearchNilStoreNoOp(t *testing.T) {
tool := &MemorySearchTool{} // Store nil
text, _ := runMemorySearch(t, tool, map[string]any{"query": "anything"})
if !strings.Contains(text, "no memory store") {
t.Fatalf("expected nil-store no-op message, got:\n%s", text)
}
}
func TestMemorySearchInvalidArgs(t *testing.T) {
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
res, err := tool.Execute(context.Background(), "call-1", json.RawMessage(`{"query": 123}`), nil)
if err != nil {
t.Fatalf("Execute returned Go error: %v", err)
}
var text strings.Builder
text.WriteString(contentText(res.Content))
if !strings.Contains(text.String(), "invalid arguments") {
t.Fatalf("expected invalid-arguments error result, got:\n%s", text.String())
}
}
+178
View File
@@ -0,0 +1,178 @@
// This file implements the read tool (US-015): read a file's contents by path,
// with optional line offset/limit, numbered output, and large-file truncation.
// Paths are resolved against a Root and rejected if they escape it (path
// traversal guard) or do not exist.
package agenttool
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// readToolMaxLines caps how many lines a single read returns before truncating
// (protects the model's context from huge files). Callers page with offset.
const readToolMaxLines = 2000
// readToolMaxLineLen caps how many bytes of a single line are returned; longer
// lines are truncated with a marker.
const readToolMaxLineLen = 2000
// scanBufInit is the initial per-line scanner buffer (it grows on demand up to
// the max). readScanBufMax is generous — a read may page through a file with
// very long lines (minified JS, JSON) that must not error out mid-read.
const (
scanBufInit = 64 * 1024
readScanBufMax = 16 * 1024 * 1024
grepScanBufMax = 1 * 1024 * 1024
)
// filePerm / dirPerm are the modes new files and parent directories are created
// with by the write/edit tools (standard non-executable file, traversable dir).
const (
filePerm os.FileMode = 0o644
dirPerm os.FileMode = 0o755
)
// ReadTool reads text files under Root. It is the first concrete AgentTool.
type ReadTool struct {
// Root is the directory that bounds all reads. A path resolving outside Root
// is rejected. Empty Root defaults to the current working directory.
Root string
// ExtraRoots are additional trusted directories a read may target even though
// they lie outside Root. It exists for the skills directory: pigo advertises
// each skill's absolute SKILL.md path in the system prompt and tells the model
// to read it, so the read tool must permit those paths (they are otherwise
// outside the workspace and rejected).
ExtraRoots []string
}
// readToolArgs is the decoded argument shape for ReadTool.
type readToolArgs struct {
// Path is the file to read, relative to Root (or absolute within Root).
Path string `json:"path"`
// Offset is the 1-based line to start reading from. 0/1 both mean line 1.
Offset int `json:"offset,omitempty"`
// Limit is the maximum number of lines to return. 0 means the default cap.
Limit int `json:"limit,omitempty"`
}
// Name implements AgentTool.
func (t *ReadTool) Name() string { return "read" }
// Description implements AgentTool.
func (t *ReadTool) Description() string {
return "Read a text file's contents by path, with optional line offset/limit. " +
"Output is line-numbered; very large files are truncated."
}
// Schema implements AgentTool.
func (t *ReadTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read, relative to the workspace root."},
"offset": {"type": "integer", "description": "1-based line number to start from.", "minimum": 0},
"limit": {"type": "integer", "description": "Maximum number of lines to return.", "minimum": 0}
},
"required": ["path"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. Reads are side-effect free → parallel.
func (t *ReadTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
// resolveWithin boundary policy, so every file tool enforces the same
// workspace-escape guard while the read tool can also reach trusted extra roots.
func (t *ReadTool) resolvePath(p string) (string, error) {
if len(t.ExtraRoots) == 0 {
return resolveWithin(t.Root, p)
}
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
}
// Execute implements AgentTool. It never returns a Go error for a read failure
// (bad path, missing file); those are encoded as error results so the model can
// react. The returned error is reserved for argument decode failures.
func (t *ReadTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[readToolArgs](args, "read")
if bad != nil {
return *bad, nil
}
if a.Path == "" {
return errorResult("read: path is required"), nil
}
full, err := t.resolvePath(a.Path)
if err != nil {
return errorResult("read: " + err.Error()), nil
}
info, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return errorResult(fmt.Sprintf("read: file %q does not exist", a.Path)), nil
}
return errorResult(fmt.Sprintf("read: cannot stat %q: %v", a.Path, err)), nil
}
if info.IsDir() {
return errorResult(fmt.Sprintf("read: %q is a directory, not a file", a.Path)), nil
}
f, err := os.Open(full)
if err != nil {
return errorResult(fmt.Sprintf("read: cannot open %q: %v", a.Path, err)), nil
}
defer f.Close()
text, truncated := readNumbered(f, a.Offset, a.Limit)
if truncated {
text += fmt.Sprintf("\n... (output truncated at %d lines; use offset to read more)", readToolMaxLines)
}
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
}
// readNumbered reads lines from r starting at 1-based offset, returning at most
// limit lines (capped at readToolMaxLines), each prefixed with its line number.
// The bool reports whether the output was truncated by the cap.
func readNumbered(r io.Reader, offset, limit int) (string, bool) {
if offset < 1 {
offset = 1
}
max := limit
if max <= 0 || max > readToolMaxLines {
max = readToolMaxLines
}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, scanBufInit), readScanBufMax)
var b strings.Builder
lineNo := 0
emitted := 0
truncated := false
for sc.Scan() {
lineNo++
if lineNo < offset {
continue
}
if emitted >= max {
truncated = true
break
}
line := sc.Text()
if len(line) > readToolMaxLineLen {
line = line[:readToolMaxLineLen] + "… (line truncated)"
}
fmt.Fprintf(&b, "%6d\t%s\n", lineNo, line)
emitted++
}
return b.String(), truncated
}
+204
View File
@@ -0,0 +1,204 @@
package agenttool
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
func runRead(t *testing.T, tool *ReadTool, args map[string]any) (agentcore.AgentToolResult, bool) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
if gerr != nil {
t.Fatalf("execute returned go error: %v", gerr)
}
return res, false
}
func resultText(res agentcore.AgentToolResult) string {
var b strings.Builder
for _, c := range res.Content {
if tc, ok := c.(agentcore.TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
func TestReadToolBasic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hello.txt")
if err := os.WriteFile(path, []byte("line one\nline two\nline three\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
tool := &ReadTool{Root: dir}
res, _ := runRead(t, tool, map[string]any{"path": "hello.txt"})
text := resultText(res)
if !strings.Contains(text, "line one") || !strings.Contains(text, "line three") {
t.Errorf("missing content: %q", text)
}
// Line numbers present.
if !strings.Contains(text, "1\tline one") || !strings.Contains(text, "3\tline three") {
t.Errorf("missing line numbers: %q", text)
}
}
func TestReadToolOffsetLimit(t *testing.T) {
dir := t.TempDir()
var sb strings.Builder
for i := 1; i <= 10; i++ {
sb.WriteString("row\n")
}
path := filepath.Join(dir, "rows.txt")
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
tool := &ReadTool{Root: dir}
res, _ := runRead(t, tool, map[string]any{"path": "rows.txt", "offset": 3, "limit": 2})
text := resultText(res)
// Should include line numbers 3 and 4, not 1,2,5.
if !strings.Contains(text, "3\trow") || !strings.Contains(text, "4\trow") {
t.Errorf("offset/limit window wrong: %q", text)
}
if strings.Contains(text, "2\trow") || strings.Contains(text, "5\trow") {
t.Errorf("offset/limit leaked outside window: %q", text)
}
}
func TestReadToolMissingFile(t *testing.T) {
tool := &ReadTool{Root: t.TempDir()}
res, _ := runRead(t, tool, map[string]any{"path": "nope.txt"})
if !strings.Contains(resultText(res), "does not exist") {
t.Errorf("expected does-not-exist error, got %q", resultText(res))
}
}
func TestReadToolPathTraversal(t *testing.T) {
dir := t.TempDir()
// A secret sits outside the root.
parent := filepath.Dir(dir)
secret := filepath.Join(parent, "secret.txt")
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
t.Fatalf("write secret: %v", err)
}
defer os.Remove(secret)
tool := &ReadTool{Root: dir}
res, _ := runRead(t, tool, map[string]any{"path": "../secret.txt"})
text := resultText(res)
if strings.Contains(text, "top secret") {
t.Fatal("path traversal escaped the root!")
}
if !strings.Contains(text, "outside the workspace root") {
t.Errorf("expected boundary error, got %q", text)
}
}
func TestReadToolDirectory(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "subdir")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
tool := &ReadTool{Root: dir}
res, _ := runRead(t, tool, map[string]any{"path": "subdir"})
if !strings.Contains(resultText(res), "is a directory") {
t.Errorf("expected directory error, got %q", resultText(res))
}
}
func TestReadToolTruncation(t *testing.T) {
dir := t.TempDir()
var sb strings.Builder
for i := 0; i < readToolMaxLines+50; i++ {
sb.WriteString("x\n")
}
path := filepath.Join(dir, "big.txt")
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
tool := &ReadTool{Root: dir}
res, _ := runRead(t, tool, map[string]any{"path": "big.txt"})
if !strings.Contains(resultText(res), "output truncated") {
t.Error("expected truncation notice for oversized file")
}
}
func TestReadToolMissingPathArg(t *testing.T) {
tool := &ReadTool{Root: t.TempDir()}
res, _ := runRead(t, tool, map[string]any{})
if !strings.Contains(resultText(res), "path is required") {
t.Errorf("expected path-required error, got %q", resultText(res))
}
}
func TestReadToolExtraRootsAllowsTrustedOutsidePath(t *testing.T) {
work := t.TempDir()
// A skill file lives OUTSIDE the workspace root (mirrors ~/.agents/skills).
skills := t.TempDir()
skillFile := filepath.Join(skills, "weather", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(skillFile, []byte("skill body"), 0o644); err != nil {
t.Fatalf("write skill: %v", err)
}
// Without ExtraRoots the absolute skill path is rejected as out-of-workspace.
bounded := &ReadTool{Root: work}
res, _ := runRead(t, bounded, map[string]any{"path": skillFile})
if !strings.Contains(resultText(res), "outside the workspace root") {
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
}
// With the skills dir as an extra root the same read succeeds.
tool := &ReadTool{Root: work, ExtraRoots: []string{skills}}
res, _ = runRead(t, tool, map[string]any{"path": skillFile})
if !strings.Contains(resultText(res), "skill body") {
t.Fatalf("expected skill contents with ExtraRoots, got %q", resultText(res))
}
}
func TestReadToolExtraRootsStillBlocksUntrustedPath(t *testing.T) {
work := t.TempDir()
skills := t.TempDir()
// A secret sits outside BOTH the workspace root and the extra root.
other := t.TempDir()
secret := filepath.Join(other, "secret.txt")
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
t.Fatalf("write secret: %v", err)
}
tool := &ReadTool{Root: work, ExtraRoots: []string{skills}}
res, _ := runRead(t, tool, map[string]any{"path": secret})
text := resultText(res)
if strings.Contains(text, "top secret") {
t.Fatal("read escaped both roots!")
}
if !strings.Contains(text, "outside the workspace root") {
t.Errorf("expected boundary error for untrusted path, got %q", text)
}
}
func TestReadToolSchemaAndMode(t *testing.T) {
tool := &ReadTool{}
if tool.Name() != "read" {
t.Errorf("name = %q", tool.Name())
}
if tool.ExecutionMode() != agentcore.ToolExecutionParallel {
t.Errorf("read should be parallel")
}
var schema map[string]any
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
t.Errorf("schema not valid JSON: %v", err)
}
}
+220
View File
@@ -0,0 +1,220 @@
// This file implements the tool registry (US-014): tools are registered by
// name, and their arguments are validated against a per-tool JSON Schema
// (santhosh-tekuri/jsonschema v6) before execution. Validation failures are
// turned into a field-level error tool result rather than a Go error, so the
// model receives actionable feedback in the loop.
package agenttool
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/smallnest/pigo/internal/agentcore"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
// schemaPrinter renders jsonschema error kinds. LocalizedString dereferences
// the printer, so it must be non-nil.
var schemaPrinter = message.NewPrinter(language.English)
// ToolRegistry stores tools by name and validates call arguments against each
// tool's declared JSON Schema. It is safe for concurrent use.
type ToolRegistry struct {
mu sync.RWMutex
tools map[string]agentcore.AgentTool
compiled map[string]*jsonschema.Schema
}
// NewToolRegistry returns an empty registry.
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{
tools: make(map[string]agentcore.AgentTool),
compiled: make(map[string]*jsonschema.Schema),
}
}
// Register adds a tool, compiling its JSON Schema up front so bad schemas fail
// at registration rather than on first call. A duplicate name is an error. A
// tool whose Schema() is empty is registered with no validation.
func (r *ToolRegistry) Register(tool agentcore.AgentTool) error {
name := tool.Name()
if name == "" {
return fmt.Errorf("registry: tool has empty name")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tools[name]; exists {
return fmt.Errorf("registry: tool %q already registered", name)
}
if raw := tool.Schema(); len(bytes.TrimSpace(raw)) > 0 && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
sch, err := compileSchema(name, raw)
if err != nil {
return fmt.Errorf("registry: tool %q schema: %w", name, err)
}
r.compiled[name] = sch
}
r.tools[name] = tool
return nil
}
// Get returns the tool registered under name and whether it was found.
func (r *ToolRegistry) Get(name string) (agentcore.AgentTool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.tools[name]
return t, ok
}
// List returns all registered tools sorted by name (stable ordering for
// deterministic provider tool declarations).
func (r *ToolRegistry) List() []agentcore.AgentTool {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]agentcore.AgentTool, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
// FieldError is a single validation failure located at a JSON-pointer path
// within the arguments.
type FieldError struct {
Field string `json:"field"` // JSON pointer, e.g. "/path" or "" for root
Message string `json:"message"` // human-readable reason
}
// Validate checks args against the tool's compiled schema. It returns nil when
// the tool has no schema or the arguments are valid; otherwise it returns the
// flattened field-level errors. An unknown tool name is reported as a single
// root-level error.
func (r *ToolRegistry) Validate(name string, args json.RawMessage) []FieldError {
r.mu.RLock()
_, known := r.tools[name]
sch, hasSchema := r.compiled[name]
r.mu.RUnlock()
if !known {
return []FieldError{{Field: "", Message: fmt.Sprintf("unknown tool %q", name)}}
}
if !hasSchema {
return nil
}
var inst any
dec := json.NewDecoder(bytes.NewReader(nonEmptyJSON(args)))
dec.UseNumber()
if err := dec.Decode(&inst); err != nil {
return []FieldError{{Field: "", Message: fmt.Sprintf("arguments are not valid JSON: %v", err)}}
}
if err := sch.Validate(inst); err != nil {
var verr *jsonschema.ValidationError
if as := asValidationError(err); as != nil {
verr = as
}
if verr != nil {
return flattenValidationError(verr)
}
return []FieldError{{Field: "", Message: err.Error()}}
}
return nil
}
// ValidationErrorResult builds an error AgentToolResult describing the given
// field errors, for the loop to hand back to the model (FR: field-level error
// tool result). Terminate is left nil (a validation failure never ends the run).
func ValidationErrorResult(toolName string, errs []FieldError) agentcore.AgentToolResult {
var b strings.Builder
fmt.Fprintf(&b, "Invalid arguments for tool %q:\n", toolName)
for _, e := range errs {
field := e.Field
if field == "" {
field = "(root)"
}
fmt.Fprintf(&b, " - %s: %s\n", field, e.Message)
}
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(strings.TrimRight(b.String(), "\n"))},
Details: errs,
}
}
// compileSchema compiles a raw JSON Schema document held in memory.
func compileSchema(name string, raw json.RawMessage) (*jsonschema.Schema, error) {
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return nil, err
}
c := jsonschema.NewCompiler()
// A synthetic in-memory URL; each tool gets its own so schemas never clash.
loc := "mem:///" + name + ".json"
if err := c.AddResource(loc, doc); err != nil {
return nil, err
}
return c.Compile(loc)
}
// flattenValidationError walks the ValidationError tree and returns one
// FieldError per leaf cause (the most specific failures), falling back to the
// node itself when it has no causes.
func flattenValidationError(e *jsonschema.ValidationError) []FieldError {
var out []FieldError
var walk func(n *jsonschema.ValidationError)
walk = func(n *jsonschema.ValidationError) {
if len(n.Causes) == 0 {
out = append(out, FieldError{
Field: jsonPointer(n.InstanceLocation),
Message: n.ErrorKind.LocalizedString(schemaPrinter),
})
return
}
for _, c := range n.Causes {
walk(c)
}
}
walk(e)
if len(out) == 0 {
out = append(out, FieldError{Field: jsonPointer(e.InstanceLocation), Message: e.Error()})
}
return out
}
// jsonPointer renders an instance-location path as a JSON pointer.
func jsonPointer(loc []string) string {
if len(loc) == 0 {
return ""
}
var b strings.Builder
for _, tok := range loc {
b.WriteByte('/')
tok = strings.ReplaceAll(tok, "~", "~0")
tok = strings.ReplaceAll(tok, "/", "~1")
b.WriteString(tok)
}
return b.String()
}
// asValidationError extracts a *jsonschema.ValidationError from err if present.
func asValidationError(err error) *jsonschema.ValidationError {
if verr, ok := err.(*jsonschema.ValidationError); ok {
return verr
}
return nil
}
// nonEmptyJSON treats empty arguments as an empty object so schemas with only
// optional properties validate, and "required" violations are reported.
func nonEmptyJSON(args json.RawMessage) []byte {
if len(bytes.TrimSpace(args)) == 0 {
return []byte("{}")
}
return args
}

Some files were not shown because too many files have changed in this diff Show More