diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 67056def3..85e03392a 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -161,6 +161,13 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md %s`, skillsSummary)) } + // Runtime status from tools (e.g., background processes) + if cb.tools != nil { + if status := cb.tools.GetRuntimeStatus(); status != "" { + parts = append(parts, status) + } + } + // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 05e395999..11a9d55dc 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -56,7 +56,9 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewListDirTool(workspace, restrict)) - toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) + execTool := tools.NewExecToolWithConfig(workspace, restrict, cfg) + toolsRegistry.Register(execTool) + toolsRegistry.Register(tools.NewBgMonitorTool(execTool)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 770d8cb04..c939f4aea 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -69,6 +69,13 @@ type AsyncTool interface { SetCallback(cb AsyncCallback) } +// StatusProvider is an optional interface that tools can implement +// to inject runtime status information into the system prompt. +// Return an empty string to inject nothing. +type StatusProvider interface { + RuntimeStatus() string +} + func ToolToSchema(tool Tool) map[string]any { return map[string]any{ "type": "function", diff --git a/pkg/tools/bg_monitor.go b/pkg/tools/bg_monitor.go new file mode 100644 index 000000000..429c7c69b --- /dev/null +++ b/pkg/tools/bg_monitor.go @@ -0,0 +1,256 @@ +package tools + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + "time" +) + +const ( + bgWatchPollInterval = 100 * time.Millisecond + bgWatchDefaultTimeout = 30 * time.Second + bgTailDefaultLines = 20 +) + +// BgMonitorTool monitors and inspects background processes managed by ExecTool. +type BgMonitorTool struct { + exec *ExecTool +} + +// NewBgMonitorTool creates a new BgMonitorTool that accesses bg processes from the given ExecTool. +func NewBgMonitorTool(exec *ExecTool) *BgMonitorTool { + return &BgMonitorTool{exec: exec} +} + +func (t *BgMonitorTool) Name() string { + return "bg_monitor" +} + +func (t *BgMonitorTool) Description() string { + return "Monitor and inspect background processes. Use 'list' to see all, 'watch' to wait for output pattern, 'tail' to get recent log lines." +} + +func (t *BgMonitorTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"list", "watch", "tail"}, + "description": "Action: 'list' all bg processes, 'watch' for a pattern in output, 'tail' recent output lines.", + }, + "bg_id": map[string]any{ + "type": "string", + "description": "Background process ID (e.g. 'bg-1'). Required for watch and tail.", + }, + "pattern": map[string]any{ + "type": "string", + "description": "Regex pattern to watch for in output (used with action='watch').", + }, + "lines": map[string]any{ + "type": "number", + "description": "Number of recent lines to return (used with action='tail', default 20).", + }, + "watch_timeout": map[string]any{ + "type": "number", + "description": "Timeout in seconds for watch action (default 30).", + }, + }, + "required": []string{"action"}, + } +} + +func (t *BgMonitorTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + switch action { + case "list": + return t.actionList() + case "watch": + return t.actionWatch(ctx, args) + case "tail": + return t.actionTail(args) + default: + return ErrorResult(fmt.Sprintf("unknown action %q (use 'list', 'watch', or 'tail')", action)) + } +} + +func (t *BgMonitorTool) actionList() *ToolResult { + procs := t.exec.BgProcesses() + if len(procs) == 0 { + return &ToolResult{ + ForLLM: "No background processes.", + ForUser: "No background processes.", + } + } + + ids := make([]string, 0, len(procs)) + for id := range procs { + ids = append(ids, id) + } + sort.Strings(ids) + + var sb strings.Builder + sb.WriteString("Background Processes:\n\n") + for _, id := range ids { + bp := procs[id] + if bp.isRunning() { + uptime := time.Since(bp.startedAt).Truncate(time.Second) + fmt.Fprintf(&sb, " [%s] pid=%d running (uptime: %s, max: %s) %s\n", + id, bp.pid, uptime, getBgMaxLifetime(), bp.command) + } else { + ran := time.Since(bp.startedAt).Truncate(time.Second) + if bp.exitErr != nil { + fmt.Fprintf(&sb, " [%s] pid=%d exited=err (ran: %s) %s\n", + id, bp.pid, ran, bp.command) + } else { + fmt.Fprintf(&sb, " [%s] pid=%d exited=0 (ran: %s) %s\n", + id, bp.pid, ran, bp.command) + } + } + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +func (t *BgMonitorTool) actionWatch(ctx context.Context, args map[string]any) *ToolResult { + bgID, _ := args["bg_id"].(string) + if bgID == "" { + return ErrorResult("bg_id is required for watch action") + } + + patternStr, _ := args["pattern"].(string) + if patternStr == "" { + return ErrorResult("pattern is required for watch action") + } + + pattern, err := regexp.Compile(patternStr) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid regex pattern %q: %v", patternStr, err)) + } + + timeout := bgWatchDefaultTimeout + if t, ok := args["watch_timeout"].(float64); ok && t > 0 { + timeout = time.Duration(t) * time.Second + } + + procs := t.exec.BgProcesses() + bp, ok := procs[bgID] + if !ok { + return ErrorResult(fmt.Sprintf("background process %q not found", bgID)) + } + + deadline := time.After(timeout) + ticker := time.NewTicker(bgWatchPollInterval) + defer ticker.Stop() + + for { + // Check for pattern match + if match := bp.output.Match(pattern); match != "" { + return &ToolResult{ + ForLLM: fmt.Sprintf("Match found in [%s]: %s", bgID, match), + ForUser: fmt.Sprintf("Match found in [%s]: %s", bgID, match), + } + } + + // Check if process exited + if !bp.isRunning() { + output := bp.output.String() + tail := lastNLines(output, 10) + var sb strings.Builder + fmt.Fprintf(&sb, "Process %s exited before pattern matched.\n", bgID) + if bp.exitErr != nil { + fmt.Fprintf(&sb, "Exit: %v\n", bp.exitErr) + } else { + fmt.Fprintf(&sb, "Exit: 0\n") + } + fmt.Fprintf(&sb, "\nLast output:\n%s", tail) + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + IsError: true, + } + } + + select { + case <-deadline: + // Timeout + output := bp.output.String() + tail := lastNLines(output, 10) + var sb strings.Builder + fmt.Fprintf(&sb, "Watch timed out after %s waiting for pattern %q in [%s].\n", timeout, patternStr, bgID) + fmt.Fprintf(&sb, "\nLast output:\n%s", tail) + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + IsError: true, + } + case <-ctx.Done(): + return ErrorResult("watch cancelled") + case <-ticker.C: + // Continue polling + } + } +} + +func (t *BgMonitorTool) actionTail(args map[string]any) *ToolResult { + bgID, _ := args["bg_id"].(string) + if bgID == "" { + return ErrorResult("bg_id is required for tail action") + } + + n := bgTailDefaultLines + if lines, ok := args["lines"].(float64); ok && lines > 0 { + n = int(lines) + } + + procs := t.exec.BgProcesses() + bp, ok := procs[bgID] + if !ok { + return ErrorResult(fmt.Sprintf("background process %q not found", bgID)) + } + + lines := bp.output.Lines(n) + + var sb strings.Builder + fmt.Fprintf(&sb, "[%s] pid=%d %s\n", bp.id, bp.pid, bp.command) + if bp.isRunning() { + fmt.Fprintf(&sb, "Status: running\n") + } else { + if bp.exitErr != nil { + fmt.Fprintf(&sb, "Status: exited (%v)\n", bp.exitErr) + } else { + fmt.Fprintf(&sb, "Status: exited=0\n") + } + } + fmt.Fprintf(&sb, "\nLast %d lines:\n", n) + for _, line := range lines { + fmt.Fprintf(&sb, "%s\n", line) + } + + if len(lines) == 0 { + fmt.Fprintf(&sb, "(no output)\n") + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +// lastNLines returns the last n lines from a string. +func lastNLines(s string, n int) string { + lines := strings.Split(s, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if n >= len(lines) { + return strings.Join(lines, "\n") + } + return strings.Join(lines[len(lines)-n:], "\n") +} diff --git a/pkg/tools/bg_monitor_test.go b/pkg/tools/bg_monitor_test.go new file mode 100644 index 000000000..b884f1a9b --- /dev/null +++ b/pkg/tools/bg_monitor_test.go @@ -0,0 +1,229 @@ +package tools + +import ( + "context" + "runtime" + "strings" + "testing" + "time" +) + +func TestBgMonitor_List(t *testing.T) { + tool := NewExecTool("", false) + monitor := NewBgMonitorTool(tool) + + // List with no processes + result := monitor.Execute(context.Background(), map[string]any{"action": "list"}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No background") { + t.Errorf("expected 'No background' message, got: %s", result.ForLLM) + } + + // Start two bg processes + var cmd1, cmd2 string + if runtime.GOOS == "windows" { + cmd1 = "Start-Sleep -Seconds 30" + cmd2 = "Start-Sleep -Seconds 30" + } else { + cmd1 = "sleep 30" + cmd2 = "sleep 30" + } + + r1 := tool.Execute(context.Background(), map[string]any{ + "command": cmd1, + "background": true, + }) + if r1.IsError { + t.Fatalf("failed to start bg-1: %s", r1.ForLLM) + } + + r2 := tool.Execute(context.Background(), map[string]any{ + "command": cmd2, + "background": true, + }) + if r2.IsError { + t.Fatalf("failed to start bg-2: %s", r2.ForLLM) + } + + // List should show both + result = monitor.Execute(context.Background(), map[string]any{"action": "list"}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "bg-1") { + t.Errorf("expected bg-1 in list, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "bg-2") { + t.Errorf("expected bg-2 in list, got: %s", result.ForLLM) + } + + // Cleanup + tool.Shutdown() +} + +func TestBgMonitor_Watch_Match(t *testing.T) { + tool := NewExecTool("", false) + monitor := NewBgMonitorTool(tool) + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Write-Output 'Server ready on port 3000'; Start-Sleep -Seconds 30" + } else { + cmd = "echo 'Server ready on port 3000'; sleep 30" + } + + r := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if r.IsError { + t.Fatalf("failed to start bg: %s", r.ForLLM) + } + + // Watch for "ready" pattern — should match quickly + result := monitor.Execute(context.Background(), map[string]any{ + "action": "watch", + "bg_id": "bg-1", + "pattern": "ready", + "watch_timeout": float64(10), + }) + if result.IsError { + t.Fatalf("expected watch to match, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Match found") { + t.Errorf("expected 'Match found' message, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "ready") { + t.Errorf("expected match to contain 'ready', got: %s", result.ForLLM) + } + + tool.Shutdown() +} + +func TestBgMonitor_Watch_Timeout(t *testing.T) { + tool := NewExecTool("", false) + monitor := NewBgMonitorTool(tool) + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 30" + } else { + cmd = "sleep 30" + } + + r := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if r.IsError { + t.Fatalf("failed to start bg: %s", r.ForLLM) + } + + // Watch for a pattern that won't appear, with short timeout + result := monitor.Execute(context.Background(), map[string]any{ + "action": "watch", + "bg_id": "bg-1", + "pattern": "never_going_to_match", + "watch_timeout": float64(1), + }) + if !result.IsError { + t.Fatalf("expected watch to timeout with error, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "timed out") { + t.Errorf("expected 'timed out' message, got: %s", result.ForLLM) + } + + tool.Shutdown() +} + +func TestBgMonitor_Watch_ProcessExit(t *testing.T) { + tool := NewExecTool("", false) + monitor := NewBgMonitorTool(tool) + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Write-Output 'done quickly'" + } else { + cmd = "echo 'done quickly'" + } + + r := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if r.IsError { + t.Fatalf("failed to start bg: %s", r.ForLLM) + } + + // Wait a bit for the process to exit + time.Sleep(4 * time.Second) + + // Watch for a pattern that doesn't match — process should have exited + result := monitor.Execute(context.Background(), map[string]any{ + "action": "watch", + "bg_id": "bg-1", + "pattern": "never_match", + "watch_timeout": float64(5), + }) + if !result.IsError { + t.Fatalf("expected error when process exits, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "exited") { + t.Errorf("expected 'exited' message, got: %s", result.ForLLM) + } + + tool.Shutdown() +} + +func TestBgMonitor_Tail(t *testing.T) { + tool := NewExecTool("", false) + monitor := NewBgMonitorTool(tool) + + var cmd string + if runtime.GOOS == "windows" { + cmd = "1..5 | ForEach-Object { Write-Output \"line $_\" }; Start-Sleep -Seconds 30" + } else { + cmd = "for i in 1 2 3 4 5; do echo \"line $i\"; done; sleep 30" + } + + r := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if r.IsError { + t.Fatalf("failed to start bg: %s", r.ForLLM) + } + + // Wait for initial output to be captured + time.Sleep(4 * time.Second) + + // Tail last 3 lines + result := monitor.Execute(context.Background(), map[string]any{ + "action": "tail", + "bg_id": "bg-1", + "lines": float64(3), + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "line 5") { + t.Errorf("expected tail to contain 'line 5', got: %s", result.ForLLM) + } + + tool.Shutdown() +} + +func TestBgMonitor_InvalidAction(t *testing.T) { + tool := NewExecTool("", false) + monitor := NewBgMonitorTool(tool) + + result := monitor.Execute(context.Background(), map[string]any{"action": "invalid"}) + if !result.IsError { + t.Fatalf("expected error for invalid action") + } + if !strings.Contains(result.ForLLM, "unknown action") { + t.Errorf("expected 'unknown action' message, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index eb1a57808..6f85ad8ab 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -194,6 +194,26 @@ func (r *ToolRegistry) Count() int { return len(r.tools) } +// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider. +// Returns empty string if no tool has status to report. +func (r *ToolRegistry) GetRuntimeStatus() string { + r.mu.RLock() + defer r.mu.RUnlock() + + var parts []string + for _, tool := range r.tools { + if sp, ok := tool.(StatusProvider); ok { + if s := sp.RuntimeStatus(); s != "" { + parts = append(parts, s) + } + } + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, "\n\n") +} + // GetSummaries returns human-readable summaries of all registered tools. // Returns a slice of "name - description" strings. func (r *ToolRegistry) GetSummaries() []string { diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index e7c04fed4..3a62bb41e 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -5,23 +5,130 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "regexp" "runtime" + "sort" "strings" + "sync" "time" "github.com/sipeed/picoclaw/pkg/config" ) +const ( + bgMaxLifetime = 45 * time.Minute + bgRingBufSize = 32 * 1024 // 32KB + bgInitCapture = 3 * time.Second + bgMaxProcesses = 10 +) + +// ringBuffer is a thread-safe circular buffer that retains the most recent bytes. +type ringBuffer struct { + mu sync.Mutex + buf []byte + size int +} + +func newRingBuffer(size int) *ringBuffer { + return &ringBuffer{size: size} +} + +// Write appends data to the ring buffer, dropping oldest bytes if capacity is exceeded. +func (rb *ringBuffer) Write(p []byte) (int, error) { + rb.mu.Lock() + defer rb.mu.Unlock() + rb.buf = append(rb.buf, p...) + if len(rb.buf) > rb.size { + rb.buf = rb.buf[len(rb.buf)-rb.size:] + } + return len(p), nil +} + +// String returns the current buffer contents. +func (rb *ringBuffer) String() string { + rb.mu.Lock() + defer rb.mu.Unlock() + return string(rb.buf) +} + +// Lines returns the last n lines from the buffer. +func (rb *ringBuffer) Lines(n int) []string { + rb.mu.Lock() + defer rb.mu.Unlock() + if len(rb.buf) == 0 { + return nil + } + all := strings.Split(string(rb.buf), "\n") + // Remove trailing empty element from final newline + if len(all) > 0 && all[len(all)-1] == "" { + all = all[:len(all)-1] + } + if n <= 0 || n >= len(all) { + return all + } + return all[len(all)-n:] +} + +// Match checks if any line in the buffer matches the given regex pattern. +// Returns the first matching line, or empty string if no match. +func (rb *ringBuffer) Match(pattern *regexp.Regexp) string { + rb.mu.Lock() + defer rb.mu.Unlock() + for _, line := range strings.Split(string(rb.buf), "\n") { + if pattern.MatchString(line) { + return line + } + } + return "" +} + +// Len returns the current number of bytes in the buffer. +func (rb *ringBuffer) Len() int { + rb.mu.Lock() + defer rb.mu.Unlock() + return len(rb.buf) +} + +// bgProcess represents a background process managed by ExecTool. +type bgProcess struct { + id string + command string + cmd *exec.Cmd + pid int + startedAt time.Time + output *ringBuffer + done chan struct{} // closed when process exits + exitErr error + cancel context.CancelFunc // cancels the monitor goroutine +} + +// isRunning returns true if the process has not yet exited. +func (bp *bgProcess) isRunning() bool { + select { + case <-bp.done: + return false + default: + return true + } +} + type ExecTool struct { workingDir string timeout time.Duration denyPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp restrictToWorkspace bool + + // Background process management + bgMu sync.Mutex + bgProcesses map[string]*bgProcess + bgNextID int + bgShutdown context.CancelFunc // cancels all bg monitor goroutines + bgCtx context.Context } var defaultDenyPatterns = []*regexp.Regexp{ @@ -102,12 +209,17 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf denyPatterns = append(denyPatterns, defaultDenyPatterns...) } + bgCtx, bgCancel := context.WithCancel(context.Background()) + return &ExecTool{ workingDir: workingDir, timeout: 5 * time.Minute, denyPatterns: denyPatterns, allowPatterns: nil, restrictToWorkspace: restrict, + bgProcesses: make(map[string]*bgProcess), + bgCtx: bgCtx, + bgShutdown: bgCancel, } } @@ -116,7 +228,7 @@ func (t *ExecTool) Name() string { } func (t *ExecTool) Description() string { - return "Execute a shell command and return its output. Use with caution." + return "Execute a shell command and return its output. Supports background execution with background=true, and managing background processes with bg_action." } func (t *ExecTool) Parameters() map[string]any { @@ -131,14 +243,36 @@ func (t *ExecTool) Parameters() map[string]any { "type": "string", "description": "Optional working directory for the command", }, + "background": map[string]any{ + "type": "boolean", + "description": "Run the command in the background. Returns immediately with a process ID.", + }, + "bg_action": map[string]any{ + "type": "string", + "enum": []string{"output", "kill"}, + "description": "Action on a background process: 'output' to get latest output, 'kill' to stop it.", + }, + "bg_id": map[string]any{ + "type": "string", + "description": "Background process ID (e.g. 'bg-1'). Required with bg_action.", + }, }, - "required": []string{"command"}, + "required": []string{}, } } func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + // Handle bg_action first (output/kill) + if bgAction, ok := args["bg_action"].(string); ok && bgAction != "" { + bgID, _ := args["bg_id"].(string) + return t.handleBgAction(bgAction, bgID) + } + + // Check for background execution + bg, _ := args["background"].(bool) + command, ok := args["command"].(string) - if !ok { + if !ok || command == "" { return ErrorResult("command is required") } @@ -166,6 +300,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(guardError) } + if bg { + return t.executeBg(command, cwd) + } + + return t.executeSync(ctx, command, cwd) +} + +// executeSync runs a command synchronously (existing behavior). +func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolResult { // timeout == 0 means no timeout var cmdCtx context.Context var cancel context.CancelFunc @@ -257,6 +400,294 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } +// executeBg starts a background process and returns immediately. +func (t *ExecTool) executeBg(command, cwd string) *ToolResult { + t.bgMu.Lock() + + // Check max processes limit + running := 0 + for _, bp := range t.bgProcesses { + if bp.isRunning() { + running++ + } + } + if running >= bgMaxProcesses { + t.bgMu.Unlock() + return ErrorResult(fmt.Sprintf("maximum background processes reached (%d). Kill an existing one first.", bgMaxProcesses)) + } + + t.bgNextID++ + id := fmt.Sprintf("bg-%d", t.bgNextID) + t.bgMu.Unlock() + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + if cwd != "" { + cmd.Dir = cwd + } + + prepareCommandForTermination(cmd) + + output := newRingBuffer(bgRingBufSize) + + // Use pipes to capture output + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err)) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err)) + } + + if err := cmd.Start(); err != nil { + return ErrorResult(fmt.Sprintf("failed to start background command: %v", err)) + } + + monitorCtx, monitorCancel := context.WithCancel(t.bgCtx) + + bp := &bgProcess{ + id: id, + command: command, + cmd: cmd, + pid: cmd.Process.Pid, + startedAt: time.Now(), + output: output, + done: make(chan struct{}), + cancel: monitorCancel, + } + + t.bgMu.Lock() + t.bgProcesses[id] = bp + t.bgMu.Unlock() + + // io.Copy goroutines: pipe stdout/stderr into ring buffer + go io.Copy(output, stdoutPipe) + go io.Copy(output, stderrPipe) + + // cmd.Wait goroutine + waitDone := make(chan error, 1) + go func() { + waitDone <- cmd.Wait() + }() + + // Monitor goroutine: handles lifetime timer, process exit, and shutdown + go func() { + lifetime := time.NewTimer(getBgMaxLifetime()) + defer lifetime.Stop() + + select { + case err := <-waitDone: + // Process exited naturally + bp.exitErr = err + close(bp.done) + case <-lifetime.C: + // Max lifetime exceeded — kill + _ = terminateProcessTree(cmd) + select { + case err := <-waitDone: + bp.exitErr = err + case <-time.After(2 * time.Second): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + bp.exitErr = <-waitDone + } + close(bp.done) + case <-monitorCtx.Done(): + // Shutdown or explicit kill via cancel + _ = terminateProcessTree(cmd) + select { + case err := <-waitDone: + bp.exitErr = err + case <-time.After(2 * time.Second): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + bp.exitErr = <-waitDone + } + select { + case <-bp.done: + default: + close(bp.done) + } + } + }() + + // Capture initial output (wait up to bgInitCapture) + time.Sleep(bgInitCapture) + initialOutput := output.String() + + var sb strings.Builder + fmt.Fprintf(&sb, "Background process started.\n") + fmt.Fprintf(&sb, " id: %s\n", id) + fmt.Fprintf(&sb, " pid: %d\n", bp.pid) + fmt.Fprintf(&sb, " cmd: %s\n", command) + fmt.Fprintf(&sb, " max lifetime: %s\n", getBgMaxLifetime()) + if initialOutput != "" { + fmt.Fprintf(&sb, "\nInitial output:\n%s", initialOutput) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: fmt.Sprintf("Background process %s (pid=%d) started: %s", id, bp.pid, command), + } +} + +// handleBgAction handles bg_action=output and bg_action=kill. +func (t *ExecTool) handleBgAction(action, bgID string) *ToolResult { + if bgID == "" { + return ErrorResult("bg_id is required for bg_action") + } + + t.bgMu.Lock() + bp, ok := t.bgProcesses[bgID] + t.bgMu.Unlock() + + if !ok { + return ErrorResult(fmt.Sprintf("background process %q not found", bgID)) + } + + switch action { + case "output": + return t.bgOutput(bp) + case "kill": + return t.bgKill(bp) + default: + return ErrorResult(fmt.Sprintf("unknown bg_action %q (use 'output' or 'kill')", action)) + } +} + +func (t *ExecTool) bgOutput(bp *bgProcess) *ToolResult { + var sb strings.Builder + fmt.Fprintf(&sb, "[%s] pid=%d %s\n", bp.id, bp.pid, bp.command) + + if bp.isRunning() { + uptime := time.Since(bp.startedAt).Truncate(time.Second) + fmt.Fprintf(&sb, "Status: running (uptime: %s, max: %s)\n", uptime, getBgMaxLifetime()) + } else { + ran := time.Since(bp.startedAt).Truncate(time.Second) + if bp.exitErr != nil { + fmt.Fprintf(&sb, "Status: exited with error (ran: %s): %v\n", ran, bp.exitErr) + } else { + fmt.Fprintf(&sb, "Status: exited=0 (ran: %s)\n", ran) + } + } + + output := bp.output.String() + if output == "" { + fmt.Fprintf(&sb, "\n(no output)") + } else { + fmt.Fprintf(&sb, "\nOutput:\n%s", output) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +func (t *ExecTool) bgKill(bp *bgProcess) *ToolResult { + if bp.isRunning() { + bp.cancel() // triggers monitor goroutine cleanup + // Wait for process to actually exit + select { + case <-bp.done: + case <-time.After(5 * time.Second): + } + } + + t.bgMu.Lock() + delete(t.bgProcesses, bp.id) + t.bgMu.Unlock() + + msg := fmt.Sprintf("Background process %s (pid=%d) terminated: %s", bp.id, bp.pid, bp.command) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } +} + +// BgProcesses returns a snapshot of background processes for use by bg_monitor. +func (t *ExecTool) BgProcesses() map[string]*bgProcess { + t.bgMu.Lock() + defer t.bgMu.Unlock() + snapshot := make(map[string]*bgProcess, len(t.bgProcesses)) + for k, v := range t.bgProcesses { + snapshot[k] = v + } + return snapshot +} + +// RuntimeStatus implements StatusProvider for system prompt injection. +func (t *ExecTool) RuntimeStatus() string { + t.bgMu.Lock() + defer t.bgMu.Unlock() + + if len(t.bgProcesses) == 0 { + return "" + } + + // Sort by ID for stable output + ids := make([]string, 0, len(t.bgProcesses)) + for id := range t.bgProcesses { + ids = append(ids, id) + } + sort.Strings(ids) + + var sb strings.Builder + sb.WriteString("## Background Processes\n\n") + for _, id := range ids { + bp := t.bgProcesses[id] + if bp.isRunning() { + uptime := time.Since(bp.startedAt).Truncate(time.Second) + fmt.Fprintf(&sb, " [%s] pid=%d running (uptime: %s, max: %s) %s\n", + id, bp.pid, uptime, getBgMaxLifetime(), bp.command) + } else { + ran := time.Since(bp.startedAt).Truncate(time.Second) + if bp.exitErr != nil { + fmt.Fprintf(&sb, " [%s] pid=%d exited=err (ran: %s) %s\n", + id, bp.pid, ran, bp.command) + } else { + fmt.Fprintf(&sb, " [%s] pid=%d exited=0 (ran: %s) %s\n", + id, bp.pid, ran, bp.command) + } + } + } + sb.WriteString("\nUse exec with bg_action=\"output\" / \"kill\" and bg_id to manage.\n") + sb.WriteString("Use bg_monitor for list/watch/tail operations.") + + return sb.String() +} + +// Shutdown terminates all background processes. Call on application exit. +func (t *ExecTool) Shutdown() { + t.bgShutdown() // cancel all monitor goroutines + + t.bgMu.Lock() + procs := make([]*bgProcess, 0, len(t.bgProcesses)) + for _, bp := range t.bgProcesses { + procs = append(procs, bp) + } + t.bgMu.Unlock() + + // Wait for all processes to exit + for _, bp := range procs { + select { + case <-bp.done: + case <-time.After(5 * time.Second): + // Force kill if still running + if bp.cmd.Process != nil { + _ = bp.cmd.Process.Kill() + } + } + } +} + func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) lower := strings.ToLower(cmd) @@ -394,3 +825,20 @@ func (t *ExecTool) SetAllowPatterns(patterns []string) error { } return nil } + +// SetBgMaxLifetimeForTest overrides bgMaxLifetime for testing purposes. +// This is exposed only for tests; the returned function restores the original value. +var bgMaxLifetimeOverride time.Duration + +func SetBgMaxLifetimeForTest(d time.Duration) func() { + old := bgMaxLifetimeOverride + bgMaxLifetimeOverride = d + return func() { bgMaxLifetimeOverride = old } +} + +func getBgMaxLifetime() time.Duration { + if bgMaxLifetimeOverride > 0 { + return bgMaxLifetimeOverride + } + return bgMaxLifetime +} diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index d760af5b4..e76a365af 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -514,3 +515,366 @@ func TestGuardCommand_AgentCLISlashCommand(t *testing.T) { } } } + +// --- Background process tests --- + +func TestExecTool_Bg_StartAndOutput(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Write-Output 'hello from bg'; Start-Sleep -Seconds 30" + } else { + cmd = "echo 'hello from bg'; sleep 30" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "bg-1") { + t.Errorf("expected bg-1 in result, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Background process started") { + t.Errorf("expected start message, got: %s", result.ForLLM) + } + + // Get output + outputResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + "bg_id": "bg-1", + }) + if outputResult.IsError { + t.Fatalf("failed to get output: %s", outputResult.ForLLM) + } + if !strings.Contains(outputResult.ForLLM, "hello from bg") { + t.Errorf("expected 'hello from bg' in output, got: %s", outputResult.ForLLM) + } + if !strings.Contains(outputResult.ForLLM, "running") { + t.Errorf("expected 'running' status, got: %s", outputResult.ForLLM) + } +} + +func TestExecTool_Bg_Kill(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 60" + } else { + cmd = "sleep 60" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + // Kill it + killResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "kill", + "bg_id": "bg-1", + }) + if killResult.IsError { + t.Fatalf("failed to kill: %s", killResult.ForLLM) + } + if !strings.Contains(killResult.ForLLM, "terminated") { + t.Errorf("expected 'terminated' message, got: %s", killResult.ForLLM) + } + + // Process should no longer be in the map + procs := tool.BgProcesses() + if _, ok := procs["bg-1"]; ok { + t.Errorf("expected bg-1 to be removed after kill") + } +} + +func TestExecTool_Bg_ExitedProcess(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Write-Output 'quick exit'" + } else { + cmd = "echo 'quick exit'" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + // Wait for process to exit (initial capture is 3s, so after that it should be done) + time.Sleep(4 * time.Second) + + // Get output — should show exited + outputResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + "bg_id": "bg-1", + }) + if outputResult.IsError { + t.Fatalf("failed to get output: %s", outputResult.ForLLM) + } + if !strings.Contains(outputResult.ForLLM, "exited") { + t.Errorf("expected 'exited' in output, got: %s", outputResult.ForLLM) + } + if !strings.Contains(outputResult.ForLLM, "quick exit") { + t.Errorf("expected 'quick exit' in output, got: %s", outputResult.ForLLM) + } +} + +func TestExecTool_Bg_InvalidID(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + // Output for non-existent ID + result := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + "bg_id": "bg-999", + }) + if !result.IsError { + t.Fatalf("expected error for invalid bg_id") + } + if !strings.Contains(result.ForLLM, "not found") { + t.Errorf("expected 'not found' message, got: %s", result.ForLLM) + } + + // Kill for non-existent ID + result = tool.Execute(context.Background(), map[string]any{ + "bg_action": "kill", + "bg_id": "bg-999", + }) + if !result.IsError { + t.Fatalf("expected error for invalid bg_id") + } +} + +func TestExecTool_Bg_InitialOutputCapture(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Write-Output 'initial line 1'; Write-Output 'initial line 2'; Start-Sleep -Seconds 30" + } else { + cmd = "echo 'initial line 1'; echo 'initial line 2'; sleep 30" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "initial line 1") { + t.Errorf("expected 'initial line 1' in initial output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "initial line 2") { + t.Errorf("expected 'initial line 2' in initial output, got: %s", result.ForLLM) + } +} + +func TestExecTool_Bg_RuntimeStatus(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + // No bg processes — should return empty + if s := tool.RuntimeStatus(); s != "" { + t.Errorf("expected empty runtime status with no bg processes, got: %s", s) + } + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 30" + } else { + cmd = "sleep 30" + } + + tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + + status := tool.RuntimeStatus() + if !strings.Contains(status, "Background Processes") { + t.Errorf("expected 'Background Processes' section, got: %s", status) + } + if !strings.Contains(status, "bg-1") { + t.Errorf("expected 'bg-1' in status, got: %s", status) + } + if !strings.Contains(status, "running") { + t.Errorf("expected 'running' in status, got: %s", status) + } +} + +func TestExecTool_Bg_Shutdown(t *testing.T) { + tool := NewExecTool("", false) + + var cmd string + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 60" + } else { + cmd = "sleep 60" + } + + tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + + // Both should be running + procs := tool.BgProcesses() + for _, bp := range procs { + if !bp.isRunning() { + t.Errorf("expected process to be running before shutdown") + } + } + + // Shutdown + tool.Shutdown() + + // All should be done + procs = tool.BgProcesses() + for _, bp := range procs { + if bp.isRunning() { + t.Errorf("expected process to be stopped after shutdown") + } + } +} + +func TestRingBuffer(t *testing.T) { + t.Run("Write and String", func(t *testing.T) { + rb := newRingBuffer(100) + rb.Write([]byte("hello ")) + rb.Write([]byte("world")) + if got := rb.String(); got != "hello world" { + t.Errorf("expected 'hello world', got %q", got) + } + }) + + t.Run("Lines", func(t *testing.T) { + rb := newRingBuffer(100) + rb.Write([]byte("line1\nline2\nline3\nline4\nline5\n")) + lines := rb.Lines(3) + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + if lines[0] != "line3" || lines[1] != "line4" || lines[2] != "line5" { + t.Errorf("unexpected lines: %v", lines) + } + }) + + t.Run("Match", func(t *testing.T) { + rb := newRingBuffer(100) + rb.Write([]byte("starting...\nServer ready on port 3000\nwaiting...\n")) + + re := regexp.MustCompile(`ready.*port`) + match := rb.Match(re) + if match == "" { + t.Fatal("expected match but got empty string") + } + if !strings.Contains(match, "ready") { + t.Errorf("expected match to contain 'ready', got: %s", match) + } + + // Non-matching pattern + re2 := regexp.MustCompile(`never_match`) + match2 := rb.Match(re2) + if match2 != "" { + t.Errorf("expected no match, got: %s", match2) + } + }) + + t.Run("Overflow", func(t *testing.T) { + rb := newRingBuffer(10) // small buffer + rb.Write([]byte("1234567890ABCDEF")) + got := rb.String() + if len(got) != 10 { + t.Errorf("expected buffer to be 10 bytes, got %d", len(got)) + } + // Should keep the last 10 bytes + if got != "7890ABCDEF" { + t.Errorf("expected '7890ABCDEF', got %q", got) + } + }) + + t.Run("Len", func(t *testing.T) { + rb := newRingBuffer(100) + if rb.Len() != 0 { + t.Errorf("expected 0 length initially") + } + rb.Write([]byte("hello")) + if rb.Len() != 5 { + t.Errorf("expected 5, got %d", rb.Len()) + } + }) + + t.Run("Empty Lines", func(t *testing.T) { + rb := newRingBuffer(100) + lines := rb.Lines(5) + if lines != nil { + t.Errorf("expected nil for empty buffer, got: %v", lines) + } + }) +} + +func TestExecTool_Bg_RingBufferOverflow(t *testing.T) { + tool := NewExecTool("", false) + defer tool.Shutdown() + + // Generate output larger than 32KB ring buffer + var cmd string + if runtime.GOOS == "windows" { + cmd = "1..2000 | ForEach-Object { Write-Output ('x' * 50) }; Start-Sleep -Seconds 30" + } else { + cmd = "for i in $(seq 1 2000); do echo $(head -c 50 /dev/zero | tr '\\0' 'x'); done; sleep 30" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + "background": true, + }) + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + // Wait for output to accumulate + time.Sleep(5 * time.Second) + + // Get output — ring buffer should have truncated old data + outputResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + "bg_id": "bg-1", + }) + if outputResult.IsError { + t.Fatalf("failed to get output: %s", outputResult.ForLLM) + } + + // The output should contain data but be bounded by the ring buffer size + procs := tool.BgProcesses() + bp := procs["bg-1"] + if bp == nil { + t.Fatal("bg-1 not found") + } + bufLen := bp.output.Len() + if bufLen > bgRingBufSize { + t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize) + } +} diff --git a/workspace/skills/dev-preview/SKILL.md b/workspace/skills/dev-preview/SKILL.md new file mode 100644 index 000000000..70de850be --- /dev/null +++ b/workspace/skills/dev-preview/SKILL.md @@ -0,0 +1,119 @@ +--- +name: dev-preview +description: Start a dev server in the background and preview it through the Mini App reverse proxy. +metadata: {"nanobot":{"emoji":"🌐"}} +--- + +# dev-preview Skill + +Launch a local dev server as a background process, wait for it to become ready, and connect it to the Mini App dev preview proxy. + +## Quickstart + +``` +1. exec(command="npm run dev", background=true) + → bg-1 started + +2. bg_monitor(action="watch", bg_id="bg-1", pattern="ready|listening|localhost") + → Match: "Server ready on http://localhost:3000" + +3. dev_preview(action="start", target="http://localhost:3000", name="frontend") + → Dev preview started +``` + +## Tools Overview + +### exec (background mode) + +Start a long-running process without blocking. + +| Call | Purpose | +|------|---------| +| `exec(command="npm run dev", background=true)` | Start dev server | +| `exec(bg_action="output", bg_id="bg-1")` | Get latest output | +| `exec(bg_action="kill", bg_id="bg-1")` | Stop process | + +- Background processes auto-terminate after **45 minutes**. +- Initial output (first 3 seconds) is included in the start response. +- Output is kept in a **32 KB ring buffer** (most recent bytes). +- Maximum **10** concurrent background processes. + +### bg_monitor + +Inspect and wait on background processes. + +| Call | Purpose | +|------|---------| +| `bg_monitor(action="list")` | List all bg processes | +| `bg_monitor(action="watch", bg_id="bg-1", pattern="ready")` | Wait for pattern (default 30s timeout) | +| `bg_monitor(action="tail", bg_id="bg-1", lines=30)` | Get last N lines | + +- `watch` polls every 100ms and returns the matching line. +- Set `watch_timeout` (seconds) to override the default 30s. +- If the process exits before a match, returns an error with the final output. + +### dev_preview + +Control the Mini App dev reverse proxy. + +| Call | Purpose | +|------|---------| +| `dev_preview(action="start", target="http://localhost:3000")` | Register + activate | +| `dev_preview(action="stop")` | Deactivate proxy | +| `dev_preview(action="status")` | Show all targets | +| `dev_preview(action="unregister", id="...")` | Remove a target | + +- Only **localhost** targets are allowed (localhost, 127.0.0.1, ::1). +- `name` is optional; auto-generated from host:port if omitted. + +## System Prompt Integration + +Active background processes are automatically injected into the system prompt: + +``` +## Background Processes + + [bg-1] pid=1234 running (uptime: 5m, max: 45m) npm run dev + [bg-2] pid=5678 exited=0 (ran: 2m) go build . +``` + +This means the agent always knows which processes are running, even across conversation turns and heartbeats. + +## Common Patterns + +### Python HTTP server + +``` +exec(command="python -m http.server 8080", background=true) +bg_monitor(action="watch", bg_id="bg-1", pattern="Serving") +dev_preview(action="start", target="http://localhost:8080") +``` + +### Vite / Next.js + +``` +exec(command="npm run dev", background=true) +bg_monitor(action="watch", bg_id="bg-1", pattern="ready|localhost|Local:") +dev_preview(action="start", target="http://localhost:5173", name="vite-app") +``` + +### Debugging + +``` +bg_monitor(action="tail", bg_id="bg-1", lines=50) +exec(bg_action="output", bg_id="bg-1") +``` + +### Cleanup + +``` +exec(bg_action="kill", bg_id="bg-1") +dev_preview(action="stop") +``` + +## Important Notes + +- Always use `bg_monitor(action="watch")` between starting a server and calling `dev_preview(action="start")`. Without it, the server may not be ready yet. +- If `watch` times out, check the output with `bg_monitor(action="tail")` to diagnose startup errors. +- Background processes persist across tool calls but are cleaned up on app shutdown. +- Exited processes remain visible (for output/exit code inspection) until explicitly killed.