Skip to content
Nirav Vaghasiya

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
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.
01

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.

02

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.

03

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).

04

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.

Architecture · single-file agent framework
Multi-agent
Routerroute · run_all · synthesize
Sessionsper-conversation state
Agentsshared config, isolated history
Agent loop
ThinkLLM
Acttool call
Observeresult → context

↺ repeat until the model stops asking for tools (max 6)

Capabilities
LLMOpenAI · Anthropic · Ollama · retries · cache
Tools@tool → JSON Schema; validate from signature
Memory20-message window → summary
RAGTF-IDF · optional embed_fn
Tool path
Locate
ValidatePydantic from signature
AuthorizeToolPolicy
Approvehuman callback
Executebounded workers
Auditevent stream
Bounds
Budgetiterations · calls · tokens · output · time · cost
Fencingper-agent random marker around untrusted text
Structured outputPydantic · self-repair
  • composed capability
  • model-requested action
  • policy / guard
Every model-requested tool call follows one fixed path with no way around it. The Router picks exactly one registered agent from a validated decision and fails closed on anything ambiguous.
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.
05

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.
python
from unchained import LLM, Agent, tool @tooldef add(a: int, b: int) -> int:    """Add two numbers together."""    return a + b agent = Agent(    LLM(provider="ollama", model="llama3.1"),    tools=[add],    system_prompt="You are a precise calculator.",)print(agent.run("What is 1234 + 5678?"))
The README's 30-second tour: a tool is a decorated function, an agent is a model plus tools.
06

Key technical decisions

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 05

    No shipped price table

    A stale table would silently under-report spend, so callers supply their own rates for cost budgets.

  6. 06

    Synchronous by design

    The client uses requests; async wrappers offload to threads instead of pretending to be a non-blocking HTTP client.

  7. 07

    Bounded tool concurrency

    The number of tool calls per turn is chosen by the model — untrusted input — so worker threads are capped.

07

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.

08

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.
09

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.
10

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.