soev for developers
soev-agentsReference

soev_agents.testing

Check that an agent keeps the determinism and no-hidden-state rules, and that a store keeps its contract.

The checks run an with scripted in place of the outside world. The resume-equivalence check stops a after each and it in a new process. The determinism check rebuilds each model call of a recorded under several configurations. The HTTP conformance suite is in soev_agents.testing.http.

Modules

Scripted doubles

A scripted plays a fixed script in place of a model, a , an or a , and keeps each call.

ScriptedChatModel

classScriptedChatModel(outputs, fragments=(), *, name='scripted')

A that gives the outputs of its script in order, one for each call, and keeps each call.

complete and stream_complete both take the next output of the script. A streamed call yields every fragment of fragments first, the same fragments for each call, and then the output. An output without a model gets the name of the model that the call asked for, or name when the call asked for none, as a real names the model that replied.

Example

from soev_agents.domain import ModelOutput
from soev_agents.ports import ChatRequest, UserMessage
from soev_agents.testing import ScriptedChatModel

model = ScriptedChatModel([ModelOutput(content="Paris.")])
output = await model.complete(ChatRequest(messages=(UserMessage(content="Capital of France?"),)))
assert output.content == "Paris." and output.model == "scripted"

Make a that plays outputs.

paramoutputsSequence[ModelOutput]

The output of each call, in call order.

paramfragmentsSequence[Fragment]
= ()

What each streamed call yields before its output.

paramnamestr
= 'scripted'

The model that a call without a model goes to.

attributerequestslist[ChatRequest]

The ChatRequest of each call, in call order.

attributestreamedlist[bool]

For each call, whether it came through stream_complete.

attributemodelslist[str | None]

The model that each call asked for, None for the 's own.

methodresolve(model) -> str

Return the name of the model that a call that asks for model goes to.

parammodelstr | None

The model that the call asks for, or None for the 's own.

Returns

str

model, or name when model is None or empty.

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

Keep the call and return the next output of the script.

paramrequestChatRequest

The ChatRequest of the call. The model keeps it in requests.

parammodelstr | None
= None

The model that the call asks for, or None for the 's own.

Returns

ModelOutput

The next output, with the name of the called model when the script gives no model.

Raises:

  • LookupError: Every output of the script was played already.
methodstream_completeasync (request, *, model=None) -> AsyncIterator[Fragment | ModelOutput]

Keep the call, then yield the fragments and the next output of the script.

paramrequestChatRequest

The ChatRequest of the call. The model keeps it in requests.

parammodelstr | None
= None

The model that the call asks for, or None for the 's own.

Yields:

  • Fragment | ModelOutput: Each fragment of fragments, then the next output, as complete returns it.

Raises:

  • LookupError: Every output of the script was played already. The first item of the iteration raises it.

ScriptedTool

dataclassScriptedTool(name, results, *, description='', arguments=…)

A that gives its calls the results of its script in order, and keeps the arguments of each call.

It raises a result that is an exception. It keeps each call's parsed arguments as a dict in calls.

It is a frozen dataclass that holds no mutable field, so ToolStep accepts it.

Make a that plays results.

paramnamestr

The name that the model calls the by.

paramresultsSequence[Exception | ToolOutputBase]

The result of each call, in call order. The raises a result that is an exception.

paramdescriptionstr
= ''

What the model reads about the .

paramargumentstype[BaseModel]
= …

The pydantic model of the arguments. By default it accepts arguments of any shape.

attributecallslist[Mapping[str, Any]]

The parsed arguments of each call that the ran, as dicts, in call order.

methodrunasync (arguments) -> ToolOutputBase

Keep the call's arguments and return the next result of the script.

paramargumentsBaseModel

The parsed arguments of the call.

Returns

ToolOutputBase

The next result of the script.

Raises:

  • LookupError: Every result of the script was played already.
  • Exception: The next result of the script is this exception.

ScriptedEffector

classScriptedEffector(results)

An that returns the results of its script in order, one for each , and keeps each .

Make an that plays results.

paramresultsSequence[EffectResult]

The result of each , in call order.

attributecallslist[EffectIntent]

Each that the performed, in call order.

