diff --git a/cmd/olliesrv/internal/agent/loop.go b/cmd/olliesrv/internal/agent/loop.go index 127d80c..a7e12a0 100644 --- a/cmd/olliesrv/internal/agent/loop.go +++ b/cmd/olliesrv/internal/agent/loop.go @@ -221,6 +221,20 @@ func (ag *Agent) run(ctx context.Context) error { h.update(msg, results) ag.save() + // A tool call may autoload tools in toolsrv. Refresh this agent's + // schemas only when its per-agent registry revision changed. + if len(toolCalls) > 0 && rt.ToolServer != nil { + revision := rt.ToolServer.ToolRegistryRevision() + if revision == 0 || revision != rt.ToolRevision { + ti, listErr := rt.ToolServer.ListTools() + if listErr != nil { + return fmt.Errorf("refresh tools: %w", listErr) + } + ag.applyTools(ti, revision) + h.injectPreamble(rt.PreambleString()) + } + } + if interrupted { return ctx.Err() } diff --git a/cmd/olliesrv/internal/agent/runtime.go b/cmd/olliesrv/internal/agent/runtime.go index 4273238..02744ed 100644 --- a/cmd/olliesrv/internal/agent/runtime.go +++ b/cmd/olliesrv/internal/agent/runtime.go @@ -143,6 +143,7 @@ type Runtime struct { Preamble *Preamble Tools []backend.Tool ToolMeta map[string]protocol.ToolInfo + ToolRevision uint64 Exec toolExecutor GenParams backend.GenerationParams CompactionModel string @@ -174,6 +175,10 @@ func BuildRuntime(cfg *AgentConfig, srv *toolclient.ToolsrvConn, cwd string, env } allTools = toolInfosToBackend(allToolInfos) } + var toolRevision uint64 + if srv != nil { + toolRevision = srv.ToolRegistryRevision() + } var agentPrompt string var userPrompt string @@ -229,6 +234,7 @@ func BuildRuntime(cfg *AgentConfig, srv *toolclient.ToolsrvConn, cwd string, env ToolServer: srv, Tools: allTools, ToolMeta: toolMeta, + ToolRevision: toolRevision, Exec: exec, Preamble: preamble, GenParams: genParams, diff --git a/cmd/olliesrv/internal/agent/skill_match.go b/cmd/olliesrv/internal/agent/skill_match.go index bc53fe9..821b93b 100644 --- a/cmd/olliesrv/internal/agent/skill_match.go +++ b/cmd/olliesrv/internal/agent/skill_match.go @@ -105,7 +105,7 @@ func matchTools(input string) string { var sb strings.Builder sb.WriteString("\n") - sb.WriteString("Relevant tools for this request (auto-load on first call):\n\n") + sb.WriteString("Relevant tools for this request:\n\n") for _, r := range results { sb.WriteString("→ `") sb.WriteString(r.Skill.Name) @@ -113,7 +113,9 @@ func matchTools(input string) string { sb.WriteString(r.Skill.Description) sb.WriteString("\n") } - sb.WriteString("\nDo NOT say \"I don't have this tool\" — just CALL IT.\n") + sb.WriteString("\nTo make one callable, use client_9p to load it through the agent ctl file:\n") + sb.WriteString("client_9p(op=\"rdwr\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl\", data=\"tool_load \")\n") + sb.WriteString("Then call the loaded tool.\n") sb.WriteString("\n") return sb.String() } diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index f163be7..d6994a4 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -13,6 +13,21 @@ import ( "ollie/toolsrv/protocol" ) +// applyTools updates the model-facing tool schemas and textual tool section. +func (ag *Agent) applyTools(ti []protocol.ToolInfo, revision uint64) { + ag.runtime.Tools = toolInfosToBackend(ti) + meta := make(map[string]protocol.ToolInfo, len(ti)) + for _, info := range ti { + meta[info.Name] = info + } + ag.runtime.ToolMeta = meta + ag.runtime.ToolRevision = revision + toolPreamble := RenderTools(ti) + if ag.runtime.Preamble.Get(SectionTools) != toolPreamble { + ag.runtime.Preamble.Set(SectionTools, toolPreamble) + } +} + // Submit processes one line of user input: it starts an agent turn that streams // events to the bus. If a turn is already in progress the prompt is queued as // an in-stream interruption instead. @@ -95,17 +110,32 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { input = "Run the memory_wake tool now. Follow its output completely before addressing my request.\n\n" + input } - // Build context block: user prompts + matched skills + tool hints + // Fetch tools from toolsrv before constructing the user message. Tool hints + // must be part of the message stored in history and sent to the backend. + if ag.runtime.ToolServer != nil { + ti, listErr := ag.runtime.ToolServer.ListTools() + if listErr != nil { + ag.log.Error("tool server unavailable: %v", listErr) + ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)}) + ag.SetState("idle") + return "" + } + ag.applyTools(ti, ag.runtime.ToolServer.ToolRegistryRevision()) + } + + // Build context block: user prompts, matched skills, and tool hints. var contextParts []string if ag.runtime.UserPrompt != "" { contextParts = append(contextParts, ag.runtime.UserPrompt) } - if toolHints := matchTools(input); toolHints != "" { - contextParts = append(contextParts, toolHints) - } if skillContent := matchSkills(input); skillContent != "" { contextParts = append(contextParts, skillContent) } + if ag.runtime.ToolServer != nil { + if toolHints := matchTools(input); toolHints != "" { + contextParts = append(contextParts, toolHints) + } + } if len(contextParts) > 0 { input = "\n" + strings.Join(contextParts, "\n") + "\n\n\n" + input } @@ -155,27 +185,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { ag.log.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID) - // Fetch tools from toolsrv (the sole owner of tool state). - if ag.runtime.ToolServer != nil { - ti, listErr := ag.runtime.ToolServer.ListTools() - if listErr != nil { - ag.log.Error("tool server unavailable: %v", listErr) - ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)}) - ag.SetState("idle") - return "" - } - ag.runtime.Tools = toolInfosToBackend(ti) - meta := make(map[string]protocol.ToolInfo, len(ti)) - for _, info := range ti { - meta[info.Name] = info - } - ag.runtime.ToolMeta = meta - toolPreamble := RenderTools(ti) - if ag.runtime.Preamble.Get(SectionTools) != toolPreamble { - ag.runtime.Preamble.Set(SectionTools, toolPreamble) - } - } - + // Tool schemas and hints were prepared before the user message was built. // Install turn-scoped output handler to intercept events for reply // accumulation, state updates, and usage tracking. var replyBuf strings.Builder diff --git a/cmd/olliesrv/internal/toolclient/toolsrv.go b/cmd/olliesrv/internal/toolclient/toolsrv.go index c36b9b9..04af629 100644 --- a/cmd/olliesrv/internal/toolclient/toolsrv.go +++ b/cmd/olliesrv/internal/toolclient/toolsrv.go @@ -349,29 +349,22 @@ func (c *ToolsrvConn) SetOnToolsChanged(fn func()) { } // ToolRegistryRevision returns the current tool registry revision. -// Returns 0 if not available (always refresh). func (c *ToolsrvConn) ToolRegistryRevision() uint64 { - fid, err := c.fsys.Open("info", plan9.OREAD) + fid, err := c.fsys.Open("tools_rev", plan9.ORDWR) if err != nil { return 0 } defer fid.Close() - + if _, err := fid.Write([]byte(c.agentID)); err != nil { + return 0 + } data, err := io.ReadAll(fid) if err != nil { return 0 } - - for _, line := range splitLines(string(data)) { - if idx := indexOf(line, '='); idx >= 0 { - if line[:idx] == "tools_rev" { - var rev uint64 - fmt.Sscanf(line[idx+1:], "%d", &rev) - return rev - } - } - } - return 0 + var rev uint64 + fmt.Sscanf(string(data), "%d", &rev) + return rev } // --- Process management --- diff --git a/cmd/toolsrv/internal/server/proc.go b/cmd/toolsrv/internal/server/proc.go index 8e8c0cc..eda1ac8 100644 --- a/cmd/toolsrv/internal/server/proc.go +++ b/cmd/toolsrv/internal/server/proc.go @@ -160,6 +160,20 @@ func (st *State) ListTools(agentID string) []protocol.ToolInfo { return reg.Loaded(agentID) } +// Revision returns the current tool registry revision for an agent. +func (st *State) Revision(agentID string) uint64 { + if agentID == "" { + return 0 + } + st.mu.RLock() + reg := st.registry + st.mu.RUnlock() + if reg == nil { + return 0 + } + return reg.Revision(agentID) +} + // LoadTool loads a tool by name for the given agent. func (st *State) LoadTool(agentID, name string) error { if agentID == "" { @@ -666,6 +680,11 @@ func (st *State) HandleCtl(input string) error { } } +// HandleToolsRevision returns the registry revision for an agent. +func (st *State) HandleToolsRevision(agentID string) string { + return strconv.FormatUint(st.Revision(strings.TrimSpace(agentID)), 10) +} + // HandleToolsRequest handles the tools rdwr: write agent ID, read tool list. func (st *State) HandleToolsRequest(agentID string) string { tools := st.ListTools(agentID) diff --git a/cmd/toolsrv/internal/server/server.go b/cmd/toolsrv/internal/server/server.go index b8e3d02..6e329d2 100644 --- a/cmd/toolsrv/internal/server/server.go +++ b/cmd/toolsrv/internal/server/server.go @@ -132,6 +132,12 @@ func Spec(srv *Server) virtfs.FsNodeDecl { return []byte(srv.Fs.HandleToolsRequest(agentID)), nil }), ), + virtfs.FileNode("tools_rev", 0666, + virtfs.Doc("Tool registry revision: write agent ID, read revision"), + virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { + return []byte(srv.Fs.HandleToolsRevision(string(data))), nil + }), + ), virtfs.FileNode("all", 0444, virtfs.Doc("All available tools on disk: namedescription per line"), virtfs.Read(func() ([]byte, error) { diff --git a/data/agents/default.json b/data/agents/default.json index e86cb7e..d8e3de7 100644 --- a/data/agents/default.json +++ b/data/agents/default.json @@ -5,33 +5,7 @@ "userPrompts": [ "$XDG_CONFIG_HOME/ollie/prompts/user-preferences.md" ], - "autoLoad": [ - "shell", - "reasoning_think", - "memory_wake", - "memory_recall", - "memory_remember", - "memory_nap", - "memory_zoom", - "file_read", - "file_edit", - "file_glob", - "file_grep", - "subagent_spawn", - "codebase_overview", - "code_symbols", - "code_outline", - "code_query", - "code_rewrite", - "code_dependencies", - "lsp_definition", - "lsp_diagnostics", - "lsp_hover", - "lsp_references", - "lsp_symbols", - "lsp_rename", - "lsp_completion" - ], + "autoLoad": [], "temperature": 0.5, "maxTokens": 16384 } diff --git a/data/tools/client_9p b/data/tools/client_9p index a1c7f27..24d050b 100755 --- a/data/tools/client_9p +++ b/data/tools/client_9p @@ -8,6 +8,30 @@ op=$(echo "$args" | jq -r '.op // empty') path=$(echo "$args" | jq -r '.path // empty') data=$(echo "$args" | jq -r '.data // empty') +# Tool calls commonly use the session placeholders from the client_9p API. +# Expand them here because jq returns the path literally and ollie-9p does +# not perform shell expansion on arguments. +expand_path() { + local value=$1 + if [[ "$value" == *'$OLLIE_SESSION_ID'* ]]; then + if [[ -z "${OLLIE_SESSION_ID:-}" ]]; then + echo "error: OLLIE_SESSION_ID is not set" >&2 + exit 1 + fi + value=${value//\$OLLIE_SESSION_ID/$OLLIE_SESSION_ID} + fi + if [[ "$value" == *'$OLLIE_UNAME'* ]]; then + if [[ -z "${OLLIE_UNAME:-}" ]]; then + echo "error: OLLIE_UNAME is not set" >&2 + exit 1 + fi + value=${value//\$OLLIE_UNAME/$OLLIE_UNAME} + fi + printf '%s' "$value" +} + +path=$(expand_path "$path") + if [[ -z "$op" ]]; then echo "error: missing 'op' (read, write, rdwr)" >&2 exit 1 diff --git a/data/tools/client_9p.meta b/data/tools/client_9p.meta index 5ce7e32..d6d6771 100644 --- a/data/tools/client_9p.meta +++ b/data/tools/client_9p.meta @@ -1,6 +1,6 @@ { - "description": "9P filesystem client for reading/writing agent state (plans, ctl, peers, etc.)", - "prompt": "## client_9p\n\nRead and write 9P namespace paths. Use instead of shelling out to ollie-9p.\n\n**Operations:**\n- `read`: read file contents\n- `write`: write data to file\n- `rdwr`: write then read (for request-response files like ctl, generate)\n\n**Examples:**\n```\nclient_9p(op=\"read\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan\")\nclient_9p(op=\"write\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan\", data=\"## Plan\\n- [ ] step one\")\nclient_9p(op=\"rdwr\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl\", data=\"compact\")\n```", + "description": "9P filesystem client for reading/writing virtual agent state paths. Paths are 9P namespace paths, not host filesystem paths.", + "prompt": "## client_9p\n\nRead and write the virtual 9P namespace. The `path` argument is always a 9P namespace path, never a host filesystem path. Use paths such as `session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan`; do not use `/home/...` or other physical paths.\n\n**Operations:**\n- `read`: read file contents\n- `write`: write data to file\n- `rdwr`: write then read (for request-response files like ctl, generate)\n\n**Examples:**\n```\nclient_9p(op=\"read\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan\")\nclient_9p(op=\"write\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan\", data=\"## Plan\\n- [ ] step one\")\nclient_9p(op=\"rdwr\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl\", data=\"compact\")\n```", "args": { "type": "object", "required": ["op", "path"], @@ -12,7 +12,7 @@ }, "path": { "type": "string", - "description": "9P path (e.g., session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan)" + "description": "Virtual 9P namespace path only, such as session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/plan. Never a host filesystem path." }, "data": { "type": "string",