yao/agent/sandbox/v2/claude/command.go
Max abde3adf13 feat(claude): enhance session management and argument building
- Introduced session ID and name handling in the buildArgs function, allowing for better tracking of chat sessions.
- Added chatIDToSessionUUID and sanitizeSessionName functions to generate and format session identifiers.
- Updated the Stream method to store session information and manage session lifecycle more effectively.
- Implemented KillSessionCmd for precise process termination based on session names in both Windows and POSIX platforms.
- Enhanced tests to cover new session management features and ensure correct behavior in various scenarios.
2026-03-25 17:25:55 +08:00

377 lines
10 KiB
Go

package claude
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
)
const defaultA2OPort = 3099
var yaoSessionNS = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
var safeNameRe = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`)
func chatIDToSessionUUID(assistantID, chatID string) string {
return uuid.NewSHA1(yaoSessionNS, []byte(assistantID+":"+chatID)).String()
}
func sanitizeSessionName(chatID string) string {
return "yao-" + safeNameRe.ReplaceAllString(chatID, "_")
}
func chatSessionExists(storeKey string) bool {
s, err := store.Get("__yao.store")
if err != nil {
return false
}
return s.Has(storeKey)
}
func markChatSession(storeKey, sessionUUID string, ttl time.Duration) {
s, err := store.Get("__yao.store")
if err != nil {
return
}
s.Set(storeKey, sessionUUID, ttl)
}
type command struct {
shell []string
env map[string]string
stdin []byte
workDir string
}
func (r *ClaudeRunner) buildCommand(ctx context.Context, req *types.StreamRequest, p platform) command {
computer := req.Computer
workDir := computer.GetWorkDir()
assistantID := ""
if req.Config != nil {
assistantID = req.Config.ID
}
chatID := req.ChatID
var isContinuation bool
if chatID != "" {
storeKey := "claude-session:" + assistantID + ":" + chatID
isContinuation = chatSessionExists(storeKey)
} else {
isContinuation = hasExistingSession(ctx, computer, p, assistantID)
}
env := buildEnv(req, p)
args := buildArgs(req, r, p, isContinuation, assistantID, chatID)
inputJSONL := buildInput(req.Messages, isContinuation)
var systemPrompt string
envPrompt := buildSandboxEnvPrompt(p, workDir)
if !isContinuation && req.SystemPrompt != "" {
systemPrompt = req.SystemPrompt + "\n\n" + envPrompt
} else if !isContinuation {
systemPrompt = envPrompt
}
promptFile := p.PathJoin(workDir, ".yao", "assistants", assistantID, "system-prompt.txt")
if assistantID == "" {
promptFile = p.PathJoin(workDir, ".yao", ".system-prompt.txt")
}
script, stdin := p.BuildScript(scriptInput{
args: args,
systemPrompt: systemPrompt,
inputJSONL: inputJSONL,
workDir: workDir,
promptFile: promptFile,
})
return command{
shell: p.ShellCmd(script),
env: env,
stdin: stdin,
workDir: workDir,
}
}
func buildEnv(req *types.StreamRequest, p platform) map[string]string {
env := make(map[string]string)
workDir := req.Computer.GetWorkDir()
for k, v := range p.HomeEnv(workDir) {
env[k] = v
}
assistantID := ""
if req.Config != nil {
assistantID = req.Config.ID
}
if assistantID != "" {
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
env["CLAUDE_CONFIG_DIR"] = configDir
}
if req.Connector != nil {
setting := req.Connector.Setting()
host, _ := setting["host"].(string)
key, _ := setting["key"].(string)
model, _ := setting["model"].(string)
if req.Connector.Is(connector.ANTHROPIC) {
env["ANTHROPIC_BASE_URL"] = host
env["ANTHROPIC_API_KEY"] = key
if model != "" {
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
}
} else {
connectorID := req.Connector.ID()
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID)
env["ANTHROPIC_API_KEY"] = "dummy"
// Use a valid Anthropic model name to pass Claude CLI's local
// validation. The a2o proxy ignores this and substitutes the
// real backend model from its connector config.
env["ANTHROPIC_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-sonnet-4-6"
env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6"
}
if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
thinkType, _ := thinking["type"].(string)
switch thinkType {
case "disabled":
env["MAX_THINKING_TOKENS"] = "0"
case "enabled":
if budget, ok := thinking["budget_tokens"].(float64); ok && budget > 0 {
env["MAX_THINKING_TOKENS"] = fmt.Sprintf("%d", int(budget))
}
}
}
}
if req.Config != nil && len(req.Config.Secrets) > 0 {
for k, v := range req.Config.Secrets {
env[k] = v
}
}
if req.Token != nil {
if req.Token.Token != "" {
env["YAO_TOKEN"] = req.Token.Token
}
if req.Token.RefreshToken != "" {
env["YAO_REFRESH_TOKEN"] = req.Token.RefreshToken
}
}
return env
}
func buildArgs(req *types.StreamRequest, r *ClaudeRunner, p platform, isContinuation bool, assistantID, chatID string) []string {
var args []string
permMode := ""
if req.Config != nil && req.Config.Runner.Options != nil {
if v, ok := req.Config.Runner.Options["permission_mode"]; ok {
permMode = fmt.Sprintf("%v", v)
}
}
if permMode == "bypassPermissions" {
args = append(args, "--dangerously-skip-permissions")
args = append(args, "--permission-mode", permMode)
}
args = append(args, "--input-format", "stream-json")
args = append(args, "--output-format", "stream-json")
args = append(args, "--include-partial-messages")
args = append(args, "--verbose")
if chatID != "" {
sessionUUID := chatIDToSessionUUID(assistantID, chatID)
sessionName := sanitizeSessionName(chatID)
if isContinuation {
args = append(args, "--resume", sessionUUID)
} else {
args = append(args, "--session-id", sessionUUID)
}
args = append(args, "--name", sessionName)
} else if isContinuation {
args = append(args, "--continue")
}
if req.Config != nil && req.Config.Runner.Options != nil {
for key, val := range req.Config.Runner.Options {
if flag, ok := claudeArgWhitelist[key]; ok {
args = append(args, flag, fmt.Sprintf("%v", val))
}
}
}
if r.hasMCP {
workDir := req.Computer.GetWorkDir()
mcpPath := p.PathJoin(workDir, ".yao", "assistants", assistantID, "mcp.json")
if assistantID == "" {
mcpPath = p.PathJoin(workDir, ".claude", "mcp.json")
}
args = append(args, "--mcp-config", mcpPath)
if r.mcpToolPattern != "" {
args = append(args, "--allowedTools", r.mcpToolPattern)
}
}
return args
}
func buildInput(messages []agentContext.Message, isContinuation bool) string {
if isContinuation {
return buildLastUserMessageJSONL(messages)
}
return buildFirstRequestJSONL(messages)
}
func buildSandboxEnvPrompt(p platform, workDir string) string {
osName := p.OS()
if osName == "" {
osName = "linux"
}
shell := p.Shell()
if shell == "" {
shell = "bash"
}
shellNote := p.EnvPromptNote()
return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s
- **Shell**: %[3]s
- **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s%[4]s
## User Attachments
User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/
Each chat session has its own subdirectory to avoid conflicts.
When the user references an attached file, read it from this directory using the Read or Bash tool.
For image files, you can view them directly as Claude supports vision on local files.
`, workDir, osName, shell, shellNote)
}
func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool {
workDir := computer.GetWorkDir()
var sessionDir string
if assistantID != "" {
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
sessionDir = p.PathJoin(configDir, "projects")
} else {
sessionDir = p.PathJoin(workDir, ".claude", "projects")
}
result, err := computer.Exec(ctx, p.ListDirCmd(sessionDir))
if err != nil || result.ExitCode != 0 {
return false
}
return strings.TrimSpace(result.Stdout) != ""
}
func buildMCPConfig(servers []types.MCPServer) []byte {
mcpServers := make(map[string]any, len(servers))
for _, s := range servers {
name := s.ServerID
if name == "" {
continue
}
mcpServers[name] = map[string]any{
"command": "tai",
"args": []string{"mcp", name},
}
}
if len(mcpServers) == 0 {
mcpServers["yao"] = map[string]any{
"command": "tai",
"args": []string{"mcp"},
}
}
config := map[string]any{"mcpServers": mcpServers}
data, _ := json.Marshal(config)
return data
}
func buildMCPAllowedTools(servers []types.MCPServer) string {
patterns := make([]string, 0, len(servers))
for _, s := range servers {
if s.ServerID != "" {
patterns = append(patterns, fmt.Sprintf("mcp__%s__*", s.ServerID))
}
}
if len(patterns) == 0 {
return "mcp__yao__*"
}
return strings.Join(patterns, ",")
}
// buildFirstRequestJSONL builds the input JSONL for a new (non-continuation)
// Claude CLI session. Per the stream-json input protocol, only user messages
// should be sent; Claude CLI manages its own assistant history internally.
func buildFirstRequestJSONL(messages []agentContext.Message) string {
var lines []string
for _, msg := range messages {
if msg.Role != "user" {
continue
}
content := msg.Content
if content == nil {
content = ""
}
streamMsg := map[string]any{
"type": "user",
"message": map[string]any{
"role": "user",
"content": content,
},
}
data, _ := json.Marshal(streamMsg)
lines = append(lines, string(data))
}
return strings.Join(lines, "\n")
}
func buildLastUserMessageJSONL(messages []agentContext.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
content := messages[i].Content
if content == nil {
content = ""
}
msg := map[string]any{
"type": "user",
"message": map[string]any{
"role": "user",
"content": content,
},
}
data, _ := json.Marshal(msg)
return string(data)
}
}
return ""
}
var claudeArgWhitelist = map[string]string{
"max_turns": "--max-turns",
"disallowed_tools": "--disallowed-tools",
"allowed_tools": "--allowedTools",
}