Merge branch 'worktree-dev-preview'
This commit is contained in:
commit
358dcfd21e
9 changed files with 1456 additions and 4 deletions
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
256
pkg/tools/bg_monitor.go
Normal file
256
pkg/tools/bg_monitor.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
229
pkg/tools/bg_monitor_test.go
Normal file
229
pkg/tools/bg_monitor_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
workspace/skills/dev-preview/SKILL.md
Normal file
119
workspace/skills/dev-preview/SKILL.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Reference in a new issue