github.com/qiniu/go-sdk/v7@v7.27.0/examples/sandbox_agent_claude/main.go (about) 1 // Sandbox Claude Code Agent 多轮对话示例。 2 // 3 // 演示如何在七牛云沙箱中使用 claude 模板进行多轮上下文延续的 Agent 对话: 4 // 首轮创建新 session,后续通过 --resume <session_id> 复用上下文。 5 // 6 // 模拟场景:与 AI 协作完成一次日志统计需求 —— 本地预生成一份访问日志,通过 7 // sandbox.Filesystem().Write 上传到沙箱后,与 Agent 多轮讨论并实现 top 10 8 // 统计。覆盖文件上传、纯对话、工具调用、流式优化、独立验证多个场景。 9 // 10 // 环境变量: 11 // - QINIU_API_KEY: 七牛沙箱 API Key(必填) 12 // - QINIU_SANDBOX_API_URL: 沙箱 API 地址,可选 13 // - ANTHROPIC_API_KEY: Anthropic API Key(必填,注入到沙箱) 14 // - ANTHROPIC_BASE_URL: 第三方网关地址,可选 15 package main 16 17 import ( 18 "bufio" 19 "bytes" 20 "context" 21 _ "embed" 22 "encoding/json" 23 "fmt" 24 "log" 25 "os" 26 "sort" 27 "strings" 28 "time" 29 30 "github.com/qiniu/go-sdk/v7/sandbox" 31 ) 32 33 // accessLogJSONL 是预生成的 100 行访问日志,作为示例数据嵌入二进制。 34 // 数据通过 testdata/access.jsonl 维护,user_id 分布固定(seed=42),便于校对结果。 35 // 36 //go:embed testdata/access.jsonl 37 var accessLogJSONL []byte 38 39 // sandboxDataPath 是数据文件在沙箱内的路径,与下面 prompt 中的描述保持一致。 40 const sandboxDataPath = "/tmp/topn/data.jsonl" 41 42 func main() { 43 apiKey := os.Getenv("QINIU_API_KEY") 44 if apiKey == "" { 45 log.Fatal("请设置 QINIU_API_KEY 环境变量") 46 } 47 anthropicKey := os.Getenv("ANTHROPIC_API_KEY") 48 if anthropicKey == "" { 49 log.Fatal("请设置 ANTHROPIC_API_KEY 环境变量") 50 } 51 52 c, err := sandbox.NewClient(&sandbox.Config{ 53 APIKey: apiKey, 54 Endpoint: os.Getenv("QINIU_SANDBOX_API_URL"), 55 }) 56 if err != nil { 57 log.Fatalf("创建客户端失败: %v", err) 58 } 59 60 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) 61 defer cancel() 62 63 // 1. 创建沙箱:使用预构建的 claude 模板,通过环境变量注入 API Key。 64 // 生产场景建议使用密钥注入规则(injection-rule),避免明文环境变量。 65 envs := map[string]string{ 66 "ANTHROPIC_API_KEY": anthropicKey, 67 } 68 if baseURL := os.Getenv("ANTHROPIC_BASE_URL"); baseURL != "" { 69 envs["ANTHROPIC_BASE_URL"] = baseURL 70 } 71 72 timeout := int32(1800) 73 sb, _, err := c.CreateAndWait(ctx, sandbox.CreateParams{ 74 TemplateID: "claude", 75 Timeout: &timeout, 76 EnvVars: &envs, 77 }, sandbox.WithPollInterval(2*time.Second)) 78 if err != nil { 79 log.Fatalf("创建沙箱失败: %v", err) 80 } 81 fmt.Printf("沙箱已创建: %s\n", sb.ID()) 82 83 defer func() { 84 killCtx, killCancel := context.WithTimeout(context.Background(), 10*time.Second) 85 defer killCancel() 86 if err := sb.Kill(killCtx); err != nil { 87 log.Printf("终止沙箱失败: %v", err) 88 } else { 89 fmt.Printf("\n沙箱 %s 已终止\n", sb.ID()) 90 } 91 }() 92 93 // 2. 上传预生成的访问日志到沙箱,Write 会自动创建父目录。 94 info, err := sb.Files().Write(ctx, sandboxDataPath, accessLogJSONL) 95 if err != nil { 96 log.Printf("上传测试数据失败: %v", err) 97 return 98 } 99 fmt.Printf("已上传 %s (%d bytes)\n", sandboxDataPath, info.Size) 100 101 // 3. 本地预计算 top 10,作为 Agent 输出的对照。 102 expected := computeTopN(accessLogJSONL, 10) 103 fmt.Println("\n--- 期望 top 10(本地计算)---") 104 for _, uc := range expected { 105 fmt.Printf(" user_id=%d count=%d\n", uc.userID, uc.count) 106 } 107 108 // 4. 模拟一次"AI 协作开发"会话:问题分析 → 动手实现 → 流式优化。 109 // 后续轮次大量依赖前一轮的上下文(设计思路、代码、对应文件路径)。 110 turns := []string{ 111 // Turn 1: 讨论方案(纯对话) 112 "我有一批 jsonl 访问日志已经放在沙箱里 /tmp/topn/data.jsonl(约 100 行)," + 113 "每行是 JSON 对象,字段包含 user_id (int)、action (string)、timestamp (int64)。" + 114 "我需要按 user_id 统计每个用户的 action 总数并输出 top 10。" + 115 "计划用 Python 实现(沙箱内 python3 可用)。先说一下你的实现思路,先不要写代码。", 116 117 // Turn 2: 动手实现(工具调用:写文件 + 跑代码) 118 "按你刚才的思路,请在 /tmp/topn 下写一个 main.py,读取同目录下的 data.jsonl," + 119 "按 user_id 统计 action 总数并输出 top 10(count 降序,相同时 user_id 升序)。" + 120 "完成后运行 main.py 贴出结果。", 121 122 // Turn 3: 迭代优化(依赖前一轮代码) 123 "很好。现在请把 main.py 改成严格流式处理:不要 readlines() 或一次读全文件," + 124 "改成 with open(...) as f: for line in f 逐行扫描,使内存占用与文件大小解耦。" + 125 "改完后再跑一次,确认输出的 top 10 与上一版一致。", 126 } 127 128 var sessionID string 129 for i, prompt := range turns { 130 fmt.Printf("\n========== Turn %d/%d ==========\n", i+1, len(turns)) 131 fmt.Printf("USER: %s\n\n", prompt) 132 133 newSessionID, err := runTurn(ctx, sb, prompt, sessionID) 134 if err != nil { 135 // 用 return 而非 log.Fatal,确保 defer 中的 sb.Kill 能执行,避免沙箱泄漏。 136 log.Printf("Turn %d 执行失败: %v", i+1, err) 137 return 138 } 139 if newSessionID != "" { 140 sessionID = newSessionID 141 } 142 fmt.Printf("\n(session_id: %s)\n", sessionID) 143 144 // Turn 2/3 结束后列出工作目录并独立运行 main.py,与期望 top 10 对照。 145 if i >= 1 { 146 listWorkdir(ctx, sb, "/tmp/topn") 147 verifyTopN(ctx, sb, "/tmp/topn/main.py") 148 } 149 } 150 } 151 152 // userCount 是一个 user 的 action 总数。 153 type userCount struct { 154 userID int 155 count int 156 } 157 158 // computeTopN 解析 jsonl 数据并返回按 count 降序、user_id 升序的前 n 名。 159 func computeTopN(data []byte, n int) []userCount { 160 counts := make(map[int]int) 161 scanner := bufio.NewScanner(bytes.NewReader(data)) 162 // 单条日志远小于默认 64KB,无需扩 buffer。 163 for scanner.Scan() { 164 line := scanner.Bytes() 165 if len(line) == 0 { 166 continue 167 } 168 var rec struct { 169 UserID int `json:"user_id"` 170 } 171 if err := json.Unmarshal(line, &rec); err != nil { 172 continue 173 } 174 counts[rec.UserID]++ 175 } 176 if err := scanner.Err(); err != nil { 177 log.Printf("computeTopN: scanner 提前终止: %v", err) 178 } 179 out := make([]userCount, 0, len(counts)) 180 for uid, c := range counts { 181 out = append(out, userCount{uid, c}) 182 } 183 sort.Slice(out, func(i, j int) bool { 184 if out[i].count != out[j].count { 185 return out[i].count > out[j].count 186 } 187 return out[i].userID < out[j].userID 188 }) 189 if len(out) > n { 190 out = out[:n] 191 } 192 return out 193 } 194 195 // verifyTopN 在沙箱里独立运行 Agent 写的 main.py,便于与期望 top 10 对照。 196 func verifyTopN(ctx context.Context, sb *sandbox.Sandbox, scriptPath string) { 197 fmt.Printf("\n--- 独立运行 %s ---\n", scriptPath) 198 res, err := sb.Commands().Run(ctx, "python3 "+shellQuote(scriptPath)) 199 if err != nil { 200 fmt.Printf("运行脚本失败: %v\n", err) 201 return 202 } 203 if res.ExitCode != 0 { 204 fmt.Printf("脚本退出码 %d, stderr:\n%s\n", res.ExitCode, res.Stderr) 205 return 206 } 207 if res.Stdout != "" { 208 fmt.Print(res.Stdout) 209 } 210 } 211 212 // listWorkdir 打印沙箱内指定目录下的文件列表。 213 func listWorkdir(ctx context.Context, sb *sandbox.Sandbox, dir string) { 214 fmt.Printf("\n--- 沙箱目录 %s ---\n", dir) 215 res, err := sb.Commands().Run(ctx, "ls -la "+shellQuote(dir)) 216 if err != nil { 217 fmt.Printf("列出文件失败: %v\n", err) 218 return 219 } 220 if res.Stdout != "" { 221 fmt.Print(res.Stdout) 222 } 223 if res.Stderr != "" { 224 fmt.Fprint(os.Stderr, res.Stderr) 225 } 226 } 227 228 // runTurn 在沙箱内执行一轮 claude 对话。 229 // resumeID 为空时创建新会话;非空时通过 --resume 延续上下文。 230 // 返回本轮捕获到的 session_id,供下一轮复用。 231 func runTurn(ctx context.Context, sb *sandbox.Sandbox, prompt, resumeID string) (string, error) { 232 args := []string{ 233 "-p", shellQuote(prompt), 234 "--output-format", "stream-json", 235 "--verbose", 236 "--dangerously-skip-permissions", 237 } 238 if resumeID != "" { 239 // resumeID 也经 shell 拼接执行,统一 shellQuote 防御非预期字符。 240 args = append(args, "--resume", shellQuote(resumeID)) 241 } 242 cmd := "claude " + strings.Join(args, " ") 243 244 p := &streamProcessor{} 245 handle, err := sb.Commands().Start(ctx, cmd, 246 sandbox.WithOnStdout(p.onStdout), 247 // stderr 数据按 chunk 透传,避免对未完成的行加前缀产生 [stderr] part1[stderr] part2 这类碎片。 248 sandbox.WithOnStderr(func(data []byte) { 249 fmt.Fprint(os.Stderr, string(data)) 250 }), 251 ) 252 if err != nil { 253 return "", fmt.Errorf("启动命令: %w", err) 254 } 255 256 result, err := handle.Wait() 257 if err != nil { 258 return "", fmt.Errorf("等待命令: %w", err) 259 } 260 261 // flush 残留缓冲(若没有以 '\n' 收尾)。 262 if rest := bytes.TrimSpace(p.buf.Bytes()); len(rest) > 0 { 263 p.handleLine(rest) 264 } 265 266 if result.ExitCode != 0 { 267 return p.sessionID, fmt.Errorf("命令退出码 %d, error: %s", result.ExitCode, result.Error) 268 } 269 return p.sessionID, nil 270 } 271 272 // streamProcessor 维护单次 claude 调用的 JSONL 解析状态: 273 // stdout 是字节流,需按 '\n' 拆分为 JSONL 后再解析;同时捕获 session_id 供后续轮次使用。 274 // 275 // 并发模型:每次 runTurn 新建独立实例,与其他 turn 完全隔离;本实例的 onStdout 276 // 仅由对应 Start 调用产生的事件流单 goroutine 顺序回调(参见 SDK 内 277 // processEventStream)。因此 buf 无需加锁。若未来要在多个 Start 之间共享同一个 278 // streamProcessor,则需自行同步。 279 type streamProcessor struct { 280 buf bytes.Buffer 281 sessionID string 282 } 283 284 // onStdout 是 Start 的 stdout 回调,由进程事件流单 goroutine 顺序调用,无需加锁。 285 func (p *streamProcessor) onStdout(data []byte) { 286 p.buf.Write(data) 287 for { 288 buf := p.buf.Bytes() 289 idx := bytes.IndexByte(buf, '\n') 290 if idx < 0 { 291 break 292 } 293 // onStdout 单 goroutine、handleLine 同步消费 line 后不保留引用, 294 // 直接切片 buf 无需额外复制。 295 line := buf[:idx] 296 p.handleLine(line) 297 p.buf.Next(idx + 1) 298 } 299 } 300 301 func (p *streamProcessor) handleLine(line []byte) { 302 line = bytes.TrimSpace(line) 303 if len(line) == 0 { 304 return 305 } 306 var evt map[string]any 307 if err := json.Unmarshal(line, &evt); err != nil { 308 // 非 JSON 行(例如 CLI 早期提示),原样输出便于排查。 309 fmt.Printf("[raw] %s\n", line) 310 return 311 } 312 // 多数事件都带 session_id,取最后一次见到的值(init/result 一致)。 313 if sid, ok := evt["session_id"].(string); ok && sid != "" { 314 p.sessionID = sid 315 } 316 printEvent(evt) 317 } 318 319 // printEvent 打印 Claude Code 流式事件的关键字段。 320 // 事件类型参见 https://developer.qiniu.com/las/13452/sandbox-agent-claude-code 321 func printEvent(evt map[string]any) { 322 t, _ := evt["type"].(string) 323 switch t { 324 case "system": 325 sub, _ := evt["subtype"].(string) 326 session, _ := evt["session_id"].(string) 327 model, _ := evt["model"].(string) 328 fmt.Printf("[system/%s] session=%s model=%s\n", sub, session, model) 329 330 case "assistant": 331 if msg, ok := evt["message"].(map[string]any); ok { 332 printAssistantMessage(msg) 333 } 334 335 case "user": 336 if msg, ok := evt["message"].(map[string]any); ok { 337 printUserMessage(msg) 338 } 339 340 case "result": 341 sub, _ := evt["subtype"].(string) 342 isErr, _ := evt["is_error"].(bool) 343 dur, _ := evt["duration_ms"].(float64) 344 cost, _ := evt["total_cost_usd"].(float64) 345 text, _ := evt["result"].(string) 346 fmt.Printf("[result/%s] duration=%.0fms error=%v cost=$%.4f\n", sub, dur, isErr, cost) 347 if text != "" { 348 fmt.Printf(" 最终结果: %s\n", text) 349 } 350 351 default: 352 // 未知类型,原样打印以便后续扩展。 353 b, _ := json.Marshal(evt) 354 fmt.Printf("[%s] %s\n", t, b) 355 } 356 } 357 358 // printAssistantMessage 打印助手消息,区分纯文本与工具调用。 359 func printAssistantMessage(msg map[string]any) { 360 contents, _ := msg["content"].([]any) 361 var text strings.Builder 362 for _, c := range contents { 363 item, ok := c.(map[string]any) 364 if !ok { 365 continue 366 } 367 switch item["type"] { 368 case "text": 369 if s, ok := item["text"].(string); ok { 370 text.WriteString(s) 371 } 372 case "tool_use": 373 name, _ := item["name"].(string) 374 input, _ := json.Marshal(item["input"]) 375 fmt.Printf("[assistant/tool_use] %s input=%s\n", name, input) 376 } 377 } 378 if text.Len() > 0 { 379 fmt.Printf("[assistant] %s\n", text.String()) 380 } 381 } 382 383 // printUserMessage 打印用户消息,主要是工具结果回灌。 384 func printUserMessage(msg map[string]any) { 385 contents, _ := msg["content"].([]any) 386 for _, c := range contents { 387 item, ok := c.(map[string]any) 388 if !ok { 389 continue 390 } 391 if item["type"] != "tool_result" { 392 continue 393 } 394 id, _ := item["tool_use_id"].(string) 395 isErr, _ := item["is_error"].(bool) 396 fmt.Printf("[user/tool_result] id=%s error=%v\n", id, isErr) 397 } 398 } 399 400 // shellQuote 将字符串包装为单引号形式的 shell 字面量。 401 func shellQuote(s string) string { 402 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" 403 }