diff --git a/.gitignore b/.gitignore index c433aefe..930b708f 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ introduction/* !sandbox/docker/build.sh sandbox/docker/yao-bridge-* sandbox/docker/claude-proxy-* +sandbox/docker/claude/claude-proxy-* +sandbox/proxy/claude-proxy-linux-* diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index 1a02197d..9c448c0b 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -157,10 +157,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa // Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox var sandboxExecutor agentsandbox.Executor var sandboxCleanup func() + var sandboxLoadingMsgID string if ast.HasSandbox() { ctx.Logger.Phase("Sandbox") var err error - sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts) + sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts) if err != nil { ast.traceAgentFail(agentNode, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) @@ -285,7 +286,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa // Choose between sandbox execution or direct LLM execution if ast.HasSandbox() { // Sandbox execution path (Claude CLI, Cursor CLI, etc.) - completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor) + completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID) } else { // Direct LLM execution path completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) diff --git a/agent/assistant/sandbox.go b/agent/assistant/sandbox.go index 18803d42..60750589 100644 --- a/agent/assistant/sandbox.go +++ b/agent/assistant/sandbox.go @@ -12,6 +12,7 @@ import ( gouMCP "github.com/yaoapp/gou/mcp" mcpProcess "github.com/yaoapp/gou/mcp/process" "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" agentsandbox "github.com/yaoapp/yao/agent/sandbox" "github.com/yaoapp/yao/config" @@ -55,22 +56,22 @@ func (ast *Assistant) HasSandbox() bool { // Returns the full Executor (for LLM calls), cleanup function, and any error // This is called BEFORE hooks so that hooks can access ctx.sandbox // The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces -func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), error) { +func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), string, error) { // Get sandbox manager (singleton) manager, err := GetSandboxManager() if err != nil { ctx.Logger.Error("Sandbox manager initialization failed: %v", err) - return nil, nil, fmt.Errorf("sandbox manager not available: %w", err) + return nil, nil, "", fmt.Errorf("sandbox manager not available: %w", err) } if manager == nil { - return nil, nil, fmt.Errorf("sandbox manager not initialized") + return nil, nil, "", fmt.Errorf("sandbox manager not initialized") } // Build executor options from assistant config execOpts, err := ast.buildSandboxOptions(ctx, opts) if err != nil { ctx.Logger.Error("Failed to build sandbox options: %v", err) - return nil, nil, fmt.Errorf("failed to build sandbox options: %w", err) + return nil, nil, "", fmt.Errorf("failed to build sandbox options: %w", err) } // Log sandbox creation @@ -86,7 +87,7 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( loadingMsg := &message.Message{ Type: message.TypeLoading, Props: map[string]interface{}{ - "message": "Preparing sandbox environment", + "message": i18n.T(ctx.Locale, "sandbox.preparing"), }, } loadingMsgID, _ := ctx.SendStream(loadingMsg) @@ -98,11 +99,21 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( if traceErr == nil && trace != nil { trace.Error("Sandbox creation failed: %v", err) } - // End loading message + // End loading message with done:true if loadingMsgID != "" { - ctx.End(loadingMsgID) + doneMsg := &message.Message{ + MessageID: loadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": i18n.T(ctx.Locale, "sandbox.failed"), + "done": true, + }, + } + ctx.Send(doneMsg) } - return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err) + return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err) } // Log sandbox ready @@ -111,11 +122,6 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( trace.Info("Sandbox container ready") } - // End loading message - if loadingMsgID != "" { - ctx.End(loadingMsgID) - } - // Return cleanup function cleanup := func() { if err := executor.Close(); err != nil { @@ -123,7 +129,9 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( } } - return executor, cleanup, nil + // Keep loadingMsgID open - it will be closed when first output is received + // This provides better UX: user sees "Preparing..." until actual content appears + return executor, cleanup, loadingMsgID, nil } // executeSandboxStream executes the request using sandbox (Claude CLI, etc.) @@ -135,6 +143,7 @@ func (ast *Assistant) executeSandboxStream( agentNode traceTypes.Node, streamHandler message.StreamFunc, executor agentsandbox.Executor, + loadingMsgID string, ) (*context.CompletionResponse, error) { // Mark the agentNode as used to avoid unused variable error @@ -147,9 +156,41 @@ func (ast *Assistant) executeSandboxStream( // Log sandbox execution ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command) + // Pass the "preparing sandbox" loading message ID to executor + // It will be closed when first output (text or tool) is received + if loadingMsgID != "" { + executor.SetLoadingMsgID(loadingMsgID) + } + // Execute LLM call via sandbox + // The loadingMsgID will be closed when first output is received + // Tool calls will create their own loading messages below the text resp, err := executor.Stream(ctx, completionMessages, streamHandler) + if err != nil { + // Close loading message on error + if loadingMsgID != "" { + doneMsg := &message.Message{ + MessageID: loadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": i18n.T(ctx.Locale, "sandbox.failed"), + "done": true, + }, + } + ctx.Send(doneMsg) + } + + // Send error message to client + errMsg := &message.Message{ + Type: message.TypeError, + Props: map[string]interface{}{ + "message": err.Error(), + }, + } + ctx.Send(errMsg) return nil, fmt.Errorf("sandbox execution failed: %w", err) } @@ -227,6 +268,40 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op execOpts.Model = model } + // Extract extra connector options (thinking, max_tokens, temperature, etc.) + // These are backend-specific parameters that need to be passed through to the proxy + connectorOptions := make(map[string]interface{}) + for k, v := range setting { + // Skip standard fields that are already handled + switch k { + case "host", "key", "model", "azure", "capabilities": + continue + default: + // Include all other fields as extra options + connectorOptions[k] = v + } + } + if len(connectorOptions) > 0 { + execOpts.ConnectorOptions = connectorOptions + ctx.Logger.Debug("Connector options extracted: %v", connectorOptions) + } + + // Extract secrets from sandbox config (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN") + if ast.Sandbox != nil && len(ast.Sandbox.Secrets) > 0 { + secrets := make(map[string]string) + for k, v := range ast.Sandbox.Secrets { + // Resolve $ENV.XXX references + resolved := resolveEnvValue(v) + if resolved != "" { + secrets[k] = resolved + } + } + if len(secrets) > 0 { + execOpts.Secrets = secrets + ctx.Logger.Debug("Secrets extracted: %d items", len(secrets)) + } + } + // Build MCP config and load tools if the assistant has MCP servers configured if ast.MCP != nil && len(ast.MCP.Servers) > 0 { // Build MCP config for Claude CLI @@ -366,3 +441,21 @@ func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, er return json.Marshal(config) } + +// resolveEnvValue resolves environment variable references in a string +// Supports format: $ENV.VAR_NAME or plain value +// Returns empty string if the variable is not set +func resolveEnvValue(value string) string { + if value == "" { + return "" + } + + // Check for $ENV.XXX format + if len(value) > 5 && value[:5] == "$ENV." { + envName := value[5:] + return os.Getenv(envName) + } + + // Return as-is if not an env reference + return value +} diff --git a/agent/assistant/sandbox_integration_test.go b/agent/assistant/sandbox_integration_test.go index 84d229a0..d9276b57 100644 --- a/agent/assistant/sandbox_integration_test.go +++ b/agent/assistant/sandbox_integration_test.go @@ -95,8 +95,13 @@ func TestClaudeCommandBuilding(t *testing.T) { assert.NotEmpty(t, env) assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL") assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy") - assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"]) - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant") + + // max_turns is passed via CLI flag + // system prompt is written to file via heredoc, then referenced via --append-system-prompt-file + assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag") + assert.Contains(t, cmd[2], "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt", "Should use heredoc for system prompt") + assert.Contains(t, cmd[2], "--append-system-prompt-file", "Should include append-system-prompt-file flag") + assert.Contains(t, cmd[2], "You are a helpful coding assistant", "Command should contain system prompt") t.Logf("Built environment: %v", env) } diff --git a/agent/i18n/builtin.go b/agent/i18n/builtin.go index eb9e0568..c73bac47 100644 --- a/agent/i18n/builtin.go +++ b/agent/i18n/builtin.go @@ -99,14 +99,39 @@ func init() { "kb.chat.name": "Chat Knowledge Base", "kb.chat.description": "Auto-created knowledge base collection for chat sessions", + // Sandbox: assistant/sandbox.go - Sandbox status messages + "sandbox.preparing": "Preparing sandbox environment", + "sandbox.ready": "Sandbox ready", + "sandbox.working": "Working on your request", + "sandbox.completed": "Completed", + "sandbox.failed": "Execution failed", + + // Sandbox: claude/executor.go - Tool execution messages + "sandbox.tool.read": "Reading file", + "sandbox.tool.write": "Writing file", + "sandbox.tool.edit": "Editing file", + "sandbox.tool.bash": "Running command", + "sandbox.tool.glob": "Finding files", + "sandbox.tool.grep": "Searching code", + "sandbox.tool.ls": "Listing directory", + "sandbox.tool.task": "Running subtask", + "sandbox.tool.web_search": "Searching web", + "sandbox.tool.web_fetch": "Fetching URL", + "sandbox.tool.todo_write": "Managing tasks", + "sandbox.tool.ask_question": "Asking question", + "sandbox.tool.switch_mode": "Switching mode", + "sandbox.tool.read_lints": "Checking lints", + "sandbox.tool.edit_notebook": "Editing notebook", + "sandbox.tool.unknown": "Executing {{name}}", + // Content: content/image/image.go - Image processing messages - "content.image.analyzing": "Analyzing image...", + "content.image.analyzing": "Analyzing image", // Content: content/pdf/pdf.go - PDF processing messages - "content.pdf.analyzing_page": "Analyzing PDF page %d/%d...", + "content.pdf.analyzing_page": "Analyzing PDF page %d/%d", // Search: assistant/search.go - Output messages - "search.loading": "Searching...", + "search.loading": "Searching", "search.success": "Found %d references", "search.success.one": "Found 1 reference", "search.partial": "Found %d references (some sources failed)", @@ -114,12 +139,12 @@ func init() { "search.no_results": "No references found", // Search Intent: assistant/search.go - Intent detection messages - "search.intent.loading": "Checking if references are needed...", - "search.intent.need_search": "Searching for references...", + "search.intent.loading": "Checking if references are needed", + "search.intent.need_search": "Searching for references", "search.intent.no_search": "No references needed", // Keyword Extraction: assistant/search.go - Keyword extraction messages - "search.keyword.loading": "Analyzing conversation...", + "search.keyword.loading": "Analyzing conversation", "search.keyword.done": "Analysis complete", // Search: assistant/search.go - Trace labels @@ -198,14 +223,39 @@ func init() { "kb.chat.name": "聊天知识库", "kb.chat.description": "自动为聊天会话创建的知识库集合", + // Sandbox: assistant/sandbox.go - Sandbox status messages + "sandbox.preparing": "正在准备沙箱环境", + "sandbox.ready": "沙箱环境就绪", + "sandbox.working": "正在处理您的请求", + "sandbox.completed": "处理完成", + "sandbox.failed": "执行失败", + + // Sandbox: claude/executor.go - Tool execution messages + "sandbox.tool.read": "正在读取文件", + "sandbox.tool.write": "正在写入文件", + "sandbox.tool.edit": "正在编辑文件", + "sandbox.tool.bash": "正在执行命令", + "sandbox.tool.glob": "正在查找文件", + "sandbox.tool.grep": "正在搜索代码", + "sandbox.tool.ls": "正在列出目录", + "sandbox.tool.task": "正在执行子任务", + "sandbox.tool.web_search": "正在搜索网页", + "sandbox.tool.web_fetch": "正在获取网页", + "sandbox.tool.todo_write": "正在管理任务", + "sandbox.tool.ask_question": "正在询问问题", + "sandbox.tool.switch_mode": "正在切换模式", + "sandbox.tool.read_lints": "正在检查代码", + "sandbox.tool.edit_notebook": "正在编辑笔记本", + "sandbox.tool.unknown": "正在执行 {{name}}", + // Content: content/image/image.go - Image processing messages - "content.image.analyzing": "正在分析图片...", + "content.image.analyzing": "正在分析图片", // Content: content/pdf/pdf.go - PDF processing messages - "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...", + "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页", // Search: assistant/search.go - Output messages - "search.loading": "正在搜索...", + "search.loading": "正在搜索", "search.success": "找到 %d 条参考资料", "search.success.one": "找到 1 条参考资料", "search.partial": "找到 %d 条参考资料(部分来源失败)", @@ -213,12 +263,12 @@ func init() { "search.no_results": "未找到相关资料", // Search Intent: assistant/search.go - Intent detection messages - "search.intent.loading": "检查是否需要查询资料...", - "search.intent.need_search": "正在查询相关资料...", + "search.intent.loading": "检查是否需要查询资料", + "search.intent.need_search": "正在查询相关资料", "search.intent.no_search": "无需查询资料", // Keyword Extraction: assistant/search.go - Keyword extraction messages - "search.keyword.loading": "正在分析对话内容...", + "search.keyword.loading": "正在分析对话内容", "search.keyword.done": "分析完成", // Search: assistant/search.go - Trace labels @@ -325,14 +375,39 @@ func init() { "kb.chat.name": "聊天知识库", "kb.chat.description": "自动为聊天会话创建的知识库集合", + // Sandbox: assistant/sandbox.go - Sandbox status messages + "sandbox.preparing": "正在准备沙箱环境", + "sandbox.ready": "沙箱环境就绪", + "sandbox.working": "正在处理您的请求", + "sandbox.completed": "处理完成", + "sandbox.failed": "执行失败", + + // Sandbox: claude/executor.go - Tool execution messages + "sandbox.tool.read": "正在读取文件", + "sandbox.tool.write": "正在写入文件", + "sandbox.tool.edit": "正在编辑文件", + "sandbox.tool.bash": "正在执行命令", + "sandbox.tool.glob": "正在查找文件", + "sandbox.tool.grep": "正在搜索代码", + "sandbox.tool.ls": "正在列出目录", + "sandbox.tool.task": "正在执行子任务", + "sandbox.tool.web_search": "正在搜索网页", + "sandbox.tool.web_fetch": "正在获取网页", + "sandbox.tool.todo_write": "正在管理任务", + "sandbox.tool.ask_question": "正在询问问题", + "sandbox.tool.switch_mode": "正在切换模式", + "sandbox.tool.read_lints": "正在检查代码", + "sandbox.tool.edit_notebook": "正在编辑笔记本", + "sandbox.tool.unknown": "正在执行 {{name}}", + // Content: content/image/image.go - Image processing messages - "content.image.analyzing": "正在分析图片...", + "content.image.analyzing": "正在分析图片", // Content: content/pdf/pdf.go - PDF processing messages - "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...", + "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页", // Search: assistant/search.go - Output messages - "search.loading": "正在搜索...", + "search.loading": "正在搜索", "search.success": "找到 %d 条参考资料", "search.success.one": "找到 1 条参考资料", "search.partial": "找到 %d 条参考资料(部分来源失败)", @@ -340,12 +415,12 @@ func init() { "search.no_results": "未找到相关资料", // Search Intent: assistant/search.go - Intent detection messages - "search.intent.loading": "检查是否需要查询资料...", - "search.intent.need_search": "正在查询相关资料...", + "search.intent.loading": "检查是否需要查询资料", + "search.intent.need_search": "正在查询相关资料", "search.intent.no_search": "无需查询资料", // Keyword Extraction: assistant/search.go - Keyword extraction messages - "search.keyword.loading": "正在分析对话内容...", + "search.keyword.loading": "正在分析对话内容", "search.keyword.done": "分析完成", // Search: assistant/search.go - Trace labels diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index 0ec616f3..37cdca09 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -8,12 +8,53 @@ import ( agentContext "github.com/yaoapp/yao/agent/context" ) +// sandboxEnvPrompt is the system prompt injected for sandbox environment +// This tells Claude CLI about the workspace and project structure +const sandboxEnvPrompt = `## Sandbox Environment + +You are running in a sandboxed environment with the following setup: + +- **Working Directory**: /workspace +- **Project Structure**: If this is a new project, create a dedicated project folder (e.g., /workspace/my-project/) and work inside it +- **File Access**: You have full read/write access to /workspace +- **Output Files**: Save all output files to the working directory + +When creating new projects: +1. Create a project directory with a descriptive name +2. Initialize the project structure inside that directory +3. Keep all related files organized within the project folder + +## IMPORTANT: Restricted Tools + +The following tools are NOT available in this environment and you must NOT use them: +- EnterPlanMode, ExitPlanMode (use regular text to explain plans instead) +- Task, TaskOutput, TaskStop (complete tasks directly without delegation) +- AskUserQuestion (make reasonable assumptions instead of asking) +- Skill, ToolSearch (not supported) + +Focus on using the core tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch. + +## GitHub CLI (gh) Usage + +When working with GitHub and a token is provided: +1. First authenticate gh CLI using the token: echo "TOKEN" | gh auth login --with-token +2. Then use gh commands normally (gh repo create, gh pr create, etc.) +3. Do NOT use curl to call GitHub API directly - always prefer gh CLI +` + // BuildCommand builds the Claude CLI command and environment variables // Uses stdin with --input-format stream-json for unlimited prompt length func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) { // Build system prompt from conversation history systemPrompt, _ := buildPrompts(messages) + // Inject sandbox environment prompt + if systemPrompt != "" { + systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt + } else { + systemPrompt = sandboxEnvPrompt + } + // Build input JSONL for Claude CLI (stream-json format) inputJSONL, err := BuildInputJSONL(messages) if err != nil { @@ -36,8 +77,16 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map // Add streaming format flags (required for proper streaming output) claudeArgs = append(claudeArgs, "--input-format", "stream-json") claudeArgs = append(claudeArgs, "--output-format", "stream-json") + claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming claudeArgs = append(claudeArgs, "--verbose") + // Add max_turns if specified + if opts != nil && opts.Arguments != nil { + if maxTurns, ok := opts.Arguments["max_turns"]; ok { + claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%v", maxTurns)) + } + } + // Add MCP config if available if opts != nil && len(opts.MCPConfig) > 0 { claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json") @@ -46,16 +95,30 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map } // Build the full bash command - // Use heredoc to pass input JSONL via stdin (no length limit) - // claude-proxy is already started by prepareEnvironment - bashCmd := "cat << 'INPUTEOF' | claude -p" + // Use heredoc for both system prompt and input JSONL to avoid shell escaping issues + // System prompt may contain quotes, newlines, special characters that break shell quoting + var bashCmd strings.Builder + + // If we have a system prompt, write it to a temp file via heredoc first + // then use --append-system-prompt-file + if systemPrompt != "" { + bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n") + bashCmd.WriteString(systemPrompt) + bashCmd.WriteString("\nPROMPTEOF\n") + claudeArgs = append(claudeArgs, "--append-system-prompt-file", "/tmp/.system-prompt.txt") + } + + // Build claude command with all arguments + bashCmd.WriteString("cat << 'INPUTEOF' | claude -p") for _, arg := range claudeArgs { // Quote arguments that might contain special characters - bashCmd += fmt.Sprintf(" %q", arg) + bashCmd.WriteString(fmt.Sprintf(" %q", arg)) } - bashCmd += "\n" + string(inputJSONL) + "\nINPUTEOF" + bashCmd.WriteString("\n") + bashCmd.WriteString(string(inputJSONL)) + bashCmd.WriteString("\nINPUTEOF") - cmd := []string{"bash", "-c", bashCmd} + cmd := []string{"bash", "-c", bashCmd.String()} // Build environment variables env := buildEnvironment(opts, systemPrompt) @@ -175,24 +238,16 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string { env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456" env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this - // Set system prompt via environment (Claude CLI supports this) - if systemPrompt != "" { - env["CLAUDE_SYSTEM_PROMPT"] = systemPrompt - } - - // Additional Claude CLI options from Arguments - if opts.Arguments != nil { - // max_turns - if maxTurns, ok := opts.Arguments["max_turns"]; ok { - env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns) - } - } + // Note: System prompt and max_turns are passed via CLI flags in BuildCommand + // CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI + // --append-system-prompt or --system-prompt flags must be used instead return env } // BuildProxyConfig builds the claude-proxy configuration JSON // This config file is read by start-claude-proxy script in the container +// Config is written to /tmp/.yao/proxy.json (not /workspace/) for security func BuildProxyConfig(opts *Options) ([]byte, error) { if opts == nil { return nil, fmt.Errorf("options is required") @@ -210,6 +265,18 @@ func BuildProxyConfig(opts *Options) ([]byte, error) { "model": opts.Model, } + // Add extra connector options if present (e.g., thinking, max_tokens, temperature) + // These will be passed to the proxy via CLAUDE_PROXY_OPTIONS environment variable + if len(opts.ConnectorOptions) > 0 { + config["options"] = opts.ConnectorOptions + } + + // Add secrets if present (e.g., GITHUB_TOKEN, AWS_ACCESS_KEY) + // These will be exported as environment variables for Claude CLI to use + if len(opts.Secrets) > 0 { + config["secrets"] = opts.Secrets + } + return json.MarshalIndent(config, "", " ") } diff --git a/agent/sandbox/claude/command_test.go b/agent/sandbox/claude/command_test.go index bb26afa9..a35876d5 100644 --- a/agent/sandbox/claude/command_test.go +++ b/agent/sandbox/claude/command_test.go @@ -33,6 +33,7 @@ func TestBuildCommand(t *testing.T) { // Should have stream-json flags assert.Contains(t, cmd[2], "--input-format") assert.Contains(t, cmd[2], "--output-format") + assert.Contains(t, cmd[2], "--include-partial-messages") assert.Contains(t, cmd[2], "--verbose") assert.Contains(t, cmd[2], "stream-json") @@ -51,12 +52,35 @@ func TestBuildCommandWithSystemPrompt(t *testing.T) { opts := &Options{} - _, env, err := BuildCommand(messages, opts) + cmd, _, err := BuildCommand(messages, opts) require.NoError(t, err) - // System prompt should include conversation history - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer") - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History") + // System prompt should be written to file via heredoc, then passed via --append-system-prompt-file + bashCmd := cmd[2] // The bash -c command string + assert.Contains(t, bashCmd, "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt") + assert.Contains(t, bashCmd, "You are a code reviewer") + assert.Contains(t, bashCmd, "PROMPTEOF") + assert.Contains(t, bashCmd, "--append-system-prompt-file") + assert.Contains(t, bashCmd, "/tmp/.system-prompt.txt") +} + +func TestBuildCommandWithSpecialCharsInPrompt(t *testing.T) { + // Test that special characters in prompts are handled correctly + messages := []agentContext.Message{ + {Role: "system", Content: "You are a helper.\n\n## Rules\n- Rule 1: Don't use \"quotes\" wrongly\n- Rule 2: Handle 'single quotes' too\n- Rule 3: Special chars like $VAR and `backticks`"}, + {Role: "user", Content: "Hello"}, + } + + opts := &Options{} + + cmd, _, err := BuildCommand(messages, opts) + require.NoError(t, err) + + bashCmd := cmd[2] + // The heredoc approach should preserve all special characters + assert.Contains(t, bashCmd, "## Rules") + assert.Contains(t, bashCmd, `Don't use "quotes" wrongly`) + assert.Contains(t, bashCmd, "'single quotes'") } func TestBuildCommandWithArguments(t *testing.T) { @@ -71,12 +95,15 @@ func TestBuildCommandWithArguments(t *testing.T) { }, } - cmd, env, err := BuildCommand(messages, opts) + cmd, _, err := BuildCommand(messages, opts) require.NoError(t, err) - assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"]) - // permission_mode should be in command args, not env - assert.Contains(t, cmd[2], "acceptEdits") + bashCmd := cmd[2] // The bash -c command string + // max_turns should be in command args via --max-turns + assert.Contains(t, bashCmd, "--max-turns") + assert.Contains(t, bashCmd, "20") + // permission_mode should be in command args + assert.Contains(t, bashCmd, "acceptEdits") } func TestBuildProxyConfig(t *testing.T) { diff --git a/agent/sandbox/claude/e2e_test.go b/agent/sandbox/claude/e2e_test.go index 9a1a8d17..0ae12111 100644 --- a/agent/sandbox/claude/e2e_test.go +++ b/agent/sandbox/claude/e2e_test.go @@ -258,8 +258,10 @@ func TestE2EBuildCommand(t *testing.T) { assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"]) assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"]) - // System prompt should be in environment - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are helpful", "System prompt should be in env") + // System prompt should be passed via CLI argument, not environment variable + // CLAUDE_SYSTEM_PROMPT env var is NOT supported by Claude CLI + assert.Contains(t, bashCmd, "--append-system-prompt", "Should have append-system-prompt flag") + assert.Contains(t, bashCmd, "You are helpful", "System prompt should be in CLI args") t.Log("✓ Command building verified") } diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index 386e746f..e028c919 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -6,10 +6,15 @@ import ( "encoding/json" "fmt" "io" + "log" + "os" + "path/filepath" "strings" "time" + goujson "github.com/yaoapp/gou/json" agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" infraSandbox "github.com/yaoapp/yao/sandbox" "github.com/yaoapp/yao/sandbox/ipc" @@ -17,21 +22,23 @@ import ( // Options for Claude executor (copied from parent package to avoid import cycle) type Options struct { - Command string - Image string - MaxMemory string - MaxCPU float64 - Timeout time.Duration - Arguments map[string]interface{} - UserID string - ChatID string - MCPConfig []byte - MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC - SkillsDir string - SystemPrompt string // System prompt from assistant prompts.yml - ConnectorHost string - ConnectorKey string - Model string + Command string + Image string + MaxMemory string + MaxCPU float64 + Timeout time.Duration + Arguments map[string]interface{} + UserID string + ChatID string + MCPConfig []byte + MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC + SkillsDir string + SystemPrompt string // System prompt from assistant prompts.yml + ConnectorHost string + ConnectorKey string + Model string + ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens) + Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN) } // Executor implements the sandbox.Executor interface for Claude CLI @@ -40,6 +47,7 @@ type Executor struct { containerName string opts *Options workDir string + loadingMsgID string // Loading message ID for tool execution updates } // NewExecutor creates a new Claude executor @@ -91,12 +99,61 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er }, nil } +// SetLoadingMsgID sets the loading message ID for tool execution updates +func (e *Executor) SetLoadingMsgID(id string) { + e.loadingMsgID = id +} + // Stream runs the Claude CLI with streaming output func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { - stdCtx := context.Background() - if ctx != nil && ctx.Context != nil { - stdCtx = ctx.Context - } + // Create a cancellable context for this stream operation + // We need to handle both: + // 1. HTTP context cancellation (client disconnect) + // 2. InterruptController cancellation (user clicks "stop" button) + // + // Note on InterruptController: + // - ctx.Interrupt.Context() is only cancelled when InterruptForce && len(Messages) == 0 + // - When user sends messages with the interrupt, the context is NOT cancelled + // - We use ctx.Interrupt.IsInterrupted() to check for any interrupt signal + stdCtx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + // Start a goroutine to monitor for interrupts and HTTP context cancellation + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-stdCtx.Done(): + // Already cancelled, exit + return + case <-ticker.C: + // Check if there's a pending interrupt signal using Peek() + // This works even when Messages are included (which doesn't cancel the context) + if ctx != nil && ctx.Interrupt != nil { + if signal := ctx.Interrupt.Peek(); signal != nil { + cancelFunc() + return + } + } + // Check InterruptController.IsInterrupted() (for context-cancelled interrupts) + if ctx != nil && ctx.Interrupt != nil && ctx.Interrupt.IsInterrupted() { + cancelFunc() + return + } + // Check HTTP context + if ctx != nil && ctx.Context != nil { + select { + case <-ctx.Context.Done(): + cancelFunc() + return + default: + } + } + } + } + }() // Set MCP tools for this request (dynamic, runtime configuration) if len(e.opts.MCPTools) > 0 { @@ -147,10 +204,51 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes if err != nil { return nil, fmt.Errorf("failed to execute command: %w", err) } - defer reader.Close() - // Parse streaming output - return e.parseStream(reader, handler) + // Ensure reader is closed when context is cancelled or function returns + // This is important for cleanup when user clicks "stop" + done := make(chan struct{}) + defer func() { + close(done) + reader.Close() + }() + + // Monitor for context cancellation and forcefully kill Claude CLI process + go func() { + // Also start a ticker to periodically check context status for debugging + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-stdCtx.Done(): + // First, kill the Claude CLI process inside the container + // This is important because closing the reader/connection alone may not stop the process + // Use a background context since stdCtx is already cancelled + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Kill claude process (the Claude CLI binary) + e.manager.KillProcess(killCtx, e.containerName, "claude") + + // Also close the reader to unblock any pending reads + reader.Close() + return + case <-done: + // Normal completion, nothing to do + return + case <-ticker.C: + // Periodic check - no action needed + } + } + }() + + // DEBUG: Tee the reader to write raw output to a log file for debugging + debugLogPath := e.workDir + "/claude-cli-raw.log" + debugReader := e.createDebugReader(stdCtx, reader, debugLogPath) + + // Parse streaming output (uses e.loadingMsgID set via SetLoadingMsgID) + return e.parseStream(ctx, debugReader, handler) } // shouldSkipClaudeCLI checks if Claude CLI execution should be skipped @@ -204,8 +302,15 @@ func (e *Executor) startClaudeProxy(ctx context.Context) error { return fmt.Errorf("failed to build proxy config: %w", err) } - // Write config to workspace - configPath := e.workDir + "/.claude-proxy.json" + // Create config directory (outside workspace for security - user can't see api_key/secrets) + // /tmp/.yao/ is not visible to user's file manager + configDir := "/tmp/.yao" + if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", configDir}, nil); err != nil { + return fmt.Errorf("failed to create config directory %s: %w", configDir, err) + } + + // Write config to secure location (not in /workspace/) + configPath := configDir + "/proxy.json" if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil { return fmt.Errorf("failed to write config to %s: %w", configPath, err) } @@ -283,12 +388,62 @@ func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Me return e.Stream(ctx, messages, nil) } +// debugWriter wraps an io.Reader to write all data to a debug log file +type debugWriter struct { + reader io.Reader + logFile *os.File + buffer []byte +} + +func (d *debugWriter) Read(p []byte) (n int, err error) { + n, err = d.reader.Read(p) + if n > 0 && d.logFile != nil { + // Write raw bytes to log file + d.logFile.Write(p[:n]) + d.logFile.Sync() + } + return n, err +} + +func (d *debugWriter) Close() error { + if d.logFile != nil { + d.logFile.Close() + } + return nil +} + +// createDebugReader creates a tee reader that writes to a debug log file +// The log file is written to the container's workspace for inspection +func (e *Executor) createDebugReader(ctx context.Context, reader io.ReadCloser, logPath string) io.Reader { + // Create a local temp file for debug logging + // We write to a local file first, then copy to container when done + localLogPath := "/tmp/claude-cli-debug-" + e.containerName + ".log" + logFile, err := os.Create(localLogPath) + if err != nil { + return reader + } + + // Write header + logFile.WriteString("=== Claude CLI Raw Output Debug Log ===\n") + logFile.WriteString(fmt.Sprintf("Container: %s\n", e.containerName)) + logFile.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339))) + logFile.WriteString(fmt.Sprintf("WorkDir: %s\n", e.workDir)) + logFile.WriteString("=== BEGIN OUTPUT ===\n") + logFile.Sync() + + return &debugWriter{ + reader: reader, + logFile: logFile, + } +} + // parseStream parses Claude CLI streaming output (stream-json format) -// Claude CLI output format: +// Claude CLI output format with --include-partial-messages: // - {"type":"system","subtype":"init",...} - initialization -// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - assistant messages +// - {"type":"stream_event","event":{"delta":{"type":"text_delta","text":"..."}}} - real-time text deltas +// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - complete messages // - {"type":"result","subtype":"success",...,"result":"..."} - final result -func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { +func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { scanner := bufio.NewScanner(reader) // Increase buffer size for potentially large outputs buf := make([]byte, 0, 64*1024) @@ -299,10 +454,58 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a var model string var usage *message.UsageInfo var finalResult string - messageStarted := false // Track if we've sent ChunkMessageStart + messageStarted := false // Track if we've sent ChunkMessageStart + prepLoadingClosed := false // Track if "preparing sandbox" loading has been closed + + // Tool input accumulation state + type toolState struct { + name string + index int + inputJSON strings.Builder + loadingID string // Each tool has its own loading message + } + var currentTool *toolState + var lastToolLoadingID string // Track the last tool loading ID to close it + + // Helper function to close "preparing sandbox" loading on first output + closePrepLoading := func() { + if !prepLoadingClosed && e.loadingMsgID != "" && ctx != nil { + doneMsg := &message.Message{ + MessageID: e.loadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": "", + "done": true, + }, + } + ctx.Send(doneMsg) + prepLoadingClosed = true + } + } + + lineCount := 0 + + // Get the underlying context for cancellation checks + var stdCtx context.Context + if ctx != nil && ctx.Context != nil { + stdCtx = ctx.Context + } else { + stdCtx = context.Background() + } for scanner.Scan() { + // Check for context cancellation on each iteration + select { + case <-stdCtx.Done(): + return nil, stdCtx.Err() + default: + // Continue processing + } + line := scanner.Text() + lineCount++ if line == "" { continue } @@ -326,10 +529,137 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a model = m } + case "stream_event": + // Real-time streaming event (from --include-partial-messages) + // Format: {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}} + if event, ok := msg["event"].(map[string]interface{}); ok { + eventType, _ := event["type"].(string) + + switch eventType { + case "content_block_start": + // Check if this is a tool_use block starting + // Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}} + if contentBlock, ok := event["content_block"].(map[string]interface{}); ok { + blockType, _ := contentBlock["type"].(string) + if blockType == "tool_use" { + toolName, _ := contentBlock["name"].(string) + blockIndex := 0 + if idx, ok := event["index"].(float64); ok { + blockIndex = int(idx) + } + if toolName != "" && ctx != nil { + // Close "preparing sandbox" loading on first tool + closePrepLoading() + + // Close previous tool loading if exists + if lastToolLoadingID != "" { + doneMsg := &message.Message{ + MessageID: lastToolLoadingID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": "", + "done": true, + }, + } + ctx.Send(doneMsg) + } + + // Create new loading message for this tool + locale := ctx.Locale + toolLoadingMsg := &message.Message{ + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": getToolDescription(toolName, locale), + }, + } + newLoadingID, _ := ctx.SendStream(toolLoadingMsg) + + // Initialize tool state for input accumulation + currentTool = &toolState{ + name: toolName, + index: blockIndex, + loadingID: newLoadingID, + } + lastToolLoadingID = newLoadingID + + log.Printf("[Sandbox] Tool started: %s", toolName) + } + } + } + + case "content_block_delta": + if delta, ok := event["delta"].(map[string]interface{}); ok { + deltaType, _ := delta["type"].(string) + switch deltaType { + case "text_delta": + if text, ok := delta["text"].(string); ok && text != "" { + // Close "preparing sandbox" loading on first text output + closePrepLoading() + + // Send to stream handler for real-time output + if handler != nil { + // Send ChunkMessageStart first if not already started + if !messageStarted { + startData := message.EventMessageStartData{ + MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), + Type: "text", + Timestamp: time.Now().UnixMilli(), + } + startDataJSON, _ := json.Marshal(startData) + handler(message.ChunkMessageStart, startDataJSON) + messageStarted = true + } + handler(message.ChunkText, []byte(text)) + } + // Also accumulate for final response + textContent.WriteString(text) + } + + case "input_json_delta": + // Accumulate tool input JSON fragments + if currentTool != nil { + if partialJSON, ok := delta["partial_json"].(string); ok { + currentTool.inputJSON.WriteString(partialJSON) + } + } + } + } + + case "content_block_stop": + // Tool input complete - parse and update loading with detailed info + if currentTool != nil && currentTool.loadingID != "" && ctx != nil { + inputStr := currentTool.inputJSON.String() + if inputStr != "" { + // Use gou/json.Parse for fault-tolerant parsing + locale := ctx.Locale + detailedMsg := getToolDetailedDescription(currentTool.name, inputStr, locale) + if detailedMsg != "" { + toolMsg := &message.Message{ + MessageID: currentTool.loadingID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": detailedMsg, + }, + } + ctx.Send(toolMsg) + log.Printf("[Sandbox] Tool: %s -> %s", currentTool.name, detailedMsg) + } + } + // Note: Don't close loading here - it will be closed when next tool starts or at end + // Reset tool state but keep lastToolLoadingID to close it later + currentTool = nil + } + } + } + case "assistant": // Assistant message - extract content - // Note: Claude CLI sends multiple assistant messages, each with cumulative content. - // We only process the final one (with stop_reason) to avoid duplicates. + // With --include-partial-messages, we receive real-time text via stream_event + // The assistant message contains the full accumulated content if msgData, ok := msg["message"].(map[string]interface{}); ok { // Get model from message if m, ok := msgData["model"].(string); ok && model == "" { @@ -340,7 +670,8 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a stopReason, hasStopReason := msgData["stop_reason"].(string) isFinalMessage := hasStopReason && stopReason != "" - // Extract content array - only from final message to avoid duplicates + // Extract content from final message + // This serves as a fallback if stream_event wasn't received if isFinalMessage { if contentArr, ok := msgData["content"].([]interface{}); ok { for _, item := range contentArr { @@ -349,41 +680,79 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a switch itemType { case "text": - if text, ok := contentItem["text"].(string); ok { - textContent.WriteString(text) - // Send to stream handler if available - if handler != nil { - // Send ChunkMessageStart first if not already started - // This initializes the stream state (inGroup=true) required for Buffer.AddAssistantMessage - if !messageStarted { - startData := message.EventMessageStartData{ - MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), - Type: "text", - Timestamp: time.Now().UnixMilli(), + // Only use this if we haven't already accumulated text from stream_event + if textContent.Len() == 0 { + if text, ok := contentItem["text"].(string); ok && text != "" { + textContent.WriteString(text) + // Send to stream handler if available + if handler != nil { + if !messageStarted { + startData := message.EventMessageStartData{ + MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), + Type: "text", + Timestamp: time.Now().UnixMilli(), + } + startDataJSON, _ := json.Marshal(startData) + handler(message.ChunkMessageStart, startDataJSON) + messageStarted = true } - startDataJSON, _ := json.Marshal(startData) - handler(message.ChunkMessageStart, startDataJSON) - messageStarted = true + handler(message.ChunkText, []byte(text)) } - handler(message.ChunkText, []byte(text)) } } case "tool_use": + toolName := getString(contentItem, "name") toolCall := agentContext.ToolCall{ ID: getString(contentItem, "id"), Type: agentContext.ToolTypeFunction, Function: agentContext.Function{ - Name: getString(contentItem, "name"), + Name: toolName, }, } // Get input as JSON string + var inputJSONStr string if input, ok := contentItem["input"]; ok { if inputJSON, err := json.Marshal(input); err == nil { - toolCall.Function.Arguments = string(inputJSON) + inputJSONStr = string(inputJSON) + toolCall.Function.Arguments = inputJSONStr } } toolCalls = append(toolCalls, toolCall) + + // Create tool loading message (from complete assistant message) + // This is a fallback for when stream_event wasn't received + if toolName != "" && ctx != nil { + // Close previous tool loading if exists + if lastToolLoadingID != "" { + doneMsg := &message.Message{ + MessageID: lastToolLoadingID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": "", + "done": true, + }, + } + ctx.Send(doneMsg) + } + + // Create new loading for this tool + locale := ctx.Locale + detailedMsg := getToolDetailedDescription(toolName, inputJSONStr, locale) + if detailedMsg != "" { + toolLoadingMsg := &message.Message{ + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": detailedMsg, + }, + } + newLoadingID, _ := ctx.SendStream(toolLoadingMsg) + lastToolLoadingID = newLoadingID + log.Printf("[Sandbox] Tool: %s -> %s", toolName, detailedMsg) + } + } } } } @@ -405,11 +774,17 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a case "result": // Final result message + // Check if this is an error result (is_error: true) + isError, _ := msg["is_error"].(bool) if result, ok := msg["result"].(string); ok { + if isError { + // This is an error - return it as an error + return nil, fmt.Errorf("Claude CLI error: %s", result) + } finalResult = result } - // Send done signal to handler (only if message was started) - if handler != nil && messageStarted { + // Send done signal to handler (only if message was started and not an error) + if handler != nil && messageStarted && !isError { handler(message.ChunkMessageEnd, nil) } @@ -430,6 +805,21 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a return nil, fmt.Errorf("error reading stream: %w", err) } + // Close the last tool loading message if exists + if lastToolLoadingID != "" && ctx != nil { + doneMsg := &message.Message{ + MessageID: lastToolLoadingID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]interface{}{ + "message": "", + "done": true, + }, + } + ctx.Send(doneMsg) + } + // Use final result if available, otherwise use accumulated text content content := textContent.String() if finalResult != "" && content == "" { @@ -460,6 +850,161 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a return response, nil } +// truncateStr truncates a string to maxLen characters +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// getToolDescription returns a human-readable, localized description for a Claude CLI tool +func getToolDescription(toolName string, locale string) string { + // Map tool names to i18n keys + toolKeys := map[string]string{ + "Read": "sandbox.tool.read", + "Write": "sandbox.tool.write", + "Edit": "sandbox.tool.edit", + "StrReplace": "sandbox.tool.edit", + "Bash": "sandbox.tool.bash", + "Shell": "sandbox.tool.bash", + "Glob": "sandbox.tool.glob", + "Grep": "sandbox.tool.grep", + "LS": "sandbox.tool.ls", + "Task": "sandbox.tool.task", + "WebSearch": "sandbox.tool.web_search", + "WebFetch": "sandbox.tool.web_fetch", + "TodoWrite": "sandbox.tool.todo_write", + "AskQuestion": "sandbox.tool.ask_question", + "SwitchMode": "sandbox.tool.switch_mode", + "ReadLints": "sandbox.tool.read_lints", + "EditNotebook": "sandbox.tool.edit_notebook", + } + + if key, ok := toolKeys[toolName]; ok { + return i18n.T(locale, key) + } + // For unknown tools, use the unknown key and replace {{name}} manually + template := i18n.T(locale, "sandbox.tool.unknown") + return strings.Replace(template, "{{name}}", toolName, 1) +} + +// getToolDetailedDescription returns a detailed description with specific parameters +// It parses the tool input JSON and extracts key information to show users +func getToolDetailedDescription(toolName string, inputJSON string, locale string) string { + // Parse the input JSON using fault-tolerant parser + parsed, err := goujson.Parse(inputJSON) + if err != nil { + // Fall back to basic description if parsing fails + return getToolDescription(toolName, locale) + } + + input, ok := parsed.(map[string]interface{}) + if !ok { + return getToolDescription(toolName, locale) + } + + // Extract key information based on tool type + var detail string + switch toolName { + case "Bash", "Shell": + // Show the command being executed + if cmd, ok := input["command"].(string); ok && cmd != "" { + // Truncate long commands + if len(cmd) > 50 { + cmd = cmd[:47] + "..." + } + detail = cmd + } + + case "Read": + // Show the file being read + if path, ok := input["path"].(string); ok && path != "" { + detail = filepath.Base(path) + } + + case "Write": + // Show the file being written + // Note: Claude CLI uses "file_path" for Write tool, not "path" + if path, ok := input["file_path"].(string); ok && path != "" { + detail = filepath.Base(path) + } else if path, ok := input["path"].(string); ok && path != "" { + detail = filepath.Base(path) + } + + case "Edit", "StrReplace": + // Show the file being edited + if path, ok := input["path"].(string); ok && path != "" { + detail = filepath.Base(path) + } + + case "Glob": + // Show the glob pattern + if pattern, ok := input["glob_pattern"].(string); ok && pattern != "" { + detail = pattern + } else if pattern, ok := input["pattern"].(string); ok && pattern != "" { + detail = pattern + } + + case "Grep": + // Show the search pattern + if pattern, ok := input["pattern"].(string); ok && pattern != "" { + if len(pattern) > 30 { + pattern = pattern[:27] + "..." + } + detail = pattern + } + + case "LS": + // Show the directory + if path, ok := input["target_directory"].(string); ok && path != "" { + detail = filepath.Base(path) + } else if path, ok := input["path"].(string); ok && path != "" { + detail = filepath.Base(path) + } + + case "WebSearch": + // Show the search query + if query, ok := input["search_term"].(string); ok && query != "" { + if len(query) > 40 { + query = query[:37] + "..." + } + detail = query + } else if query, ok := input["query"].(string); ok && query != "" { + if len(query) > 40 { + query = query[:37] + "..." + } + detail = query + } + + case "WebFetch": + // Show the URL + if url, ok := input["url"].(string); ok && url != "" { + // Extract domain from URL + if len(url) > 50 { + url = url[:47] + "..." + } + detail = url + } + + case "Task": + // Show the task description + if desc, ok := input["description"].(string); ok && desc != "" { + if len(desc) > 40 { + desc = desc[:37] + "..." + } + detail = desc + } + } + + // Build the message with detail + baseMsg := getToolDescription(toolName, locale) + if detail != "" { + return baseMsg + ": " + detail + } + return baseMsg +} + // ReadFile reads a file from the container func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) { // Make path absolute if not diff --git a/agent/sandbox/executor.go b/agent/sandbox/executor.go index 94f8bfb6..89ba56a6 100644 --- a/agent/sandbox/executor.go +++ b/agent/sandbox/executor.go @@ -26,21 +26,23 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) { case "claude": // Convert to claude.Options claudeOpts := &claude.Options{ - Command: opts.Command, - Image: opts.Image, - MaxMemory: opts.MaxMemory, - MaxCPU: opts.MaxCPU, - Timeout: opts.Timeout, - Arguments: opts.Arguments, - UserID: opts.UserID, - ChatID: opts.ChatID, - MCPConfig: opts.MCPConfig, - MCPTools: opts.MCPTools, - SkillsDir: opts.SkillsDir, - SystemPrompt: opts.SystemPrompt, // Required for Claude CLI execution - ConnectorHost: opts.ConnectorHost, - ConnectorKey: opts.ConnectorKey, - Model: opts.Model, + Command: opts.Command, + Image: opts.Image, + MaxMemory: opts.MaxMemory, + MaxCPU: opts.MaxCPU, + Timeout: opts.Timeout, + Arguments: opts.Arguments, + UserID: opts.UserID, + ChatID: opts.ChatID, + MCPConfig: opts.MCPConfig, + MCPTools: opts.MCPTools, + SkillsDir: opts.SkillsDir, + SystemPrompt: opts.SystemPrompt, // Required for Claude CLI execution + ConnectorHost: opts.ConnectorHost, + ConnectorKey: opts.ConnectorKey, + Model: opts.Model, + ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens + Secrets: opts.Secrets, // Secrets for container env vars } return claude.NewExecutor(manager, claudeOpts) case "cursor": diff --git a/agent/sandbox/types.go b/agent/sandbox/types.go index ad17e734..ea17f710 100644 --- a/agent/sandbox/types.go +++ b/agent/sandbox/types.go @@ -18,6 +18,9 @@ type Executor interface { // Stream runs the request with streaming output (uses options set at creation time) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) + // SetLoadingMsgID sets the loading message ID for tool execution status updates + SetLoadingMsgID(id string) + // Filesystem operations (for Hooks) ReadFile(ctx context.Context, path string) ([]byte, error) WriteFile(ctx context.Context, path string, content []byte) error @@ -83,6 +86,14 @@ type Options struct { ConnectorHost string `json:"-"` ConnectorKey string `json:"-"` Model string `json:"-"` + + // ConnectorOptions - extra options from connector config (e.g., thinking, max_tokens, temperature) + // These are backend-specific parameters passed to the proxy + ConnectorOptions map[string]interface{} `json:"-"` + + // Secrets - sensitive values from sandbox.secrets config (e.g., GITHUB_TOKEN) + // Resolved from $ENV.XXX references, exported as env vars in container + Secrets map[string]string `json:"-"` } // SandboxConfig represents the sandbox configuration in assistant package.yao diff --git a/agent/store/types/types.go b/agent/store/types/types.go index 657c8e8e..732a1af9 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -370,6 +370,7 @@ type Sandbox struct { MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0) Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m") Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments + Secrets map[string]string `json:"secrets,omitempty"` // Secrets to pass to container (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN") } // Tool represents a tool configuration for storage diff --git a/sandbox/docker/claude/Dockerfile b/sandbox/docker/claude/Dockerfile index 62a1ae4d..429e0216 100644 --- a/sandbox/docker/claude/Dockerfile +++ b/sandbox/docker/claude/Dockerfile @@ -25,6 +25,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* +# GitHub CLI (gh) for repository operations +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update \ + && apt-get install -y gh \ + && rm -rf /var/lib/apt/lists/* + # npm global packages directory for sandbox user RUN mkdir -p /home/sandbox/.npm-global && \ chown -R sandbox:sandbox /home/sandbox/.npm-global @@ -39,6 +47,21 @@ ENV PATH="/home/sandbox/.npm-global/bin:${PATH}" RUN npm install -g @anthropic-ai/claude-code || \ echo "Claude CLI installation skipped (may not be available yet)" +# Create Claude CLI configuration for auto-approve all operations +# This is CRITICAL for non-interactive sandbox usage +# The --dangerously-skip-permissions flag alone is not enough; +# we also need the settings.json to fully bypass permission prompts +RUN mkdir -p /home/sandbox/.claude && \ + cat > /home/sandbox/.claude/settings.json << 'EOF' +{ + "permissions": { + "defaultMode": "bypassPermissions", + "allow": ["*"], + "deny": [] + } +} +EOF + USER root # Install claude-proxy (architecture-specific binary) @@ -140,14 +163,14 @@ SCRIPT RUN chmod +x /usr/local/bin/claude-run # Create start-claude-proxy script for programmatic use (called by Yao) -# This reads proxy config from /workspace/.claude-proxy.json if exists +# Config is read from /tmp/.yao/proxy.json (NOT /workspace/ for security - api_key/secrets hidden from user) RUN cat > /usr/local/bin/start-claude-proxy << 'SCRIPT' #!/bin/bash # Start claude-proxy from config file or environment variables -# Config file: /workspace/.claude-proxy.json -# Format: {"backend": "...", "api_key": "...", "model": "..."} +# Config file: /tmp/.yao/proxy.json (secure location, not visible to user file manager) +# Format: {"backend": "...", "api_key": "...", "model": "...", "options": {...}, "secrets": {...}} -CONFIG_FILE="${WORKSPACE:-/workspace}/.claude-proxy.json" +CONFIG_FILE="/tmp/.yao/proxy.json" LOG_FILE="${WORKSPACE:-/workspace}/proxy.log" PORT="${CLAUDE_PROXY_PORT:-3456}" @@ -156,6 +179,8 @@ if [ -f "$CONFIG_FILE" ]; then BACKEND=$(jq -r '.backend // empty' "$CONFIG_FILE" 2>/dev/null) API_KEY=$(jq -r '.api_key // empty' "$CONFIG_FILE" 2>/dev/null) MODEL=$(jq -r '.model // empty' "$CONFIG_FILE" 2>/dev/null) + # Read extra options as JSON string (e.g., {"thinking":{"type":"enabled"}}) + OPTIONS=$(jq -c '.options // empty' "$CONFIG_FILE" 2>/dev/null) if [ -n "$BACKEND" ]; then export CLAUDE_PROXY_BACKEND="$BACKEND" @@ -166,6 +191,23 @@ if [ -f "$CONFIG_FILE" ]; then if [ -n "$MODEL" ]; then export CLAUDE_PROXY_MODEL="$MODEL" fi + # Only set options if it's a valid non-empty JSON object + if [ -n "$OPTIONS" ] && [ "$OPTIONS" != "null" ] && [ "$OPTIONS" != "" ]; then + export CLAUDE_PROXY_OPTIONS="$OPTIONS" + fi + + # Export secrets as environment variables for Claude CLI to use + # e.g., {"GITHUB_TOKEN": "ghp_xxx"} -> export GITHUB_TOKEN=ghp_xxx + SECRETS=$(jq -c '.secrets // empty' "$CONFIG_FILE" 2>/dev/null) + if [ -n "$SECRETS" ] && [ "$SECRETS" != "null" ] && [ "$SECRETS" != "" ] && [ "$SECRETS" != "{}" ]; then + # Parse each key-value pair and export + for key in $(echo "$SECRETS" | jq -r 'keys[]' 2>/dev/null); do + value=$(echo "$SECRETS" | jq -r --arg k "$key" '.[$k]' 2>/dev/null) + if [ -n "$value" ] && [ "$value" != "null" ]; then + export "$key"="$value" + fi + done + fi fi # Check if we have the required config @@ -181,9 +223,14 @@ if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then exit 0 fi -# Start proxy +# Start proxy with environment variables explicitly passed mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true -nohup /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 & +nohup env \ + CLAUDE_PROXY_BACKEND="$CLAUDE_PROXY_BACKEND" \ + CLAUDE_PROXY_API_KEY="$CLAUDE_PROXY_API_KEY" \ + CLAUDE_PROXY_MODEL="$CLAUDE_PROXY_MODEL" \ + CLAUDE_PROXY_OPTIONS="$CLAUDE_PROXY_OPTIONS" \ + /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 & # Wait for startup for i in {1..20}; do diff --git a/sandbox/manager.go b/sandbox/manager.go index 161f0e72..7921bd5c 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -42,6 +42,8 @@ type demuxReadCloser struct { closer io.Closer done chan struct{} err error + closed bool + mu sync.Mutex } // newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream @@ -60,11 +62,20 @@ func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser { defer close(d.done) defer pw.Close() + fmt.Printf("[DEBUG demux] Starting stdcopy.StdCopy\n") + startTime := time.Now() + // Use stdcopy to demux stdout and stderr // We only care about stdout here, stderr goes to a discard writer - _, err := stdcopy.StdCopy(pw, io.Discard, src) + n, err := stdcopy.StdCopy(pw, io.Discard, src) + elapsed := time.Since(startTime) + + fmt.Printf("[DEBUG demux] stdcopy.StdCopy returned: bytes=%d, err=%v, elapsed=%v\n", n, err, elapsed) + if err != nil && err != io.EOF { + d.mu.Lock() d.err = err + d.mu.Unlock() } }() @@ -76,15 +87,39 @@ func (d *demuxReadCloser) Read(p []byte) (int, error) { } func (d *demuxReadCloser) Close() error { - // Close the source to stop the demux goroutine + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil + } + d.closed = true + d.mu.Unlock() + + // Close the pipe writer first to signal EOF to any readers + // This will cause pipeReader.Read() to return io.EOF + d.pipeWriter.CloseWithError(io.EOF) + + // Close the source connection to interrupt stdcopy.StdCopy if d.closer != nil { d.closer.Close() } + // Close the pipe reader to unblock any pending reads d.pipeReader.Close() - // Wait for demux goroutine to finish - <-d.done - return d.err + + // Wait for demux goroutine to finish with a timeout + // Don't block forever if stdcopy.StdCopy is stuck + select { + case <-d.done: + // Normal completion + case <-time.After(5 * time.Second): + fmt.Printf("[DEBUG demux] Timeout waiting for demux goroutine to finish\n") + } + + d.mu.Lock() + err := d.err + d.mu.Unlock() + return err } // Manager manages sandbox containers @@ -562,6 +597,39 @@ func (m *Manager) Remove(ctx context.Context, name string) error { return nil } +// KillProcess kills a process inside the container by name pattern +// This is used to forcefully stop long-running processes like Claude CLI +func (m *Manager) KillProcess(ctx context.Context, name string, processPattern string) error { + c, ok := m.containers.Load(name) + if !ok { + return ErrContainerNotFound + } + cont := c.(*Container) + + // Use pkill to kill processes matching the pattern + // -f matches against the full command line + // Use SIGKILL (-9) to ensure the process is killed immediately + cmd := []string{"pkill", "-9", "-f", processPattern} + + execConfig := container.ExecOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + } + + execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig) + if err != nil { + return fmt.Errorf("failed to create exec for kill: %w", err) + } + + // Start the exec + if err := m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}); err != nil { + return fmt.Errorf("failed to start exec for kill: %w", err) + } + + return nil +} + // List returns all containers for a user func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) { var result []*Container diff --git a/sandbox/proxy/README.md b/sandbox/proxy/README.md index fa2c6420..7d779629 100644 --- a/sandbox/proxy/README.md +++ b/sandbox/proxy/README.md @@ -153,13 +153,13 @@ claude --dangerously-skip-permissions "your task" ### Option Reference -| Option | Description | -|--------|-------------| -| `-p, --print` | Print mode, exit after output | -| `--dangerously-skip-permissions` | Skip all permission checks | -| `--permission-mode bypassPermissions` | Bypass permission mode | -| `--output-format stream-json` | Output JSON stream | -| `--verbose` | Verbose output (required for stream-json) | +| Option | Description | +| ------------------------------------- | ----------------------------------------- | +| `-p, --print` | Print mode, exit after output | +| `--dangerously-skip-permissions` | Skip all permission checks | +| `--permission-mode bypassPermissions` | Bypass permission mode | +| `--output-format stream-json` | Output JSON stream | +| `--verbose` | Verbose output (required for stream-json) | ## Viewing Logs @@ -173,13 +173,13 @@ curl http://127.0.0.1:3456/health ## Supported Backends -| Backend | API URL | -|---------|---------| -| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | -| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | -| DeepSeek Official | `https://api.deepseek.com/chat/completions` | -| OpenAI | `https://api.openai.com/v1/chat/completions` | -| Other OpenAI-compatible APIs | Custom URL | +| Backend | API URL | +| ---------------------------- | ----------------------------------------------------------- | +| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | +| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | +| DeepSeek Official | `https://api.deepseek.com/chat/completions` | +| OpenAI | `https://api.openai.com/v1/chat/completions` | +| Other OpenAI-compatible APIs | Custom URL | ## API Endpoints diff --git a/sandbox/proxy/convert.go b/sandbox/proxy/convert.go index 7a5bd58f..02879b43 100644 --- a/sandbox/proxy/convert.go +++ b/sandbox/proxy/convert.go @@ -3,25 +3,58 @@ package proxy import ( "encoding/json" "fmt" + "strings" ) // convertRequest converts an Anthropic request to OpenAI format func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest { - // Limit max_tokens to backend's maximum (most models support 16384) + // Get max_tokens from options if specified, otherwise use request value maxTokens := req.MaxTokens - if maxTokens > 16384 { - maxTokens = 16384 + if s.config.Options != nil { + if mt, ok := s.config.Options["max_tokens"]; ok { + switch v := mt.(type) { + case float64: + maxTokens = int(v) + case int: + maxTokens = v + } + } + } + + // Get temperature from options if specified + temperature := req.Temperature + if s.config.Options != nil { + if temp, ok := s.config.Options["temperature"]; ok { + if v, ok := temp.(float64); ok { + temperature = &v + } + } } openaiReq := &OpenAIRequest{ Model: s.config.Model, MaxTokens: maxTokens, Stream: req.Stream, - Temperature: req.Temperature, + Temperature: temperature, TopP: req.TopP, Stop: req.StopSequences, } + // Pass through extra options (e.g., thinking, reasoning_effort, etc.) + // These are backend-specific parameters that will be merged into the request + if s.config.Options != nil { + openaiReq.ExtraOptions = make(map[string]interface{}) + for k, v := range s.config.Options { + // Skip standard fields that are already handled + switch k { + case "max_tokens", "temperature", "model", "key", "proxy": + continue + default: + openaiReq.ExtraOptions[k] = v + } + } + } + // Convert messages openaiReq.Messages = s.convertMessages(req.Messages, req.System) @@ -302,12 +335,17 @@ func extractSystemText(system interface{}) string { for _, item := range s { if block, ok := item.(map[string]interface{}); ok { if text, ok := block["text"].(string); ok { + // Skip billing headers and other metadata + if strings.HasPrefix(text, "x-anthropic-") { + continue + } texts = append(texts, text) } } } + // Concatenate all system texts with newlines if len(texts) > 0 { - return texts[0] // Return first system text + return strings.Join(texts, "\n\n") } } return "" diff --git a/sandbox/proxy/main.go b/sandbox/proxy/main.go index fc572c0b..903ca969 100644 --- a/sandbox/proxy/main.go +++ b/sandbox/proxy/main.go @@ -26,6 +26,7 @@ type Config struct { Timeout int Verbose bool LogFile string + Options map[string]interface{} // Extra options to pass to backend (e.g., thinking, max_tokens) } // Server is the API proxy server @@ -58,6 +59,10 @@ func Main() { log.Printf("Claude API Proxy starting on %s", addr) log.Printf("Backend: %s", config.Backend) log.Printf("Model: %s", config.Model) + if len(config.Options) > 0 { + optBytes, _ := json.Marshal(config.Options) + log.Printf("Options: %s", string(optBytes)) + } http.HandleFunc("/v1/messages", server.handleMessages) http.HandleFunc("/health", server.handleHealth) @@ -118,6 +123,17 @@ func parseFlags() *Config { config.Timeout = 300 } + // Parse extra options from environment variable (JSON format) + // Example: CLAUDE_PROXY_OPTIONS='{"thinking":{"type":"enabled"},"max_tokens":65536}' + if optionsStr := os.Getenv("CLAUDE_PROXY_OPTIONS"); optionsStr != "" { + var options map[string]interface{} + if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { + log.Printf("Warning: failed to parse CLAUDE_PROXY_OPTIONS: %v", err) + } else { + config.Options = options + } + } + return config } diff --git a/sandbox/proxy/types.go b/sandbox/proxy/types.go index 296f1a0e..4a4f9e88 100644 --- a/sandbox/proxy/types.go +++ b/sandbox/proxy/types.go @@ -1,5 +1,7 @@ package proxy +import "encoding/json" + // ============================================ // Anthropic API Types // ============================================ @@ -126,6 +128,54 @@ type OpenAIRequest struct { Stop []string `json:"stop,omitempty"` Tools []OpenAITool `json:"tools,omitempty"` ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", "required", or object + + // Extra options for backend-specific parameters (e.g., thinking for GLM-4) + // These are merged into the final JSON request + ExtraOptions map[string]interface{} `json:"-"` +} + +// MarshalJSON custom marshaler to merge ExtraOptions into the request +func (r OpenAIRequest) MarshalJSON() ([]byte, error) { + // Create a map with standard fields + m := map[string]interface{}{ + "model": r.Model, + "messages": r.Messages, + } + + if r.MaxTokens > 0 { + m["max_tokens"] = r.MaxTokens + } + if r.Stream { + m["stream"] = r.Stream + } + if r.StreamOptions != nil { + m["stream_options"] = r.StreamOptions + } + if r.Temperature != nil { + m["temperature"] = *r.Temperature + } + if r.TopP != nil { + m["top_p"] = *r.TopP + } + if len(r.Stop) > 0 { + m["stop"] = r.Stop + } + if len(r.Tools) > 0 { + m["tools"] = r.Tools + } + if r.ToolChoice != nil { + m["tool_choice"] = r.ToolChoice + } + + // Merge extra options (backend-specific parameters like thinking, etc.) + for k, v := range r.ExtraOptions { + // Don't override standard fields + if _, exists := m[k]; !exists { + m[k] = v + } + } + + return json.Marshal(m) } // StreamOptions represents stream options in OpenAI format