mathMathematical reasoning agent with Calculator and PythonREPL.
- Tools
- 2
- Schema cost
- ~338 tok
- Temperature
- 0.3
- Max turns
- 8
Agent(AgentConfig(...)).run(task) is the whole surface for a first agent. Everything past that — 66 tools and your own functions, hooks around every model and tool call, one agent serving many conversations, four ways to compact a history that no longer fits, retrieval, teams, and workflows that resume where they died — is a parameter on that same object.
from effgen import Agent, AgentConfig, tool
CATALOGUE = {"wall bracket": "KX-9", "shelf pin": "TR-2"}
STOCK = {"KX-9": 3400, "TR-2": 118}
@tool
def find_sku(product: str) -> str:
"""Look up the SKU for a product name."""
return CATALOGUE[product.lower()]
@tool
def stock_level(sku: str) -> int:
"""How many units of a SKU are in the warehouse."""
return STOCK[sku]
with Agent(AgentConfig(
model="openai:gpt-5-nano",
tools=[find_sku, stock_level],
tool_calling_mode="react",
)) as agent:
r = agent.run("How many wall brackets do we have in stock?")
print(r.output)
print()
print("calls:", r.tool_calls.total, "· names:", r.tool_calls.names)
for call in r.tool_calls:
print(f" iteration {call.iteration} {call.name}({call.arguments})")
print(f" -> {call.result} in {call.duration:.4f}s error={call.error}")
print("failed: ", len(r.tool_calls.failed))
print("find_sku: ", r.tool_calls.by_name("find_sku").names)
print("compares as its own count:", r.tool_calls == r.tool_calls.total,
"· int():", int(r.tool_calls))3,400 wall brackets in stock (SKU: KX-9).
calls: 3 · names: ['find_sku', 'find_sku', 'stock_level']
iteration 1 find_sku({"product": "wall brackets"})
-> Error executing tool 'find_sku': Tool execution failed: 'wall brackets' in 0.0031s error=Error executing tool 'find_sku': Tool execution failed: 'wall brackets'
iteration 2 find_sku({"product": "wall bracket"})
-> KX-9 in 0.0015s error=None
iteration 3 stock_level({"sku": "KX-9"})
-> 3400 in 0.0015s error=None
failed: 1
find_sku: ['find_sku', 'find_sku']
compares as its own count: True · int(): 3Before 1.0.0, tool_calls was an integer: you could learn that two calls happened and nothing else. It now carries the calls — each one’s name, the arguments the model chose, what came back, how long it took, which turn it was on, and the error if it failed — while still comparing and casting as the count, so code written against the old integer keeps working.
That is the difference between an agent you can debug and one you have to instrument first. This run took three turns: the model guessed a product name the catalogue does not carry, the tool raised, it corrected itself and looked the stock up — and all of that is readable from the response afterwards.
tool_calling_mode="react" is what makes it one call per turn. In 1.0.0 a turn in which the model asks for several tools at once records their names; the fields beside them are filled in for a call the loop dispatched on its own turn.
AgentConfig carries 49 fields, and model is the only one you have to supply. The rest have defaults that make a useful agent, and the ones below are what a first agent is usually written with. Every type and default in the tables is read out of the installed class.
from effgen import Agent, AgentConfig
with Agent(AgentConfig(
model="openai:gpt-5-nano",
system_prompt="Answer in one short sentence.",
)) as agent:
r = agent.run("What is an agent loop?")
print(r.output)
print("success:", r.success, "· iterations:", r.iterations)
print("tokens:", r.tokens_used, "· cost: $%.6f" % r.metadata["cost_usd"])An agent loop is the continuous cycle in which an agent perceives its environment, reasons and updates its internal state, selects and executes an action, and then observes the outcome to repeat the process. success: True · iterations: 1 tokens: 322 · cost: $0.000122
An agent holds a model handle, an HTTP client and, on a local engine, weights. with closes all of it at the end of the block; agent.close() does the same thing where a with does not fit, and await agent.aclose() is the async form.
run() is synchronous. run_async() is the coroutine, stream() yields the answer as it arrives, run_batch() takes a list of tasks, and run_background() hands the work to a worker thread and returns a task id.
The model it runs on, the tools it may call, and what it is told it is for.
| Parameter | Type | Default | Description |
|---|---|---|---|
modelrequired | BaseModel | str | — | |
models | list[BaseModel | str] | None | None | |
tools | list[BaseTool] | — | |
system_prompt | str | 'You are a helpful AI assistant.' | |
name | str | '' |
How many turns it may take, how the model samples, and whether it streams.
| Parameter | Type | Default | Description |
|---|---|---|---|
max_iterations | int | 10 | |
temperature | float | 0.7 | |
max_tokens | int | None | None | |
seed | int | None | None | |
enable_streaming | bool | False |
Whether the conversation is kept, how it is measured, and what leaves when it stops fitting.
| Parameter | Type | Default | Description |
|---|---|---|---|
enable_memory | bool | True | |
max_context_length | int | None | None | |
compaction_strategy | Any | None | |
tokenizer | Any | None |
Which provider, which endpoint, which credential. All three are optional; a bare model id resolves them.
| Parameter | Type | Default | Description |
|---|---|---|---|
provider | str | None | None | |
base_url | str | None | None | |
api_key | str | None | None |
The hooks around the loop, the checks over the text, the approval gate, and what a failure does.
| Parameter | Type | Default | Description |
|---|---|---|---|
middleware | list[Any] | — | |
guardrails | Any | None | |
approval_mode | str | 'never' | |
raise_on_error | bool | True |
Ask for a shape, and the answer is validated against it before the run reports success.
| Parameter | Type | Default | Description |
|---|---|---|---|
output_format | str | None | None | |
output_schema | dict[str, Any] | None | None |
Types and defaults from AgentConfig in effGen 1.0.0. The other 26 fields cover sub-agents, routing, multimodal input, prompt caching and the callbacks a human-in-the-loop run uses.
run() returns an AgentResponse. It is imported from effgen.core.agent rather than the top-level package, because you rarely name the type — you read fields off what you were handed.
| Field | Type | Description |
|---|---|---|
output | str | The answer, as text. Passing the response to str() gives the same string. |
success | bool | Whether the run finished the task rather than running out of turns or failing. |
tool_calls | ToolCallList | The calls the run made, as records. Still compares and casts as their count. |
iterations | int | How many turns of the loop it took. |
tokens_used | int | Prompt and completion tokens across every model call in the run. |
execution_time | float | Wall-clock seconds from the call to the answer. |
sources | list[str] | The documents a retrieval-backed answer drew on. |
citations | list[Any] | The specific passages behind the answer, each with its source. |
execution_trace | list[dict[str, Any]] | Every step in order: the thought, the action, the observation. |
metadata | dict[str, Any] | Cost, per-call token counts, the partial answer of a run that stopped early, and what redaction removed. |
AgentResponse, effGen 1.0.0
| Attribute | Type | Description |
|---|---|---|
name | str | |
arguments | Any | |
result | str | None | |
duration | float | None | |
error | str | None | |
iteration | int | None |
ToolCall — one entry per dispatch, in the order they were made
tool_calls is a list of those records. .names is the call order with repeats kept, .failed is the subset that reported an error, .by_name("calculator") is the calls to one tool, and .total is how many the run made.
It also compares and casts as that number, so if response.tool_calls:, response.tool_calls == 2 and int(response.tool_calls) all mean what they meant when the field was an integer. .to_list() gives plain dictionaries for JSON.
A preset is an AgentConfig someone already wrote: a tool set, a system prompt, a temperature and an iteration cap chosen for one kind of work. create_agent(preset, model) builds it, and any field can still be overridden by keyword.
mathMathematical reasoning agent with Calculator and PythonREPL.
researchResearch agent with WebSearch, URLFetch, Wikipedia, academic search (arXiv, PubMed, Semantic Scholar), RSS feeds, news, YouTube transcript/metadata, Reddit, Hacker News, and document parsing (PDF, DOCX, Excel) tools.
codingCoding agent with CodeExecutor, PythonREPL, FileOperations, and BashTool.
generalGeneral-purpose agent with a broad set of built-in tools, including QR, OCR, audio transcription, image analysis, document parsing (PDF, DOCX, Excel), weather/geo, email (SMTP/IMAP), Slack, and Discord webhooks. The unsandboxed shell (bash) is opt-in via the 'coding' preset, not bundled here.
ragRetrieval-Augmented Generation agent with hybrid search over a knowledge base.
minimalMinimal agent with no tools — direct model inference only.
multimodalMultimodal agent for image, audio, and video understanding. Uses Gemini Flash (primary) with OpenAI gpt-4o-mini and HF fallbacks. Equipped with ImageInfo, ImageCaption, OCR, AudioTranscribe, PDF, Weather, and MultimodalDescribe (auto-dispatch for any media type).
notifyNotification agent that can send emails (SMTP), read email (IMAP), and post Slack or Discord messages. Configure credentials via env vars: SMTP_HOST/SMTP_USER/SMTP_PASSWORD, IMAP_HOST/IMAP_USER/IMAP_PASSWORD, SLACK_WEBHOOK_URL, DISCORD_WEBHOOK_URL.
mediaMedia processing agent with AudioTranscribeTool (speech-to-text) and ImageCaptionTool (vision captioning via OpenAI/Gemini). Handles audio files (MP3, WAV, OGG, FLAC) and images (PNG, JPEG, WEBP).
from effgen import create_agent, list_presets
for name, description in list_presets().items():
print(f"{name:12} {description[:60]}")
agent = create_agent("math", "openai:gpt-5-nano")
r = agent.run("What is the 12th Fibonacci number?")
print()
print(r.output)
print("tools:", [t.name for t in agent.config.tools])
print("calls:", r.tool_calls.names)
agent.close()math Mathematical reasoning agent with Calculator and PythonREPL. research Research agent with WebSearch, URLFetch, Wikipedia, academic coding Coding agent with CodeExecutor, PythonREPL, FileOperations, general General-purpose agent with a broad set of built-in tools, in rag Retrieval-Augmented Generation agent with hybrid search over minimal Minimal agent with no tools — direct model inference only. multimodal Multimodal agent for image, audio, and video understanding. notify Notification agent that can send emails (SMTP), read email ( media Media processing agent with AudioTranscribeTool (speech-to-t 144 tools: ['calculator', 'python_repl'] calls: ['calculator', 'calculator', 'calculator', 'python_repl']
Every tool a preset carries sends its JSON schema on every request. The general preset’s 31 tools cost about 7,944 tokens of context before the task is even stated, which is most of a small model’s window. math costs about 338. That figure is on every card above for the same reason it is in effgen presets: it decides which models a preset fits.
create_agent(domain=LegalDomain()) builds the same object from a knowledge domain instead — its system prompt, its tool names and its guardrails. Legal, health, finance, science and tech ship; Domain is the base class for one of your own.
Whichever route it arrives by, a tool is awaited as await tool.execute(**kwargs) and hands back a ToolResult. Inside an agent you never call it yourself — the loop does, and the record of that call lands in response.tool_calls.
Ask the registry for one of the tools the framework ships and put it in the list.
Decorate it with @tool, or wrap it with Tool.from_function. The name, the description and the parameter schema are read from the signature, the type hints and the docstring.
Web search, code interpreter, file search, computer use and the text editor, executed by the provider rather than by your process.
MCP, A2A and ACP servers are mounted as tools, so an agent reaches what those servers expose without a wrapper per tool.
import asyncio
from effgen import Agent, AgentConfig, tool
@tool
def shipping_cost(weight_kg: float, express: bool = False) -> str:
"""Quote a shipping cost in euros for a parcel."""
price = 4.50 + 1.75 * weight_kg + (9.0 if express else 0.0)
return f"EUR {price:.2f}"
result = asyncio.run(shipping_cost.execute(weight_kg=2.5, express=True))
print("success:", result.success)
print("output: ", result.output)
print("error: ", result.error)
print("took: %.4fs" % result.execution_time)
with Agent(AgentConfig(model="openai:gpt-5-nano", tools=[shipping_cost])) as agent:
r = agent.run("How much to send a 2.5 kg parcel by express?")
print(r.output)
print("calls:", r.tool_calls.names)success: True output: EUR 17.88 error: None took: 0.0005s EUR 17.88 for express shipping of a 2.5 kg parcel. If you'd like, I can check the standard (non-express) rate or other options as well. calls: ['shipping_cost']
ToolResult has no data field, and it is not indexableRead what a tool returned from result.output, after checking result.success. Arguments go in as keywords — execute(operation="search", query="...") — not as one dictionary. Both are worth stating plainly, because the other shape has been written down often enough to look plausible, and it raises.
| Attribute | Type | Description |
|---|---|---|
success | bool | |
output | Any | |
error | str | None | |
execution_time | float | |
metadata | dict[str, Any] | |
timestamp | str |
ToolResult, effGen 1.0.0
from effgen import Tool
def seat_count(rows: int, seats_per_row: int) -> int:
"""Count the seats in a rectangular block of a theatre."""
return rows * seats_per_row
seats = Tool.from_function(seat_count, category="computation")
print(seats.name, "·", seats.description)
print(seats.metadata.category.value)
for parameter in seats.metadata.parameters:
print(f" {parameter.name}: {parameter.type.value} "
f"(required={parameter.required}) — {parameter.description}")seat_count · Count the seats in a rectangular block of a theatre. computation rows: integer (required=True) — The rows parameter. seats_per_row: integer (required=True) — The seats_per_row parameter.
The name comes from the function, the description from the docstring and the parameters from the type hints, so the schema the model sees cannot drift from the function that runs. requires_approval=True marks a tool with a real-world side effect, which the approval gate below then holds.
An approval gate, a cache, a redaction pass, a per-run spend cap, a trace exporter of your own. Subclass AgentMiddleware, override the hooks you need, and pass the instance in AgentConfig(middleware=[...]). Every hook has a default that does nothing, so overriding one leaves the other five as they were.
| Hook | Type | Description |
|---|---|---|
before_run | (ctx: 'RunContext') -> "'AgentResponse | None'" | Called once before the run starts. |
after_run | (ctx: 'RunContext', response: "'AgentResponse'") -> "'AgentResponse'" | Called once after the run finishes. Return the response to report. |
before_model_call | (ctx: 'ModelCallContext') -> 'Any' | Called before each generation. |
after_model_call | (ctx: 'ModelCallContext', result: 'Any') -> 'Any' | Called after each generation. Return the result to use. |
before_tool_call | (ctx: 'ToolCallContext') -> 'str | None' | Called before each tool dispatch. |
after_tool_call | (ctx: 'ToolCallContext', result: 'str') -> 'str' | Called after each tool dispatch. Return the output to use. |
AgentMiddleware — the order they fire in
import time
from effgen import Agent, AgentConfig, AgentMiddleware, get_tool_registry
class TimeEachTool(AgentMiddleware):
"""Record how long every tool call took."""
def __init__(self) -> None:
self.timings: dict[str, float] = {}
self._started: dict[str, float] = {}
def before_tool_call(self, ctx):
self._started[ctx.tool_name] = time.perf_counter()
def after_tool_call(self, ctx, result):
started = self._started.pop(ctx.tool_name, None)
if started is not None:
self.timings[ctx.tool_name] = time.perf_counter() - started
return result
timer = TimeEachTool()
clock = get_tool_registry().get_tool_sync("datetime")
with Agent(AgentConfig(
model="openai:gpt-5-nano",
tools=[clock],
middleware=[timer],
)) as agent:
agent.run("What is the current date in UTC?")
print({name: round(seconds, 4) for name, seconds in timer.timings.items()}){'datetime': 0.0007}A before_ hook receives a context it may edit in place. Return None and the call goes ahead with whatever the hook left behind; return anything else and the real call does not happen — the returned value is used as its result, and the matching after_ hook still runs. An after_ hook returns the result to use, so it can transform as well as observe.
before_ hooks run in the order given and after_ hooks in reverse, so middleware nest the way context managers do. A hook that raises is not caught, which is what lets a refusing gate stop the run outright. run(..., middleware=[...]) adds to the configured list for that one call. The list itself is held by a MiddlewareChain, which is what walks it around each of the three points.
LoggingMiddlewareLog every model call and tool call the run makes.
ToolApprovalMiddlewareAsk before letting named tools run.
import logging
from effgen import Agent, AgentConfig, LoggingMiddleware, get_tool_registry
logging.basicConfig(level=logging.WARNING, format="%(message)s")
logging.getLogger("effgen.core.middleware").setLevel(logging.INFO)
clock = get_tool_registry().get_tool_sync("datetime")
with Agent(AgentConfig(
model="openai:gpt-5-nano",
tools=[clock],
middleware=[LoggingMiddleware()],
)) as agent:
r = agent.run("What is the current date in UTC?")
print(r.output)run start: What is the current date in UTC?
model call: openai:gpt-5-nano (attempt 1)
tool call: datetime({"operation": "now", "timezone": "UTC"})
model call: openai:gpt-5-nano (attempt 1)
run end: success=True tool_calls=1
{'datetime': '2026-08-22 09:51:41', 'date': '2026-08-22', 'time': '09:51:41', 'timezone': 'UTC', 'day_of_week': 'Saturday', 'week_number': 34, 'timestamp': 1787392301}from effgen import Agent, AgentConfig, ToolApprovalMiddleware, tool
asked = []
@tool
def issue_refund(order_id: str, amount: float) -> str:
"""Refund an order."""
return f"refunded {amount} on {order_id}"
def approve(name: str, arguments: str) -> bool:
asked.append(name)
return False
with Agent(AgentConfig(
model="openai:gpt-5-nano",
tools=[issue_refund],
middleware=[ToolApprovalMiddleware(approve, tools=["issue_refund"])],
)) as agent:
r = agent.run("Refund order A-4471 for 20 euros.")
print("asked about:", asked)
for call in r.tool_calls:
print(call.name, "->", call.result)asked about: ['issue_refund'] issue_refund -> This call was not approved, so the tool did not run.
The refusal reaches the model as the call’s result, so the run continues and can say what it was not allowed to do — rather than the tool returning nothing and the answer being written as though the refund had happened.
An agent remembers its own conversation by default. session_id= on the constructor binds one conversation to the agent for its whole life; session= on run() names the conversation per call, which is what a server handling many people wants.
import os
import tempfile
os.environ["EFFGEN_SESSIONS_DIR"] = tempfile.mkdtemp()
from effgen import Agent, AgentConfig
with Agent(AgentConfig(model="openai:gpt-5-nano")) as agent:
agent.run("My dog is named Pixel.", session="user-123")
agent.run("My cat is named Mote.", session="user-456")
print(agent.run("What is my pet's name? One word.", session="user-123").output)
print(agent.run("What is my pet's name? One word.", session="user-456").output)Pixel Mote
The run builds its prompt from that conversation’s history and appends the turn to it. The two conversations never see each other, and the agent’s own memory is untouched and restored when the call ends — including when the run fails. Without session=, run() uses the agent’s own memory as before.
Sessions are JSON under ~/.effgen/sessions/, written atomically so a crash mid-write cannot leave a truncated file that fails to load later. Every turn is stamped with the model, the token counts, the cost and the latency it was answered with. A Session object works wherever an id does, and effgen sessions lists, reads, exports and cleans them up.
from effgen import Agent, AgentConfig
with Agent(AgentConfig(model="openai:gpt-5-nano", enable_memory=True)) as agent:
agent.run("The build server is called hawthorn. Reply with ok.")
agent.run("It runs Ubuntu 24.04. Reply with ok.")
print(agent.run("Which operating system does hawthorn run?").output)
print("messages in memory:", len(agent.short_term_memory.get_messages()))
print("tokens held:", agent.short_term_memory.get_token_count())Ubuntu 24.04 messages in memory: 6 tokens held: 34
Which turns survive changes the answer more for a small model than for a large one, and different tasks want different answers — so the choice is a strategy rather than a fixed rule. Each one takes its thresholds from the memory’s own settings when you do not name them.
SummarizeOldestthreshold=None, keep_recent=NoneSummarize everything but the most recent few. The default.
DropOldestkeep_recent=NoneDiscard the oldest messages without summarizing them.
KeepFirstAndLastfirst=2, last=6, summarize_middle=TrueKeep the opening turns and the recent ones; compact the middle.
KeepToolResultskeep_recent=None, summarize_dropped=TrueDrop the reasoning, keep what the tools returned.
from effgen import Agent, AgentConfig, KeepFirstAndLast
with Agent(AgentConfig(
model="openai:gpt-5-nano",
compaction_strategy=KeepFirstAndLast(first=2, last=4),
max_context_length=2048,
)) as agent:
for line in ["The invoice number is INV-8842.",
"The customer is Marta Reyes.",
"The amount is 412 euros.",
"The due date is the 30th.",
"The purchase order is PO-19.",
"The shipping city is Porto."]:
agent.run(line + " Reply with ok.")
print(agent.run("What is the invoice number?").output)
print("messages held:", len(agent.short_term_memory.get_messages()))
print("tokens held: ", agent.short_term_memory.get_token_count())INV-8842 messages held: 14 tokens held: 68
The invoice number was in the first turn, which is the one KeepFirstAndLast is built to hold on to. A strategy can also be named as a string — compaction_strategy="drop_oldest" — and tokenizer= measures the history in the units the model’s window is actually measured in, rather than estimating four characters to a token.
The rag preset takes a knowledge_base= and runs the pipeline behind it: ingestion, chunking, hybrid search, reranking and attribution. The response then carries sources and citations next to the answer, so a claim can be traced back to the passage it came from.
import pathlib
import tempfile
from effgen import create_agent
docs = pathlib.Path(tempfile.mkdtemp())
(docs / "runbook.md").write_text(
"# Runbook\n\n"
"The nightly export starts at 02:15 UTC.\n"
"If it has not finished by 03:00, restart the exporter and page the on-call.\n"
)
(docs / "contacts.md").write_text(
"# Contacts\n\nThe on-call rota is in the operations calendar.\n"
)
agent = create_agent("rag", "openai:gpt-5-nano", knowledge_base=str(docs))
r = agent.run("When does the nightly export start?")
print(r.output)
print("sources:", r.sources)
print("citations:", [c.source for c in r.citations])
agent.close()The nightly export starts at 02:15 UTC. [1] sources: ['/tmp/tmp8vuzhhe1/runbook.md', '/tmp/tmp8vuzhhe1/contacts.md'] citations: ['/tmp/tmp8vuzhhe1/runbook.md', '/tmp/tmp8vuzhhe1/contacts.md']
DocumentIngester reads a directory — text, Markdown, JSON, JSONL, CSV and HTML built in, PDF, DOCX and EPUB with their optional dependencies — recursing and de-duplicating on a content hash.
Four chunkers cover what a flat splitter gets wrong: SemanticChunker splits on meaning, CodeChunker on functions and classes, TableChunker keeps a table whole, and HierarchicalChunker keeps a document’s structure.
HybridSearchEngine combines dense and keyword retrieval; the rerankers are a cross-encoder, a rule-based pass and a model as judge. CitationTracker is what puts the [1] in the answer above.
Memory is what this agent has said and heard. Retrieval is a corpus it can look things up in. ShortTermMemory holds the conversation, LongTermMemory keeps entries across runs with an importance level, and VectorMemoryStore searches them by similarity rather than by recency.
A team runs agents under one of 6 patterns and aggregates what they produced. A WorkflowDAG is the explicit form: nodes, edges, independent nodes running in parallel, and a cycle rejected when the graph is built rather than when it runs.
from effgen import (Agent, AgentConfig, MultiAgentOrchestrator,
OrchestrationPattern, load_model)
model = load_model("openai:gpt-5-nano")
writer = Agent(AgentConfig(name="writer", model=model,
system_prompt="Write one plain sentence."))
editor = Agent(AgentConfig(name="editor", model=model,
system_prompt="Shorten what you are given. Reply with the sentence only."))
orchestrator = MultiAgentOrchestrator()
team = orchestrator.create_team(
"copy", [writer, editor], pattern=OrchestrationPattern.SEQUENTIAL,
)
result = orchestrator.assign_task("Describe what a checkpoint is.", team)
print(result.output)
print("pattern:", result.pattern.value, "· agents:", len(result.agent_responses))
for stage in result.agent_responses:
print(f" {stage['agent_name']}: {stage['output'][:60]}"
f" ({stage['tokens_used']} tokens, ${stage['cost_usd']:.6f})")
writer.close()
editor.close()A checkpoint is a saved state that can be restored later. pattern: sequential · agents: 2 writer: A checkpoint is a saved state or snapshot of a process, syst (436 tokens, $0.000168) editor: A checkpoint is a saved state that can be restored later. (514 tokens, $0.000190)
A team reports success only when at least one agent ran and every agent that ran succeeded. On a failure it never echoes the input back as the answer — a caller reading .output without checking .success must not mistake the task for a result. Partial work stays readable in agent_responses.
Give run() a store and a run id. There is no separate resume call: a run id the store has never seen starts from the beginning, and one it knows continues. Below, the third node fails on the first pass; running the same line again re-runs only that node, and the two that finished are restored from the checkpoint at 0.0s without calling a model.
import tempfile
from effgen import (Agent, AgentConfig, AgentMiddleware, FileCheckpointStore,
WorkflowDAG, WorkflowNode, load_model)
class FailOnce(AgentMiddleware):
"""Stand in for the process being killed part-way through."""
def __init__(self) -> None:
self.armed = True
def before_run(self, ctx):
if self.armed:
self.armed = False
raise RuntimeError("the box went away")
return None
model = load_model("openai:gpt-5-nano")
short = "Reply with one short sentence."
research = Agent(AgentConfig(name="research", model=model, system_prompt=short))
draft = Agent(AgentConfig(name="draft", model=model, system_prompt=short))
breaker = FailOnce()
review = Agent(AgentConfig(name="review", model=model, system_prompt=short,
middleware=[breaker]))
store = FileCheckpointStore(tempfile.mkdtemp())
def build() -> WorkflowDAG:
dag = WorkflowDAG("report")
dag.add_node(WorkflowNode(id="research", agent=research))
dag.add_node(WorkflowNode(id="draft", agent=draft))
dag.add_node(WorkflowNode(id="review", agent=review))
dag.connect("research", "draft")
dag.connect("draft", "review")
return dag
def show(label, result):
print(f"{label}:", [(n["id"], n["status"], n["execution_time"])
for n in result.node_results], "success:", result.success)
show("first run ", build().run("Summarise what a DAG is.", checkpoint=store, run_id="q3"))
print("checkpoint:", "completed", sorted(store.load("q3").completed),
"failed", sorted(store.load("q3").failed))
show("resumed ", build().run("Summarise what a DAG is.", checkpoint=store, run_id="q3"))
for agent in (research, draft, review):
agent.close()first run : [('research', 'completed', 3.672), ('draft', 'completed', 3.817), ('review', 'failed', 0.002)] success: False
checkpoint: completed ['draft', 'research'] failed ['review']
resumed : [('research', 'completed', 0.0), ('draft', 'completed', 0.0), ('review', 'completed', 4.392)] success: True| State when the run stopped | Type | Description |
|---|---|---|
completed | — | not run again — its output is restored and flows downstream |
skipped | — | stays skipped, with the reason it was skipped for |
failed | — | retried, which is usually why the run is being resumed |
never started | — | run normally |
Progress is saved after each topological level, atomically.
FileCheckpointStore — A checkpoint store backed by one JSON file per run.
InMemoryCheckpointStore — A checkpoint store that lives and dies with the process.
CheckpointStore — Where workflow checkpoints are kept.
The store holds run state, never the graph: agents own sockets, model handles and credentials, none of which survive a process boundary. Rebuild the same WorkflowDAG in the new process and hand it the same run id. Resuming into a graph with different node ids raises rather than mixing two workflows’ outputs, and re-running a finished run replays its stored outputs without calling a model — which makes a workflow idempotent under a job runner that retries.
output_schema= validates the answer against a JSON Schema before the run reports success, so a caller that parses the result is not parsing prose that happens to look like JSON. stream() yields the answer as it is generated, with the intermediate steps delivered to callbacks rather than mixed into the text.
import json
from effgen import Agent, AgentConfig
schema = {
"type": "object",
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
"construction_began": {"type": "integer"},
},
"required": ["city", "country", "construction_began"],
}
with Agent(AgentConfig(model="openai:gpt-5-nano")) as agent:
r = agent.run("Where is the Alhambra, and in which year did its construction begin?",
output_schema=schema)
print(r.output)
print(json.loads(r.output)["construction_began"])
print("structured:", r.metadata["structured_output"]){"city":"Granada","country":"Spain","construction_began":1238}
1238
structured: Truefrom effgen import Agent, AgentConfig
with Agent(AgentConfig(model="openai:gpt-5-nano", enable_streaming=True)) as agent:
chunks = []
for chunk in agent.stream("Name the three primary colours, in one line."):
chunks.append(chunk)
print(chunk, end="", flush=True)
print()
print("chunks:", len(chunks))
print("joined == answer:", "".join(chunks).strip() != "")Red, blue and yellow. chunks: 6 joined == answer: True
Iterating yields answer-text deltas, and joining every chunk reconstructs the final answer — on the no-tool path and the tool path alike. The loop’s own scaffolding is never part of the text. On a tool-using run the intermediate steps arrive through the on_thought, on_tool_call and on_observation callbacks, or as typed events with include_events=True.
A tool that fails does not take the run down: the error is reported to the model, which can try something else or say what it could not do. It is also kept on the call record — so a run that produced a plausible answer over a failed lookup can be found afterwards, instead of being indistinguishable from one that worked.
from effgen import Agent, AgentConfig, tool
@tool
def read_ledger(account: str) -> str:
"""Read an account's ledger."""
raise FileNotFoundError(f"no ledger for {account}")
with Agent(AgentConfig(
model="openai:gpt-5-nano",
tools=[read_ledger],
max_iterations=2,
raise_on_error=False,
)) as agent:
r = agent.run("Read the ledger for account 55-2 and tell me the balance.")
print("success:", r.success, "· iterations:", r.iterations)
for call in r.tool_calls:
print(call.name, "· ok:", call.ok, "· error:", call.error)
print("failed calls:", len(r.tool_calls.failed))
print("partial_output:", repr(r.metadata.get("partial_output"))[:120])success: True · iterations: 2 read_ledger · ok: False · error: Error executing tool 'read_ledger': Tool execution failed: no ledger for 55-2 failed calls: 1 partial_output: None
One of the release’s three breaking changes. Before, a run that failed returned a response whose success was False — and a caller that read .output without checking got an error string treated as an answer. It now raises by default. Pass raise_on_error=False for the old behaviour, as the sample here does.
Hitting max_iterations is not success. success is False, and metadata["partial_output"] carries what the run had produced when it stopped — so the work is not lost, and it is not mistaken for a finished answer.
A task that ran and failed is something you can inspect. A connection that was refused is not. So a backend that never answered raises BackendUnreachableError whatever raise_on_error says — the third breaking change, and the one that stops a whole batch completing against nothing and looking healthy in the summary.
Every signature, every default and every exception, plus the guides behind the sections above: middleware, sessions and checkpoints, compaction, the tool registry, and the multi-agent patterns.
docs/tools/index.md — writing and registering tools