feat(tools): add background execution support to shell tool to prevent agent blocking (fixes #197)

This commit is contained in:
mrbeandev 2026-02-16 18:17:30 +05:30
parent 4777024781
commit 0759823248

View file

@ -19,6 +19,7 @@ type ExecTool struct {
denyPatterns []*regexp.Regexp denyPatterns []*regexp.Regexp
allowPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp
restrictToWorkspace bool restrictToWorkspace bool
callback AsyncCallback
} }
func NewExecTool(workingDir string, restrict bool) *ExecTool { 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(`\bdd\s+if=`),
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) 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(`\b(shutdown|reboot|poweroff)\b`),
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
} }
return &ExecTool{ return &ExecTool{
workingDir: workingDir, workingDir: workingDir,
timeout: 60 * time.Second, 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 { func (t *ExecTool) Name() string {
return "exec" return "exec"
} }
@ -62,6 +66,10 @@ func (t *ExecTool) Parameters() map[string]interface{} {
"type": "string", "type": "string",
"description": "Optional working directory for the command", "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"}, "required": []string{"command"},
} }
@ -89,6 +97,53 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
return ErrorResult(guardError) 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)
}
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel() defer cancel()