Skip to main content
Droste — nested frames converging on the answer

Droste

The recursive harness for your data.

Droste is a Recursive Language Model (RLM) engine: instead of stuffing your data into a context window, the model gets it as a variable in a sandboxed Python REPL. It inspects the data, writes code over it, and fans out llm_query / llm_query_batched subcalls over the pieces that need semantic judgment — map-reduce, where the model writes both the map and the reduce.

Coding harnesses bolt a model onto a transcript. Droste harnesses it to data.

uvx droste "which customer had a failed charge, and why?" server.log
uvx droste "which plan has the highest refund rate vs its MRR?" shop.db

droste answering a two-part question over a 444 kB server log, streaming its code as it works

The first example, against a 444 kB log with gemini-3.5-flash:

$ droste "Which customer had a failed charge, for what amount, and why?
  How many timeout errors are there, and which upstream do they blame?" server.log

1. **Failed Charge Details**:
   - **Customer**: `cus_9982`
   - **Amount**: 1499 (USD, which is $14.99)
   - **Reason**: The card was declined due to insufficient funds
     (`reason=card_declined decline_code=insufficient_funds`).

2. **Timeout Errors**:
   - **Count**: There are exactly 66 timeout errors in the log.
   - **Upstream blamed**: They blame `payments-v2` (`upstream=payments-v2`).

The counts are exact because the model counted them in Python — it never read 3,400 log lines through its attention. In --db mode the model introspects your schema, writes read-only SQL, and computes over the rows; in the demo above it noticed the free plan makes refund-rate-vs-MRR undefined and answered for the paid plans instead.

Why

Two things, at once: better answers and bounded cost.

Better answers. Long-context models read everything and still miss things. Retrieval finds the right chunk but can't compute across all of them. An RLM does what you would do: look at the shape of the data, narrow mechanically (regex and SQL find where; subcalls understand what), delegate semantic judgment in bounded batches, and aggregate in code.

Bounded cost. The naive version of this loop is ruinous — left to itself, one subcall spent 62,911 thinking tokens producing a 4-token answer, and tasks cost $4–6 each. Subcall work is extraction, not reasoning: with subcall reasoning off and the default 2,048-token output cap, the same tasks measured ~25× cheaper at the same accuracy. The cap is a client default; reasoning-off is enforced server-side on ModelRelay and passed through on BYOK (--reasoning-effort none, for endpoints that honor it). And because the root writes the map and the reduce, you can put a strong model at the root and a cheap one in the fan-out (--model gemini-3.5-pro --subcall-model gemini-3.5-flash) — a few expensive tokens where judgment happens, thousands of cheap ones where reading happens.

Measured on OOLONG (131k-token contexts, 50 tasks, gemini-3.5-flash everywhere):

approach score cost/task wall/task
Droste (server defaults) 0.84 ~$0.37 27s
same model, full context inline 0.52 ~$0.26 44s
dspy.RLM (matched models & budgets) 0.74 ~$0.26 73s

+32 points over stuffing the window, at comparable cost. On TAG-Bench (agentic analysis over SQL), Droste scores 50% strict-match where published text-to-SQL baselines sit under 20% — no pipeline, just droste pointed at the .db file.

Caveats: one dataset per benchmark, one context length, one model family, n=50. Per-task artifacts and cost derivations ship with the benchmark harness.

Use it

Ask questions over files, folders, and SQLite from the terminal. The contract: args that exist are data, the one that doesn't is the question, no args means the current directory, pipes are data too — and it always prints one line saying what it read.

uvx droste "…" ./docs        # zero-install, npx-style
uv tool install droste       # or keep the binary around
pipx install droste          # the older equivalent
droste login                 # one-time setup: free credits, or your own key
droste "what changed between these?" report.txt logs.txt
droste "which customers churned last month?" app.db
droste "how does auth work here?" ./docs
cd ~/notes && droste "what did I decide about pricing?"
tail -5000 app.log | droste "why did it crash?"

SQLite files are recognized by their magic bytes — no flag needed (--db remains as an explicit override). Directory walks skip binaries, dotfiles, and the usual junk (.git, node_modules, …) and cap sizes (--max-file-bytes, --max-bytes); every skip is counted in the report line. droste ask … still works as an alias.

Files are materialized as the sandbox's context variable — the model is told each file's name and size (not its contents) and pulls data in via code, so multi-MB files are fine. What the model reads is whatever its code chooses to print. --db uses the engine's local-mode SQL data source (read-only policy as a guardrail, not a boundary; OS permissions are the boundary).