methodperformasync (intent) -> EffectResult

Keep intent and return the next result of the script.

paramintentEffectIntent

The recorded .

Returns

EffectResult

The next result of the script.

Raises:

  • LookupError: Every result of the script was played already.

ScriptedStep

classScriptedStep(outcomes)

A that returns the of its script in order, one for each call, and keeps each .

A call whose outcome is an exception raises it, so the records a Failure.

Make a that plays outcomes.

paramoutcomesSequence[Exception | Sequence[Event]]

The outcome of each call, in call order: the to return, or an exception to raise.

attributecallslist[Trace]

The that each call received, in call order.

method__call__async (trace) -> Sequence[Event]

Keep trace and return the next outcome of the script.

paramtraceTrace

The of the 's .

Returns

Sequence[Event]

The of the next outcome, as a new list.

Raises:

  • LookupError: Every outcome of the script was played already.
  • Exception: The next outcome of the script is this exception.

Resume equivalence

A that stops after any and in a new process must record the same as a that never stopped.

check_resume_equivalence

functioncheck_resume_equivalenceasync (scenario, agent, inputs) -> None

Check that a that stops after any and in a new process records the same .

The check runs inputs, each a fresh of agent, once without a stop. Then, for each k of that run except the last, it runs them again: it stops after k, builds a new process, , and sends the rest. Last, it runs them with every on the other of two processes that it built once. Every must record the of the first .

A is one append of a under its . Its landed before the stop, so a stop after it loses only what the process held. The check releases the of the stopped run, as an expired is released. Each uses a new in-memory store whose clock always gives the same instant.

The comparison leaves out the timestamp of each . It renames the ids of and by their first appearance, because a makes fresh ids where the world gives none.

Example

from soev_agents.domain import Input, ModelOutput
from soev_agents.runtime import AgentCatalog
from soev_agents.testing import Process, ScriptedChatModel, check_resume_equivalence

# MyAgent is an Agent of your own that calls `model`.


def scenario() -> Process:
    model = ScriptedChatModel([ModelOutput(content="Twenty years.")])
    return lambda store: AgentCatalog(store, {"chat": MyAgent(model)}, version="test")

await check_resume_equivalence(scenario, "chat", [Input(payload="How long?", model="scripted")])
paramscenarioScenario

Sets up the world of one . The check calls it once for each .

paramagentstr

The name of the in the catalog that a process builds.

paraminputsSequence[Input]

The fresh inputs, one each, in order. The first input opens the .

Raises:

  • ResumeMismatchError: A recorded another than the first . The message names the and the first that differs.

Scenario

type aliasScenario = Callable[[], Process]

A function that sets up a fresh outside world and returns the Process that is built in that world.

The world is the behind the , such as a ScriptedChatModel. Every process of one shares that world, as real processes share the real one. check_resume_equivalence calls it once for each , so that each starts from the same world.

Process

type aliasProcess = Callable[[TraceStore], AgentCatalog]

A function that builds everything that one process holds over a store, as an AgentCatalog.

Everything means the , the , the and the state. check_resume_equivalence calls it again for each new process.

ResumeMismatchError

errorResumeMismatchError

A resumed turn recorded another than the that never stopped.

check_resume_equivalence raises it. The message names the and the first that differs.

Determinism

What a model call receives must depend only on the code and the before the call.

check_determinism

functioncheck_determinismasync (events, configurations) -> None

Rebuild every model call of events under each of configurations and refuse any difference.

Give each configuration that refuse every call (refusing) and vary what a can vary, so that a ChatRequest that reads an or a setting fails here. Every call must rebuild, the ChatRequests must be equal across configurations, and each must hash as the ChatRequest that its call recorded, when it recorded one. The check runs replay once for each configuration.

Example

from soev_agents.domain import Event
from soev_agents.runtime import refusing
from soev_agents.testing import built_by, check_determinism

# agent is the Agent under test, and thread is a Thread of that agent with a recorded turn.
trace = await thread.resume()
configurations = [built_by(agent, {"web": None}), built_by(agent, {"web": refusing("the web")})]
await check_determinism(trace.by_type(Event), configurations)
parameventsSequence[Event]

