Guide 00 — The Simple Agent¶
The first rung of the ladder. One import, one required dependency, four methods:
from symfonic import Agent
from symfonic.core.providers import AnthropicProvider
agent = Agent(AnthropicProvider())
result = await agent.run("Say hello.")
print(result.text)
There is no FrameworkConfig, no tenant scope, no session, no context
manager you are obliged to enter, no background flush, and nothing printed
about capabilities you did not ask for. Everything above is the whole API
of this rung.
New composition and existing applications
Start new compositions with Agent and add the capabilities you need.
Memory, delegation and other capabilities are not enabled by this minimal
example. Existing SymfonicAgent applications retain their compatibility
entry point, subject to existing configuration validation.
Moving to 11.0 separates adoption from retirement;
it does not introduce new deprecations.
See Guide 22 — Capability-Architecture Migration
for the whole ladder.
The public surface¶
from symfonic import ... exports exactly twelve names:
| Name | What it is |
|---|---|
Agent |
the facade below |
AgentResult |
what run() returns |
AgentEvent |
what stream() yields |
Message |
one transcript entry (role, content, tool_calls) |
ToolCall |
one call, with result or error and duration_ms |
TokenUsage |
input_tokens, output_tokens, total_tokens |
CapabilityConfig |
the typed seam for optional capability composition |
Attachment |
a file handed to a turn |
StructuredOutputError |
raised when a typed result cannot be produced |
ConfigurationError |
you passed something the agent will not accept |
ContractViolationError |
you used the agent in a way it forbids |
SymfonicError |
the base of both |
Importing the top-level module executes no package import: from
symfonic import Agent pulls in the facade chain and nothing else — no
symfonic.memory, no fastapi, no MCP client. That is a mechanically
checked property, not an aspiration, and it is why the simple path stays
fast to import in a cold container.
Constructing¶
Agent(
model_provider, # the one required dependency
*,
instructions=None, # the system prompt, used verbatim
tools=(), # fixed at construction
capabilities=(), # optional capability contributions
max_model_rounds=None, # provider round trips per run(); None = the default
)
max_model_rounds bounds how many provider round trips one run() may
take before the kernel stops with stop_reason="tool_limit". None keeps the
facade default. An agent migrated off SymfonicAgent passes the budget it
already had (config.agent.recursion_limit counts graph steps, roughly two
per round), because a loop bounded lower than the path it replaced returns a
partial answer as if it were the whole one.
model_provider is any object with a get_chat_model() method — the
shipped providers, or your own class. instructions=None sends no system
message at all; the framework substitutes no default of its own.
Construction validates and stores, and does nothing else: no connection,
no task, no file read, no environment read, no chat model. The first side
effect of an Agent is the provider call inside run().
Running one turn¶
result = await agent.run("What is the capital of France?")
result.text # the assistant text
result.messages # the full transcript of this turn
result.tool_calls # every call, with results and durations
result.usage # TokenUsage
result.stop_reason # why the loop ended
result.run_id # correlates with every event of this run
run() is a coroutine and there is no run_sync() wrapper: a wrapper
that calls asyncio.run explodes inside an already-running event loop,
and hiding that would be worse than not offering it.
Tools¶
Any @symfonic_tool, any LangChain BaseTool, or a plain annotated
callable:
from symfonic import Agent
from symfonic.core import symfonic_tool
@symfonic_tool(name="celsius_to_f")
def celsius_to_f(celsius: float) -> float:
"""Convert Celsius to Fahrenheit."""
return celsius * 9 / 5 + 32
agent = Agent(provider, tools=[celsius_to_f])
result = await agent.run("What is 21C in Fahrenheit?")
The tool loop runs to completion inside run(). Every round trip is on
result.tool_calls, in order, with the value the tool returned or the
error it raised.
Structured output¶
Pass a Pydantic model as output_type and read result.output:
from pydantic import BaseModel
class City(BaseModel):
name: str
country: str
result = await agent.run("Where is the Eiffel Tower?", output_type=City)
result.output.country # "France"
output_type is per call, not per agent: two calls on the same instance
may ask for different shapes, because the plan is compiled per invocation
and nothing mutable is carried on the agent. A response that cannot be
coerced raises StructuredOutputError.
Streaming¶
final = None
async for event in agent.stream("Write a haiku about rain."):
if event.kind == "text_delta":
print(event.text, end="")
elif event.kind == "done":
final = event.result # the same AgentResult run() returns
The kinds are thinking, text_delta, tool_call, tool_result,
done, error, cancelled. Exactly one terminal event — done,
error or cancelled — arrives per stream, and it is the last one.
stream() is a plain function that returns an async iterator, not an
async generator function: argument validation happens at the call site, so
a ConfigurationError cannot surface after you believed the stream had
already started. async for event in agent.stream(...) reads identically
either way.
Multi-turn, without a session¶
The simple Agent is stateless on purpose. You hold the transcript and
hand it back:
first = await agent.run("My name is Ada.")
second = await agent.run("What is my name?", history=first.messages)
When you want the framework to hold it for you — sessions, transcripts,
checkpoints, restart recovery — that is rung 2, and it lives on
SymfonicAgent and the conversation runtime service. See
Conversation Persistence.
Attachments¶
import base64
from symfonic import Agent, Attachment
pdf = base64.b64encode(Path("contract.pdf").read_bytes()).decode()
result = await agent.run(
"Summarise this contract.",
attachments=[
Attachment(
kind="document",
source_type="base64",
data=pdf,
media_type="application/pdf",
filename="contract.pdf",
)
],
)
source_type="url" accepts http/https only — file:// and friends
are rejected, so an attachment cannot be turned into a server-side file
read. Text extraction dependencies are optional extras; a missing one
raises a typed error naming the extra rather than failing silently. See
Guide 07 — Attachment Extraction.
Lifecycle¶
close() is optional, idempotent, terminal, and never raises. In this
release the facade owns no resources, so it only marks the instance
closed; it never closes the provider you passed in, because the provider
outlives the agent. An in-flight run() is allowed to finish — close()
does not cancel it.
Using a closed agent raises ContractViolationError. There is no reopen:
construct a new one.
What this rung does not have¶
Sessions, HMS memory, tenant scope, checkpoints and resume, sub-agents, human-approval pauses, plugins, MCP, the FastAPI router, and the 26-hook callback surface. None of it was removed and none of it is deprecated — it lives one rung up, and Guide 22 is the map.
Where to go next¶
| You want | Go to |
|---|---|
| tools, prompts, memory, delegation as first-class capabilities | Capabilities and Guide 22 §Capabilities |
| sessions, checkpoints, budgets, consolidation | Conversation Persistence |
| tenants, HTTP, auth, privacy, billing | Guide 05 — Production |
| providers, backends, MCP, plugins, OpenTelemetry | Model Providers, Guide 04 — Plugins & Domains |
| the kernel, the event stream, the contracts | Architecture |
| everything you already use, and where it is now | Feature Preservation Reference |