Skip to content

Latest commit

 

History

History
304 lines (237 loc) · 12.1 KB

File metadata and controls

304 lines (237 loc) · 12.1 KB

Runtime architecture

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.

The shortest accurate model

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.

Read the implementation in this order

  1. actant/runtime/temporal/workflow.py
    • AgentThreadWorkflow.run: lifetime of a thread.
    • AgentThreadWorkflow._run_agent: orchestration for one agent run.
    • AgentThreadWorkflow._run_tool_group: the group barrier.
  2. 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.
  3. actant/runtime/runtime.py
    • AgentRuntime: commands, queries, and worker polling.
  4. actant/runtime/temporal/types.py
    • Serializable payloads crossing workflow/activity boundaries.
  5. actant/runtime/interfaces/stores.py
    • Projection-store contracts.
  6. actant/runtime/stores/postgres/
    • models.py: schema and Alembic metadata.
    • stores.py: queries and transaction boundaries.
    • conversion.py: pure row/domain translation.
  7. 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, run, turn, and group

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 workflow algorithm

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.

Tool-group algorithm

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 resolution

Deferred tools use a durable workflow signal and condition:

  1. Admission persists WAITING and publishes the wait request.
  2. The workflow suspends on workflow.wait_condition; no Python task or worker slot remains occupied.
  3. AgentRuntime.resolve_tool_call signals the owning thread workflow.
  4. Temporal records the signal durably and wakes the workflow.
  5. A short resolve_tool activity transforms and persists the result.
  6. 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.

Activity contracts

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.

Tool-call states and continuation

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.

Three kinds of state

Temporal workflow state

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.

Projection-store state

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.

Live events

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.

Lifecycle and stream events

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.

One runtime

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.

Subagents

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.

Change checklist

When changing orchestration code, answer these questions in the pull request:

  1. Which component is authoritative for the new state?
  2. Can the operation be replayed or retried safely?
  3. Can a worker disappear after the external effect but before completion is recorded?
  4. Does every emitted tool call still receive exactly one transcript result?
  5. Can one waiting sibling prevent another sibling from starting?
  6. Can the next agent turn begin before every group member is terminal?
  7. Does cancellation reconcile Temporal and projection state?
  8. Can the UI reconstruct the same state without receiving live events?