soev for developers
soev-agentsReference

soev_agents.ports

Connect a model and a store: the protocols at which the framework reaches outside the process.

A is a protocol that the framework calls and that an implements for one technology. The two are ChatModel, which calls a model, and TraceStore, which keeps the of . Implement one of them to connect a model or a store that the built-in do not cover.

Models

A takes a ChatRequest of messages and offered , and gives the model's output.

ChatModel

protocolChatModel

An that calls a model: it sends a ChatRequest and returns the model's output.

A model calls it with the request that it built from the . The maps the whole response of its API to one ModelOutput, which the records. Translate the errors of the API to TransientError in the , 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 output
methodresolve(model) -> str

Return the name of the model that a call with model goes to.

parammodelstr | None

The model that the call names. None names the model that the was built with.

Returns

str

The name of the model.

methodcompleteasync (request, *, model=None) -> ModelOutput

Call 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 records the model of the call.

paramrequestChatRequest

The messages and to send.

parammodelstr | None
= None

The model to call. None calls the model that the was built with.

Returns

ModelOutput

The model's output, not .

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 calls this method instead of complete when a client follows the while it runs, for example through a streaming HTTP response. The 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 as .

paramrequestChatRequest

The messages and to send.

parammodelstr | None
= None

The model to call. None calls the model that the was built with.

Yields:

  • Fragment | ModelOutput: Strings of the output's text and ReasoningDeltas of its reasoning, in the order that they arrive. The last item is the complete ModelOutput.

Raises:

  • TransientError: The service was down, slow or unreachable.

ChatRequest

dataclassChatRequest(messages, tools=())

What one model call sends: the messages and the that the model can call.

parammessagestuple[Message, ...]

The messages in their order.

paramtoolstuple[ToolDef, ...]
= ()

The offered to the model. Empty when the model can call no .

attributecharactersint

The characters that this request sends a model.

The count includes every message, the in them and the 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 records the count on each ModelOutput as prompt_characters.

attributehashstr

The 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 records the hash on each ModelOutput as request_hash, and replay compares a rebuilt request with it.

PreparesRequests

protocolPreparesRequests

A ChatModel that can show what it sends for a request, without a call.

Not every can. replay uses it to show what the changes in each request that it rebuilds.

methodprepareasync (request, *, model=None) -> PreparedRequest

Return what the sends for request, without a call to the model.

paramrequestChatRequest

The messages and to prepare.

parammodelstr | None
= None

The model to call. None calls the model that the was built with.

Returns

PreparedRequest

The request in the shape that the 's client sends, with its settings and changes.

PreparedRequest

dataclassPreparedRequest(request, settings, changes=())

A request in the shape that the 's client sends, prepared but not sent.

PreparesRequests.prepare returns it.

paramrequestChatRequest

The messages and in the shape that the client sends them, after its changes.

paramsettingsdict[str, JsonValue]

Everything that the client sends besides the messages and , such as sampling and thinking settings.

paramchangestuple[str, ...]
= ()

What the client changed, in order.

Messages

The messages of a ChatRequest, their parts, and the that the request offers.

Message

type aliasMessage = SystemMessage | UserMessage | AssistantMessage | ToolResult

One message of a ChatRequest: the instructions, a user message, an earlier output or a result.

SystemMessage

dataclassSystemMessage(content)

A system message: the instructions to the model.

paramcontentstr

The 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.

paramcontentstr

The text of the output.

paramtool_callstuple[ToolCall, ...]
= ()

The that the output made, with the ids that their ToolResults name.

paramreasoningstr | None
= None

The reasoning of the output, when the sends it back, else None. An whose API has no field for it does not send it.

ToolResult

dataclassToolResult(tool_call_id, content)

The result of one that the model made, sent back to the model.

paramtool_call_idstr

The id of the call, as in ToolCall.id.

paramcontentstr

The result as text.

ToolDef

dataclassToolDef(name, description, parameters, elements=dict(), results=())

A that the model can call.

tool_def makes one from a .

paramnamestr

The name of the , as the model sees it.

paramdescriptionstr

What the model reads about the .

paramparametersMapping[str, Any]

The JSON schema of its arguments.

paramelementsMapping[str, tuple[str, ...]]
= dict()

Each argument that holds ids, with the types (type ) that it accepts. The model never sees it. The model uses it to replace the numbers that the model writes with real ids before it records the call, so the call can be read with real ids without the 's code.

paramresultstuple[str, ...]
= ()

The arguments that hold handles of results. The model replaces each handle with the call id of its result before it records the call, for the same reason.

ContentPart

type aliasContentPart = TextPart | ImagePart

One part of a user message: a text or an image.

TextPart

dataclassTextPart(text)

A text in the parts of a user message.

paramtextstr

The text.

ImagePart

dataclassImagePart(data, mime='image/png', name=None)

A picture in the parts of a user message.

paramdatabytes

