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
The first example, against a 231 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: customer cus_9982, amount 1499 ($14.99), reason card_declined
2. Timeout errors: 66 total; all blame the upstream service payments-v2
The counts are exact because the model counted them in Python — it never
read 4,000 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 dumps the full loop (generated code, outputs, responses). 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 (currently1) — 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.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 accessLLMClient- Chat completion interface for the root LLMSubcallClient- Providesllm_query()andllm_batch()for sub-LLM callsDataSource- 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
)
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file droste-0.10.0.tar.gz.
File metadata
- Download URL: droste-0.10.0.tar.gz
- Upload date:
- Size: 2.4 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2f165df22e1a39a487aa4b6f0a4a5333657f5e69819aba84464383965dfc416e
|
|
| MD5 |
9c82399d929b56c7f757271f2ba1fd62
|
|
| BLAKE2b-256 |
922e01b64b559ad5060dace931ff09272910660ded1d79579ebeb6c56c4a26b0
|
File details
Details for the file droste-0.10.0-py3-none-any.whl.
File metadata
- Download URL: droste-0.10.0-py3-none-any.whl
- Upload date:
- Size: 128.8 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa398e6d7e75c5eda6f7a748b9a694f221688998a068ac5a8d75a5ba4db42e61
|
|
| MD5 |
1ded8b25cdfe17a40e7f2c6b774592d9
|
|
| BLAKE2b-256 |
b61a95f196129d34bdb4429bc816641198411b087bdaf3f630b211104482d833
|