loom.ai.runtime

Live agent runtime: one entered lifecycle, shared clients, bounded runs.

The lifecycle lives in _lifecycle, the shared MCP sessions and their tool-filter validation in _mcp, the per-run limits in _limits and the health vocabulary in _health. This package is the whole public surface.

Nothing here imports FastAPI or Starlette: the HTTP surface lives in loom.ai.fastapi and this package stays usable from any transport.

The classes here are experimental and may change within a major line; the artifact format they run is not. See loom.ai for the distinction.

class loom.ai.runtime.AgentHealth(*, status, checks=mappingproxy({}), detail=None)[source]

Bases: LoomFrozenStruct

Cached health of one agent and of its live dependencies.

Parameters:
status

Aggregate state, the worst of every check.

Type:

Literal[‘ok’, ‘degraded’, ‘unavailable’]

checks

Per-dependency state, keyed "model", "mcp:<server>", "a2a:<agent>" or "sql:<connection>", always by the name the deployment registered rather than by URL. Internal topology: only an authenticated caller ever sees it (FR-029c).

Type:

collections.abc.Mapping[str, str]

detail

Optional explanation, "probing" until the first probe of the background refresher completes.

Type:

str | None

class loom.ai.runtime.AgentRuntime(*, plans, config, engine_provider, deps, container, sql_config=None, mcp_client_factory=None, a2a_client_factory=None, use_case_mcp=())[source]

Bases: object

Owns the live agents of one worker: clients, engines and their limits.

Usable only as an async context manager, and only from the task that entered it: every live client is opened through one AsyncExitStack created in __aenter__, so closing happens in strict reverse order in the same task. That is what keeps a session-affine client (MCP over a framed transport) from being closed by a task that never opened it.

Parameters:
  • plans (Sequence[AgentPlan]) – Compiled plans this worker serves.

  • config (AiConfig) – Deployment configuration of the AI pillar.

  • engine_provider (AgentEngineProvider) – Provider building one engine per plan, exactly once.

  • deps (DepsFactory) – Per-invocation dependency factory handed to every engine.

  • container (LoomContainer) – Application container the engines resolve services from.

  • sql_config (SqlConfig | None) – Live sql: configuration, re-verified at start-up against what the plans were compiled against (FR-046).

  • mcp_client_factory (McpClientFactory | None) – Builds the client of one MCP capability. Required when any plan declares an mcp capability: without it the declared tool filters cannot be validated, so start-up fails closed rather than serving unvalidated grants. A missing factory is a wiring bug, not an offline network, so ai.remote_clients: optional does not tolerate it either.

  • a2a_client_factory (A2AClientFactory | None) – Builds the client of one A2A capability, with the same fail-closed rule.

  • use_case_mcp (Sequence[UseCaseMcpGrant]) – Every Mcp() marker binding a declaring use case carries, already verified and compiled — see UseCaseMcpGrant. Each one’s server joins the set of clients this runtime opens, even when no agent plan names it. A server named only this way stays outside the background health probe: this runtime never reports it as an /health check entry — see _probe_forever(), which iterates the per-plan slots. What a caller observes when such a server is down is decided by mcp_marker_resolver(): a tolerated-unreachable server resolves to a handle whose every call raises TOOL_UNAVAILABLE, never a bind-time failure.

Runs emit no span of their own: the transport owns observability, because only it knows the route, the method and the status code a run is attributed to. Over HTTP that owner is bind_agent_endpoints().

Raises:

AgentCompilationError – From __aenter__, aggregating every start-up failure — an unreachable server (named as the deployment registered it, never by URL; tolerated under ai.remote_clients: optional), a tool filter matching nothing, two grants of one MCP server name describing different connections, or a SQL connection whose read-only state drifted.

Parameters:

Example:

async with AgentRuntime(
    plans=plans, config=ai_config, engine_provider=provider,
    deps=deps, container=container,
) as runtime:
    result = await runtime.run("analyst", prompt, identity=identity)
agent_names()[source]

Return the names of every agent this runtime serves.

Returns:

One name per compiled plan, in the order the plans were given.

Return type:

tuple[str, …]

has_agent(name)[source]

Report whether an agent with that name is served by this runtime.

Parameters:

name (str) – Agent name to look for.

Returns:

True when the runtime holds a plan with that name.

Return type:

bool

has_conversation(name)[source]

Report whether one agent declares a conversation loader.

Parameters:

name (str) – Agent to describe.

Returns:

True when the artifact declares a conversation loader.

Raises:

KeyError – When no agent is named name.

Return type:

bool

capability_kinds(name)[source]

Return the capability kinds one agent was granted.

Lets a transport state what a route exposes without reaching into the compiled plan, which carries resolved handles and instructions.

Parameters:

name (str) – Agent to describe.

Returns:

The distinct kind identifiers of the agent’s capabilities, in declaration order.

Raises:

KeyError – When no agent is named name.

Return type:

tuple[str, …]