The bytes of the image.

parammimestr
= 'image/png'

The media type of the image.

paramnamestr | None
= None

The name that a note uses when the leaves the image out, or None. The built-in do not send it to the model.

Output fragments

The pieces that ChatModel.stream_complete yields before the complete output.

Fragment

type aliasFragment = str | ReasoningDelta

A 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 yields while the model's output arrives.

The complete reasoning is in ModelOutput.reasoning.

paramtextstr

The text of the piece.

Stores

A store keeps each 's as JSON, and hands out the that lets one process run a .

TraceStore

protocolTraceStore

A store that keeps the of each , as a log of StoredEvents.

The store knows no class, so it works unchanged for every type that an defines. It seals each at append, and it hands out the that lets one process at a time run a . InMemoryTraceStore, SqliteTraceStore and PostgresTraceStore are the built-in stores.

methodappendasync (thread_id, events, *, expected=None, lease=None) -> Sequence[StoredEvent]

Seal events into their and return them as stored.

The store sets each 's seq as its place in its and ts as the time of the . The append is atomic: when it raises, nothing of the lands.

paramthread_idstr

The to append to. The first append to an id makes the .

parameventsSequence[StoredEvent]

The to append, not , in their order.

paramexpectedMapping[Stream, int] | None
= None

The length of each as the caller last read it. The whole is refused with StreamMovedError when any listed stream has grown since. Unlisted streams are not checked, and None checks nothing.

paramleaseLease | None
= None

The that the caller holds on the . The whole is refused with LeaseExpiredError when a later acquisition replaced this . A that expired or was released, and that no acquisition replaced, still appends. None appends unchecked, which is what a writer outside the 's does.

Returns

Sequence[StoredEvent]

The 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:

  • SealedEventError: An of the is already.
  • StreamMovedError: A in expected has grown since the caller read it.
  • LeaseExpiredError: A later acquisition replaced lease, or the store holds no of the .
methodacquireasync (thread_id, *, owner, ttl) -> Lease

Take the 's for ttl seconds.

The is free when nobody holds one, when the held one has expired, or when owner holds it already. Every acquisition replaces the previous , so a holder that stalled past its expiry can never write under the it had.

paramthread_idstr

The to take the of.

paramownerstr

Who takes the , for example the id of a process.

paramttlfloat

The seconds until the expires, unless the owner renews it.

Returns

Lease

The new , with a counter one higher than the that it replaces.

Raises:

  • LeaseHeldError: Another owner's has not expired.
methodrenewasync (lease, *, ttl) -> None

Move the 's expiry to ttl seconds from now.

paramleaseLease

The to renew.

paramttlfloat

The seconds from now until the expires.

Raises:

  • LeaseExpiredError: The was replaced.
methodreleaseasync (lease) -> None

Give the up, so the next acquisition does not wait for its expiry.

A that was replaced is left alone.

paramleaseLease

The to give up.

methodleaseasync (thread_id) -> Lease | None

Return the 's current .

paramthread_idstr

The to read the of.

Returns

Lease | None

The current while it has not expired and is not released, else None.

methodread(thread_id, *, after=0, follow=False) -> AsyncGenerator[LogEntry[StoredEvent], None]

Read a 's in the order the store accepted them, starting past a .

The store numbers a 's 1, 2, 3, ... in the order it accepted them. That number is the 's , and every entry yielded here pairs one with its . Called with only a id, it yields the whole log. An unknown yields nothing.

paramthread_idstr

The to read.

paramafterint
= 0

The reader's : yield only entries whose is greater than this. 0 means from the beginning. A reader that reconnects passes the last it saw and misses nothing.

paramfollowbool
= False

False yields the entries that exist now and returns. True then waits, and yields each new as the store accepts it, like tail -f on a file. The read ends when the is deleted or loaded again. A re-created under the same id is another , and a read with follow ends with the old one.

Yields:

  • LogEntry[StoredEvent]: The entries of the 's log, in the order of their .
methodread_backwards(thread_id) -> AsyncGenerator[LogEntry[StoredEvent], None]

Read the of a 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 and cannot say where that end begins. An example is the of a , which needs the in progress and none of the before it. Without this method, that reader reads the from its first to find where the last opened. It never follows the .

paramthread_idstr

The to read. An unknown yields nothing.

Yields:

  • LogEntry[StoredEvent]: The entries of the 's log, from the newest to the oldest.
methodloadasync (thread_id, events) -> None

Replace the 's log with events, already , in the order given: a restore.

The keep their seq and ts, and take the 1, 2, 3, ... A read with follow of the replaced ends.

paramthread_idstr

The to restore. A with this id is replaced.

parameventsSequence[StoredEvent]

The , in log order.

Raises:

methodthreadsasync (*, limit, before=None) -> Sequence[tuple[str, StoredEvent]]

Return one page of the : each 's id and its first , newest first.

