diff --git a/agent/agent.go b/agent/agent.go index 1549e5a..ee769de 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -454,6 +454,12 @@ func (ag *Agent) execServer() toolsrv.Runner { return ag.runtime.ToolServer } +// ToolServer returns the tool execution server, or nil if unavailable. +// Exported for use by the 9P filesystem layer to sync tool registries. +func (ag *Agent) ToolServer() toolsrv.Runner { + return ag.execServer() +} + // Queue pushes a prompt onto the agent's FIFO. func (ag *Agent) Queue(prompt string) { ag.fifo.Push(prompt) diff --git a/agent/agent_config.go b/agent/agent_config.go index 30e7442..00809ed 100644 --- a/agent/agent_config.go +++ b/agent/agent_config.go @@ -65,6 +65,8 @@ type AgentConfig struct { Model string `json:"model,omitempty"` Tools *bool `json:"tools,omitempty"` AllowTools []string `json:"allowTools,omitempty"` + AutoLoad []string `json:"autoLoad,omitempty"` + DisallowTools []string `json:"disallowTools,omitempty"` MaxTokens int `json:"maxTokens,omitempty"` MaxCompletionTokens int `json:"maxCompletionTokens,omitempty"` // MaxSteps caps the number of tool-call rounds per turn. 0 means unlimited. diff --git a/agent/build_runtime.go b/agent/build_runtime.go index b6aa604..8586079 100644 --- a/agent/build_runtime.go +++ b/agent/build_runtime.go @@ -29,11 +29,8 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string if listErr != nil { messages = append(messages, fmt.Sprintf("list tools: %v", listErr)) } - // Only built-in executors (with InputSchema) become backend toolsrv. + // Only loaded tools become backend callable tools. allTools = toolInfosToBackend(allToolInfos) - - // Append named tool scripts for preamble listing only. - allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...) } hooks := Hooks{} @@ -139,8 +136,11 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string preamble += "\n# Available Tools\n\n" + toolListing.String() } + // Append 9P discovery instructions for loading additional tools. + preamble += "\n## Tool discovery\n\nUse the 9P filesystem to discover and load tools dynamically:\n- **List available tools** — `ollie-9p read session/{id}/agent/{aid}/tools`\n- **Load a tool** — `echo \"toolname\" | ollie-9p write session/{id}/agent/{aid}/tools`\n- **See loaded tools** — `ollie-9p read session/{id}/agent/{aid}/tools.loaded`\n" + return &Runtime{ - ToolServer: srv, + ToolServer: srv, Tools: allTools, Exec: exec, ClassifyTool: classify, diff --git a/cmd/ollie-remote/main.go b/cmd/ollie-remote/main.go index ed40b6f..e8053c7 100644 --- a/cmd/ollie-remote/main.go +++ b/cmd/ollie-remote/main.go @@ -29,8 +29,8 @@ import ( "sync" "syscall" - "ollie/toolsrv" "ollie/tools/builtin" + "ollie/toolsrv" ) //go:embed sandbox/default.yaml @@ -98,6 +98,15 @@ func main() { } // Signal handling + + // Auto-load essential core tools into the registry so they are immediately + // available for agent execution without requiring a separate tool_load call. + for _, name := range []string{"shell", "reasoning_think"} { + if err := server.LoadTool(name); err != nil { + fmt.Fprintf(os.Stderr, "auto-load %s: %v\n", name, err) + } + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigCh := make(chan os.Signal, 1) @@ -194,6 +203,7 @@ type rpcResponse struct { ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` + Stream bool `json:"stream,omitempty"` } type rpcError struct { @@ -205,9 +215,20 @@ type outputNotif struct { Data string `json:"data"` } +type lockedEncoder struct { + mu sync.Mutex + enc *json.Encoder +} + +func (e *lockedEncoder) Encode(v any) error { + e.mu.Lock() + defer e.mu.Unlock() + return e.enc.Encode(v) +} + func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Writer) { dec := json.NewDecoder(in) - enc := json.NewEncoder(out) + enc := &lockedEncoder{enc: json.NewEncoder(out)} for { var req rpcRequest @@ -223,180 +244,203 @@ func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Wri continue } - switch req.Method { - case "list_tools": - tools, err := srv.ListTools() - if err != nil { - enc.Encode(rpcResponse{ - JSONRPC: "2.0", - ID: req.ID, - Error: &rpcError{Code: -32000, Message: err.Error()}, - }) - } else { - enc.Encode(rpcResponse{ - JSONRPC: "2.0", - ID: req.ID, - Result: tools, - }) - } + go handleRPC(ctx, srv, req, enc) + } +} - case "host_info": - info := map[string]any{ - "platform": runtime.GOOS, - "arch": runtime.GOARCH, - "is_git_repo": isGitRepo(*cwd), - } +func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lockedEncoder) { + switch req.Method { + case "list_tools": + tools, err := srv.ListTools() + if err != nil { enc.Encode(rpcResponse{ JSONRPC: "2.0", ID: req.ID, - Result: info, + Error: &rpcError{Code: -32000, Message: err.Error()}, }) - - case "ping": + } else { enc.Encode(rpcResponse{ JSONRPC: "2.0", ID: req.ID, - Result: "pong", + Result: tools, }) + } - case "set_env": - var params struct { - Key string `json:"key"` - Value string `json:"value"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - srv.SetEnv(params.Key, params.Value) + case "host_info": + info := map[string]any{ + "platform": runtime.GOOS, + "arch": runtime.GOARCH, + "is_git_repo": isGitRepo(*cwd), + } + enc.Encode(rpcResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: info, + }) + + case "ping": + enc.Encode(rpcResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: "pong", + }) + + case "set_env": + var params struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + srv.SetEnv(params.Key, params.Value) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) + + case "set_cwd": + var params struct { + Dir string `json:"dir"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + srv.SetCWD(params.Dir) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) + + case "set_allow_tools": + var params struct { + Names []string `json:"names"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + srv.SetAllowTools(params.Names) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) + + case "detach": + ok := srv.Detach() + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok}) + + case "list_detached": + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: srv.ListDetachedRaw()}) + + case "signal_detached": + var params struct { + PID int `json:"pid"` + Signal int `json:"signal"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + if err := srv.SignalDetached(params.PID, syscall.Signal(params.Signal)); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) + } else { enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) + } - case "set_cwd": - var params struct { - Dir string `json:"dir"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - srv.SetCWD(params.Dir) - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) + case "get_detached_output": + var params struct { + PID int `json:"pid"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + out, err := srv.GetDetachedOutput(params.PID) + if err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) + } else { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: out}) + } - case "set_allow_tools": - var params struct { - Names []string `json:"names"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - srv.SetAllowTools(params.Names) - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) + case "dismiss_detached": + var params struct { + PID int `json:"pid"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + ok := srv.DismissDetached(params.PID) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok}) - case "detach": - ok := srv.Detach() - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok}) + case "is_parallel_read": + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + ok := srv.IsParallelRead(params.Name) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok}) - case "list_detached": - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: srv.ListDetachedRaw()}) + case "result_tier": + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + tier := srv.ResultTier(params.Name) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier}) - case "signal_detached": - var params struct { - PID int `json:"pid"` - Signal int `json:"signal"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - if err := srv.SignalDetached(params.PID, syscall.Signal(params.Signal)); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) - } else { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) - } + case "result_tier_args": + var params struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + tier := srv.ResultTierArgs(params.Name, params.Args) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier}) - case "get_detached_output": - var params struct { - PID int `json:"pid"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - out, err := srv.GetDetachedOutput(params.PID) - if err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) - } else { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: out}) - } + case "tool_load": + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) + return + } + if err := srv.LoadTool(params.Name); err != nil { + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) + } else { + res, _ := json.Marshal(map[string]string{"result": "loaded: " + params.Name}) + enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(res)}) + } - case "dismiss_detached": - var params struct { - PID int `json:"pid"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - ok := srv.DismissDetached(params.PID) - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok}) - - case "is_parallel_read": - var params struct { - Name string `json:"name"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - ok := srv.IsParallelRead(params.Name) - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok}) - - case "result_tier": - var params struct { - Name string `json:"name"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - tier := srv.ResultTier(params.Name) - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier}) - - case "result_tier_args": - var params struct { - Name string `json:"name"` - Args json.RawMessage `json:"args"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) - continue - } - tier := srv.ResultTierArgs(params.Name, params.Args) - enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier}) - - default: - // Dispatch to named tool via the tool server's CallTool. - streamCtx := toolsrv.WithOutputStream(ctx, func(data string) { - enc.Encode(rpcResponse{ - JSONRPC: "2.0", - Result: outputNotif{Data: data}, - }) + default: + // Dispatch to named tool via the tool server's CallTool. + // Correlate streaming output with the request so multiplexed + // clients can route it to the correct caller. + streamCtx := toolsrv.WithOutputStream(ctx, func(data string) { + enc.Encode(rpcResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: outputNotif{Data: data}, + Stream: true, + }) + }) + result, err := srv.CallTool(streamCtx, req.Method, req.Params) + if err != nil { + enc.Encode(rpcResponse{ + JSONRPC: "2.0", + ID: req.ID, + Error: &rpcError{Code: -32000, Message: err.Error()}, + }) + } else { + enc.Encode(rpcResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(result), }) - result, err := srv.CallTool(streamCtx, req.Method, req.Params) - if err != nil { - enc.Encode(rpcResponse{ - JSONRPC: "2.0", - ID: req.ID, - Error: &rpcError{Code: -32000, Message: err.Error()}, - }) - } else { - enc.Encode(rpcResponse{ - JSONRPC: "2.0", - ID: req.ID, - Result: json.RawMessage(result), - }) - } } } } diff --git a/cmd/olliesrv/main.go b/cmd/olliesrv/main.go index 55185f5..05d13d8 100644 --- a/cmd/olliesrv/main.go +++ b/cmd/olliesrv/main.go @@ -101,12 +101,6 @@ func runServer(sockPath string) { agentsDirs := agent.AgentsDirs() sessionsDir := paths.DataDir() + "/sessions" - // Create the tool registry - toolRegistry, regErr := toolsrv.NewRegistry() - if regErr != nil { - fmt.Fprintf(os.Stderr, "warning: tool registry: %v\n", regErr) - } - // Create the skills registry skillsRegistry := skills.NewRegistry() @@ -120,7 +114,6 @@ func runServer(sockPath string) { rootTree := fs.NewRoot(fs.Config{ Ctx: daemonCtx, - ToolRegistry: toolRegistry, SkillsRegistry: skillsRegistry, AgentsDir: agentsDirs[0], SessionsDir: sessionsDir, diff --git a/data/agents/copilot.json b/data/agents/copilot.json index 3ba3e1b..92de5d5 100644 --- a/data/agents/copilot.json +++ b/data/agents/copilot.json @@ -3,11 +3,17 @@ "$OLLIE_CFG_PATH/prompts/user-preferences.md", "$OLLIE_CFG_PATH/prompts/agent-copilot.md" ], - "tools": true, + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_glob", + "file_grep" + ], "allowTools": ["file_read", "file_glob", "file_grep"], "hooks": { "turnError": [ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/default.json b/data/agents/default.json index f62e119..dabd1a1 100644 --- a/data/agents/default.json +++ b/data/agents/default.json @@ -4,6 +4,38 @@ "$OLLIE_CFG_PATH/prompts/agent-coding.md", "bash -c 'd=$PWD; while [ \"$d\" != / ]; do [ -d \"$d/.beads\" ] && exec bd prime; [ ! -e \"$d/.git\" ] && break; d=$(dirname \"$d\"); done' 2>/dev/null || true" ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_write", + "file_edit", + "file_glob", + "file_grep", + "memory_recall", + "memory_remember", + "lsp_definition", + "lsp_diagnostics", + "lsp_hover", + "lsp_references", + "lsp_symbols", + "gui_screenshot", + "gui_windows", + "gui_clipboard", + "gui_notify", + "image_read", + "skill_list", + "skill_load", + "subagent_generate", + "process_list", + "process_output", + "process_signal", + "process_dismiss", + "logseq", + "system_logs", + "browser_screencap", + "route" + ], "maxSteps": 50, "temperature": 0.5, "maxTokens": 16384, @@ -16,4 +48,4 @@ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/driver.json b/data/agents/driver.json index 6e5aac9..721a5a3 100644 --- a/data/agents/driver.json +++ b/data/agents/driver.json @@ -4,6 +4,38 @@ "$OLLIE_CFG_PATH/prompts/agent-coding.md", "bash -c 'd=$PWD; while [ \"$d\" != / ]; do [ -d \"$d/.beads\" ] && exec bd prime; [ ! -e \"$d/.git\" ] && break; d=$(dirname \"$d\"); done' 2>/dev/null || true" ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_write", + "file_edit", + "file_glob", + "file_grep", + "memory_recall", + "memory_remember", + "lsp_definition", + "lsp_diagnostics", + "lsp_hover", + "lsp_references", + "lsp_symbols", + "gui_screenshot", + "gui_windows", + "gui_clipboard", + "gui_notify", + "image_read", + "skill_list", + "skill_load", + "subagent_generate", + "process_list", + "process_output", + "process_signal", + "process_dismiss", + "logseq", + "system_logs", + "browser_screencap", + "route" + ], "backend": "openrouter", "model": "deepseek/deepseek-v4-flash", "maxSteps": 50, @@ -14,4 +46,4 @@ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/explorer.json b/data/agents/explorer.json index de961b6..653fd0a 100644 --- a/data/agents/explorer.json +++ b/data/agents/explorer.json @@ -1,7 +1,19 @@ { "prompt": [ "$OLLIE_CFG_PATH/prompts/user-preferences.md", - "$OLLIE_CFG_PATH/prompts/agent-explorer.md", + "$OLLIE_CFG_PATH/prompts/agent-explorer.md" + ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_glob", + "file_grep", + "gui_screenshot", + "gui_windows", + "gui_clipboard", + "gui_notify", + "image_read" ], "maxSteps": 40, "temperature": 0.3, @@ -11,4 +23,4 @@ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/librarian.json b/data/agents/librarian.json index c9ee15e..a5e197b 100644 --- a/data/agents/librarian.json +++ b/data/agents/librarian.json @@ -1,11 +1,17 @@ { "prompt": [ "$OLLIE_CFG_PATH/prompts/user-preferences.md", - "$OLLIE_CFG_PATH/prompts/agent-librarian.md", + "$OLLIE_CFG_PATH/prompts/agent-librarian.md" + ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_glob", + "file_grep", + "memory_recall", + "memory_remember" ], - "maxSteps": 20, - "temperature": 0.3, - "maxTokens": 16384, "allowTools": [ "file_read", "file_glob", @@ -13,9 +19,12 @@ "memory_recall", "memory_remember" ], + "maxSteps": 20, + "temperature": 0.3, + "maxTokens": 16384, "hooks": { "turnError": [ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/navigator.json b/data/agents/navigator.json index 25b0124..bbaa463 100644 --- a/data/agents/navigator.json +++ b/data/agents/navigator.json @@ -1,7 +1,20 @@ { "prompt": [ "$OLLIE_CFG_PATH/prompts/user-preferences.md", - "$OLLIE_CFG_PATH/prompts/agent-navigator.md", + "$OLLIE_CFG_PATH/prompts/agent-navigator.md" + ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_glob", + "file_grep", + "gui_screenshot", + "gui_windows", + "gui_clipboard", + "gui_notify", + "image_read", + "browser_screencap" ], "temperature": 0.3, "maxTokens": 8192, @@ -11,4 +24,4 @@ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/taskmanager.json b/data/agents/taskmanager.json index 78df5db..8efcd19 100644 --- a/data/agents/taskmanager.json +++ b/data/agents/taskmanager.json @@ -1,11 +1,21 @@ { "prompt": [ "$OLLIE_CFG_PATH/prompts/user-preferences.md", - "$OLLIE_CFG_PATH/prompts/agent-taskmanager.md", + "$OLLIE_CFG_PATH/prompts/agent-taskmanager.md" + ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_glob", + "file_grep", + "file_write", + "file_edit", + "subagent_generate", + "subagent_spawn", + "memory_recall", + "memory_remember" ], - "maxSteps": 20, - "temperature": 0.3, - "maxTokens": 8192, "allowTools": [ "file_read", "file_glob", @@ -17,9 +27,12 @@ "memory_recall", "memory_remember" ], + "maxSteps": 20, + "temperature": 0.3, + "maxTokens": 8192, "hooks": { "turnError": [ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/agents/theo.json b/data/agents/theo.json index 007b0bd..4637be6 100644 --- a/data/agents/theo.json +++ b/data/agents/theo.json @@ -1,7 +1,30 @@ { "prompt": [ "$OLLIE_CFG_PATH/prompts/user-preferences.md", - "$OLLIE_CFG_PATH/prompts/agent-theo.md", + "$OLLIE_CFG_PATH/prompts/agent-theo.md" + ], + "autoLoad": [ + "shell", + "reasoning_think", + "file_read", + "file_glob", + "file_grep", + "file_write", + "file_edit", + "gui_screenshot", + "gui_windows", + "gui_clipboard", + "gui_notify", + "image_read", + "memory_recall", + "memory_remember", + "lsp_definition", + "lsp_diagnostics", + "lsp_hover", + "lsp_references", + "lsp_symbols", + "skill_list", + "skill_load" ], "maxSteps": 30, "temperature": 0.3, @@ -11,4 +34,4 @@ "$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID" ] } -} +} \ No newline at end of file diff --git a/data/tools/reasoning_think b/data/tools/reasoning_think new file mode 100755 index 0000000..421eb2a --- /dev/null +++ b/data/tools/reasoning_think @@ -0,0 +1,6 @@ +#!/bin/bash +# reasoning_think — scratchpad for externalizing reasoning. +# The thought is recorded in conversation history but not shown to the user. +# Takes up practically zero context space — the value is in writing it. +# This is a no-op: the LLM handler logs the thought internally. +exit 0 \ No newline at end of file diff --git a/data/tools/reasoning_think.meta b/data/tools/reasoning_think.meta new file mode 100644 index 0000000..8d992b5 --- /dev/null +++ b/data/tools/reasoning_think.meta @@ -0,0 +1,16 @@ +{ + "description": "Scratchpad for externalizing reasoning. Recorded in history but not shown to the user.", + "prompt": "Scratchpad for externalizing reasoning. Recorded in conversation history but not shown to the user.", + "args": { + "type": "object", + "required": ["thought"], + "properties": { + "thought": { + "type": "string", + "description": "Your reasoning." + } + } + }, + "tier": "hot", + "readOnly": true +} \ No newline at end of file diff --git a/data/tools/shell b/data/tools/shell new file mode 100755 index 0000000..e181e54 --- /dev/null +++ b/data/tools/shell @@ -0,0 +1,33 @@ +#!/bin/bash +# Shell — execute a single bash command in a sandboxed environment. +# Already running inside the sandbox applied by toolsrv. +# Reads JSON args from stdin: {"cmd": "...", "timeout": 30, "elevated": false} +# +# This is the bootstrap primitive. All other tool scripts use this +# to execute commands on the system. + +set -euo pipefail + +# Parse JSON args from stdin. +args=$(cat) + +# Extract fields using python (preferred) or jq (fallback). +if command -v python3 &>/dev/null; then + cmd=$(echo "$args" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('cmd',''))") +elif command -v python &>/dev/null; then + cmd=$(echo "$args" | python -c "import json,sys; d=json.load(sys.stdin); print(d.get('cmd',''))") +elif command -v jq &>/dev/null; then + cmd=$(echo "$args" | jq -r '.cmd // ""') +else + echo '{"isError":true,"content":[{"type":"text","text":"shell: need python3, python, or jq to parse args"}]}' + exit 1 +fi + +if [ -z "$cmd" ]; then + echo '{"isError":true,"content":[{"type":"text","text":"shell: cmd is required"}]}' + exit 1 +fi + +# Execute the command. The sandbox (landlock) is already applied by toolsrv. +# We use eval to handle pipes, redirects, and compound commands. +eval "$cmd" \ No newline at end of file diff --git a/data/tools/shell.meta b/data/tools/shell.meta new file mode 100644 index 0000000..35c898f --- /dev/null +++ b/data/tools/shell.meta @@ -0,0 +1,28 @@ +{ + "description": "Execute a single bash command in a sandboxed environment.", + "prompt": "Execute a single bash command in a sandboxed environment.\n\nUsage: {\"cmd\": \"your command here\"}\n\nSandbox prevents dangerous operations. Use for computation, builds, scripting.\ntimeout applies to each call (default: 30s). A non-zero exit is an error.", + "args": { + "type": "object", + "required": ["cmd"], + "properties": { + "cmd": { + "type": "string", + "description": "Bash command to execute." + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds (default: 30). Use 0 for no timeout." + }, + "sandbox": { + "type": "string", + "description": "Sandbox profile name (default: default)." + }, + "elevated": { + "type": "boolean", + "description": "Run outside the sandbox via elevation broker." + } + } + }, + "tier": "hot", + "readOnly": false +} \ No newline at end of file diff --git a/fs/agentfiles.go b/fs/agentfiles.go index 0729329..2465f21 100644 --- a/fs/agentfiles.go +++ b/fs/agentfiles.go @@ -227,61 +227,37 @@ func listAgents(ctx HandlerCtx) ([]FsNodeDecl, error) { return []byte("#!/bin/sh\nexec tail -f \"$(dirname \"$0\")/chat\"\n"), nil })), - // tools — list all available tools - Leaf("tools", 0666, - Read(func(ctx HandlerCtx) ([]byte, error) { - if ctx.ToolReg == nil { - return []byte("registry not available\n"), nil - } - var sb strings.Builder - for _, ti := range ctx.ToolReg.Summaries() { - if ti.Description != "" { - fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description) - } else { - sb.WriteString(ti.Name + "\n") + // tools — list all available tools (read) / load a tool (write) + Leaf("tools", 0666, + Read(func(ctx HandlerCtx) ([]byte, error) { + // Use the session's dedicated tools management connection, + // NOT a.ToolServer() which would deadlock if the agent + // loop is mid-execution (it holds the Conn mutex). + if s.toolsConn == nil { + return []byte("(no tool server)\n"), nil } - } - return []byte(sb.String()), nil - }), - Write(func(ctx HandlerCtx, data []byte) error { - input := strings.TrimSpace(string(data)) - if input == "" { - return nil - } - if ctx.ToolReg == nil { - return fmt.Errorf("registry not available") - } - return ctx.ToolReg.Load(s.id, input) - }), - ), + loaded, err := s.toolsConn.ListTools() + if err != nil { + return []byte(fmt.Sprintf("(error: %v)\n", err)), nil + } + var sb strings.Builder + for _, ti := range loaded { + if ti.Description != "" { + fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description) + } else { + sb.WriteString(ti.Name + "\n") + } + } + return []byte(sb.String()), nil + }), + Write(func(ctx HandlerCtx, data []byte) error { + return s.loadTool(string(data), a) + }), + ), + + - // toolsrv.loaded — currently loaded tools - Leaf("toolsrv.loaded", 0444, Read(func(ctx HandlerCtx) ([]byte, error) { - if ctx.ToolReg == nil { - return []byte("registry not available\n"), nil - } - loaded := ctx.ToolReg.Loaded(s.id) - if len(loaded) == 0 { - return []byte("(no tools loaded)\n"), nil - } - var sb strings.Builder - for _, ti := range loaded { - if ti.Description != "" { - fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description) - } else { - sb.WriteString(ti.Name + "\n") - } - } - return []byte(sb.String()), nil - })), - // toolsrv.rev — tool registry revision - Leaf("toolsrv.rev", 0444, Read(func(ctx HandlerCtx) ([]byte, error) { - if ctx.ToolReg == nil { - return []byte("0\n"), nil - } - return []byte(fmt.Sprintf("%d\n", ctx.ToolReg.Revision(s.id))), nil - })), // name — agent name Leaf("name", 0666, diff --git a/fs/lifecycle.go b/fs/lifecycle.go index 0080834..aa8ba13 100644 --- a/fs/lifecycle.go +++ b/fs/lifecycle.go @@ -510,7 +510,18 @@ func CreateAgent(rs *rootState, sessName string, args []string) error { envBlock := prompts.Environment(cwd, platform, isGitRepo, "") toolSrv := newToolServer() + // Load configured tools before building the runtime. BuildRuntime snapshots + // the loaded-tool list to construct the agent's initial tool definitions. + if cfg != nil { + for _, tl := range cfg.AutoLoad { + if err := loadToolOnRunner(toolSrv, tl); err != nil { + rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessName, tl, err) + } + } + } + rt := agent.BuildRuntime(cfg, toolSrv, cwd, env, sysPrompt, opModel, envBlock) + sess.toolsConn = toolSrv al := NewAgentLog(remoteTarget) @@ -602,4 +613,4 @@ func CreateAgentFromRoot(root *Tree, sessName string, args []string) (string, er return "", fmt.Errorf("agent not created") } return sess.Core.Agent().Name(), nil -} \ No newline at end of file +} diff --git a/fs/newroot.go b/fs/newroot.go index 6691167..13e779a 100644 --- a/fs/newroot.go +++ b/fs/newroot.go @@ -8,7 +8,6 @@ import ( "ollie/elevate" olog "ollie/log" "ollie/skills" - "ollie/toolsrv" coresession "ollie/session" ) @@ -23,7 +22,6 @@ type Config struct { MkdirAll func(string, os.FileMode) error NewCore func(sessionID, agentName, cwd string) (*coresession.Session, error) Yolo bool - ToolRegistry *toolsrv.Registry SkillsRegistry *skills.Registry ModelCache *ModelCache ElevateBroker *elevate.Broker @@ -49,7 +47,6 @@ func NewRoot(cfg Config) *Tree { ctx := HandlerCtx{ Root: nil, // set after tree is built Log: cfg.Log, - ToolReg: cfg.ToolRegistry, Skills: cfg.SkillsRegistry, Models: cfg.ModelCache, Elevate: cfg.ElevateBroker, diff --git a/fs/persist.go b/fs/persist.go index 4e023b3..5bac395 100644 --- a/fs/persist.go +++ b/fs/persist.go @@ -163,7 +163,7 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { promptEnv = []string{ "PRIME_CWD=" + cwd, "PRIME_PLATFORM=" + proc.Info.Platform, -"PRIME_IS_GIT_REPO=" + fmt.Sprintf("%v", proc.Info.IsGitRepo), + "PRIME_IS_GIT_REPO=" + fmt.Sprintf("%v", proc.Info.IsGitRepo), } } else { var dialOpts []toolsrv.Option @@ -212,6 +212,16 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { envBlock := prompts.Environment(cwd, platform, isGitRepo, "") toolSrv := newToolServer() + // Load configured tools before building the runtime. BuildRuntime snapshots + // the loaded-tool list to construct the restored agent's tool definitions. + if cfg != nil { + for _, tl := range cfg.AutoLoad { + if err := loadToolOnRunner(toolSrv, tl); err != nil { + rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessID, tl, err) + } + } + } + rt := agent.BuildRuntime(cfg, toolSrv, cwd, env, sysPrompt, opModel, envBlock) restoredSession := agent.RestoreHistory(ps) @@ -253,9 +263,21 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { sess.uname = uname sess.SetAgentLog(uname, al) sess.proc = proc + // Use the multiplexed connection for both agent execution and 9P management. + sess.toolsConn = toolSrv replayMessagesToLog(sess.AgentLog(), ps.Messages) + // Pre-load autoLoad tools into the remote. + if cfg != nil { + ag := sess.Core.Agent() + for _, tl := range cfg.AutoLoad { + if err := sess.loadTool(tl, ag); err != nil { + rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessID, tl, err) + } + } + } + name := sess.Name() rs.mu.Lock() rs.sessions[name] = sess diff --git a/fs/rootfiles.go b/fs/rootfiles.go index d2e06cc..a9531fb 100644 --- a/fs/rootfiles.go +++ b/fs/rootfiles.go @@ -10,6 +10,7 @@ import ( "ollie/agent" "ollie/backend" "ollie/paths" + "ollie/toolsrv" ) // ── Root-level handlers ────────────────────────────────────────── @@ -51,6 +52,20 @@ func readAgents(ctx HandlerCtx) ([]byte, error) { return []byte(sb.String()), nil } +// readTools lists all discoverable tools from disk. +// This is a global catalog — the same for all sessions and agents. +func readTools(ctx HandlerCtx) ([]byte, error) { + tools := toolsrv.DiscoverTools() + if len(tools) == 0 { + return []byte("(no tools found)\n"), nil + } + var sb strings.Builder + for _, ti := range tools { + fmt.Fprintf(&sb, "%-24s %s\n", ti.Name, ti.Description) + } + return []byte(sb.String()), nil +} + func writeRootCtl(ctx HandlerCtx, data []byte) error { cmd := strings.TrimSpace(string(data)) switch cmd { diff --git a/fs/spec.go b/fs/spec.go index a1c6859..af38262 100644 --- a/fs/spec.go +++ b/fs/spec.go @@ -16,6 +16,7 @@ var treeSpec = Dir("/", Leaf("help", 0444, Read(readHelp), GID("agent")), Leaf("models", 0444, Read(readModels), GID("agent")), Leaf("agents", 0444, Read(readAgents), GID("agent")), + Leaf("tools", 0444, Read(readTools), GID("agent")), Leaf("ctl", 0666, Write(writeRootCtl), GID("agent")), Leaf("eventwait", 0444, Read(readEventwait), @@ -51,4 +52,4 @@ var treeSpec = Dir("/", // nopRead returns empty content. func nopRead(_ HandlerCtx) ([]byte, error) { return nil, nil -} \ No newline at end of file +} diff --git a/fs/types.go b/fs/types.go index c02ba29..2d20a8f 100644 --- a/fs/types.go +++ b/fs/types.go @@ -2,6 +2,8 @@ package fs import ( "context" + "encoding/json" + "fmt" "strings" "sync" "time" @@ -23,6 +25,10 @@ type Session struct { cancel context.CancelFunc proc *toolsrv.Process // toolsrv subprocess (session owns lifecycle) + // toolsConn is the multiplexed connection to the remote tool server, + // shared by agent execution and 9P tool-management handlers. + toolsConn toolsrv.Runner + // AgentLogs for the session's agents, keyed by agent ID. // Backward compat: AgentLog() returns the first agent's log (by map iteration order). AgentLogs map[string]*AgentLog @@ -31,6 +37,10 @@ type Session struct { modelsMu sync.Mutex modelsCache string modelsCacheAt time.Time + + // disallowTools is the set of tool names that cannot be loaded + // for agents in this session. Set from agent config at creation. + disallowTools map[string]struct{} } // AgentLog holds all per-agent state exposed via 9P. @@ -130,6 +140,65 @@ func (sess *Session) InvalidateModelsCache() { sess.modelsMu.Unlock() } +// loadTool loads a tool into the remote ollie-remote process via the +// session's management toolsConn. Returns an error if the tool is in +// the session's disallow list or if the remote rejects it. +// +// This is the single convergent path for tool loading — both the 9P +// agent/{id}/tools write handler and the autoLoad initialization call this. +func (sess *Session) loadTool(name string, ag *agent.Agent) error { + name = strings.TrimSpace(name) + if name == "" { + return nil + } + + // Check disallow list. + sess.mu.RLock() + _, blocked := sess.disallowTools[name] + sess.mu.RUnlock() + if blocked { + return fmt.Errorf("tool %q is disallowed for this session", name) + } + + // Load on the remote ollie-remote process (for agent execution). + // Prefer the session's management toolsConn (avoids deadlocking with + // the agent loop), fall back to the agent's ToolServer. + var runner toolsrv.Runner + if sess.toolsConn != nil { + runner = sess.toolsConn + } else if ag != nil { + runner = ag.ToolServer() + } + return loadToolOnRunner(runner, name) +} + +func loadToolOnRunner(runner toolsrv.Runner, name string) error { + if runner == nil { + return nil + } + ctxTO, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + args, _ := json.Marshal(map[string]string{"name": name}) + if _, err := runner.CallTool(ctxTO, "tool_load", json.RawMessage(args)); err != nil { + return fmt.Errorf("remote load: %w", err) + } + return nil +} + +// SetDisallowTools replaces the session's disallow tool list. +func (sess *Session) SetDisallowTools(names []string) { + sess.mu.Lock() + defer sess.mu.Unlock() + if len(names) == 0 { + sess.disallowTools = nil + return + } + sess.disallowTools = make(map[string]struct{}, len(names)) + for _, n := range names { + sess.disallowTools[n] = struct{}{} + } +} + func (sess *Session) RunnableID() string { return sess.id } func (sess *Session) Uname() string { return sess.uname } func (sess *Session) Name() string { diff --git a/justfile b/justfile index 9e5bacd..fa1107a 100644 --- a/justfile +++ b/justfile @@ -137,6 +137,8 @@ install-data: # Tools (executables) mkdir -p {{cfg}}/tools/_lib install -m755 data/tools/browser_screencap {{cfg}}/tools/browser_screencap + install -m755 data/tools/reasoning_think {{cfg}}/tools/reasoning_think + install -m755 data/tools/shell {{cfg}}/tools/shell install -m755 data/tools/file_edit {{cfg}}/tools/file_edit install -m755 data/tools/file_glob {{cfg}}/tools/file_glob install -m755 data/tools/file_grep {{cfg}}/tools/file_grep diff --git a/prompts/system_prompt.md b/prompts/system_prompt.md index 3751c1d..43162db 100644 --- a/prompts/system_prompt.md +++ b/prompts/system_prompt.md @@ -24,9 +24,11 @@ You have autonomous access to tools and skills. Proactively load what you need ## Tools -- **`tool_list`** — discover all available tools with descriptions. -- **`tool_load`** — promote a tool to a native callable function: `{"name": "toolname"}`. -- **`tool_active`** — list tools currently loaded in your session. +Tools are loaded into your session at startup (configured in the agent's `autoLoad` list). Additional tools can be loaded dynamically via the 9P filesystem: + +- **List available tools** — write to `session/{id}/agent/{aid}/tools` (empty string lists all) +- **Load a tool** — write the tool name to `session/{id}/agent/{aid}/tools` +- **See loaded tools** — read from `session/{id}/agent/{aid}/tools` Once loaded, a tool becomes a first-class function. Call it directly by name with JSON arguments matching its schema. @@ -51,7 +53,7 @@ Skills are markdown modules that provide specialized domain knowledge, conventio **Procedure** — before reaching for `shell`, follow this sequence: 1. **Is a loaded tool already fit for purpose?** If so, use it directly. -2. **Search available tools** (`tool_list`) — is there an unloaded tool that fits? Load it (`tool_load`) and use it. +2. **Search available tools** — check `session/{id}/agent/{aid}/tools` (read) to see what's available. Load any missing tool by writing its name there. 3. **Only if no tool exists** for the operation, fall back to `shell`. **Common mappings** (not exhaustive): diff --git a/sandbox/default.yaml b/sandbox/default.yaml index fed5b27..c56efee 100644 --- a/sandbox/default.yaml +++ b/sandbox/default.yaml @@ -28,6 +28,7 @@ filesystem: - "/dev" - "{HOME}/img" - "{OLLIE_CFG_PATH}" + - "{OLLIE_SKILLS_PATH}" rw: - "{OLLIE_MEMORY_PATH}" - "{OLLIE_PLAN_PATH}" @@ -78,6 +79,8 @@ filesystem: - "/dev/fuse" - "{OLLIE_DATA_PATH}" - "{HOME}/notes" + - "{OLLIE_AGENTS_PATH}" + - "{OLLIE_PROMPTS_PATH}" rox: - "/usr" - "/lib" @@ -101,16 +104,7 @@ filesystem: - "{HOME}/.cache/uv" - "{HOME}/bin" - "{OLLIE}" - - "{OLLIE_CFG_PATH}" - - "{OLLIE_DATA_PATH}" - - "{OLLIE_MEMORY_PATH}" - - "{OLLIE_PLAN_PATH}" - - "{OLLIE_SKILLS_PATH}" - "{OLLIE_TMP_PATH}" - - "{OLLIE_TOOLS_PATH}" - - "{OLLIE_TRANSCRIPT_PATH}" - - "{OLLIE_AGENTS_PATH}" - - "{OLLIE_PROMPTS_PATH}" # XDG defaults for OLLIE_* paths (in case env vars are unset) - "{XDG_CONFIG_HOME}/ollie" - "{XDG_DATA_HOME}/ollie" diff --git a/tools/builtin/builtins.go b/tools/builtin/builtins.go index 6794618..a1dcfba 100644 --- a/tools/builtin/builtins.go +++ b/tools/builtin/builtins.go @@ -5,12 +5,8 @@ package builtin import "ollie/toolsrv" // Builtins returns the default set of built-in tool handlers. +// Returns nil — there are zero built-in tools. All tools are loaded +// dynamically via the 9P agent/{id}/tools write mechanism. func Builtins() map[string]toolsrv.Handler { - return map[string]toolsrv.Handler{ - "shell": Shell, - "reasoning_think": Think, - "tool_list": ToolList, - "tool_load": ToolLoad, - "tool_active": ToolActive, - } -} + return nil +} \ No newline at end of file diff --git a/tools/builtin/reasoning.go b/tools/builtin/reasoning.go deleted file mode 100644 index d5c3933..0000000 --- a/tools/builtin/reasoning.go +++ /dev/null @@ -1,25 +0,0 @@ -package builtin - -import ( - "context" - "encoding/json" - "fmt" - - "ollie/toolsrv" -) - -// Think is a scratchpad for externalizing reasoning. The thought is recorded -// in conversation history but not shown to the user. Takes up practically -// zero context space — the value is in writing it. -func Think(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { - var a struct { - Thought string `json:"thought"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("reasoning_think: bad args: %w", err) - } - if a.Thought == "" { - return "", fmt.Errorf("reasoning_think: thought is required") - } - return "", nil -} diff --git a/tools/builtin/shell.go b/tools/builtin/shell.go deleted file mode 100644 index 1e39044..0000000 --- a/tools/builtin/shell.go +++ /dev/null @@ -1,38 +0,0 @@ -package builtin - -import ( - "context" - "encoding/json" - "fmt" - - "ollie/toolsrv" -) - -// Shell executes a single bash command in a sandboxed environment. -func Shell(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { - var a struct { - Cmd string `json:"cmd"` - Timeout int `json:"timeout"` - Sandbox string `json:"sandbox"` - Elevated bool `json:"elevated"` - Detach bool `json:"detach"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("shell: bad args: %w", err) - } - if a.Cmd == "" { - return "", fmt.Errorf("shell: cmd is required") - } - timeout := a.Timeout - if timeout <= 0 { - timeout = 30 - } - if a.Elevated { - return srv.ExecuteElevated(ctx, a.Cmd, srv.CWD(), timeout, a.Detach) - } - sandboxName := a.Sandbox - if sandboxName == "" { - sandboxName = "default" - } - return srv.ExecuteInSandbox(ctx, a.Cmd, "bash", timeout, sandboxName, false, "", a.Detach) -} diff --git a/tools/builtin/tool.go b/tools/builtin/tool.go deleted file mode 100644 index ce3d80f..0000000 --- a/tools/builtin/tool.go +++ /dev/null @@ -1,97 +0,0 @@ -package builtin - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "ollie/toolsrv" -) - -// ToolList lists all available tools from the global registry. -func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { - reg := srv.ToolRegistry() - if reg == nil { - return "", fmt.Errorf("tool_list: no registry available") - } - - // Refresh loaded tool schemas from disk so the model sees current definitions. - sid := srv.SessionID() - if sid != "" { - reg.RefreshLoaded(sid) - } - - summaries := reg.Summaries() - if len(summaries) == 0 { - return "(no tools found)", nil - } - var out strings.Builder - for _, s := range summaries { - out.WriteString(s.Name) - if s.Description != "" { - out.WriteString(" — ") - out.WriteString(s.Description) - } - out.WriteString("\n") - } - - // Notify the agent that the tool listing may have changed so the - // preamble stays in sync with the directory contents. - if srv.OnToolsChanged != nil { - var listing strings.Builder - for _, s := range summaries { - if s.Description != "" { - fmt.Fprintf(&listing, "- **%s** — %s\n", s.Name, s.Description) - } - } - srv.OnToolsChanged(listing.String()) - } - - return strings.TrimRight(out.String(), "\n"), nil -} - -// ToolLoad loads a tool into the current session. -func ToolLoad(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { - var a struct { - Name string `json:"name"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("tool_load: bad args: %w", err) - } - if a.Name == "" { - return "", fmt.Errorf("tool_load: name is required") - } - reg := srv.ToolRegistry() - sid := srv.SessionID() - if reg == nil || sid == "" { - return "", fmt.Errorf("tool_load: no session registry") - } - if err := reg.Load(sid, a.Name); err != nil { - return "", fmt.Errorf("tool_load: %w", err) - } - return fmt.Sprintf("loaded: %s", a.Name), nil -} - -// ToolActive lists tools currently loaded (promoted) in this session. -func ToolActive(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { - reg := srv.ToolRegistry() - sid := srv.SessionID() - if reg == nil || sid == "" { - return "(no tools loaded)", nil - } - loaded := reg.Loaded(sid) - if len(loaded) == 0 { - return "(no tools loaded)", nil - } - var out strings.Builder - for _, t := range loaded { - out.WriteString(t.Name) - if t.Description != "" { - out.WriteString(" — ") - out.WriteString(t.Description) - } - out.WriteString("\n") - } - return strings.TrimRight(out.String(), "\n"), nil -} diff --git a/toolsrv/conn.go b/toolsrv/conn.go index 5879c9f..3e856ca 100644 --- a/toolsrv/conn.go +++ b/toolsrv/conn.go @@ -16,7 +16,13 @@ import ( // agent layer uses via type assertions. Both local and remote connections // return the same *Conn — the only difference is the underlying transport. type Conn struct { - mu sync.Mutex + writeMu sync.Mutex + pendingMu sync.Mutex + pending map[int64]*pendingCall + closed chan struct{} + closeOnce sync.Once + readErr error + enc *json.Encoder dec *json.Decoder nextID atomic.Int64 @@ -32,20 +38,28 @@ type Conn struct { // NewConn wraps an io.ReadWriteCloser as a JSON-RPC connection. // The cleanup function is called when Close() is invoked. func NewConn(rwc io.ReadWriteCloser, cleanup func()) *Conn { - return &Conn{ + c := &Conn{ enc: json.NewEncoder(rwc), dec: json.NewDecoder(bufio.NewReader(rwc)), cleanup: cleanup, + pending: make(map[int64]*pendingCall), + closed: make(chan struct{}), } + go c.readLoop() + return c } // NewConnSplit creates a Conn from separate reader/writer (e.g. stdin/stdout pipes). func NewConnSplit(r io.Reader, w io.WriteCloser, cleanup func()) *Conn { - return &Conn{ + c := &Conn{ enc: json.NewEncoder(w), dec: json.NewDecoder(bufio.NewReader(r)), cleanup: cleanup, + pending: make(map[int64]*pendingCall), + closed: make(chan struct{}), } + go c.readLoop() + return c } // --- Runner interface --- @@ -84,9 +98,9 @@ func (c *Conn) SetAllowTools(names []string) { } func (c *Conn) SetOnToolsChanged(fn func(string)) { - c.mu.Lock() + c.pendingMu.Lock() c.onToolsChanged = fn - c.mu.Unlock() + c.pendingMu.Unlock() } func (c *Conn) Detach() bool { @@ -175,6 +189,7 @@ func (c *Conn) ResultTierArgs(name string, args json.RawMessage) string { // Close shuts down the connection and subprocess. func (c *Conn) Close() { + c.fail(fmt.Errorf("connection closed")) if c.cleanup != nil { c.cleanup() } @@ -199,6 +214,55 @@ func (c *Conn) FetchHostInfo() (HostInfo, error) { return info, nil } +type pendingCall struct { + response chan rpcResponse + ctx context.Context +} + +func (c *Conn) readLoop() { + for { + var resp rpcResponse + if err := c.dec.Decode(&resp); err != nil { + c.fail(fmt.Errorf("rpc read: %w", err)) + return + } + + c.pendingMu.Lock() + call := c.pending[resp.ID] + c.pendingMu.Unlock() + if call == nil { + continue + } + + // Tool output is sent as an intermediate response with the request ID. + var notif outputNotification + if resp.Stream && resp.Error == nil && json.Unmarshal(resp.Result, ¬if) == nil { + if notif.Data != "" { + StreamOutput(call.ctx, notif.Data) + } + continue + } + + c.pendingMu.Lock() + delete(c.pending, resp.ID) + c.pendingMu.Unlock() + call.response <- resp + } +} + +func (c *Conn) fail(err error) { + c.closeOnce.Do(func() { + c.pendingMu.Lock() + c.readErr = err + close(c.closed) + for id, call := range c.pending { + delete(c.pending, id) + call.response <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}} + } + c.pendingMu.Unlock() + }) +} + // --- JSON-RPC internals --- func (c *Conn) call(method string, params json.RawMessage) (json.RawMessage, error) { @@ -207,41 +271,45 @@ func (c *Conn) call(method string, params json.RawMessage) (json.RawMessage, err func (c *Conn) callStreaming(ctx context.Context, method string, params json.RawMessage) (json.RawMessage, error) { id := c.nextID.Add(1) - req := rpcRequest{ - JSONRPC: "2.0", - ID: id, - Method: method, - Params: params, + call := &pendingCall{response: make(chan rpcResponse, 1), ctx: ctx} + c.pendingMu.Lock() + select { + case <-c.closed: + c.pendingMu.Unlock() + return nil, fmt.Errorf("rpc %s: connection closed", method) + default: } + c.pending[id] = call + c.pendingMu.Unlock() - c.mu.Lock() - if err := c.enc.Encode(req); err != nil { - c.mu.Unlock() + c.writeMu.Lock() + err := c.enc.Encode(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}) + c.writeMu.Unlock() + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() return nil, fmt.Errorf("rpc write %s: %w", method, err) } - // Read responses, handling interleaved streaming notifications. - for { - var resp rpcResponse - if err := c.dec.Decode(&resp); err != nil { - c.mu.Unlock() - return nil, fmt.Errorf("rpc read %s: %w", method, err) - } - - // Notification: id == 0 means streaming output or event. - if resp.ID == 0 && resp.Result != nil { - var notif outputNotification - if json.Unmarshal(resp.Result, ¬if) == nil && notif.Data != "" { - StreamOutput(ctx, notif.Data) - } - continue - } - - // Actual response for our request. - c.mu.Unlock() + select { + case resp := <-call.response: if resp.Error != nil { return nil, fmt.Errorf("rpc %s: %s", method, resp.Error.Message) } return resp.Result, nil + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, ctx.Err() + case <-c.closed: + c.pendingMu.Lock() + err := c.readErr + c.pendingMu.Unlock() + if err == nil { + err = fmt.Errorf("connection closed") + } + return nil, fmt.Errorf("rpc %s: %w", method, err) } } diff --git a/toolsrv/rpc.go b/toolsrv/rpc.go index 552a504..23d04c1 100644 --- a/toolsrv/rpc.go +++ b/toolsrv/rpc.go @@ -16,6 +16,7 @@ type rpcResponse struct { ID int64 `json:"id"` Result json.RawMessage `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` + Stream bool `json:"stream,omitempty"` } type rpcError struct { diff --git a/toolsrv/server.go b/toolsrv/server.go index 9d32d95..d0a7074 100644 --- a/toolsrv/server.go +++ b/toolsrv/server.go @@ -143,80 +143,25 @@ func WithToolRegistry(r *Registry, sessionID string) Option { } } - -// ListTools implements Server, returning shell plus any -// tools promoted in the session's tool registry. -func (e *Server) ListTools() ([]ToolInfo, error) { - all := []ToolInfo{ - { - Name: "shell", - Description: `Execute a single bash command in a sandboxed environment. - -Usage: {"cmd": "your command here"} - -Sandbox prevents dangerous operations. Use for computation, builds, scripting. -timeout applies to each call (default: 30s). A non-zero exit is an error.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "required": ["cmd"], - "properties": { - "cmd": {"type": "string", "description": "Bash command to execute."}, - "timeout": {"type": "integer", "description": "Timeout in seconds (default: 30). Use 0 for no timeout."}, - "sandbox": {"type": "string", "description": "Sandbox profile name (default: default)."}, - "elevated": {"type": "boolean", "description": "Run outside the sandbox via elevation broker."} - } - }`), - }, - { - Name: "tool_list", - Description: `List all available tools that can be loaded. - -Usage: {"name": "toolname"} — if name is provided, loads that tool. -Otherwise lists all tools with descriptions.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "properties": {} - }`), - }, - { - Name: "tool_load", - Description: `Load a tool by name into the current session. - -Usage: {"name": "toolname"} - -After loading, the tool becomes a native callable function.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "required": ["name"], - "properties": { - "name": {"type": "string", "description": "Tool name to load."} - } - }`), - }, - { - Name: "tool_active", - Description: `List tools currently loaded (promoted) in this session. - -Usage: (no arguments) - -Returns tools with descriptions, one per line.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "properties": {} - }`), - }, - { - Name: "reasoning_think", - Description: `Scratchpad for externalizing reasoning. Recorded in history but not shown to the user.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "required": ["thought"], - "properties": { - "thought": {"type": "string", "description": "Your reasoning."} - } - }`), - }, +// LoadTool loads a tool by name into this server's tool registry. +// Returns an error if no registry is configured or the tool cannot be found. +func (e *Server) LoadTool(name string) error { + if e.toolRegistry == nil { + return fmt.Errorf("no tool registry configured") } + if e.sessionID == "" { + return fmt.Errorf("no session ID configured") + } + return e.toolRegistry.Load(e.sessionID, name) +} + + + +// ListTools implements Server, returning tools loaded in the session's +// tool registry. With zero built-in tools, only dynamically loaded tools +// appear here. +func (e *Server) ListTools() ([]ToolInfo, error) { + var all []ToolInfo if e.toolRegistry != nil && e.sessionID != "" { all = append(all, e.toolRegistry.Loaded(e.sessionID)...)