- Modify the expected API URL in the `TestBuildProxyConfig` to include the `/v1` prefix for consistency with the updated URL building logic. - Remove unnecessary `modified` flags in the `prepareAttachments` method to streamline attachment processing and ensure clarity in the codebase. This change enhances the accuracy of tests and improves the maintainability of the attachment handling logic.
1409 lines
43 KiB
Go
1409 lines
43 KiB
Go
package claude
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"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"
|
|
"github.com/yaoapp/yao/attachment"
|
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
|
"github.com/yaoapp/yao/sandbox/ipc"
|
|
)
|
|
|
|
// 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
|
|
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
|
|
type Executor struct {
|
|
manager *infraSandbox.Manager
|
|
containerName string
|
|
opts *Options
|
|
workDir string
|
|
loadingMsgID string // Loading message ID for tool execution updates
|
|
}
|
|
|
|
// NewExecutor creates a new Claude executor
|
|
func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, error) {
|
|
if manager == nil {
|
|
return nil, fmt.Errorf("manager is required")
|
|
}
|
|
|
|
// Type assertion to get options
|
|
var execOpts *Options
|
|
switch o := opts.(type) {
|
|
case *Options:
|
|
execOpts = o
|
|
default:
|
|
// Try to convert from map or other struct
|
|
return nil, fmt.Errorf("invalid options type: %T", opts)
|
|
}
|
|
|
|
if execOpts == nil {
|
|
return nil, fmt.Errorf("options is required")
|
|
}
|
|
if execOpts.UserID == "" {
|
|
return nil, fmt.Errorf("UserID is required")
|
|
}
|
|
if execOpts.ChatID == "" {
|
|
return nil, fmt.Errorf("ChatID is required")
|
|
}
|
|
|
|
// Create or get container
|
|
// Note: IPC session is created by manager.createContainer, socket is already bind mounted
|
|
ctx := context.Background()
|
|
createOpts := infraSandbox.CreateOptions{
|
|
UserID: execOpts.UserID,
|
|
ChatID: execOpts.ChatID,
|
|
Image: execOpts.Image,
|
|
}
|
|
container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID, createOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create container: %w", err)
|
|
}
|
|
|
|
// Get workspace directory from config
|
|
config := manager.GetConfig()
|
|
workDir := config.ContainerWorkDir
|
|
if workDir == "" {
|
|
workDir = "/workspace"
|
|
}
|
|
|
|
return &Executor{
|
|
manager: manager,
|
|
containerName: container.Name,
|
|
opts: execOpts,
|
|
workDir: workDir,
|
|
}, 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) {
|
|
// 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 {
|
|
ipcManager := e.manager.GetIPCManager()
|
|
if ipcManager != nil {
|
|
if session, ok := ipcManager.Get(e.opts.ChatID); ok {
|
|
session.SetMCPTools(e.opts.MCPTools)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prepare environment: write configs and copy skills
|
|
if err := e.prepareEnvironment(stdCtx); err != nil {
|
|
return nil, fmt.Errorf("failed to prepare environment: %w", err)
|
|
}
|
|
|
|
// Resolve attachment URLs and write files to container
|
|
// This converts __yao.attachment:// URLs to local file paths in /workspace/.attachments/
|
|
if resolved, attErr := e.prepareAttachments(stdCtx, messages); attErr != nil {
|
|
// Non-fatal: log warning and continue with original messages
|
|
log.Printf("[sandbox] Warning: failed to prepare attachments: %v", attErr)
|
|
} else {
|
|
messages = resolved
|
|
}
|
|
|
|
// Check if we should skip Claude CLI execution
|
|
// Skip if no prompts, no skills, and no MCP config
|
|
if e.shouldSkipClaudeCLI() {
|
|
// Return empty response - hooks can use sandbox API to do their work
|
|
return &agentContext.CompletionResponse{
|
|
ID: fmt.Sprintf("sandbox-skip-%d", time.Now().UnixNano()),
|
|
Model: "sandbox",
|
|
Created: time.Now().Unix(),
|
|
Role: "assistant",
|
|
Content: "",
|
|
FinishReason: agentContext.FinishReasonStop,
|
|
}, nil
|
|
}
|
|
|
|
// Check if this is a continuation (Claude CLI session exists in workspace)
|
|
isContinuation := e.hasExistingSession(stdCtx)
|
|
|
|
// Build Claude CLI command using stored options
|
|
cmd, env, err := BuildCommandWithContinuation(messages, e.opts, isContinuation)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build command: %w", err)
|
|
}
|
|
|
|
// Prepare execution options
|
|
execOpts := &infraSandbox.ExecOptions{
|
|
WorkDir: e.workDir,
|
|
Env: env,
|
|
}
|
|
|
|
if e.opts != nil && e.opts.Timeout > 0 {
|
|
execOpts.Timeout = e.opts.Timeout
|
|
}
|
|
|
|
reader, err := e.manager.Stream(stdCtx, e.containerName, cmd, execOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute command: %w", err)
|
|
}
|
|
|
|
// 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
|
|
// Skip when: no system prompt, no skills, and no MCP config
|
|
func (e *Executor) shouldSkipClaudeCLI() bool {
|
|
hasPrompts := e.opts.SystemPrompt != ""
|
|
hasSkills := e.opts.SkillsDir != ""
|
|
hasMCP := len(e.opts.MCPConfig) > 0
|
|
|
|
// If any of these are present, execute Claude CLI
|
|
return !hasPrompts && !hasSkills && !hasMCP
|
|
}
|
|
|
|
// hasExistingSession checks if Claude CLI has an existing session in the workspace
|
|
// Claude CLI stores session data in $HOME/.claude/projects/ (which is /workspace/.claude/projects/)
|
|
// If session data exists, we should use --continue to resume the session
|
|
func (e *Executor) hasExistingSession(ctx context.Context) bool {
|
|
// Check if /workspace/.claude/projects/ directory has any content
|
|
// This indicates a previous session exists
|
|
sessionDir := e.workDir + "/.claude/projects"
|
|
files, err := e.manager.ListDir(ctx, e.containerName, sessionDir)
|
|
if err != nil {
|
|
// Directory doesn't exist or error reading - no existing session
|
|
return false
|
|
}
|
|
// If there are any files/directories in the projects folder, session exists
|
|
return len(files) > 0
|
|
}
|
|
|
|
// prepareEnvironment prepares the container environment before execution
|
|
// This includes: claude-proxy config, MCP config, and Skills directory
|
|
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
|
// 1. Write claude-proxy config and start the proxy
|
|
if err := e.startClaudeProxy(ctx); err != nil {
|
|
return fmt.Errorf("failed to start claude-proxy: %w", err)
|
|
}
|
|
|
|
// 2. Write MCP config if provided
|
|
if len(e.opts.MCPConfig) > 0 {
|
|
if err := e.writeMCPConfig(ctx); err != nil {
|
|
return fmt.Errorf("failed to write MCP config: %w", err)
|
|
}
|
|
}
|
|
|
|
// 3. Copy Skills directory if provided
|
|
if e.opts.SkillsDir != "" {
|
|
if err := e.copySkillsDirectory(ctx); err != nil {
|
|
// Non-fatal: log warning but continue
|
|
// Skills might not exist or be optional
|
|
_ = err // Ignore error, skills are optional
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// startClaudeProxy writes proxy config and starts claude-proxy
|
|
func (e *Executor) startClaudeProxy(ctx context.Context) error {
|
|
// Skip if no connector configured (e.g., test containers without claude-proxy)
|
|
if e.opts.ConnectorHost == "" || e.opts.ConnectorKey == "" {
|
|
return nil
|
|
}
|
|
|
|
// Build proxy config
|
|
configJSON, err := BuildProxyConfig(e.opts)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build proxy config: %w", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Start the proxy (only if start-claude-proxy exists in the image)
|
|
result, err := e.manager.Exec(ctx, e.containerName, []string{"which", "start-claude-proxy"}, &infraSandbox.ExecOptions{
|
|
WorkDir: e.workDir,
|
|
})
|
|
if err != nil || result.ExitCode != 0 {
|
|
// start-claude-proxy not available (e.g., alpine test image), skip
|
|
return nil
|
|
}
|
|
|
|
// Start the proxy
|
|
result, err = e.manager.Exec(ctx, e.containerName, []string{"start-claude-proxy"}, &infraSandbox.ExecOptions{
|
|
WorkDir: e.workDir,
|
|
Env: map[string]string{
|
|
"WORKSPACE": e.workDir,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start claude-proxy: %w", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
return fmt.Errorf("claude-proxy failed to start: %s", result.Stderr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// writeMCPConfig writes the MCP configuration file to the container workspace
|
|
func (e *Executor) writeMCPConfig(ctx context.Context) error {
|
|
if len(e.opts.MCPConfig) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Write MCP config to workspace (.mcp.json)
|
|
mcpPath := e.workDir + "/.mcp.json"
|
|
if err := e.manager.WriteFile(ctx, e.containerName, mcpPath, e.opts.MCPConfig); err != nil {
|
|
return fmt.Errorf("failed to write MCP config to %s: %w", mcpPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copySkillsDirectory copies the skills directory to the container
|
|
func (e *Executor) copySkillsDirectory(ctx context.Context) error {
|
|
if e.opts.SkillsDir == "" {
|
|
return nil
|
|
}
|
|
|
|
// Target path in container: /workspace/.claude/skills/
|
|
// This follows Claude CLI's expected skills location
|
|
claudeDir := e.workDir + "/.claude"
|
|
|
|
// Create .claude directory first
|
|
if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", claudeDir}, nil); err != nil {
|
|
return fmt.Errorf("failed to create .claude directory: %w", err)
|
|
}
|
|
|
|
// Copy skills from host to container
|
|
// CopyToContainer extracts tar to containerPath, and createTarFromPath uses
|
|
// filepath.Dir(hostPath) as base, so if hostPath is /path/to/skills,
|
|
// tar entries are like "skills/skill-name/SKILL.md"
|
|
// Extracting to /workspace/.claude/ gives us /workspace/.claude/skills/skill-name/SKILL.md
|
|
if err := e.manager.CopyToContainer(ctx, e.containerName, e.opts.SkillsDir, claudeDir); err != nil {
|
|
return fmt.Errorf("failed to copy skills to container: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// prepareAttachments resolves __yao.attachment:// URLs in messages,
|
|
// writes the actual files to the container's /workspace/.attachments/ directory,
|
|
// and replaces the attachment content parts with text references to the file paths.
|
|
// This allows Claude CLI to read the files using its built-in Read/Bash tools.
|
|
func (e *Executor) prepareAttachments(ctx context.Context, messages []agentContext.Message) ([]agentContext.Message, error) {
|
|
// Track used filenames to handle duplicates
|
|
usedNames := make(map[string]int)
|
|
attachmentDir := e.workDir + "/.attachments"
|
|
dirCreated := false
|
|
hasAttachments := false
|
|
|
|
result := make([]agentContext.Message, len(messages))
|
|
copy(result, messages)
|
|
|
|
for i, msg := range result {
|
|
if msg.Role != "user" {
|
|
continue
|
|
}
|
|
|
|
// Handle content array (multimodal messages come as []interface{} from JSON)
|
|
parts, ok := msg.Content.([]interface{})
|
|
if !ok {
|
|
// Try typed content parts
|
|
if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok {
|
|
iparts := make([]interface{}, len(typedParts))
|
|
for j, p := range typedParts {
|
|
// Convert to map for uniform handling
|
|
m := map[string]interface{}{"type": string(p.Type)}
|
|
if p.Text != "" {
|
|
m["text"] = p.Text
|
|
}
|
|
if p.ImageURL != nil {
|
|
m["image_url"] = map[string]interface{}{
|
|
"url": p.ImageURL.URL,
|
|
"detail": string(p.ImageURL.Detail),
|
|
}
|
|
}
|
|
if p.File != nil {
|
|
m["file"] = map[string]interface{}{
|
|
"url": p.File.URL,
|
|
"filename": p.File.Filename,
|
|
}
|
|
}
|
|
iparts[j] = m
|
|
}
|
|
parts = iparts
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Process each content part
|
|
var textParts []string
|
|
|
|
for _, item := range parts {
|
|
m, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
partType, _ := m["type"].(string)
|
|
|
|
switch partType {
|
|
case "text":
|
|
if text, ok := m["text"].(string); ok && text != "" {
|
|
textParts = append(textParts, text)
|
|
}
|
|
|
|
case "image_url":
|
|
imgData, _ := m["image_url"].(map[string]interface{})
|
|
if imgData == nil {
|
|
continue
|
|
}
|
|
url, _ := imgData["url"].(string)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
|
|
uploaderName, fileID, isWrapper := attachment.Parse(url)
|
|
if !isWrapper {
|
|
// Not an attachment URL, keep as text reference
|
|
textParts = append(textParts, fmt.Sprintf("[Image: %s]", url))
|
|
continue
|
|
}
|
|
|
|
// Resolve the attachment
|
|
ref, err := e.resolveAttachment(ctx, uploaderName, fileID, "", attachmentDir, usedNames, &dirCreated)
|
|
if err != nil {
|
|
log.Printf("[sandbox] Warning: failed to resolve image attachment %s: %v", fileID, err)
|
|
textParts = append(textParts, "[Attached image: failed to load]")
|
|
continue
|
|
}
|
|
|
|
textParts = append(textParts, ref)
|
|
hasAttachments = true
|
|
|
|
case "file":
|
|
fileData, _ := m["file"].(map[string]interface{})
|
|
if fileData == nil {
|
|
continue
|
|
}
|
|
url, _ := fileData["url"].(string)
|
|
hintName, _ := fileData["filename"].(string)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
|
|
uploaderName, fileID, isWrapper := attachment.Parse(url)
|
|
if !isWrapper {
|
|
textParts = append(textParts, fmt.Sprintf("[File: %s]", url))
|
|
continue
|
|
}
|
|
|
|
ref, err := e.resolveAttachment(ctx, uploaderName, fileID, hintName, attachmentDir, usedNames, &dirCreated)
|
|
if err != nil {
|
|
log.Printf("[sandbox] Warning: failed to resolve file attachment %s: %v", fileID, err)
|
|
textParts = append(textParts, "[Attached file: failed to load]")
|
|
continue
|
|
}
|
|
|
|
textParts = append(textParts, ref)
|
|
hasAttachments = true
|
|
|
|
default:
|
|
// Keep other types as-is (shouldn't happen normally)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Merge text parts into a single string when the original content was
|
|
// a multimodal array ([]interface{} / []ContentPart). This is needed
|
|
// even when only "text" parts are present so that downstream code
|
|
// (BuildInputJSONL, etc.) always sees a plain string.
|
|
if len(textParts) > 0 {
|
|
newMsg := result[i]
|
|
newMsg.Content = strings.Join(textParts, "\n\n")
|
|
result[i] = newMsg
|
|
}
|
|
}
|
|
|
|
if !hasAttachments {
|
|
return result, nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// resolveAttachment reads an attachment from the attachment manager and writes it
|
|
// to the container's .attachments directory. Returns a text reference string.
|
|
func (e *Executor) resolveAttachment(
|
|
ctx context.Context,
|
|
uploaderName, fileID, hintName, attachmentDir string,
|
|
usedNames map[string]int,
|
|
dirCreated *bool,
|
|
) (string, error) {
|
|
// Get attachment manager
|
|
manager, exists := attachment.Managers[uploaderName]
|
|
if !exists {
|
|
return "", fmt.Errorf("attachment manager not found: %s", uploaderName)
|
|
}
|
|
|
|
// Get file info
|
|
fileInfo, err := manager.Info(ctx, fileID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get file info: %w", err)
|
|
}
|
|
|
|
// Read file data
|
|
data, err := manager.Read(ctx, fileID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
// Determine filename
|
|
filename := fileInfo.Filename
|
|
if filename == "" && hintName != "" {
|
|
filename = hintName
|
|
}
|
|
if filename == "" {
|
|
// Fallback: use fileID with extension from content type
|
|
ext := extensionFromContentType(fileInfo.ContentType)
|
|
filename = fileID + ext
|
|
}
|
|
|
|
// Handle duplicate filenames
|
|
baseName := filename
|
|
if count, exists := usedNames[baseName]; exists {
|
|
ext := filepath.Ext(filename)
|
|
name := strings.TrimSuffix(filename, ext)
|
|
filename = fmt.Sprintf("%s_%d%s", name, count+1, ext)
|
|
usedNames[baseName] = count + 1
|
|
} else {
|
|
usedNames[baseName] = 0
|
|
}
|
|
|
|
// Create attachments directory if not yet created
|
|
if !*dirCreated {
|
|
if err := e.manager.WriteFile(ctx, e.containerName, attachmentDir+"/.keep", []byte("")); err != nil {
|
|
return "", fmt.Errorf("failed to create attachments directory: %w", err)
|
|
}
|
|
*dirCreated = true
|
|
}
|
|
|
|
// Write file to container
|
|
containerPath := attachmentDir + "/" + filename
|
|
if err := e.manager.WriteFile(ctx, e.containerName, containerPath, data); err != nil {
|
|
return "", fmt.Errorf("failed to write file to container: %w", err)
|
|
}
|
|
|
|
// Build human-readable size string
|
|
sizeStr := formatFileSize(fileInfo.Bytes)
|
|
|
|
// Return text reference
|
|
return fmt.Sprintf("[Attached file: %s (%s, %s)]", containerPath, fileInfo.ContentType, sizeStr), nil
|
|
}
|
|
|
|
// extensionFromContentType returns a file extension for a given content type
|
|
func extensionFromContentType(contentType string) string {
|
|
switch contentType {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/svg+xml":
|
|
return ".svg"
|
|
case "application/pdf":
|
|
return ".pdf"
|
|
case "text/plain":
|
|
return ".txt"
|
|
case "text/html":
|
|
return ".html"
|
|
case "text/css":
|
|
return ".css"
|
|
case "text/javascript", "application/javascript":
|
|
return ".js"
|
|
case "application/json":
|
|
return ".json"
|
|
case "application/zip":
|
|
return ".zip"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// formatFileSize returns a human-readable file size string
|
|
func formatFileSize(bytes int) string {
|
|
if bytes < 1024 {
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
if bytes < 1024*1024 {
|
|
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
|
|
}
|
|
return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
|
|
}
|
|
|
|
// Execute runs the Claude CLI and returns the response
|
|
func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) {
|
|
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 with --include-partial-messages:
|
|
// - {"type":"system","subtype":"init",...} - initialization
|
|
// - {"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(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)
|
|
scanner.Buffer(buf, 1024*1024)
|
|
|
|
var textContent strings.Builder
|
|
var toolCalls []agentContext.ToolCall
|
|
var model string
|
|
var usage *message.UsageInfo
|
|
var finalResult string
|
|
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
|
|
}
|
|
|
|
// Try to parse as JSON (Claude CLI --output-format stream-json)
|
|
var msg map[string]interface{}
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
// Not JSON, might be plain text output
|
|
textContent.WriteString(line)
|
|
textContent.WriteString("\n")
|
|
continue
|
|
}
|
|
|
|
msgType, _ := msg["type"].(string)
|
|
|
|
// Process Claude CLI stream-json message types
|
|
switch msgType {
|
|
case "system":
|
|
// Initialization message - extract model if available
|
|
if m, ok := msg["model"].(string); ok {
|
|
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":
|
|
// Handle new content blocks
|
|
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use"|"text",...}}}
|
|
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
|
|
blockType, _ := contentBlock["type"].(string)
|
|
switch blockType {
|
|
case "text":
|
|
// New text block starting - add paragraph separator if we already have content
|
|
// This ensures proper separation between text blocks across tool-use rounds
|
|
if textContent.Len() > 0 {
|
|
textContent.WriteString("\n\n")
|
|
if handler != nil && messageStarted {
|
|
handler(message.ChunkText, []byte("\n\n"))
|
|
}
|
|
}
|
|
case "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
|
|
// 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 == "" {
|
|
model = m
|
|
}
|
|
|
|
// Check if this is the final message (has stop_reason)
|
|
stopReason, hasStopReason := msgData["stop_reason"].(string)
|
|
isFinalMessage := hasStopReason && stopReason != ""
|
|
|
|
// 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 {
|
|
if contentItem, ok := item.(map[string]interface{}); ok {
|
|
itemType, _ := contentItem["type"].(string)
|
|
|
|
switch itemType {
|
|
case "text":
|
|
// 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
|
|
}
|
|
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: toolName,
|
|
},
|
|
}
|
|
// Get input as JSON string
|
|
var inputJSONStr string
|
|
if input, ok := contentItem["input"]; ok {
|
|
if inputJSON, err := json.Marshal(input); err == nil {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract usage (from any message that has it)
|
|
if usageData, ok := msgData["usage"].(map[string]interface{}); ok {
|
|
usage = &message.UsageInfo{}
|
|
if v, ok := usageData["input_tokens"].(float64); ok {
|
|
usage.PromptTokens = int(v)
|
|
}
|
|
if v, ok := usageData["output_tokens"].(float64); ok {
|
|
usage.CompletionTokens = int(v)
|
|
}
|
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
|
}
|
|
}
|
|
|
|
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 and not an error)
|
|
if handler != nil && messageStarted && !isError {
|
|
handler(message.ChunkMessageEnd, nil)
|
|
}
|
|
|
|
case "error":
|
|
// Error message
|
|
if errMsg, ok := msg["error"].(string); ok {
|
|
return nil, fmt.Errorf("Claude CLI error: %s", errMsg)
|
|
}
|
|
if errObj, ok := msg["error"].(map[string]interface{}); ok {
|
|
if errMsg, ok := errObj["message"].(string); ok {
|
|
return nil, fmt.Errorf("Claude CLI error: %s", errMsg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
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 == "" {
|
|
content = finalResult
|
|
}
|
|
|
|
// Build response
|
|
response := &agentContext.CompletionResponse{
|
|
ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
|
Model: model,
|
|
Created: time.Now().Unix(),
|
|
Role: "assistant",
|
|
Content: content,
|
|
FinishReason: agentContext.FinishReasonStop,
|
|
}
|
|
|
|
// Add tool calls if any
|
|
if len(toolCalls) > 0 {
|
|
response.ToolCalls = toolCalls
|
|
response.FinishReason = agentContext.FinishReasonToolCalls
|
|
}
|
|
|
|
// Add usage if available
|
|
if usage != nil {
|
|
response.Usage = usage
|
|
}
|
|
|
|
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
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = e.workDir + "/" + path
|
|
}
|
|
return e.manager.ReadFile(ctx, e.containerName, path)
|
|
}
|
|
|
|
// WriteFile writes content to a file in the container
|
|
func (e *Executor) WriteFile(ctx context.Context, path string, content []byte) error {
|
|
// Make path absolute if not
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = e.workDir + "/" + path
|
|
}
|
|
return e.manager.WriteFile(ctx, e.containerName, path, content)
|
|
}
|
|
|
|
// ListDir lists directory contents in the container
|
|
func (e *Executor) ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error) {
|
|
// Make path absolute if not
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = e.workDir + "/" + path
|
|
}
|
|
|
|
return e.manager.ListDir(ctx, e.containerName, path)
|
|
}
|
|
|
|
// Exec executes a command in the container
|
|
func (e *Executor) Exec(ctx context.Context, cmd []string) (string, error) {
|
|
result, err := e.manager.Exec(ctx, e.containerName, cmd, &infraSandbox.ExecOptions{
|
|
WorkDir: e.workDir,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if result.ExitCode != 0 {
|
|
return result.Stdout, fmt.Errorf("command exited with code %d: %s", result.ExitCode, result.Stderr)
|
|
}
|
|
|
|
return result.Stdout, nil
|
|
}
|
|
|
|
// GetWorkDir returns the container workspace directory
|
|
func (e *Executor) GetWorkDir() string {
|
|
return e.workDir
|
|
}
|
|
|
|
// GetSandboxID returns the sandbox ID (userID-chatID)
|
|
func (e *Executor) GetSandboxID() string {
|
|
if e.opts == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s-%s", e.opts.UserID, e.opts.ChatID)
|
|
}
|
|
|
|
// GetVNCUrl returns the VNC preview URL path
|
|
// Returns empty string if VNC is not enabled for this sandbox image
|
|
func (e *Executor) GetVNCUrl() string {
|
|
if e.opts == nil {
|
|
return ""
|
|
}
|
|
|
|
imageName := e.opts.Image
|
|
if imageName == "" {
|
|
return ""
|
|
}
|
|
|
|
// Check if the image supports VNC using the shared keyword list in sandbox package
|
|
if !infraSandbox.IsVNCImage(imageName) {
|
|
return ""
|
|
}
|
|
|
|
// Return only the sandbox ID, the full URL is constructed by openapi/sandbox.GetVNCClientURL()
|
|
return e.GetSandboxID()
|
|
}
|
|
|
|
// Close releases the executor resources and removes the container
|
|
// Note: IPC session is managed by sandbox.Manager.Remove()
|
|
func (e *Executor) Close() error {
|
|
if e.manager != nil && e.containerName != "" {
|
|
ctx := context.Background()
|
|
return e.manager.Remove(ctx, e.containerName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Helper function to get string from map
|
|
func getString(m map[string]interface{}, key string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|