- Introduced a new "Heavy" role in the system configuration, allowing for complex reasoning tasks. - Updated the initialization and environment resolution functions to handle the new Heavy role appropriately. - Enhanced tests to validate the integration of the Heavy role, ensuring proper connector resolution and model handling. - Adjusted OpenAPI settings to reflect the addition of the Heavy role, improving overall system capabilities.
422 lines
15 KiB
Go
422 lines
15 KiB
Go
package claude
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/yaoapp/gou/connector"
|
|
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.
|
|
|
|
## User Attachments
|
|
|
|
User-uploaded files (images, documents, code files, etc.) are placed in /workspace/.attachments/
|
|
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.
|
|
|
|
## 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
|
|
`
|
|
|
|
// claudeArgWhitelist maps package.yao sandbox.arguments keys to Claude CLI flags.
|
|
// Only keys listed here are passed through; everything else is ignored.
|
|
var claudeArgWhitelist = map[string]string{
|
|
"max_turns": "--max-turns", // Maximum conversation turns
|
|
"disallowed_tools": "--disallowed-tools", // Comma-separated tool blacklist (e.g. "WebSearch,WebFetch")
|
|
"allowed_tools": "--allowedTools", // Comma-separated tool whitelist (e.g. "Bash,Read,Write")
|
|
}
|
|
|
|
// BuildCommand builds the Claude CLI command and environment variables
|
|
// Uses stdin with --input-format stream-json for unlimited prompt length
|
|
// isContinuation: if true, uses --continue to resume previous session (only sends last user message)
|
|
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
|
return BuildCommandWithContinuation(messages, opts, false)
|
|
}
|
|
|
|
// BuildCommandWithContinuation builds the Claude CLI command with continuation support
|
|
// isContinuation: if true, uses --continue to resume previous session
|
|
func BuildCommandWithContinuation(messages []agentContext.Message, opts *Options, isContinuation bool) ([]string, map[string]string, error) {
|
|
// Build system prompt from conversation history (only for first request)
|
|
var systemPrompt string
|
|
if !isContinuation {
|
|
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)
|
|
// For continuation, only send the last user message
|
|
var inputJSONL []byte
|
|
var err error
|
|
if isContinuation {
|
|
inputJSONL, err = BuildLastUserMessageJSONL(messages)
|
|
} else {
|
|
inputJSONL, err = BuildFirstRequestJSONL(messages)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
|
|
}
|
|
|
|
// Build Claude CLI arguments
|
|
var claudeArgs []string
|
|
|
|
// Add permission mode (required for MCP tools to work)
|
|
permMode := "bypassPermissions" // default for sandbox
|
|
if opts != nil && opts.Arguments != nil {
|
|
if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" {
|
|
permMode = mode
|
|
}
|
|
}
|
|
claudeArgs = append(claudeArgs, "--dangerously-skip-permissions")
|
|
claudeArgs = append(claudeArgs, "--permission-mode", permMode)
|
|
|
|
// 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")
|
|
|
|
// For continuation, use --continue to resume the previous session
|
|
// Claude CLI will read session data from $HOME/.claude/ (which is /workspace/.claude/)
|
|
if isContinuation {
|
|
claudeArgs = append(claudeArgs, "--continue")
|
|
}
|
|
|
|
// Pass through whitelisted arguments to Claude CLI flags.
|
|
// Map: package.yao arguments key → Claude CLI flag
|
|
if opts != nil && opts.Arguments != nil {
|
|
for key, flag := range claudeArgWhitelist {
|
|
if val, ok := opts.Arguments[key]; ok {
|
|
claudeArgs = append(claudeArgs, flag, fmt.Sprintf("%v", val))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add MCP config if available
|
|
if opts != nil && len(opts.MCPConfig) > 0 {
|
|
claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json")
|
|
// Allow all tools from the "yao" MCP server
|
|
claudeArgs = append(claudeArgs, "--allowedTools", "mcp__yao__*")
|
|
}
|
|
|
|
// Build the full bash command
|
|
// 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
|
|
|
|
// Ensure $HOME/.Xauthority exists for PyAutoGUI/Xlib (HOME=/workspace).
|
|
// Xvfb runs without auth, but Xlib requires the file to exist.
|
|
bashCmd.WriteString("touch /home/sandbox/.Xauthority 2>/dev/null; touch \"$HOME/.Xauthority\" 2>/dev/null\n")
|
|
|
|
// If we have a system prompt (first request only), 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
|
|
// Append 2>&1 to the claude command so stderr is merged into stdout;
|
|
// Docker's stdcopy discards the stderr stream, making errors invisible.
|
|
bashCmd.WriteString("cat << 'INPUTEOF' | claude -p")
|
|
for _, arg := range claudeArgs {
|
|
bashCmd.WriteString(fmt.Sprintf(" %q", arg))
|
|
}
|
|
bashCmd.WriteString(" 2>&1")
|
|
bashCmd.WriteString("\n")
|
|
bashCmd.WriteString(string(inputJSONL))
|
|
bashCmd.WriteString("\nINPUTEOF")
|
|
|
|
cmd := []string{"bash", "-c", bashCmd.String()}
|
|
|
|
// Build environment variables
|
|
env := buildEnvironment(opts, systemPrompt)
|
|
|
|
return cmd, env, nil
|
|
}
|
|
|
|
// BuildInputJSONL converts messages to Claude CLI stream-json input format
|
|
// Deprecated: Use BuildFirstRequestJSONL or BuildLastUserMessageJSONL instead
|
|
func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
|
return BuildFirstRequestJSONL(messages)
|
|
}
|
|
|
|
// BuildFirstRequestJSONL builds JSONL for the first request (all messages)
|
|
// Sends all user and assistant messages to establish context
|
|
func BuildFirstRequestJSONL(messages []agentContext.Message) ([]byte, error) {
|
|
var lines []string
|
|
|
|
for _, msg := range messages {
|
|
// Skip system messages (handled via --system-prompt)
|
|
if msg.Role == "system" {
|
|
continue
|
|
}
|
|
|
|
// Build the message content
|
|
var content interface{}
|
|
if msg.Content != nil {
|
|
content = msg.Content
|
|
} else {
|
|
content = ""
|
|
}
|
|
|
|
// Create stream-json message
|
|
streamMsg := map[string]interface{}{
|
|
"type": string(msg.Role), // "user" or "assistant"
|
|
"message": map[string]interface{}{
|
|
"role": string(msg.Role),
|
|
"content": content,
|
|
},
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(streamMsg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
|
}
|
|
lines = append(lines, string(jsonBytes))
|
|
}
|
|
|
|
return []byte(strings.Join(lines, "\n")), nil
|
|
}
|
|
|
|
// BuildLastUserMessageJSONL builds JSONL with only the last user message
|
|
// Used for continuation requests where Claude CLI manages history via --continue
|
|
func BuildLastUserMessageJSONL(messages []agentContext.Message) ([]byte, error) {
|
|
// Find the last user message
|
|
var lastUserMessage *agentContext.Message
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i].Role == "user" {
|
|
lastUserMessage = &messages[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if lastUserMessage == nil {
|
|
return nil, fmt.Errorf("no user message found")
|
|
}
|
|
|
|
var content interface{}
|
|
if lastUserMessage.Content != nil {
|
|
content = lastUserMessage.Content
|
|
} else {
|
|
content = ""
|
|
}
|
|
|
|
userMsg := map[string]interface{}{
|
|
"type": "user",
|
|
"message": map[string]interface{}{
|
|
"role": "user",
|
|
"content": content,
|
|
},
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(userMsg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal user message: %w", err)
|
|
}
|
|
|
|
return jsonBytes, nil
|
|
}
|
|
|
|
// buildPrompts extracts system prompt and user prompt from messages
|
|
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
|
var systemParts []string
|
|
var conversationParts []string
|
|
var lastUserMessage string
|
|
|
|
for _, msg := range messages {
|
|
switch msg.Role {
|
|
case "system":
|
|
systemParts = append(systemParts, getMessageContent(msg))
|
|
case "user":
|
|
lastUserMessage = getMessageContent(msg)
|
|
conversationParts = append(conversationParts, fmt.Sprintf("User: %s", lastUserMessage))
|
|
case "assistant":
|
|
conversationParts = append(conversationParts, fmt.Sprintf("Assistant: %s", getMessageContent(msg)))
|
|
}
|
|
}
|
|
|
|
// Build system prompt with conversation history
|
|
systemPrompt = strings.Join(systemParts, "\n\n")
|
|
|
|
// If there's conversation history, include it in the system prompt
|
|
if len(conversationParts) > 1 {
|
|
historySection := "\n\n## Conversation History\n\n" + strings.Join(conversationParts[:len(conversationParts)-1], "\n\n")
|
|
systemPrompt += historySection
|
|
}
|
|
|
|
// The user prompt is the last user message
|
|
userPrompt = lastUserMessage
|
|
|
|
return systemPrompt, userPrompt
|
|
}
|
|
|
|
// getMessageContent extracts text content from a message
|
|
func getMessageContent(msg agentContext.Message) string {
|
|
if msg.Content == nil {
|
|
return ""
|
|
}
|
|
|
|
// Handle string content
|
|
if str, ok := msg.Content.(string); ok {
|
|
return str
|
|
}
|
|
|
|
// Handle content array (multimodal messages)
|
|
if arr, ok := msg.Content.([]interface{}); ok {
|
|
var parts []string
|
|
for _, item := range arr {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
if m["type"] == "text" {
|
|
if text, ok := m["text"].(string); ok {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// buildEnvironment builds environment variables for Claude CLI
|
|
func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
|
env := make(map[string]string)
|
|
|
|
if opts == nil {
|
|
return env
|
|
}
|
|
|
|
// Set HOME to /workspace so Claude CLI stores session data in the workspace
|
|
// This allows session persistence across requests for the same chat
|
|
// Session data is stored in $HOME/.claude/ (i.e., /workspace/.claude/)
|
|
env["HOME"] = "/workspace"
|
|
|
|
// Fix Python user-site-packages: changing HOME from /home/sandbox to /workspace
|
|
// breaks Python's ability to find packages installed via pip --user (e.g., playwright,
|
|
// pyautogui, playwright-stealth) which live in /home/sandbox/.local/lib/pythonX.Y/site-packages/
|
|
env["PYTHONPATH"] = "/home/sandbox/.local/lib/python3.12/site-packages"
|
|
|
|
// Fix X11 auth: PyAutoGUI/Xlib looks for $HOME/.Xauthority, but HOME=/workspace
|
|
// so it fails to find /home/sandbox/.Xauthority created during image build.
|
|
// Explicitly set XAUTHORITY to the correct path.
|
|
env["XAUTHORITY"] = "/home/sandbox/.Xauthority"
|
|
|
|
if opts.ConnectorType == "anthropic" {
|
|
// Anthropic mode: Claude CLI connects directly to the Anthropic-compatible backend
|
|
// No proxy needed — the backend already speaks Anthropic Messages API
|
|
env["ANTHROPIC_BASE_URL"] = opts.ConnectorHost
|
|
env["ANTHROPIC_API_KEY"] = opts.ConnectorKey
|
|
} else {
|
|
// OpenAI mode (default): Claude CLI connects to claude-proxy on localhost:3456
|
|
// The proxy translates Anthropic Messages API → OpenAI Chat Completions API
|
|
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
|
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
|
}
|
|
|
|
// Set model environment variables from connector
|
|
// Claude CLI uses these to select the model for all roles
|
|
if opts.Model != "" {
|
|
env["ANTHROPIC_MODEL"] = opts.Model
|
|
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = opts.Model
|
|
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = opts.Model
|
|
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = opts.Model
|
|
env["CLAUDE_CODE_SUBAGENT_MODEL"] = opts.Model
|
|
}
|
|
|
|
// Pass secrets as environment variables for Claude CLI to use
|
|
// These are configured in package.yao sandbox.secrets (e.g., LLM_API_KEY, GITHUB_TOKEN)
|
|
// start-claude-proxy also exports them for the proxy process, but Claude CLI
|
|
// is launched via a separate docker exec, so it needs them passed explicitly here.
|
|
if len(opts.Secrets) > 0 {
|
|
for k, v := range opts.Secrets {
|
|
env[k] = v
|
|
}
|
|
}
|
|
|
|
// Prevent Claude CLI from using an excessive max_tokens that the backend
|
|
// API will reject. In OpenAI-proxy mode the hardcoded model is
|
|
// claude-sonnet-4-6 whose limit is 16384.
|
|
if opts.ConnectorType != "anthropic" {
|
|
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = "16384"
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Build backend URL using the shared connector.BuildAPIURL helper
|
|
// so that the /v1 prefix is applied consistently with the agent LLM path.
|
|
backendURL := connector.BuildAPIURL(opts.ConnectorHost, "/chat/completions")
|
|
|
|
config := map[string]interface{}{
|
|
"backend": backendURL,
|
|
"api_key": opts.ConnectorKey,
|
|
"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, "", " ")
|
|
}
|
|
|
|
// BuildCCRConfig is deprecated, kept for backward compatibility
|
|
// Use BuildProxyConfig instead
|
|
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
|
return BuildProxyConfig(opts)
|
|
}
|