soev_agents.runtime
Build agents as graphs of steps, run them on threads, and read what the threads recorded.
An agent parses each input and builds the graph that runs the turn. Each step of the graph reads the thread's trace and returns the events to record. The trace is the only state, so a turn can stop after any step and resume in another process.
Agents
An agent is the contract between your code and the server that runs it.
Agent
protocolAgent[R, T: BaseModel]One agent: the whole contract between it and the server that runs it.
An agent says which class each fresh input's payload must parse into, strictly, and builds the graph for a
parsed turn. ReActAgent in soev_agents.toolkit is a ready agent. Write your own class with these attributes
and build for any other graph.
attributeinputtype[T]attributeelement_typesSequence[type[Element]]methodbuild(resources, turn, model) -> GraphBuild the graph that runs the whole turn that turn opened.
build hands resources to tools and steps, and never branches on them. It is synchronous, so it cannot
call a resource: what any model of the graph receives follows from the code, turn, model and the trace.
parammodelstr | NoneThe model that the turn's opening input names, or None when it names none.
Raises:
InputDeniedError: The turn is refused before anything is recorded.
Resources
type aliasResources = Callable[[Agent[Any, Any]], Awaitable[Any]]accepted
functionaccepted[T: BaseModel](input_type, input) -> tuple[Input, T]Parse an input's payload, strictly, and return the input with the elements that the payload holds.
The catalog calls it for each fresh input, so that the thread records the elements of the input. Code that builds graphs without the catalog calls it too.
paraminput_typetype[T]The agent's input class.
paraminputInputThe fresh input.
Returns
tuple[Input, T]A tuple (recorded, turn): the input to record, with its elements, and the parsed payload.
Raises:
InputDeniedError: The payload does not parse intoinput_type.
Graphs
A graph holds the nodes that run a turn, and the edges and routers between them.
Graph
classGraph()The nodes that run a turn, and the edges between them.
A fresh input starts at the entry point. After each node, the turn follows the edge from that node. A node
without an edge ends the turn, as does a Request, which waits for a response. A step that raises stops the run
with a Failure. If the failure is transient, the node runs again when the thread runs again.
Build a graph once for each turn, in Agent.build. The runner does not change it.
Example
from soev_agents.domain import ModelOutput, Trace
from soev_agents.runtime import Graph, LlmStep, ToolStep
# builder is a ContextBuilder, model is a ChatModel, and tools are the Tool objects of the turn.
def after_model(view: Trace) -> str | None:
output = view.latest(ModelOutput)
return "tools" if output is not None and output.calls_tools else None
graph = Graph()
graph.add_node("model", LlmStep(builder=builder, model=model))
graph.add_node("tools", ToolStep(tools={tool.name: tool for tool in tools}))
graph.set_entry_point("model")
graph.add_conditional_edge("model", after_model)
graph.add_edge("tools", "model")Make an empty graph, without nodes, entry point or budget.
attributeentry_pointNodeNamemethodadd_node(name, step) -> Nonemethodadd_edge(source, target) -> Nonemethodadd_conditional_edge(source, router) -> Nonemethodadd_join(name) -> NoneMake a node the join where parallel branches meet.
A branch ends when it reaches a join. When all branches of a fan-out ended, the join runs once, in the
stream that fanned out. If branches reach different joins, the runner records a Failure. A branch that
ends without a join does not stop the join. If no branch reaches a join, the turn ends after the branches.
Raises:
ValueError: The node is unknown.
methodis_join(name) -> boolmethodset_entry_point(name) -> Nonemethodset_budget(budget) -> Nonemethodnode(name) -> Nodemethodhas_node(name) -> boolmethodedge(source) -> Edge | NoneNode
dataclassNode(name, step)Edge
dataclassEdge(source, targets=(), router=None)Router
type aliasRouter = Callable[[Trace], NodeName | None]A function that picks the next node from the trace.
The runner calls it with the view of the stream after the node of the edge recorded its events. It returns the
name of the next node, or None to end the turn. A router is a pure function of the trace: if it raises or names
a node that the graph does not have, the runner records a Failure and does not try again.
Budget
dataclassBudget(cap, resume_at, tolerance, resets_on_answer=False)A limit on the model calls that call tools in one turn of a stream.
When a model output that calls tools brings the count past cap, the runner records BudgetExceeded instead of
the next node. The turn then resumes at resume_at, at most tolerance times. After the last of these, the
next model call gets no tools, so the model can only write its final output. One more trip ends the turn.
The model call loses its tools only when no event other than a Compaction comes between the trip and the call.
paramresume_atNodeNameparamresets_on_answerbool= FalseIf true, the count starts again at each response to a Request, so each response
brings a fresh cap. If false, the count covers the whole turn.
methodwithdraws_tools(view) -> boolReturn whether the next model call on view gets no tools.
A step that offers tools asks this before each model call. It is true after the last trip that the turn
resumes from: the latest event, compactions aside, is a BudgetExceeded, and the trips reached
tolerance.
Returns
boolTrue if the model call must get no tools, so that the model writes its final output.
Delegation
protocolDelegationAnother graph that a node runs as subagents, one for each task that hand_offs finds in the parent's view.
A node whose step is a Delegation starts one subagent for each task, each in a stream of its own. When all
subagents ended, the node records what hand_back returns for each of them in the parent stream, and the
parent continues on the node's edge.
methodgraph_for(task, /) -> Graphmethodhand_offs(parent, /) -> Sequence[Event]Return the tasks that the parent's latest model call asked for, in call order.
methodhand_back(child, /) -> Sequence[Event]Steps
A step reads the trace and returns the events to record.
Step
type aliasStep = Callable[[Trace], Awaitable[Sequence[Event]]]An async function that reads the trace and returns the events that its node records.
The runner calls it with the view of the node's stream, and records what it returns with the node's name as
origin. It must return at least one event, no Input, and a Request only as the last event. If it raises, the
runner records a Failure and the run stops. A TransientError makes that failure transient, so the node runs
again when the thread runs again.
LlmStep
dataclassLlmStep(builder, model)A step that makes one model call with the context that builder builds, and records the output.
The model's output records:
- the model that replied: the model of the call, unless the adapter names another, as a fallback does
- the characters that the model was sent, and the hash of the
ChatRequest - its tool calls, with the numbers that they name translated to real ids and call ids
When the context has a citation strategy, the step removes the markers from the output's text and resolves each number to the source that it named. Streamed text reaches a listener without markers, and each citation follows its text.
parambuilderContextBuilderWhat builds the context of the call from the trace.
parammodelChatModelThe chat model adapter that replies.
method__call__async (trace) -> Sequence[ModelOutput]Build the context from trace, call the model, and return its output as the one event.
paramtraceTracemethodanswerasync (context) -> ModelOutputCall the model with a context that is already built, and return its output as the step records it.
Use it in a step of your own that changes the context before the call.
paramcontextModelContextThe context of the call.
Returns
ModelOutputThe model's output, ready to record.
Raises:
ValueError: The model's stream ended without an output.
ToolStep
dataclassToolStep(tools, repeats=False)A step that runs the tool calls of the latest ModelOutput and records one result for each call.
Each call's result is what its tool returns. When the tool is unknown or the arguments do not parse, the result
is a ToolOutput with an error instead, and the tool does not run.
Other results, for which the tool does not run:
- A call that is identical to an earlier call of the turn, by tool name and arguments, gets an error that says
so, when the earlier call succeeded or is in the same model output.
repeatslets each tool run it again, and a tool's ownrepeatsreplaces the step's. - A call to a tool that its
turn_limitsays is spent gets the limit's error.
The calls run in parallel. A call to a tool with a limit waits for the calls before it in the same model output
and sees their results, so the results are the same in any order of completion. An argument of type Recorded
gets the elements of its type that the turn recorded. A tool that raises TransientError, InvalidQueryError or
ResourceUnavailableError gets that error as its result. Any other exception stops the step, and the run
records a Failure.
paramtoolsMapping[str, Tool[Any]]The tools that the model can call, by name.
paramrepeatsbool= FalseWhether each tool runs an identical call again. False gives it the repeated-call error as its result.
Raises:
MutableToolError: When the step is made: a tool, or an object that tools share, could keep state between calls.ToolDefinitionError: When the step is made: a tool'sruntakes more than the call's arguments, or a tool has parameters that still need a value (unbound).
method__call__async (trace) -> Sequence[Event]Return the result of each tool call of the latest model output, in call order.
paramtraceTraceRaises:
RequestStep
dataclassRequestStep(kind, actor, expects=None)A step that stops the turn to wait for a response from outside, such as an approval.
It records one Request. The thread then waits. The next input is the response, and the turn continues on the
edge of this node, not at the entry point.
paramkindstrWhat kind of request this is, in the agent's own words: "approval", "clarification".
paramactorActorWho is responsible for the request, for example "system" for a fixed approval gate.
paramexpectsdict[str, JsonValue] | None= NoneThe JSON Schema that the response must satisfy, or None to accept any JSON.
method__call__async (trace) -> Sequence[Event]Return the one Request that this step records.
paramtraceTraceModel context
These names build what a model call receives, and say how the model cites.
ModelContext
dataclassModelContext(request, model, citations, numbering)Everything that one model call receives, and what its output is read with.
A ContextBuilder makes it from the trace, and LlmStep sends it.
paramrequestChatRequestThe ChatRequest that the model receives: the messages and the tools.
parammodelstrThe name of the model that the call goes to. The model's output records it.
paramcitationsCitationStrategy | NoneContextBuilder
type aliasContextBuilder = Callable[[Trace], ModelContext]CitationStrategy
dataclassCitationStrategy(pattern, opens, write, write_invalid=…)How one model is told to cite and how its markers are read back.
soev_agents.toolkit has ready strategies, such as BRACKETS for markers like [1].
parampatternre.Pattern[str]The pattern of one marker. Each integer in a match is a cited number. A match must start with one
of the characters in opens, and it must not be able to grow once complete.
paramopensstrparamwriteCallable[[Sequence[int]], str]Effects
An effect step changes the world exactly once. These names define the step, perform its effect, and decide about the effect after a crash or a cancel.
EffectStep
protocolEffectStepA node that changes the world exactly once: it records its intent before the effect.
When the node runs, intend returns the intent and the runner records it. Then effector performs it, and the
runner records the result, linked to the intent. If the process stops between the two, the next run finds the
intent without a result and calls recovery instead of a second effect. If the caller cancels the turn during
the effect, cancellation decides. A response to a Request of the step returns the turn to the same node.
methodintend(trace) -> EffectIntentEffector
protocolEffectorThe adapter that changes the world for an EffectStep: it sends the mail, or makes the refund.
methodperformasync (intent) -> EffectResultDo the effect that intent declares.
The runner recorded intent before this call, so a crash during the call leaves an intent without a
result, which the step's recovery resolves.
paramintentEffectIntentThe recorded intent, with its kind and args.
Returns
EffectResultThe result: what the world replied. Put a definitive refusal of the world in error. The graph can
route on it.
Raises:
EffectRecovery
type aliasEffectRecovery = Callable[[Trace, EffectIntent, Effector], Awaitable[Sequence[Event]]]An async function that resolves an intent without a result, after a crash during its effect.
The runner calls it when the effect step's node runs again and the stream's last intent has no result. It gets the
view, the intent and the effector, and it can ask the world whether the effect happened. It must return exactly
one event: the EffectResult of the intent, or a Request that asks a person. A response to that request brings
the node back to its recovery, with the response in the view.
EffectCancellation
type aliasEffectCancellation = Callable[[Trace, EffectIntent, Effector, asyncio.Task[EffectResult]], Awaitable[Sequence[Event]]]An async function that decides about an effect in progress when the caller cancels the turn.
It gets the view, the intent, the effector and the asyncio task that performs the effect. It can await that asyncio
task, or ask the world. It returns the one EffectResult of the intent, one Request that asks a person, or nothing.
Nothing leaves the intent without a result for the step's recovery. The runner cancels the asyncio task when the
function returns. Use abandon to give the effect up at once.
abandon
functionabandonasync (trace, intent, effector, performing) -> Sequence[Event]Give up the effect in progress: the EffectCancellation that awaits nothing and records nothing.
intent stays without a result, so the effect step's recovery resolves it when the thread runs again.
paramtraceTraceThe view of the effect step's stream.
Returns
Sequence[Event]No events.
Tools
A tool is what a model can call. The tool decorator makes a tool of an async function.
tool
functiontool(*, name, description, repeats=None, limit=None) -> ToolDecoratorReturn the decorator that makes an async function the tool that the model calls as name.
The parameters before * are what the model writes, and the tool's argument schema comes from their types and
defaults. A parameter after * has the type Recorded, or keeps its default, or gets its value later from
FunctionTool.bind. The model reads description, never the function's docstring.
Example
from soev_agents.domain import ToolOutput
from soev_agents.runtime import tool
# SearchIndex is your own search client class, and resources are the resources that `Agent.build` gets.
@tool(name="search", description="Search the documents. Use short keyword queries.")
async def search(query: str, *, index: SearchIndex) -> ToolOutput:
...
agent_tools = [search.bind(index=resources.index)]paramnamestrThe name that the model calls the tool by.
paramdescriptionstrWhat the model reads about when and how to use the tool.
paramrepeatsbool | None= NoneWhether a call that is identical to an earlier successful call of the turn runs again. None
follows the repeats of the ToolStep.
Returns
ToolDecoratorThe decorator. Apply it to an async function that returns a ToolOutputBase.
ToolDecorator
dataclassToolDecorator(name, description, repeats=None, limit=None)The decorator that tool(...) returns. Applied to a function, it makes the FunctionTool.
paramnamestrThe name that the model calls the tool by.
paramdescriptionstrWhat the model reads about the tool.
paramrepeatsbool | None= NoneWhether an identical call runs again, or None to follow the step's repeats.
method__call__[**P, R: ToolOutputBase](function) -> FunctionTool[P, R]Make the tool of function.
paramfunctionCallable[P, Coroutine[Any, Any, R]]Raises:
ToolDefinitionError: The function does not return aToolOutputBase, or has a parameter that the model cannot write.ValueError: AnElementIdaccepts no element class, or sits deeper than a field, a list item or an optional.
FunctionTool
dataclassFunctionTool[**P, R: ToolOutputBase](name, description, arguments, function, repeats=None, turn_limit=None, unbound=())A tool that the tool decorator made of an async function.
run calls the function with the call's arguments and the Recorded values. Calling the tool itself calls the
function, with its own signature and result type, so a test can call it directly.
paramnamestrThe name that the model calls the tool by.
paramdescriptionstrWhat the model reads about the tool.
paramargumentstype[BaseModel]The pydantic model of the parameters that the model writes, made from the function's signature.
paramfunctionCallable[P, Coroutine[Any, Any, R]]The function, with the bound parameters already in it.
paramrepeatsbool | None= NoneWhether an identical call runs again, or None to follow the step's repeats.
paramunboundtuple[str, ...]= ()The keyword-only parameters that still need a value from bind. ToolStep refuses the tool until
each of them has one.
method__call__(*args, **kwargs) -> Coroutine[Any, Any, R]Call the function directly, for example in a test.
paramargsP.argsparamkwargsP.kwargsmethodrunasync (arguments) -> RRun one call: call the function with the parsed arguments as keyword arguments.
paramargumentsBaseModelmethodbind(**values) -> FunctionTool[..., R]Return this tool with values bound to parameters, such as the adapters or the values of a turn.
The model never sees a bound parameter. Bind the keyword-only parameters after * that have no default, in
Agent.build, before a ToolStep gets the tool.
paramvaluesAnyThe value of each parameter, by name.
Raises:
Tool
protocolTool[A: BaseModel]What the model can call: a name, a description, the arguments that the model writes, and run.
run gets only the call's arguments, parsed into arguments, with each element id already real. What the turn
recorded so far reaches a tool as a Recorded argument, never as the trace. A tool is a frozen dataclass whose
fields hold adapters, code constants and values of the turn's input. ToolStep refuses any other tool, so that
no tool keeps state between calls. The tool decorator makes a tool of an async function.
A tool can also have these attributes, which ToolStep reads:
turn_limit: aLimitfor each turn, for exampleat_most(3, calls="search", error="You searched three times already.").repeats: whether a call that is identical to an earlier successful call of the turn runs again. It replaces the step'srepeatsfor this tool.unbound: the parameters that still need a value.ToolSteprefuses a tool that names any, so a missing value fails when the graph is built, not when the model calls the tool.
attributedescriptionstrWhat the model reads about when and how to use the tool.
attributeargumentstype[A]The pydantic model of the arguments. The model is shown its JSON schema (see tool_def).
methodrunasync (arguments) -> ToolOutputBaseRun one call of the tool.
paramargumentsAThe call's arguments, parsed, with element ids already replaced by real ids.
Returns
ToolOutputBaseThe one result of the call. Put every element and text of the call in it.
Raises:
TransientError: A resource failed for a moment. The model reads the error as the result.InvalidQueryError: The resource refused the query. The model reads the error as the result.ResourceUnavailableError: The resource is not available. The model reads the error as the result.
tool_def
functiontool_def(tool) -> ToolDefReturn what the model is shown of tool, with the element id fields that its calls record.
The schema of the arguments has no titles and no class docstrings: the model reads the property names and the field descriptions.
paramtoolTool[Any]The tool to show.
Returns
ToolDefThe definition that a ChatRequest offers the model.
Raises:
ValueError: AnElementIdintool.argumentssits where no call could be translated.
ElementId
classElementId(*accepts)A mark on a tool argument that holds the id of an element of one of accepts, subclasses included.
Use it as Annotated[str, ElementId(Document)], alone, in a list or optional. The model writes the number that
it was shown, and LlmStep replaces the number with the real id when it records the call. The mark itself
changes no validation, so code can build arguments from real ids. The JSON schema shows a plain string.
A field with an element type instead, such as Annotated[MailReference, ElementId()], gets the element
first recorded under that id. ElementId() without classes is only for a field with an element type. It accepts
the element classes of that type.
Make the mark.
ResultHandle
classResultHandleA mark on a tool argument that holds the handle of a tool result, such as "r12".
Use it as Annotated[str, ResultHandle()], alone, in a list or optional. The model writes the handle that it was
shown, and LlmStep replaces it with the call id of the tool call that has that result, as it does for
ElementId.
Recorded
type aliasRecorded = …The type of a tool parameter that gets the elements of one type that the turn recorded so far.
Write Recorded[Document] as the type of a parameter after *, or of a field of the tool's arguments. Before each
call, ToolStep fills it with every Document of the turn, the input's included, in recorded order. The model
never sees the parameter, and a value that the model writes for it is replaced. This is how a tool reads what the
turn built up without the trace.
Limit
type aliasLimit = Callable[[Trace], str | None]at_most
functionat_most(n, *, calls=None, elements=None, error) -> AtMostReturn a limit that makes error a call's result, and does not run the tool, once the turn reached n.
The limit counts the calls with a result of the tool named calls, or the distinct elements of the type elements.
An element recorded again counts once. Give the limit to a tool as limit in tool, or as its turn_limit.
paramnintThe count at which the tool is spent. At least 1.
paramcallsstr | Noneparamelementstype[Element] | NoneparamerrorstrWhat the model reads instead of a result when the tool is spent. Tell the model what to do next.
Returns
AtMostThe limit.
Raises:
ValueError:nis below 1, or not exactly one ofcallsandelementsis given.
AtMost
dataclassAtMost(n, error, calls=None, elements=None)A limit of one tool for each turn, which at_most makes.
Exactly one of calls and elements is set.
paramnintThe count at which the tool is spent.
paramerrorstrWhat the model reads when the tool is spent.
paramcallsstr | None= NoneThe name of the tool whose calls with a result without an error count, or None.
method__call__(view) -> str | NoneReturn error once the turn in view reached n, and None before.
paramviewTraceToolDefinitionError
errorToolDefinitionError(tool, parameter, problem)MutableToolError
errorMutableToolError(tool, fields, holds)ToolResultError
errorToolResultError(tool, returned)UnrecordedWholeError
errorUnrecordedWholeErrorThreads
A thread is one conversation in a store. A catalog opens threads and runs their agents.
AgentCatalog
classAgentCatalog(store, agents, runs=None, *, version, resources=…)A catalog of agents, by name, over one store: the object that opens, runs and reads threads.
open makes a thread under an agent's name and records Opened with the first input. thread reads that
Opened back and returns the Thread, with a graph that its agent built for the turn that the caller is about
to run, or without a graph for a caller that only reads.
A fresh input is parsed by its agent's input class and recorded with the elements that it holds, so a
thread's numbering never depends on the agent's code.
Example
from soev_agents.adapters import InMemoryTraceStore
from soev_agents.domain import Input
from soev_agents.runtime import AgentCatalog
from soev_agents.toolkit import ReActAgent, ReActConfig
# chat_model: a ChatModel adapter, such as an OpenAIChatModel.
config = ReActConfig(model=chat_model, prompt="Answer briefly.")
catalog = AgentCatalog(InMemoryTraceStore(), {"assistant": ReActAgent(config)}, version="1.4.0")
thread, _ = await catalog.open("assistant", Input(payload="Hello", model="my-model"))
trace = await thread.resume()Make a catalog.
paramstoreTraceStoreThe store of the threads.
paramrunsRuns | None= NoneThe registry of this process's runs. Share one between catalogs of one process. None makes a
new one.
paramversionstrmethodopenasync (name, input, *, thread_id=None) -> tuple[Thread, Sequence[LogEntry[Event]]]Open a thread for the agent name and record its first input.
Nothing runs yet. Call Thread.resume or Thread.stream on the returned thread to run the first turn.
paramthread_idstr | None= NoneThe id of the new thread, or None to make a fresh id.
Returns
tuple[Thread, Sequence[LogEntry[Event]]]A tuple (thread, entries): the thread, with a graph for the first turn, and the log entries that the
open appended.
Raises:
methodthreadasync (thread_id, *, agent=None, input=None, resuming=False) -> ThreadReturn the Thread of the agent that its Opened names, over a graph built for the turn about to run.
paramthread_idstrThe thread to return.
paraminputInput | None= NoneThe fresh input that the caller submits next. The graph is built for it.
Returns
ThreadThe thread.
Raises:
UnknownThreadError: No thread has events underthread_id.UnknownAgentError: The thread names no agent, or an agent that the catalog does not have.AgentMismatchError: The thread runs another agent thanagent.InputDeniedError:input, or the input that opened the turn, does not parse into the agent's class.ValueError: Bothinputandresumingare given.
Thread
classThread(thread_id, graph, store, runs, agent=None, accept=None, event_types=CORE_EVENT_TYPES, version=None)One thread of a store: it reads the thread, takes inputs, runs its graph, cancels and forks.
Get a thread from AgentCatalog.open or AgentCatalog.thread. A thread from the catalog takes no fresh input
other than the input that its graph was built for. To start the next turn, get a new thread with
AgentCatalog.thread(thread_id, input=...). A thread from the catalog without a graph reads, takes responses to
requests, cancels and forks, but cannot run.
The usual calls:
sendrecords an input and runs the turn to its end.submitonly records the input.streamruns the turn and yields each event as it is recorded.resumeruns it to its end.statusreads the state of the current turn, andtracereads all events.cancelends the current turn,forkcopies the thread up to a position, anddeleteremoves it.
Make a handle on a thread. Most callers get a thread from an AgentCatalog instead.
paramthread_idstrThe id of the thread in the store.
paramrunsRunsThe registry of this process's runs.
paramacceptCallable[[Input], Input] | None= NoneTurns a fresh input into the input to record, or refuses it with InputDeniedError. None
records a fresh input as given.
paramevent_typesEventTypesattributeidThe id of the thread in the store.
methodopenasync (agent, input) -> Sequence[LogEntry[Event]]Record Opened for agent and the first input, in one append. Nothing runs.
AgentCatalog.open calls it. Call it yourself only for a thread made without a catalog.
Returns
Sequence[LogEntry[Event]]The log entries that the open appended.
Raises:
methodsubmitasync (input, *, answering=None) -> LogEntry[Event]Route, validate and append one input; nothing runs.
paraminputInputReturns
LogEntry[Event]The log entry of the recorded input.
Raises:
ThreadActiveError: A fresh input while the turn has not ended, a request waits, or an effect intent has no result. A response to an open request is accepted.InputDeniedError: The named request is not waiting, the payload does not satisfy itsexpectsschema, the response names another model than the turn, or the thread was built for another fresh input.
methodsendasync (input, *, answering=None) -> TraceRecord one input and run the turn to its end: submit, then resume.
paraminputInputThe input, as in submit.
paramansweringRequest | None= NoneThe waiting request that input responds to, or None for a fresh input.
Raises:
methodresumeasync () -> Tracemethodstreamasync () -> AsyncGenerator[LogEntry[Event], None]Run the thread from its tail and yield each event as the run records it.
If this process already runs the thread, the call follows that run instead of a second one. A reader that
stops does not stop the run: cancel does. Once Runs.close has begun, nothing new starts, and the thread
keeps its work for the next process.
Yields:
LogEntry[Event]: Each log entry that the run appends, in log order.
Raises:
methodwatchasync (*, after=0, follow=True) -> AsyncGenerator[LogEntry[Event], None]methodfollowasync (*, after=0) -> AsyncGenerator[LogEntry[Event], None]Yield the thread's events past after while a process is running it, and stop once none is.
Nothing runs. The process that holds the lease writes the turn, and follow reads the events that it
writes. follow ends when the lease is free: the turn ended, or the run stopped and the thread is
interrupted. To run the thread, use stream. To read until the thread is deleted, use watch.
paramafterint= 0The log position to read after. 0 reads from the start.
Yields:
LogEntry[Event]: Each log entry pastafter, in log order.
methodstatusasync () -> Statusmethodrunningasync () -> boolmethodheld_elsewhereasync () -> boolmethodtraceasync () -> Tracemethodcancelasync (*, input=None) -> Statusmethodforkasync (*, at) -> ThreadCopy the thread's events up to a log position into a new thread.
The copies keep the version that wrote them. The new thread has the same agent and graph, and a fresh id.
Use a fork to try another input or other code from a point of a recorded thread. To run another input on
the fork, get it with AgentCatalog.thread(fork.id, input=...), which parses the input and builds its graph.
Returns
ThreadThe new thread.
Raises:
PositionOutOfRangeError:atis outside the thread.
methoddeleteasync () -> NoneRuns
classRuns(*, lease_ttl=30.0)The running asyncio tasks of this process, one per thread.
A caller joins the running asyncio task instead of a second one, so one process never runs a
thread twice. Across processes each run holds the thread's lease in the store, under this
registry's owner, for lease_ttl seconds at a time.
Make one Runs for each process and give it to the AgentCatalog. Call close when the process
stops.
Make an empty registry.
attributeownerThe owner name of this process's leases, a fresh id.
methodstart(thread_id, run) -> asyncio.Task[object] | NoneReturn the thread's running asyncio task, and start one when none runs.
paramthread_idstrThe thread to run.
paramrunCallable[[], Coroutine[Any, Any, object]]Makes the coroutine of the run. It is called only when an asyncio task starts, so no coroutine is made and dropped.
Returns
asyncio.Task[object] | NoneThe running asyncio task, or None when no asyncio task runs after close began, because no new one
starts.
methodstopasync (thread_id, *, wait=True) -> NoneCancel the thread's running asyncio task.
paramwaitbool= TrueIf true, wait until the asyncio task stops. An effect step's cancellation can make this slow.
If false, ask the asyncio task to stop and return at once.
methodcloseasync (timeout) -> NoneWait up to timeout seconds for the running asyncio tasks, then cancel the rest.
It then waits for each cancelled asyncio task to stop. An effect step's cancellation can make this take
longer than timeout. After this call, start starts no asyncio task. A thread whose run was cancelled
keeps its work for the next process.
paramtimeoutfloatHow many seconds to wait for the running asyncio tasks.
Status
dataclassStatus(state, pending, position)The state of a thread's current turn, read from its trace and its lease.
ThreadState
type aliasThreadState = Literal['running', 'waiting', 'failed', 'interrupted', 'cancelled', 'finished']The state of a thread's current turn, read from its trace and its lease alone.
"running": a process holds the thread's lease."waiting": a request waits for a response."failed": a step failed, and the run stopped on theFailure."interrupted": the run stopped without an end of the turn, for example in a crash. Resume the thread."cancelled": the caller cancelled the turn."finished": the turn ended, or the thread has no turn yet.
InputDeniedError
errorInputDeniedErrorAn input was refused, and nothing was recorded.
The payload does not fit the agent's input class, a response does not fit the schema of its request, the
request that it responds to is not waiting, or Agent.build refused the turn. The default HTTP hooks respond
with 422.
ThreadActiveError
errorThreadActiveErrorThreadExistsError
errorThreadExistsErrorUnknownAgentError
errorUnknownAgentErrorUnknownThreadError
errorUnknownThreadErrorNothingToCancelError
errorNothingToCancelErrorPositionOutOfRangeError
errorPositionOutOfRangeErrorAgentMismatchError
errorAgentMismatchErrorReading a turn
These names read facts of a thread's turns and elements, without agent code.
cancelled
functioncancelled(trace) -> boolopening_input
functionopening_inputasync (store, thread_id, types) -> Input | NoneReturn the root input that opened the current turn, None for a thread without one.
It reads the store backwards only as far as that input, so a server can build the turn's graph before it loads the whole thread.
paramstoreTraceStoreThe store of the thread.
paramthread_idstrThe thread.
paramtypesEventTypesThe types to read the events with, for example from event_types_of.
Returns
Input | NoneThe input, or None.
turn_starts
functionturn_starts(events) -> list[int]open_requests
functionopen_requests(trace) -> tuple[Request, ...]Return the current turn's requests that wait for a response, in all streams of the turn.
After a cancel, only the requests that ask for the outcome of an effect with an unknown result still wait.
Returns
tuple[Request, ...]The waiting requests. Respond to each with Thread.submit(..., answering=request).
numbering
functionnumbering(trace) -> NumberingNumber every element in trace by first appearance, a ref before the element naming it.
A real id keeps its number wherever it appears again, and the numbers do not change as the thread grows.
Each tool result with a call id gets a handle, r1 first, in recorded order.
Returns
NumberingThe numbering that the model sees.
Numbering
dataclassNumbering(numbers, elements, calls=dict(), handles=dict())Each real id's number, and the element that first showed it.
The model never sees a real id: it sees the number, and it cites and passes that number to tools. numbering
makes a numbering from the trace.
paramnumbersdict[str, int]The number of each real id.
paramelementsdict[int, Element]paramcallsdict[str, str]= dict()The call id that each tool result's handle (r1, r2, ...) stands for.
paramhandlesdict[str, str]= dict()The handle of each call id's tool result.
methodnumber(real_id) -> int | NoneReturn the number of real_id, or None for an id that the trace never showed.
paramreal_idstrmethodelement(number) -> Element | Nonemethodcall(result) -> str | NoneReturn the call id whose tool result has the handle result, or None.
paramresultstrmethodhandle(call_id) -> str | NoneReturn the handle of the tool result of call_id, or None when the call has no result.
paramcall_idstrmethodsource(number) -> str | NoneReturn the real id of the Source shown under number, or None when no source is.
paramnumberintmethodby_id(real_id) -> Element | NoneStored events
A store keeps each event as JSON with a type tag. These names write events as a StoredEvent and read them back.
EventTypes
classEventTypes(*types, elements=…)The event and element classes stored events are read back into, each by its type tag.
CORE_EVENT_TYPES holds the types of the framework. Add an agent's own types with including, or use
event_types_of.
Make the set of types.
Raises:
methodincluding(*types) -> EventTypesmethoddecode(stored) -> Eventmethodencode_readable(event) -> StoredEventmethodentry(entry) -> LogEntry[Event]CORE_EVENT_TYPES
constantCORE_EVENT_TYPES: Final[EventTypes]event_types_of
functionevent_types_of(agents) -> EventTypesReturn the core event types plus every class that agents declare their threads record.
Use it to read stored threads of these agents, for example in replay or a store migration.
paramagentsIterable[_Declaring]The agents, or any objects with element_types and event_types.
Returns
EventTypesThe types to read the threads with.
Raises:
encode
functionencode(event) -> StoredEventReturn event as a StoredEvent: its type tag, its bookkeeping fields and its payload as JSON.
A store keeps each event as a StoredEvent. EventTypes.decode reads it back. Use it to write a trace directly
into a store, for example in a test or an import.
parameventEventThe event to store.
Returns
StoredEventThe stored event, with the same seq and ts, which are None until the store appends it.
Raises:
UnknownEventTypeError
errorUnknownEventTypeErrorUnreadableEventError
errorUnreadableEventErrorAn event would not read back as it was recorded.
Its type, or the type of an element that it holds, is not among the types that the thread is read with, or one
of its fields does not survive JSON. A thread raises it before it records the event, and the runner records a
Failure instead of an event from a step.
Replay
Replay rebuilds each model call of a recorded thread with the current code.
replay
functionreplayasync (events, graph_for) -> list[ModelCall]Rebuild every model call in events, one thread's events in log order.
graph_for builds the agent's graph for the turn opened by an input, as the server did when the turn ran; it
is called once per turn, and its models are asked to prepare but never to reply. Nothing is executed, so
the result is what today's code renders, which differs from what was sent if the agent changed since.
Use it to see what each model call of a recorded thread received, or what a change of a prompt or a
context policy does to recorded threads. Build the graphs with refusing adapters, so that a build that
calls a resource fails at once.
Example
from soev_agents.domain import Input
from soev_agents.runtime import Graph, accepted, refusing, replay
# agent is the Agent of the thread, and thread is a Thread with recorded turns.
def graph_for(input: Input) -> Graph:
return agent.build(refusing("resources"), accepted(agent.input, input)[1], input.model)
events = [entry.event async for entry in thread.watch(follow=False)]
calls = await replay(events, graph_for)ModelCall
dataclassModelCall(answer, started, request=None, prepared=None, unreplayable=None, view=None, builder=None)One model call of a thread, as replay rebuilt it.
paramanswerModelOutput | Compaction | FailureWhat the call recorded: a ModelOutput, the Compaction of a call that wrote a summary, or the
Failure of a call that the model refused or never answered.
paramstarteddatetime | NoneThe timestamp of the event before answer, so answer.ts - started is about the call's duration.
paramrequestChatRequest | None= NoneThe ChatRequest that today's code builds for the call, or None when the call cannot be rebuilt.
parampreparedPreparedRequest | Noneparamunreplayablestr | None= NoneWhy the call cannot be rebuilt, or None when it was rebuilt.
parambuilderContextBuilder | None= NoneThe context builder of the call's step, so a reader can ask it more about the call, or None.
attributesame_requestbool | NoneWhether request has the hash of the ChatRequest that the call recorded.
False means that today's code builds another ChatRequest from the same trace. None means that either
hash is missing, which is always so for a Failure.
refusing
functionrefusing(name='this resource') -> AnyReturn an adapter that refuses every call with ResourceUnavailableError, whatever the method.
Give it to build in place of each resource when replay builds a graph, so a build that calls an adapter
fails at once.
paramnamestr= 'this resource'The name of the resource, which the error names.
Returns
AnyThe refusing adapter.