async run(name, prompt, *, identity, conversation_id=None, output_type=None, state=None)[source]

Run one agent to completion.

Parameters:
  • name (str) – Agent to run.

  • prompt (str) – Caller prompt.

  • identity (Identity) – Verified caller; every capability call runs as them.

  • conversation_id (str | None) – Opaque value the application supplies; selects the conversation the loader use case receives, when the artifact declares one, and is copied verbatim into the output hook’s command. Never read by loom.

  • output_type (type[Any] | None) – When given, decode this run’s answer into this type instead of the plan’s own declared output (T304); the plan’s own output check does not run. None — the default — runs the plan’s declared shape exactly as before this parameter existed. Not part of AgentEngine’s own run/run_stream, whose signature is pinned to exactly prompt, identity and conversation: an engine opts into shape overrides through the separate, optional run_stream_shaped capability instead (see _grants).

  • state (object | None) – This run’s state, already decoded by whichever boundary received it and passed as a normalised mapping, or None. Resolved against name’s declared shape before this call reaches the engine (FR-009, FR-010): None on a stateful artefact whose every field carries a default carries the shape’s own declared defaults; None against a shape with a field that has no default fails the call, since there is no default to fill it with; a non-None value against an artefact declaring no shape fails the call.

Returns:

The decoded output, the run’s usage, its interaction_id, the output hook’s result and the run’s new messages.

Raises:
  • KeyError – When no agent is named name.

  • ValueError – When conversation_id is empty or longer than CONVERSATION_ID_MAX_LENGTH.

  • AgentRunError – When the run is refused (TOO_MANY_RUNS), the conversation cannot be loaded (CONVERSATION_LOAD_FAILED, CONVERSATION_LOAD_TIMEOUT), state is given against an artefact declaring none (STATE_UNDECLARED), state is omitted against a shape with a field that has no default (STATE_REQUIRED), breaches a declared limit, or ends in a failure event.

Return type:

AgentResult

run_stream(name, prompt, *, identity, conversation_id=None, state=None)[source]

Run one agent, streaming its supervised events.

The stream is an async context manager so the engine connection behind it closes deterministically instead of waiting for the collector.

Parameters:
  • name (str) – Agent to run.

  • prompt (str) – Caller prompt.

  • identity (Identity) – Verified caller; every capability call runs as them.

  • conversation_id (str | None) – Opaque value the application supplies; selects the conversation the loader use case receives, when the artifact declares one, and is copied verbatim into the output hook’s command. Never read by loom.

  • state (object | None) – This run’s state; see run()’s own state for what it carries and how it is resolved against name’s declared shape.

Returns:

An async context manager yielding the limit-supervised events; the terminal event carries the run’s interaction_id.

Raises:
  • KeyError – When no agent is named name.

  • ValueError – On entry, when conversation_id is empty or longer than CONVERSATION_ID_MAX_LENGTH.

  • AgentRunError – On entry, when the worker’s max_concurrent_runs is already taken (TOO_MANY_RUNS), the conversation cannot be loaded (CONVERSATION_LOAD_FAILED, CONVERSATION_LOAD_TIMEOUT), state is given against an artefact declaring none (STATE_UNDECLARED), or state is omitted against a shape with a field that has no default (STATE_REQUIRED).

Return type:

AbstractAsyncContextManager[AsyncIterator[TextDeltaEvent | ToolCallEvent | ToolResultEvent | ErrorEvent | FinalEvent]]

async health(name)[source]

Return the cached health of one agent — never network I/O per call.

Parameters:

name (str) – Agent to report on.

Returns:

The last state the background probe recorded, or a degraded health with detail="probing" before its first pass completes.

Raises:

KeyError – When no agent is named name.

Return type:

AgentHealth

state_shape(name)[source]

Return one agent’s declared state shape.

Read by _decode_state() to parse a request’s raw state bytes against the artefact’s own shape (FR-008), and available before this runtime is entered — a compiled plan already carries its shape, unlike a grant, which is resolved only once the runtime opens its clients.

Parameters:

name (str) – Agent whose declared shape is read.

Returns:

The plan’s StateShape, or None for an artefact declaring neither deps_type nor deps_schema.

Raises:

KeyError – When no agent is named name.

Return type:

StateShape | None

grants(name)[source]

Return one agent’s own resolved grants, built once at start-up.

Parameters:

name (str) – Agent whose grants are read.

Returns:

The immutable AgentGrants this runtime resolved for name when it was entered.

Raises:

KeyError – When no agent is named name.

Return type:

AgentGrants

use_case_mcp_grant(server)[source]

Return the shared substrate grant of one server Mcp() markers declared.

This is the server-keyed substrate — live session, full unfiltered catalogue — every binding on server shares; it is not any one caller’s filtered view, and its own capability carries an explicitly empty include/exclude (see _build_use_case_grants()). A caller’s own filter comes from its own McpBinding, passed to the resolver by the executor at resolution time, never from the value this method returns.

Parameters:

server (str) – MCP server name a marker declared.

