This guide is the code-reading map for Actant. It explains not only which components exist, but where execution crosses a durability boundary and which component owns each decision.
If you only need the vocabulary, begin with core concepts. If you are trying to change or debug the runtime, start here.
Actant uses one logical Temporal workflow ID for each (agent_id, thread_id).
Executions close when idle; later messages reopen the same logical thread.
The workflow is the durable coordinator. Activities perform all work that can
touch the outside world.
AgentRuntime (client API)
|
| signal-with-start / resolve signal / cancel / query
v
AgentThreadWorkflow (durable decisions)
|
| schedules and awaits
v
TemporalRuntimeActivities (side effects)
|
+-- model provider
+-- tools
+-- projection stores
+-- lifecycle events and stream listeners
The central invariant is:
Tool calls emitted by one agent turn progress independently, but the next agent turn cannot start until every call in that group has reached a terminal result and the group has been finalized into the transcript.
actant/runtime/temporal/workflow.pyAgentThreadWorkflow.run: lifetime of a thread.AgentThreadWorkflow._run_agent: orchestration for one agent run.AgentThreadWorkflow._run_tool_group: the group barrier.
actant/runtime/temporal/activities/runs.py: run lifecycle and one model turn.tools.py: admission, execution, resolution, and group finalization.threads.py: thread-level cancellation repair.context.py: dependencies shared by worker-bound activities.
actant/runtime/runtime.pyAgentRuntime: commands, queries, and worker polling.
actant/runtime/temporal/types.py- Serializable payloads crossing workflow/activity boundaries.
actant/runtime/interfaces/stores.py- Projection-store contracts.
actant/runtime/stores/postgres/models.py: schema and Alembic metadata.stores.py: queries and transaction boundaries.conversion.py: pure row/domain translation.
actant/runtime/events/- Optional live event and model-stream observers.
The public entry point is actant/runtime/runtime.py. It deliberately contains
command and worker wiring; durable orchestration stays in the workflow.
agent thread (stable workflow ID; executions close when idle)
└── agent run (one end-to-end activation)
├── agent turn 1 (one model invocation)
│ └── tool group
│ ├── tool call A
│ └── tool call B
├── agent turn 2
└── ...
- An agent thread remains addressable between user messages.
- An agent run starts after the workflow drains its inbox, advances through turns and tool groups, and ends on completion, exhaustion, failure, or cancellation.
- An agent turn is one model call and its assistant output.
- A tool group contains every tool call emitted by the same agent turn.
All calls in a group share a group_id. Every persisted call also carries the
run_id and turn_id that produced it.
The following pseudocode mirrors AgentThreadWorkflow intentionally. Keep the
documentation and method order aligned when changing the algorithm.
while True:
await wait_for_message_or_cancellation()
if cancelled:
break
new_messages = drain_inbox()
start_run()
turns_remaining = max_turns_per_run
while turns_remaining > 0:
turn = await run_turn(new_messages)
new_messages = []
turns_remaining -= 1
if turn.has_no_tool_calls:
break
should_stop = await run_tool_group(turn.tool_calls)
if should_stop:
break
finalize_run()
if not inbox:
break
rotate_temporal_history_if_needed()run_turn is an activity because it loads projections, calls the model,
streams provider output, and persists the resulting assistant message. The
workflow sees only its durable TurnResult. When the worker has a TurnGate,
run_turn consults it before the model call; a refusal returns a TurnResult
with a stop_reason and no model call, which ends the run as exhausted.
For every tool call in one turn:
1. Schedule admit_tool for every call.
2. Await all admission outcomes in completion order.
3. For each outcome:
EXECUTE -> schedule execute_tool
AWAIT_HUMAN -> suspend until a resolve_tool signal arrives
DENY -> no second activity; admission already persisted a terminal result
4. Await every tool outcome in completion order.
5. Run finalize_tool_group once.
6. Return control to the agent run for its next agent turn.
Admission and execution use workflow.as_completed. Completion order does not
control transcript order: finalize_tool_group materializes results in a
deterministic order after the whole group has resolved.
For a mixed group, the timeline can be:
time --->
allowed call: admit -- execute ---------------- completed
deferred call: admit -- AWAIT_HUMAN ........ approve -- completed
group barrier: ================================== open
next agent turn: start
The allowed call does not wait for the deferred call before executing. The agent does wait before starting another agent turn.
Deferred tools use a durable workflow signal and condition:
- Admission persists
WAITINGand publishes the wait request. - The workflow suspends on
workflow.wait_condition; no Python task or worker slot remains occupied. AgentRuntime.resolve_tool_callsignals the owning thread workflow.- Temporal records the signal durably and wakes the workflow.
- A short
resolve_toolactivity transforms and persists the result. - The group barrier closes only when every sibling outcome is terminal.
Signals may arrive before the workflow reaches its condition. Temporal retains them in workflow history, so deferred resolution has no registration race and requires no polling.
Temporal gives an activity a durable scheduled/completed boundary. It does not make several external side effects one database transaction. Each activity therefore needs an explicit idempotency expectation.
| Activity | Responsibility | External effects | Idempotency key/expectation |
|---|---|---|---|
start_run |
Open a projected run | Thread/run writes | run_id; safe to observe an existing run |
run_turn |
Produce one agent turn | Message reads/writes, model call, live events | turn_id identifies the logical turn; model calls are not inherently idempotent |
admit_tool |
Classify one call | Tool construction/policy, tool-call write, event | tool_call_id; terminal failure conversion at activity boundary |
execute_tool |
Run one allowed call | Arbitrary tool side effect, tool-call write, event | Tool implementations own external idempotency; automatic retries are disabled |
resolve_tool |
Apply an external resolution | Tool-call write, resolved event | tool_call_id; first workflow signal wins |
finalize_tool_group |
Append tool results | Message writes, resolved events | group_id; message stores must prevent duplicate materialization |
finalize_run |
Close run projection | Run/thread writes, completion event | run_id; terminal writes are repeatable |
apply_thread_cancellation |
Repair open projected state | Run/thread/tool/message writes | Thread identity; explicitly idempotent |
Activity code converts expected tool/admission failures into typed outcomes so one failed tool does not fail the entire workflow task. Infrastructure failures that escape an activity still follow its configured Temporal retry policy.
Names are defined in actant/tools/calls.py; the important semantic
distinction is terminal versus non-terminal.
| State | Meaning | Group may treat this call as resolved? |
|---|---|---|
REQUESTED |
Recorded but not classified | No |
RUNNING |
Allowed and executing | No |
WAITING |
Awaiting an external result | No |
COMPLETED |
Successful result persisted | Yes |
BLOCKED |
Admission rejected; error result persisted | Yes |
FAILED |
Execution/resolution error persisted | Yes |
Failure is terminal, not invisible. The transcript must receive one tool-result message for every tool call the model emitted; otherwise the next provider call would see an invalid assistant/tool-message sequence.
Authoritative for execution: inbox contents, current run, cancellation, pending resolution signals, and whether the group barrier has closed. Workflow code must stay deterministic because Temporal reconstructs it by replay.
Authoritative for product reads: threads, runs, messages, tool-call status, wait requests, and results. APIs and viewers query these stores instead of replaying workflow history.
Projection state describes execution; it must not independently decide that a workflow may continue.
Hooks and stream listeners are low-latency notifications. Consumers must assume events can be missed or duplicated and reload projections on reconnect.
Live events must not become the only mechanism for a durable coordination decision. In particular, parent/subagent completion should ultimately be owned by durable orchestration rather than by an SSE-publishing hook.
An activity-scoped event publisher reports persisted lifecycle facts and implements
StreamListener for provider deltas. Both paths use the same explicit application
EventSink, immutable turn identity, and exception boundary.
provider deltas -----------+
+--> EventSink --> application UI/telemetry
persisted lifecycle ------+
Good consumers include SSE/websocket publication, telemetry, audit feeds, and non-critical notifications. Event adapters should not duplicate canonical message writes. A reconnecting consumer loads stores first and then resumes live event consumption.
AgentRuntime owns submission, observation, and run_worker(). API and execution processes
use the same class with different dependencies. Temporal is the only executor.
An injected client carries namespace, interceptors, TLS, and authentication; Actant does not
open a hidden second connection. Execution resolves agent definitions asynchronously.
Activity groups compose an ActivityContext; none inherits other groups. Media resolution
runs before model requests, outside workflow orchestration. Existing stores and SQL models
remain the canonical transcript and projection interface.
A deferred TaskTool makes a parent tool call wait while another thread runs.
The parent/child relationship includes:
- parent thread ID;
- parent tool-call ID;
- child thread ID;
- child agent identity.
Child lifecycle and streaming events may be dual-published to the root thread
for UI display. Event routing is not the same thing as durable parent
resolution. A RunCompletionHandler runs inside the retryable run-finalization
activity and derives parent linkage from projections. The in-memory registry
can therefore be rebuilt after restart without deciding whether the parent may
resume.
See subagents for the public patterns and coordinator guide for application wiring.
When changing orchestration code, answer these questions in the pull request:
- Which component is authoritative for the new state?
- Can the operation be replayed or retried safely?
- Can a worker disappear after the external effect but before completion is recorded?
- Does every emitted tool call still receive exactly one transcript result?
- Can one waiting sibling prevent another sibling from starting?
- Can the next agent turn begin before every group member is terminal?
- Does cancellation reconcile Temporal and projection state?
- Can the UI reconstruct the same state without receiving live events?