first commit
This commit is contained in:
@@ -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/
|
||||||
@@ -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
@@ -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/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Node(pi-coop 本地调试可能产生)
|
||||||
|
# ============================================================
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# IDE / 编辑器
|
||||||
|
# ============================================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 操作系统
|
||||||
|
# ============================================================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AI 工具记忆目录
|
||||||
|
# ============================================================
|
||||||
|
.workbuddy/
|
||||||
@@ -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 引擎运行时(仅容器内需要,宿主机不用装) |
|
||||||
|
| WSL(Windows) | — | 在 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` |
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Local Agent
|
||||||
|
|
||||||
|
基于 Go(Gin)的多轮对话 AI Agent,ChatGPT 风格 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 通过环境变量注入,切勿提交到仓库。
|
||||||
@@ -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/ | 会话 ID(supervisor 维护,供跨轮恢复) |
|
||||||
|
| /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 创建 DONE(summary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
|
||||||
|
|
||||||
|
## 红线
|
||||||
|
|
||||||
|
- 不直接写 /blackboard/DONE——必须走 blackboard done 工具,保证原子创建。
|
||||||
|
- 不伪造实验、命令或提交结果;如实记录。
|
||||||
|
- 不修改 /blackboard/task.md、/blackboard/CLAUDE.md 与 /blackboard/result.json。
|
||||||
@@ -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 server(stdio 协议)
|
||||||
|
# - 模型配置走 ANTHROPIC_* 环境变量(不是 pi provider 体系)
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# ---- 系统工具 + Python ----
|
||||||
|
# 先把 apk 源换成清华镜像并用 http(node:22-alpine 的 Alpine 3.24 用官方 CDN
|
||||||
|
# 拉 APKINDEX 会报 TLS: unspecified error,改 http 绕过;装上 ca-certificates 后
|
||||||
|
# TLS 即恢复正常)。之后 apk add 装系统工具 + python3。
|
||||||
|
RUN sed -i 's|https://dl-cdn.alpinelinux.org|http://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories \
|
||||||
|
&& apk add --no-cache \
|
||||||
|
bash ca-certificates git ripgrep \
|
||||||
|
curl wget jq openssl file \
|
||||||
|
python3 py3-pip
|
||||||
|
|
||||||
|
# ---- Python 常用库 ----
|
||||||
|
# 用清华 PyPI 镜像加速;--no-cache-dir 避免膨胀;--break-system-packages 放行 Alpine pip 全局安装。
|
||||||
|
RUN pip3 install --no-cache-dir --break-system-packages \
|
||||||
|
-i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
requests urllib3 certifi idna charset-normalizer \
|
||||||
|
cryptography pyOpenSSL paramiko pyjwt \
|
||||||
|
PyYAML beautifulsoup4 lxml \
|
||||||
|
python-dotenv click tqdm \
|
||||||
|
numpy pandas \
|
||||||
|
httpx aiohttp websockets dnspython \
|
||||||
|
psutil pillow
|
||||||
|
|
||||||
|
# ---- 全局安装 Claude Code ----
|
||||||
|
# 使用 npmmirror 镜像加速国内下载。
|
||||||
|
# @anthropic-ai/claude-code:提供 claude CLI(headless 模式入口)
|
||||||
|
RUN npm install -g \
|
||||||
|
--registry=https://registry.npmmirror.com \
|
||||||
|
@anthropic-ai/claude-code@latest
|
||||||
|
|
||||||
|
# ---- 协作组件 ----
|
||||||
|
# blackboard MCP server 放 /mcp,supervisor 与 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"]
|
||||||
@@ -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 CLI,headless 模式入口) |
|
||||||
|
| 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 | 仅 anthropic(Claude Code 限制) |
|
||||||
|
| session 提取 | NDJSON 首行 session header | JSON result 的 session_id 字段 |
|
||||||
|
| session 恢复 | --session-id | --resume |
|
||||||
|
| 权限 | 无(pi 无权限弹窗) | --dangerously-skip-permissions(headless 必需,非 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`
|
||||||
@@ -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_APPEND,POSIX 下并发不交错)
|
||||||
|
* - done 原子创建 DONE 标记(O_CREAT|O_EXCL,保证唯一创建)
|
||||||
|
*
|
||||||
|
* 路径安全:所有传入路径都做 traversal 校验,拒绝绝对路径与 .. 越界。
|
||||||
|
*
|
||||||
|
* 通信协议:MCP stdio(JSON-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 用了内部 clean,Node 标准 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);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"blackboard": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/mcp/blackboard-server.mjs"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 done(BB 指向黑板根目录)。
|
||||||
|
- 不要伪造命令结果、文件内容或提交响应;如实记录。
|
||||||
|
- 全部工作完成、交付物完整时,用 blackboard done 创建完成标记(summary 写最终交付总结,含关键结果、flag、提交响应、产物清单)。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
- 若本轮未完成,下一轮会用 --resume 恢复你的会话继续推进,跨轮保持上下文。
|
||||||
|
- 宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 blackboard done 标记,summary 写明「未解出」与已尝试内容,让调度方及时切换下一题。
|
||||||
@@ -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 把结果
|
||||||
|
# 以结构化 JSON(result.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 不带 /v1(SDK 自己追加)。
|
||||||
|
# 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/workspace,AGENTS.md 注入链从 cwd 起,
|
||||||
|
# Claude Code 会自动加载 cwd 下的 CLAUDE.md(AGENTS.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 不带 /v1(SDK 自动追加 /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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 是接口类型:openai(OpenAI 兼容)或 anthropic(Anthropic 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()
|
||||||
|
}
|
||||||
@@ -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("估算应大于 0,got %d", n)
|
||||||
|
}
|
||||||
|
plain := estimateMessageTokens(Message{Role: "user", Content: stringPointer("你好")})
|
||||||
|
if n <= plain {
|
||||||
|
t.Fatalf("含 tool_calls 的消息估算应大于纯文本,got %d vs %d", n, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 进程 PID(docker 模式为 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
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
|
||||||
|
// ---- pigo(anthropicCompatDriver 只追加 /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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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_content,content 可能为空
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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("模型没有返回压缩摘要")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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=pigo(pigo,支持 openai+anthropic 协议)或 engine=claude(Claude 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)、pigo(pigo)或 claude(Claude 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), ¶ms); 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
// 默认 1800s:600s/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 区分:
|
||||||
|
// - pigo:anthropicCompatDriver 只追加 /messages(不含 /v1),因此 anthropic
|
||||||
|
// 端点需补 /v1(最终 /v1/messages);openai 端点需保留 /v1。
|
||||||
|
// - pi:provider 使用官方 SDK(@anthropic-ai/sdk / openai),SDK 自行追加完整
|
||||||
|
// 路径(anthropic: /v1/messages;openai: /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/sdk,SDK 自带 /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 任务。
|
||||||
|
// 与 runCoopAsync(Docker 模式)对应:无需 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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}$`)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 单 agent 任务协议
|
||||||
|
|
||||||
|
你是独立的任务执行 agent,完成 /blackboard/task.md 中的任务。任务信息通过工作区与黑板目录交换。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
| 路径 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| /blackboard/task.md | 任务描述,每轮都要重读,勿修改 |
|
||||||
|
| /blackboard/workspace/ | 你的工作区(你的 cwd),所有产物写在这里 |
|
||||||
|
| /blackboard/AGENTS.md | 本协议(副本在你的工作区,勿修改) |
|
||||||
|
| /blackboard/DONE | 完成标记,存在即表示任务已交付完成,只用 blackboard 工具创建 |
|
||||||
|
| /blackboard/logs/ | 每轮运行日志(supervisor 维护) |
|
||||||
|
| /blackboard/sessions/ | 会话 ID(supervisor 维护,供跨轮恢复) |
|
||||||
|
| /blackboard/result.json | 任务结果(supervisor 结束时生成,勿手动修改) |
|
||||||
|
|
||||||
|
## 每轮流程
|
||||||
|
|
||||||
|
1. **读取**:读取 /blackboard/task.md,明确任务与提交规则(若为解题任务,通常含 flag 提交 API / token / unique_code)。
|
||||||
|
2. **评估**:检查工作区已有产物,判断进度与缺口,不重复已完成工作。
|
||||||
|
3. **执行**:推进任务——执行命令、读写文件、编写产物到 /blackboard/workspace/。
|
||||||
|
4. **提交**:拿到 flag 的任务,立即按 task.md 中约定的规则提交,并把提交响应与得分写入产物。
|
||||||
|
5. **判定**:全部工作完成、交付物完整后,用 blackboard 工具 action=done 创建 DONE(summary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
|
||||||
|
|
||||||
|
## 红线
|
||||||
|
|
||||||
|
- 不直接写 /blackboard/DONE——必须走 blackboard 工具 action=done,保证原子创建。
|
||||||
|
- 不伪造实验、命令或提交结果;如实记录。
|
||||||
|
- 不修改 /blackboard/task.md、/blackboard/AGENTS.md 与 /blackboard/result.json。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# pi 协作镜像(单 agent 版,与 blackboard 协议适配)
|
||||||
|
#
|
||||||
|
# 构建(在仓库根目录执行,构建上下文为 pi-coop/ 目录):
|
||||||
|
# cd /mnt/e/Code/Go/awesomeProject/agent
|
||||||
|
# DOCKER_BUILDKIT=0 docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/
|
||||||
|
#
|
||||||
|
# 基础镜像用 node:22-alpine(自带 node 22 + npm,满足 pi >=22.19 要求)。
|
||||||
|
# 在其上装系统工具(curl/wget/git/jq/openssl/ripgrep)和 python3 + 常用库,
|
||||||
|
# 再全局安装 pi coding agent,最后放入协作组件(extension/supervisor/prompts)。
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# ---- 系统工具 + Python ----
|
||||||
|
# 先把 apk 源换成清华镜像并用 http(node:22-alpine 的 Alpine 3.24 用官方 CDN
|
||||||
|
# 拉 APKINDEX 会报 TLS: unspecified error,改 http 绕过;装上 ca-certificates 后
|
||||||
|
# TLS 即恢复正常)。之后 apk add 装系统工具 + python3。
|
||||||
|
RUN sed -i 's|https://dl-cdn.alpinelinux.org|http://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories \
|
||||||
|
&& apk add --no-cache \
|
||||||
|
bash ca-certificates git ripgrep \
|
||||||
|
curl wget jq openssl file \
|
||||||
|
python3 py3-pip
|
||||||
|
|
||||||
|
# ---- Python 常用库 ----
|
||||||
|
# 用清华 PyPI 镜像加速;--no-cache-dir 避免膨胀;--break-system-packages 放行 Alpine pip 全局安装。
|
||||||
|
RUN pip3 install --no-cache-dir --break-system-packages \
|
||||||
|
-i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
requests urllib3 certifi idna charset-normalizer \
|
||||||
|
cryptography pyOpenSSL paramiko pyjwt \
|
||||||
|
PyYAML beautifulsoup4 lxml \
|
||||||
|
python-dotenv click tqdm \
|
||||||
|
numpy pandas \
|
||||||
|
httpx aiohttp websockets dnspython \
|
||||||
|
psutil pillow
|
||||||
|
|
||||||
|
# ---- 全局安装 pi coding agent ----
|
||||||
|
# --ignore-scripts 跳过 lifecycle script(pi 官方推荐,dist 已预编译)。
|
||||||
|
# 使用 npmmirror 镜像加速国内下载。
|
||||||
|
RUN npm install -g --ignore-scripts \
|
||||||
|
--registry=https://registry.npmmirror.com \
|
||||||
|
@earendil-works/pi-coding-agent@latest
|
||||||
|
|
||||||
|
# ---- 协作组件 ----
|
||||||
|
# extension 放 /extensions,supervisor 与 prompts 放默认路径。
|
||||||
|
RUN mkdir -p /extensions /prompts /blackboard
|
||||||
|
COPY coop.ts /extensions/coop.ts
|
||||||
|
COPY supervisor.sh /usr/local/bin/supervisor.sh
|
||||||
|
COPY AGENTS.md /prompts/AGENTS.md
|
||||||
|
COPY prompts /prompts
|
||||||
|
RUN chmod +x /usr/local/bin/supervisor.sh
|
||||||
|
|
||||||
|
# ---- 非 root 用户 ----
|
||||||
|
# node:22-alpine 自带 node 用户(uid 1000),直接复用,与 pigo-coop 的 uid 1000 对齐,
|
||||||
|
# 便于 blackboard 挂载卷权限一致。把协作目录所有权交给 node。
|
||||||
|
RUN chown -R node:node /extensions /prompts /blackboard
|
||||||
|
USER node
|
||||||
|
WORKDIR /blackboard
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/supervisor.sh"]
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
# pi 协作运行指南
|
||||||
|
|
||||||
|
> 只讲怎么跑。原理、架构见 [Dockerfile](./Dockerfile) 与 [supervisor.sh](./supervisor.sh)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 前置检查(1 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version # 确认 Docker 可用
|
||||||
|
node --version # 本机构建不需要,仅容器内 pi 需要 node>=22.19
|
||||||
|
```
|
||||||
|
|
||||||
|
与 pigo-coop 不同,pi-coop **不需要**预编译二进制:pi 通过 npm 全局安装在镜像内,
|
||||||
|
extension(coop.ts)由 pi 的 jiti 直接加载 .ts,无需预编译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 构建
|
||||||
|
|
||||||
|
在仓库根目录执行(构建上下文为 `pi-coop/` 目录,避免把 `pi/`、`pigo/` 等大目录发给 daemon):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f pi-coop/Dockerfile -t pi-coop pi-coop/
|
||||||
|
```
|
||||||
|
|
||||||
|
构建会:
|
||||||
|
1. 拉取 `node:24-bookworm-slim` 基础镜像;
|
||||||
|
2. `npm install -g @earendil-works/pi-coding-agent`(pi 本体);
|
||||||
|
3. 装系统工具(git/ripgrep/python3/jq/openssl…)与 Python 常用库;
|
||||||
|
4. 拷入 coop extension、supervisor、协议与提示。
|
||||||
|
|
||||||
|
> 国内构建慢时,Dockerfile 已用清华 PyPI 镜像加速 pip。npm 若慢可配 `--build-arg` 或宿主 npm 镜像。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 运行
|
||||||
|
|
||||||
|
### 3.1 直接运行(不保留任何数据)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-e MODEL=deepseek-chat \
|
||||||
|
-e BASE_URL=https://api.deepseek.com \
|
||||||
|
-e API_KEY=<your-key> \
|
||||||
|
-e PROTOCOL=openai \
|
||||||
|
-e TASK="请为 XX 编写设计文档并实现原型" \
|
||||||
|
-e ROUND_MAX=6 \
|
||||||
|
pi-coop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 推荐运行(保留黑板产物 + 跨 run 会话)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p blackboard
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD/blackboard:/blackboard" \
|
||||||
|
-e MODEL=deepseek-chat \
|
||||||
|
-e BASE_URL=https://api.deepseek.com \
|
||||||
|
-e API_KEY=<your-key> \
|
||||||
|
-e PROTOCOL=openai \
|
||||||
|
-e TASK="请为 XX 编写设计文档并实现原型" \
|
||||||
|
-e ROUND_MAX=6 \
|
||||||
|
pi-coop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 常用参数速查
|
||||||
|
|
||||||
|
| 参数 | 必填 | 默认 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `MODEL` | ✅ | — | 模型名 |
|
||||||
|
| `BASE_URL` | ✅ | — | OpenAI/Anthropic 兼容 base-url |
|
||||||
|
| `API_KEY` | ✅ | — | API key |
|
||||||
|
| `PROTOCOL` | 否 | `openai` | `openai` / `anthropic` |
|
||||||
|
| `TASK` | ✅(或用 `task.md`) | — | 任务描述 |
|
||||||
|
| `ROUND_MAX` | 否 | `10` | 最大轮次 |
|
||||||
|
| `TIMEOUT` | 否 | `600` | 单轮超时秒数(`0`=不限) |
|
||||||
|
|
||||||
|
> 环境变量与 pigo-coop 完全一致,`run_coop` 工具无需改动即可注入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 结果怎么看
|
||||||
|
|
||||||
|
- **完成**:stdout 打印 `[supervisor] status=solved`,退出码 `0`。
|
||||||
|
- **未完成**:达到 `ROUND_MAX`,`status=unsolved`,退出码 `1`。
|
||||||
|
- **参数错**:退出码 `2`。
|
||||||
|
|
||||||
|
保留黑板后查看:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls blackboard/workspace/ # agent 产物
|
||||||
|
cat blackboard/DONE # 最终交付总结
|
||||||
|
cat blackboard/result.json # 结构化结果(status/exit_code/summary/flag/artifacts)
|
||||||
|
cat blackboard/logs/round-1.log # 第 1 轮运行日志(pi --mode json 的 NDJSON 流)
|
||||||
|
```
|
||||||
|
|
||||||
|
`result.json` 字段固定:`status`、`exit_code`、`summary`、`flag`、`artifacts`,
|
||||||
|
与 pigo-coop 完全一致,外部调度方(如 agent-web 的 run_coop 工具)解析逻辑无需改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 与 pigo-coop 的差异
|
||||||
|
|
||||||
|
| 维度 | pigo-coop | pi-coop |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| agent | Go 编译的 pigo 二进制 | npm 安装的 pi(TypeScript) |
|
||||||
|
| 模型接入 | CLI `--base-url`/`--protocol` 直传 | coop extension 读环境变量注册 provider |
|
||||||
|
| blackboard 工具 | pigo 内置(blackboard_tool.go) | coop extension 注册(coop.ts) |
|
||||||
|
| 输出格式 | `-o stream-json` | `--mode json`(NDJSON,首行 session header) |
|
||||||
|
| session 恢复 | `--resume <id>` | `--session-id <id> --session-dir <dir>` |
|
||||||
|
| cwd | `-C <dir>` | 进程 cwd(supervisor 先 `cd`) |
|
||||||
|
| 协议(task.md/DONE/result.json) | 一致 | 一致 |
|
||||||
|
| 环境变量(MODEL/BASE_URL/...) | 一致 | 一致 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 常见运行问题
|
||||||
|
|
||||||
|
| 报错 / 现象 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 退出 2:`必须提供 MODEL / BASE_URL / API_KEY` | 补传环境变量 |
|
||||||
|
| `DONE already exists` | 正常,上一次运行已留标记(幂等) |
|
||||||
|
| `MissingSessionCwdError` | 跨轮 resume 时 session 记录的 cwd(/blackboard/workspace)不存在;确保挂载未变 |
|
||||||
|
| extension 加载失败 | 确认镜像内 `/extensions/coop.ts` 存在;`pi -e` 用 jiti 加载 ts,无需预编译 |
|
||||||
|
| 想从头重跑 | 删除挂载目录里的 `DONE` 与 `result.json` 后重新 run |
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* pi-coop extension:让 pi 适配 blackboard 协作协议。
|
||||||
|
*
|
||||||
|
* 做两件事:
|
||||||
|
* 1. 注册 "coop" provider —— 从环境变量 BASE_URL / API_KEY / PROTOCOL / MODEL
|
||||||
|
* 构造一个 OpenAI 兼容或 Anthropic 兼容的 provider,使 supervisor 注入的
|
||||||
|
* 模型配置(与 pigo-coop 完全相同的环境变量)能直接被 pi 使用。
|
||||||
|
* 2. 注册 "blackboard" 工具 —— 复刻 pigo 的 blackboard 工具(read / post / done),
|
||||||
|
* 让 agent 通过 action=done 原子创建 /blackboard/DONE 标记,supervisor 据此判定
|
||||||
|
* 任务完成。AGENTS.md 协议因此无需改动。
|
||||||
|
*
|
||||||
|
* 与 pigo blackboard_tool.go 行为对齐:post 用 O_APPEND 单次写(原子),
|
||||||
|
* done 用 O_CREAT|O_EXCL('wx',保证唯一创建),路径全部做 traversal 校验。
|
||||||
|
*/
|
||||||
|
import { Type } from "@earendil-works/pi-ai";
|
||||||
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { openSync, closeSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync } from "node:fs";
|
||||||
|
import { join, resolve, sep, isAbsolute, clean, basename } from "node:path";
|
||||||
|
|
||||||
|
// 单条 post / 单文件 read 上限:放开到 128KB,复杂任务的脚本与日志能整段读写。
|
||||||
|
const MAX_POST_BYTES = 128 * 1024;
|
||||||
|
const MAX_READ_BYTES = 128 * 1024;
|
||||||
|
|
||||||
|
// provider 协议 → pi api 类型映射。PROTOCOL 由 supervisor 从外部注入
|
||||||
|
// (取值 openai | anthropic,见 agent api_config.go Provider())。
|
||||||
|
function protocolToApi(protocol: string): "openai-completions" | "anthropic-messages" {
|
||||||
|
return protocol === "anthropic" ? "anthropic-messages" : "openai-completions";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const baseUrl = process.env.BASE_URL ?? "";
|
||||||
|
const apiKey = process.env.API_KEY ?? "";
|
||||||
|
const protocol = process.env.PROTOCOL ?? "openai";
|
||||||
|
const modelId = process.env.MODEL ?? "";
|
||||||
|
const bbRoot = process.env.BB ?? "/blackboard";
|
||||||
|
|
||||||
|
if (!baseUrl || !apiKey || !modelId) {
|
||||||
|
// supervisor 已在前置校验里保证这三个变量非空,这里只做兜底:
|
||||||
|
// 若 provider 注册失败,pi 启动时会报 model 无法解析,比这里静默更好。
|
||||||
|
throw new Error(`coop extension: BASE_URL / API_KEY / MODEL 环境变量必须同时提供`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册 coop provider。models 数组在工厂里动态构造(id 取自 MODEL),
|
||||||
|
// 这样 --provider coop --model "$MODEL" 即可命中。
|
||||||
|
pi.registerProvider("coop", {
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
api: protocolToApi(protocol),
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: modelId,
|
||||||
|
name: modelId,
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
// 完全放开上下文与输出限制,让复杂任务能充分展开思考与多轮工具调用。
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 65536,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- blackboard 工具 ----
|
||||||
|
const blackboardTool = defineTool({
|
||||||
|
name: "blackboard",
|
||||||
|
label: "Blackboard",
|
||||||
|
description:
|
||||||
|
"Read and write the task blackboard used by the coop runner. This is the ONLY tool that may touch shared blackboard files atomically. " +
|
||||||
|
"Actions: read (no path: global snapshot of task.md, workspace listing, DONE state; with path like \"workspace/exploit.py\": contents of that one file); " +
|
||||||
|
"post (atomically append a progress note; file must be a bare .md name under messages/, e.g. \"round-1-a.md\"); " +
|
||||||
|
"done (atomically create the DONE marker with a final delivery summary, only when the deliverable is truly complete; fails if DONE already exists). " +
|
||||||
|
"The blackboard root, current round and your name are available in the environment as BB, ROUND, NAME.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
action: Type.String({ description: "read | post | done" }),
|
||||||
|
path: Type.Optional(Type.String({ description: 'For read: a path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot.' })),
|
||||||
|
file: Type.Optional(Type.String({ description: 'For post: bare file name under messages/, must end in .md (e.g. round-1-a.md).' })),
|
||||||
|
content: Type.Optional(Type.String({ description: "For post: the message body (max 128 KiB)." })),
|
||||||
|
summary: Type.Optional(Type.String({ description: "For done: final delivery summary written into DONE." })),
|
||||||
|
}),
|
||||||
|
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const action = String(params.action ?? "");
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case "read":
|
||||||
|
return read(params.path);
|
||||||
|
case "post":
|
||||||
|
return post(params.file, params.content);
|
||||||
|
case "done":
|
||||||
|
return done(params.summary);
|
||||||
|
default:
|
||||||
|
return err(`blackboard: unknown action "${action}" (want read|post|done)`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return err(`blackboard ${action}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pi.registerTool(blackboardTool);
|
||||||
|
|
||||||
|
// ---- 实现部分 ----
|
||||||
|
|
||||||
|
// safePath:把相对路径解析到 blackboard 根内,拒绝绝对路径与 .. 越界。
|
||||||
|
// 与 pigo blackboard_tool.go safePath 行为一致。
|
||||||
|
function safePath(p: string): string {
|
||||||
|
const trimmed = (p ?? "").trim();
|
||||||
|
if (trimmed === "") throw new Error("empty path");
|
||||||
|
const c = clean(trimmed);
|
||||||
|
if (isAbsolute(c)) throw new Error(`path "${p}" must be relative to the blackboard root`);
|
||||||
|
const rootClean = clean(bbRoot);
|
||||||
|
const full = join(rootClean, c);
|
||||||
|
if (full !== rootClean && !full.startsWith(rootClean + sep)) {
|
||||||
|
throw new Error(`path "${p}" escapes the blackboard root`);
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(text: string) {
|
||||||
|
return { content: [{ type: "text" as const, text }] };
|
||||||
|
}
|
||||||
|
function err(text: string) {
|
||||||
|
return { content: [{ type: "text" as const, text }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
return s.length > max ? s.slice(0, max) + "\n...<truncated>" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// read:无 path → 全局快照(task.md / messages 列表 / workspace 列表 / DONE 状态);
|
||||||
|
// 有 path → 单文件内容。
|
||||||
|
function read(pathParam: unknown) {
|
||||||
|
const p = pathParam != null ? String(pathParam) : "";
|
||||||
|
if (p.trim() !== "") {
|
||||||
|
return readFile(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const b: string[] = [];
|
||||||
|
// task.md
|
||||||
|
try {
|
||||||
|
const data = readFileSync(join(bbRoot, "task.md"), "utf8");
|
||||||
|
b.push("# task.md\n" + truncate(data, MAX_READ_BYTES) + "\n");
|
||||||
|
} catch {
|
||||||
|
b.push("# task.md\n<missing>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages/
|
||||||
|
b.push("\n# messages/ (" + listNames("messages").join(", ") + ")\n");
|
||||||
|
for (const name of listListing("messages")) b.push(" - " + name + "\n");
|
||||||
|
|
||||||
|
// workspace/
|
||||||
|
b.push("\n# workspace/ (your workspace)\n");
|
||||||
|
for (const name of listListing("workspace")) b.push(" - " + name + "\n");
|
||||||
|
|
||||||
|
// DONE
|
||||||
|
let doneText = "";
|
||||||
|
try {
|
||||||
|
doneText = truncate(readFileSync(join(bbRoot, "DONE"), "utf8"), MAX_READ_BYTES);
|
||||||
|
} catch {}
|
||||||
|
b.push("\n# DONE\n");
|
||||||
|
b.push(doneText ? "EXISTS:\n" + doneText + "\n" : "<not created yet — cooperation is still in progress>\n");
|
||||||
|
|
||||||
|
// environment
|
||||||
|
const env: string[] = [];
|
||||||
|
for (const k of ["BB", "ROUND", "NAME"]) {
|
||||||
|
const v = (process.env[k] ?? "").trim();
|
||||||
|
if (v) env.push(` ${k}=${v}`);
|
||||||
|
}
|
||||||
|
if (env.length) b.push("\n# environment\n" + env.join("\n") + "\n");
|
||||||
|
|
||||||
|
return ok(b.join(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p: string) {
|
||||||
|
const full = safePath(p);
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = statSync(full);
|
||||||
|
} catch (e) {
|
||||||
|
return err(`blackboard read: ${p}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
if (info.isDirectory()) return err(`blackboard read: ${p} is a directory; only files can be read`);
|
||||||
|
const data = readFileSync(full, "utf8");
|
||||||
|
return ok("# " + p + "\n" + truncate(data, MAX_READ_BYTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
// post:原子追加一条消息到 messages/<file>。file 必须是裸 *.md 名。
|
||||||
|
function post(fileParam: unknown, contentParam: unknown) {
|
||||||
|
const name = String(fileParam ?? "").trim();
|
||||||
|
if (!validMessageName(name)) {
|
||||||
|
return err('blackboard post: file must be a bare name ending in .md (e.g. "round-1-a.md"), no path separators, no ".."');
|
||||||
|
}
|
||||||
|
const content = String(contentParam ?? "").trim();
|
||||||
|
if (content === "") return err("blackboard post: content must not be empty");
|
||||||
|
if (Buffer.byteLength(content) > MAX_POST_BYTES) {
|
||||||
|
return err(`blackboard post: content too large (${Buffer.byteLength(content)} bytes, max ${MAX_POST_BYTES})`);
|
||||||
|
}
|
||||||
|
const dir = join(bbRoot, "messages");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
// O_APPEND 单次写:POSIX 下并发 post 不会交错字节。
|
||||||
|
appendFileSync(join(dir, name), content + "\n");
|
||||||
|
return ok(`Message appended to messages/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// done:原子创建 DONE 标记。'wx' = O_CREAT|O_EXCL|O_WRONLY,保证唯一创建。
|
||||||
|
function done(summaryParam: unknown) {
|
||||||
|
const summary = String(summaryParam ?? "").trim();
|
||||||
|
if (summary === "") return err("blackboard done: summary must not be empty (include the final delivery summary)");
|
||||||
|
const header = "Blackboard cooperation DONE\ncreated: " + new Date().toISOString() + "\n\n";
|
||||||
|
const target = join(bbRoot, "DONE");
|
||||||
|
let fd: number;
|
||||||
|
try {
|
||||||
|
fd = openSync(target, "wx");
|
||||||
|
} catch (e) {
|
||||||
|
if (existsSync(target)) {
|
||||||
|
const existing = readFileSync(target, "utf8");
|
||||||
|
return err("blackboard done: DONE already exists — cooperation already finished:\n" + truncate(existing, MAX_READ_BYTES));
|
||||||
|
}
|
||||||
|
return err("blackboard done: " + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
writeFileSync(fd, header + summary);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
return ok("DONE marker created. Cooperation finished.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 post 文件名:裸 *.md,无分隔符,无 . / ..
|
||||||
|
function validMessageName(name: string): boolean {
|
||||||
|
if (name === "" || !name.endsWith(".md")) return false;
|
||||||
|
if (/[\\/]/.test(name) || name === "." || name === "..") return false;
|
||||||
|
const base = name.slice(0, -3);
|
||||||
|
if (base === "" || base.startsWith(".") || base.includes("..")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listNames(sub: string): string[] {
|
||||||
|
try {
|
||||||
|
return readdirSync(join(bbRoot, sub)).sort();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listListing(sub: string): string[] {
|
||||||
|
try {
|
||||||
|
return readdirSync(join(bbRoot, sub))
|
||||||
|
.sort()
|
||||||
|
.map((name) => {
|
||||||
|
try {
|
||||||
|
const info = statSync(join(bbRoot, sub, name));
|
||||||
|
if (info.isDirectory()) return `${name} (dir)`;
|
||||||
|
return `${name} (${info.size} bytes, ${info.mtime.toISOString().slice(11, 19)})`;
|
||||||
|
} catch {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 角色:agent(主执行者)
|
||||||
|
|
||||||
|
你是 pi 单 agent 任务执行者,独立完成 /blackboard/task.md 中的任务。
|
||||||
|
|
||||||
|
## 容器内可用工具
|
||||||
|
容器内预装了安全工具(nuclei、observer_ward、radare2、gdb、ROPgadget、capstone 等)。
|
||||||
|
**解题前先读取 `/opt/tools/TOOLS.md` 了解完整工具清单和用法**,选择最合适的工具。
|
||||||
|
也可用 `which <tool>` 或 `pip3 list` 确认工具是否可用。
|
||||||
|
|
||||||
|
## 你的职责
|
||||||
|
- 理解任务、制定方案、完成核心产出。
|
||||||
|
- 所有产物写入你的工作区(你的 cwd,即 /blackboard/workspace/),按需命名。
|
||||||
|
- 需要提交 flag 的任务:拿到 flag 后立即按任务中的提交规则提交,并把提交响应与得分记录在产物中。
|
||||||
|
|
||||||
|
## 工作方式
|
||||||
|
- 每轮:读取 /blackboard/task.md 与工作区已有产物 → 推进任务 → 将进展与产物写入工作区。
|
||||||
|
- 环境变量 ROUND、NAME、BB 由 supervisor 提供;需要记录进展或创建完成标记时用 blackboard 工具(BB 指向黑板根目录)。
|
||||||
|
- 不要伪造命令结果、文件内容或提交响应;如实记录。
|
||||||
|
- 全部工作完成、交付物完整时,用 blackboard 工具 action=done 创建完成标记(summary 写最终交付总结,含关键结果、flag、提交响应、产物清单)。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
- 若本轮未完成,下一轮会用 --session-id 恢复你的会话继续推进,跨轮保持上下文。
|
||||||
|
- 宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 action=done 标记,summary 写明「未解出」与已尝试内容,让调度方及时切换下一题。
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pi 协作 supervisor(单 agent 版)
|
||||||
|
#
|
||||||
|
# 在容器内运行一个 pi headless 进程完成 /blackboard/task.md 中的任务。
|
||||||
|
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
|
||||||
|
# 任务完成(agent 用 blackboard 工具创建 DONE)后,supervisor 把结果
|
||||||
|
# 以结构化 JSON(result.json)落盘并打印一行摘要,供外部调度方直接解析。
|
||||||
|
#
|
||||||
|
# 与 pigo-coop supervisor 的差异(因 pi CLI 与 pigo 不同):
|
||||||
|
# 1. cwd:pi 用进程 cwd 作工作区(无 -C 参数),supervisor 先 cd 到
|
||||||
|
# $BB/workspace 再启动 pi。
|
||||||
|
# 2. 模型接入:pi 用 provider 体系,没有 --base-url/--protocol。
|
||||||
|
# 由 coop extension(-e /extensions/coop.ts)读 BASE_URL/API_KEY/
|
||||||
|
# PROTOCOL/MODEL 环境变量注册 "coop" provider;CLI 用
|
||||||
|
# --provider coop --model "$MODEL"。
|
||||||
|
# 3. 输出格式:pi 用 --mode json(NDJSON),首行是 session header
|
||||||
|
# {"type":"session","id":"<uuid>",...},supervisor 从首行提取 id
|
||||||
|
# 供下一轮 --session-id 恢复。
|
||||||
|
# 4. session 恢复:pi 用 --session-id <id> + --session-dir <dir>,
|
||||||
|
# 替代 pigo 的 --resume <id>。
|
||||||
|
#
|
||||||
|
# 环境变量(均由外部调用方传入,与 pigo-coop 完全一致):
|
||||||
|
# MODEL 模型名,如 deepseek-chat (必填)
|
||||||
|
# BASE_URL OpenAI/Anthropic 兼容 API base-url (必填)
|
||||||
|
# API_KEY API key (必填)
|
||||||
|
# PROTOCOL 协议 openai|anthropic (可选,默认按 model 推断)
|
||||||
|
# TASK 任务描述 (必填;或挂载 $BB/task.md)
|
||||||
|
# BLACKBOARD 黑板目录 (默认 /blackboard)
|
||||||
|
# PROMPTS 协作 prompt 目录 (默认 /prompts)
|
||||||
|
# EXTENSIONS extension 目录 (默认 /extensions)
|
||||||
|
# ROUND_MAX 最大轮次 (默认 20)
|
||||||
|
# TIMEOUT 单轮超时秒数,0=不超时 (默认 1800)
|
||||||
|
# FAIL_MODE agent 失败时:stop=立即退出 | continue=继续下一轮(默认)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BB="${BLACKBOARD:-/blackboard}"
|
||||||
|
PROMPTS="${PROMPTS:-/prompts}"
|
||||||
|
EXTENSIONS="${EXTENSIONS:-/extensions}"
|
||||||
|
ROUND_MAX="${ROUND_MAX:-20}"
|
||||||
|
TIMEOUT="${TIMEOUT:-1800}"
|
||||||
|
FAIL_MODE="${FAIL_MODE:-continue}"
|
||||||
|
|
||||||
|
log() { echo "[supervisor] $*"; }
|
||||||
|
|
||||||
|
# emit_result 把任务结果以结构化 JSON 写入 $BB/result.json 并打印一行摘要。
|
||||||
|
# status: solved | unsolved | error | timeout
|
||||||
|
emit_result() {
|
||||||
|
local status="$1" summary="$2" code="$3"
|
||||||
|
local flag="" artifacts_json="[]" summary_json='""' flag_json='""'
|
||||||
|
|
||||||
|
# flag 优先从 summary 提取,其次在 workspace 产物中全量检索
|
||||||
|
if [ -n "$summary" ]; then
|
||||||
|
flag=$(printf '%s' "$summary" | grep -oE 'flag\{[^}]+\}' | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$flag" ] && [ -d "$BB/workspace" ]; then
|
||||||
|
flag=$(grep -rhoE 'flag\{[^}]+\}' "$BB/workspace" 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 产物清单:workspace 下的全部文件(最多 200 个),经 python3 转义为 JSON 数组
|
||||||
|
if [ -d "$BB/workspace" ]; then
|
||||||
|
artifacts_json=$(
|
||||||
|
cd "$BB/workspace" && find . -type f 2>/dev/null | sed 's|^\./||' | head -200 |
|
||||||
|
python3 -c 'import json,sys; print(json.dumps([l.rstrip("\n") for l in sys.stdin]))' 2>/dev/null
|
||||||
|
)
|
||||||
|
[ -z "$artifacts_json" ] && artifacts_json="[]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# summary 截断到 8192 字符并经 python3 转义,防止引号/换行破坏 JSON
|
||||||
|
summary_json=$(printf '%s' "$summary" | head -c 8192 | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
|
||||||
|
[ -z "$summary_json" ] && summary_json='""'
|
||||||
|
flag_json=$(printf '%s' "$flag" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
|
||||||
|
[ -z "$flag_json" ] && flag_json='""'
|
||||||
|
|
||||||
|
cat > "$BB/result.json" <<EOF
|
||||||
|
{
|
||||||
|
"status": "$status",
|
||||||
|
"exit_code": $code,
|
||||||
|
"summary": $summary_json,
|
||||||
|
"flag": $flag_json,
|
||||||
|
"artifacts": $artifacts_json
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
log "===== 协作结束 ====="
|
||||||
|
log "status=$status exit_code=$code"
|
||||||
|
[ -n "$flag" ] && log "flag=$flag"
|
||||||
|
log "结构化结果已写入:$BB/result.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 前置校验 ----
|
||||||
|
if [ -z "${MODEL:-}" ] || [ -z "${BASE_URL:-}" ] || [ -z "${API_KEY:-}" ]; then
|
||||||
|
echo "错误:必须提供 MODEL / BASE_URL / API_KEY 环境变量" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ -z "${TASK:-}" ] && [ ! -f "$BB/task.md" ]; then
|
||||||
|
echo "错误:请通过 TASK 环境变量或挂载 $BB/task.md 提供任务" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ -f "$BB/DONE" ]; then
|
||||||
|
log "黑板已有完成标记,如需重新开始请删除 $BB/DONE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 初始化黑板 ----
|
||||||
|
mkdir -p "$BB/workspace" "$BB/logs" "$BB/sessions"
|
||||||
|
if [ -n "${TASK:-}" ]; then
|
||||||
|
printf '%s\n' "$TASK" > "$BB/task.md"
|
||||||
|
fi
|
||||||
|
# 协议注入:agent 的 cwd 是 $BB/workspace,AGENTS.md 注入链从 cwd 起,
|
||||||
|
# 因此把协议副本放进工作区(勿修改,它是每轮系统提示的一部分)
|
||||||
|
cp "$PROMPTS/AGENTS.md" "$BB/workspace/AGENTS.md"
|
||||||
|
log "黑板初始化完成:$BB"
|
||||||
|
log "任务:$(head -c 200 "$BB/task.md")"
|
||||||
|
|
||||||
|
# 从 pi --mode json 输出的首行(session header)提取 session id。
|
||||||
|
# 首行形如 {"type":"session","version":3,"id":"<uuid>","cwd":...}
|
||||||
|
extract_session_id() {
|
||||||
|
local logfile="$1"
|
||||||
|
head -1 "$logfile" 2>/dev/null | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
if d.get("type") == "session":
|
||||||
|
print(d.get("id", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 单轮运行:一个 pi 进程,--mode json 输出 NDJSON(首行携带 session id)----
|
||||||
|
run_agent() {
|
||||||
|
local round="$1"
|
||||||
|
local session_file="$BB/sessions/agent.session"
|
||||||
|
local task_prompt="轮次 $round 开始。先读取 /blackboard/task.md 中的任务,检查工作区已有产物与 DONE 状态,然后继续推进任务。全部工作完成、交付物完整时,用 blackboard 工具 action=done 创建完成标记(summary 写最终交付总结,含关键结果/flag/提交响应/产物清单)。"
|
||||||
|
local args=(-p "$task_prompt" -a --mode json \
|
||||||
|
--provider coop --model "$MODEL" \
|
||||||
|
--session-dir "$BB/sessions" \
|
||||||
|
-e "$EXTENSIONS/coop.ts" \
|
||||||
|
--append-system-prompt "$PROMPTS/agent.md")
|
||||||
|
if [ -s "$session_file" ]; then
|
||||||
|
# --session-id:精确匹配,找不到则用该 id 新建(不报错),比 --session 更健壮
|
||||||
|
args+=(--session-id "$(cat "$session_file")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
export ROUND="$round" NAME="agent" BB="$BB"
|
||||||
|
local logfile="$BB/logs/round-$round.log"
|
||||||
|
log "第 $round 轮开始运行 agent(日志:$logfile)"
|
||||||
|
# pi 用进程 cwd 作为工作区,必须先进入 $BB/workspace
|
||||||
|
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
|
||||||
|
(cd "$BB/workspace" && timeout "$TIMEOUT" pi "${args[@]}") > "$logfile" 2>&1
|
||||||
|
else
|
||||||
|
(cd "$BB/workspace" && pi "${args[@]}") > "$logfile" 2>&1
|
||||||
|
fi
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
# 从 --mode json 首行(session header)提取 session id 供下一轮恢复
|
||||||
|
local sid
|
||||||
|
sid=$(extract_session_id "$logfile")
|
||||||
|
if [ -n "$sid" ]; then
|
||||||
|
printf '%s' "$sid" > "$session_file"
|
||||||
|
fi
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 主循环:逐轮运行 agent,直到 DONE 或达到轮次上限 ----
|
||||||
|
for round in $(seq 1 "$ROUND_MAX"); do
|
||||||
|
[ -f "$BB/DONE" ] && break
|
||||||
|
log "===== 第 $round 轮开始 ====="
|
||||||
|
|
||||||
|
run_agent "$round"
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
log "第 $round 轮完成"
|
||||||
|
else
|
||||||
|
log "第 $round 轮失败(exit=$rc),日志见 $BB/logs/round-$round.log"
|
||||||
|
if [ "$FAIL_MODE" = "stop" ]; then
|
||||||
|
# 兜底:agent 虽超时/失败,但黑板已有提交成功证据(workspace 内 *.md 含 correct:true)
|
||||||
|
# → 视为完成正常退出,避免"flag 已提交却因未写 DONE 被强杀(exit 143)"。
|
||||||
|
_done=0
|
||||||
|
for _f in "$BB"/workspace/*.md "$BB"/messages/*.md; do
|
||||||
|
[ -f "$_f" ] && grep -q '"correct":true' "$_f" && _done=1 && break
|
||||||
|
done
|
||||||
|
if [ "$_done" = "1" ]; then
|
||||||
|
log "检测到提交成功证据,自动标记完成"
|
||||||
|
{ echo "Task finished (auto-detected submit success)"; } > "$BB/DONE"
|
||||||
|
emit_result solved "$(cat "$BB/DONE")" 0
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$rc" -eq 124 ]; then
|
||||||
|
emit_result timeout "" "$rc"
|
||||||
|
else
|
||||||
|
emit_result error "agent 退出码 $rc,日志见 logs/round-$round.log" "$rc"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -f "$BB/DONE" ]; then
|
||||||
|
log "检测到完成标记,任务结束"
|
||||||
|
emit_result solved "$(cat "$BB/DONE")" 0
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "达到最大轮次 $ROUND_MAX 仍未完成,请检查黑板产物与日志"
|
||||||
|
emit_result unsolved "" 1
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 精简 Docker 构建上下文(用于 coop/Dockerfile)
|
||||||
|
.git
|
||||||
|
book
|
||||||
|
docs
|
||||||
|
examples
|
||||||
|
tasks
|
||||||
|
*.exe
|
||||||
|
pigo
|
||||||
|
install.sh
|
||||||
|
vm_vpnstart.py
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 单 agent 任务协议
|
||||||
|
|
||||||
|
你是独立的任务执行 agent,完成 /blackboard/task.md 中的任务。任务信息通过工作区与黑板目录交换。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
| 路径 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| /blackboard/task.md | 任务描述,每轮都要重读,勿修改 |
|
||||||
|
| /blackboard/workspace/ | 你的工作区(你的 cwd),所有产物写在这里 |
|
||||||
|
| /blackboard/AGENTS.md | 本协议(副本在你的工作区,勿修改) |
|
||||||
|
| /blackboard/DONE | 完成标记,存在即表示任务已交付完成,只用 blackboard 工具创建 |
|
||||||
|
| /blackboard/logs/ | 每轮运行日志(supervisor 维护) |
|
||||||
|
| /blackboard/sessions/ | 会话 ID(supervisor 维护,供 --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 创建 DONE(summary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
|
||||||
|
|
||||||
|
## 红线
|
||||||
|
|
||||||
|
- 不直接写 /blackboard/DONE——必须走 blackboard 工具 action=done,保证原子创建。
|
||||||
|
- 不伪造实验、命令或提交结果;如实记录。
|
||||||
|
- 不修改 /blackboard/task.md、/blackboard/AGENTS.md 与 /blackboard/result.json。
|
||||||
@@ -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:latest(Alpine,已含 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=agent,RUN 层以非 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-packages:Alpine 3.20+ 标记 externally-managed,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
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# pigo 协作 —— 调用文档
|
||||||
|
|
||||||
|
> 在 Docker 容器内运行一个 pigo agent(单 agent)完成 /blackboard/task.md 中的任务,工作区为 /blackboard/workspace,完成时通过 blackboard 工具创建 DONE;supervisor 把结果以结构化 JSON(result.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 id(supervisor 内部维护,供 --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。
|
||||||
@@ -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 |
|
||||||
@@ -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"
|
||||||
@@ -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 写明「未解出」与已尝试内容,让调度方及时切换下一题。
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pigo 协作 supervisor(单 agent 版)
|
||||||
|
#
|
||||||
|
# 在容器内运行一个 pigo headless 进程完成 /blackboard/task.md 中的任务。
|
||||||
|
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
|
||||||
|
# 任务完成(agent 用 blackboard 工具创建 DONE)后,supervisor 把结果
|
||||||
|
# 以结构化 JSON(result.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/workspace,AGENTS.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 供下一轮 --resume(agent_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
@@ -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
@@ -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
@@ -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 页面下载 .zip:https://github.com/$REPO/releases" ;;
|
||||||
|
*) err "不支持的操作系统: $os_raw" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 3. 检测架构,映射到归档命名(amd64→x86_64,386→i386,arm64 保持)。
|
||||||
|
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
|
||||||
|
|
||||||
|
# 归档名里的版本号不带前导 v(goreleaser 的 .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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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), " ")
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user