Single-file agentic framework
Unchained
An agentic AI framework — tools, memory, RAG, structured output and multi-agent routing — written as one readable Python file with exactly two dependencies.
- Agents
- Tool calling
- RAG
- Multi-agent orchestration
- Status
- v0.4.0 · pre-1.0
- Started
- 2026
- Stack
- Python 3.9+ · Pydantic · requests · OpenAI · Anthropic · Ollama
- Core file
- 4,086 lines
- Dependencies
- 2 (requests, pydantic)
- Tests
- 488 offline test functions
- CI matrix
- Python 3.9 – 3.13
- Providers
- OpenAI · Anthropic · Ollama
Jump to section
Case-study brief
Verified 15 Sep 2026 (opens in a new tab)- Contribution
- A ReAct agent loop at the centre, composed from four capabilities — LLM, tools, memory and RAG — with authorization, budgets and observability wrapped around every tool call, and a router layered on top for multi-agent work.
- Best evidence
- 488 test functions
- Counted from three public test modules; CI spans Python 3.9–3.13.
- Main limitation
- Not a sandbox: tools run in-process with full privileges, and a tool timeout bounds the wait, not the work.
- Verification basis
- Core source, architecture/security docs, tests, packaging metadata and CI configuration inspected.
Overview
Unchained is an agentic framework built from first principles to show how tool calling, retrieval, memory compression and multi-agent routing actually work. Everything lives in a single file, unchained/__init__.py, that can be read in one sitting or copied into a project as unchained.py. It is small enough to understand completely and complete enough to run a real multi-agent application.
Problem
Most agent frameworks ask you to learn a mountain of abstractions before you can print “hello world”, and they hide the parts that hurt in practice: argument validation, authorization, run budgets, retries, and the trust boundary between the model and the tools it can call.
Motivation
In the author's words: “I wanted to understand agentic systems by building one from first principles rather than wiring together someone else's abstractions.” The result doubles as a readable reference implementation — one file, two dependencies, no magic — and as a running application (PickMyStack, a three-agent recommender with a synthesizer).
Architecture
A ReAct agent loop at the centre, composed from four capabilities — LLM, tools, memory and RAG — with authorization, budgets and observability wrapped around every tool call, and a router layered on top for multi-agent work.
↺ repeat until the model stops asking for tools (max 6)
- composed capability
- model-requested action
- policy / guard
- Tools
- The @tool decorator turns a Python signature into a JSON Schema (including Literal, Enum and nested Pydantic models) and validates arguments from the signature, not the schema. Tools carry metadata the model never sees: permissions, approval, side effects, timeout, output size and concurrency.
- LLM client
- OpenAI, Anthropic and Ollama behind one interface, with retries on 429 and 5xx (backoff, jitter and Retry-After), a persistent session, a bounded LRU cache with TTL, streaming for all providers, and environment overrides for any OpenAI-compatible endpoint. MockLLM is a deterministic offline stand-in.
- Agent loop
- Think → act → observe until the model stops asking for tools (six iterations by default). Tool calls execute concurrently on a bounded thread pool; structured output is validated with Pydantic and self-repaired; retrieved documents, tool results and summaries are fenced with a per-agent random marker.
- Memory
- A sliding window of twenty messages whose oldest half compresses into a running summary via the LLM or truncation, with an optional token cap. A tool-call group is never split.
- RAG
- In-memory TF-IDF with smoothed IDF and cosine similarity, validated inputs, and an optional embed_fn for dense embeddings with dimension checking.
- Authorization and budgets
- Every model-requested call follows one path: locate → validate → authorize → approve → execute → audit. ToolPolicy and PermissionPolicy decide; an approval callback lets a human confirm destructive calls. Budget caps iterations, tool calls, tokens, output size, wall-clock time and estimated cost.
- Sessions and routing
- Session holds per-conversation state on shared agent configuration. Router picks exactly one registered agent from a validated JSON decision, runs all agents in parallel, or synthesizes their answers.
Implementation
- A 4,086-line unchained/__init__.py organised into ten numbered sections and thirty top-level classes; the package directory exists only to ship a PEP 561 py.typed marker, which a packaging test enforces by building a wheel.
- Exactly two runtime dependencies: requests ≥ 2.28 and pydantic ≥ 2.0.
- 488 test functions across unit, example and packaging suites — all offline, using a fake LLM and a monkeypatched HTTP session.
- CI runs ruff, ruff format, mypy and pytest with coverage across Python 3.9, 3.10, 3.11, 3.12 and 3.13.
- Eight runnable examples — quickstart, researcher, coder, data analyst, SQLite memory, policy, sessions and PickMyStack (three agents plus a synthesizer, a Streamlit UI and a Dockerfile).
- Typed event stream: AgentStarted, AgentIteration, LLMStarted/Finished, ToolStarted/Finished/Failed and AgentFinished/Failed, each a frozen record with run and session ids.
Key technical decisions
01
One file, by design
Living in a single file forces discipline — every line has to earn its place — and makes the framework readable as a reference for how agents work.
02
Validate from the signature, not the schema
The schema is advice the model may ignore. Arguments are checked against a Pydantic model built from the real Python signature before anything runs.
03
Fail closed
Policy exceptions deny, a missing approver refuses, and the router raises RoutingError rather than guessing. An unanswerable question is a refusal, not a pass.
04
Cache answers, never decisions
cache=True means final answers only. A tool-call response is a decision to act in a world that has since moved on, so it is never replayed from cache.
05
No shipped price table
A stale table would silently under-report spend, so callers supply their own rates for cost budgets.
06
Synchronous by design
The client uses requests; async wrappers offload to threads instead of pretending to be a non-blocking HTTP client.
07
Bounded tool concurrency
The number of tool calls per turn is chosen by the model — untrusted input — so worker threads are capped.
Evaluation & results
- Test suite
- 488 offline test functions
- Includes dedicated tiers for tool authorization and for trust boundaries and prompt injection; CI green across five Python versions.
- Footprint
- 1 file · 2 dependencies
- The benchmark script measures Unchained's own line count and import time live rather than quoting numbers in prose.
- Reference application
- PickMyStack
- Three specialised agents (cost, fit, trend) plus a synthesizer, running from the CLI or a Streamlit UI.
Comparison figures for other frameworks in the benchmark script are order-of-magnitude reference values, not measurements, and are not repeated here.
Challenges
- Concurrency without bounds: before worker threads were capped, a burst of tool calls produced runaway live threads — the changelog records the failure and the fix.
- Documentation drift: a dedicated pull request corrected five README claims that no longer matched the code, and the benchmark now counts lines live for the same reason.
- Keeping a single file coherent past four thousand lines meant strict sectioning and tests that pin the packaging shape.
Limitations
- Not a sandbox: tools run in-process with full privileges, and a tool timeout bounds the wait, not the work.
- Prompt injection is mitigated by fencing but not solved; there is no URL validation or SSRF protection.
- RAG has no chunking, metadata filtering or persistence and scans linearly — fine for hundreds or a few thousand chunks, not a vector database.
- Cost tracking is an estimate. The project is pre-1.0 and, at time of writing, not yet published to PyPI.
What I learned
- A schema is advice; validation creates an enforceable boundary — the same principle the chess project applies to selected prose claims.
- Fail-closed defaults make an agent framework debuggable, because every refusal comes back as an observation with a reason.
- Documentation is a claim about code and has to be tested like one.
Links
- Repository (opens in a new tab)github.com/NiravRVaghasiya/unchained
- Architecture (opens in a new tab)github.com/NiravRVaghasiya/unchained/blob/main/docs/ARCHITECTURE.md
- Security model (opens in a new tab)github.com/NiravRVaghasiya/unchained/blob/main/SECURITY.md
- PickMyStack example (opens in a new tab)github.com/NiravRVaghasiya/unchained/tree/main/examples/pickmystack