agent: use platform shell tool names

This commit is contained in:
ParthSareen
2026-06-26 17:26:38 -07:00
parent b839fa49f0
commit 4784290c9b
12 changed files with 100 additions and 24 deletions

View File

@@ -225,11 +225,12 @@ func approvalRequestWithEvaluation(req ApprovalRequest, evaluation ApprovalEvalu
} }
func approvalSessionKey(req ApprovalRequest) string { func approvalSessionKey(req ApprovalRequest) string {
switch req.ToolName { if IsShellToolName(req.ToolName) {
case "bash":
if command, ok := stringApprovalArg(req.Args, "command"); ok { if command, ok := stringApprovalArg(req.Args, "command"); ok {
return "bash:" + command return req.ToolName + ":" + command
} }
}
switch req.ToolName {
case "edit": case "edit":
if path, ok := stringApprovalArg(req.Args, "path"); ok { if path, ok := stringApprovalArg(req.Args, "path"); ok {
return "edit:" + path return "edit:" + path
@@ -261,8 +262,8 @@ func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalReq
return evaluateWebApproval(req) return evaluateWebApproval(req)
case "edit": case "edit":
return evaluateEditApproval(req) return evaluateEditApproval(req)
case "bash": case "bash", "powershell":
return evaluateBashApproval(req) return evaluateShellApproval(req)
default: default:
return ApprovalEvaluation{ return ApprovalEvaluation{
RequirePrompt: true, RequirePrompt: true,
@@ -351,10 +352,10 @@ func sanitizeApprovalDisplay(value string) string {
return value return value
} }
func evaluateBashApproval(req ApprovalRequest) ApprovalEvaluation { func evaluateShellApproval(req ApprovalRequest) ApprovalEvaluation {
command, ok := stringApprovalArg(req.Args, "command") command, ok := stringApprovalArg(req.Args, "command")
if !ok || strings.TrimSpace(command) == "" { if !ok || strings.TrimSpace(command) == "" {
return denyApproval("bash", ApprovalRiskHigh, "missing command argument") return denyApproval(req.ToolName, ApprovalRiskHigh, "missing command argument")
} }
risk, reasons := classifyBashCommand(command) risk, reasons := classifyBashCommand(command)
@@ -364,9 +365,9 @@ func evaluateBashApproval(req ApprovalRequest) ApprovalEvaluation {
return ApprovalEvaluation{ return ApprovalEvaluation{
RequirePrompt: true, RequirePrompt: true,
Risk: risk, Risk: risk,
Summary: "Bash wants to run a command", Summary: fmt.Sprintf("%s wants to run a command", ToolDisplayName(req.ToolName)),
Reasons: reasons, Reasons: reasons,
SessionKey: "bash:" + command, SessionKey: req.ToolName + ":" + command,
} }
} }
@@ -380,7 +381,7 @@ func denyApproval(toolName string, risk ApprovalRisk, reason string) ApprovalEva
} }
} }
// Bash approval is static analysis of model-generated commands, not a sandbox. // Shell approval is static analysis of model-generated commands, not a sandbox.
// Globs, aliases, environment, and runtime shell state are intentionally out of scope. // Globs, aliases, environment, and runtime shell state are intentionally out of scope.
func classifyBashCommand(command string) (ApprovalRisk, []string) { func classifyBashCommand(command string) (ApprovalRisk, []string) {
classifier := bashClassifier{risk: ApprovalRiskMedium} classifier := bashClassifier{risk: ApprovalRiskMedium}

View File

@@ -223,6 +223,23 @@ func TestBashApprovalClassifiesHighRiskShell(t *testing.T) {
} }
} }
func TestPowerShellApprovalUsesShellPolicy(t *testing.T) {
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
ToolName: "powershell",
Args: map[string]any{"command": "Remove-Item -Recurse tmp"},
})
if !evaluation.RequirePrompt {
t.Fatal("powershell should require prompt")
}
if evaluation.Summary != "PowerShell wants to run a command" {
t.Fatalf("summary = %q", evaluation.Summary)
}
if evaluation.SessionKey != "powershell:Remove-Item -Recurse tmp" {
t.Fatalf("session key = %q", evaluation.SessionKey)
}
}
func TestBashApprovalClassifiesDynamicShellEvasions(t *testing.T) { func TestBashApprovalClassifiesDynamicShellEvasions(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -18,6 +18,8 @@ func ToolDisplayName(name string) string {
return "Web Fetch" return "Web Fetch"
case "bash": case "bash":
return "Bash" return "Bash"
case "powershell":
return "PowerShell"
case "read": case "read":
return "Read" return "Read"
case "list": case "list":
@@ -39,7 +41,7 @@ func ToolInvocationLabel(name string, args map[string]any) string {
displayName := ToolDisplayName(name) displayName := ToolDisplayName(name)
for _, key := range []string{"query", "url", "command", "path", "name"} { for _, key := range []string{"query", "url", "command", "path", "name"} {
if value, ok := displayStringArg(args, key); ok { if value, ok := displayStringArg(args, key); ok {
if name == "bash" && key == "command" { if IsShellToolName(name) && key == "command" {
value = truncateDisplayRunes(value, maxToolInvocationCommandRunes) value = truncateDisplayRunes(value, maxToolInvocationCommandRunes)
} }
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value)) return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
@@ -51,6 +53,11 @@ func ToolInvocationLabel(name string, args map[string]any) string {
return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args)) return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args))
} }
// IsShellToolName reports whether name identifies a platform shell tool.
func IsShellToolName(name string) bool {
return name == "bash" || name == "powershell"
}
func displayStringArg(args map[string]any, key string) (string, bool) { func displayStringArg(args map[string]any, key string) (string, bool) {
value, ok := args[key].(string) value, ok := args[key].(string)
if !ok || strings.TrimSpace(value) == "" { if !ok || strings.TrimSpace(value) == "" {

View File

@@ -22,3 +22,12 @@ func TestToolInvocationLabelCountsRunes(t *testing.T) {
t.Fatalf("label = %q, want %q", label, want) t.Fatalf("label = %q, want %q", label, want)
} }
} }
func TestToolInvocationLabelTruncatesLongPowerShellCommand(t *testing.T) {
command := strings.Repeat("a", 101)
label := ToolInvocationLabel("powershell", map[string]any{"command": command})
want := `PowerShell("` + strings.Repeat("a", 100) + `...")`
if label != want {
t.Fatalf("label = %q, want %q", label, want)
}
}

View File

@@ -26,18 +26,18 @@ func NewBash() *Bash {
} }
func (b *Bash) Name() string { func (b *Bash) Name() string {
return "bash" return shellToolName()
} }
func (b *Bash) Description() string { func (b *Bash) Description() string {
return "Execute a shell command on the system. Use this to run shell commands, inspect files, run tests, and perform development tasks." return shellToolDescription()
} }
func (b *Bash) Schema() api.ToolFunction { func (b *Bash) Schema() api.ToolFunction {
props := api.NewToolPropertiesMap() props := api.NewToolPropertiesMap()
props.Set("command", api.ToolProperty{ props.Set("command", api.ToolProperty{
Type: api.PropertyType{"string"}, Type: api.PropertyType{"string"},
Description: "The shell command to execute.", Description: shellCommandDescription(),
}) })
return api.ToolFunction{ return api.ToolFunction{
Name: b.Name(), Name: b.Name(),

View File

@@ -8,6 +8,18 @@ import (
"syscall" "syscall"
) )
func shellToolName() string {
return "bash"
}
func shellToolDescription() string {
return "Execute a bash command on the system. Use this to inspect files, run tests, and perform development tasks."
}
func shellCommandDescription() string {
return "The bash command to execute."
}
func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd { func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd {
script := command + "\n__ollama_status=$?\npwd -P > " + shellQuote(cwdPath) + "\nexit $__ollama_status" script := command + "\n__ollama_status=$?\npwd -P > " + shellQuote(cwdPath) + "\nexit $__ollama_status"
cmd := exec.CommandContext(ctx, "bash", "-c", script) cmd := exec.CommandContext(ctx, "bash", "-c", script)

View File

@@ -14,6 +14,18 @@ import (
var bashJobHandles sync.Map var bashJobHandles sync.Map
func shellToolName() string {
return "powershell"
}
func shellToolDescription() string {
return "Execute a PowerShell command on the system. Use this to inspect files, run tests, and perform development tasks."
}
func shellCommandDescription() string {
return "The PowerShell command to execute."
}
func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd { func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd {
return exec.CommandContext( return exec.CommandContext(
ctx, ctx,

View File

@@ -768,7 +768,7 @@ func agentToolsRegistry(ctx context.Context, client *api.Client, modelName strin
} }
registry := coreagent.NewRegistry() registry := coreagent.NewRegistry()
if os.Getenv("OLLAMA_AGENT_DISABLE_BASH") == "" { if os.Getenv("OLLAMA_AGENT_DISABLE_SHELL") == "" {
registry.Register(agenttools.NewBash()) registry.Register(agenttools.NewBash())
} }
registry.Register(agenttools.NewRead()) registry.Register(agenttools.NewRead())

View File

@@ -16,6 +16,7 @@ import (
coreagent "github.com/ollama/ollama/agent" coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/agent/skills" "github.com/ollama/ollama/agent/skills"
agenttools "github.com/ollama/ollama/agent/tools"
"github.com/ollama/ollama/api" "github.com/ollama/ollama/api"
agentchat "github.com/ollama/ollama/cmd/tui/chat" agentchat "github.com/ollama/ollama/cmd/tui/chat"
"github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/envconfig"
@@ -208,7 +209,7 @@ func captureStderr(t *testing.T, fn func()) string {
func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) { func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) {
t.Setenv("OLLAMA_NO_CLOUD", "1") t.Setenv("OLLAMA_NO_CLOUD", "1")
t.Setenv("OLLAMA_AGENT_DISABLE_BASH", "") t.Setenv("OLLAMA_AGENT_DISABLE_SHELL", "")
t.Setenv("OLLAMA_AGENT_DISABLE_WEBSEARCH", "") t.Setenv("OLLAMA_AGENT_DISABLE_WEBSEARCH", "")
statusCalls := 0 statusCalls := 0
@@ -235,7 +236,7 @@ func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) {
if registry == nil { if registry == nil {
t.Fatal("registry = nil, want local tools") t.Fatal("registry = nil, want local tools")
} }
if !registry.Has("bash") || !registry.Has("read") || !registry.Has("edit") { if !registry.Has(agenttools.NewBash().Name()) || !registry.Has("read") || !registry.Has("edit") {
t.Fatalf("local tools missing: %v", registry.Names()) t.Fatalf("local tools missing: %v", registry.Names())
} }
if registry.Has("list") { if registry.Has("list") {

View File

@@ -222,13 +222,14 @@ func (m chatModel) renderApprovalPromptLines(width int) []string {
} }
func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string { func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string {
switch request.ToolName { if coreagent.IsShellToolName(request.ToolName) {
case "bash":
command, ok := rawStringArg(request.Args, "command") command, ok := rawStringArg(request.Args, "command")
if !ok { if !ok {
return "" return ""
} }
return strings.Join(wrapChatText("$ "+command, width), "\n") return strings.Join(wrapChatText(shellPromptPrefix(request.ToolName)+command, width), "\n")
}
switch request.ToolName {
case "edit": case "edit":
path, ok := rawStringArg(request.Args, "path") path, ok := rawStringArg(request.Args, "path")
if !ok { if !ok {

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"runtime"
"slices" "slices"
"strings" "strings"
@@ -236,7 +237,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) {
m.preloadingModel = strings.TrimSpace(opts.Model) m.preloadingModel = strings.TrimSpace(opts.Model)
} }
p := tea.NewProgram(m, tea.WithReportFocus(), tea.WithMouseCellMotion()) programOptions := []tea.ProgramOption{tea.WithReportFocus()}
if runtime.GOOS != "windows" {
programOptions = append(programOptions, tea.WithMouseCellMotion())
}
p := tea.NewProgram(m, programOptions...)
finalModel, err := p.Run() finalModel, err := p.Run()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -429,6 +434,9 @@ func (m *chatModel) enterFullScreen() {
} }
func (m chatModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m chatModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if runtime.GOOS == "windows" {
return m, nil
}
if m.modelPicker != nil { if m.modelPicker != nil {
switch msg.Type { switch msg.Type {
case tea.MouseWheelUp: case tea.MouseWheelUp:

View File

@@ -1076,11 +1076,12 @@ func renderToolCallDetailLines(entry chatEntry, width int) []string {
if len(entry.args) == 0 { if len(entry.args) == 0 {
return nil return nil
} }
switch entry.detail { if coreagent.IsShellToolName(entry.detail) {
case "bash":
if command, ok := rawStringArg(entry.args, "command"); ok { if command, ok := rawStringArg(entry.args, "command"); ok {
return renderToolCallArgLine("$ "+command, width) return renderToolCallArgLine(shellPromptPrefix(entry.detail)+command, width)
} }
}
switch entry.detail {
case "web_search": case "web_search":
if query, ok := rawStringArg(entry.args, "query"); ok { if query, ok := rawStringArg(entry.args, "query"); ok {
return renderToolCallArgLine("query: "+query, width) return renderToolCallArgLine("query: "+query, width)
@@ -1093,6 +1094,13 @@ func renderToolCallDetailLines(entry chatEntry, width int) []string {
return renderToolCallArgs(entry.args, width) return renderToolCallArgs(entry.args, width)
} }
func shellPromptPrefix(toolName string) string {
if toolName == "powershell" {
return "PS> "
}
return "$ "
}
func renderToolCallArgs(args map[string]any, width int) []string { func renderToolCallArgs(args map[string]any, width int) []string {
keys := make([]string, 0, len(args)) keys := make([]string, 0, len(args))
for key := range args { for key := range args {