yao/agent/sandbox/claude/command.go
Max 702b743de7 Implement continuation support for Claude CLI commands
- Refactor BuildCommand to support session continuation using --continue flag.
- Introduce BuildCommandWithContinuation to handle both initial and continuation requests.
- Add BuildLastUserMessageJSONL for generating JSONL from the last user message in continuation scenarios.
- Enhance Executor to check for existing sessions and adjust command building accordingly.
- Update environment setup to ensure session data is stored correctly for persistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-01 13:07:18 +08:00

359 lines
12 KiB
Go

package claude
import (
"encoding/json"
"fmt"
"strings"
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
// 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")
}
// 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")
// 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
// 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
bashCmd.WriteString("cat << 'INPUTEOF' | claude -p")
for _, arg := range claudeArgs {
// Quote arguments that might contain special characters
bashCmd.WriteString(fmt.Sprintf(" %q", arg))
}
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"
// claude-proxy runs on localhost:3456, Claude CLI connects to it
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
// 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")
}
// Build backend URL - ensure it ends with /chat/completions
backendURL := opts.ConnectorHost
if !strings.HasSuffix(backendURL, "/chat/completions") {
backendURL = strings.TrimSuffix(backendURL, "/") + "/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)
}