the Python library · 1.0.0

One class to configure, one call to run

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.

223
Names the package exports
66
Built-in tools, 8 categories
9
Presets, each a configured agent
6
Middleware points around the loop
python
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))
what that run printed
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(): 3

A run does not just say what it answered

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

Agent and AgentConfig

Every decision an agent makes is a field on one object

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.

python
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"])
what that printed
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

The context manager is the point

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.

What the agent is

The model it runs on, the tools it may call, and what it is told it is for.

ParameterTypeDefaultDescription
modelrequiredBaseModel | str
modelslist[BaseModel | str] | NoneNone
toolslist[BaseTool]
system_promptstr'You are a helpful AI assistant.'
namestr''

How the loop runs

How many turns it may take, how the model samples, and whether it streams.

ParameterTypeDefaultDescription
max_iterationsint10
temperaturefloat0.7
max_tokensint | NoneNone
seedint | NoneNone
enable_streamingboolFalse

What it remembers

Whether the conversation is kept, how it is measured, and what leaves when it stops fitting.

ParameterTypeDefaultDescription
enable_memoryboolTrue
max_context_lengthint | NoneNone
compaction_strategyAnyNone
tokenizerAnyNone

Where the calls go

Which provider, which endpoint, which credential. All three are optional; a bare model id resolves them.

ParameterTypeDefaultDescription
providerstr | NoneNone
base_urlstr | NoneNone
api_keystr | NoneNone

What it refuses, and what it reports

The hooks around the loop, the checks over the text, the approval gate, and what a failure does.

ParameterTypeDefaultDescription
middlewarelist[Any]
guardrailsAnyNone
approval_modestr'never'
raise_on_errorboolTrue

What comes back

Ask for a shape, and the answer is validated against it before the run reports success.

ParameterTypeDefaultDescription
output_formatstr | NoneNone
output_schemadict[str, Any] | NoneNone

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.

AgentResponse

What comes back is a record of the run, not just its answer

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.

FieldTypeDescription
outputstrThe answer, as text. Passing the response to str() gives the same string.
successboolWhether the run finished the task rather than running out of turns or failing.
tool_callsToolCallListThe calls the run made, as records. Still compares and casts as their count.
iterationsintHow many turns of the loop it took.
tokens_usedintPrompt and completion tokens across every model call in the run.
execution_timefloatWall-clock seconds from the call to the answer.
sourceslist[str]The documents a retrieval-backed answer drew on.
citationslist[Any]The specific passages behind the answer, each with its source.
execution_tracelist[dict[str, Any]]Every step in order: the thought, the action, the observation.
metadatadict[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

One call, as a record

AttributeTypeDescription
namestr
argumentsAny
resultstr | None
durationfloat | None
errorstr | None
iterationint | None

ToolCall — one entry per dispatch, in the order they were made

The list reads three ways

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.

Presets

9 agents that are already configured

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.

math

Mathematical reasoning agent with Calculator and PythonREPL.

Tools
2
Schema cost
~338 tok
Temperature
0.3
Max turns
8
research

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

Tools
15
Schema cost
~3,374 tok
Temperature
0.5
Max turns
10
coding

Coding agent with CodeExecutor, PythonREPL, FileOperations, and BashTool.

Tools
4
Schema cost
~1,047 tok
Temperature
0.4
Max turns
12
general

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

Tools
31
Schema cost
~7,944 tok
Temperature
0.7
Max turns
10
rag

Retrieval-Augmented Generation agent with hybrid search over a knowledge base.

Tools
1
Schema cost
~270 tok
Temperature
0.3
Max turns
8
minimal

Minimal agent with no tools — direct model inference only.

Tools
0
Schema cost
~0 tok
Temperature
0.7
Max turns
1
multimodal

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

Tools
7
Schema cost
~2,201 tok
Temperature
0.3
Max turns
10
notify

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

Tools
4
Schema cost
~1,069 tok
Temperature
0.3
Max turns
6
media

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

Tools
2
Schema cost
~684 tok
Temperature
0.3
Max turns
8
python
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()
what that printed
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']

The schema cost is why the list is short

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.

A domain instead of a preset

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.

Tools

A tool is a function the model may call, and there are four ways to supply one

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.

The built-in registry

Ask the registry for one of the tools the framework ships and put it in the list.

A function of yours

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.

The provider's own tools

Web search, code interpreter, file search, computer use and the text editor, executed by the provider rather than by your process.

A server that speaks a protocol

MCP, A2A and ACP servers are mounted as tools, so an agent reaches what those servers expose without a wrapper per tool.

Your own function, decorated

python
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)
what that printed
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 indexable

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

What a tool hands back

AttributeTypeDescription
successbool
outputAny
errorstr | None
execution_timefloat
metadatadict[str, Any]
timestampstr

ToolResult, effGen 1.0.0

The schema is read from the signature

python
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}")
what that printed
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.

All 66 built-in tools
Middleware

6 places to put behaviour the framework does not ship

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.

HookTypeDescription
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

One of your own

python
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()})
what that printed
{'datetime': 0.0007}

Editing, short-circuiting and ordering

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.

The two that ship

LoggingMiddleware

Log every model call and tool call the run makes.

ToolApprovalMiddleware

Ask before letting named tools run.

python
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)
what that printed
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}

A refusal is reported to the model

python
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)
what that printed
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.

docs/guides/middleware.md
Sessions, memory and compaction

One agent, many conversations, and a history that has to fit

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.

python
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)
what that printed
Pixel
Mote

What a session is, and where it lives

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.

python
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())
what that printed
Ubuntu 24.04
messages in memory: 6
tokens held: 34

When the history stops fitting, something has to go

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=None

Summarize everything but the most recent few. The default.

DropOldestkeep_recent=None

Discard the oldest messages without summarizing them.

KeepFirstAndLastfirst=2, last=6, summarize_middle=True

Keep the opening turns and the recent ones; compact the middle.

KeepToolResultskeep_recent=None, summarize_dropped=True

Drop the reasoning, keep what the tools returned.

python
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())
what that printed
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.

Retrieval

Point an agent at documents, and the answer carries its sources

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.

python
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()
what that printed
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']

The pieces, when the preset is not the shape you want

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 a different thing

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.

docs/tutorials/rag-pipeline.md
Teams and workflows

Several agents, and a pipeline that does not start again from the top

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.

A team, and what each agent contributed

python
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()
what that printed
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)

The patterns

  • sequential
  • parallel
  • hierarchical
  • collaborative
  • competitive
  • pipeline

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.

docs/tutorials/multi-agent.md

A workflow that resumes

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.

python
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()
what that printed
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

What resuming does with each node

State when the run stoppedTypeDescription
completednot run again — its output is restored and flows downstream
skippedstays skipped, with the reason it was skipped for
failedretried, which is usually why the run is being resumed
never startedrun normally

Progress is saved after each topological level, atomically.

Where a checkpoint is kept

FileCheckpointStoreA checkpoint store backed by one JSON file per run.

InMemoryCheckpointStoreA checkpoint store that lives and dies with the process.

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

Structured output and streaming

Ask for a shape, or ask for it as it arrives

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.

python
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"])
what that printed
{"city":"Granada","country":"Spain","construction_began":1238}
1238
structured: True
python
from 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() != "")
what that printed
Red, blue and yellow.
chunks: 6
joined == answer: True

What the stream contains

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.

When it does not work

A tool that raised is on the record, not swallowed

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.

python
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])
what that printed
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

raise_on_error defaults to True in 1.0.0

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.

A run that stops early says so

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.

An unreachable backend has no opt-out

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.

What that looks like

The reference for the library

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