Returns:

The resolved shared grant, or None when server was never declared by any use case, or its connection was tolerated as unreachable under ai.remote_clients: optional.

Raises:

RuntimeError – When the runtime was never entered. This follows _require_slot’s own rule rather than calling it: that method calls _require_plan first, which would raise KeyError on a server name that is not an agent name.

Return type:

McpGrant | None

class loom.ai.runtime.ConcurrentMcpSession[source]

Bases: object

Declares an McpSession implementation already safe for concurrent calls.

A JSON-RPC session is one framed stream: two callers writing into it at the same time can interleave their frames, and a caller cancelled mid-call can leave the stream desynchronised for whoever is waiting beside it. That is why a session gets serialised behind one lock by default. A session that already guards its own frames — one that multiplexes concurrent calls by matching each response back to its own request id, rather than writing straight through a single unmatched stream — declares that guarantee by also subclassing this, in addition to implementing McpSession, and the runtime leaves it unwrapped.

This class carries no members: subclassing it is the declaration. A session that does not subclass it is treated exactly as every McpSession was before this class existed — wrapped and serialised — because not declaring the guarantee is the safe default, never a failure.

A subclass keeping this promise must (a) tolerate overlapping calls by matching each response back to its own request id — the multiplexing that makes the guarantee true in the first place — and (b) must not shield a call from its own caller’s cancellation the way the runtime’s locked wrapper does: with no shared frame to desynchronise, there is nothing left to drain, and shielding would only stop the plan’s own tool_timeout_ms from bounding the call.

class loom.ai.runtime.McpSession(*args, **kwargs)[source]

Bases: Protocol

Minimal MCP session the runtime needs from any client library.

Migration (breaking, from v1.16.1): list_tools used to return tool names (tuple[str, ...]) and call_tool used to return the server’s structured content directly (object). Both shapes shipped, so a third-party session implementing this Protocol has to update both methods. list_tools now returns McpToolInfo so a caller can see which tools publish an output schema, and call_tool now returns McpToolCallResult so a caller can see the server’s own failure flag instead of having it silently folded into a successful-looking return. Nothing else about the Protocol moved.

async list_tools()[source]

Return the tools the server exposes.

Returns:

Every tool the server advertises, before any declared filter is applied, each carrying whether it publishes an output schema.

Return type:

tuple[McpToolInfo, …]

async call_tool(name, arguments)[source]

Invoke one tool and return its protocol-level result.

Parameters:
  • name (str) – Tool name as the server exposes it.

  • arguments (Mapping[str, Any]) – Arguments to pass to the tool.

Returns:

The server’s own error flag and structured content, neither interpreted nor decoded.

Return type:

McpToolCallResult

class loom.ai.runtime.SharedMcpSession(session, *, label)[source]

Bases: object

Serialises every call to one MCP session shared by concurrent runs.

A JSON-RPC session is a single framed stream: two overlapping calls interleave their frames, and a caller cancelled mid-frame leaves the session desynchronised for its neighbours. Both are prevented here — one lock per session, and the in-flight call shielded and drained to completion before the lock is released, after which the cancellation is re-raised to the caller that asked for it.

Parameters:
  • session (McpSession) – The live session to guard.

  • label (str) – Human-readable name used in log messages.

async list_tools()[source]

Return the tools the server exposes, serialised with every other call.

Returns:

Every tool the underlying session advertises.

Return type:

tuple[McpToolInfo, …]

async call_tool(name, arguments)[source]

Invoke one tool, serialised with every other call on this session.

Parameters:
  • name (str) – Tool name as the server exposes it.

  • arguments (Mapping[str, Any]) – Arguments to pass to the tool.

Returns:

The tool’s result.

Raises:

asyncio.CancelledError – When the caller is cancelled. The in-flight call still runs to completion, so the session stays usable.

Return type:

McpToolCallResult

class loom.ai.runtime.UseCaseMcpGrant(capability, usecase, parameter)[source]

Bases: object

One verified Mcp() marker binding, compiled into a runtime input.

Built by loom.rest.fastapi.auto (_resolve_ai) once the marker’s server name has already been verified against ai.mcp_servers, and handed to AgentRuntime so the server it names joins the set the runtime opens even when no agent plan declares it. capability carries this binding’s own include — read only by _use_case_filter_issues(), to check it at start-up against the server’s real tool list. At call time the resolver never reads this include back: it receives its own from the resolving McpBinding, via the executor, which is why one instance of this class exists per binding rather than per server, even though start-up and call time end up checking the same value on two different objects. The shared grant a server’s live session and full catalogue are read from is a separate, server-keyed McpGrant built by _build_use_case_grants(), whose own capability carries no include at all. usecase names the declaring use case, and is also carried into FilterTarget.agent for the server this binding lists on its own (_use_case_filter_targets()) — carried, not read: nothing on that path reads the field back. parameter is read in one place only: to name the offending signature when the marker’s own include matches no tool the server publishes.

Parameters: