Skip to content

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. From 11.3.0, SymfonicAgent(...) and AgentBuilder().build() raise EntryPointRemovedError, a migration error, with no compatibility shim. symfonic-core==9.12.* is the newest public PyPI line on which both still construct (private 11.0 to 11.2.6 did too), but it predates Agent and the other 11.x features, so pinning it does not migrate an 11.x application. Do not start new code on either. See Moving to 11.0 and 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
    result_policy=None,      # optional native tool-result compaction
    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.

Tool result compaction

Compaction is off by default. To keep large tool results out of later model requests, opt in when constructing the agent:

from symfonic import Agent
from symfonic.agent.result_policy import ResultPolicySettings

agent = Agent(
    provider,
    tools=[lookup],
    result_policy=ResultPolicySettings(enabled=True, size_chars=8_000, keep_last_n=1),
)

A result above size_chars becomes a deterministic recall stub once it falls outside the newest keep_last_n tool results. The model can call the registered recall_tool_result tool to read it during the same run. The returned AgentResult.tool_calls and transcript retain full results; only the provider view is compacted. Recall handles and payloads belong to one invocation and cannot be used by a later run, even on the same Agent. Replayed history is retained in full. This option does not translate legacy FrameworkConfig compaction settings or persist results across runs. Only enabled, size_chars, and keep_last_n are supported by native Agent. Nondefault offload_enabled, large_offload_threshold_chars, turn_boundary_index, model_name, expected_conversation_depth, and expected_recall_probability are refused during construction.

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)

An agent with instructions sends those as its only system prompt, so system messages in history are not sent. history[0] is where first.messages carries the instructions that turn used, and the instructions compiled for a turn can differ from one turn to the next. So any system message at history[0] is dropped without a warning, including one you put there yourself, because the agent cannot tell it apart from a replayed copy. A system message at any later position emits symfonic.agent.warnings.SystemMessageDroppedWarning, which gives the count and positions but not the content. To keep that text, add it to instructions or send it as a user message.

When you want the framework to hold it for you — sessions, transcripts, checkpoints, restart recovery — that is a host-level runtime service, not a keyword on Agent. Conversation Persistence describes the SymfonicAgent compatibility configuration (which refuses construction from 11.3.0) and points to the native AgentHost composition in Moving to 11.0; use the latter for new code.

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.

async with Agent(provider) as agent:      # optional, not required
    await agent.run("hello")

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