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:
parent
059cceed5c
commit
44e420fc7e
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue