From 93e0d3be270bdc04c41db4a1bfece50c36f69ed0 Mon Sep 17 00:00:00 2001 From: mrbeandev Date: Mon, 16 Feb 2026 18:17:30 +0530 Subject: [PATCH] feat(tools): add background execution support to shell tool to prevent agent blocking (fixes #197) --- pkg/tools/shell.go | 59 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 713850f97..8bdcd42c5 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -19,6 +19,7 @@ type ExecTool struct { denyPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp restrictToWorkspace bool + callback AsyncCallback } func NewExecTool(workingDir string, restrict bool) *ExecTool { @@ -30,9 +31,7 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { regexp.MustCompile(`\bdd\s+if=`), regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), } - return &ExecTool{ workingDir: workingDir, timeout: 60 * time.Second, @@ -42,6 +41,11 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { } } +// SetCallback implements AsyncTool interface +func (t *ExecTool) SetCallback(cb AsyncCallback) { + t.callback = cb +} + func (t *ExecTool) Name() string { return "exec" } @@ -62,6 +66,10 @@ func (t *ExecTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional working directory for the command", }, + "background": map[string]interface{}{ + "type": "boolean", + "description": "Run the command in the background. Use this for starting servers or long-running tasks. The tool will return immediately and report results later.", + }, }, "required": []string{"command"}, } @@ -89,6 +97,53 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To return ErrorResult(guardError) } + if background, _ := args["background"].(bool); background { + // Run in background + go func() { + // Create a fresh context for background execution + // We don't use the timeout from the tool since background tasks are expected to be long-lived + bgCtx := context.Background() + + var bgCmd *exec.Cmd + if runtime.GOOS == "windows" { + bgCmd = exec.CommandContext(bgCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) + } else { + bgCmd = exec.CommandContext(bgCtx, "sh", "-c", command) + } + if cwd != "" { + bgCmd.Dir = cwd + } + + var bgStdout, bgStderr bytes.Buffer + bgCmd.Stdout = &bgStdout + bgCmd.Stderr = &bgStderr + + err := bgCmd.Run() + bgOutput := bgStdout.String() + if bgStderr.Len() > 0 { + bgOutput += "\nSTDERR:\n" + bgStderr.String() + } + if err != nil { + bgOutput += fmt.Sprintf("\nExit code: %v", err) + } + if bgOutput == "" { + bgOutput = "(no output)" + } + + if t.callback != nil { + res := &ToolResult{ + ForLLM: fmt.Sprintf("Background command '%s' completed:\n%s", command, bgOutput), + ForUser: fmt.Sprintf("✅ Background command '%s' completed.", command), + IsError: err != nil, + } + t.callback(bgCtx, res) + } + }() + + msg := fmt.Sprintf("Started command '%s' in background.", command) + return AsyncResult(msg) + } + // timeout == 0 means no timeout var cmdCtx context.Context var cancel context.CancelFunc