soev_agents.ports
Connect a model and a store: the protocols at which the framework reaches outside the process.
A port is a protocol that the framework calls and that an adapter implements for one technology. The two ports are
ChatModel, which calls a model, and TraceStore, which keeps the traces of threads. Implement one of them to
connect a model or a store that the built-in adapters do not cover.
Models
A chat model takes a ChatRequest of messages and offered tools, and gives the model's output.
ChatModel
protocolChatModelAn adapter that calls a model: it sends a ChatRequest and returns the model's output.
A model step calls it with the request that it built from the trace. The adapter maps the whole response of its
API to one ModelOutput, which the step records. Translate the transient errors of the API to TransientError
in the adapter, and let every other error propagate.
Example
from collections.abc import AsyncIterator
from soev_agents.domain import ModelOutput
from soev_agents.ports import ChatRequest, Fragment, UserMessage
class EchoModel:
def resolve(self, model: str | None) -> str:
return model or "echo"
async def complete(self, request: ChatRequest, *, model: str | None = None) -> ModelOutput:
last = request.messages[-1]
text = last.content if isinstance(last, UserMessage) and isinstance(last.content, str) else ""
return ModelOutput(content=text, model=self.resolve(model), finish_reason="stop")
async def stream_complete(
self, request: ChatRequest, *, model: str | None = None
) -> AsyncIterator[Fragment | ModelOutput]:
output = await self.complete(request, model=model)
yield output.content
yield outputmethodresolve(model) -> strReturn the name of the model that a call with model goes to.
parammodelstr | NoneThe model that the call names. None names the model that the adapter was built with.
Returns
strThe name of the model.
methodcompleteasync (request, *, model=None) -> ModelOutputCall the model with one rendered request, and return the model's output.
The output's model names the model that responded, as resolve names models (the name of an
endpoint, not the versioned model name of a provider), or is None. When it is None, the model step
records the model of the call.
paramrequestChatRequestThe messages and tools to send.
parammodelstr | None= NoneThe model to call. None calls the model that the adapter was built with.
Returns
ModelOutputThe model's output, not sealed.
Raises:
TransientError: The service was down, slow or unreachable.
methodstream_complete(request, *, model=None) -> AsyncIterator[Fragment | ModelOutput]Call the model with one rendered request, and yield the model's output as it arrives.
The model step calls this method instead of complete when a client follows the turn while it
runs, for example through a streaming HTTP response. The step stops at the first ModelOutput and records
it, so yield nothing after it. When the service ends the response without a complete output, raise
TransientError: an iteration that ends without a ModelOutput fails the turn as fatal.
paramrequestChatRequestThe messages and tools to send.
parammodelstr | None= NoneThe model to call. None calls the model that the adapter was built with.
Yields:
Fragment | ModelOutput: Strings of the output's text andReasoningDeltas of its reasoning, in the order that they arrive. The last item is the completeModelOutput.
Raises:
TransientError: The service was down, slow or unreachable.
ChatRequest
dataclassChatRequest(messages, tools=())What one model call sends: the messages and the tools that the model can call.
parammessagestuple[Message, ...]The messages in their order.
attributecharactersintThe characters that this request sends a model.
The count includes every message, the tool calls in them and the tools offered, with each argument and schema
counted as JSON. An image counts as 6,000 characters. At four characters a token, that is about the 1,500
tokens that a vision model spends on a page-sized image. The model step records the count on each
ModelOutput as prompt_characters.
attributehashstrThe SHA-256 of this request as canonical JSON, as a hex string.
Canonical JSON has its keys sorted, no whitespace, each message and part named by its class, and bytes as
base64. Equal requests give equal hashes. The model step records the hash on each ModelOutput as
request_hash, and replay compares a rebuilt request with it.
PreparesRequests
protocolPreparesRequestsA ChatModel that can show what it sends for a request, without a call.
Not every adapter can. replay uses it to show what the adapter changes in each request that it rebuilds.
methodprepareasync (request, *, model=None) -> PreparedRequestReturn what the adapter sends for request, without a call to the model.
paramrequestChatRequestThe messages and tools to prepare.
parammodelstr | None= NoneThe model to call. None calls the model that the adapter was built with.
Returns
PreparedRequestThe request in the shape that the adapter's client sends, with its settings and changes.
PreparedRequest
dataclassPreparedRequest(request, settings, changes=())A request in the shape that the adapter's client sends, prepared but not sent.
PreparesRequests.prepare returns it.
paramrequestChatRequestThe messages and tools in the shape that the client sends them, after its changes.
paramsettingsdict[str, JsonValue]Everything that the client sends besides the messages and tools, such as sampling and thinking settings.
paramchangestuple[str, ...]= ()What the client changed, in order.
Messages
The messages of a ChatRequest, their parts, and the tools that the request offers.
Message
type aliasMessage = SystemMessage | UserMessage | AssistantMessage | ToolResultOne message of a ChatRequest: the instructions, a user message, an earlier output or a tool result.
SystemMessage
dataclassSystemMessage(content)A system message: the instructions to the model.
paramcontentstrThe text of the instructions.
UserMessage
dataclassUserMessage(content)A message from the user to the model.
paramcontentstr | tuple[ContentPart, ...]The text, or the text and image parts in their order. Only a user message holds images, because a
ToolResult holds only text.
AssistantMessage
dataclassAssistantMessage(content, tool_calls=(), reasoning=None)An earlier output of the model, sent back to the model.
paramcontentstrThe text of the output.
paramtool_callstuple[ToolCall, ...]= ()The tool calls that the output made, with the ids that their ToolResults name.
paramreasoningstr | None= NoneThe reasoning of the output, when the context policy sends it back, else None. An adapter whose
API has no field for it does not send it.
ToolResult
dataclassToolResult(tool_call_id, content)The result of one tool call that the model made, sent back to the model.
paramtool_call_idstrThe id of the call, as in ToolCall.id.
paramcontentstrThe result as text.
ToolDef
dataclassToolDef(name, description, parameters, elements=dict(), results=())ContentPart
type aliasContentPart = TextPart | ImagePartOne part of a user message: a text or an image.
TextPart
dataclassTextPart(text)A text in the parts of a user message.
paramtextstrThe text.
ImagePart
dataclassImagePart(data, mime='image/png', name=None)A picture in the parts of a user message.
paramdatabytesThe bytes of the image.
parammimestr= 'image/png'The media type of the image.
paramnamestr | None= NoneThe name that a note uses when the context policy leaves the image out, or None. The built-in
adapters do not send it to the model.
Output fragments
The pieces that ChatModel.stream_complete yields before the complete output.
Fragment
type aliasFragment = str | ReasoningDeltaA piece of a model's output while it arrives: a string of the output's text, or a ReasoningDelta.
ReasoningDelta
dataclassReasoningDelta(text)A piece of the model's reasoning, which the adapter yields while the model's output arrives.
The complete reasoning is in ModelOutput.reasoning.
paramtextstrThe text of the piece.
Stores
A trace store keeps each thread's events as JSON, and hands out the lease that lets one process run a thread.
TraceStore
protocolTraceStoreA store that keeps the trace of each thread, as a log of StoredEvents.
The store knows no event class, so it works unchanged for every event type that an agent defines. It seals each
event at append, and it hands out the lease that lets one process at a time run a thread. InMemoryTraceStore,
SqliteTraceStore and PostgresTraceStore are the built-in stores.
methodappendasync (thread_id, events, *, expected=None, lease=None) -> Sequence[StoredEvent]Seal events into their streams and return them as stored.
The store sets each event's seq as its place in its stream and ts as the time of the batch. The append
is atomic: when it raises, nothing of the batch lands.
paramexpectedMapping[Stream, int] | NoneparamleaseLease | None= NoneThe lease that the caller holds on the thread. The whole batch is refused with
LeaseExpiredError when a later acquisition replaced this lease. A lease that expired or was
released, and that no acquisition replaced, still appends. None appends unchecked, which is what a
writer outside the thread's run does.
Returns
Sequence[StoredEvent]The events as stored. They are what a read yields later, which can differ from what was given where the store cannot keep a value as it is.
Raises:
methodacquireasync (thread_id, *, owner, ttl) -> LeaseTake the thread's lease for ttl seconds.
The lease is free when nobody holds one, when the held one has expired, or when owner holds it already.
Every acquisition replaces the previous lease, so a holder that stalled past its expiry can never write
under the lease it had.
paramownerstrWho takes the lease, for example the id of a process.
paramttlfloatThe seconds until the lease expires, unless the owner renews it.
Raises:
LeaseHeldError: Another owner's lease has not expired.
methodrenewasync (lease, *, ttl) -> Nonemethodreleaseasync (lease) -> Nonemethodleaseasync (thread_id) -> Lease | Nonemethodread(thread_id, *, after=0, follow=False) -> AsyncGenerator[LogEntry[StoredEvent], None]Read a thread's events in the order the store accepted them, starting past a position.
The store numbers a thread's events 1, 2, 3, ... in the order it accepted them. That number is the event's position, and every entry yielded here pairs one event with its position. Called with only a thread id, it yields the whole log. An unknown thread yields nothing.
paramthread_idstrThe thread to read.
paramafterintparamfollowbool= FalseFalse yields the entries that exist now and returns. True then waits, and yields each new
event as the store accepts it, like tail -f on a file. The read ends when the thread is deleted
or loaded again. A thread re-created under the same id is another thread, and a read with follow
ends with the old one.
Yields:
methodread_backwards(thread_id) -> AsyncGenerator[LogEntry[StoredEvent], None]Read the events of a thread from the newest to the oldest.
It is the mirror of read: the same entries, in the same order reversed. The store fetches only what the
reader asks for. It is for a reader that wants the end of a thread and cannot say where that end
begins. An example is the status of a thread, which needs the turn in progress and none of the turns before
it. Without this method, that reader reads the thread from its first event to find where the last turn
opened. It never follows the thread.
Yields:
methodloadasync (thread_id, events) -> Nonemethodthreadsasync (*, limit, before=None) -> Sequence[tuple[str, StoredEvent]]Return one page of the threads: each thread's id and its first event, newest first.
Newest means the first event's ts, then the thread id, both descending. A thread with no events is not
listed.
methoddeleteasync (thread_id) -> NoneStoredEvent
dataclassStoredEvent(type, stream, actor, origin, payload, seq=None, ts=None, version=None)An event as TraceStore takes and returns it.
The store reads the bookkeeping fields, such as stream and seq. It keeps payload as JSON and never reads
it. encode makes the StoredEvent of an event, and EventTypes.decode reads it back.
LogEntry
dataclassLogEntry[T](position, event)Lease
dataclassLease(thread_id, owner, counter)LeaseHeldError
errorLeaseHeldErrorLeaseExpiredError
errorLeaseExpiredErrorSealedEventError
errorSealedEventErrorStreamMovedError
errorStreamMovedErrorObservers
An observer gets each recorded event and each model call and tool call, for example to export spans.
Observer
protocolObserverA passive watcher of runs: it gets each HTTP request, run, recorded event, model call and tool call.
An observer must only watch: it must not change what is recorded or what a model gets. When a method raises, the
runtime logs a warning, and the run continues unchanged. Give an observer to create_app as observer.
OtelObserver is the built-in observer.
methodrequest(headers) -> AbstractContextManager[None]Return the context that the HTTP app enters for the time of one HTTP request.
The runs that the HTTP request starts inherit this context.
paramheadersMapping[str, str]The headers of the HTTP request.
Returns
AbstractContextManager[None]A context manager, for example one that attaches the W3C traceparent context of the caller.
methodevent(thread_id, agent, event) -> Nonemethodrun(thread_id, agent, opening) -> AbstractContextManager[None]Return the context that the runtime enters for the time of one run of a thread.
paramthread_idstrThe thread that runs.
paramopeningInput | NoneThe input that opened the current turn when that turn is not over, else None.
Returns
AbstractContextManager[None]A context manager. The calls of the run arrive while it is entered.
methodcall(thread_id, stream, node, kind, name, call_id) -> NoneTake the start of one model call or tool call in a run, before the model or the tool runs.
methodclose() -> NoneCallKind
type aliasCallKind = Literal['model', 'tool']The kind of a call that an Observer gets: "model" for a model call, "tool" for a tool call.
Errors of the outside world
An adapter or a tool raises these errors for a fact about the outside world. The runtime decides what follows.
TransientError
errorTransientErrorA call to the outside world failed for a reason that is not in the call itself.
The service was down, slow or unreachable. The same call can succeed later, so the failed step can run again. Any failure that is not raised as this error is permanent.
Model adapters raise it, and a step or a tool can raise it. When a step raises it, the runner records a
transient Failure, and the node runs again when the thread runs again. When a tool raises it, the model reads
its message as the result of the call.
InvalidQueryError
errorInvalidQueryErrorThe backend rejected the query itself.
A tool raises it, so that the model can fix the query and call again. The model reads its message as the result
of the call. The backend responded, which tells this error apart from TransientError.
ResourceUnavailableError
errorResourceUnavailableError