Engine knobs mirror RLMConfig: --subcall-model, --subcall-max-output-tokens (default 2048), --reasoning-effort, --max-iterations, --max-subcalls. --json prints a result object for scripting; --verbose streams one-line progress to stderr (watch it think); --trace renders the full structured event stream — generated code, execution output with per-iteration sub-call counts and answer state, LLM responses, execution errors. Exit code 0 means a confirmed (or extracted-with-note) answer.

Three worked starting points live in docs/recipes.md (logs, chat archives, SQLite).

Pointing --base-url at ModelRelay lights up the platform features (validated SQL policies, server-enforced subcall cost controls, audit) — documented, not required. droste is the engine CLI; mrl remains the ModelRelay platform CLI.

Embed it

The same wheel is the engine as a library — zero runtime dependencies, urllib-only. Add it to your app and point the loop at your own data sources:

uv add droste        # or: pip install droste

Using is asking over your data; embedding is building RLM answers into a product for your users.

BYOK: OpenAI-compatible endpoints, and Anthropic natively

The engine ships built-in clients for any endpoint that speaks the OpenAI chat-completions shape (OpenAI, OpenRouter, Google's OpenAI-compat endpoint, vLLM, Ollama, ...) — plus a native client for Anthropic's Messages API (their compat layer is a testing shim, so Claude gets its own client). Bring your own key — no ModelRelay account required. The CLI detects the provider from facts: an sk-ant-… key (or ANTHROPIC_API_KEY) routes to Anthropic; an explicit --base-url/OPENAI_BASE_URL always wins.

export ANTHROPIC_API_KEY=sk-ant-...
droste "why did it crash?" ./logs --model claude-opus-4-8
from droste import (
    OpenAICompatClient,
    OpenAICompatSubcallClient,
    create_execution_context,
    run_rlm,
)

context = create_execution_context(max_calls=50, max_depth=1)
root = OpenAICompatClient(model="gpt-5.2-mini")  # OPENAI_API_KEY / OPENAI_BASE_URL from env
subcalls = OpenAICompatSubcallClient(
    model="gpt-5.2-mini",
    context=context,               # shared call/token accounting
    max_output_tokens=2048,        # per-subcall output bound (cost control)
)

env = ...  # your RLMEnvironment implementation (see Core Concepts below)
result = run_rlm(question, environment=env, root_llm=root, subcalls=subcalls, context=context)

Explicit base_url= / api_key= constructor args win over the environment variables. Subcall batches run with bounded concurrency (5 workers) and every subcall's usage block is added to result.tokens_used.

reasoning_effort and extra_body pass through to the endpoint as-is. Disabling thinking per-subcall is a gateway capability: ModelRelay enforces it server-side; raw endpoints may ignore a client-side disable.

Runner architecture (droste_runner)

The droste_runner package is a thin orchestration layer that wires droste to HTTP-backed root LLM calls and subcalls. It is shared across hosts (ModelRelay's hosted runner, in-process embedders) so the loop logic stays in one place. For custom environments, set adapter_module in the runner request to delegate to an adapter module's run(request) function.

flowchart LR
    Host[Host App] --> Runner[droste_runner]
    Runner --> Core[droste run_rlm]
    Runner --> Env[RunnerEnvironment]
    Env --> Sandbox[Python REPL execute]

    Core --> RootLLM[LLMClient responses_create]
    RootLLM --> Responses[Host /responses]

    Core --> Subcalls[SubcallClient llm_query llm_batch]
    Subcalls --> SubcallAPI[Host /rlm/subcall]

Runner Inputs

  • protocol_version: required on every request (currently 1) — a missing or mismatched version gets a structured refusal, so hosts detect incompatibility instead of failing on a missing field. See docs/architecture.md for the compatibility rules and UPGRADING.md for per-release embedder migration notes.
  • root_endpoint + subcall_endpoint + token: required for HTTP-backed runs.
  • adapter_module: optional Python module path to override the runner entirely.

Core concepts

Protocols

Implement these to integrate with your infrastructure:

  • RLMEnvironment - Sandboxed Python REPL with data access
  • LLMClient - Chat completion interface for the root LLM
  • SubcallClient - Provides llm_query() and llm_batch() for sub-LLM calls
  • DataSource - Optional data source integration

Data sources are domain-blind

DataSource carries core verbs only — query, search, get, get_recent, get_schema, get_stats, plus the generic optionals find/content/sample. The engine knows nothing about any product's data shape. A source with domain-specific verbs declares them itself:

class MessageArchiveSource:
    extra_methods = ("get_messages", "get_chats")  # your verbs, your names
    ...

Exactly those callables are exposed to the sandbox — validated against engine verbs, Python builtins, and reserved names — and the declaration works identically in-process and across the Pyodide bridge. Registrations via register_source_type must pass the source-protocol version they implement (protocol=2 today); a stale extension fails loudly at startup instead of silently losing its verbs.

Configuration

RLMConfig(
    max_iterations=20,      # Max refinement loops (default)
    max_depth=1,            # Max nested subcall depth (default)
    max_calls=50,           # Max total subcalls (default)
    max_output_chars=25000, # Output budget per iteration (default)
)

Result

RLMResult(
    answer="...",           # Final answer from answer["content"]
    ready=True,             # Whether answer["ready"] was set
    iterations=3,           # Iterations used
    tokens_used=1500,       # Total tokens consumed
    sub_calls_made=12,      # Total llm_query/llm_batch calls
    trajectory=[...],       # Full execution history
    extracted=False,        # True if the answer came from the post-exhaustion
                            # extract pass (best-effort, not confirmed)
)

Development

uv sync          # Install dependencies
uv run pytest    # Run tests
uv build         # Build wheel

The name

The Droste effect is the picture that contains itself. M.C. Escher's Print Gallery pushed it to its limit — a man in a gallery viewing a print that contains the gallery he is standing in — and Escher left the center of the spiral famously blank, signed but uncompleted, where the recursion outran his hand. Fifty years later, mathematicians completed it; their project was titled "The Mathematics Behind the Droste Effect."

The answer at the center of the spiral — the part the picture couldn't hold — is what recursion computes.

License

Apache-2.0. See LICENSE. Contributions welcome — CONTRIBUTING.md. Versioning is semver; the runner protocol and source-registry contract carry an explicit compatibility window (see docs/architecture.md).

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

droste-0.10.5.tar.gz (4.3 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

droste-0.10.5-py3-none-any.whl (135.5 kB view details)

Uploaded Python 3

File details

Details for the file droste-0.10.5.tar.gz.

File metadata

  • Download URL: droste-0.10.5.tar.gz
  • Upload date:
  • Size: 4.3 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for droste-0.10.5.tar.gz
Algorithm Hash digest
SHA256 61dc081a5bf9a416bef781fbd0c4e4b86f93498a37cf81c15cb75d72ecbcd85c
MD5 dce9900c93012f12f8083bdfd4595155
BLAKE2b-256 c9ec468a694ba10aba31ed9fa6f6831451e84efce847b914e25295705756466e

See more details on using hashes here.

File details

Details for the file droste-0.10.5-py3-none-any.whl.

File metadata

  • Download URL: droste-0.10.5-py3-none-any.whl
  • Upload date:
  • Size: 135.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for droste-0.10.5-py3-none-any.whl
Algorithm Hash digest
SHA256 741c27f4e1764149d8d6386133321420ea1a6a3af0268f267f393ea0ae0dce4e
MD5 e99221cecea9783a2bf1adb36f345e9c
BLAKE2b-256 57e9e8490172f7157c6a8e778280345e9f91fc0527aee5bd99edb1d95f4a9526

See more details on using hashes here.

Release history Release notifications | RSS feed

0.26.0

2 files

0.25.0

2 files

0.24.0

2 files

0.23.0

2 files

0.22.0

2 files

0.21.1

2 files

0.21.0

2 files

0.20.1

2 files

0.20.0

2 files

0.19.2

2 files

0.19.1

2 files

0.19.0

2 files

0.18.0

2 files

0.17.0

2 files

0.16.0

2 files

0.15.5

2 files

0.15.4

2 files

0.15.3

2 files

0.15.2

2 files

0.15.1

2 files

0.15.0

2 files

0.14.1

2 files

0.13.1

2 files

0.13.0

2 files

0.12.1

2 files

0.12.0

2 files

0.11.0

2 files

0.10.6

2 files

This release

0.10.5 This release

2 files

0.10.4

2 files

0.10.3

2 files

0.10.2

2 files

0.10.1

2 files

0.10.0

2 files

0.9.0

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page