Load hinted tools through the agent namespace

This commit is contained in:
Ollie Agent 2026-08-20 18:29:10 +02:00
parent 28c76a76d4
commit 4bf46ae85d
10 changed files with 119 additions and 71 deletions

View File

@ -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()
}

View File

@ -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,

View File

@ -105,7 +105,7 @@ func matchTools(input string) string {
var sb strings.Builder
sb.WriteString("<tool-hints>\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 <name>\")\n")
sb.WriteString("Then call the loaded tool.\n")
sb.WriteString("</tool-hints>\n")
return sb.String()
}

View File

@ -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 = "<context>\n" + strings.Join(contextParts, "\n") + "\n</context>\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

View File

@ -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 ---

View File

@ -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)

View File

@ -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: name<tab>description per line"),
virtfs.Read(func() ([]byte, error) {

View File

@ -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
}

View File

@ -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

View File

@ -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",