package main import ( "context" "fmt" "log/slog" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" ) // terminalToolName is the registered name of the exec/terminal tool. const terminalToolName = "terminal" // terminalTool is an sdk.Tool that runs a command through the grant-based exec // sandbox (exec.Provider). It is registered even when the sandbox is closed: // Execute then returns exec.ErrExecutionDisabled, so the agent gains the ability // to run code only after the owner explicitly enables the sandbox (default off). type terminalTool struct { prov exec.Provider log *slog.Logger } // Name returns the tool identifier. func (t *terminalTool) Name() string { return terminalToolName } // Description explains what the tool does. func (t *terminalTool) Description() string { return "Run a terminal command through the sandboxed exec provider and return its stdout, stderr and exit code." } // Parameters returns the input JSON Schema. func (t *terminalTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "The command line to run; split into argv by whitespace (no shell interpretation).", }, "workdir": map[string]any{ "type": "string", "description": "Optional working directory, confined to an allowed sandbox root.", }, "timeout": map[string]any{ "type": "number", "description": "Optional timeout in seconds (default 30).", }, }, "required": []any{"command"}, }, nil } // Execute runs the command via the exec provider and returns its outcome. When // the sandbox is closed the provider returns exec.ErrExecutionDisabled, which the // dispatcher wraps into a tool error safe to feed back to the LLM. func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) { m, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("terminal: args must be an object") } cmd, _ := m["command"].(string) argv := strings.Fields(cmd) if len(argv) == 0 { return nil, fmt.Errorf("terminal: command is required") } if t.log != nil { t.log.Debug("terminal tool invoked", "cmd", strings.Join(argv, " ")) } req := exec.ExecRequest{Command: argv, Timeout: timeoutArg(m["timeout"])} if wd, ok := m["workdir"].(string); ok { req.WorkDir = wd } res, err := t.prov.Execute(ctx, req) if err != nil { return nil, err } return map[string]any{ "stdout": res.Stdout, "stderr": res.Stderr, "exit": res.ExitCode, "truncated": res.Truncated, }, nil } // timeoutArg converts the optional timeout argument (seconds) to a duration. A // missing or non-positive value returns zero, letting the provider apply its own // default. func timeoutArg(v any) time.Duration { switch n := v.(type) { case float64: if n > 0 { return time.Duration(n * float64(time.Second)) } case int: if n > 0 { return time.Duration(n) * time.Second } } return 0 } // wireTerminalTool registers the terminal tool with the dispatcher so the agent // can issue sandboxed terminal commands. It no-ops (with a warning) when no exec // provider is wired, which keeps callers safe. func (a *app) wireTerminalTool() { log := logger.WithSource(a.log, "cmd/cyrene") if a.execProv == nil { log.Warn("terminal tool skipped: no exec provider") return } if err := a.dispatcher.Register(&terminalTool{ prov: a.execProv, log: logger.WithSource(a.log, "cmd/cyrene"), }); err != nil { log.Warn("terminal tool register skipped", "error", err) return } log.Info("terminal tool registered", "sandbox_enabled", a.cfg.ExecSandbox.Enabled) }