toolsrv: support cmd field in .meta for external tool resolution

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/<name> → 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.
This commit is contained in:
Ollie Agent 2026-07-30 23:02:08 +02:00
parent 059cceed5c
commit 44e420fc7e
3 changed files with 171 additions and 76 deletions

View File

@ -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/<name>`** — 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)

View File

@ -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/<name> (local wrapper wins over system binary)
// 2. .meta "cmd" field (absolute path or bare name resolved via $PATH)
// 3. exec.LookPath(<name>) 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)
}

View File

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