Glossary
The terms that this reference uses, defined once.
Thread and record
thread
One conversation or task lifetime, with an id a client keeps. A thread is an entity: its identity outlives its contents, and it is what gets opened, cancelled, forked and deleted. Its contents are its trace, and that trace is the only state there is: no current-node pointer, no waiting flag, no copy of the messages sent to the model. A thread is longer than a turn and wider than a stream.
trace
A thread's events, read through a typed query API (last, latest, by_type, stream, view) that selects facts and applies no rule; steps never walk raw events. A trace is a value, not an entity: a sequence of events you can hold, slice, serialize and compare, with no identity of its own. That is why a trace exists where no thread does, and the other way round: a step is handed a view, a Trace over part of one thread's events, never the thread; a fork copies a prefix of one thread's trace into a new thread; a replay feeds a recorded trace to a builder with no live thread at all. In prose the trace is also called the log, in the event-sourcing sense of "the log is the state"; the two words name the same events.
stream
One append-only sequence of events with its own numbering from 0. A thread's events form a tree of streams rather than one sequence, and every thread starts with a single one, named root. More streams exist because of concurrency: when a graph fans out, or a node delegates to subagents, the branches run at the same time. If they all append to one sequence, the log records which branch happened to finish first, a fact decided by timing that a replay cannot reproduce. So each branch appends to a stream of its own, never sees a sibling's, and the order across streams follows the tree rather than the clock. This is CQRS's rule of one event stream per aggregate, the unit with a single writer, applied to branches. Streams hang below one another as a tree, and parent, child, sibling, ancestor and descendant are used in their plain tree sense for any stream; what a stream carries is said by branch or subagent. A stream's name says where it hangs: root/search@3 is the branch that started at node search after event 3 of the root. The verb in Thread.stream() is unrelated: it runs a thread while yielding its events.
branch
A stream a fan-out started: one per target of the fan-out node, walked at the same time as its siblings, ending at a join or wherever its own tail says stop. A branch runs the same graph as its parent and can read the parent's events up to the fan-out, but never a sibling's. That is what separates it from a subagent's stream, which runs another graph and sees only the task it was handed. In a stream name a subagent's stream carries an index after #, as in root/research#0@7; a branch does not, as in root/search@3.
scope
The part of the thread that one graph run — the agent, or one subagent — produced and can read. It is the region of the stream tree cut at every stream whose first event is an Input: the root stream heads the agent's scope, and each subagent's stream, which begins with its hand-off, heads its own. Scope is a read boundary, not only a way to select events. A subagent's steps are handed nothing of the parent before the hand-off, and the parent's steps nothing of the subagent beyond the hand-back it recorded in its own stream. That is what lets the same graph behave identically standalone and as a subagent, and it makes the hand-off the one recorded place where the parent decides what a subagent gets to see, which matters when the subagent runs on another model. Selecting a subagent's part of the trace afterwards uses the same cut. The cut looks at a stream's first event only, so an answer that lands in the middle of a waiting branch does not cut the tree. A view is one step's slice inside its scope.
view
What a step running in a given stream can read: that stream, the streams below it, and its ancestors up to the position it fanned out from — never a sibling branch, never an ancestor's later events, never outside its scope. A branch reads the parent's facts and its own; the join, and the parent after it, read every branch that ended. Two branches cannot see each other because whether a sibling had already landed depends on timing, and the log must never record a fact decided by timing. The view is handed to the step as a Trace, and it is the only thing a step, a router or a context builder receives at call time: the messages the model sees are rendered from the view plus the builder's own parameters, which were fixed when the builder was built. The Trace holds only the view's events, so it cannot be widened from inside; a step that reached past it by holding the store breaks the rule that a step is a function of its view.
turn
The agent's work on one fresh input: from that input up to the next fresh input in the same stream. "Turn" as in taking turns: the outside world gives an input, the agent works until it stops (Finished), is stopped (Cancelled) or fails (Failure), and then the world can give the next one. It is not chat-specific: a fresh input is any Input that responds to no Request, whatever sent it — a user's message, a webhook, a scheduler, or a parent's hand-off, which opens a subagent's first turn in its own stream. An Input that responds to a Request continues the turn it was asked in and does not open one. A one-shot agent, such as a pipeline, takes one turn; a chat agent takes one per user message. The agent's turns are those of the root stream. The rule is a fact of the trace, so reads need no agent code: status reads the current turn, a cancel ends it, the graph is built for the input that opened it, and a response cannot switch its model. A turn is not a run: one turn can take several runs, after a crash or a transient failure. Context management also counts in turns (see unit).
round
One model call that asked for tools, and the results of those calls: from a ModelOutput with tool calls up to the next such ModelOutput in the view. A ReAct turn that searched three times holds three rounds; a turn that answered at once holds none. A context unit (see unit), so an agent that works long on one input can shrink and compact by rounds where turns[:-2] selects nothing. Example, one turn of two rounds: Input (turn 1 starts) · ModelOutput calling search (round 1 starts) · ToolOutput r1 · ModelOutput calling open twice (round 2 starts) · ToolOutput r2 · ToolOutput r3 · ModelOutput answering. Here turns[-1] selects r1–r3, rounds[-1] r2 and r3, results[-1] r3.
unit
What context management counts age in when it selects tool results to shrink or a part of the history to summarise: turns (see turn), rounds (see round) or results (one tool result each, in recorded order), each indexed or sliced like a Python list, the current one last, as Compress(rounds[:-3]) or Compact(where=turns[:-2]). All three are facts of the trace, so no agent defines its own: a selection that is not a fact, such as a time window, is a Shrink of the agent's own. A summary never splits a tool call from its results: a cut by any unit that splits them falls back to before the call.
run
One pass of the runner over the thread: from the tails of its streams until every stream it walks has nothing left to do. A fresh turn, the middle of a run, a resume after a crash and a retry after a failure are all the same computation, so a run needs no checkpoint. Inside a run, the traversal of one stream is a walk. A run belongs to the process, not to the caller that started it: Runs keeps the running task per thread id, a second caller joins the run rather than starting another, and a caller that leaves does not end it. One runner per thread is the rule; in one process Runs enforces it, and across processes the lease does.
tail
The last event of a stream. The runner reads it, with the graph, to decide what happens next in the stream. The store reads a thread with TraceStore.read, which yields events from a cursor onward and, when following, waits for more; TraceStore.read_backwards mirrors it, newest event first and never following, for a reader that wants the end of a thread without reading its beginning.
tree order
The one order across a thread's streams: a branch sorts directly after the parent event it fanned out from and before the parent's next event, siblings by name and then by index, each stream internally by its own numbering. It depends only on the names of the streams and the seq of the events, never on when a branch's events were appended. The order in which branches finish depends on timing, and the log never records a fact that timing decides. A Trace holds its events in tree order.
append order
The order the store sealed a thread's events, across all its streams. It is the one order a subscriber can bookmark, and it is what the position numbers. It differs from tree order whenever branches ran concurrently.
Graph
graph
The configured, runnable unit the runtime executes: nodes, each holding its step with the model, context builder, tools and prompt already bound; edges; an entry point; and optionally joins and a budget. A graph is not a bare shape — hand it a store and it runs. An agent builds one for each turn it runs. The shape alone, without configuration, is a topology, and nothing in the code is only that. Cycles are allowed and end when a router returns None.
hidden state
A value that agent code, a tool or a server keeps in memory, that changes what is recorded or what a model receives, and that cannot be derived from the trace. Not allowed: the next process after a crash behaves differently without it. State inside an adapter that changes only what the outside world answers is not hidden state, because the answer is recorded. Neither is a lock that holds no data. A tool keeps none: it never receives the trace, gets what earlier calls produced as recorded elements, and keeps working state in local variables of one call. The rule and its test: "The no-hidden-state rule" in the package README.
resume equivalence
The test property that rules out hidden state: a turn that stops after any step and resumes with every object built again produces the same trace as a turn that never stopped, ignoring ids and timestamps. The framework's test kit runs it for each agent, also with the steps alternating between two servers.
test kit
The checks every agent's tests run, in soev_agents.testing: resume equivalence (check_resume_equivalence) and the determinism check (check_determinism), which rebuilds a recorded thread's requests under several server configurations with refusing adapters and refuses any difference, between them or from the recorded request hashes.
recorded element
An element that an earlier step of the turn recorded, given to a tool as a parameter typed Recorded[T]: before each call, the tool step fills it with every T that the turn recorded so far, the input's included, in recorded order, an id recorded twice listed twice. A tool that needs the latest state of an element, such as whether an attached file is ready, reads the latest record of its id. The model never sees the parameter. It is how one tool uses what another produced without reading the trace: the two depend on the same element class, not on each other.
refusing adapter
An adapter that fails every call with ResourceUnavailableError, whose message the model reads: what a server gives where the real one is unavailable or unconfigured (no web, no live ticket), and what replay and the test kit build graphs with (refusing(name)), so a build that called an adapter fails at once.
resources
What a server gives an agent's build for one request: the objects that touch the outside world (model clients, stores, search, web, live connections). Resources hold no data that a decision reads, and a resource is never absent: an unavailable one is present and fails each call with a typed error. build only hands resources to tools and to the model step; it never reads, calls or tests them. The access a caller has lives in its resources, so the framework knows no permissions.
agent
An input class and how a graph is built for a turn: what a server registers and AgentCatalog serves under a name, so that threads are opened for it and Opened records that name; the whole contract between a server and an agent (runtime.Agent). A ready-made agent such as ReActAgent is configured, not extended: what depends on the turn goes in its config function. The catalog parses each fresh input with the class, records the input with the elements the parsed value holds, and calls the agent's synchronous build(resources, turn, model) with the resources the catalog's caller gives for that request, of the caller's own type and passed through unread, the parsed turn, and the model the input names, None when it names none: the whole turn runs on that model, and an answer that names another one is refused. An agent also declares the element and event types its threads record beyond the core ones; its threads are read and recorded with those and the core types only, so a step that produces an undeclared type records a Failure. The code that builds a graph — choosing parts, topology and every prompt — is where policy lives.
ReAct config
Every setting of a ReActAgent for one turn but its input class: the model adapter, the prompt, the limits of the model, the tools, the context strategy and the rest (ReActConfig). Models behave differently and need their own tuning, so a config holds the settings for one model. The agent takes one config for every turn, or a function that builds the turn's config from the request's resources, the turn and the model the input names; nothing below the agent looks a model up. Only the adapter and the prompt are required. A config function can refuse a turn whose model it has no settings for, before anything is recorded.
prompt file
Every text an agent chooses for its models, by key, in one YAML file per agent: the system prompt as a template with blocks, plain texts, and texts that replace a tool's own description or argument descriptions; then a layer per group of models, by endpoint ids, that replaces keys the base defines (PromptFile). A ReAct config takes its prompt from the texts its model resolves to; a tool keeps default texts of its own, which the prompt file overrides. The keys and the digest of each resolved set are what a prompt optimizer such as GEPA writes and compares; the digest is not an event's version, which names the code that wrote it.
node
A named position in the graph: a name plus what it holds — a step, an effect step or a delegation. Edges connect names, and every event records the name of the node that produced it. The same step configuration can sit in two nodes, which is why node and step are different words.
step
The work a node holds: an async function from the trace to new events, configured completely when built, consulting no clock, randomness or environment. Steps are where the outside world is consulted — model calls, tool I/O — and they must return at least one event, because a node that records nothing is invisible to the log. A step differs from a part (a strategy it is configured with) and from a projection (a sync function that only reads).
effect step
The second kind of work a node can hold: a two-phase shape for a step that changes the world instead of observing it. It has a sync intend that computes the EffectIntent, an effector that performs it, a recovery for the window between, and a cancellation for a call still in flight when the turn is cancelled. The runner drives the phases; the step is never called as one function.
edge
A node's way out. Plain edges name one or more fixed targets, and a second target makes the node a fan-out. A conditional edge names a router. A node has one or the other, never both; a node with no edge is terminal.
router
A pure function from the trace to the name of the next node, or None to stop: the switch, written as ordinary Python over recorded facts. Routing needs no event of its own, because everything the router reads is already in the log and re-deriving it on resume gives the same answer. Routers are sync on purpose, so they cannot do I/O, and are defined with the graph so topology stays out of steps. A router that raises, or names a node the graph does not have, ends the turn with a Failure on the node its edge leaves, never transient because the same trace routes the same way again; the thread stays readable.
entry point
The node a fresh turn dispatches from — in a thread, and in a subagent's stream alike, which is why one graph runs unchanged as agent and as subagent.
fan-out
A node with more than one plain target: the walk starts one branch per target and walks them at the same time, and a join is where they collapse again. Not to be confused with a fork, which copies a whole thread.
join
A node declared as the place branches collapse. A branch that reaches it ends there; the join runs once, in the parent stream, after every branch has ended and at least one reached it. Plain edges into a shared node are not a join: such a node runs inside each branch, on that branch's own facts. Two edges into one node mean "either path" elsewhere in a graph, so the join must be declared.
budget
The cap on model calls that ask for tools in one turn, the node that gets the wrap-up call when the cap is hit, and how many wrap-up attempts a model that keeps asking for tools gets before the runner stops the turn. An answer to a request continues the turn, so it continues the count too; a budget set to resets_on_answer starts the count again at each answer instead.
input class
The pydantic class an agent's fresh inputs must parse into, in strict mode: a payload it refuses is never appended. It is the whole contract for a fresh input. The elements the parsed value holds are recorded on the Input, so numbering reads the thread alone and a change to the class never renumbers a stored thread. A tool-shaped delegation derives the tool's arguments from the subagent's input class, so what the parent's model sees and what the subagent accepts cannot drift. A Request's expects is the same idea in the other direction, as a JSON Schema: what a reply must look like.
Events
event
One immutable fact in the log. A step creates it with its payload fields; the runner stamps which node produced it and which stream it belongs to; the store stamps its position and time at append; from then on it never changes. Event types are an open set: an agent defines its own by subclassing Event. An event is not a message (what the model sees is rendered from events) and not a frame (an event on the wire).
bookkeeping field
A field the machinery fills in, never the step: stream, seq, actor, origin, ts, version. On the wire they sit beside the payload, not inside it.
version
The code that wrote an event: one opaque string the server names its code with at startup (for example myapp@<commit> (soev-agents@<commit>), +dirty for uncommitted changes), written by the runtime on every event it appends, beside ts. It says which code to check out to replay a thread exactly; nothing reads it to decide anything.
request hash
ModelOutput.request_hash: the SHA-256 of the request the framework built for a call, as canonical JSON. A rebuilt request that hashes otherwise was built by other code or from something besides the trace.
payload
An event's own fields, the ones its class declares beyond the bookkeeping; on the wire, the nested object under payload. Input.payload is also the name of the one field on an Input that holds the message or value from outside — any JSON value, so a task spec or an approval decision fits without new fields.
sealed
In the log: seq and ts stamped, always together, at append. A sealed event is a fact and can never be appended again; an event with only one of the two stamped is refused at construction. Everything before that point is unsealed: what a step returns, what a port hands back.
batch
The events one step call returns, or one append lands: appended together, atomically, sealed with one timestamp, in the order produced. A crash can fall between batches, never inside one, which is why tests kill a run at every batch boundary.
expected position
The length of each stream a writer read, named on its append. The store refuses the batch when any named stream has grown since — the stream moved — so two writers never interleave; unlisted streams are not checked. It is per stream, so concurrent branches never refuse each other. The runner always names it; a cancel or an answer appends without one.
position
An event's place in its thread's log: 1-based, without gaps, assigned when the event is sealed, in the order the store committed, across every stream of the thread. It sits on the LogEntry the store's tail yields and on the wire frame, never on the event itself: it records who won a race between concurrent branches, so it is never serialized with the event and never read by a step; seq records the fact's place in its own stream, which no race can change.
cursor
A reader's bookmark: the last position it has seen. read(after=) and watch(after=) take one, the SSE id: line hands one out and Last-Event-ID sends it back, and fork(at=) cuts at one. A cursor holds a position; the position belongs to the event's entry, the cursor to whoever is reading.
origin
The name of the graph node that produced an event; None for an event no node produced, such as an Input from outside. The one Input a node does produce, a hand-off to a subagent, carries the delegating node's name. origin says which code ran; actor says who is responsible.
tag
The string an event class declares in its type field, which EventTypes keys on to rebuild the right class. The store keeps it as an opaque string and never looks it up. Two types that exist to be subclassed, Input and ToolOutput, declare it as a plain string so a subclass can set its own.
envelope
The one shape an event takes outside the process — a database row and a wire frame alike: the bookkeeping fields and the tag beside a payload object holding every other field exactly as the class declares it. Nested rather than flat so that a payload's own id never collides with a wire-level id. StoredEvent is the envelope as TraceStore takes and returns it; the OpenAPI document of the thread API names the same shape on the wire Envelope, with the event id and position added.
frame
One server-sent-event message on the HTTP tail. An event frame carries one sealed event: the envelope plus the event id and the position, with the type tag on the event: line. A core event type's frame is typed, <Type>Frame, its payload the type's own fields, which is what the OpenAPI document describes; any other registered type travels as the plain EventFrame of the OpenAPI document, the same envelope over an untyped payload. A delta frame carries answer text and a reasoning_delta frame carries reasoning; both have a {"text": "..."} payload and no id. Answer text never carries citation markers: a citation frame, shaped as one entry of an answer's citations, follows the text it belongs after. A closing status frame says where the thread stands.
event id
An event's identity on the wire, written stream:seq, for example root:7. A client answers a request by naming its event id. It is not the position, the number on the id: line of a frame that a subscriber keeps as its cursor.
Event types
Compaction
A summary the model wrote of every event before the one at kept_from_seq in kept_from_stream, which from then on replaces those events in what the model is sent (see compaction). The kept event is named by its stream and its seq (kept_from_stream, kept_from_seq), not by a turn count, so reading a summary needs no turn rule. Recorded because the summary took a model call and cannot be worked out again from the thread. It records the model that wrote it, its token usage, and its text without markers beside the markers taken out of it, as a ModelOutput does (see marker). The thread keeps the turns it replaced.
verdict
A response from outside the system to a verification request: the evidence recovery needs to resolve a torn intent.
hand-off
The Input a delegating node seals at the head of a subagent's stream: one per task, phrased by the parent's policy, stamped with the delegating node's name. It is the one Input a node produces, and it never travels in a step's batch.
hand-back
The batch the parent records for one ended subagent, projected from the subagent's view: typically one ToolOutput carrying the subagent's result, with the Sources it was shown, and what they are part of, as elements. A result that cites comes back as the thread API serves it, text without markers and its citations with real ids, and the parent's builder writes markers there in the parent's own numbering and strategy. It is how a parent cites its subagent's sources, numbered in the parent's own view, and the only thing of the subagent the parent ever sees.
Running
runner
The loop that runs a thread: read a stream's tail, get what happens next from a pure function of the tail and the graph, act, append, repeat. It keeps no position variable and decides nothing itself.
transient, fatal
The two classes of failure. Transient: the service gave no answer and the same call can succeed later without any change — a 503, a timeout, a dropped connection — so the next run re-enters the node. Fatal: everything else, an unclassified exception being a bug until proven otherwise, so the walk ends and only a new input, or someone acting on the code, moves the thread. Adapters translate their vendor's errors into TransientError in one place each.
projection
Anything derived from the log by a pure function of the trace: a router's decision, a rendered context, the thread's status, a delegation's hand-offs and hand-back, what the runner does next in a stream. Projections are sync where the type system can enforce it, so they cannot do I/O, and are never recorded, because a derived value cannot disagree with what it was derived from.
thread state
Where a thread stands, computed from its facts and its lease only, never with agent code or a graph, so a read works after any code change: finished when the root stream's last event is Finished, or the thread holds only its Opened; cancelled when the current turn holds a Cancelled and no effect intent of the root stream still needs its result; running when a process holds the lease; waiting when a request is open; failed when a stream of the current turn ends in a Failure, transient or not; else interrupted, work left and nobody running it. The server computes the crash, so a client computes nothing. A failed thread takes a fresh input, which opens a new turn and leaves the failed one in the log, unless the failed turn still holds an effect intent without its result; resume finishes the failed turn instead. An interrupted thread is one a run left before its turn ended: the process was killed, the run raised, or a response landed after the run's last look; resume picks it up, and an input just submitted and not yet run reads interrupted for that moment too. Running and interrupted both refuse a fresh input, since the turn is not over.
lease
The right to run one thread, held by one owner at a time and kept by the store beside the thread: the owner, an expiry, and a counter that goes up by one at every acquisition. A run takes the lease before it walks, names the counter on every append, renews the lease while the walk lasts and releases it when the walk stops. The store refuses an append whose counter is not the thread's current one, so a run that stalled past its expiry and lost the thread to another process cannot write under the lease it had, whatever the timing. The owner is the process, as Runs names it. A lease is only ever taken on a thread that has events, the run refusing an empty id before it asks for one. A run releases when its walk stops and when its task is cancelled; a lease that expired without a release is what a killed run leaves behind, and the next acquisition takes it; an owner that still holds a lease takes it again at once, with a new counter, which is how a process that cancelled its own run starts another without waiting for the expiry.
open request
A Request a caller can still respond to: the waiting tail of a stream in the current turn. In a cancelled turn every request is closed except a recovery's verification request. open_requests computes it, the one definition both the status and the routing of responses use.
resume
Run a thread from its tail with nothing new to say: the verb for a crashed or transiently failed turn, and for continuing a forked thread, which is cut at a position and so left with work to do.
fork
Copying a thread up to a position into a new thread, which then continues on its own while the original is untouched: git's sense of the word. It has nothing to do with a fan-out, which splits a graph's walk into branches inside one thread.
cancel
End the turn with one Cancelled in the root stream. It closes every open request of the turn except a recovery's verification, and an unanswered effect intent outranks it: recovery still runs on the next pass, because nobody knows whether money moved. In the process that owns the run, a cancel stops the running task at once; one posted through another process's store takes effect when that runner's running step ends, because the runner's next append is refused and it plans again; until then that step continues, at most one model call or tool call.
replay
Feeding a recorded log to a reader: reconstruct it, re-project it with a different builder, or fork it and continue live from a point with effects refused. None of these re-executes a node, because state is a projection of the trace. Replay equivalence is the test behind it: identical logs must route identically.
Effects
effect
An action in the world — a mail sent, a refund issued — that must happen exactly once and must be visible after a crash. It is recorded as an intent before it runs and a result after, with recovery for the window between.
intent
The sealed declaration that an effect is about to run, unanswered until its result lands. See EffectIntent.
effector
The effect step's member that performs a sealed intent and maps the world's answer to one unsealed result. A definitive refusal from the world comes back as error data; an exception means no answer exists. Agents define their own effectors and wire their own clients; there is no adapter family for effects, which is why an effector is not a port.
cancellation
The effect step's required strategy for the call that is in flight when the caller cancels the turn. It is handed the view, the sealed intent, the effector and the call itself, still running, and returns one EffectResult when it resolved what happened, one Request when only the outside world can say, or nothing when the call is to be given up and the intent left to recovery. It is a strategy of its own rather than a branch inside recovery because the two know different things: at cancel time the process is alive, holds the lease and has the live call in hand, which is the only moment an effect can still be stopped at its far end, while recovery runs later, possibly in another process, with nothing but the log. Nothing is recorded when the run is cancelled with no Cancelled in the log, which is a process on its way out rather than a caller's cancel. abandon is the one that gives the call up.
recovery
The effect step's required strategy, consulted whenever the runner re-enters the node while the intent is unanswered. It returns exactly one EffectResult — it resolved what happened, by looking the outcome up, by re-performing when that is safe, or through a verdict — or exactly one Request when it needs the outside world to tell it. Recovery is chosen per effect, because re-sending a notification email is noise and re-issuing a refund is money.
torn intent
An intent with no result in the log: nobody knows whether the world changed, and only evidence from outside can tell. Re-entering the node lands in recovery, never in the effector. Prose says "torn thread" for a thread whose tail is one.
verification request
The Request recovery emits when it needs a human to say what happened. It stays open through a cancel, and its response is a verdict.
gate
A node that pauses for approval: a RequestStep emitting its configured Request, with the decision over the response on its conditional edge — approve and decline as router targets.
Delegation
delegation
Another graph held by a node, built per subagent from the Input heading its stream (graph_for), together with two sync projections: hand_offs, which reads the parent's view and produces, per task in call order, the Input that starts its subagent or the event that answers the call without one, such as a refusal of its arguments; and hand_back, which reads one ended subagent's view and produces what the parent records for it. Answers made without a subagent land with the hand-backs, in call order. It is the third thing a node can hold, next to a step and an effect step, and it binds topology, so it lives with the graph rather than with the steps. The runner drives the delegating node in phases, as it does an effect step and a fan-out: seal the hand-offs, walk the subagents at the same time, and once every one has ended land the hand-backs in the parent stream as one batch under the node's name. A subagent still waiting or failed leaves the parent's tail unchanged, so every later pass re-derives the delegation and resumes each subagent from its own tail.
task
One unit of delegated work: one hand-off Input, one subagent. A delegation hands off a sequence of tasks, one per tool call when it is tool-shaped, the way a model can spawn two researchers in one turn.
subagent
A graph run by a delegating node inside another graph's thread, in a stream of its own that begins with the hand-off: one run per task, so a node asked for two researchers produces two subagents. A subagent's stream is numbered in hand-off order, root/research#0@7 then root/research#1@7, and heads its own scope, so the subagent sees nothing of the parent beyond the hand-off and the parent sees nothing of the subagent beyond the hand-back. The parent is the graph whose delegating node opened it. Everything the subagent did stays in the thread's single log; the cut is only in what steps are handed. Nothing marks a graph as a subagent: the same graph runs unchanged standalone as an agent and inside another as a subagent, and it need not involve a model.
Context and citation
context builder
A projection from the trace to everything one model call receives, "context" in the sense of a model's context window: the messages, the tools offered, and the model the call goes to; together with what the answer is read with, the citation strategy that model was told to cite with and the numbering it was shown. It also refuses a call whose prompt does not fit, before it is made. Sync so it cannot do I/O. Its parameters — the prompt, the layout, the tools, and any window on how much history to render — are bound when it is built, as part of the agent; at call time it receives the view and nothing else, so a per-call argument can never shape the rendering without being in the log. The model step only calls the model with what the builder built and records the answer. The builder owns every message, the placement of the instructions included: a model that wants its instructions elsewhere gets a different builder, not a different step. Rendering is what the builder does, just in time, and the rendered messages are discarded after the call because they are re-derivable. A builder decides per event type what the model sees, and raises on a type it never decided about rather than silently skipping it. Each model node has its own builder, so each node of a graph can see different events, a different prompt and different tools.
context strategy
The replaceable context management of a ReActAgent (ContextStrategy), given as its config's context: it makes the context builder of each turn from the parts the agent computes (the model, the prompt, the offered tools, the budget, how an input renders, the citation strategy, the limits), names the tools it offers itself (reopen), and wraps the model step (compaction). The three are one context management because they depend on each other: compaction starts from the size of the request the builder made, writes its summary from that request, and helps only if the builder shows the summary; reopen is offered by the builder and run by the tool step. MessageBuilder with its context policy is the default and owns all three. The policy says how much the model sees, the builder's other settings (select, show, renderers) what it sees, and a strategy of one's own how the request is laid out.
chat model
A model behind a message API, the port's ChatModel and ChatRequest: it takes system, user, assistant and tool messages and offered tools. "Chat" names the API format, as the industry does, not the agent shape: a pipeline node calls a chat model too.
context policy
How an agent keeps what a model is sent within the model's limits, one policy per model, held by the MessageBuilder in the agent's ReActConfig for that model (context=MessageBuilder(ContextPolicy(...))). It holds when to shrink old tool results and how (start_at, target and the shrinks, below); how a tool result looks in each form (see forms), one function per form for every tool plus a tool's own Forms by name; the output reserve, the room kept free for the answer: a call whose prompt estimate leaves less than that in the window is refused before it is made, and fails the turn; the reserve is not sent as a limit, so an answer can use more. And a cap per tool result: a result the model reads at more than that many tokens, whatever its form left, is cut at that point and says how much was left out; a tool can have its own cap or none, such as a reading tool that bounds what it reads itself, because an oversized result is rendered again on every later call and otherwise leaves every call over the window. And which earlier answers' reasoning is sent back to the model: never (the default), those of the running turn, or always; reasoning sent back counts toward the context like any text. And the reopen tool, offered once something is shortened (see reopen). Every setting's default is a visible default of the policy itself; a setting given replaces it whole, and nothing is added behind the scenes.
prompt estimate
How many tokens a prompt about to be sent is taken to be, without a tokenizer. The latest model call recorded its prompt's exact token count and its characters, so only the characters added since are divided by the model's characters per token; the whole prompt is divided when that call was another model's, recorded no counts, or was longer than the prompt now is, since a removal estimated with a pessimistic ratio counts too few tokens. The ratio's error thus only applies to what was added.
form
How much of one tool result the model is shown: full, as its tool's full function gives it (as recorded by default); compressed, as its tool's compressed function gives it (by default its elements as tags with their numbers but without text, its own text and data kept), under a line naming its result handle, so the model can tell text was left out and reopen what it needs; or stubbed, its output replaced by one line naming its handle and the elements floors keep, while the call that asked for it stays visible. Forms are never recorded: each call works them out again (see shrink). While the model stays the same, a result's form only goes down. A form that frees nothing is never given, so the line always means text was left out: a result holding only JSON stays full until a stub reaches it, unless its tool's compressed function shortens the JSON (json_cap).
forms
How one tool's results look in each form: a function per form from the whole result to a smaller one (Shorten), the policy's full and compressed for every tool and a tool's own Forms(full, compressed, cap) by name, a field left out using the policy's. A function can change the result's text, its data and the text of its elements; it keeps every element and the error, which the drawing checks, because floors and reopen find elements by id. An element a floor lifts shows its text in the form it is lifted to. An input's elements are always shown as recorded.
shrink
What a context policy does to old tool results when the context fills: an ordered list of shrinks, such as compressing the results of every turn but the last two, then stubbing those of every turn but the last four. A shrink without its own when starts above the policy's start_at and goes on to target; one with when (an emergency, such as stubbing within the last turns above 85% of the window) starts above it and stops once under it. A pass that could free less than the policy's minimum gain is not made, so the prompt cache is not broken for a few tokens. Which results a shrink applies to is a slice of the view's turns, rounds or results (see unit), counted at the call being judged, the current one last. On every call the builder walks the view's model calls from the first, keeping a running total of the context's size in its own rendering (characters divided by the ratio); wherever the total passed the policy's start_at, the shrinks were applied, oldest results first, one at a time, until it was under target. So each call reaches the decisions of the calls before it with nothing stored, and the walk is judged by the model the upcoming call goes to: a switch to another model re-judges the whole history. Not to be confused with a step, the work a graph node holds.
compaction
The last resort of a context policy, when shrinking tool results is not enough: the history before the last turns, rounds or results (see unit) is replaced by a summary the model writes, user messages included. An agent opts in by giving its context policy a compact, a Compact with its own threshold (when, 80% of the window by default), the units it replaces (every turn but the last two by default; the running unit is always kept) and the instructions that ask for it. The cut never splits a tool call from its results: it falls back to before the call. It is not one of the shrinks: it always comes after all of them. It is context management inside the model node, not part of the graph: a wrapper around the model step builds the context once, and when that context is still over the threshold after every shrink, or does not fit at all, the model about to answer first writes the summary, not streamed, and then answers from it. The summary call is the earlier conversation exactly as the earlier calls sent it, plus the instructions. The Compaction and the answer are recorded together, so routers and budgets never see compaction; a failed answer records neither, and the next attempt writes a new summary. From then on the builder renders the prompt and the latest summary in the system message, then the events after those it replaced; those events no longer count toward the context's size. Element numbers and result handles are still those of the whole thread, so the summary keeps the markers the earlier answers gave the facts it keeps, in the citation strategy of the model that will read it, and reopen brings any of them back in full. Nothing is compacted when no whole unit is left to replace; the call then fails as full. Compaction is another mechanism than shrinking (it writes to the trace, with a model call of its own) but the same context management, so it is configured in the policy of the builder that shows its summaries (see context strategy); a summary after which the builder builds the very same request is refused.
floor
The lowest form some elements can reach whatever form their result has: by default, a passage cited before its result shrank stays full, so a compressed result keeps that passage's text and a stubbed result still shows it, inside the whole it is part of. A citation made after the result shrank changes nothing in it, so the earlier message stays as the model was sent it; the model reads that passage where it read it last, in a reopen. An element's form is its result's form lifted to its highest floor. A floor is a predicate and a form, Floor(applies, form): the predicate is given the element, the name of the tool that recorded it, and the events before the call where its result shrank, so what happens later never moves a floor. The default is Floor(cited, FULL), cited being the citing toolkit's predicate; any predicate sets one, such as every element of a class; a policy with no floors lets everything shrink. Floors only show more of a result; they never change its form.
result handle
A tool result's name for the model, r1, r2, …, by recorded order among the results of the view, a reopen's result included: derived from the thread, never renumbered, independent of any configuration. It names the result by the call id it answers, and Numbering is the one place that counts it; a result with no call id has none. A compressed or stubbed result shows it, and reopening takes it to show a whole output again. A tool argument holding handles is translated like an element id argument, when the model step records the call: the tool gets the call id of the tool call the named result answers, a handle naming no result is refused before the tool runs, and the thread API serves the call with call ids.
reopen
Showing the model again what a context policy shortened or removed: elements by number, whole tool results by handle. It is part of the context policy, not one of an agent's tools: the policy holds the tool, and the context builder offers it from the first model call that shortens a tool result or follows a summary, on every call after it, until the turn's tools are withdrawn; tool states and required-tool instructions never apply to it. Its recorded output names what it reopened, real ids and call ids, and stores no copy; the context builder draws that content from where it was first recorded, as the reopen's own result at the bottom of the conversation, each element inside its whole in the forms of the tool that recorded it and each whole result under its handle and its tool's cap. Nothing earlier in the conversation changes. A reopen result is a tool result like any other: it has a handle and is compressed or stubbed as it ages; it is never cut as a whole, since its parts were cut under their own tools' caps.
element
Something the model is shown that it can refer to again: as a tool argument, or as a citation. Being an element makes it known to the framework, which can then act on it: show it under a number and translate that number back before a tool runs, resolve and check citations, verify claims against a source's text, hide it when access is revoked. Anything the model can refer to must be an element, and its real id never appears in plain text. What an element is — a document, a knowledge base, a passage — is its class: an app subclasses Reference or Source once per kind of thing, never the base, and registers its subclasses beside its event types. An element has a real id, properties passed through by the tool and never read by the framework, and optionally ref, the real id of the element it is part of. It is stored under its class's name in kebab case, knowledge-base for KnowledgeBase, unless the class declares its own type; renaming a class therefore renames its stored name, and a class renamed in code declares the old name to keep old threads readable. Elements ride in the elements of an ElementEvent: a ToolOutput, an Input (read from its fields typed with an element type, in the order the input's class declares them), or an agent's own event type that extends ElementEvent, such as a pipeline node's retrieved sources; numbering counts them all.
numbering
The number each element is shown under, derived from the thread alone: every real id, by first appearance, with an element's ref counted just before the element itself. The same real id always has the same number, and numbers already given never change as the thread grows. Never stored: each step that needs it computes it once. Nothing recorded holds a number: the model step records the real id each cited number and each tool argument named, so the rule can change without changing what an old answer means.
layout
How the elements of one tool call are shown to the model: the format, the tag or field names, what nests inside what. It is wholly policy, the format included: the context builder takes it as show, a function from one tool call's elements, the numbering and the tool's name to text, so it can differ per model step and per tool. It shows numbers, never real ids. The toolkit's default, xml, shows every element as a tag named by its stored type with every field and property as an attribute, a Source's text as the tag's content; an agent picks and renames attributes and tags per class, or passes a function of its own. The layout shows each text as its tool's forms left it; it never shortens text itself. It is also handed the citation strategy of the model about to read it, and xml writes on every Source, never on a Reference, the exact marker that model cites it with (<passage id="7" cite="[7]">), so the context itself says what can be cited and how, while id stays the handle for tools. xml escapes every text, so text inside a source never reads as a tag, and keeps its own attributes (id, cite, the one naming the whole): a field or property of the same name is shown with data- before it, so the number is never replaced.
citation strategy
How one AI model is told to cite and how its markers are read back, held by one declaration so the two never disagree: a pattern matching one marker, the characters that can start one, how to write one, and what a prompt shows for a marker that named no source. Which strategy a model gets is the agent's policy, set in that model's ReAct config, because models are trained to cite differently; the parsing, the positions and the streaming are the framework's, the same for every strategy. The model step reads an answer's markers with the strategy the context builder told the called model to cite with, once, when it records the answer (see marker), so a later change of the mapping or the format never re-reads an old answer differently.
citation
One number an answer cited, as the thread API serves it: its position in the answer's text, which is served without markers, and the passage and document it resolved to, or invalid with the reason. Positions rather than markers in the text, as most model APIs do, so no client parses again and a bracket quoted from a document is never taken for one. A marker is removed with the spaces before it; a marker citing several numbers is one citation per number. An agent that does not cite is served as written, with none.
marker
What a model writes in its text to cite, such as [4], as its citation strategy reads it. The model step takes every marker out of an answer, or a summary, when it records it, and records each as a Marker: its position in the text without markers, the exact characters cut out there, the marker and the spaces before it, and each number it cited as a CitedNumber, the number written and the real id of the Source it named then, or none. Putting the cut-out characters back gives the text exactly as the model wrote it. Nothing recorded holds a number to be read again later: the builder writes each marker into a later prompt with the source's number there and the current strategy's format, and leaves out a source the prompt does not number; a marker with a number that named no source is shown as the model wrote it, followed by what the strategy's write_invalid adds, by default a note that it is invalid.
Tools
tool
What the model can call: a plain async function made a tool with the decorator @tool(name=..., description=..., repeats=..., limit=...). The function gets four things and nothing else: the arguments the model writes (its parameters before *), its adapters, values of the turn's input (both given with bind in build), and recorded elements (Recorded[T]). It returns exactly one ToolOutput, whose elements hold what the tool showed: a grounding tool's Sources, a listing's References. Control, whether a call runs and what a tool is given, belongs to the agent and the framework, never to the tool. name is a wire contract and description is what the model reads; the function's docstring is for developers only. Below tool is the runtime's Tool: a name, a description, an arguments class and a run(arguments) that takes the call's arguments parsed into that class and returns one ToolOutputBase; reopen is one written as a class. A tool is the tool step's extension point, not a world boundary: a tool that reaches the world does so through a named port, as a search tool does through soev_retrieval's VectorStore, awaiting its search through sources_of. A tool cannot halt a run: it raises TransientError or InvalidQueryError for a fact about the world, and the tool step answers the call with a ToolOutput whose error says so; any other exception is a fault that becomes a Failure. What the model is shown of a tool is a ToolDef, which the framework builds from the tool, the parameters being the JSON schema pydantic generates for the arguments class, without the titles pydantic derives from class and field names and without class docstrings, and refusing an argument name it does not declare. A ToolDef can exist with nothing executable behind it: ask_user is declared so the model can call it, and its call is answered by a recorded Request instead of an execution.
element id argument
A tool argument that names an element, declared as Annotated[str, ElementId(Document)] with the element classes it accepts, subclasses included. The model writes the number it was shown; the model step translates it when it records the answer, with the numbering the model was shown, into the real id of the element recorded under that number, and records the call with real ids and each replaced value as a Translation, its place in the arguments and what the model wrote there. Putting the written values back gives the call as the model wrote it; the builder shows an earlier call with each real id under its number in the prompt. A number with no recorded element, or one of a class the argument does not accept, becomes null and is refused before the tool runs, like any argument that fails validation: the call is answered with a ToolOutput whose error says why, and the tool does not run. So is a call to a tool its request did not offer, whose numbers were never translated. Tools never see numbers, and the thread API serves every call as recorded, with real ids. An argument typed as the element class itself, Annotated[Document, ElementId()], arrives as the element first recorded under that id, and its schema shows the model a string; ElementId() without classes accepts the classes of the type it marks. A tool-shaped delegation shows a subagent's element fields the same way and hands the subagent the parent's elements themselves, since the subagent numbers from its own hand-off.
tool state
What one turn asks of one tool: off, not offered to the model; auto, offered, the model decides; required, offered, with an instruction to use it added to that turn's message, on every later call too, worded as its tool declaration words it now. A request, not a guarantee: the model can still answer without a call to it. A tool declaration's default is its state on a turn that names none, and locked keeps a turn from naming another.
tool declaration
A tool an agent has, by name, with the deployment's policy for offering it: default, locked, and the instruction a turn that requires it adds, "Use the {tool} tool for this message." unless the declaration words its own. Not the tool: the same tool is offered differently by different deployments. An agent declares its tools once; per turn its wiring supplies a tool for each declared name or leaves it out.
tool call
One requested invocation inside a ModelOutput: id, name, parsed arguments. It is never an event of its own; its answer is a ToolOutput linked by the call's id. A call id is unique in its thread: the model step keeps the provider's id unless it is empty, already answered by a result, or used by an earlier call of the same answer, and then records a fresh one, so no two results share a result handle.
Boundary
port
A protocol at the world boundary that framework code consumes and a technology adapter implements: a port implies an adapter. Three exist: TraceStore, ChatModel, and Observer, which only watches what is recorded, for example to export telemetry. Its OpenTelemetry implementation lives in soev_agents.observability. Tools search through soev_retrieval's VectorStore protocol directly, which is the retrieval port. A port is shaped from what the runtime needs, always async because its implementations do real I/O. The soev_agents.ports module is therefore the framework's egress map: every seam where the framework's own code reaches outside the process, and only those. A protocol between two modules of the framework, such as Step or Tool, is not a port. The question that tells them apart: what sits in soev_agents.adapters for this?
adapter
An implementation of one port for one technology, or its stand-in: InMemoryTraceStore, PostgresTraceStore, OpenAIChatModel. Named after the port module it implements. An adapter translates its vendor's transient errors into TransientError, and a retrieval adapter its vendor's rejected-query errors into InvalidQueryError, in one place, and lets everything else propagate as fatal.
double
A scripted stand-in for a port or contract in tests, answering from a script or from what it sees in the request: ScriptedChatModel, ScriptedTool, ScriptedEffector, ScriptedStep. Verification harness, not product. A double is not an oracle: that word means a test oracle, such as seal for the store contract suite.
contract suite
The executable semantics of a port, which every implementation must pass. For the store, a meta-test fails the build for any store class no contract subclass names, so a new store cannot be added without running the suite.
Serving
server
The application that serves agents over HTTP, for example soev-server. It calls create_app, and gives the hooks and the resources.
status
Where a thread stands for a caller: the thread state, the open requests with their positions, and the thread's last position. It is read from facts alone, so reading it never builds a graph; the thread API serves it with every answer and alone at GET /threads/{id}/status.