Agents¶
Agent is Draive's lightweight abstraction for building reusable async workers that:
- accept typed multimodal input,
- stream visible output chunks and
ProcessingEvents, - preserve conversation thread and metadata through
ctx.scope(...), and - can be exposed to other agents as tools through
AgentsGroup.
The package is intentionally small. It builds on top of existing Draive primitives:
Stepfor custom execution pipelines,GenerativeModelfor model-backed execution,Toolboxfor tool handling,MultimodalContentfor input and output transport.
Runtime Model¶
The agents API is intentionally built as a thin layer over existing Draive runtime abstractions.
AgentIdentitydescribes the agent instance:uri,name,description,meta.AgentMessageis the fully prepared input payload:thread,created,content,meta.AgentThreadis the scoped runtime state propagated throughctx.scope(...): itsidentifiernames the conversation thread shared across nested agent calls, whileagent_urimarks the URI of the agent currently executing within it - agents bind a thread stamped with their own URI while handling a message.AgentExecutingis the executor protocol:AgentMessage -> AsyncGenerator[MultimodalContentPart | ProcessingEvent].
In other words, Agent itself is not a stateful conversation object. It is an immutable wrapper
that runs an executor inside a scoped agent context and streams output.
1. Build An Agent From Steps¶
Use Agent.steps(...) when you already have a Step pipeline and want to expose it as an
agent.
from collections.abc import AsyncGenerator
from draive import Agent, ProcessingEvent
from draive.multimodal import TextContent
from draive.steps import Step, StepState
async def execute(
state: StepState,
) -> AsyncGenerator[ProcessingEvent | TextContent | StepState]:
yield ProcessingEvent.of("progress", "Analyzing request...")
yield TextContent.of("Done")
yield state
worker: Agent = Agent.steps(
Step(execute),
agent="worker",
description="Handles a small processing task",
)
Call the agent inside a context scope and consume the stream.
from collections.abc import AsyncGenerator
from draive import ctx
from draive.multimodal import MultimodalContentPart
from draive.utils import ProcessingEvent
async with ctx.scope("agents.step"):
stream: AsyncGenerator[MultimodalContentPart | ProcessingEvent] = worker.call(
input="Please help"
)
async for chunk in stream:
print(chunk)
If you need lower-level control, build AgentMessage yourself and call respond(...) directly.
from collections.abc import AsyncGenerator
from draive import AgentMessage
from draive.multimodal import MultimodalContentPart
from draive.utils import ProcessingEvent
message: AgentMessage = AgentMessage.of("Please help")
async with ctx.scope("agents.respond"):
stream: AsyncGenerator[MultimodalContentPart | ProcessingEvent] = worker.respond(
message
)
async for chunk in stream:
print(chunk)
What steps(...) Does¶
- Seeds the initial pipeline context with the incoming agent message as a single
ModelInput, carrying over the message metadata. - Executes your steps as one
Step.sequence(...)and streams it. - Filters out
ModelReasoningChunk. - Filters out
ModelToolRequestandModelToolResponsechunks. - Streams only user-visible content and
ProcessingEvents.
This makes step-backed agents a good fit when you want deterministic orchestration and typed state updates, but a clean public output stream.
One important implication: if your step emits reasoning or tool protocol chunks, callers of the
agent will not see them - those chunks are dropped from the public stream. Agent.steps(...) is
intentionally a public-facing wrapper over a more verbose internal step stream.
2. Build A Generative Model-Backed Agent¶
Use Agent.generative(...) when the agent should directly call the configured
GenerativeModel.completion(...).
from collections.abc import AsyncGenerator
from draive import Agent, ctx, load_env, tool
from draive.multimodal import MultimodalContentPart
from draive.openai import OpenAI, OpenAIResponsesConfig
from draive.utils import ProcessingEvent
load_env()
@tool(description="Return current system status")
async def system_status() -> str:
return "All systems operational"
assistant: Agent = Agent.generative(
agent="support",
description="Answers product support questions",
instructions="You are a concise support assistant. Use tools when useful.",
tools=[system_status],
)
async with ctx.scope(
"agents.generative",
OpenAIResponsesConfig(model="gpt-5.5"),
disposables=(OpenAI(),),
):
stream: AsyncGenerator[MultimodalContentPart | ProcessingEvent] = assistant.call(
input="Check the current system status"
)
async for chunk in stream:
print(chunk)
How The Generative Loop Works¶
For each call, the agent:
- converts the incoming message into
ModelInput, - calls
GenerativeModel.completion(...), - collects any
ModelToolRequests, - executes them through
Toolbox.handle(...), - appends
ModelToolResponses back into model context, - repeats until the model produces a final answer.
If a tool uses handling="output", the tool can stream visible output directly and terminate the
loop early.
By default the model context is local to a single request; the agent does not persist prior turns
by itself. Pass memory= to keep context across turns (see below), use higher level conversation
APIs, or provide the required context explicitly.
Persist Context Across Turns With AgentMemory¶
Agent.generative(...) and Agent.from_skill(...) accept a memory argument controlling how
model context is prepared and recalled before each turn and persisted afterwards. The default is
AgentMemory.disabled, which scopes context to a single turn. Agent.steps(...) applies no memory
implicitly - compose the predefined memory steps (memory.recall_step(),
memory.remember_step()) into the pipeline where needed; Agent.generative(...) and
Agent.from_skill(...) invoke memory.prepare, memory.recall, and memory.remember directly
within their step bodies instead. There is no prepare step - a step cannot contribute the tools
it produces to the toolbox of a subsequent completion step - so preparation runs only in
Agent.generative(...) and Agent.from_skill(...).
from draive import Agent, AgentMemory
assistant: Agent = Agent.generative(
agent="support",
instructions="You are a concise support assistant.",
memory=AgentMemory.volatile(threads_limit=32),
)
With Agent.steps(...) the same memory is composed explicitly - recall before the work, remember
after it.
from draive import Agent, AgentMemory, ModelOutput
from draive.multimodal import MultimodalContent
from draive.steps import StepState, step
memory: AgentMemory = AgentMemory.volatile()
@step
async def reply(
state: StepState,
) -> StepState:
return state.appending_context(ModelOutput.of(MultimodalContent.of("reply")))
worker: Agent = Agent.steps(
memory.recall_step(),
reply,
memory.remember_step(),
agent="worker",
)
recall_step() replaces StepState.context with the recalled context, remember_step() persists
the current StepState.context and leaves state unchanged. Running recall twice within one
pipeline duplicates stored history; that is a composition error.
Memory operations receive the active AgentThread - the executing agent's URI together with the
conversation thread identifier - so one memory instance can serve multiple agents and multiple
concurrent conversation threads, as long as the implementation keys stored context by both (the
built-in ones do). Context is stored as the latest snapshot per agent and thread: whatever is
remembered after a turn becomes the next recalled context, exactly as provided, which allows steps
to compact, summarize, or replace the context freely.
AgentMemory.volatile(...)keeps snapshots in-process, optionally seeded with aninitialcontext for new agent-thread entries, and with optional LRU-like eviction viathreads_limit. An entry's usage is refreshed byprepareandremember, but not byrecall; intended for local development, tests, and single-process deployments.PostgresAgentMemory.instance()(fromdraive.postgres) persists immutable snapshots in PostgreSQL, keyed by executing agent URI and thread; runPostgresAgentMemory.migrate()once to create its schema.AgentMemory(recalling=..., remembering=...)wraps custom async callables for any other backend; each receives the executingAgentThread, and an optionalpreparing=callable runs before each recall to set up state based on the agent's instructions (it must be idempotent per agent and thread).
Three behavioral notes:
- Context is remembered only when a turn completes and its output stream is fully consumed; turns abandoned mid-stream or failing with an error are not persisted.
- Memory steps (
recall_step,remember_step) require anAgentThreadbound in the current context and raiseAgentExceptionotherwise. Agents bind one stamped with their own URI automatically; when running memory steps outside an agent, enter a scope with anAgentThreadinstance first. preparing=may return tools (Sequence[Tool] | None); see below.
Let Memory Contribute Tools¶
preparing= can return tools to extend the agent toolbox for the prepared turn, which lets memory
expose its own operations to the model - looking up older context, recording a fact - without the
agent knowing about the backend. Returned tools may close over whatever preparation resolved, so no
extra keying by agent and thread is needed inside them.
from collections.abc import Sequence
from typing import Any
from draive import AgentThread, ModelInstructions, Tool, tool
async def preparing(
thread: AgentThread,
instructions: ModelInstructions,
**extra: Any,
) -> Sequence[Tool]:
@tool(name="memory_search")
async def memory_search(query: str) -> str:
# the tool closes over the prepared turn scope
return await search_archive(
query,
agent_uri=thread.agent_uri,
thread=thread.identifier,
)
return (memory_search,)
# passed as AgentMemory(recalling=..., remembering=..., preparing=preparing)
- Prepared tools live for one turn and stay available across all of its tool-calling iterations.
- They are merged into the toolbox passed to the agent and replace provided tools using the same names, so prefix memory tool names to avoid shadowing agent tools by accident.
- They participate in the toolbox suggestion strategy; with
Toolbox.of(..., suggesting=True)the model may be required to call one of them. - Returning
Noneor an empty sequence contributes nothing and leaves the toolbox untouched. - Requests and responses of prepared tools are part of the context passed to
remember, so a stored turn may reference a tool that a later turn no longer offers. Keep tool names stable, or drop those context elements before remembering.
3. Preserve Thread And Metadata¶
Agent.call(...) automatically reuses the current AgentThread when present. That allows nested
agent calls to share a logical thread and metadata.
from collections.abc import AsyncGenerator
from uuid import uuid4
from draive import Agent, AgentIdentity, AgentMessage, ctx
from draive.agents import AgentThread
from draive.multimodal import MultimodalContentPart, TextContent
from draive.utils import ProcessingEvent
async def echo(
message: AgentMessage,
) -> AsyncGenerator[MultimodalContentPart | ProcessingEvent]:
context = ctx.state(AgentThread)
yield TextContent.of(
f"thread={context.identifier} source={context.meta.get_str('source')}"
)
agent: Agent = Agent(
identity=AgentIdentity.of(name="echo"),
executing=echo,
)
async with ctx.scope(
"agents.context",
AgentThread.of(identifier=uuid4(), agent_uri="agent://outer", meta={"source": "outer"}),
):
stream: AsyncGenerator[MultimodalContentPart | ProcessingEvent] = agent.call(
input="hello",
meta={"request": "nested"},
)
async for chunk in stream:
print(chunk)
In practice:
thread=oncall(...)overrides the current context thread,meta=is merged with the currentAgentThread.meta,respond(...)is useful when you already have a preparedAgentMessage.
This matters when agents call other agents. Nested calls inherit the active thread and metadata by default, which makes it easier to correlate delegation chains in observability and preserve request context without global state.
4. Expose Agents As Tools¶
AgentsGroup lets one agent delegate work to another using the same tool infrastructure used for
regular model tools.
from draive import Agent, AgentsGroup
from draive.multimodal import TextContent
from draive.steps import Step
researcher: Agent = Agent.steps(
Step.emitting(TextContent.of("Collected facts")),
agent="researcher",
description="Collects background information",
)
writer: Agent = Agent.generative(
agent="writer",
description="Writes the final response",
instructions="Delegate research first, then answer clearly.",
tools=[AgentsGroup.of(researcher).as_tool()],
)
The generated tool takes:
agent: selected agent name,task: plain-text request sent to that agent.
Agent names must be unique inside a group. AgentsGroup.of(...) raises ValueError if duplicate
names are provided.
You can also expose a single agent directly as a tool when you do not need a group registry.
5. Choose Response vs Output Handling¶
Both Agent.as_tool(...) and AgentsGroup.as_tool(...) support two delegation modes through the
handling= argument.
handling="response"¶
Use handling="response" when the delegated agent result should come back as a normal tool
response and be fed into the caller's model loop.
This behaves like any regular handling="response" tool.
Choose this mode when the caller agent should inspect the delegated result and continue its own reasoning loop.
handling="output"¶
Use handling="output" when the delegated agent should stream final output directly to the user.
This behaves like a handling="output" tool. Output chunks from the selected agent are streamed
immediately, and the tool still finishes by returning a ModelToolResponse with
handling="output".
Choose this mode when delegation should feel like a transfer of control rather than a background lookup.
6. Typical Multi-Agent Pattern¶
from draive import Agent, AgentsGroup
researcher: Agent = Agent.generative(
agent="researcher",
description="Finds facts and prepares structured findings",
instructions="Gather only the information needed for the task.",
)
reviewer: Agent = Agent.generative(
agent="reviewer",
description="Checks output for completeness and correctness",
instructions="Review the provided answer and suggest corrections.",
)
coordinator: Agent = Agent.generative(
agent="coordinator",
description="Routes tasks between specialized agents",
instructions=(
"Use `agent_request` to delegate work to the specialized agents. "
"Combine their results into one final answer."
),
tools=[AgentsGroup.of(researcher, reviewer).as_tool()],
)
This pattern works well when:
- one model-facing agent orchestrates the workflow,
- specialized agents have narrow responsibilities,
- you want delegation without introducing a separate orchestration framework.
Keep the specialized agents narrow. AgentsGroup is most useful when each delegated agent has a
clear role and the coordinator can select among them by name from the generated tool schema.
7. When To Use Each API¶
- Prefer
Agent.steps(...)for deterministic pipelines, typed artifacts, and explicit control. - Try
Agent.generative(...)for prompt-first, tool-aware model agents. - Use
Agent.from_skill(...)to turn a loadedSkillinto a model-backed agent with access to its bundled resources - see Skills. - Expose one concrete agent via
Agent.as_tool(...)when a model should call it directly. - Delegate using
AgentsGroup.as_tool(handling="response")when the caller should continue reasoning after delegation. - Delegate using
AgentsGroup.as_tool(handling="output")when the delegated agent should take over visible output.
Avoid using Agent.generative(...) as a substitute for persistent chat history. By design it loops
only within one request while tools are being resolved; use memory= when prior turns should be
recalled across requests.
8. Public Types¶
The public agents API exported from draive includes:
AgentAgentExceptionAgentExecutingAgentIdentityAgentMemoryAgentMessageAgentThreadAgentUnavailableAgentsGroupProcessingEvent
The memory protocols are exported from draive.agents only:
AgentMemoryPreparingAgentMemoryRecallingAgentMemoryRemembering