One 's in log order, decoded, for example from Thread.trace.

paramconfigurationsSequence[GraphFor]

One GraphFor for each configuration. Give at least one.

Raises:

  • ValueError: configurations is empty.
  • NondeterministicBuildError: A call does not rebuild under a configuration, two configurations build different ChatRequests, or a rebuilt ChatRequest does not hash as the recorded one.

built_by

functionbuilt_by(agent, resources) -> GraphFor

Return the GraphFor that builds the of agent with resources, as a builds them.

The function parses the input with the 's , as accepted does. Then it calls the 's build with resources, the parsed turn and the model that the input names.

paramagentAgent[Any, Any]

The under test.

paramresourcesAny

The of one configuration. Give that refuse every call (refusing).

Returns

GraphFor

The function that builds the for the that an input opened.

GraphFor

type aliasGraphFor = Callable[[Input], Graph]

A function that builds the for the that an input opened, under one configuration.

built_by makes one from an . check_determinism calls it with the input that opened a of the recorded , once for each that holds a model call.

NondeterministicBuildError

errorNondeterministicBuildError

A rebuilt ChatRequest depends on something other than the code and the .

check_determinism raises it. The message names the model call, counted from 1, and the configuration, counted from 0.

Store contract

The holds the tests that every TraceStore must pass.

TraceStoreContract

classTraceStoreContract

The of TraceStore: the tests that every store must pass.

Subclass it in a test module, in a class whose name starts with Test, so that pytest collects the tests. Give the subclass make_store. store_type names the class under test: the suite does not read it. The tests are coroutines, so they need an async pytest plugin, for example pytest-asyncio with asyncio_mode = "auto". Some tests wait 0.1 seconds for a to expire, so the store must date its by the real time.

Example

from soev_agents.adapters import InMemoryTraceStore
from soev_agents.ports import TraceStore
from soev_agents.testing import TraceStoreContract


class TestInMemoryTraceStore(TraceStoreContract):
    store_type = InMemoryTraceStore

    def make_store(self) -> TraceStore:
        return InMemoryTraceStore()
attributestore_typetype

The class of the store under test. The suite does not read it.

methodmake_store() -> TraceStore

Return the store that one test runs on.

Each test calls it once. Return a new, empty store, or a store that an autouse pytest fixture opened for the test.

Returns

TraceStore

The empty store.

Raises:

  • NotImplementedError: The subclass does not override it.
methodtest_append_seals_exactly_as_the_sealing_rule_saysasync ()

Check that append seals a of several streams as seal does, with one timestamp.

methodtest_append_seals_with_seq_and_tsasync ()

Check that append returns the with a seq from 0 and a timestamp.

methodtest_seq_continues_across_appendsasync ()

Check that seq continues from one append to the next.

methodtest_seq_is_per_streamasync ()

Check that each numbers its from 0, apart from the other of the .

methodtest_any_type_and_payload_read_back_as_appendedasync ()

Check that an of any type, and reads back as it was appended.

methodtest_a_literal_backslash_u0000_in_text_round_trips_unchangedasync ()

Check that the literal text \u0000 in a 's keys and values reads back unchanged.

methodtest_threads_are_isolatedasync ()

Check that each holds only its own and numbers them from 0.

methodtest_append_rejects_sealed_entryasync ()

Check that append refuses an that is already with SealedEventError.

methodtest_append_refuses_a_batch_whose_expected_position_movedasync ()

Check that append refuses a stale with StreamMovedError and lands nothing.

An of 0 for a new lands.

methodtest_failed_append_lands_nothingasync ()

Check that a that holds one lands none of its .

methodtest_read_numbers_appends_gaplessly_in_append_orderasync ()

Check that read yields the of all streams in , at 1, 2, 3 and on.

methodtest_read_after_a_cursor_skips_what_was_seenasync ()

Check that read(after=n) yields only the entries after n.

methodtest_positions_are_per_threadasync ()

Check that each numbers its from 1.

methodtest_read_of_an_unknown_thread_is_emptyasync ()

Check that read of an unknown yields nothing, also when it follows.

methodtest_a_following_read_yields_live_appendsasync ()

Check that a following read yields the of the appends that come after it started.

