Skip to main content
Glama
README.md
# delegate

Delegates a task to a separate, full agentic loop running on a different model (local via
Ollama, or remote via OpenRouter), with its own tool access (files, bash), returning only a
final result — functionally equivalent to a native subagent, but model-agnostic. Driven from
the command line by a person or a script.

See [mcp-subagent-delegation-plan.md](mcp-subagent-delegation-plan.md) for the full build
plan, phased as separate commits/checkpoints.

## Status

Phase 1, 2, 3, and 4 complete.

- `delegate task` — single-shot chat completion against a configured OpenAI-compatible
  endpoint (Ollama, LM Studio, vLLM, OpenRouter, ...).
- `delegate agentic` — gives the delegated model its own tool-use loop (`read_file`,
  `write_file`, `run_bash`) scoped to a caller-specified working directory, running until it
  stops calling tools, hits `max_iterations`, or exceeds `timeout_seconds`.
- `delegate batch` — a JSONL of jobs run concurrently in one process, under one semaphore.
- `delegate list` — inspect what past delegations actually did, without digging through logs
  or re-running anything.
- `delegate transcript` — full message/tool-call transcript for one delegation, when it was
  run with `--capture-transcript` (e.g. for model comparison/eval runs).

**Deviation from the original plan:** Phase 2 called for wrapping
[agent-loop](https://github.com/AlessandroAnnini/agent-loop) as a subprocess. agent-loop only
supports Linux/macOS/WSL, and this needs to run natively on Windows, so we built the
in-process loop described as Phase 5's alternative instead — same tool interface, no
subprocess/ANSI-stripping complexity, and it sidesteps agent-loop's AGPL/no-commercial license
entirely. See [delegate/agentic.py](delegate/agentic.py).

**Safety note:** `working_dir` is caller-specified, not a fixed sandbox — the delegated model
gets unattended file/bash access to whatever directory it's pointed at. File tools
(`read_file`/`write_file`) are scoped to stay within `working_dir`; `run_bash` runs with that
directory as `cwd` but shell commands are not fully sandboxed and could escape it (e.g. `cd ..`).
Point this at a directory you're comfortable an unattended model can read, write, and execute
commands in.

**Guardrail note:** the original plan's Phase 4 asked to confirm agent-loop's own guardrails
(iteration cap, repetition detection) were active. Since we're not using agent-loop, that
doesn't apply directly — our loop has its own `max_iterations` and `timeout_seconds` caps
(verified in testing), but no repetition detection. A model that gets stuck alternating between
two tool calls will run until it hits `max_iterations` rather than being caught early. Worth
adding if that turns out to happen in practice.

### Binary files

**`read_file` refuses binary files** (`tools.BINARY_EXTENSIONS` — PDF, ZIP, `.db`, Office
formats, images — plus a null-byte sniff on the first 1 KB for anything not on that list)
instead of silently decoding them as garbled text. Before this guard existed, a delegated model
called `read_file` directly on three PDFs instead of extracting their text with
`pypdf`/`pdfplumber` first; `.read_text(errors="replace")` turned each one into a wall of `�`
characters — often *more* characters than the source file's own byte size, since many raw bytes
decode to multi-byte replacement characters — and the resulting ~1.9M-character blob blew
straight through the model's context window on the very next turn, before any real work
happened. Now that raises a clear `ToolError` naming the file type and pointing at the right
extraction library instead.

Images are on that list like anything else, so the error names the format ("PNG image") rather
than falling through to the sniff's generic "binary data".

### Tool results are capped

`run_bash` and `read_file` both return at most 10,000 characters (`tools.MAX_OUTPUT_CHARS`): the first 5,000, a
note saying how many were dropped and what to do instead, then the last 5,000. Head *and* tail,
because the tail is where a traceback and its failing line are, and a head-only cut throws away
the half worth reading. The `[exit code: N]` line is appended after truncation, never before, so
it cannot be the thing that gets cut.

This is the same failure the binary guard above exists for, arriving by a different door — a
`cat` of a room file, a chatty `pip install`, a wide `find`. The guard can refuse a file that is
the wrong *kind*; it can't refuse one that is merely large, and `run_bash` can't refuse anything
— so both truncate.

The advice in the notice differs by tool, because what the model can do about it differs.
Command output is gone once cut, so `run_bash` says to re-run something narrower. A file is
still sitting on disk unchanged, so `read_file` says to go back for the part it wants with
`grep`, `sed -n 'START,ENDp'`, or `head`/`tail`. `read_file` matters more than it looks: workers
are told to extract a PDF's text to a file and read that back, and extracted text is exactly
what gets large.

Every command also runs with `PAGER=cat`, `MANPAGER=cat`, `LESS=-R`, `PIP_PROGRESS_BAR=off` and
`TQDM_DISABLE=1`. The two Python ones do the real work, since delegated workers are told to do
file operations in Python; the pager settings bind when a Unix tool gets invoked, which happens
because Git for Windows puts `less`/`man` on PATH. There is no stdin in a delegated run, so a
pager doesn't produce noise — it hangs until the timeout kills it.

### Vision was removed

`read_file` used to detect images by extension and show them to the model as real multimodal
content, with downscaling and context pruning to keep an image-heavy run affordable. Visual
judgment on plans and RCPs moved to the take-off tool, so none of that is reachable any more and
it was deleted rather than left to rot.

It worked, and three things it had to get right are worth knowing before anyone adds vision back
— all recoverable in full from the commit that removed this section:

- A `tool`-role message cannot carry an `image_url` block on any OpenAI-compatible provider, so
  the image has to go in a synthetic `user` message *after* every tool result for that turn.
- Pruning an old image means replacing the whole content block, not overwriting
  `image_url.url` with placeholder text — OpenRouter validates that field as a URL and 400s.
- Without pruning, a real 3-room extraction task reached ~194K prompt tokens by iteration 20 and
  still didn't finish, almost entirely from re-sending every image it had ever viewed.

## Setup

```bash
uv sync
cp .env.example .env             # fill in DELEGATE_BASE_URL / DELEGATE_API_KEY / DELEGATE_MODEL
cp models.json.example models.json   # optional: named backends, see below
```

### Multiple backends

Both tools take an optional `backend` param that looks up `base_url`/`model`/`api_key` from
`models.json` instead of the default `DELEGATE_*` env vars — e.g. `backend="ollama-local"` for
one call and `backend="openrouter-free"` for another in the same turn, each running
concurrently. `model`, if also given, overrides just the model string within that backend.

Reference an env var for a key instead of writing it into `models.json` directly:

```json
{
  "openrouter-free": {
    "base_url": "https://openrouter.ai/api/v1",
    "model": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
    "api_key_env": "OPENROUTER_API_KEY"
  }
}
```

`models.json` is gitignored, same as `.env`.

### Concurrency

`DELEGATE_MAX_CONCURRENCY` (default 4, see `.env.example`) caps how many delegations — any
command, any backend — run at once, to avoid a large fan-out overwhelming a local model server
or a paid API's rate limits. It is an in-process semaphore, so use `delegate batch` to fan out
rather than launching N copies of the CLI from a shell loop; see the CLI section below.

### Logging

Every `task`/`agentic` call — success or failure — is logged to a local
SQLite file, `delegations.db` (gitignored, created on first use): tool, backend, model, task
text, start/end time, iteration count, success/failure, a truncated result/error preview, and
token usage if the backend returned it. Query it with `delegate list`, or
directly with `sqlite3 delegations.db "select * from delegations order by id desc limit 20"`.
Logging is best-effort — a logging failure won't take down an otherwise-successful delegation.

Both also append a trailing `[tokens: N prompt / N completion / N total ($cost)]` line to their
own output when the backend reports usage, so you see it immediately without a separate
`delegate list` call.

### Cost tracking

**The provider prices the call and tells us; nothing is looked up.** OpenRouter returns a `cost`
field on every response's `usage` object — no request flag needed — and that number is summed
across a delegation's iterations, logged to `delegations.db` (`cost_usd`), and shown in the
`[tokens: ...]` suffix.

The distinction that matters: a missing `cost` is **unknown, not free**. Ollama and other local
endpoints have no such field, so those rows log `cost_usd = NULL` and print no dollar figure.
A model that genuinely costs nothing — OpenRouter's `:free` variants — reports `cost: 0`, which
is a fact and gets printed as `$0.000000`. Summing an absent field into `0.0` would render a
paid run as free, which is the one error this must never make.

This replaced a hand-maintained `pricing.json`. That file mapped model → per-million rates and
had to be re-fetched whenever a price moved or a model was added, and it could only ever cover
models someone had remembered to enter. Provider-reported cost is ground truth, covers every
model automatically, and cannot go stale. The two agreed where they overlapped: delegation 56
logged $0.01137843 from the table, which is exactly 353,970 prompt tokens at $0.03/M plus 5,841
completion tokens at $0.13/M.

### Transcript capture (model comparison / eval runs)

Both take `--capture-transcript` (off by default). When set, the full message exchange —
every model message, tool call, and tool result, not just the final answer — is logged, and
the output gets a `[delegation_id: N]` suffix. Fetch it with
`delegate transcript <delegation_id>`.

This exists for running the same task through several different models/backends and comparing
not just the final answer but *how* each one got there (tool selection, malformed tool calls,
retries) — e.g. a bake-off across candidate models before picking one for production use.
Off by default since it's extra logging overhead you don't want for routine delegation.

## CLI

`delegate/__main__.py` is the only front door. An MCP server used to sit beside it, exposing
four of these commands as model-callable tools, but it was enabled in no project, never grew
`batch`, and made every change a question of whether both surfaces had been updated. Delegation
here is driven by a person or a script — which is what a bake-off or a batch run actually is.

```bash
py -m delegate task    "summarise this" --backend openrouter --model qwen/qwen3.7-flash
py -m delegate agentic --dir ./sandbox --task-file step-600.md --backend openrouter
py -m delegate batch   jobs.jsonl --out-dir results/
py -m delegate list    --limit 10
py -m delegate transcript 42
```

`uv run delegate ...` works too, via the `[project.scripts]` entry point.

**`--task-file` is the point.** A real task is paragraphs — a step file, a spec extract — and
shell-quoting that is how a task arrives mangled. Every command taking a prompt also accepts `-`
or a pipe, to read stdin.

### `batch`, and why it exists

`concurrency.limit_concurrency` is an **in-process** semaphore. It caps parallel delegations inside
one invocation and cannot see any other, so launching N copies of this CLI from a shell loop gives
each its own cap and effectively removes it — a real way to trip a provider's rate limit or swamp a
local Ollama. `batch` runs every job in one process, under one semaphore.

Jobs are JSONL, one per line; `#` comments and blank lines are skipped. A job with a `dir` runs
agentic, one without runs single-shot; every other key matches the corresponding function argument.

```json
{"name": "step-900", "task": "...", "dir": "./sandbox", "backend": "openrouter", "model": "qwen/qwen3.7-flash"}
{"name": "smoke",    "task": "Reply with exactly: OK",   "backend": "openrouter"}
```

One job failing does not stop the rest. Each result lands in `--out-dir/<name>.txt` (or stdout),
failures included, and the exit status is non-zero if any job failed.

### A retired model is an ordinary outcome, not a crash

Free-tier model ids get withdrawn without notice — `nvidia/nemotron-nano-9b-v2:free` began 404ing
three days after the bake-off measured it. The CLI prints that as one error line rather than a
traceback; pass `--traceback` when you want the stack because it really is a bug.

## Tools

- `delegate_task(prompt, model=None, system_prompt=None, backend=None, capture_transcript=False) -> str` —
  single-shot chat completion against the configured backend.
- `delegate_agentic_task(task, working_dir, model=None, max_iterations=20, timeout_seconds=600, backend=None, capture_transcript=False) -> str` —
  multi-step delegation with `read_file`/`write_file`/`run_bash` tools scoped to `working_dir`.
  Returns only the final answer, not the full transcript, unless `capture_transcript=True`.
  `read_file` on an image file shows it to a vision-capable model as an actual image — see
  "Vision" above.
- `list_recent_delegations(limit=20) -> list[dict]` — most recent logged delegations, newest
  first.
- `get_delegation_transcript(delegation_id) -> list[dict]` — full transcript for one delegation
  logged with `capture_transcript=True`.

`delegate_task`/`delegate_agentic_task` return errors (bad config, unreachable endpoint,
timeout, iteration cap) as `"Error: ..."` strings rather than raising, so a calling agent can
see what went wrong.

TDQS

A4.7/5.0

Scored across 4 tools

Disambiguation5/5

Each tool has a distinct purpose: delegate_task for single-turn, delegate_agentic_task for multi-step with tool use, list_recent_delegations for querying history, and get_delegation_transcript for retrieving full logs. No overlap.

Naming Consistency5/5

All tool names follow a consistent snake_case verb_noun pattern (delegate_task, delegate_agentic_task, list_recent_delegations, get_delegation_transcript), with clear action prefixes.

Tool Count5/5

Four tools precisely cover the core delegation workflow: create a delegation (two variants), list delegations, and inspect a transcript. No unnecessary extras.

Completeness5/5

The tool set covers creating delegations, retrieving summaries, and fetching full transcripts. No update/delete is needed for delegation records, so the surface is complete for its purpose.

Maintenance

ActivityMaintained
ResponsivenessNo issues