Architecture#
Corral has three distinct execution values:
Commit log Immutable source of truth
ExecutionState Materialized projection at one commit
AgentContext Authorized view for one agent run
Authored commit ledger#
Every durable event is one small typed commit in SQLite. The store assigns the
timestamp, global execution sequence, branch sequence, parent_hash, and
content hash atomically. Runtime code submits only a CommitRequest containing
an idempotency key, selected branch, based_on_hash, bound actor, and event.
Ordinary activity always extends the selected branch head. A stale
based_on_hash records that an agent or tool worked from an older observation;
it does not create a fork. Divergence is possible only through the explicit
SQLiteCommitStore.create_branch() API.
execution.started
-> task.configured
-> agent.started
-> agent.turn_recorded
-> tool.started
-> tool.completed
-> agent.completed
-> execution.completed
The event reducer is deterministic and reconstructs ExecutionState from the
ledger. Occasional complete projections are stored only as replay accelerators.
They are not independent history records and replay never emits observability
events.
Trusted authors and concurrency#
Every commit carries an ActorRef for a runtime, agent run, or tool invocation.
Agent and tool commits use author-bound store capabilities, so model-produced
content cannot select its own durable author. The SQLite transaction serializes
concurrent commits and advances the branch head only after author checks,
history checks, event reduction, and shared-state preconditions succeed.
Private events can safely append from stale observations. Environment and workspace effects carry expected revision numbers; an overlapping shared write raises a retryable conflict instead of being silently rebased.
One model turn may propose an ordered parallel action group. Each tool gets a stable invocation ID. Completion commits retain real completion order, while an agent's next context presents tool results in declared action order.
Runtime-selected tool transport#
Environment owns the task's tool implementations, resolves the toolset, and
provides the workspace and execution guards. AgentSession.tool_catalog exposes
a validated schema snapshot derived from that environment, together with
applicable runtime/session actions such as submission and subagent inspection.
session.tools returns that catalog in provider format; the catalog's
mcp_tools() method supplies the MCP representation of the same schemas.
Agents request actions through AgentSession, which supplies agent identity,
records commits, and dispatches task-tool execution to the environment. The
session runner in corral.agents.session selects and provisions tool access for
every agent invocation:
tool_transport = "python"uses directsession.execute()calls without an MCP binding. This is the default when an agent omits the declaration.tool_transport = "mcp"asks the runtime to expose the bound catalog through HTTP/MCP. Codex, Claude Code and OpenHands declare this transport.
The runtime supplies a ToolConnection through session.tool_connection.
An external adapter reads its URL and runs the harness:
mcp_url = session.tool_connection.mcp_url
# Configure the harness with mcp_url and return its AgentOutcome.
The adapter owns no listener or server lifecycle. Run adapters through
TaskRuntime or pass a caller-owned mcp_host to run_agent_session() so the
session runner can supply their connection.
Reading mcp_url without a provisioned MCP connection raises an explanatory error.
Each active TaskRuntime.run() attempt owns a host that starts one localhost
HTTP/MCP server when its first MCP invocation requests a binding. Python-only
executions allocate no listener or server task. An MCP delegate or subagent can
start the listener later; concurrent first bindings share the same startup.
Failed or cancelled startup releases its resources and permits a later binding
to retry. Already terminal executions follow the recovery path without creating
a host. The host is local to the call, so concurrent executions on one runtime
have independent listeners and state.
The backend separates that server from its agent bindings:
async with open_mcp_host() as host:
async with host.bind(catalog=catalog, execute=dispatcher) as connection:
...
corral.backend.mcp.open_mcp_host() owns the listener and async server task.
host.bind() starts the listener if needed and registers a catalog and authorized
action dispatcher, with no dependency on agent classes. The runtime supplies
session.execute, preserving
the ordinary authored commit and environment tool execution path. Each binding
has an unguessable route on the shared host and port; requests resolve directly
to their binding. The transport creates no separate tool implementations.
Transport selection also runs for run_delegate() and spawn_subagent().
Delegates sharing a session receive context-local connection details, and their
caller's connection is restored when they return or raise. Subagents and forked
sessions borrow the same host while retaining their own catalogs, identities,
branches and dispatchers. Standalone callers using MCP must own an
open_mcp_host() context and pass its host to run_agent_session(mcp_host=host, ...),
including when a Python parent delegates to an MCP agent. The session runner
borrows that host without creating or closing it. An MCP invocation without a
host raises RuntimeError; Python-only standalone runs may omit it. Host and
connection details are ephemeral; recovery provisions fresh resources against
the saved execution history, and state snapshots contain only data.
The server runs on the execution's event loop and preserves the application's signal handlers. Requests inherit the context captured when their binding was registered, including delegate iteration limits. Synchronous tools continue to run off the event loop through the session's execution path.
When an invocation exits, the runtime revokes its route and drains accepted
requests before recording agent completion. Other bindings remain usable.
Before-task hooks run before binding registration, so a rejecting hook grants
no tool connection and does not start a listener.
At execution teardown, agents and their descendants finish or are cancelled,
their requests are drained, and the server is stopped and awaited before the
final execution commit. Accepting submit_answer does not stop the server: the
HTTP response and the SDK's final processing must finish first. Cancellation
awaits cleanup; a synchronous tool already running in a Python thread must
finish before session resources can be released, with no cleanup timeout that
silently abandons it.
Completion waits for outstanding subagents and propagates unobserved child
failures. A failure already delivered through wait_for_subagent() is not raised
again by cleanup, allowing the caller to catch it and complete with a fallback.
Custom adapters previously opening MCP themselves should declare
tool_transport = "mcp" and consume session.tool_connection.mcp_url.
ToolConnection and ToolResponse live in corral.core.tool.
AgentSession.close() is removed; transport cleanup belongs to the runtime.
Multi-agent context isolation#
ExecutionState partitions conversations, actions, tool invocations, usage,
and algorithm state by agent run. It has no flat global message list.
AgentContextResolver exposes only an agent's own conversation and actions,
tool or subagent results it requested, task context, handoff, and explicitly
imported trace details.
AgentSession.spawn_subagent() appends agent.spawned at the current branch
tip, registers the child, and runs it with its own ActorRef and filtered
context. In the parent's conversation, spawning is rendered exactly like a
tool call and child completion automatically produces the corresponding
tool-style result summary. Spawning is not branching. The child's private
conversation and state remain isolated: a parent may inspect descendants, but
inspection does not change model context. Selected trace details can be added
by import_subagent_context(), which appends context.imported with source
commit hashes for provenance. Children and siblings cannot inspect a parent's
or each other's private trace.
Model-driven inspection is an opt-in session capability. An agent declaring
AgentSessionCapabilities(inspect_subagents=True) receives the general
inspect_subagent tool in addition to its environment tools. Calling it follows
the ordinary action/tool-completion path and returns a bounded child context,
optionally including recent commits. Agents without the capability never see
the schema, and the context resolver still enforces ancestor-only access. AI
Scientist opts in by default.
run_delegate() remains available for composite scaffolds that deliberately
share one identity and context. Explicit experimental histories use
fork_branch().
Recovery and terminal behavior#
An agent.turn_recorded event durably records actions before execution.
tool.started records the stable invocation identity; tool.completed stores
the observation and all environment/workspace/runtime effects atomically. A
retry materializes the current head, finds pending actions, and resumes them
without asking the model to decide again. Repeating a completed action request
returns its existing idempotent commit.
submit_answer is the only canonical answer path and only a root agent run may
submit. The runtime records submission.accepted, the agent outcome, and then
execution.completed. Ordinary actions cannot run after submission, and no
commit may follow execution.completed.
Observability#
Observers receive persisted commits after the transaction succeeds. Langfuse
exports the typed event delta plus commit, author, branch, causal, timing, and
usage metadata; it never receives a complete ExecutionState. Event payloads
are redacted before export, idempotent commit hashes are emitted once, and an
observer failure cannot roll back persistence.
Task and benchmark execution#
execute_task in run.py launches one task. CorralRunner schedules trials
with asyncio, applies retries and concurrency limits, and waits for dependency
outputs. Scheduling runs in the invoking process, while authored commits keep
task state persisted independently.
Why evaluation is separate#
Execution produces a final ExecutionState projection and optional task output.
A scorer evaluates that projection independently and associates its result with
the final commit hash. Benchmark correctness never mutates execution state and
never controls whether downstream task dependencies receive an output.