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  }