Files
2026-08-14 23:41:57 +08:00

216 lines
9.1 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Claude Code 协作 supervisor(单 agent 版)
#
# 在容器内运行一个 Claude Code headless 进程(claude -p)完成 /blackboard/task.md 中的任务。
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
# 任务完成(agent 用 blackboard MCP 工具创建 DONE)后,supervisor 把结果
# 以结构化 JSONresult.json)落盘并打印一行摘要,供外部调度方直接解析。
#
# 与 pi-coop supervisor 的关键差异(Claude Code CLI 与 pi 不同):
# 1. 模型接入:Claude Code 用 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN /
# ANTHROPIC_MODEL 环境变量(不是 --provider/--model flag)。
# 第三方 Anthropic 兼容端点(如 DeepSeek)需 ANTHROPIC_API_KEY="" 触发
# 回退到 ANTHROPIC_AUTH_TOKEN,且 ANTHROPIC_BASE_URL 不带 /v1SDK 自己追加)。
# 2. 工具注册:Claude Code 用 MCP server--mcp-config),blackboard 工具
# 通过 blackboard-server.mjs 提供,工具命名空间为 mcp__blackboard__*。
# 3. 输出格式:claude -p --output-format json 输出单个 JSON,含 session_id
# 字段,直接用 jq 提取即可(无需像 pi 那样解析 NDJSON 首行)。
# 4. session 恢复:claude -p --resume <id>,跨轮保持上下文。
# 5. 权限:--dangerously-skip-permissions 跳过所有权限弹窗(headless 必需),
# 且必须以非 root 用户运行(node:22-alpine 自带 node 用户,uid 1000)。
#
# 环境变量(均由外部调用方传入,与 pi-coop 完全一致):
# MODEL 模型名,如 deepseek-v4-flash (必填)
# BASE_URL Anthropic 兼容 API base-url (必填)
# API_KEY API key(注入为 ANTHROPIC_AUTH_TOKEN (必填)
# PROTOCOL 协议(仅 anthropic 受 Claude Code 支持) (可选)
# TASK 任务描述 (必填;或挂载 $BB/task.md
# BLACKBOARD 黑板目录 (默认 /blackboard
# ROUND_MAX 最大轮次 (默认 10)
# TIMEOUT 单轮超时秒数,0=不超时 (默认 600)
# FAIL_MODE agent 失败时:stop=立即退出(默认)| continue=继续下一轮
set -u
BB="${BLACKBOARD:-/blackboard}"
PROMPTS="${PROMPTS:-/prompts}"
MCP_CONFIG="${MCP_CONFIG:-/mcp-servers.json}"
ROUND_MAX="${ROUND_MAX:-10}"
TIMEOUT="${TIMEOUT:-600}"
FAIL_MODE="${FAIL_MODE:-stop}"
log() { echo "[supervisor] $*"; }
# emit_result 把任务结果以结构化 JSON 写入 $BB/result.json 并打印一行摘要。
# status: solved | unsolved | error | timeout
emit_result() {
local status="$1" summary="$2" code="$3"
local flag="" artifacts_json="[]" summary_json='""' flag_json='""'
# flag 优先从 summary 提取,其次在 workspace 产物中全量检索
if [ -n "$summary" ]; then
flag=$(printf '%s' "$summary" | grep -oE 'flag\{[^}]+\}' | head -1)
fi
if [ -z "$flag" ] && [ -d "$BB/workspace" ]; then
flag=$(grep -rhoE 'flag\{[^}]+\}' "$BB/workspace" 2>/dev/null | head -1)
fi
# 产物清单:workspace 下的全部文件(最多 50 个),经 python3 转义为 JSON 数组
if [ -d "$BB/workspace" ]; then
artifacts_json=$(
cd "$BB/workspace" && find . -type f 2>/dev/null | sed 's|^\./||' | head -50 |
python3 -c 'import json,sys; print(json.dumps([l.rstrip("\n") for l in sys.stdin]))' 2>/dev/null
)
[ -z "$artifacts_json" ] && artifacts_json="[]"
fi
# summary 截断到 4000 字符并经 python3 转义,防止引号/换行破坏 JSON
summary_json=$(printf '%s' "$summary" | head -c 4000 | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
[ -z "$summary_json" ] && summary_json='""'
flag_json=$(printf '%s' "$flag" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
[ -z "$flag_json" ] && flag_json='""'
cat > "$BB/result.json" <<EOF
{
"status": "$status",
"exit_code": $code,
"summary": $summary_json,
"flag": $flag_json,
"artifacts": $artifacts_json
}
EOF
log "===== 协作结束 ====="
log "status=$status exit_code=$code"
[ -n "$flag" ] && log "flag=$flag"
log "结构化结果已写入:$BB/result.json"
}
# ---- 前置校验 ----
if [ -z "${MODEL:-}" ] || [ -z "${BASE_URL:-}" ] || [ -z "${API_KEY:-}" ]; then
echo "错误:必须提供 MODEL / BASE_URL / API_KEY 环境变量" >&2
exit 2
fi
if [ -z "${TASK:-}" ] && [ ! -f "$BB/task.md" ]; then
echo "错误:请通过 TASK 环境变量或挂载 $BB/task.md 提供任务" >&2
exit 2
fi
if [ -f "$BB/DONE" ]; then
log "黑板已有完成标记,如需重新开始请删除 $BB/DONE"
exit 0
fi
# ---- 初始化黑板 ----
mkdir -p "$BB/workspace" "$BB/logs" "$BB/sessions"
if [ -n "${TASK:-}" ]; then
printf '%s\n' "$TASK" > "$BB/task.md"
fi
# 协议注入:agent 的 cwd 是 $BB/workspaceAGENTS.md 注入链从 cwd 起,
# Claude Code 会自动加载 cwd 下的 CLAUDE.mdAGENTS.md 兼容),因此把协议
# 副本放进工作区(勿修改,它是每轮系统提示的一部分)
cp "$PROMPTS/AGENTS.md" "$BB/workspace/CLAUDE.md"
cp "$PROMPTS/AGENTS.md" "$BB/workspace/AGENTS.md"
log "黑板初始化完成:$BB"
log "任务:$(head -c 200 "$BB/task.md")"
# ---- 导出 Claude Code 模型配置环境变量 ----
# 关键:ANTHROPIC_API_KEY 必须设为空字符串(而非 unset),Claude Code 才会
# 回退到 ANTHROPIC_AUTH_TOKEN。BASE_URL 不带 /v1SDK 自动追加 /v1/messages)。
export ANTHROPIC_BASE_URL="$BASE_URL"
export ANTHROPIC_AUTH_TOKEN="$API_KEY"
export ANTHROPIC_API_KEY=""
export ANTHROPIC_MODEL="$MODEL"
# 把 opus/sonnet/haiku 三个别名都映射到目标模型,避免别名解析失败。
export ANTHROPIC_DEFAULT_OPUS_MODEL="$MODEL"
export ANTHROPIC_DEFAULT_SONNET_MODEL="$MODEL"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$MODEL"
# 禁用非必要流量(遥测/登录检测),第三方端点必需。
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# ---- 单轮运行:claude -p --output-format json,从 JSON 提取 session_id ----
run_agent() {
local round="$1"
local session_file="$BB/sessions/agent.session"
local task_prompt="轮次 $round 开始。先读取 /blackboard/task.md 中的任务,检查工作区已有产物与 DONE 状态,然后继续推进任务。全部工作完成、交付物完整时,用 blackboard 工具 done 创建完成标记(summary 写最终交付总结,含关键结果/flag/提交响应/产物清单)。"
local args=(-p "$task_prompt"
--output-format json
--mcp-config "$MCP_CONFIG"
--allowedTools "mcp__blackboard__*,Read,Write,Edit,Bash"
--append-system-prompt-file "$PROMPTS/agent.md"
--dangerously-skip-permissions)
if [ -s "$session_file" ]; then
args+=(--resume "$(cat "$session_file")")
fi
export ROUND="$round" NAME="agent" BB="$BB"
local logfile="$BB/logs/round-$round.log"
log "第 $round 轮开始运行 agent(日志:$logfile"
# Claude Code 用 --cwd / 进程 cwd 作工作区,先进入 $BB/workspace
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
(cd "$BB/workspace" && timeout "$TIMEOUT" claude "${args[@]}") > "$logfile" 2>&1
else
(cd "$BB/workspace" && claude "${args[@]}") > "$logfile" 2>&1
fi
local rc=$?
# 从 --output-format json 的结果提取 session_id 供下一轮 --resume
# JSON 形如 {"type":"result","session_id":"<uuid>",...}
local sid
sid=$(python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
if d.get("type") == "result":
print(d.get("session_id", ""))
except Exception:
pass
' < "$logfile" 2>/dev/null)
if [ -n "$sid" ]; then
printf '%s' "$sid" > "$session_file"
fi
return $rc
}
# ---- 主循环:逐轮运行 agent,直到 DONE 或达到轮次上限 ----
for round in $(seq 1 "$ROUND_MAX"); do
[ -f "$BB/DONE" ] && break
log "===== 第 $round 轮开始 ====="
run_agent "$round"
rc=$?
if [ "$rc" -eq 0 ]; then
log "第 $round 轮完成"
else
log "第 $round 轮失败(exit=$rc),日志见 $BB/logs/round-$round.log"
if [ "$FAIL_MODE" = "stop" ]; then
# 兜底:agent 虽超时/失败,但黑板已有提交成功证据(workspace 内 *.md 含 correct:true
# → 视为完成正常退出,避免"flag 已提交却因未写 DONE 被强杀(exit 143"。
_done=0
for _f in "$BB"/workspace/*.md "$BB"/messages/*.md; do
[ -f "$_f" ] && grep -q '"correct":true' "$_f" && _done=1 && break
done
if [ "$_done" = "1" ]; then
log "检测到提交成功证据,自动标记完成"
{ echo "Task finished (auto-detected submit success)"; } > "$BB/DONE"
emit_result solved "$(cat "$BB/DONE")" 0
exit 0
fi
if [ "$rc" -eq 124 ]; then
emit_result timeout "" "$rc"
else
emit_result error "agent 退出码 $rc,日志见 logs/round-$round.log" "$rc"
fi
exit "$rc"
fi
fi
done
if [ -f "$BB/DONE" ]; then
log "检测到完成标记,任务结束"
emit_result solved "$(cat "$BB/DONE")" 0
exit 0
fi
log "达到最大轮次 $ROUND_MAX 仍未完成,请检查黑板产物与日志"
emit_result unsolved "" 1
exit 1