Newest means the first 's ts, then the id, both descending. A with no is not listed.

paramlimitint

The maximum count of on the page.

parambeforestr | None
= None

The last id of the previous page, or None for the first page. An id that the store does not hold gives an empty page.

Returns

Sequence[tuple[str, StoredEvent]]

Pairs of a id and the 's first .

methoddeleteasync (thread_id) -> None

Remove the 's log and its .

A read with follow of the ends. An id that the store does not hold changes nothing.

paramthread_idstr

The to remove.

StoredEvent

dataclassStoredEvent(type, stream, actor, origin, payload, seq=None, ts=None, version=None)

An as TraceStore takes and returns it.

The store reads the , such as stream and seq. It keeps payload as JSON and never reads it. encode makes the StoredEvent of an , and EventTypes.decode reads it back.

paramtypestr

The 's type . The store does not read it.

paramstreamStream

The of the .

paramactorstr

Who made the : "user", "model", "tool" or "system".

paramoriginstr | None

The that made the , or None.

parampayloadMapping[str, Any]

The 's own fields as JSON.

paramseqint | None
= None

The 's place in its , from 0. None until the store seals the at append.

paramtsdatetime | None
= None

When the store the . None until the store seals the at append.

paramversionstr | None
= None

The of the code that wrote the , or None.

LogEntry

dataclassLogEntry[T](position, event)

One of a 's log, with its .

TraceStore.read yields entries that hold a StoredEvent. The runtime hands on the decoded with the same .

parampositionint

The place of the in the 's log, in the order that the store accepted the : 1, 2, 3, and so on. A reader passes the last that it saw to read on from there.

parameventT

The .

Lease

dataclassLease(thread_id, owner, counter)

The right to run one , as TraceStore.acquire handed it out.

paramthread_idstr

The that the is for.

paramownerstr

Who holds the , for example one process.

paramcounterint

The count of the acquisitions on the , up to and including this one. The store compares it with the 's current count to tell a that is still the 's from one that was replaced.

LeaseHeldError

errorLeaseHeldError

Another owner holds the 's , and that has not expired.

TraceStore.acquire raises it. Nothing changes. The caller decides to wait, to give up, or only to read.

LeaseExpiredError

errorLeaseExpiredError

The that a call names is no longer the 's current .

TraceStore.append and TraceStore.renew raise it. The expired and another acquisition replaced it, or its owner took a new one. Nothing lands. The process that holds the current continues the from its .

SealedEventError

errorSealedEventError

An to append is already.

TraceStore.append raises it when an of the has its seq or ts set. Appending is what seals an into the log, and a fact is only once. Nothing of the lands.

StreamMovedError

errorStreamMovedError

An append names a that a moved past.

TraceStore.append raises it when a in expected grew after the caller read it. The never lands. The writer reads again and decides again. It does not retry the same append, because what landed can change the decision.

Observers

An observer gets each recorded and each model call and , for example to export spans.

Observer

protocolObserver

A passive watcher of runs: it gets each HTTP request, run, recorded , model call and .

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 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 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) -> None

Take one after the store it.

paramthread_idstr

The of the .

paramagentstr | None

The 's , or None for an that the recorded.

parameventEvent
methodrun(thread_id, agent, opening) -> AbstractContextManager[None]

Return the context that the runtime enters for the time of one of a .

paramthread_idstr

The that runs.

paramagentstr | None

The 's , or None for a made directly over a .

paramopeningInput | None

The input that opened the current when that turn is not over, else None.

Returns

AbstractContextManager[None]

A context manager. The calls of the arrive while it is entered.

methodcall(thread_id, stream, node, kind, name, call_id) -> None

Take the start of one model call or in a , before the model or the runs.

paramthread_idstr

The that makes the call.

paramstreamstr

The that makes the call.

paramnodestr | None

The that makes the call, or None outside a .

paramkindCallKind

Whether it is a model call or a .

paramnamestr

The model of a model call, or the of a .

paramcall_idstr | None

The id of a , or None for a model call.

methodclose() -> None

End what the observer still has open.

The HTTP app calls it at shutdown, after its stopped. An application that runs in its own process calls it itself.

CallKind

type aliasCallKind = Literal['model', 'tool']

The kind of a call that an Observer gets: "model" for a model call, "tool" for a .

Errors of the outside world

An or a raises these errors for a fact about the outside world. The runtime decides what follows.

TransientError

errorTransientError

A 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 can run again. Any failure that is not raised as this error is permanent.

Model raise it, and a or a can raise it. When a raises it, the records a Failure, and the runs again when the runs again. When a raises it, the model reads its message as the result of the call.

InvalidQueryError

errorInvalidQueryError

The backend rejected the query itself.

A 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

An that is unavailable or not configured refused the call.

Its message is worded for the model. When a raises it, the model reads the message as the result of the call. An from refusing raises it for every call.

On this page