methodtest_a_following_read_waits_rather_than_ending_when_caught_upasync ()

Check that a following read waits for more after the last one, and does not end.

methodtest_read_backwards_yields_the_log_newest_firstasync ()

Check that read_backwards yields the entries of read in reverse order.

methodtest_read_backwards_of_an_unknown_thread_is_emptyasync ()

Check that read_backwards of an unknown yields nothing.

methodtest_read_backwards_reaches_the_first_event_of_a_long_threadasync ()

Check that read_backwards yields every entry of a of 301 , down to 1.

methodtest_read_backwards_stops_where_its_reader_stopsasync ()

Check that a reader can close read_backwards after its first entry, which is the newest.

methodtest_delete_removes_the_threadasync ()

Check that delete removes one , and that a new with its id starts from the start.

The other keep their . The new starts at seq 0 and 1.

methodtest_delete_of_an_unknown_thread_is_a_noopasync ()

Check that delete of an unknown does nothing and raises nothing.

methodtest_a_following_read_ends_when_the_thread_is_deletedasync ()

Check that a following read ends when its is deleted.

methodtest_a_stale_expectation_on_a_missing_thread_creates_nothingasync ()

Check that an append with a stale to an unknown creates no .

The append raises StreamMovedError. An append with the 0 lands.

methodtest_a_following_read_ends_at_the_delete_even_when_another_thread_takes_the_idasync ()

Check that a following read ends at the delete, also when a new takes the same id.

methodtest_a_following_read_holding_an_entry_still_ends_after_the_deleteasync ()

Check that a following read that yielded an entry before the delete ends after the delete.

It yields no entry that is not an entry of the deleted .

methodtest_acquire_gives_a_lease_that_lease_reads_backasync ()

Check that acquire returns a of the and the owner, and that lease returns it.

methodtest_a_lease_alone_makes_no_threadasync ()

Check that a of a without makes no .

read yields nothing, and a following read ends at once.

methodtest_two_acquisitions_at_once_let_exactly_one_throughasync ()

Check that one of two concurrent acquisitions gets the , and the other LeaseHeldError.

methodtest_acquire_refuses_a_live_lease_of_another_ownerasync ()

Check that acquire raises LeaseHeldError while another owner holds a live .

methodtest_the_same_owner_retakes_its_lease_and_the_old_one_is_staleasync ()

Check that an owner can acquire its live again, and that the old becomes stale.

An append under the old raises LeaseExpiredError and lands nothing.

methodtest_an_expired_lease_is_freeasync ()

Check that lease returns None after the expiry, and that another owner can then acquire it.

methodtest_append_under_a_stale_lease_lands_nothingasync ()

Check that an append under an expired that another owner took raises LeaseExpiredError.

The append lands nothing. An append under the new owner's lands.

methodtest_an_append_without_a_lease_is_not_checkedasync ()

Check that an append without a lands while an owner holds the , and keeps the .

methodtest_renew_keeps_a_lease_alive_and_refuses_a_stale_oneasync ()

Check that renew extends a live , and raises LeaseExpiredError for a stale .

methodtest_release_frees_the_lease_and_leaves_another_owners_aloneasync ()

Check that release frees the , and that the release of a stale changes nothing.

methodtest_delete_drops_the_leaseasync ()

Check that delete removes the of the , so that another owner can acquire one.

methodtest_load_restores_a_sealed_log_of_several_streamsasync ()

Check that load restores a log of several streams, and that appends continue after it.

methodtest_load_replaces_the_thread_it_namesasync ()

Check that load replaces every of a that the store holds already.

methodtest_load_refuses_unsealed_and_gapped_logsasync ()

Check that load raises ValueError for an unsealed or a gap in seq, and loads nothing.

methodtest_threads_lists_each_thread_with_its_first_event_newest_firstasync ()

Check that threads lists each with its first , the newest first.

methodtest_threads_pages_follow_one_another_until_the_lastasync ()

Check that threads(before=id) gives the page after the id.

After the last , and for an unknown id, the page is empty.

methodtest_threads_leaves_out_deleted_threads_and_bare_leasesasync ()

Check that threads lists no deleted , and no that has a but no .

On this page