A tiny async framework for reliable, observable tool-using agents: a clean ReAct loop, typed memory, streaming events, recoverable failures, and progressive-disclosure skills, in a codebase small enough to read in one sitting.
Status: alpha (0.x). The API may still change before 1.0.
agentling is built around one idea: an agent is a loop that turns a model, some tools, and a memory of what happened into more actions, until it has an answer. Everything else (streaming, skills, self-healing, persistence) is a thin layer on top of that loop.
import asyncio
from agentling import Agent, OpenAIModel, tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city.
Args:
city: The city to look up.
"""
return f"It is 22C and sunny in {city}."
async def main() -> None:
agent = Agent(model=OpenAIModel("gpt-4o-mini"), tools=[get_weather])
print(await agent.run("What's the weather in Paris?"))
asyncio.run(main())- Why agentling
- Install
- Quickstart
- Examples
- Usage
- Architecture
- Configuration reference
- Development
- License
- Async first. The loop, tools, and model calls are all
async. Tool calls in a single step run concurrently by default. - One code path. Blocking and streaming share the exact same loop. There is a single async generator; blocking mode just drains it.
- Typed memory. A run is a list of typed steps, not a bag of raw messages. Steps know how to render themselves back into model messages and serialize to JSON for persistence and replay.
- Progressive-disclosure skills. Drop a
SKILL.mdfolder in and the model sees only its name and description until it decides to load it. Big skill libraries stay cheap. - Self-healing. A tool that raises becomes an observation the model can recover from, not a crash.
- Small and readable. No metaclasses, no plugin registry, no DSL. Around 800 lines of source you can actually read.
pip install agentlingOr with uv:
uv add agentlingRequires Python 3.11 or newer. The only runtime dependencies are openai (the
client used by the built-in provider adapter) and pyyaml (for skill
frontmatter).
Set your provider key in the environment:
export OPENAI_API_KEY="sk-..."import asyncio
from agentling import Agent, OpenAIModel, tool
@tool
def add(a: int, b: int) -> int:
"""Add two integers.
Args:
a: The first number.
b: The second number.
"""
return a + b
async def main() -> None:
agent = Agent(model=OpenAIModel("gpt-4o-mini"), tools=[add])
answer = await agent.run("What is 19 + 23, and why?")
print(answer)
asyncio.run(main())Runnable examples live in examples/. The offline ones need no API
key; the rest use an OpenAI-compatible model (set OPENAI_API_KEY, and
optionally AGENTLING_EXAMPLE_MODEL).
Offline (no API key):
uv run python examples/cli_failure_recovery.py # recover from tool errors
uv run python examples/cli_memory_chat.py # persist and continue a session
uv run python examples/cli_advanced_observer.py # watch every event typeNeeds an API key:
uv run python examples/cli_math_tutor.py # smallest useful agent
uv run python examples/cli_repo_assistant.py "..." # streaming + safe file tools
uv run python examples/cli_notes_agent.py # file-backed notes toolsWhat each one covers:
| Feature | Example |
|---|---|
| Basic tool calling | cli_math_tutor.py |
Streaming events (print_events) |
cli_repo_assistant.py, cli_advanced_observer.py |
Every event type + context_manager |
cli_advanced_observer.py |
| Safe, sandboxed file tools | cli_repo_assistant.py, cli_notes_agent.py |
| Failure recovery (tool errors) | cli_failure_recovery.py |
Memory persistence + reset=False |
cli_memory_chat.py |
| Skills (progressive disclosure) | cli_repo_assistant.py, skills/code-reviewer |
Timeouts, redaction, parallel_safe |
cli_notes_agent.py |
max_tool_output_chars |
cli_repo_assistant.py |
| Custom scripted/fake model | cli_failure_recovery.py, cli_memory_chat.py |
A tool is any Python function wrapped with @tool. The function name becomes
the tool name, the docstring summary becomes the description, and the type hints
plus a Google-style Args: section become the JSON Schema the model sees. Both
synchronous and asynchronous functions are supported; a synchronous tool runs in
a worker thread so it cannot block the event loop.
tool_timeoutcaveat: a timeout stops the agent from waiting on a tool and turns it into an observation, but a synchronous tool already running in a thread cannot be forcibly cancelled and will finish in the background. Prefer async tools, or make blocking tools cooperative, when timeouts matter.
from agentling import tool
@tool
async def search(query: str, limit: int = 5) -> str:
"""Search the docs and return the top matches.
Args:
query: What to search for.
limit: How many results to return.
"""
...Supported parameter types map to JSON Schema: str, int, float, bool,
list[...], dict[...], Optional[...] / X | None (treated as "may be
omitted"), and Literal[...] (becomes an enum). Parameters without a default
are marked required. *args, **kwargs, and positional-only parameters are
rejected at registration time, because a tool is always called with a JSON
object of named arguments.
If the model sends arguments that do not match the schema (missing a required
field, wrong type, unknown key), the tool raises a ToolCallError which the
loop feeds back to the model as an error observation. The model gets a chance to
fix its call rather than the run blowing up.
Every agent also has a built-in final_answer tool. The model can call it to
end the run explicitly, or it can just reply with plain text (see
forgiving termination).
run() has two modes that share one implementation.
Blocking mode returns the final answer:
answer: str = await agent.run("Summarize this.")Streaming mode returns an async iterator of typed events:
from agentling import FinalEvent, TextDelta, ToolCallEvent
async for event in agent.run("Summarize this.", stream=True):
if isinstance(event, TextDelta):
print(event.text, end="", flush=True)
elif isinstance(event, ToolCallEvent):
print(f"\n[calling {event.tool_call.name}]")
elif isinstance(event, FinalEvent):
print(f"\nDone: {event.answer}")There is a ready-made renderer, print_events, that consumes the stream, prints
text as it arrives along with each tool call and result, and returns the final
answer. It is the "streaming CLI" in about thirty lines:
import asyncio
from agentling import Agent, OpenAIModel, print_events, tool
@tool
def add(a: int, b: int) -> int:
"""Add two integers.
Args:
a: The first number.
b: The second number.
"""
return a + b
async def main() -> None:
agent = Agent(model=OpenAIModel("gpt-4o-mini"), tools=[add])
answer = await print_events(agent.run("What is 19 + 23?", stream=True))
print("\nFinal answer:", answer)
asyncio.run(main())A skill is a folder containing a SKILL.md file: YAML frontmatter followed by a
markdown body of instructions.
---
name: code-reviewer
description: Review a code change for bugs, security issues, and style problems.
---
# Code Reviewer
You are reviewing a code change. Work through it methodically and report only
findings you are confident about...Skills use progressive disclosure. When you pass skills to an agent, only
their names and descriptions are added to the system prompt as a catalog. The
full instruction body stays out of context until the model calls the built-in
load_skill(name) tool, at which point the body is returned as an observation
and any tools the skill declares are registered. This keeps the base prompt
small even with a large library of skills installed.
import asyncio
from agentling import Agent, OpenAIModel, tool
@tool
def read_file(path: str) -> str:
"""Read a UTF-8 text file and return its contents.
Args:
path: Path to the file to read.
"""
with open(path, encoding="utf-8") as handle:
return handle.read()
async def main() -> None:
agent = Agent(
model=OpenAIModel("gpt-4o-mini"),
tools=[read_file],
skills=["examples/skills/code-reviewer"],
)
print(await agent.run("Review the code in src/agentling/agent.py"))
asyncio.run(main())A skill can also bring its own tools. List Python entry points in the frontmatter and they are imported and registered when the skill loads:
---
name: linting
description: Lint Python files and report issues.
tools:
- my_package.lint_tools:run_ruff
---Each entry point is a "module.path:attribute" string that must resolve to a
Tool (a function decorated with @tool). You can pass skills as folder paths
(strings or Path) or as pre-built Skill objects.
Security: a skill's
tools:entry point is imported, which runs that module's code. Load skills only from sources you trust, exactly as you would a Python import. See SECURITY.md for the full trust model.
An Agent is immutable configuration (model, tools, skills, settings) and is
safe to build once and share. The per-run state (memory, the interrupt token,
and any skill tools loaded during a run) lives on an AgentSession.
agent.run(task) is a one-shot convenience: it spins up a fresh session, runs
it, and returns the answer. Because each call gets its own session, concurrent
calls on one shared agent never touch each other's memory or tools. When you
need multi-turn, inspection, or interruption, hold a session with
agent.start().
# one-shot: simplest, and safe under concurrency
answer = await agent.run("A single question")
# hold a session for multi-turn, inspection, or interruption
session = agent.start()
answer = await session.run("First question")
print(session.memory.steps)Each session keeps a Memory of typed steps. You can serialize it and reload
it later:
session = agent.start()
await session.run("First question")
saved = session.memory.dump_json()
# ... later, in another process ...
from agentling import Memory
restored = agent.start()
restored.memory = Memory.load_json(saved)By default each run() starts fresh. Pass reset=False to continue from the
session's existing memory, which is how a multi-turn conversation or a resumed
run works:
session = agent.start()
await session.run("First question")
await session.run("A follow-up", reset=False) # sees the earlier turnCall session.interrupt() to request a graceful stop. The current run does not
crash; it finishes at the next step boundary and the session's memory is
preserved, so you can resume it with run(..., reset=False).
session = agent.start()
session.interrupt() # from a signal handler, another task, a UI button
await session.run(task) # returns "Run interrupted." at the next boundary
# ... later ...
await session.run(task, reset=False) # picks up where it left offOpenAIModel is an adapter for any OpenAI-compatible chat-completions endpoint.
Point it at a different base_url to use a compatible provider (a local server,
a gateway, or another vendor's OpenAI-compatible API):
from agentling import OpenAIModel
model = OpenAIModel(
"llama-3.1-70b",
base_url="http://localhost:8000/v1",
api_key="not-needed-locally",
)Transient failures (rate limits, connection or timeout errors, 5xx responses) are retried with exponential backoff. Permanent errors (a bad request, bad auth) fail fast without retrying.
Any object implementing the Model protocol works, so you can write your own
adapter:
class Model(Protocol):
async def generate(self, messages, tools=None) -> ChatMessage: ...
def stream(self, messages, tools=None) -> AsyncIterator[Delta]: ...agentling is a small set of focused modules. Each one owns a single concept, and they depend on each other in one direction only (agent depends on skills, tools, memory, events, models; nothing depends on agent).
| Module | Responsibility |
|---|---|
models.py |
Provider-neutral message types (ChatMessage, ToolCall, Usage), streaming types (Delta, ToolCallDelta), the Model protocol, and the OpenAIModel adapter. |
tools.py |
The Tool abstraction, the @tool decorator, JSON Schema generation from function signatures, argument validation, and the built-in final_answer tool. |
memory.py |
Typed steps (TaskStep, ActionStep, FinalStep), the Memory container, rendering to model messages, and JSON serialization. |
events.py |
The streaming event types, the Event union, and the print_events renderer. |
errors.py |
The exception hierarchy: AgentlingError and its domain subclasses. |
skills.py |
The Skill dataclass, the SKILL.md loader (frontmatter plus body), and entry-point tool resolution. |
agent.py |
The Agent config/factory, the AgentSession that holds one run's state, and the ReAct loop that ties everything together. |
The whole framework hangs off a single async generator, AgentSession._run_stream.
run() is a thin dispatcher: in streaming mode it hands back that generator; in
blocking mode it drains the generator and returns the final answer. There is no
second implementation to keep in sync.
run(task, stream=True) run(task) (stream=False)
│ │
▼ ▼
_run_stream(task) ◀─────────────── _drain(_run_stream(task))
(async generator) (awaits, returns the answer str)
│
│ one iteration == one "step"
▼
┌───────────────────────────────────────────────────────────────┐
│ 1. interrupt requested? -> yield FinalEvent, stop (resumable) │
│ 2. Memory.to_messages(instructions) -> the full prompt │
│ 3. Model.stream(messages, tools) -> Delta stream │
│ - each text chunk is yielded as a TextDelta │
│ - agglomerate_deltas() rebuilds one ChatMessage │
│ 4. no tool calls? -> that text is the answer; finish │
│ 5. for each tool call: yield ToolCallEvent │
│ 6. execute tools (concurrently by default) -> ToolResults │
│ - a raised exception becomes an error observation │
│ - an exact repeat of last step's calls gets a nudge │
│ 7. yield ToolResultEvent per result; record an ActionStep │
│ 8. final_answer called? -> finish with FinalEvent │
└───────────────────────────────────────────────────────────────┘
│
▼
step limit reached -> ask once for a tool-free answer, then finish
Each iteration is a step. A step streams one model turn, runs whatever tools the
model asked for, records the outcome, and checks whether the run is done. The
loop ends in one of three ways: the model calls final_answer, the model
replies with plain text and no tool calls, or the step limit is hit and the loop
asks for one last tool-free answer.
Everything above the provider speaks in framework-neutral types, not vendor payloads:
ChatMessageis the one message type used internally. It has a role, content, optional tool calls, an optional tool-call id (for tool results), and optional usage.ToolCallis a provider-neutral tool call: an id, a name, and a parsed arguments dict.Usageis input and output token counts, with atotal_tokensproperty.
OpenAIModel is the only place that knows OpenAI's wire format. It converts
ChatMessage lists into OpenAI messages on the way out and converts responses
back into ChatMessage on the way in, so the rest of the framework never sees a
provider-specific shape. Swapping providers means writing one adapter, not
touching the loop.
Streaming and reassembly. Model.stream yields Delta objects: small
chunks of content or fragments of a tool call. Tool-call arguments in particular
arrive in pieces across many deltas. The module-level agglomerate_deltas
function reassembles a delta stream back into a single ChatMessage: it
concatenates content, merges tool-call fragments by their index, and captures
the final usage. This is why the loop can stream text to the user and still work
with a complete ChatMessage for tool execution: it streams and reassembles at
the same time.
A run is recorded as a list of typed steps rather than a flat list of messages:
TaskStepis the user's task (or a continued turn).ActionStepis one loop iteration: the assistant message the model produced, the tool results from it, and metadata (token usage, wall-clock duration).FinalStepis the terminal answer.
Each step knows how to render itself into the ChatMessage list the model sees,
via to_messages(). Memory.to_messages(system_prompt) prepends the system
prompt (which is runtime configuration, not history, so it is never stored in a
step) and then renders every step in order. This separation is what makes the
memory serializable: dump_json / load_json round-trip the whole run, tagging
each step with its kind so it can be rebuilt, which gives you free persistence,
replay, and multi-turn continuation.
The loop communicates progress through a small set of frozen event types:
| Event | Meaning |
|---|---|
TextDelta |
A chunk of streamed assistant text. |
ToolCallEvent |
Emitted just before a tool call runs. |
ToolResultEvent |
Emitted after a tool call completes (success or error). |
StepEvent |
Emitted after a step is recorded to memory; carries the step. |
FinalEvent |
Emitted once when the run ends; carries the answer and usage. |
StepEvent is the bridge between the live event stream and the durable memory:
it carries the exact ActionStep that was just written. print_events is a
reference consumer of this stream, but you can write your own to drive a UI, log
to a database, or compute metrics.
@tool wraps a function into a Tool. At registration time it reads the
function's signature and docstring and builds a JSON Schema parameters object:
type hints become JSON types, the Args: section becomes per-parameter
descriptions, and defaults decide what is required. Before a tool runs, the loop
validates the model's arguments against that schema and raises ToolCallError
on a mismatch. For stateful tools you can subclass Tool directly instead of
using the decorator.
Skill.from_path parses a SKILL.md into a name, description, instruction body,
and an optional list of tool entry points. When an agent is built with skills,
it registers a single built-in load_skill tool and appends a catalog of
name: description lines to the system prompt. It does not put any skill bodies
in the prompt.
When the model calls load_skill(name), the agent returns that skill's full
instruction body as the tool observation and registers any tools the skill
declared. Because the loop reads the live tool set fresh on every step, those
newly registered tools are available on the very next turn. The result is that
the cost of a skill (its instructions, its tools) is only paid once the model
actually decides to use it.
These are small rules layered onto the loop that make agents robust in practice.
Not every model reliably calls final_answer. So if the model replies with
plain text and no tool calls, agentling treats that text as the answer and ends
the run. Explicit final_answer and plain-text replies both work.
_execute_tool catches any exception a tool raises and turns it into a
ToolResult with is_error=True, tagged by kind. The observation the model
sees carries a hint that depends on the failure: an invalid-argument error says
"Fix the arguments and try again", while an execution, timeout, or unknown-tool
error says "Consider a different approach or tool". So the model can correct
course, and one bad tool call does not kill the run.
If a step's tool calls are an exact repeat of the previous step's (same names, same arguments), the loop appends a short nudge to the observations telling the model it already made that call and got the same result. This helps the model break out of a stuck cycle without a hard failure.
interrupt() sets an event that the loop checks at the top of each step. When
set, the loop emits a final "Run interrupted." event and returns without writing
a terminal FinalStep. Because memory is left intact and no terminal step is
written, run(..., reset=False) can pick the run back up exactly where it
paused.
When a single model turn requests several tool calls, they run concurrently with
asyncio.gather by default. Set parallel_tools=False to run them in order
instead (useful when tools share state or must not interleave).
Agent(...):
| Parameter | Default | Description |
|---|---|---|
model |
required | Any object implementing the Model protocol. |
tools |
() |
Tools to register (a final_answer tool is always added). |
skills |
() |
Skills as folder paths or Skill objects. |
instructions |
built-in default | The system prompt. A skill catalog is appended when skills are present. |
max_steps |
15 |
Maximum loop iterations before a forced answer. Must be at least 1. |
step_callbacks |
() |
Callables invoked with each ActionStep as it is recorded. |
parallel_tools |
True |
Run a turn's tool calls concurrently, or in order when False. |
tool_timeout |
None |
Per-call time budget (seconds) for tools; a timeout becomes a recoverable observation. |
model_timeout |
None |
Time budget (seconds) for each model turn; exceeding it raises ModelError. |
max_tool_output_chars |
None |
Truncate tool observations head and tail beyond this length. |
redact_errors |
False |
Hide unexpected tool-exception messages from the model and log them instead. |
context_manager |
None |
Callable messages -> messages applied before each model call, to trim or summarize. |
OpenAIModel(...):
| Parameter | Default | Description |
|---|---|---|
model |
required | The model name to request. |
api_key |
env | Falls back to the OpenAI SDK's environment configuration. |
base_url |
None |
Point at any OpenAI-compatible endpoint. |
context_window |
128_000 |
Advertised context window for this model. |
max_retries |
2 |
Retries after the initial request for transient errors. |
retry_base_delay |
0.5 |
Initial backoff delay in seconds (doubles each retry). |
agent.run(task, *, stream=False, reset=True, max_steps=None):
stream=Falsereturns an awaitable that resolves to the final answer string.stream=Truereturns an async iterator ofEventobjects.reset=Falsecontinues from existing memory instead of starting fresh.max_stepsoverrides the agent's limit for this run only.
The project uses uv for environment and dependency management.
uv sync # install everything, including dev deps
uv run pytest # run the test suite
uv run ruff check src tests # lint
uv run ruff format --check src tests # formatting (run `ruff format` to fix)
uv run mypy src tests # type-check
uv build # verify the package buildsagentling is deliberately small, so some things are out of scope for now:
- Schema validation is shallow. Tool arguments are checked against a
practical subset of JSON Schema (primitives,
list/dict,Optional,Literal), not the full spec. - No built-in tracing backend.
step_callbacksand the event stream are the hooks; wiring them to a tracer or metrics store is up to you. - No sandboxing. Tools and skill-provided tools run as trusted code (see Security).
- No automatic context summarization. Long runs can outgrow the context
window; supply a
context_managerto trim or summarize.
Tools and skill-provided tools run as trusted code in your process, and tool
output is fed back to the model. See SECURITY.md for the trust
model, the redact_errors and max_tool_output_chars knobs, and how to report
a vulnerability.
MIT (c) Folarin Akinloye.
The design borrows the best ideas from the broader agent ecosystem: the clean ReAct loop and code-first tools popularized by smolagents, and the progressive-disclosure skill format used by Claude. agentling's contribution is packaging those ideas into a small, typed, async codebase you can read end to end.