From 44e420fc7e09db21fb55d5da5fd899ff35dde4b1 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Thu, 30 Jul 2026 23:02:08 +0200 Subject: [PATCH] toolsrv: support cmd field in .meta for external tool resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools no longer need to be co-located in $OLLIE_TOOLS_PATH. The .meta sidecar can specify a cmd field pointing to any executable: - Absolute path: use directly - Bare name: resolve via $PATH Resolution order: OLLIE_TOOLS_PATH/ → meta.cmd → PATH lookup. Local wrappers always win, enabling simplified interfaces over complex system binaries. Rewrites doc/WRITING_TOOLS.md to document the plugin model: any executable + .meta = tool, no source tree integration required. --- doc/WRITING_TOOLS.md | 205 ++++++++++++++++++++++++++++--------------- toolsrv/meta.go | 35 ++++++++ toolsrv/server.go | 7 +- 3 files changed, 171 insertions(+), 76 deletions(-) diff --git a/doc/WRITING_TOOLS.md b/doc/WRITING_TOOLS.md index 61a35b6..211ffe0 100644 --- a/doc/WRITING_TOOLS.md +++ b/doc/WRITING_TOOLS.md @@ -1,29 +1,43 @@ # Writing Ollie Tools -Tools are executables that extend ollie's capabilities. They are loaded lazily via `tool_load` and become first-class callable functions with JSON schemas. +A tool is any executable with a `.meta` sidecar file. That's it. -## Tool Discovery +The executable can be a bash script, a Python script, a compiled Go binary, a +Rust binary, a symlink to `/usr/bin/jq` — anything that reads JSON from stdin +and writes results to stdout. The `.meta` file tells the registry how to +present it to the model. -Tools live in `$OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools/`). Any file with a corresponding `.meta` sidecar is a tool. The tool name is the filename (without `.meta`). +No integration with the ollie source tree is required. Drop a `.meta` file in +`$OLLIE_TOOLS_PATH` and the tool is live on the next session. -## Tool Metadata +## Quick start -Each tool has a `.meta` JSON sidecar file alongside the executable. This is the sole source of metadata — the registry reads only `.meta` files. +``` +~/.config/ollie/tools/ +├── my_tool ← executable (any language) +└── my_tool.meta ← JSON metadata +``` + +That's a complete tool. The agent can now `tool_load("my_tool")` and call it. + +## The .meta sidecar + +The `.meta` file is the sole source of discovery and schema. The registry +reads only `.meta` files — it never inspects the executable itself. ```json { - "description": "Does something useful.", - "prompt": "## my_tool\n\nDoes something useful.\n\n**Args**: `path` (required), `verbose` (optional)\n\n```\nmy_tool(path=\"/foo\", verbose=true)\n```", + "description": "One-liner shown in tool listings.", + "prompt": "## my_tool\n\nFull documentation shown when loaded.\n\n**Args**: `path` (required)\n\n```\nmy_tool(path=\"/foo\")\n```", "args": { "type": "object", "required": ["path"], "properties": { - "path": {"type": "string", "description": "File path"}, - "verbose": {"type": "boolean", "description": "Enable verbose output"} + "path": {"type": "string", "description": "File path"} } }, - "tier": "cold", - "readOnly": true + "tier": "hot", + "readOnly": false } ``` @@ -34,96 +48,141 @@ Each tool has a `.meta` JSON sidecar file alongside the executable. This is the | `args` | JSON Schema | Input schema exposed to the model | | `tier` | `"hot"\|"warm"\|"cold"` | Context retention tier (default: hot) | | `readOnly` | bool | Safe for parallel execution with other read tools | +| `cmd` | string | Executable path or name (see Resolution below) | -## Input Protocol +## Executable resolution -Tools receive their arguments as a **JSON object on stdin**. The dispatcher pipes the model's tool call arguments directly — no positional argument translation. +The `cmd` field is optional. When the dispatcher needs to run a tool, it +resolves the executable in this order: -### Python tools +1. **`$OLLIE_TOOLS_PATH/`** — if an executable with the tool's name + exists in the tools directory, use it. This always wins, allowing local + wrappers to shadow system binaries. +2. **`.meta` `cmd` field** — if set, use it: + - Absolute path → use directly + - Bare name → resolve via `$PATH` +3. **`$PATH` lookup** — final fallback, search PATH for the tool name. -```python -#!/usr/bin/env python3 -import os, sys -sys.path.insert(0, os.environ.get('OLLIE_TOOLS_PATH', os.path.dirname(os.path.abspath(__file__)))) -from _lib.args import parse_args +This means: +- **Co-located scripts** (the common case): no `cmd` needed, just put the + executable next to the `.meta`. +- **System binaries**: write a `.meta` with `"cmd": "/usr/bin/rg"` or + `"cmd": "rg"` — no copying or symlinking required. +- **Wrappers**: put a script in `$OLLIE_TOOLS_PATH` that wraps a complex + binary with simpler arguments the model can understand. The wrapper shadows + the system binary automatically. -args = parse_args() -path = args.require("path") # exits with error if missing -verbose = args.get_bool("verbose") # False if missing -count = args.get_int("count", 10) # default 10 -items = args.get_list("items") # [] if missing +### Example: wrapping a system binary + +```json +{ + "description": "Search files with ripgrep.", + "cmd": "rg", + "args": {"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}, + "readOnly": true +} ``` -The `_lib/args.py` module provides: -- `args.require(key)` — get required string arg (exits on missing) -- `args.get(key, default)` — get optional string arg -- `args.get_bool(key, default)` — get boolean arg -- `args.get_int(key, default)` — get integer arg -- `args.get_list(key)` — get list arg -- `args.raw()` — get the raw parsed dict +No executable in the tools directory — the registry finds `rg` via PATH. -### Bash tools +### Example: wrapper that shadows a complex binary + +Put this in `$OLLIE_TOOLS_PATH/docker_ps`: + +```bash +#!/bin/sh +echo '{"containers":'$(docker ps --format json | jq -s '.')'}' +``` + +The wrapper gives the model a clean JSON interface to `docker ps` without +exposing docker's full CLI surface. + +## Input/Output contract + +**Input**: JSON object on stdin. Fields match the `args` schema in the `.meta`. + +**Output (success)**: print result to stdout, exit 0. + +**Output (error)**: print error to stdout or stderr, exit non-zero. + +**Structured output**: return `{"content": [...]}` JSON to pass content blocks directly. + +That's the entire protocol. The dispatcher pipes the model's tool-call arguments +to stdin and captures stdout. No flags, no argv, no environment negotiation. + +## Examples by language + +### Bash ```bash #!/usr/bin/env bash -source "${OLLIE_TOOLS_PATH:-$(dirname "$0")}/_lib/args.sh" - -path=$(arg_require "path") # exits with error if missing -verbose=$(arg_bool "verbose") # "false" if missing -mode=$(arg_get "mode" "default") # "default" if missing -raw=$(arg_json "config") # raw JSON value +set -e +input=$(cat) +path=$(echo "$input" | jq -r '.path') +echo "result: $path" ``` -The `_lib/args.sh` module provides: -- `arg_require key` — get required arg (exits on missing) -- `arg_get key [default]` — get optional arg -- `arg_bool key [default]` — get boolean ("true"/"false") -- `arg_json key` — get raw JSON value -- `arg_raw` — get the full stdin JSON +### Python -### Go tools +```python +#!/usr/bin/env python3 +import json, sys -Compiled Go binaries work the same way — read JSON from stdin, write result to stdout. No `_lib` dependency needed; use `encoding/json` directly. +args = json.load(sys.stdin) +path = args["path"] +print(f"result: {path}") +``` + +### Go + +```go +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +func main() { + var args struct { + Path string `json:"path"` + } + json.NewDecoder(os.Stdin).Decode(&args) + fmt.Printf("result: %s\n", args.Path) +} +``` + +### Any other language + +The contract is language-agnostic. If it's executable and reads JSON from +stdin, it's a valid tool. ## Sandboxing -**Every tool runs inside a Landlock sandbox** by default. Elevation is the escape hatch — when the agent passes `"elevated": true`, the tool runs unsandboxed (but still as the current user). This means by default: - -- Filesystem access is restricted by the sandbox profile (default: `~/.config/ollie/sandbox/default.yaml`) -- Network access may be restricted depending on the profile -- System calls are filtered by Landlock rules +**Every tool runs inside a Landlock sandbox** by default. The sandbox profile +(`~/.config/ollie/sandbox/default.yaml`) controls filesystem and network access. ### Elevation -If your tool needs to run outside the sandbox (e.g., access paths the sandbox restricts), it must request elevation explicitly. The agent passes `"elevated": true` in the tool call, which the dispatcher extracts before piping the remaining args to the tool. - -From the tool's perspective, elevation is transparent — it simply runs outside the Landlock sandbox (but still as the current user, not root). The tool script itself does not need to handle elevation logic. - -## Output Protocol - -Tools communicate results through stdout/stderr. The output is returned as a text content block. - -**Success**: print the result to stdout and exit 0. - -**Error**: print error information to stdout/stderr and exit non-zero. The dispatcher wraps the error. - -**Structured output**: if a tool returns `{"content": [...]}` JSON, it is passed through as-is. +Tools that need to escape the sandbox don't handle it themselves. The agent +passes `"elevated": true` in the tool call; the dispatcher runs the tool +outside Landlock. From the tool's perspective, elevation is transparent. ## Environment Variables -Tools receive these environment variables: +Tools receive: | Variable | Purpose | |---|---| | `OLLIE_TOOLS_PATH` | Path to the tools directory | | `OLLIE_ELEVATE_SOCKET` | Unix socket for elevation broker | -| `PWD` / working directory | Session's current working directory | +| `PWD` | Session's current working directory | -## Examples +## Bundled examples -See the tools in `data/tools/` for real examples: -- `file_read` — reads files with line numbers -- `file_edit` — fuzzy text replacement -- `file_grep` — ripgrep wrapper -- `gui_*` — KDE desktop automation -- `lsp_*` — language server protocol clients +See `data/tools/` in the ollie repo: +- `file_read`, `file_edit`, `file_grep` — filesystem I/O (Python) +- `gui_*` — KDE desktop automation (Bash/Python) +- `lsp_*` — language server protocol (Go, compiled from `tools/lsp/cmd/`) +- `memory_*` — persistent memory (Bash) diff --git a/toolsrv/meta.go b/toolsrv/meta.go index 16afbb5..72276ca 100644 --- a/toolsrv/meta.go +++ b/toolsrv/meta.go @@ -2,7 +2,9 @@ package toolsrv import ( "encoding/json" + "fmt" "os" + "os/exec" "path/filepath" ) @@ -13,6 +15,7 @@ type MetaFile struct { Args json.RawMessage `json:"args,omitempty"` Tier string `json:"tier,omitempty"` ReadOnly bool `json:"readOnly,omitempty"` + Cmd string `json:"cmd,omitempty"` } // LoadMetaFile reads and parses a .meta sidecar file for the given tool name. @@ -52,3 +55,35 @@ func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo { ReadOnly: m.ReadOnly, } } + +// ResolveTool returns the executable path for a named tool. +// Resolution order: +// 1. $OLLIE_TOOLS_PATH/ (local wrapper wins over system binary) +// 2. .meta "cmd" field (absolute path or bare name resolved via $PATH) +// 3. exec.LookPath() as final fallback +func ResolveTool(name string) (string, error) { + // Local tools path always wins — allows wrappers to shadow system binaries. + local := filepath.Join(ToolsPath(), name) + if info, err := os.Stat(local); err == nil && !info.IsDir() { + return local, nil + } + + // Check .meta cmd field. + if m, err := LoadMetaFile(name); err == nil && m != nil && m.Cmd != "" { + if filepath.IsAbs(m.Cmd) { + return m.Cmd, nil + } + // Bare name — resolve via PATH. + if p, err := exec.LookPath(m.Cmd); err == nil { + return p, nil + } + return "", fmt.Errorf("tool %s: cmd %q not found in PATH", name, m.Cmd) + } + + // Final fallback: maybe name itself is in PATH. + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + + return "", fmt.Errorf("tool %s: executable not found", name) +} diff --git a/toolsrv/server.go b/toolsrv/server.go index 748c779..f38bcd7 100644 --- a/toolsrv/server.go +++ b/toolsrv/server.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "path/filepath" "strings" "sync" "syscall" @@ -298,7 +297,10 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra if strings.Contains(tool, "/") || strings.Contains(tool, "..") { return nil, fmt.Errorf("invalid tool name") } - path := filepath.Join(ToolsPath(), tool) + path, err := ResolveTool(tool) + if err != nil { + return nil, err + } // Extract elevated flag (dispatch-level concern, not passed to tool). elevated := false @@ -322,7 +324,6 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra stdinData := string(args) var result string - var err error if elevated { e.wdMu.RLock() workDir := e.cwd