Skip to content

Engine

Agent orchestration, execution loops, task decomposition, routing, and parallel execution.

Agent Engine

agent_engine

Agent engine -- top-level orchestrator.

Ties together prompt construction, execution context, execution loop, tool invocation, and budget tracking into a single run() entry point.

PersonalityTrimNotifier

PersonalityTrimNotifier = Callable[[PersonalityTrimPayload], Awaitable[None]]

Async callback invoked when an agent's personality section is trimmed.

PersonalityTrimPayload

Bases: TypedDict

Structured payload forwarded to :data:PersonalityTrimNotifier callbacks.

AgentEngine

AgentEngine(
    *,
    provider,
    execution_loop=None,
    tool_registry=None,
    cost_tracker=None,
    recovery_strategy=_DEFAULT_RECOVERY_STRATEGY,
    shutdown_checker=None,
    error_taxonomy_config=None,
    classification_sinks=(),
    evolution_service=None,
    policy_engine=None,
    budget_enforcer=None,
    security_config=None,
    security_config_provider=None,
    approval_store=None,
    review_gate=None,
    review_pipeline=None,
    artifact_probe=None,
    clarification_enabled=True,
    scoping_enabled=True,
    parked_context_repo=None,
    cost_forecast_repo=None,
    approval_gate=None,
    mcp_self_consumer=None,
    task_engine=None,
    checkpoint_repo=None,
    heartbeat_repo=None,
    checkpoint_config=None,
    coordinator=None,
    stagnation_detector=None,
    step_classifier=None,
    steering_inbox=None,
    auto_loop_config=None,
    openhands_loop_config=None,
    openhands_loop_deps=None,
    compaction_callback=None,
    provider_registry=None,
    provider_configs=None,
    model_resolver=None,
    tool_invocation_tracker=None,
    memory_injection_strategy_provider=None,
    ontology_injection_strategy=None,
    procedural_memory_config=None,
    capture_strategy=None,
    memory_backend=None,
    distillation_capture_enabled=False,
    config_resolver=None,
    personality_trim_notifier=None,
    coordination_metrics_collector=None,
    audit_log=None,
    project_repo=None,
    agent_middleware_chain=None,
    event_reader=None,
    event_stream_hub=None,
    interrupt_store=None,
    approval_interrupt_timeout_seconds=None,
    external_api_runtime=None,
    forge_tools_runtime=None,
    chat_tools_runtime=None,
    brain_tool_factory_provider=None,
    knowledge_tool_factory_provider=None,
    docs_tool_factory_provider=None,
    research_tool_factory_provider=None,
    structure_map_tool_factory_provider=None,
    capability=None,
    agent_registry=None,
    flight_recorder_sink=None,
    agent_state_repository_provider=None,
    clock=None,
)

Bases: AgentEngineChatActionMixin, AgentEngineContextMixin, AgentEngineErrorsMixin, AgentEngineFactoriesMixin, AgentEngineLoopFactoriesMixin, AgentEnginePostExecMixin, AgentEngineRecoveryMixin, AgentEngineResumeMixin, AgentEngineRunMixin, AgentEngineStakesErrorsMixin

Top-level orchestrator for agent execution.

Source code in src/synthorg/engine/agent_engine.py
def __init__(  # noqa: PLR0913
    self,
    *,
    provider: CompletionProvider,
    execution_loop: ExecutionLoop | None = None,
    tool_registry: ToolRegistry | None = None,
    cost_tracker: CostTrackerProtocol | None = None,
    recovery_strategy: RecoveryStrategy | None = _DEFAULT_RECOVERY_STRATEGY,
    shutdown_checker: ShutdownChecker | None = None,
    error_taxonomy_config: ErrorTaxonomyConfig | None = None,
    classification_sinks: tuple[ClassificationSink, ...] = (),
    evolution_service: EvolutionService | None = None,
    policy_engine: PolicyEngine | None = None,
    budget_enforcer: BudgetEnforcer | None = None,
    security_config: SecurityConfig | None = None,
    security_config_provider: Callable[[], SecurityConfig | None] | None = None,
    approval_store: ApprovalStoreProtocol | None = None,
    review_gate: ReviewGateService | None = None,
    review_pipeline: ReviewPipeline | None = None,
    artifact_probe: ExpectedArtifactProbe | None = None,
    clarification_enabled: bool = True,
    scoping_enabled: bool = True,
    parked_context_repo: ParkedContextRepository | None = None,
    cost_forecast_repo: CostForecastRepository | None = None,
    approval_gate: ApprovalGate | None = None,
    mcp_self_consumer: MCPSelfConsumerProvider | None = None,
    task_engine: TaskEngine | None = None,
    checkpoint_repo: CheckpointRepository | None = None,
    heartbeat_repo: HeartbeatRepository | None = None,
    checkpoint_config: CheckpointConfig | None = None,
    coordinator: MultiAgentCoordinator | None = None,
    stagnation_detector: StagnationDetector | None = None,
    step_classifier: StepQualityClassifier | None = None,
    steering_inbox: SteeringInbox | None = None,
    auto_loop_config: AutoLoopConfig | None = None,
    openhands_loop_config: OpenHandsLoopConfig | None = None,
    openhands_loop_deps: OpenHandsLoopDeps | None = None,
    compaction_callback: CompactionCallback | None = None,
    provider_registry: ProviderRegistry | None = None,
    provider_configs: Mapping[str, ProviderConfig] | None = None,
    model_resolver: ModelResolver | None = None,
    tool_invocation_tracker: ToolInvocationTracker | None = None,
    memory_injection_strategy_provider: MemoryInjectionStrategyProvider
    | None = None,
    ontology_injection_strategy: OntologyInjectionStrategy | None = None,
    procedural_memory_config: ProceduralMemoryConfig | None = None,
    capture_strategy: CaptureStrategy | None = None,
    memory_backend: MemoryBackend | None = None,
    distillation_capture_enabled: bool = False,
    config_resolver: ConfigResolver | None = None,
    personality_trim_notifier: PersonalityTrimNotifier | None = None,
    coordination_metrics_collector: CoordinationMetricsCollector | None = None,
    audit_log: AuditLog | None = None,
    project_repo: ProjectRepository | None = None,
    agent_middleware_chain: AgentMiddlewareChain | None = None,
    event_reader: EventReader | None = None,
    event_stream_hub: EventStreamHub | None = None,
    interrupt_store: InterruptStore | None = None,
    approval_interrupt_timeout_seconds: float | None = None,
    external_api_runtime: ExternalApiRuntime | None = None,
    forge_tools_runtime: ForgeToolsRuntime | None = None,
    chat_tools_runtime: ChatToolsRuntime | None = None,
    brain_tool_factory_provider: BrainToolFactoryProvider | None = None,
    knowledge_tool_factory_provider: KnowledgeToolFactoryProvider | None = None,
    docs_tool_factory_provider: DocsToolFactoryProvider | None = None,
    research_tool_factory_provider: ResearchToolFactoryProvider | None = None,
    structure_map_tool_factory_provider: (
        StructureMapToolFactoryProvider | None
    ) = None,
    capability: CapabilityPolicy | None = None,
    agent_registry: AgentRegistryProtocol | None = None,
    flight_recorder_sink: FlightRecorderSink | None = None,
    agent_state_repository_provider: AgentStateRepositoryProvider | None = None,
    clock: Clock | None = None,
) -> None:
    self._agent_middleware_chain = agent_middleware_chain
    self._event_reader = event_reader
    self._flight_recorder_sink = flight_recorder_sink
    self._agent_state_repository_provider = (
        agent_state_repository_provider or _no_agent_state
    )
    self._clock: Clock = clock if clock is not None else SystemClock()
    self._event_stream_hub = event_stream_hub
    self._interrupt_store = interrupt_store
    if execution_loop is not None and auto_loop_config is not None:
        msg = "execution_loop and auto_loop_config are mutually exclusive"
        logger.warning(
            EXECUTION_ENGINE_ERROR,
            reason=msg,
        )
        raise ValueError(msg)
    self._provider = provider
    self._provider_registry = provider_registry
    self._provider_configs = provider_configs
    self._model_resolver = model_resolver
    self._approval_store = approval_store
    self._review_gate = review_gate
    self._review_pipeline = review_pipeline
    self._artifact_probe = artifact_probe
    self._clarification_enabled = clarification_enabled
    self._scoping_enabled = scoping_enabled
    self._external_api_runtime = external_api_runtime
    self._forge_tools_runtime = forge_tools_runtime
    self._chat_tools_runtime = chat_tools_runtime
    self._brain_tool_factory_provider = brain_tool_factory_provider
    self._knowledge_tool_factory_provider = knowledge_tool_factory_provider
    self._docs_tool_factory_provider = docs_tool_factory_provider
    self._research_tool_factory_provider = research_tool_factory_provider
    self._structure_map_tool_factory_provider = structure_map_tool_factory_provider
    self._parked_context_repo = parked_context_repo
    self._cost_forecast_repo = cost_forecast_repo
    # The boot path constructs one ApprovalGate (backed by the
    # persistence ParkedContextRepository) and injects it so the
    # engine parks and the /approvals controller resumes on the
    # same gate. When absent (standalone / legacy callers) the
    # factory builds a gate from the engine's own collaborators.
    self._injected_approval_gate = approval_gate
    # Agent -> SynthOrg-MCP self-consumer: when wired, the
    # tool-invoker factory adds trust-scoped SynthOrg MCP tools to
    # the agent's registry. ``None`` (mode DISABLED) is a no-op.
    self._mcp_self_consumer = mcp_self_consumer
    self._approval_interrupt_timeout_seconds = approval_interrupt_timeout_seconds
    self._capability = capability
    self._stagnation_detector = stagnation_detector
    self._step_classifier = step_classifier
    self._steering_inbox = steering_inbox
    self._auto_loop_config = auto_loop_config
    self._openhands_loop_config = openhands_loop_config
    self._openhands_loop_deps = openhands_loop_deps
    self._compaction_callback = compaction_callback
    self._approval_gate = self._make_approval_gate()
    if execution_loop is not None and (
        self._approval_gate is not None
        or self._stagnation_detector is not None
        or self._compaction_callback is not None
    ):
        logger.warning(
            APPROVAL_GATE_LOOP_WIRING_WARNING,
            note=(
                "execution_loop provided externally -- approval_gate, "
                "stagnation_detector, and compaction_callback will NOT "
                "be wired automatically. Configure the loop with "
                "approval_gate=, stagnation_detector=, and "
                "compaction_callback= explicitly."
            ),
        )
    self._loop: ExecutionLoop = execution_loop or self._make_default_loop()
    self._tool_registry = tool_registry
    self._budget_enforcer = budget_enforcer
    if (checkpoint_repo is None) != (heartbeat_repo is None):
        msg = (
            "checkpoint_repo and heartbeat_repo must both be "
            "provided or both omitted"
        )
        raise ValueError(msg)
    self._checkpoint_repo = checkpoint_repo
    self._heartbeat_repo = heartbeat_repo
    self._checkpoint_config = checkpoint_config or CheckpointConfig()
    self._cost_tracker: CostTrackerProtocol | None
    if budget_enforcer is not None:
        if (
            cost_tracker is not None
            and cost_tracker is not budget_enforcer.cost_tracker
        ):
            msg = (
                "cost_tracker must match budget_enforcer.cost_tracker "
                "when budget_enforcer is provided"
            )
            raise ValueError(msg)
        self._cost_tracker = budget_enforcer.cost_tracker
    else:
        self._cost_tracker = cost_tracker
    self._security_config = security_config
    # When a provider is wired (boot path), the live security config is
    # read through it per request so operator toggles to
    # security.enabled / audit_enabled / post_tool_scanning_enabled /
    # output_scan_policy_type apply without a restart. Tests / direct
    # construction omit it and fall back to the static ``security_config``.
    self._security_config_provider = security_config_provider
    self._task_engine = task_engine
    self._recovery_strategy = recovery_strategy
    self._shutdown_checker = shutdown_checker
    self._error_taxonomy_config = error_taxonomy_config
    self._classification_sinks = classification_sinks
    self._evolution_service = evolution_service
    self._policy_engine = policy_engine
    self._policy_evaluation_mode = (
        security_config.policy_engine.evaluation_mode
        if security_config is not None
        else "log_only"
    )
    self._coordinator = coordinator
    self._tool_invocation_tracker = tool_invocation_tracker
    self._memory_injection_strategy_provider = memory_injection_strategy_provider
    self._ontology_injection_strategy = ontology_injection_strategy
    self._procedural_memory_config = procedural_memory_config
    self._capture_strategy = capture_strategy
    self._memory_backend = memory_backend
    self._distillation_capture_enabled = distillation_capture_enabled
    self._config_resolver = config_resolver
    self._personality_trim_notifier = personality_trim_notifier
    self._coordination_metrics_collector = coordination_metrics_collector
    self._procedural_proposer: ProceduralMemoryProposer | None = None
    # Constructed regardless of ``enabled`` so the switch stays live: the
    # post-execution hook re-resolves it per capture, and constructing a
    # proposer costs nothing until a capture actually dispatches.
    if procedural_memory_config is not None and memory_backend is not None:
        from synthorg.memory.procedural.proposer import (  # noqa: PLC0415
            ProceduralMemoryProposer,
        )

        self._procedural_proposer = ProceduralMemoryProposer(
            provider=provider,
            config=procedural_memory_config,
        )
    self._audit_log = audit_log if audit_log is not None else AuditLog()
    self._project_repo = project_repo
    self._agent_registry = agent_registry
    # Bound after construction by the boot path (the resolver reads the
    # per-agent level and the initiative mode, both of which the worker
    # layer owns); see ``set_autonomy_resolution``.
    self._autonomy_resolution: AutonomyResolution | None = None
    # Blocking-delegation runner dispatches child runs back through this
    # same engine (``AgentEngine.run`` holds no per-run instance state, so
    # the nested call is re-entrant). Wired only when both the task engine
    # and the agent registry are present; ``None`` disables delegation.
    self._sub_agent_runner: SubAgentRunner | None = None
    if task_engine is not None and agent_registry is not None:
        from synthorg.engine.delegation.runner import (  # noqa: PLC0415
            InProcessSubAgentRunner,
        )

        self._sub_agent_runner = InProcessSubAgentRunner(
            engine=self,
            task_engine=task_engine,
            agent_registry=agent_registry,
        )
    logger.debug(
        EXECUTION_ENGINE_CREATED,
        loop_type=(
            "auto"
            if self._auto_loop_config is not None
            else self._loop.get_loop_type()
        ),
        has_tool_registry=self._tool_registry is not None,
        has_cost_tracker=self._cost_tracker is not None,
        has_budget_enforcer=self._budget_enforcer is not None,
        has_coordinator=self._coordinator is not None,
        has_compaction_callback=self._compaction_callback is not None,
        has_openhands_loop_deps=self._openhands_loop_deps is not None,
        has_personality_trim_notifier=self._personality_trim_notifier is not None,
        has_sub_agent_runner=self._sub_agent_runner is not None,
    )

coordinator property

coordinator

Return the multi-agent coordinator, or None if not configured.

has_mcp_self_consumer property

has_mcp_self_consumer

Whether trust-scoped SynthOrg MCP tools are wired into agents.

Gates the direct-MCP conversational actor: with no self-consumer an acting agent has no MCP tools, so /meta/chat/act 503s.

set_autonomy_resolution

set_autonomy_resolution(resolution)

Bind the one resolver every dispatch path asks for autonomy.

Called by the boot path once the worker execution service exists. Until it is bound, a caller that supplies no autonomy runs degraded, which is what a coordinated wave did permanently.

Parameters:

Name Type Description Default
resolution AutonomyResolution

The single owner of "what autonomy governs this run", asked whenever :meth:run is called without one.

required
Source code in src/synthorg/engine/agent_engine.py
def set_autonomy_resolution(self, resolution: AutonomyResolution) -> None:
    """Bind the one resolver every dispatch path asks for autonomy.

    Called by the boot path once the worker execution service exists.
    Until it is bound, a caller that supplies no autonomy runs
    degraded, which is what a coordinated wave did permanently.

    Args:
        resolution: The single owner of "what autonomy governs this
            run", asked whenever :meth:`run` is called without one.
    """
    self._autonomy_resolution = resolution

coordinate async

coordinate(context)

Delegate to the multi-agent coordinator.

Returns:

Name Type Description
The CoordinationResultWithAttribution

class:CoordinationResultWithAttribution from the

CoordinationResultWithAttribution

coordinator's coordinate() call.

Raises:

Type Description
ExecutionStateError

If no coordinator was configured.

Source code in src/synthorg/engine/agent_engine.py
async def coordinate(
    self,
    context: CoordinationContext,
) -> CoordinationResultWithAttribution:
    """Delegate to the multi-agent coordinator.

    Returns:
        The :class:`CoordinationResultWithAttribution` from the
        coordinator's ``coordinate()`` call.

    Raises:
        ExecutionStateError: If no coordinator was configured.
    """
    if self._coordinator is None:
        msg = "No coordinator configured for multi-agent dispatch"
        logger.warning(
            EXECUTION_ENGINE_ERROR,
            error=msg,
        )
        raise ExecutionStateError(msg)
    return await self._coordinator.coordinate(context)

project_background_failure async

project_background_failure(*, task_id, agent_id)

Project a terminal RUN_ERROR for a run that failed before the loop.

A backgrounded conversational run can fail in the pipeline spine (project resolution, decomposition, assignment) before the execution loop ever runs to publish its own terminal frame, leaving a dashboard subscribed to the task's SSE stream hung on "Working". Called by the worker's background wrapper on such a failure so the operator sees the run end. No-op when no event-stream hub is wired.

Source code in src/synthorg/engine/agent_engine.py
async def project_background_failure(self, *, task_id: str, agent_id: str) -> None:
    """Project a terminal RUN_ERROR for a run that failed before the loop.

    A backgrounded conversational run can fail in the pipeline spine
    (project resolution, decomposition, assignment) before the execution
    loop ever runs to publish its own terminal frame, leaving a dashboard
    subscribed to the task's SSE stream hung on "Working". Called by the
    worker's background wrapper on such a failure so the operator sees the
    run end. No-op when no event-stream hub is wired.
    """
    hub = self._event_stream_hub
    if hub is None:
        return
    await publish_run_terminated(
        hub, task_id=task_id, agent_id=agent_id, reason=TerminationReason.ERROR
    )

run async

run(
    *,
    identity,
    task,
    completion_config=None,
    max_turns=None,
    memory_messages=(),
    timeout_seconds=None,
    effective_autonomy=None,
    resume_execution_id=None,
)

Execute an agent on a task.

Returns:

Name Type Description
The AgentRunResult

class:AgentRunResult from the loop, with cost

AgentRunResult

tracking, post-execution transitions, and recovery /

AgentRunResult

checkpoint resume applied.

Raises:

Type Description
MemoryError

Re-raised after logging from the explicit log-and-raise critical-error path (the engine surfaces non-recoverable interpreter signals to the worker).

RecursionError

Same path as MemoryError.

ProjectNotFoundError

From project validation when the task references a missing project.

Source code in src/synthorg/engine/agent_engine.py
async def run(
    self,
    *,
    identity: AgentIdentity,
    task: Task,
    completion_config: CompletionConfig | None = None,
    max_turns: int | None = None,
    memory_messages: tuple[ChatMessage, ...] = (),
    timeout_seconds: float | None = None,
    effective_autonomy: EffectiveAutonomy | None = None,
    resume_execution_id: str | None = None,
) -> AgentRunResult:
    """Execute an agent on a task.

    Returns:
        The :class:`AgentRunResult` from the loop, with cost
        tracking, post-execution transitions, and recovery /
        checkpoint resume applied.

    Raises:
        MemoryError: Re-raised after logging from the explicit
            log-and-raise critical-error path (the engine surfaces
            non-recoverable interpreter signals to the worker).
        RecursionError: Same path as ``MemoryError``.
        ProjectNotFoundError: From project validation when the
            task references a missing project.
    """
    agent_id = str(identity.id)
    task_id = str(task.id)
    if max_turns is None:
        max_turns = await self._resolve_max_turns(
            agent_id=agent_id, task_id=task_id
        )

    validate_run_inputs(
        agent_id=agent_id,
        task_id=task_id,
        max_turns=max_turns,
        timeout_seconds=timeout_seconds,
    )
    validate_agent(identity, agent_id)
    validate_task(task, agent_id, task_id)
    validate_task_metadata(task, agent_id, task_id)

    with (
        correlation_scope(
            agent_id=agent_id,
            task_id=task_id,
            project_id=task.project,
        ),
        ExitStack() as run_scopes,
    ):
        start = self._clock.monotonic()
        ctx: AgentContext | None = None
        system_prompt: SystemPrompt | None = None
        provider: CompletionProvider = self._provider
        _project_budget: float = 0.0
        try:
            # Entered here rather than in the `with` header above so a
            # capture failure lands inside the fatal-error boundary. The
            # probe deliberately propagates everything that is not storage
            # I/O, and outside the boundary that left the run with no
            # terminal projection at all: no FAILED, nothing to replan.
            run_scopes.enter_context(
                artifact_baseline_scope(
                    await capture_run_baseline(
                        self._artifact_probe,
                        project_id=task.project,
                        expected=task.artifacts_expected,
                    )
                )
            )
            # Dispatch to the provider serving this agent's own model,
            # which nothing downstream re-points; a registry miss (agent
            # pinned to an unregistered provider) fails the run here
            # rather than mis-dispatching to the engine default.
            provider = self._dispatch_client_for(identity, self._provider)
            if effective_autonomy is None:
                effective_autonomy = await self._effective_autonomy_for(
                    identity, task_id=task_id, project_id=task.project
                )
            loop_mode = (
                "auto"
                if self._auto_loop_config is not None
                else self._loop.get_loop_type()
            )
            logger.info(
                EXECUTION_ENGINE_START,
                agent_id=agent_id,
                task_id=task_id,
                loop_type=loop_mode,
                max_turns=max_turns,
            )

            provider, identity, completion_config = await self._bind_run(
                identity=identity,
                task=task,
                provider=provider,
                completion_config=completion_config,
            )

            if self._project_repo is not None:
                _project_budget = await self._validate_project(
                    task=task,
                    agent_id=agent_id,
                    task_id=task_id,
                )
            elif task.project:
                # Fail loud for a work task (aborts to the fatal-error
                # boundary, which terminates the task FAILED) rather than
                # running it unvalidated against an unconfigured repo.
                self._reject_unconfigured_project_repo(
                    task=task,
                    agent_id=agent_id,
                    task_id=task_id,
                )

            replay_ctx: AgentContext | None = None
            if resume_execution_id is not None:
                replay_ctx = await self._replay_session(
                    resume_execution_id=resume_execution_id,
                    identity=identity,
                    task=task,
                    max_turns=max_turns,
                )

            # Once for the whole task. The reconciler can replace a
            # backend between these two calls, and a task whose tools
            # came from one strategy while its context came from another
            # would recall against a backend its tools do not write to.
            memory_strategy = self._resolve_memory_strategy()
            tool_invoker = self._make_tool_invoker(
                identity,
                task_id=task_id,
                effective_autonomy=effective_autonomy,
                project_id=task.project,
                memory_strategy=memory_strategy,
            )
            ctx, system_prompt = await self._prepare_context(
                identity=identity,
                task=task,
                agent_id=agent_id,
                task_id=task_id,
                max_turns=max_turns,
                memory=MemoryContextInputs(
                    messages=memory_messages, strategy=memory_strategy
                ),
                tool_invoker=tool_invoker,
                effective_autonomy=effective_autonomy,
            )
            if replay_ctx is not None:
                ctx = self._merge_replayed(ctx, replay_ctx)
            # Bind the run identity (same execution_id flight frames
            # carry) so capture leaves tag records the receipt joins on.
            with run_identity_scope(
                execution_id=ctx.execution_id,
                task_id=task_id,
                project_id=task.project,
            ):
                return await self._execute(
                    AgentExecuteRequest(
                        identity=identity,
                        task=task,
                        agent_id=agent_id,
                        task_id=task_id,
                        completion_config=completion_config,
                        ctx=ctx,
                        system_prompt=system_prompt,
                        start=start,
                        timeout_seconds=timeout_seconds,
                        tool_invoker=tool_invoker,
                        effective_autonomy=effective_autonomy,
                        provider=provider,
                        project_budget=_project_budget,
                    )
                )
        except (MemoryError, RecursionError) as exc:
            log_exception_redacted(
                logger,
                EXECUTION_ENGINE_ERROR,
                exc,
                agent_id=agent_id,
                task_id=task_id,
                reason="non-recoverable error in run()",
            )
            raise
        except ProjectNotFoundError:
            raise
        except BudgetExhaustedError as exc:
            budget_result = await self._handle_budget_error(
                exc=exc,
                identity=identity,
                task=task,
                agent_id=agent_id,
                task_id=task_id,
                duration_seconds=self._clock.monotonic() - start,
                ctx=ctx,
                system_prompt=system_prompt,
            )
            # Project the terminal the budget handler actually selected: a
            # parked hard-ceiling crossing is PARKED (silent -- the pause
            # surfaces via the approval-interrupt projection), a plain
            # controlled stop is BUDGET_EXHAUSTED (RUN_ERROR). The inner
            # handler skips RUN_ERROR for a budget error precisely so a
            # parked run is never projected as failed.
            budget_hub = self._event_stream_hub
            if budget_hub is not None:
                await publish_run_terminated(
                    budget_hub,
                    task_id=task_id,
                    agent_id=agent_id,
                    reason=budget_result.execution_result.termination_reason,
                )
            return budget_result
        except StakesModelUnavailableError as exc:
            return await self._handle_stakes_unavailable(
                exc=exc,
                identity=identity,
                task=task,
                agent_id=agent_id,
                task_id=task_id,
                duration_seconds=self._clock.monotonic() - start,
                ctx=ctx,
                system_prompt=system_prompt,
                completion_config=completion_config,
                effective_autonomy=effective_autonomy,
                provider=provider,
            )
        except Exception as exc:  # noqa: BLE001 -- engine fatal-error boundary
            # lint-allow: swallow-ok -- fatal-error boundary returns FAILED
            return await self._handle_fatal_error(
                exc=exc,
                identity=identity,
                task=task,
                agent_id=agent_id,
                task_id=task_id,
                duration_seconds=self._clock.monotonic() - start,
                ctx=ctx,
                system_prompt=system_prompt,
                completion_config=completion_config,
                effective_autonomy=effective_autonomy,
                provider=provider,
            )

Execution Loop Protocol

loop_protocol

Execution loop protocol and supporting models.

Defines the ExecutionLoop protocol that the agent engine calls to run a task, along with ExecutionResult, TerminationReason, and the BudgetChecker and ShutdownChecker type aliases. TurnRecord is imported from synthorg.execution.turn (the engine-free leaf) and re-exported here for callers.

BudgetChecker module-attribute

BudgetChecker = Callable[[AgentContext], bool]

Callback that returns True when the budget is exhausted.

ShutdownChecker module-attribute

ShutdownChecker = Callable[[], bool]

Callback that returns True when a graceful shutdown has been requested.

TaskCancellationChecker module-attribute

TaskCancellationChecker = Callable[[], Awaitable[bool]]

Async callback that returns True when the running task has been cancelled or superseded externally (e.g. by a steering supersession or a cockpit kill).

Consulted at the top-of-turn safe boundary so the agent halts cleanly instead of running an obsolete task to completion. The task's terminal DB status is the durable cross-process signal (the operator cancels in the API process; the agent runs in the worker process).

TurnObserver module-attribute

TurnObserver = Callable[[TurnProgress], Awaitable[None]]

Async progress callback invoked with a :class:TurnProgress. Two calling conventions share this shape:

  • ReAct loop: fires after each continuing turn with the tool names that turn requested; the terminal turn (which ends the loop) returns before the hook, so no observation marks it.
  • OpenHands loop: fires as each event arrives off the harness stream, with a one-element tuple naming the tool the event used, or empty when the event named none.

Purely observational: it never affects control flow, and an observer raising must not corrupt the run. Used to surface incremental progress on a streamed chat action and to keep the live-activity state current; None disables it.

TerminationReason

Bases: StrEnum

Why the execution loop terminated.

NO_OP class-attribute instance-attribute

NO_OP = 'no_op'

A task-backed run that finished without calling any tool, so it produced no artifacts. A silent no-op success is a failure: the run is routed to FAILED unless an explicit no-op justification was recorded (see engine.task_sync).

ExecutionResult pydantic-model

Bases: BaseModel

Result returned by an execution loop.

Attributes:

Name Type Description
context AgentContext

Final agent context after execution.

termination_reason TerminationReason

Why the loop stopped.

turns tuple[TurnRecord, ...]

Per-turn metadata records.

total_tool_calls int

Total tool calls across all turns (computed).

error_message str | None

Error description when termination_reason is ERROR.

metadata dict[str, object]

Forward-compatible dict for future loop types. Note: frozen=True prevents field reassignment but not in-place mutation of the dict contents; deep-copy at system boundaries per project conventions.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _deep_copy_metadata
  • _validate_error_message

context pydantic-field

context

Final agent context

termination_reason pydantic-field

termination_reason

Why the loop stopped

turns pydantic-field

turns = ()

Per-turn metadata

quality_signals pydantic-field

quality_signals = ()

Per-step quality signals produced during the loop

error_message pydantic-field

error_message = None

Error description (when reason is ERROR)

error_type pydantic-field

error_type = None

Class name of the exception that terminated the run

metadata pydantic-field

metadata

Forward-compatible metadata for future loop types

total_tool_calls property

total_tool_calls

Sum of tool calls from all turn records.

TurnProgress

Bases: NamedTuple

What a loop reports about one turn while the run is still going.

The context is carried because everything an operator wants to know about a run in flight (how many turns, how much spend, when it last did anything) lives on it and nowhere else until the run finishes, so a report without it can say only that a turn happened.

The context carries the run's whole conversation, which is agent-authored and holds tool results from outside the system. It is fenced where it is STORED, not here, so an observer that puts any of it into a prompt (a narration call, a summary, an LLM-scored dashboard) owes it a wrap_untrusted at that boundary, exactly as the review gate's own inputs do. The observers shipped today read scalars only (turn count, spend, timestamps, tool names), so none of them needs one.

Attributes:

Name Type Description
turn_number int

1-based index of the turn just observed.

tool_names tuple[str, ...]

Short labels for what that turn did.

context AgentContext

The run's context as it stands after the turn. Untrusted content: see above before putting any of it in a prompt.

ExecutionLoop

Bases: Protocol

Protocol for agent execution loops.

The agent engine calls execute to run a task through the loop. Implementations decide the control flow but all return an ExecutionResult with a TerminationReason.

execute async

execute(
    *,
    context,
    provider,
    tool_invoker=None,
    budget_checker=None,
    shutdown_checker=None,
    completion_config=None,
    task_cancellation_checker=None,
    turn_observer=None,
    streaming_enabled=False,
)

Run the execution loop.

Parameters:

Name Type Description Default
context AgentContext

Initial agent context with conversation and identity.

required
provider CompletionProvider

LLM completion provider.

required
tool_invoker ToolInvokerProtocol | None

Optional tool invoker for tool execution.

None
budget_checker BudgetChecker | None

Optional callback; returns True when budget is exhausted.

None
shutdown_checker ShutdownChecker | None

Optional callback; returns True when a graceful shutdown has been requested.

None
completion_config CompletionConfig | None

Optional per-execution override for temperature/max_tokens (defaults to identity's model config).

None
task_cancellation_checker TaskCancellationChecker | None

Optional async callback; returns True when the running task was cancelled or superseded externally, so the loop halts at the next safe boundary.

None
turn_observer TurnObserver | None

Optional per-run progress callback; used to project live execution progress onto the AG-UI stream and to keep the live-activity state current. Awaited once per turn with a single :class:TurnProgress.

None
streaming_enabled bool

When True, each per-turn LLM call streams and is interruptible mid-flight (operator cancellation and steering REDIRECT); otherwise a non-streaming call is used.

False

Returns:

Type Description
ExecutionResult

Execution result with final context and termination reason.

Source code in src/synthorg/engine/loop_protocol.py
async def execute(  # noqa: PLR0913
    self,
    *,
    context: AgentContext,
    provider: CompletionProvider,
    tool_invoker: ToolInvokerProtocol | None = None,
    budget_checker: BudgetChecker | None = None,
    shutdown_checker: ShutdownChecker | None = None,
    completion_config: CompletionConfig | None = None,
    task_cancellation_checker: TaskCancellationChecker | None = None,
    turn_observer: TurnObserver | None = None,
    streaming_enabled: bool = False,
) -> ExecutionResult:
    """Run the execution loop.

    Args:
        context: Initial agent context with conversation and identity.
        provider: LLM completion provider.
        tool_invoker: Optional tool invoker for tool execution.
        budget_checker: Optional callback; returns ``True`` when
            budget is exhausted.
        shutdown_checker: Optional callback; returns ``True`` when
            a graceful shutdown has been requested.
        completion_config: Optional per-execution override for
            temperature/max_tokens (defaults to identity's model config).
        task_cancellation_checker: Optional async callback; returns
            ``True`` when the running task was cancelled or superseded
            externally, so the loop halts at the next safe boundary.
        turn_observer: Optional per-run progress callback; used to
            project live execution progress onto the AG-UI stream and to
            keep the live-activity state current. Awaited once per turn
            with a single :class:`TurnProgress`.
        streaming_enabled: When ``True``, each per-turn LLM call streams
            and is interruptible mid-flight (operator cancellation and
            steering REDIRECT); otherwise a non-streaming call is used.

    Returns:
        Execution result with final context and termination reason.
    """
    ...

get_loop_type

get_loop_type()

Return the loop type identifier (e.g. "react").

Returns:

Type Description
str

The loop's type discriminator string.

Source code in src/synthorg/engine/loop_protocol.py
def get_loop_type(self) -> str:
    """Return the loop type identifier (e.g. ``"react"``).

    Returns:
        The loop's type discriminator string.
    """
    ...

make_budget_checker

make_budget_checker(task)

Create a budget checker if the task carries either bound.

The returned callable returns True when accumulated cost meets the task's money limit OR accumulated tokens meet its token ceiling. The token half matters because money measures nothing against a provider that bills by flat subscription, where the cost bound can never fire.

Returns:

Name Type Description
A BudgetChecker | None

class:BudgetChecker closure over task.budget_limit and

BudgetChecker | None

task.hard_token_ceiling; None when the task carries neither.

Source code in src/synthorg/engine/loop_protocol.py
def make_budget_checker(task: Task) -> BudgetChecker | None:
    """Create a budget checker if the task carries either bound.

    The returned callable returns ``True`` when accumulated cost meets the
    task's money limit OR accumulated tokens meet its token ceiling. The
    token half matters because money measures nothing against a provider
    that bills by flat subscription, where the cost bound can never fire.

    Returns:
        A :class:`BudgetChecker` closure over ``task.budget_limit`` and
        ``task.hard_token_ceiling``; ``None`` when the task carries neither.
    """
    return build_session_budget_checker(
        SessionCeilings.of(
            cost_ceiling=task.budget_limit,
            token_ceiling=task.hard_token_ceiling,
        )
    )

ReAct Loop

react_loop

ReAct execution loop -- think, act, observe.

Implements the ExecutionLoop protocol using the ReAct pattern: check shutdown -> check budget -> call LLM -> record turn -> check for LLM errors -> update context -> handle completion or (check shutdown -> execute tools) -> repeat.

ReactLoop

ReactLoop(
    checkpoint_callback=None,
    *,
    approval_gate=None,
    stagnation_detector=None,
    compaction_callback=None,
    steering_inbox=None,
    step_classifier=None,
    turn_observer=None,
    clock=None,
)

ReAct execution loop: reason, act, observe.

The loop checks for shutdown, checks the budget, calls the LLM, checks for termination conditions, executes any requested tools, feeds results back, and repeats until the LLM signals completion, the turn limit is reached, the budget is exhausted, a shutdown is requested, or an error occurs.

Parameters:

Name Type Description Default
checkpoint_callback CheckpointCallback | None

Optional async callback invoked after each completed turn; the callback itself decides whether to persist.

None
approval_gate ApprovalGate | None

Optional gate that checks for pending escalations after tool execution and parks the agent when approval is required. None disables approval checks.

None
stagnation_detector StagnationDetector | None

Optional detector that checks for repetitive tool-call patterns and intervenes with corrective prompts or early termination. None disables stagnation detection.

None
compaction_callback CompactionCallback | None

Optional async callback invoked at turn boundaries to compress older conversation turns when the context fill level is high. None disables compaction.

None
step_classifier StepQualityClassifier | None

Optional step-quality classifier. ReAct is turn-based with no step boundary, so a single whole-run signal is emitted at natural termination; None disables quality classification.

None
steering_inbox SteeringInbox | None

Optional inbox polled at turn boundaries for mid-run steering messages; None disables steering.

None
turn_observer TurnObserver | None

Optional async callback invoked after each continuing turn with the tools it requested; None disables it. Purely observational.

None
Source code in src/synthorg/engine/react_loop.py
def __init__(
    self,
    checkpoint_callback: CheckpointCallback | None = None,
    *,
    approval_gate: ApprovalGate | None = None,
    stagnation_detector: StagnationDetector | None = None,
    compaction_callback: CompactionCallback | None = None,
    steering_inbox: SteeringInbox | None = None,
    step_classifier: StepQualityClassifier | None = None,
    turn_observer: TurnObserver | None = None,
    clock: Clock | None = None,
) -> None:
    self._checkpoint_callback = checkpoint_callback
    self._approval_gate = approval_gate
    self._stagnation_detector = stagnation_detector
    self._compaction_callback = compaction_callback
    self._steering_inbox = steering_inbox
    self._step_classifier = step_classifier
    self._turn_observer = turn_observer
    self._clock: Clock = clock if clock is not None else SystemClock()

approval_gate property

approval_gate

Return the approval gate, or None.

stagnation_detector property

stagnation_detector

Return the stagnation detector, or None.

compaction_callback property

compaction_callback

Return the compaction callback, or None.

steering_inbox property

steering_inbox

Return the steering inbox, or None.

get_loop_type

get_loop_type()

Return the loop type identifier.

Source code in src/synthorg/engine/react_loop.py
def get_loop_type(self) -> str:
    """Return the loop type identifier."""
    return "react"

execute async

execute(
    *,
    context,
    provider,
    tool_invoker=None,
    budget_checker=None,
    shutdown_checker=None,
    completion_config=None,
    task_cancellation_checker=None,
    turn_observer=None,
    streaming_enabled=False,
)

Run the ReAct loop until termination.

Parameters:

Name Type Description Default
context AgentContext

Initial agent context with conversation.

required
provider CompletionProvider

LLM completion provider.

required
tool_invoker ToolInvokerProtocol | None

Optional tool invoker for tool execution.

None
budget_checker BudgetChecker | None

Optional budget exhaustion callback.

None
shutdown_checker ShutdownChecker | None

Optional callback; returns True when a graceful shutdown has been requested.

None
completion_config CompletionConfig | None

Optional per-execution config override.

None
task_cancellation_checker TaskCancellationChecker | None

Optional async callback; returns True when the task was cancelled/superseded externally.

None
turn_observer TurnObserver | None

Optional per-run progress callback; when given, it takes precedence over the construction-time observer so a per-execution stream (e.g. AG-UI task progress) can be wired without rebuilding the shared loop.

None
streaming_enabled bool

When True, each per-turn LLM call streams and is interruptible mid-flight (operator cancellation and steering REDIRECT); otherwise a non-streaming call is used.

False

Returns:

Type Description
ExecutionResult

Execution result with final context and termination info.

Raises:

Type Description
MemoryError

Re-raised unconditionally (non-recoverable).

RecursionError

Re-raised unconditionally (non-recoverable).

Source code in src/synthorg/engine/react_loop.py
async def execute(  # noqa: PLR0913
    self,
    *,
    context: AgentContext,
    provider: CompletionProvider,
    tool_invoker: ToolInvokerProtocol | None = None,
    budget_checker: BudgetChecker | None = None,
    shutdown_checker: ShutdownChecker | None = None,
    completion_config: CompletionConfig | None = None,
    task_cancellation_checker: TaskCancellationChecker | None = None,
    turn_observer: TurnObserver | None = None,
    streaming_enabled: bool = False,
) -> ExecutionResult:
    """Run the ReAct loop until termination.

    Args:
        context: Initial agent context with conversation.
        provider: LLM completion provider.
        tool_invoker: Optional tool invoker for tool execution.
        budget_checker: Optional budget exhaustion callback.
        shutdown_checker: Optional callback; returns ``True`` when
            a graceful shutdown has been requested.
        completion_config: Optional per-execution config override.
        task_cancellation_checker: Optional async callback; returns
            ``True`` when the task was cancelled/superseded externally.
        turn_observer: Optional per-run progress callback; when given,
            it takes precedence over the construction-time observer so
            a per-execution stream (e.g. AG-UI task progress) can be
            wired without rebuilding the shared loop.
        streaming_enabled: When ``True``, each per-turn LLM call streams
            and is interruptible mid-flight (operator cancellation and
            steering REDIRECT); otherwise a non-streaming call is used.

    Returns:
        Execution result with final context and termination info.

    Raises:
        MemoryError: Re-raised unconditionally (non-recoverable).
        RecursionError: Re-raised unconditionally (non-recoverable).
    """
    model_id, config, tool_defs, turns = self._prepare_loop(
        context, completion_config, tool_invoker
    )
    ctx = context
    corrections_injected = 0
    effective_observer = turn_observer or self._turn_observer

    # Bounded by the turn budget and its extensions; every iteration
    # re-checks shutdown, task cancellation and the cost budget below.
    # lint-allow: long-running-loop-kill-switch -- turn-budget bounded
    while True:
        if not ctx.has_turns_remaining:
            # The ceiling is a backstop against a pathological loop, not
            # a verdict on work that is taking longer than the estimate.
            # Carry on while there are extensions left; park only once
            # they are spent, so nothing is discarded either way.
            extended = grant_extension(ctx, turns)
            if extended is None:
                break
            ctx = extended
        shutdown_result = check_shutdown(ctx, shutdown_checker, turns)
        if shutdown_result is not None:
            return await self._attach_whole_run_signals(shutdown_result, turns)

        budget_result = check_budget(ctx, budget_checker, turns)
        if budget_result is not None:
            return await self._attach_whole_run_signals(budget_result, turns)

        cancel_result = await check_task_cancelled(
            ctx, task_cancellation_checker, turns
        )
        if cancel_result is not None:
            return await self._attach_whole_run_signals(cancel_result, turns)

        # Adopt any pending steering directives before the LLM call so
        # the operator's constraint is in context for this turn.
        steered = await check_steering(ctx, self._steering_inbox)
        if steered is not None:
            ctx = steered

        # Refresh tool defs each turn so newly loaded tools appear
        tool_defs = get_tool_definitions(tool_invoker, ctx.loaded_tools)

        turn_number = ctx.turn_count + 1
        outcome = await run_provider_turn(
            ctx,
            provider,
            model_id,
            tool_defs=tool_defs,
            config=config,
            turns=turns,
            streaming_enabled=streaming_enabled,
            watch=InterruptWatch(
                cancellation_checker=task_cancellation_checker,
                steering_inbox=self._steering_inbox,
                clock=self._clock,
            ),
        )
        if isinstance(outcome, ExecutionResult):
            return await self._attach_whole_run_signals(outcome, turns)
        if isinstance(outcome, _TurnInterrupted):
            # A steering REDIRECT aborted the in-flight call; fold the
            # partial usage and re-issue the turn so the top-of-loop
            # steering check adopts the directive into context.
            ctx = fold_interrupt_usage(ctx, outcome)
            continue
        response = outcome

        turns.append(
            make_turn_record(
                turn_number,
                response,
                call_category=classify_turn(turn_number, response, ctx),
                provider_metadata=response.provider_metadata,
            )
        )

        result = await self._process_turn_response(
            ctx,
            response,
            turn_number=turn_number,
            turns=turns,
            tool_invoker=tool_invoker,
            shutdown_checker=shutdown_checker,
        )
        if isinstance(result, ExecutionResult):
            return await self._attach_whole_run_signals(result, turns)
        ctx = result

        await self._notify_turn_observer(
            turn_number, response, effective_observer, ctx
        )

        # Before the fingerprint detector, because this signal survives
        # drifting arguments: a turn whose every tool call resolved to
        # nothing ran nothing, and a run doing only that has no way back.
        unresolved = unresolved_tools_result(ctx, turns)
        if unresolved is not None:
            return await self._attach_whole_run_signals(unresolved, turns)

        # Stagnation detection after successful turn processing
        stag_outcome = await check_stagnation(
            ctx,
            self._stagnation_detector,
            turns,
            corrections_injected,
        )
        if isinstance(stag_outcome, ExecutionResult):
            return await self._attach_whole_run_signals(stag_outcome, turns)
        if isinstance(stag_outcome, tuple):
            ctx, corrections_injected = stag_outcome

        # Context compaction at turn boundaries
        compacted = await invoke_compaction(
            ctx,
            self._compaction_callback,
            turn_number,
        )
        if compacted is not None:
            ctx = compacted

    return await self._attach_whole_run_signals(
        ceiling_result(ctx, turns),
        turns,
    )

OpenHands Loop

loop

The OpenHands adapter: the bundled ExecutionLoop.

Drives an OpenHands conversation through the injected factory, maps its event stream to TurnRecords, and consults the budget / shutdown / cancellation checkers at each turn boundary (after recording a turn event), stopping the run (via the sink's False return) when any trips. Completion honours the same NO_OP / artifacts_expected rule as the native loops. All logic is independent of the SDK, which lives behind the conversation factory.

OpenHandsLoop

OpenHandsLoop(*, config, deps)

Runs a task through the OpenHands coding agent as an ExecutionLoop.

Parameters:

Name Type Description Default
config OpenHandsLoopConfig

Frozen, settings-driven behaviour.

required
deps OpenHandsLoopDeps

Injected collaborators (conversation factory, signer, URLs, clock).

required
Source code in src/synthorg/engine/openhands/loop.py
def __init__(self, *, config: OpenHandsLoopConfig, deps: OpenHandsLoopDeps) -> None:
    self._config = config
    self._deps = deps

get_loop_type

get_loop_type()

Return the loop discriminator.

Returns:

Type Description
str

The string "openhands".

Source code in src/synthorg/engine/openhands/loop.py
def get_loop_type(self) -> str:
    """Return the loop discriminator.

    Returns:
        The string ``"openhands"``.
    """
    return _LOOP_TYPE

execute async

execute(
    *,
    context,
    provider,
    tool_invoker=None,
    budget_checker=None,
    shutdown_checker=None,
    completion_config=None,
    task_cancellation_checker=None,
    turn_observer=None,
    streaming_enabled=False,
)

Run the task through OpenHands and return an ExecutionResult.

provider / tool_invoker / streaming_enabled are unused: OpenHands runs its own LLM (through the gateway, which owns its own streaming + cost) and its own tools (native + credentialed-MCP). completion_config is not: its sampling half travels into the run spec, because the harness choosing its own temperature while the native loop is handed one is a difference between the loops that nobody chose.

Returns:

Type Description
ExecutionResult

The terminal :class:ExecutionResult with mapped TurnRecords.

Source code in src/synthorg/engine/openhands/loop.py
async def execute(  # noqa: PLR0913 -- ExecutionLoop protocol surface
    self,
    *,
    context: AgentContext,
    provider: CompletionProvider,
    tool_invoker: ToolInvokerProtocol | None = None,
    budget_checker: BudgetChecker | None = None,
    shutdown_checker: ShutdownChecker | None = None,
    completion_config: CompletionConfig | None = None,
    task_cancellation_checker: TaskCancellationChecker | None = None,
    turn_observer: TurnObserver | None = None,
    streaming_enabled: bool = False,
) -> ExecutionResult:
    """Run the task through OpenHands and return an ExecutionResult.

    ``provider`` / ``tool_invoker`` / ``streaming_enabled`` are unused:
    OpenHands runs its own LLM (through the gateway, which owns its own
    streaming + cost) and its own tools (native + credentialed-MCP).
    ``completion_config`` is not: its sampling half travels into the run
    spec, because the harness choosing its own temperature while the native
    loop is handed one is a difference between the loops that nobody chose.

    Returns:
        The terminal :class:`ExecutionResult` with mapped ``TurnRecord``s.
    """
    # OpenHands runs its own LLM (via the gateway) and tools (native + MCP).
    del provider, tool_invoker, streaming_enabled
    # Continued from the context, not restarted: a resumed run arrives with
    # turns already on its conversation, and numbering the next one 1 gives
    # the recorder a second turn 1 for the same execution. The frames are
    # keyed on that index, so the pairing a replay depends on comes apart
    # exactly on the runs that were interrupted.
    state = _RunState(ctx=context, turn_index=context.turn_count)
    spec = self._build_spec(context, completion_config)

    async def sink(event: OpenHandsEvent) -> bool:
        return await self._handle_event(
            event,
            state,
            budget_checker=budget_checker,
            shutdown_checker=shutdown_checker,
            task_cancellation_checker=task_cancellation_checker,
            turn_observer=turn_observer,
        )

    conversation = await self._deps.build_conversation(spec, sink)
    try:
        outcome = await conversation.run()
    except OpenHandsLoopError as exc:
        logger.warning(
            EXECUTION_LOOP_ERROR,
            loop_type=_LOOP_TYPE,
            error_type=type(exc).__name__,
            error=safe_error_description(exc),
        )
        return build_result(
            state.ctx,
            TerminationReason.ERROR,
            state.turns,
            error_message=safe_error_description(exc),
        )
    return self._finalize(state, outcome)

Execution Context

context

Agent execution context.

Wraps an AgentIdentity (frozen config) with evolving runtime state (conversation, cost, turn count, task execution) using model_copy(update=...) for cheap, immutable state transitions.

AgentContext pydantic-model

Bases: BaseModel

Frozen runtime context for agent execution.

All state evolution happens via model_copy(update=...). The context tracks the conversation, accumulated cost, and optionally a TaskExecution for task-bound agent runs.

Attributes:

Name Type Description
execution_id NotBlankStr

Unique identifier for this execution run.

identity AgentIdentity

Frozen agent identity configuration.

task_execution TaskExecution | None

Current task execution state (if any).

conversation tuple[ChatMessage, ...]

Accumulated chat messages.

accumulated_cost TokenUsage

Running token usage and cost totals.

turn_count int

Number of LLM turns completed.

max_turns int

Hard limit on turns before the engine stops.

started_at AwareDatetime

When this execution began.

context_fill_tokens int

Estimated tokens currently in the full context (system prompt + conversation + tool defs).

context_capacity_tokens int | None

Model's max context window tokens, or None when unknown.

compression_metadata CompressionMetadata | None

Metadata about conversation compression, set when compaction has occurred.

async_task_state AsyncTaskStateChannel

Dedicated state channel for tracked async tasks. Separate from conversation -- not touched by compaction or context reset.

loaded_tools frozenset[str]

Tool names with L2 bodies active in context.

loaded_resources frozenset[tuple[str, str]]

(tool_name, resource_id) pairs with L3 resources fetched.

tool_load_order tuple[str, ...]

Insertion-ordered tool names for FIFO auto-unload under budget pressure.

Config:

  • frozen: True
  • allow_inf_nan: False

Fields:

Validators:

  • _validate_disclosure_consistency

execution_id pydantic-field

execution_id

Unique execution run identifier

identity pydantic-field

identity

Frozen agent identity config

task_execution pydantic-field

task_execution = None

Current task execution state

conversation pydantic-field

conversation = ()

Accumulated conversation messages

accumulated_cost pydantic-field

accumulated_cost = ZERO_TOKEN_USAGE

Running cost totals across all turns

turn_count pydantic-field

turn_count = 0

Turns completed

max_turns pydantic-field

max_turns = DEFAULT_MAX_TURNS

Hard turn limit

turn_extensions_remaining pydantic-field

turn_extensions_remaining = 0

Further turn budgets this run may grant itself

turn_extensions_granted pydantic-field

turn_extensions_granted = 0

Further turn budgets this run has already taken

max_unresolved_tool_turns pydantic-field

max_unresolved_tool_turns = DEFAULT_MAX_UNRESOLVED_TOOL_TURNS

Consecutive turns resolving to no tool before the run stops

cost_ceiling pydantic-field

cost_ceiling = None

Optional per-session cost ceiling; the chat-action loop halts once accumulated cost meets it. Carried on the context so the bound survives a park/resume round-trip.

token_ceiling pydantic-field

token_ceiling = None

Optional per-session token ceiling, the companion to cost_ceiling: money measures nothing against a provider that bills by flat subscription, where the cost bound can never fire, and tokens are counted on every provider. Carried on the context for the same reason, so the bound survives a park/resume round-trip.

started_at pydantic-field

started_at

When execution began

context_fill_tokens pydantic-field

context_fill_tokens = 0

Estimated tokens in the full context

context_capacity_tokens pydantic-field

context_capacity_tokens = None

Model's max context window tokens

compression_metadata pydantic-field

compression_metadata = None

Compression metadata when compacted

async_task_state pydantic-field

async_task_state

Async task tracking state (survives compaction and context reset)

loaded_tools pydantic-field

loaded_tools = frozenset()

Tool names with L2 body active in context

loaded_resources pydantic-field

loaded_resources = frozenset()

(tool_name, resource_id) pairs with L3 active

tool_load_order pydantic-field

tool_load_order = ()

Insertion-ordered tool names for FIFO unload

adopted_steering_ids pydantic-field

adopted_steering_ids = frozenset()

Steering directive entry ids already adopted by this run

context_fill_percent property

context_fill_percent

Percentage of context window currently filled.

Returns None when context capacity is unknown.

has_turns_remaining property

has_turns_remaining

Whether the agent has turns remaining before hitting max_turns.

from_identity classmethod

from_identity(
    identity,
    *,
    task=None,
    max_turns=DEFAULT_MAX_TURNS,
    turn_extensions=0,
    max_unresolved_tool_turns=DEFAULT_MAX_UNRESOLVED_TOOL_TURNS,
    context_capacity_tokens=None,
    cost_ceiling=None,
    token_ceiling=None,
)

Create a fresh execution context from an agent identity.

Parameters:

Name Type Description Default
identity AgentIdentity

The frozen agent identity card.

required
task Task | None

Optional task to bind to this execution.

None
max_turns int

Maximum number of LLM turns allowed.

DEFAULT_MAX_TURNS
turn_extensions int

How many further turn budgets the run may grant itself before parking for a human. Zero, the default, ends the run at the first ceiling: extensions are task-run policy, and a bounded session (decomposition, a review panellist, a chat action) sets its own cap and never asked to exceed it. Only the task-run path passes the operator's configured value.

0
max_unresolved_tool_turns int

How many consecutive turns the run may spend asking only for tools that are not registered before it is stopped. Zero never stops it early.

DEFAULT_MAX_UNRESOLVED_TOOL_TURNS
context_capacity_tokens int | None

Model's max context window tokens, or None when unknown.

None
cost_ceiling float | None

Optional per-session cost ceiling. Passed through the constructor (not a post-hoc model_copy) so the gt=0 / no-NaN field constraint actually validates it.

None
token_ceiling int | None

Optional per-session token ceiling, the bound that still applies where money measures nothing.

None

Returns:

Type Description
AgentContext

New AgentContext ready for execution.

Source code in src/synthorg/engine/context.py
@classmethod
def from_identity(
    cls,
    identity: AgentIdentity,
    *,
    task: Task | None = None,
    max_turns: int = DEFAULT_MAX_TURNS,
    turn_extensions: int = 0,
    max_unresolved_tool_turns: int = DEFAULT_MAX_UNRESOLVED_TOOL_TURNS,
    context_capacity_tokens: int | None = None,
    cost_ceiling: float | None = None,
    token_ceiling: int | None = None,
) -> AgentContext:
    """Create a fresh execution context from an agent identity.

    Args:
        identity: The frozen agent identity card.
        task: Optional task to bind to this execution.
        max_turns: Maximum number of LLM turns allowed.
        turn_extensions: How many further turn budgets the run may grant
            itself before parking for a human. Zero, the default, ends the
            run at the first ceiling: extensions are task-run policy, and
            a bounded session (decomposition, a review panellist, a chat
            action) sets its own cap and never asked to exceed it. Only
            the task-run path passes the operator's configured value.
        max_unresolved_tool_turns: How many consecutive turns the run may
            spend asking only for tools that are not registered before it
            is stopped. Zero never stops it early.
        context_capacity_tokens: Model's max context window
            tokens, or ``None`` when unknown.
        cost_ceiling: Optional per-session cost ceiling. Passed through
            the constructor (not a post-hoc ``model_copy``) so the
            ``gt=0`` / no-NaN field constraint actually validates it.
        token_ceiling: Optional per-session token ceiling, the bound
            that still applies where money measures nothing.

    Returns:
        New ``AgentContext`` ready for execution.
    """
    task_execution = TaskExecution.from_task(task) if task is not None else None
    context = cls(
        execution_id=str(uuid4()),
        identity=identity,
        task_execution=task_execution,
        max_turns=max_turns,
        turn_extensions_remaining=turn_extensions,
        max_unresolved_tool_turns=max_unresolved_tool_turns,
        started_at=datetime.now(UTC),
        context_capacity_tokens=context_capacity_tokens,
        cost_ceiling=cost_ceiling,
        token_ceiling=token_ceiling,
    )
    logger.debug(
        EXECUTION_CONTEXT_CREATED,
        execution_id=context.execution_id,
        agent_id=str(identity.id),
        has_task=task is not None,
    )
    return context

with_message

with_message(msg)

Append a single message to the conversation.

Parameters:

Name Type Description Default
msg ChatMessage

The chat message to append.

required

Returns:

Type Description
AgentContext

New AgentContext with the message appended.

Source code in src/synthorg/engine/context.py
def with_message(self, msg: ChatMessage) -> AgentContext:
    """Append a single message to the conversation.

    Args:
        msg: The chat message to append.

    Returns:
        New ``AgentContext`` with the message appended.
    """
    return self.model_copy(update={"conversation": (*self.conversation, msg)})

with_steering_adopted

with_steering_adopted(directive_id)

Mark a mid-flight steering directive as adopted by this run.

Adoption is context-local and travels with the checkpointed context, so every concurrent agent on a project adopts a directive independently and a resumed run never re-adopts one it already consumed. Idempotent: re-adopting is a no-op.

Parameters:

Name Type Description Default
directive_id NotBlankStr

The project-brain entry id of the directive.

required

Returns:

Type Description
AgentContext

New AgentContext with the directive id recorded; the same

AgentContext

instance when it was already adopted.

Source code in src/synthorg/engine/context.py
def with_steering_adopted(self, directive_id: NotBlankStr) -> AgentContext:
    """Mark a mid-flight steering directive as adopted by this run.

    Adoption is context-local and travels with the checkpointed
    context, so every concurrent agent on a project adopts a
    directive independently and a resumed run never re-adopts one it
    already consumed. Idempotent: re-adopting is a no-op.

    Args:
        directive_id: The project-brain entry id of the directive.

    Returns:
        New ``AgentContext`` with the directive id recorded; the same
        instance when it was already adopted.
    """
    if directive_id in self.adopted_steering_ids:
        return self
    return self.model_copy(
        update={
            "adopted_steering_ids": self.adopted_steering_ids | {directive_id},
        },
    )

with_turn_completed

with_turn_completed(usage, response_msg)

Record a completed turn.

Increments turn count, appends the response message, and accumulates cost on both the context and the task execution (if present).

The turn count and the cost advance whether or not there is a message: a wordless turn still happened and was still billed.

Parameters:

Name Type Description Default
usage TokenUsage

Token usage from this turn's LLM call.

required
response_msg ChatMessage | None

The assistant's response message, or None when the turn said nothing on either channel.

required

Returns:

Type Description
AgentContext

New AgentContext with updated state.

Raises:

Type Description
MaxTurnsExceededError

If max_turns has been reached.

Source code in src/synthorg/engine/context.py
def with_turn_completed(
    self,
    usage: TokenUsage,
    response_msg: ChatMessage | None,
) -> AgentContext:
    """Record a completed turn.

    Increments turn count, appends the response message, and
    accumulates cost on both the context and the task execution
    (if present).

    The turn count and the cost advance whether or not there is a message:
    a wordless turn still happened and was still billed.

    Args:
        usage: Token usage from this turn's LLM call.
        response_msg: The assistant's response message, or ``None`` when
            the turn said nothing on either channel.

    Returns:
        New ``AgentContext`` with updated state.

    Raises:
        MaxTurnsExceededError: If ``max_turns`` has been reached.
    """
    if not self.has_turns_remaining:
        msg = (
            f"Agent {self.identity.id} exceeded max_turns "
            f"({self.max_turns}) for execution {self.execution_id}"
        )
        logger.error(
            EXECUTION_MAX_TURNS_EXCEEDED,
            execution_id=self.execution_id,
            agent_id=str(self.identity.id),
            max_turns=self.max_turns,
            turn_count=self.turn_count,
        )
        raise MaxTurnsExceededError(msg)
    conversation = (
        self.conversation
        if response_msg is None
        else (*self.conversation, response_msg)
    )
    updates: dict[str, object] = {
        "turn_count": self.turn_count + 1,
        "conversation": conversation,
        "accumulated_cost": add_token_usage(self.accumulated_cost, usage),
    }
    if self.task_execution is not None:
        updates["task_execution"] = self.task_execution.with_cost(usage)

    result = self.model_copy(update=updates)
    logger.info(
        EXECUTION_CONTEXT_TURN,
        execution_id=self.execution_id,
        turn=result.turn_count,
        cost=usage.cost,
    )
    return result

with_context_fill

with_context_fill(fill_tokens)

Update the estimated context fill level.

Parameters:

Name Type Description Default
fill_tokens int

New estimated fill in tokens.

required

Returns:

Type Description
AgentContext

New AgentContext with updated fill level.

Raises:

Type Description
ValueError

If fill_tokens is negative.

Source code in src/synthorg/engine/context.py
def with_context_fill(self, fill_tokens: int) -> AgentContext:
    """Update the estimated context fill level.

    Args:
        fill_tokens: New estimated fill in tokens.

    Returns:
        New ``AgentContext`` with updated fill level.

    Raises:
        ValueError: If ``fill_tokens`` is negative.
    """
    if fill_tokens < 0:
        msg = f"fill_tokens must be >= 0, got {fill_tokens}"
        raise ValueError(msg)
    return self.model_copy(
        update={"context_fill_tokens": fill_tokens},
    )

with_async_task_state

with_async_task_state(state)

Replace the async task state channel.

Parameters:

Name Type Description Default
state AsyncTaskStateChannel

New state channel.

required

Returns:

Type Description
AgentContext

New AgentContext with updated state channel.

Source code in src/synthorg/engine/context.py
def with_async_task_state(
    self,
    state: AsyncTaskStateChannel,
) -> AgentContext:
    """Replace the async task state channel.

    Args:
        state: New state channel.

    Returns:
        New ``AgentContext`` with updated state channel.
    """
    return self.model_copy(update={"async_task_state": state})

with_compression

with_compression(metadata, compressed_conversation, fill_tokens)

Replace conversation with a compressed version.

Parameters:

Name Type Description Default
metadata CompressionMetadata

Compression metadata to attach.

required
compressed_conversation tuple[ChatMessage, ...]

The compressed message tuple.

required
fill_tokens int

Updated fill estimate after compression.

required

Returns:

Type Description
AgentContext

New AgentContext with compressed conversation.

Raises:

Type Description
ValueError

If fill_tokens is negative.

Source code in src/synthorg/engine/context.py
def with_compression(
    self,
    metadata: CompressionMetadata,
    compressed_conversation: tuple[ChatMessage, ...],
    fill_tokens: int,
) -> AgentContext:
    """Replace conversation with a compressed version.

    Args:
        metadata: Compression metadata to attach.
        compressed_conversation: The compressed message tuple.
        fill_tokens: Updated fill estimate after compression.

    Returns:
        New ``AgentContext`` with compressed conversation.

    Raises:
        ValueError: If ``fill_tokens`` is negative.
    """
    if fill_tokens < 0:
        msg = f"fill_tokens must be >= 0, got {fill_tokens}"
        raise ValueError(msg)
    return self.model_copy(
        update={
            "conversation": compressed_conversation,
            "compression_metadata": metadata,
            "context_fill_tokens": fill_tokens,
        },
    )

with_task_transition

with_task_transition(target, *, reason='')

Transition the task execution status.

Delegates to :meth:~synthorg.engine.task_execution.TaskExecution.with_transition.

Parameters:

Name Type Description Default
target TaskStatus

The desired target status.

required
reason str

Optional reason for the transition.

''

Returns:

Type Description
AgentContext

New AgentContext with updated task execution.

Raises:

Type Description
ExecutionStateError

If no task execution is set.

ValueError

If the transition is invalid (from validate_transition).

Source code in src/synthorg/engine/context.py
def with_task_transition(
    self,
    target: TaskStatus,
    *,
    reason: str = "",
) -> AgentContext:
    """Transition the task execution status.

    Delegates to
    :meth:`~synthorg.engine.task_execution.TaskExecution.with_transition`.

    Args:
        target: The desired target status.
        reason: Optional reason for the transition.

    Returns:
        New ``AgentContext`` with updated task execution.

    Raises:
        ExecutionStateError: If no task execution is set.
        ValueError: If the transition is invalid (from
            ``validate_transition``).
    """
    if self.task_execution is None:
        msg = "Cannot transition task status: no task execution is set"
        logger.error(
            EXECUTION_CONTEXT_NO_TASK,
            execution_id=self.execution_id,
            agent_id=str(self.identity.id),
            target_status=target.value,
        )
        raise ExecutionStateError(msg)
    try:
        new_execution = self.task_execution.with_transition(target, reason=reason)
    except ValueError:
        logger.warning(
            EXECUTION_CONTEXT_TRANSITION_FAILED,
            execution_id=self.execution_id,
            agent_id=str(self.identity.id),
            target_status=target.value,
            current_status=self.task_execution.status.value,
        )
        raise
    return self.model_copy(update={"task_execution": new_execution})

to_snapshot

to_snapshot()

Create a compact snapshot for reporting and logging.

Returns:

Type Description
AgentContextSnapshot

Frozen AgentContextSnapshot with current state.

Source code in src/synthorg/engine/context.py
def to_snapshot(self) -> AgentContextSnapshot:
    """Create a compact snapshot for reporting and logging.

    Returns:
        Frozen ``AgentContextSnapshot`` with current state.
    """
    snapshot = build_context_snapshot(self, agent_id=str(self.identity.id))
    logger.debug(
        EXECUTION_CONTEXT_SNAPSHOT,
        execution_id=self.execution_id,
    )
    return snapshot

with_tool_loaded

with_tool_loaded(tool_name)

Mark a tool's L2 body as loaded.

Idempotent: loading an already-loaded tool is a no-op.

Parameters:

Name Type Description Default
tool_name str

Name of the tool to load.

required

Returns:

Type Description
AgentContext

New AgentContext with the tool marked as loaded.

Source code in src/synthorg/engine/context.py
def with_tool_loaded(self, tool_name: str) -> AgentContext:
    """Mark a tool's L2 body as loaded.

    Idempotent: loading an already-loaded tool is a no-op.

    Args:
        tool_name: Name of the tool to load.

    Returns:
        New ``AgentContext`` with the tool marked as loaded.
    """
    update = tool_loaded_update(self.loaded_tools, self.tool_load_order, tool_name)
    return self if update is None else self.model_copy(update=update)

with_tool_unloaded

with_tool_unloaded(tool_name)

Mark a tool's L2 body as unloaded.

Also removes any L3 resources for the unloaded tool. Idempotent: unloading an already-unloaded tool is a no-op.

Parameters:

Name Type Description Default
tool_name str

Name of the tool to unload.

required

Returns:

Type Description
AgentContext

New AgentContext with the tool removed.

Source code in src/synthorg/engine/context.py
def with_tool_unloaded(self, tool_name: str) -> AgentContext:
    """Mark a tool's L2 body as unloaded.

    Also removes any L3 resources for the unloaded tool.
    Idempotent: unloading an already-unloaded tool is a no-op.

    Args:
        tool_name: Name of the tool to unload.

    Returns:
        New ``AgentContext`` with the tool removed.
    """
    update = tool_unloaded_update(
        self.loaded_tools, self.tool_load_order, self.loaded_resources, tool_name
    )
    return self if update is None else self.model_copy(update=update)

with_resource_loaded

with_resource_loaded(tool_name, resource_id)

Mark an L3 resource as fetched.

Idempotent: loading an already-loaded resource is a no-op.

Parameters:

Name Type Description Default
tool_name str

Name of the tool owning the resource.

required
resource_id str

Identifier of the resource.

required

Returns:

Type Description
AgentContext

New AgentContext with the resource marked as loaded.

Source code in src/synthorg/engine/context.py
def with_resource_loaded(
    self,
    tool_name: str,
    resource_id: str,
) -> AgentContext:
    """Mark an L3 resource as fetched.

    Idempotent: loading an already-loaded resource is a no-op.

    Args:
        tool_name: Name of the tool owning the resource.
        resource_id: Identifier of the resource.

    Returns:
        New ``AgentContext`` with the resource marked as loaded.
    """
    update = resource_loaded_update(self.loaded_resources, tool_name, resource_id)
    return self if update is None else self.model_copy(update=update)

Prompt Builder

prompt

System prompt construction from agent identity and context.

Translates agent configuration (personality, skills, authority, role) into contextually rich system prompts that shape agent behavior during LLM calls.

Non-inferable principle: System prompts should contain only information that agents cannot discover by reading the codebase or environment. Full tool definitions are delivered via the LLM provider's API tools parameter. However, lightweight L1 metadata (name, category, cost tier, one-line description) IS injected into the system prompt so agents can discover what tools exist and decide which to load via load_tool().

Example::

from synthorg.engine.prompt import build_system_prompt

prompt = build_system_prompt(agent=agent_identity, task=task)
prompt.content  # rendered system prompt string

SystemPrompt pydantic-model

Bases: BaseModel

Immutable result of system prompt construction.

Attributes:

Name Type Description
content str

Full rendered prompt text.

template_version str

Version of the template that produced this prompt.

estimated_tokens int

Token estimate of the prompt content.

sections tuple[str, ...]

Names of sections included in the prompt.

metadata dict[str, str]

Agent identity metadata (agent_id, name, role, department, level, and optionally profile_capability).

personality_trim_info PersonalityTrimInfo | None

Populated when personality section was trimmed to fit the profile's token budget.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _deep_copy_metadata

content pydantic-field

content

Full rendered prompt text

template_version pydantic-field

template_version

Template version that produced this prompt

estimated_tokens pydantic-field

estimated_tokens

Estimated token count of prompt content

sections pydantic-field

sections

Names of sections included in the prompt

metadata pydantic-field

metadata

Agent identity metadata (string-only values; shallow-frozen)

personality_trim_info pydantic-field

personality_trim_info = None

Populated when personality section was trimmed

build_system_prompt

build_system_prompt(
    *,
    agent,
    role=None,
    task=None,
    available_tools=(),
    l1_summaries=(),
    company=None,
    org_policies=(),
    max_tokens=None,
    custom_template=None,
    token_estimator=None,
    effective_autonomy=None,
    context_budget_indicator=None,
    currency=DEFAULT_CURRENCY,
    capability=None,
    personality_trimming_enabled=True,
    max_personality_tokens_override=None,
    strategy_config=None,
    async_task_state=None,
)

Build a system prompt from agent identity and optional context.

When max_tokens is provided and the prompt exceeds it, optional sections are progressively trimmed (strategy, company, task, org_policies).

Parameters:

Name Type Description Default
agent AgentIdentity

Agent identity containing personality, skills, authority.

required
role Role | None

Optional role with description and responsibilities.

None
task Task | None

Optional task context injected into the prompt.

None
available_tools tuple[ToolDefinition, ...]

Tool definitions populated into template context for custom templates only; the default template omits tools per D22 (non-inferable principle).

()
l1_summaries tuple[ToolL1Metadata, ...]

L1 metadata for system prompt injection. Lightweight tool summaries rendered in the Available Tools section of the default template.

()
company Company | None

Opt-in. Non-inferable principle recommends omitting unless agents need org-level context they cannot discover.

None
org_policies tuple[str, ...]

Company-wide policy texts to inject into prompt.

()
max_tokens int | None

Token budget; sections are trimmed if exceeded.

None
custom_template str | None

Optional Jinja2 template string override.

None
token_estimator PromptTokenEstimator | None

Custom token estimator (defaults to char/4).

None
effective_autonomy EffectiveAutonomy | None

Resolved autonomy for the current run.

None
context_budget_indicator str | None

Formatted context budget indicator string to inject into the prompt.

None
currency CurrencyCode

ISO 4217 currency code for budget displays. Validated against the allowlist in synthorg.budget.currency.

DEFAULT_CURRENCY
capability CapabilityLevel | None

Capability rung for prompt profile selection. None defaults to the full (expert) profile.

None
personality_trimming_enabled bool

When True (default), the personality section is progressively trimmed if it exceeds the profile's max_personality_tokens.

True
max_personality_tokens_override int | None

When set to a positive value, overrides the profile's max_personality_tokens limit. Values <= 0 are ignored (profile default is used).

None
strategy_config StrategyConfig | None

Strategy and trendslop mitigation config. When provided and the agent qualifies (C-suite/VP/Director or has explicit strategic_output_mode), strategic analysis sections are injected into the prompt.

None
async_task_state AsyncTaskStateChannel | None

Optional async task state channel. When non-empty, appends an Active Async Tasks section to the prompt (survives trimming).

None

Returns:

Name Type Description
Immutable SystemPrompt

class:SystemPrompt with rendered content and metadata.

Raises:

Type Description
PromptBuildError

If prompt construction fails.

Source code in src/synthorg/engine/prompt.py
def build_system_prompt(  # noqa: PLR0913
    *,
    agent: AgentIdentity,
    role: Role | None = None,
    task: Task | None = None,
    available_tools: tuple[ToolDefinition, ...] = (),
    l1_summaries: tuple[ToolL1Metadata, ...] = (),
    company: Company | None = None,
    org_policies: tuple[str, ...] = (),
    max_tokens: int | None = None,
    custom_template: str | None = None,
    token_estimator: PromptTokenEstimator | None = None,
    effective_autonomy: EffectiveAutonomy | None = None,
    context_budget_indicator: str | None = None,
    currency: CurrencyCode = DEFAULT_CURRENCY,
    capability: CapabilityLevel | None = None,
    personality_trimming_enabled: bool = True,
    max_personality_tokens_override: int | None = None,
    strategy_config: StrategyConfig | None = None,
    async_task_state: AsyncTaskStateChannel | None = None,
) -> SystemPrompt:
    """Build a system prompt from agent identity and optional context.

    When ``max_tokens`` is provided and the prompt exceeds it, optional
    sections are progressively trimmed (strategy, company, task,
    org_policies).

    Args:
        agent: Agent identity containing personality, skills, authority.
        role: Optional role with description and responsibilities.
        task: Optional task context injected into the prompt.
        available_tools: Tool definitions populated into template context
            for custom templates only; the default template omits tools
            per D22 (non-inferable principle).
        l1_summaries: L1 metadata for system prompt injection.
            Lightweight tool summaries rendered in the Available
            Tools section of the default template.
        company: Opt-in. Non-inferable principle recommends omitting
            unless agents need org-level context they cannot discover.
        org_policies: Company-wide policy texts to inject into prompt.
        max_tokens: Token budget; sections are trimmed if exceeded.
        custom_template: Optional Jinja2 template string override.
        token_estimator: Custom token estimator (defaults to char/4).
        effective_autonomy: Resolved autonomy for the current run.
        context_budget_indicator: Formatted context budget indicator
            string to inject into the prompt.
        currency: ISO 4217 currency code for budget displays.  Validated
            against the allowlist in ``synthorg.budget.currency``.
        capability: Capability rung for prompt profile selection.
            ``None`` defaults to the full (expert) profile.
        personality_trimming_enabled: When ``True`` (default), the
            personality section is progressively trimmed if it exceeds
            the profile's ``max_personality_tokens``.
        max_personality_tokens_override: When set to a positive value,
            overrides the profile's ``max_personality_tokens`` limit.
            Values ``<= 0`` are ignored (profile default is used).
        strategy_config: Strategy and trendslop mitigation config.
            When provided and the agent qualifies (C-suite/VP/Director
            or has explicit ``strategic_output_mode``), strategic
            analysis sections are injected into the prompt.
        async_task_state: Optional async task state channel.
            When non-empty, appends an ``Active Async Tasks``
            section to the prompt (survives trimming).

    Returns:
        Immutable :class:`SystemPrompt` with rendered content and metadata.

    Raises:
        PromptBuildError: If prompt construction fails.
    """
    validate_max_tokens(agent, max_tokens)
    validate_org_policies(agent, org_policies)

    if l1_summaries:
        logger.info(
            TOOL_L1_INJECTED,
            tool_count=len(l1_summaries),
            tool_names=tuple(s.name for s in l1_summaries),
        )

    profile = get_prompt_profile(capability)
    if max_personality_tokens_override is not None:
        if max_personality_tokens_override > 0:
            profile = profile.model_copy(
                update={"max_personality_tokens": max_personality_tokens_override},
            )
        else:
            logger.warning(
                PROMPT_PROFILE_SELECTED,
                override_ignored=max_personality_tokens_override,
                reason="max_personality_tokens_override must be > 0",
            )
    logger.info(
        PROMPT_PROFILE_SELECTED,
        requested_capability=capability,
        selected_capability=profile.capability,
        defaulted=capability is None,
        personality_mode=profile.personality_mode,
        autonomy_detail_level=profile.autonomy_detail_level,
    )

    # Advisory only -- issues are logged but never block prompt construction.
    if org_policies:
        try:
            validate_policy_quality(org_policies)
        except Exception as exc:  # noqa: BLE001 -- criticals re-raised
            # lint-allow: swallow-ok -- degrade-to-None wiring
            reraise_critical(exc)
            logger.warning(
                PROMPT_POLICY_VALIDATION_FAILED,
                agent_id=str(agent.id),
            )

    logger.info(
        PROMPT_BUILD_START,
        agent_id=str(agent.id),
        agent_name=agent.name,
        has_task=task is not None,
        tool_count=len(available_tools),
        has_company=company is not None,
        has_custom_template=custom_template is not None,
        capability=capability,
    )

    # The untrusted-content directive is appended after trimming (so it
    # survives) but still counts toward the real token budget, so
    # reserve its upper-bound cost before trimming. The maximal tag set
    # is derived from the inputs; trimming only removes sections, so the
    # directive actually appended (derived from surviving sections) is a
    # subset whose cost never exceeds the reservation.
    has_async_tasks = async_task_state is not None and bool(async_task_state.records)
    # A tool-capable agent receives ``<tool-result>`` content in later
    # turns' message history (loop_tool_execution._wrap_tool_result), so
    # its standing untrusted-content directive must declare that fence up
    # front even though the fenced payload is not a system-prompt section.
    # The live runtime path injects tools as ``l1_summaries`` (not
    # ``available_tools``), so either signal means the agent is tool-capable
    # and the fence must be declared.
    fences_tool_results = bool(available_tools or l1_summaries)
    # ``TAG_CONFIG_VALUE`` is reserved unconditionally: two sections can now
    # emit it (company policies, and the ask policy's operator-authored
    # additions) and neither is known before the render. Over-reserving one
    # tag's worth of budget is the safe direction; under-reserving would let
    # the directive push the prompt past its ceiling.
    max_directive_tags: tuple[str, ...] = (
        *((TAG_TASK_DATA,) if task is not None or has_async_tasks else ()),
        TAG_CONFIG_VALUE,
        *((TAG_TOOL_RESULT,) if fences_tool_results else ()),
        *((TAG_PEER_CONTRIBUTION,) if task is not None else ()),
    )

    try:
        estimator = token_estimator or DefaultTokenEstimator()
        template_str = resolve_template(custom_template)

        directive_reserve = untrusted_content_directive_token_cost(
            max_directive_tags,
            estimator,
        )
        trim_budget = (
            max(max_tokens - directive_reserve, 1) if max_tokens is not None else None
        )

        result = render_with_trimming(
            template_str=template_str,
            agent=agent,
            role=role,
            task=task,
            available_tools=available_tools,
            l1_summaries=l1_summaries,
            company=company,
            org_policies=org_policies,
            max_tokens=trim_budget,
            estimator=estimator,
            effective_autonomy=effective_autonomy,
            context_budget_indicator=context_budget_indicator,
            currency=currency,
            profile=profile,
            trimming_enabled=personality_trimming_enabled,
            strategy_config=strategy_config,
        )
    except PromptBuildError:
        raise  # Already logged by inner functions.
    except Exception as exc:
        reraise_critical(exc)
        logger.warning(
            PROMPT_BUILD_ERROR,
            agent_id=str(agent.id),
            agent_name=agent.name,
            error_type=type(exc).__name__,
            error=safe_error_description(exc),
        )
        detail = sanitize_message(str(exc))
        msg = f"Unexpected error building prompt for agent '{agent.name}': {detail}"
        raise PromptBuildError(msg) from exc

    # Inject async task state section (survives trimming -- appended
    # after the main render since it must never be trimmed away).
    try:
        if async_task_state is not None and async_task_state.records:
            result = append_async_task_section(
                result,
                async_task_state,
                estimator,
            )
    except Exception as exc:
        reraise_critical(exc)
        logger.warning(
            PROMPT_BUILD_ERROR,
            agent_id=str(agent.id),
            agent_name=agent.name,
            error_type=type(exc).__name__,
            error=safe_error_description(exc),
        )
        detail = sanitize_message(str(exc))
        msg = f"Error injecting async task state for agent '{agent.name}': {detail}"
        raise PromptBuildError(msg) from exc

    # Append the untrusted-content directive naming every fence
    # tag still present in the final content, after trimming so it is
    # never trimmed away from the content it governs. Tags are derived
    # from the surviving sections (a section the trimmer dropped no
    # longer has fences to govern); the async-task section also fences
    # task-data. Its cost was reserved from the trim budget above.
    directive_tags: tuple[str, ...] = (
        *(
            (TAG_TASK_DATA,)
            if "task" in result.sections or "async_tasks" in result.sections
            else ()
        ),
        # Derived from the content, not the section list: the company-policy
        # section and the ask policy's operator additions both emit this
        # fence, and the ask-policy section emits it only when the operator
        # actually configured an addition. The rendered fence is the exact
        # condition under which the directive should name the tag.
        *((TAG_CONFIG_VALUE,) if f"<{TAG_CONFIG_VALUE}>" in result.content else ()),
        # Declared for tool-capable agents only: the ``<tool-result>``
        # fence governs later-turn message history, not a section here, so
        # it is gated on tool availability rather than a surviving section.
        *((TAG_TOOL_RESULT,) if fences_tool_results else ()),
        # Also a later-turn fence rather than a section: a review that sends
        # the run back quotes the reviewing agent's own prose at it. The
        # prompt is built once and reused across rework rounds, so the tag
        # has to be declared before the turn that carries it exists.
        *((TAG_PEER_CONTRIBUTION,) if "task" in result.sections else ()),
    )
    result = append_untrusted_content_directive(result, directive_tags, estimator)

    return log_and_return(agent, result)

build_error_prompt

build_error_prompt(identity, agent_id, system_prompt)

Return the existing system prompt or a minimal error placeholder.

Used by the engine when the execution pipeline fails and a SystemPrompt was never built (or was partially built).

Parameters:

Name Type Description Default
identity AgentIdentity

Agent identity for metadata.

required
agent_id str

String agent identifier.

required
system_prompt SystemPrompt | None

Previously built prompt, or None.

required

Returns:

Type Description
SystemPrompt

The existing prompt if available, else a minimal placeholder.

Source code in src/synthorg/engine/prompt.py
def build_error_prompt(
    identity: AgentIdentity,
    agent_id: str,
    system_prompt: SystemPrompt | None,
) -> SystemPrompt:
    """Return the existing system prompt or a minimal error placeholder.

    Used by the engine when the execution pipeline fails and a
    ``SystemPrompt`` was never built (or was partially built).

    Args:
        identity: Agent identity for metadata.
        agent_id: String agent identifier.
        system_prompt: Previously built prompt, or ``None``.

    Returns:
        The existing prompt if available, else a minimal placeholder.
    """
    if system_prompt is not None:
        return system_prompt
    metadata = {**_build_metadata(identity), "agent_id": agent_id}
    return SystemPrompt(
        content="",
        template_version="error",
        estimated_tokens=0,
        sections=(),
        metadata=metadata,
    )

Task Execution

task_execution

Runtime task execution state.

Wraps the frozen Task config model with evolving execution state (status, cost, turn count) using model_copy(update=...) for cheap, immutable state transitions.

StatusTransition pydantic-model

Bases: BaseModel

Frozen audit record for a single status transition.

Attributes:

Name Type Description
from_status TaskStatus

Status before the transition.

to_status TaskStatus

Status after the transition.

timestamp AwareDatetime

When the transition occurred (timezone-aware).

reason str

Optional human-readable reason for the transition.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

from_status pydantic-field

from_status

Status before transition

to_status pydantic-field

to_status

Status after transition

timestamp pydantic-field

timestamp

When the transition occurred

reason pydantic-field

reason = ''

Optional reason for the transition

TaskExecution pydantic-model

Bases: BaseModel

Frozen runtime wrapper around a Task for execution tracking.

All state evolution happens via model_copy(update=...). Transitions are validated explicitly via :func:~synthorg.core.task_transitions.validate_transition before the copy is made.

Attributes:

Name Type Description
task Task

Original frozen task definition.

status TaskStatus

Current execution status (starts from task.status).

transition_log tuple[StatusTransition, ...]

Audit trail of status transitions.

accumulated_cost TokenUsage

Running token usage and cost totals.

turn_count int

Number of LLM turns completed.

retry_count int

Number of previous failure-reassignment cycles.

started_at AwareDatetime | None

Set by with_transition on first entry to IN_PROGRESS (None until then).

completed_at AwareDatetime | None

When execution reached a terminal state.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

task pydantic-field

task

Original frozen task definition

status pydantic-field

status

Current execution status

transition_log pydantic-field

transition_log = ()

Audit trail of status transitions

accumulated_cost pydantic-field

accumulated_cost = ZERO_TOKEN_USAGE

Running cost totals

turn_count pydantic-field

turn_count = 0

Number of turns completed

retry_count pydantic-field

retry_count = 0

Number of previous failure-reassignment cycles

started_at pydantic-field

started_at = None

When execution entered IN_PROGRESS

completed_at pydantic-field

completed_at = None

When execution reached a terminal state

is_terminal property

is_terminal

Whether execution is in a terminal state.

from_task classmethod

from_task(task, *, retry_count=0)

Create a fresh execution from a task definition.

Parameters:

Name Type Description Default
task Task

The frozen task to wrap.

required
retry_count int

Number of previous failure-reassignment cycles.

0

Returns:

Type Description
TaskExecution

New TaskExecution with status matching the task.

Source code in src/synthorg/engine/task_execution.py
@classmethod
def from_task(
    cls,
    task: Task,
    *,
    retry_count: int = 0,
) -> TaskExecution:
    """Create a fresh execution from a task definition.

    Args:
        task: The frozen task to wrap.
        retry_count: Number of previous failure-reassignment cycles.

    Returns:
        New ``TaskExecution`` with status matching the task.
    """
    execution = cls(task=task, status=task.status, retry_count=retry_count)
    logger.debug(
        EXECUTION_TASK_CREATED,
        task_id=task.id,
        initial_status=task.status.value,
    )
    return execution

with_transition

with_transition(target, *, reason='')

Validate and apply a status transition.

Returns:

Type Description
TaskExecution

A new :class:TaskExecution with the requested status

TaskExecution

applied (Pydantic copy-on-write).

Raises:

Type Description
ValueError

If the transition is invalid.

Source code in src/synthorg/engine/task_execution.py
def with_transition(
    self,
    target: TaskStatus,
    *,
    reason: str = "",
) -> TaskExecution:
    """Validate and apply a status transition.

    Returns:
        A new :class:`TaskExecution` with the requested status
        applied (Pydantic copy-on-write).

    Raises:
        ValueError: If the transition is invalid.
    """
    try:
        validate_transition(self.status, target)
    except ValueError:
        logger.warning(
            EXECUTION_TASK_TRANSITION_FAILED,
            task_id=self.task.id,
            from_status=self.status.value,
            to_status=target.value,
            turn_count=self.turn_count,
        )
        raise
    now = datetime.now(UTC)
    transition = StatusTransition(
        from_status=self.status,
        to_status=target,
        timestamp=now,
        reason=reason,
    )
    updates = _build_transition_updates(
        self,
        target,
        transition,
        now,
    )
    result = self.model_copy(update=updates)
    logger.info(
        EXECUTION_TASK_TRANSITION,
        task_id=self.task.id,
        from_status=self.status.value,
        to_status=target.value,
        reason=reason,
    )
    return result

with_cost

with_cost(usage)

Accumulate token usage and increment turn count.

Parameters:

Name Type Description Default
usage TokenUsage

Token usage from a single LLM call.

required

Returns:

Type Description
TaskExecution

New TaskExecution with updated cost and turn count.

Raises:

Type Description
ExecutionStateError

If execution is in a terminal state.

Source code in src/synthorg/engine/task_execution.py
def with_cost(self, usage: TokenUsage) -> TaskExecution:
    """Accumulate token usage and increment turn count.

    Args:
        usage: Token usage from a single LLM call.

    Returns:
        New ``TaskExecution`` with updated cost and turn count.

    Raises:
        ExecutionStateError: If execution is in a terminal state.
    """
    if self.is_terminal:
        msg = (
            f"Cannot record cost on terminal task execution "
            f"(task_id={self.task.id}, status={self.status.value})"
        )
        logger.error(
            EXECUTION_COST_ON_TERMINAL,
            task_id=self.task.id,
            status=self.status.value,
        )
        raise ExecutionStateError(msg)
    result = self.model_copy(
        update={
            "accumulated_cost": add_token_usage(self.accumulated_cost, usage),
            "turn_count": self.turn_count + 1,
        }
    )
    logger.debug(
        EXECUTION_COST_RECORDED,
        task_id=self.task.id,
        turn=result.turn_count,
        cost=usage.cost,
    )
    return result

to_task_snapshot

to_task_snapshot()

Return the original task with the current execution status.

Useful for persistence or reporting where a plain Task is expected.

Returns:

Type Description
Task

A copy of the original task with updated status.

Source code in src/synthorg/engine/task_execution.py
def to_task_snapshot(self) -> Task:
    """Return the original task with the current execution status.

    Useful for persistence or reporting where a plain ``Task`` is
    expected.

    Returns:
        A copy of the original task with updated status.
    """
    return self.task.model_copy(update={"status": self.status})

Parallel Execution

parallel

Parallel agent execution orchestrator.

Coordinates multiple AgentEngine.run() calls in parallel using structured concurrency (asyncio.TaskGroup), with error isolation, concurrency limits, resource locking, and progress tracking.

Inspired by the ToolInvoker.invoke_all() pattern from tools/invoker.py (TaskGroup + Semaphore + guarded execution), extended with fail-fast, progress tracking, and CancelledError handling.

ProgressCallback module-attribute

ProgressCallback = Callable[[ParallelProgress], None]

Synchronous callback invoked on progress updates.

Called directly (not awaited) from the executor's event loop; must not block. Async functions will produce un-awaited coroutines.

ParallelExecutor

ParallelExecutor(
    *,
    engine,
    shutdown_manager=None,
    resource_lock=None,
    progress_callback=None,
    clock=None,
)

Orchestrates concurrent agent execution.

Composition over inheritance -- takes an AgentEngine and coordinates concurrent run() calls.

Parameters:

Name Type Description Default
engine AgentEngine

Agent execution engine.

required
shutdown_manager ShutdownManager | None

Optional shutdown manager for task registration.

None
resource_lock ResourceLock | None

Optional resource lock for exclusive file access. Defaults to one InMemoryResourceLock owned by this executor. Owned rather than minted per group: a lock created inside a group covers claims that group already proved non-colliding, so it can only ever succeed, and two concurrent groups naming the same resource would each hold their own.

None
progress_callback ProgressCallback | None

Optional synchronous callback invoked on progress updates.

None
Source code in src/synthorg/engine/parallel.py
def __init__(
    self,
    *,
    engine: AgentEngine,
    shutdown_manager: ShutdownManager | None = None,
    resource_lock: ResourceLock | None = None,
    progress_callback: ProgressCallback | None = None,
    clock: Clock | None = None,
) -> None:
    self._engine = engine
    self._shutdown_manager = shutdown_manager
    self._resource_lock: ResourceLock = (
        resource_lock if resource_lock is not None else InMemoryResourceLock()
    )
    self._progress_callback = progress_callback
    self._clock: Clock = clock if clock is not None else SystemClock()

execute_group async

execute_group(group)

Execute a parallel group of agent assignments.

Parameters:

Name Type Description Default
group ParallelExecutionGroup

The execution group to run.

required

Returns:

Type Description
ParallelExecutionResult

Result with all agent outcomes. Under fail_fast the first

ParallelExecutionResult

agent failure cancels the remaining assignments; the failure

ParallelExecutionResult

and the cancellations are recorded as outcomes and logged, not

ParallelExecutionResult

raised, so callers detect them via all_succeeded / the

ParallelExecutionResult

per-agent outcomes.

Raises:

Type Description
ResourceConflictError

If resource claims conflict between assignments.

MemoryError

Propagated directly (single fatal) so the interpreter-fatal reaches the top of the stack unmasked.

RecursionError

Propagated directly (single fatal), as above.

ExceptionGroup

When more than one fatal error occurred; its members are the original MemoryError/RecursionError instances.

ParallelExecutionError

Only when resource-lock release fails and no other error is pending to carry the note (never wraps a fatal).

Source code in src/synthorg/engine/parallel.py
async def execute_group(
    self,
    group: ParallelExecutionGroup,
) -> ParallelExecutionResult:
    """Execute a parallel group of agent assignments.

    Args:
        group: The execution group to run.

    Returns:
        Result with all agent outcomes. Under ``fail_fast`` the first
        agent failure cancels the remaining assignments; the failure
        and the cancellations are recorded as outcomes and logged, not
        raised, so callers detect them via ``all_succeeded`` / the
        per-agent outcomes.

    Raises:
        ResourceConflictError: If resource claims conflict between
            assignments.
        MemoryError: Propagated directly (single fatal) so the
            interpreter-fatal reaches the top of the stack unmasked.
        RecursionError: Propagated directly (single fatal), as above.
        ExceptionGroup: When more than one fatal error occurred; its
            members are the original MemoryError/RecursionError
            instances.
        ParallelExecutionError: Only when resource-lock release fails
            and no other error is pending to carry the note (never
            wraps a fatal).
    """
    start = self._clock.monotonic()

    logger.info(
        PARALLEL_GROUP_START,
        group_id=group.group_id,
        agent_count=len(group.assignments),
        max_concurrency=group.max_concurrency,
        fail_fast=group.fail_fast,
    )

    lock = resolve_lock(group, self._resource_lock)
    validate_resource_claims(group)

    outcomes: dict[str, AgentOutcome] = {}
    fatal_errors: list[Exception] = []
    progress = _ProgressState(
        group_id=group.group_id,
        total=len(group.assignments),
    )

    task_error: Exception | None = None
    release_error: Exception | None = None
    try:
        if lock is not None:
            await acquire_all_locks(group, lock)
        await self._run_task_group(
            group,
            outcomes,
            fatal_errors,
            progress,
        )
    except Exception as exc:  # noqa: BLE001 -- captured, re-raised below
        # lint-allow: swallow-ok -- captured, re-raised below
        task_error = exc
    finally:
        if lock is not None:
            release_error = await release_locks_bounded(group, lock)

    if release_error is not None:
        lock_msg = (
            f"Parallel group {group.group_id!r}: "
            "resource locks could not be released"
        )
        if task_error is not None:
            task_error.add_note(lock_msg)
        elif fatal_errors:
            # A pending interpreter-fatal must win over a teardown
            # failure: attach the note to the fatal and let the
            # fatal re-raise path below surface it, never masked by a
            # swallowable ParallelExecutionError.
            fatal_errors[0].add_note(lock_msg)
        else:
            raise ParallelExecutionError(
                lock_msg,
            ) from release_error

    if task_error is not None:
        raise task_error

    result = self._build_result(
        group,
        outcomes,
        self._clock.monotonic() - start,
    )

    logger.info(
        PARALLEL_GROUP_COMPLETE,
        group_id=group.group_id,
        succeeded=result.agents_succeeded,
        failed=result.agents_failed,
        awaiting_human=result.agents_awaiting_human,
        duration_seconds=result.total_duration_seconds,
    )

    if fatal_errors:
        msg = (
            f"Parallel group {group.group_id!r} had "
            f"{len(fatal_errors)} fatal error(s)"
        )
        logger.error(
            PARALLEL_AGENT_ERROR,
            group_id=group.group_id,
            fatal_error_count=len(fatal_errors),
            error=msg,
        )
        # Re-raise the original MemoryError/RecursionError (or an
        # ExceptionGroup of them) directly, never wrapped in a domain
        # error: downstream ``reraise_critical`` inspects the exception
        # itself (and ExceptionGroup members), not ``__cause__``, so
        # wrapping would launder an interpreter-fatal into an ordinary
        # ``ParallelExecutionError`` a caller can swallow. Mirrors
        # ``ToolInvoker._raise_fatal_errors``.
        if len(fatal_errors) == 1:
            raise fatal_errors[0]
        raise ExceptionGroup(msg, fatal_errors)

    return result

Run Result

run_result

Agent run result model.

Frozen Pydantic model wrapping ExecutionResult with outer metadata from the engine layer (system prompt, wall-clock duration, agent/task IDs).

AgentRunResult pydantic-model

Bases: BaseModel

Immutable result of a complete agent engine run.

Wraps the ExecutionResult from the loop with engine-level metadata: system prompt, wall-clock duration, and agent/task IDs.

Attributes:

Name Type Description
execution_result ExecutionResult

Outcome from the execution loop.

system_prompt SystemPrompt

System prompt used for this run.

duration_seconds float

Wall-clock run time in seconds.

agent_id NotBlankStr

Agent identifier (string form of UUID).

task_id NotBlankStr | None

Task identifier (always set currently; None reserved for future taskless runs).

Config:

  • frozen: True
  • allow_inf_nan: False

Fields:

execution_result pydantic-field

execution_result

Outcome from the execution loop

system_prompt pydantic-field

system_prompt

System prompt used for this run

duration_seconds pydantic-field

duration_seconds

Wall-clock run time in seconds

agent_id pydantic-field

agent_id

Agent identifier

task_id pydantic-field

task_id = None

Task identifier, or None for future taskless runs

produced_artifacts pydantic-field

produced_artifacts = ()

Artifacts produced during execution

bound_model pydantic-field

bound_model = None

The (provider, model) pair the run actually committed to, after stakes routing and any budget ceiling have spoken. This is not always the pair the agent carries on the roster, so a caller recording what produced an output reads it here rather than off the identity it dispatched. None on the paths that terminate before a binding is committed.

currency pydantic-field

currency

ISO 4217 currency that denominates total_cost. Populated by the engine from the active BudgetConfig.currency so cross-agent aggregations (e.g. ParallelExecutionResult.total_cost) can enforce the same-currency invariant before summing. Required so constructor sites cannot silently mis-label a non-default run as DEFAULT_CURRENCY.

termination_reason property

termination_reason

Why the execution loop terminated.

total_turns property

total_turns

Number of turns completed during execution.

quality_signals property

quality_signals

Per-step quality signals produced during the loop.

total_cost property

total_cost

Accumulated cost from the execution context.

is_success property

is_success

True when termination reason is COMPLETED.

is_awaiting_human property

is_awaiting_human

True when the run parked on an escalation.

The single owner of "is this run a human wait", so every consumer reads the same answer. A parked run is neither a success nor a failure: the task is alive, an approval is pending, and the run resumes from its parked context once the human decides. Counting it as a failure fails the wave, skips the merge, tears down the workspace the resume needs, and kills the plan while its approval is still open.

completion_summary property

completion_summary

Extract the last assistant message content as a work summary.

Walks the conversation in reverse to find the most recent assistant message with non-empty text content. Tool-call-only assistant messages (content is None or empty) are skipped.

Returns:

Type Description
str | None

The content string, or None if no qualifying message exists.

Metrics

metrics

Task completion metrics model.

Proxy overhead metrics for an agent run, computed from AgentRunResult data per docs/design/coordination-metrics.md.

TaskCompletionMetrics pydantic-model

Bases: BaseModel

Proxy overhead metrics for an agent run.

See docs/design/coordination-metrics.md.

Computed from AgentRunResult after execution to surface orchestration overhead indicators (turns, tokens, cost, duration).

Attributes:

Name Type Description
task_id NotBlankStr | None

Task identifier (None for future taskless runs).

agent_id NotBlankStr

Agent identifier (string form of UUID).

turns_per_task int

Number of LLM turns to complete the task.

tokens_per_task int

Total tokens consumed (input + output).

cost_per_task float

Total cost for the task in the configured currency.

duration_seconds float

Wall-clock execution time in seconds.

prompt_tokens int

Estimated system prompt tokens (per-call estimate from SystemPrompt.estimated_tokens).

prompt_token_ratio float

Per-call ratio of prompt tokens to total tokens (overhead indicator, derived via @computed_field). For multi-turn runs, the actual overhead is higher because the system prompt is resent on every turn.

accuracy_effort_ratio float | None

Accuracy-effort ratio from step-level quality signals (None when quality signals are unavailable).

Config:

  • frozen: True
  • allow_inf_nan: False

Fields:

Validators:

  • _cap_prompt_tokens

task_id pydantic-field

task_id = None

Task identifier

agent_id pydantic-field

agent_id

Agent identifier

turns_per_task pydantic-field

turns_per_task

Number of LLM turns to complete the task

tokens_per_task pydantic-field

tokens_per_task

Total tokens consumed (input + output)

cost_per_task pydantic-field

cost_per_task

Total cost for the task in the configured currency

duration_seconds pydantic-field

duration_seconds

Wall-clock execution time in seconds

prompt_tokens pydantic-field

prompt_tokens = 0

Estimated system prompt tokens

accuracy_effort_ratio pydantic-field

accuracy_effort_ratio = None

Accuracy-effort ratio from step-level quality signals (None when quality signals are unavailable)

prompt_token_ratio property

prompt_token_ratio

Per-call ratio of prompt tokens to total tokens (overhead indicator).

For multi-turn runs the actual overhead is higher because the system prompt is resent on every turn.

from_run_result classmethod

from_run_result(result)

Build metrics from an agent run result.

Parameters:

Name Type Description Default
result AgentRunResult

The AgentRunResult to extract metrics from.

required

Returns:

Type Description
TaskCompletionMetrics

New TaskCompletionMetrics with values extracted from

TaskCompletionMetrics

the result's execution context and metadata.

Source code in src/synthorg/engine/metrics.py
@classmethod
def from_run_result(cls, result: AgentRunResult) -> TaskCompletionMetrics:
    """Build metrics from an agent run result.

    Args:
        result: The ``AgentRunResult`` to extract metrics from.

    Returns:
        New ``TaskCompletionMetrics`` with values extracted from
        the result's execution context and metadata.
    """
    accumulated = result.execution_result.context.accumulated_cost
    ae_data = result.execution_result.metadata.get("accuracy_effort")
    ae_ratio: float | None = None
    if ae_data is not None:
        if isinstance(ae_data, AccuracyEffortRatio):
            ae_ratio = ae_data.ratio
        else:
            logger.warning(
                EXECUTION_METRICS_UNEXPECTED_TYPE,
                type=type(ae_data).__name__,
                task_id=result.task_id,
            )
    return cls(
        task_id=result.task_id,
        agent_id=result.agent_id,
        turns_per_task=result.total_turns,
        tokens_per_task=accumulated.total_tokens,
        cost_per_task=result.total_cost,
        duration_seconds=result.duration_seconds,
        prompt_tokens=result.system_prompt.estimated_tokens,
        accuracy_effort_ratio=ae_ratio,
    )

Errors

errors

Engine-layer error hierarchy.

EngineError

EngineError(message=None)

Bases: DomainError

Base exception for all engine-layer errors.

Inherits from :class:DomainError so the prefix-vs-category validator runs on every subclass; a typo in a subclass error_code whose first digit no longer matches the declared error_category is rejected at class-definition time.

Class Attributes

status_code: Default HTTP status for API exposure (500). error_code: Default RFC 9457 error code. error_category: Default RFC 9457 error category. retryable: Whether the client should retry the request. default_message: Generic 5xx-safe message used by exception handlers.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

PromptBuildError

PromptBuildError(message=None)

Bases: EngineError

Raised when system prompt construction fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ExecutionStateError

ExecutionStateError(message=None)

Bases: EngineError

Raised when an execution state transition is invalid.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

MaxTurnsExceededError

MaxTurnsExceededError(message=None)

Bases: EngineError

Raised when turn_count reaches max_turns during execution.

Enforced by AgentContext.with_turn_completed when the hard turn limit has been reached.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

LoopExecutionError

LoopExecutionError(message=None)

Bases: EngineError

Non-recoverable execution loop error for the engine layer.

The execution loop returns TerminationReason.ERROR internally. This exception is available for the engine layer above the loop to convert that result into a raised error when appropriate.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ParallelExecutionError

ParallelExecutionError(message=None)

Bases: EngineError

Raised when a parallel execution group encounters a fatal error.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ResourceConflictError

ResourceConflictError(message=None)

Bases: EngineError

Raised when resource claims conflict between assignments.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DecompositionError

DecompositionError(message=None)

Bases: EngineError

Base exception for task decomposition failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DecompositionBudgetExhaustedError

DecompositionBudgetExhaustedError(message=None)

Bases: DecompositionError

Raised when the model hit its token ceiling before writing content.

Distinct from a parse failure, and deliberately not retried: the next attempt truncates at the same place. A reasoning model spends completion tokens on its own reasoning before any content, so a budget sized for the answer alone returns an empty string that reaches the JSON parser and is reported as malformed JSON. The fix is a larger max_output_tokens, which is not what a parse error tells anyone to do.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DecompositionTimeoutError

DecompositionTimeoutError(message=None)

Bases: DecompositionError

Raised when a decomposition outran one of its wall-clock ceilings.

Distinct from every other decomposition failure, and for the same reason :class:DecompositionBudgetExhaustedError is: the ceiling is unchanged on the next attempt, so a retry buys the same outcome at full price. That price is the whole ceiling, which is what makes the distinction worth a type rather than a log line: a caller that retries a parse failure is paying for a fresh roll of the dice, while one that retries a timeout is paying the ceiling twice to reach the same place.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

PlanReviewUnavailableError

PlanReviewUnavailableError(message=None)

Bases: EngineError

Raised when a seated review panel could not review at all.

Distinct from a quiet panel: every seated reviewer's provider failed, so the plan carries no quality signal for a reason that is an outage, not a judgement. Parking it would present an unreviewed plan as an unobjectionable one, so plan preparation fails instead.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DecompositionCycleError

DecompositionCycleError(message=None)

Bases: DecompositionError

Raised when a dependency cycle is detected in the subtask graph.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DecompositionDepthError

DecompositionDepthError(message=None)

Bases: DecompositionError

Raised when decomposition exceeds the maximum nesting depth.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DecompositionSubtaskLimitError

DecompositionSubtaskLimitError(*, produced, limit)

Bases: DecompositionError

Raised when a plan carries more subtasks than the caller allowed.

Every strategy refuses an over-limit plan rather than substituting a smaller one: the request named the ceiling, and quietly returning a thinner plan the operator never saw is a worse answer wearing a success.

Both numbers are attributes, not only prose, so a caller can offer to raise the ceiling to the number actually produced without parsing the message. Composing the message here also keeps the three strategies from wording the same refusal differently.

Source code in src/synthorg/engine/errors.py
def __init__(self, *, produced: int, limit: int) -> None:
    super().__init__(
        f"Plan has {produced} subtasks, exceeds max_subtasks of {limit}"
    )
    self.produced: int = produced
    self.limit: int = limit

RetrospectiveError

RetrospectiveError(message=None)

Bases: EngineError

Base exception for objective-retrospective capture failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

RetrospectiveParseError

RetrospectiveParseError(message=None)

Bases: RetrospectiveError

Raised when a submitted retrospective cannot be parsed.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

InitiativeEvaluationError

InitiativeEvaluationError(message=None)

Bases: EngineError

Base exception for initiative-evaluation failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

InitiativeEvaluationParseError

InitiativeEvaluationParseError(message=None)

Bases: InitiativeEvaluationError

Raised when a submitted evaluation cannot be parsed.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

PlanReviewError

PlanReviewError(message=None)

Bases: EngineError

Base exception for stakeholder plan-review failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

PlanReviewParseError

PlanReviewParseError(message=None)

Bases: PlanReviewError

Raised when a panellist's submitted review cannot be parsed.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

PlanReviewCategoryGuidanceError

PlanReviewCategoryGuidanceError(message=None)

Bases: PlanReviewError

Raised when a finding category carries no reviewer-facing meaning.

The brief and the tool schema render the vocabulary from one mapping, so a category present in the enum and absent from that mapping would reach a reviewer as a bare name. A reviewer shown a name it was never told the sense of proposes its own, which is the behaviour the vocabulary exists to remove, so the render fails rather than shipping a half-explained list.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskRoutingError

TaskRoutingError(message=None)

Bases: EngineError

Raised when task routing to an agent fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskAssignmentError

TaskAssignmentError(message=None)

Bases: EngineError

Raised when task assignment fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

NoEligibleAgentError

NoEligibleAgentError(message=None)

Bases: TaskAssignmentError

Raised when no eligible agent is found for assignment.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

RecoveryConfigError

RecoveryConfigError(message=None)

Bases: EngineError

Configuration cannot satisfy the selected recovery strategy.

Typical cause: EngineRecoveryConfig.strategy == CHECKPOINT but no :class:CheckpointRepository was wired through to the factory.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

RecoveryCheckpointMissingError

RecoveryCheckpointMissingError(message=None)

Bases: EngineError

A resumable recovery result carries no checkpoint to resume from.

The strategy answered can_resume true and then supplied no checkpoint JSON, so the two halves of its own answer disagree. Typed rather than a bare RuntimeError because the recovery boundary catches broadly: an untyped breach degrades into one warning line indistinguishable from any other failure the resume path hit.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ParkedContextRepoMissingError

ParkedContextRepoMissingError(message=None)

Bases: EngineError

A context was parked with nowhere to persist it.

Raised rather than returning quietly, because a park that stores nothing is a run reported PARKED that no resume can ever find: the approval waits for a decision, the decision looks up a parked context that was never written, and the run's only remaining exit is a manual cancellation nobody knows to perform. Every caller already has a honest fallback for a failed park (a hard-ceiling crossing stops the run as BUDGET_EXHAUSTED, a tool escalation denies), so failing loud costs a real behaviour and buys back a reachable one.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ProjectNotFoundError

ProjectNotFoundError(*, project_id=None)

Bases: EngineError

Referenced project does not exist.

The single not-found error for a missing project, raised from both the engine lookup and work-pipeline intake paths. The project_id attribute is for structured logs only and must NOT be surfaced to clients; the wire message stays the generic default_message.

Source code in src/synthorg/engine/errors.py
def __init__(self, *, project_id: NotBlankStr | None = None) -> None:
    super().__init__(self.default_message)
    self.project_id: NotBlankStr | None = project_id

ProjectRepositoryNotConfiguredError

ProjectRepositoryNotConfiguredError(*, project_id=None)

Bases: EngineError

Task declares a project but no project repository is configured.

Fail-loud precondition: with no project repository wired the engine cannot resolve the task's project or enforce its budget, so it must not run the agent unvalidated. Raised into the engine's fatal-error boundary so the task terminates FAILED with the surfaced reason. The project_id attribute is for structured logs only.

Source code in src/synthorg/engine/errors.py
def __init__(self, *, project_id: NotBlankStr | None = None) -> None:
    super().__init__(self.default_message)
    self.project_id: NotBlankStr | None = project_id

WorkspaceError

WorkspaceError(message=None)

Bases: EngineError

Base exception for workspace isolation failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkspaceSetupError

WorkspaceSetupError(message=None)

Bases: WorkspaceError

Raised when workspace creation fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkspaceMergeError

WorkspaceMergeError(message=None)

Bases: WorkspaceError

Raised when workspace merge fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkspaceCleanupError

WorkspaceCleanupError(message=None)

Bases: WorkspaceError

Raised when workspace teardown fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkspaceLimitError

WorkspaceLimitError(message=None)

Bases: WorkspaceError

Raised when maximum concurrent workspaces reached.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkspacePushError

WorkspacePushError(message=None)

Bases: WorkspaceError

Raised when the coordinator-owned push to the git backend fails.

Distinct from :class:WorkspaceMergeError (local git merge state) so callers can tell a forge/remote push rejection apart from a local textual merge conflict.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ProjectWorkspaceError

ProjectWorkspaceError(message=None)

Bases: EngineError

Base exception for persistent project-workspace failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ProjectWorkspaceNotProvisionedError

ProjectWorkspaceNotProvisionedError(*, project_id)

Bases: ProjectWorkspaceError

Raised when a project workspace is required but not yet provisioned.

The wire message stays generic to avoid leaking identifiers; the project_id attribute is for structured logs only and must NOT be surfaced to clients.

Source code in src/synthorg/engine/errors.py
def __init__(self, *, project_id: NotBlankStr) -> None:
    super().__init__("Project workspace not provisioned")
    self.project_id: NotBlankStr = project_id

GitBackendError

GitBackendError(message=None)

Bases: EngineError

Base exception for pluggable git-backend failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendConfigError

GitBackendConfigError(message=None)

Bases: GitBackendError

Raised when git-backend configuration is invalid for the strategy.

Fail-fast at factory construction (e.g. LOCAL_PATH selected but no local_repo_path, or EXTERNAL_REMOTE without its connection catalog / secret-backend dependency).

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendProvisionError

GitBackendProvisionError(message=None)

Bases: GitBackendError

Raised when the git backend fails to provision a repository.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendSeedError

GitBackendSeedError(message=None)

Bases: GitBackendError

Raised when the git backend fails to seed an existing source.

Seeding is the one-shot import of an existing repository (clone of a remote URL or copy of a local path) into a freshly provisioned workspace. Distinct from provisioning (which creates an empty repo): a seed onto a workspace that already holds a git history fails here, and the brownfield intake service maps that to its own typed error.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendPushError

GitBackendPushError(message=None)

Bases: GitBackendError

Raised when the git backend fails to push a branch.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendFetchError

GitBackendFetchError(message=None)

Bases: GitBackendError

Raised when the git backend fails to fetch from the remote.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendRemoteMissingError

GitBackendRemoteMissingError(message=None)

Bases: GitBackendError

Raised when a push targets a forge repo that does not exist yet.

Distinct from a transient push failure: the operator's credential is valid but the addressed repository has never been created. The external-remote backend catches this to trigger lazy forge-API repo provisioning (create-then-retry-once); it is NOT retried by the transient-I/O retry handler.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendRateLimitError

GitBackendRateLimitError(message=None, *, retry_after=None)

Bases: GitBackendError

Raised when a forge rate-limits a git or forge-API operation.

Retryable via the transient-I/O backoff handler. retry_after carries the server-advertised cooldown (seconds) when present, for observability; the backoff itself is exponential (Pattern A).

Source code in src/synthorg/engine/errors.py
def __init__(
    self,
    message: str | None = None,
    *,
    retry_after: float | None = None,
) -> None:
    super().__init__(message)
    self.retry_after: float | None = retry_after

GitBackendForgeApiError

GitBackendForgeApiError(message=None)

Bases: GitBackendError

Raised when a forge REST API call fails (non-auth).

Retryable: forge-API 5xx / connection failures are transient.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

GitBackendForgeAuthError

GitBackendForgeAuthError(message=None)

Bases: GitBackendForgeApiError

Raised on 401/403 forge-API responses (invalid/expired token).

Non-retryable: a fresh credential is required, not a backoff.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

ProjectEnvironmentError

ProjectEnvironmentError(message=None)

Bases: EngineError

Base exception for reproducible per-project environment failures.

Named ProjectEnvironmentError (not EnvironmentError) to avoid shadowing the built-in EnvironmentError alias of OSError; mirrors the :class:ProjectWorkspaceError sibling.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

EnvironmentConfigError

EnvironmentConfigError(message=None)

Bases: ProjectEnvironmentError

Raised when environment configuration is invalid for the strategy.

Fail-fast at factory construction (e.g. a strategy selected without the runtime dependency it requires, mirroring :class:GitBackendConfigError).

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

EnvironmentProvisionError

EnvironmentProvisionError(message=None)

Bases: ProjectEnvironmentError

Raised when an environment strategy fails to provision.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

EnvironmentDockerBuildError

EnvironmentDockerBuildError(message=None)

Bases: EnvironmentProvisionError

Raised when the devcontainer image build fails.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

EnvironmentBackendUnavailableError

EnvironmentBackendUnavailableError(message=None)

Bases: ProjectEnvironmentError

Raised when a declaration needs a sandbox backend that is not active.

Loud, never silent: e.g. a DEVCONTAINER declaration on a project whose build/test categories resolve to the subprocess backend cannot build a sealed image, so provisioning fails rather than degrading to an unfaithful host-only run.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskEngineError

TaskEngineError(message=None)

Bases: EngineError

Base exception for all task engine errors.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskEngineNotRunningError

TaskEngineNotRunningError(message=None)

Bases: TaskEngineError

Raised when a mutation is submitted to a stopped task engine.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskEngineQueueFullError

TaskEngineQueueFullError(message=None)

Bases: TaskEngineError

Raised when the task engine queue is at capacity.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskMutationError

TaskMutationError(message=None)

Bases: TaskEngineError

Raised when a task mutation fails (not found, validation, etc.).

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskNotFoundError

TaskNotFoundError(message=None)

Bases: TaskMutationError, NotFoundError

Raised when a task is not found during mutation.

Multi-inherits :class:TaskMutationError (engine-layer family catch) and :class:NotFoundError (API-layer :func:require_resource_or_404 accepts as error_class).

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskVersionConflictError

TaskVersionConflictError(message=None)

Bases: TaskMutationError

Raised when optimistic concurrency version does not match.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskOrphanedPlanError

TaskOrphanedPlanError(message=None)

Bases: TaskEngineError

A task names a plan that no longer exists.

Filing it would leave live work under nothing: its plan id resolves to no row, so the rollup that would notice the work never reaches it. The complement of the plan delete's own guard, which refuses to remove a plan while live tasks exist.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

TaskInternalError

TaskInternalError(message=None)

Bases: TaskEngineError

Raised when a task mutation fails due to an internal engine error.

Sibling of :class:TaskMutationError, not a subtype, so a broad except TaskMutationError handler does not accidentally catch internal engine faults. Inherits the default 500 / ENGINE_ERROR / INTERNAL metadata from :class:TaskEngineError.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

DelegationRoundLimitError

DelegationRoundLimitError(current_round, soft_limit)

Bases: EngineError

Hard abort when delegation rounds exceed 2x the soft cap.

Attributes:

Name Type Description
current_round int

The round number that triggered the abort.

soft_limit int

The configured soft cap on delegation rounds.

Source code in src/synthorg/engine/errors.py
def __init__(self, current_round: int, soft_limit: int) -> None:
    self.current_round: int = current_round
    self.soft_limit: int = soft_limit
    super().__init__(
        f"Delegation round {current_round} exceeds hard limit "
        f"({soft_limit * 2}, soft cap {soft_limit})"
    )

CoordinationError

CoordinationError(message=None)

Bases: EngineError

Base exception for multi-agent coordination failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

CoordinationConfigError

CoordinationConfigError(message=None)

Bases: CoordinationError

Coordinator configuration is invalid at startup.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

CoordinationPhaseError

CoordinationPhaseError(message, *, phase, partial_phases=())

Bases: CoordinationError

Raised when a coordination pipeline phase fails.

Carries the failing phase name and all phase results accumulated up to and including the failure, enabling partial-result inspection.

Attributes:

Name Type Description
phase str

Name of the phase that failed.

partial_phases tuple[CoordinationPhaseResult, ...]

Phase results accumulated before and including this failure.

Source code in src/synthorg/engine/errors.py
def __init__(
    self,
    message: str,
    *,
    phase: str,
    partial_phases: tuple[CoordinationPhaseResult, ...] = (),
) -> None:
    super().__init__(message)
    self.phase: str = phase
    self.partial_phases: tuple[CoordinationPhaseResult, ...] = partial_phases

RuntimeServicesBuildError

RuntimeServicesBuildError(message=None)

Bases: EngineError

Raised when the boot/reinit runtime-services build fails.

Wraps the underlying failure from build_runtime_services (provider registry, tool registry, agent engine, or coordinator factory) so the boot hook and the /setup/complete controller see a typed domain error instead of a raw exception. The original cause is preserved via raise ... from exc.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowExecutionError

WorkflowExecutionError(message=None)

Bases: EngineError

Base exception for workflow execution failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowDefinitionInvalidError

WorkflowDefinitionInvalidError(message=None)

Bases: WorkflowExecutionError

Raised when a workflow definition fails validation at activation time.

422 + WORKFLOW_DEFINITION_INVALID: a definition that fails activation-time structural checks is a caller-side validation failure surfaced after the request reached the engine, not an internal fault. Distinct from :class:WorkflowDefinitionValidationError (the create/update path, WORKFLOW_DEFINITION_VALIDATION_FAILED) so a client can tell an activation-time rejection from a create/update one; both stay in the 422 VALIDATION category.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowConditionEvalError

WorkflowConditionEvalError(message=None)

Bases: WorkflowExecutionError

Raised when a condition expression cannot be evaluated.

422 + WORKFLOW_CONDITION_EVAL_FAILED: a condition expression that fails evaluation is authored by the caller as part of the workflow definition, so the failure is a request-shape problem rather than an engine fault.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowExecutionNotFoundError

WorkflowExecutionNotFoundError(message=None)

Bases: WorkflowExecutionError, NotFoundError

Raised when a workflow execution instance is not found.

Multi-inherits :class:WorkflowExecutionError (engine-layer family catch) and :class:NotFoundError (:func:require_resource_or_404 accepts as error_class).

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SubworkflowNotFoundError

SubworkflowNotFoundError(message, *, subworkflow_id, version)

Bases: WorkflowExecutionError

Raised when a referenced subworkflow version cannot be resolved.

Attributes:

Name Type Description
subworkflow_id NotBlankStr

The subworkflow identifier.

version NotBlankStr

The semver pin that failed to resolve.

Source code in src/synthorg/engine/errors.py
def __init__(
    self,
    message: str,
    *,
    subworkflow_id: NotBlankStr,
    version: NotBlankStr,
) -> None:
    super().__init__(message)
    self.subworkflow_id: NotBlankStr = subworkflow_id
    self.version: NotBlankStr = version

SubworkflowCycleError

SubworkflowCycleError(message, *, cycle_path)

Bases: WorkflowExecutionError

Raised when the subworkflow reference graph contains a cycle.

Attributes:

Name Type Description
cycle_path tuple[tuple[str, str], ...]

Ordered (subworkflow_id, version) tuples that participate in the cycle.

Source code in src/synthorg/engine/errors.py
def __init__(
    self,
    message: str,
    *,
    cycle_path: tuple[tuple[str, str], ...],
) -> None:
    super().__init__(message)
    self.cycle_path: tuple[tuple[str, str], ...] = cycle_path

SubworkflowDepthExceededError

SubworkflowDepthExceededError(message, *, depth, max_depth)

Bases: WorkflowExecutionError

Raised when runtime subworkflow nesting exceeds the configured limit.

Attributes:

Name Type Description
depth int

The depth at which the limit was exceeded.

max_depth int

The configured maximum.

Source code in src/synthorg/engine/errors.py
def __init__(
    self,
    message: str,
    *,
    depth: int,
    max_depth: int,
) -> None:
    super().__init__(message)
    self.depth: int = depth
    self.max_depth: int = max_depth

SubworkflowIOError

SubworkflowIOError(message=None)

Bases: WorkflowExecutionError

Raised when subworkflow input or output binding is invalid.

Covers missing required inputs, unknown inputs, unknown outputs, type mismatches, and invalid binding expressions. The 422 mapping treats binding mismatches as caller-side validation failures so the centralised RFC 9457 dispatch surfaces a structured envelope without controller-level translation.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowTypeInvalidError

WorkflowTypeInvalidError(message=None)

Bases: WorkflowExecutionError

Raised when a request specifies an unknown workflow_type value.

Uses WORKFLOW_TYPE_INVALID and 400: the value did not parse against the WorkflowType enum at the API boundary, a request-shape failure distinct from the workflow-definition validation codes.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowDefinitionValidationError

WorkflowDefinitionValidationError(message=None)

Bases: WorkflowExecutionError

Raised when a workflow definition fails structural checks.

The default message is intentionally generic so Pydantic validation detail does not leak to API clients; callers may still chain the underlying exception with raise … from exc for the structured log emitted by the centralised handler.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowYamlExportError

WorkflowYamlExportError(message=None)

Bases: WorkflowExecutionError

Raised when YAML serialisation of a workflow definition fails.

Maps to 422 (Unprocessable Entity) on /workflows/{id}/export: the request itself is well-formed, but the persisted definition cannot be serialised to YAML -- a content-level failure rather than a request-syntax problem.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

KanbanInvalidMoveError

KanbanInvalidMoveError(message=None)

Bases: EngineError

Raised when a requested Kanban column move is not a legal transition.

Maps to 400: the target column is unreachable from the card's current column under VALID_COLUMN_TRANSITIONS (e.g. a jump that skips the board's flow), a request-shape failure surfaced by the board service.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

KanbanWipLimitError

KanbanWipLimitError(message=None)

Bases: EngineError

Raised when a move would push a column past its enforced WIP limit.

Maps to 409 (conflict): the move is legal but the target column is at capacity and WIP enforcement is on, so the board rejects it until a slot frees. Advisory mode never raises this.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SprintError

SprintError(message=None)

Bases: EngineError

Base for agile-sprint service failures.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SprintNotFoundError

SprintNotFoundError(message=None)

Bases: SprintError, NotFoundError

Raised when a sprint id resolves to no persisted row.

Maps to 404: the requested sprint does not exist.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SprintBacklogFullError

SprintBacklogFullError(message=None)

Bases: SprintError, ConflictError

Raised when adding a task would exceed max_tasks_per_sprint.

Maps to 409 (conflict): the sprint backlog is at capacity, so the task belongs in a later sprint until a slot frees.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SprintTransitionConflictError

SprintTransitionConflictError(message=None)

Bases: SprintError, ConflictError

Raised when a sprint is not in the state a lifecycle hop requires.

Maps to 409 (conflict). Fires from two places: an upfront status check (e.g. add_task / start_sprint on a non-PLANNING sprint, or advancing a terminal sprint), and the transition_if CAS returning a mismatch when a concurrent advance moved the row out of the expected from state before this hop landed.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SprintTaskNotInBacklogError

SprintTaskNotInBacklogError(message=None)

Bases: SprintError, ValidationError

Raised when work is requested on a task outside the active sprint.

Maps to 400: the board move targets a task that is not in the active sprint's backlog, so the sprint gate rejects pulling it into flow.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

WorkflowExecutionAlreadyTerminalError

WorkflowExecutionAlreadyTerminalError(message=None)

Bases: VersionConflictError

Raised when cancel targets an execution already in a terminal status.

Distinct from :class:synthorg.core.domain_errors.VersionConflictError (4002) so API clients can discriminate "the execution finished before you cancelled" (no retry will succeed) from a row-level optimistic- concurrency race where the caller can re-read and try again. Both map to 409 + CONFLICT so the HTTP envelope shape is unchanged; only the error_code differs.

Source code in src/synthorg/core/domain_errors.py
def __init__(self, message: str | None = None) -> None:
    super().__init__(message or self.default_message)

SelfReviewError

SelfReviewError(*, task_id, agent_id)

Bases: EngineError

Raised when an agent attempts to review their own work.

Structurally prevents an agent from acting as reviewer on a task they executed, enforcing separation of duties at the approval gate.

The exception message is deliberately generic ("Self-review is not permitted") to avoid leaking internal agent/task identifiers across authorization boundaries when the message is surfaced via an HTTP error response. The task_id and agent_id attributes are available for structured logs but must NOT be passed to user-facing error responses.

Attributes:

Name Type Description
task_id NotBlankStr

The task identifier the self-review was attempted on.

agent_id NotBlankStr

The agent identifier that is both executor and reviewer.

Source code in src/synthorg/engine/errors.py
def __init__(
    self,
    *,
    task_id: NotBlankStr,
    agent_id: NotBlankStr,
) -> None:
    super().__init__("Self-review is not permitted")
    self.task_id: NotBlankStr = task_id
    self.agent_id: NotBlankStr = agent_id

Task Decomposition

protocol

Decomposition strategy protocol.

DecompositionStrategy

Bases: Protocol

Protocol for task decomposition strategies.

Implementations produce a DecompositionPlan from a parent task and a decomposition context. The plan describes subtask definitions and their dependency relationships.

decompose async

decompose(task, context)

Decompose a task into subtasks.

Parameters:

Name Type Description Default
task Task

The parent task to decompose.

required
context DecompositionContext

Decomposition constraints (max subtasks, depth).

required

Returns:

Type Description
DecompositionPlan

A decomposition plan with subtask definitions.

Source code in src/synthorg/engine/decomposition/protocol.py
async def decompose(
    self,
    task: Task,
    context: DecompositionContext,
) -> DecompositionPlan:
    """Decompose a task into subtasks.

    Args:
        task: The parent task to decompose.
        context: Decomposition constraints (max subtasks, depth).

    Returns:
        A decomposition plan with subtask definitions.
    """
    ...

get_strategy_name

get_strategy_name()

Return a human-readable name for this strategy.

Source code in src/synthorg/engine/decomposition/protocol.py
def get_strategy_name(self) -> str:
    """Return a human-readable name for this strategy."""
    ...

plans_any_task

plans_any_task()

Whether this strategy can plan a task it was not constructed for.

Recursion decomposes a CHILD task, which the caller never named, so a strategy holding one operator-supplied plan for one parent cannot serve it: asked about the child, it refuses, and the refusal fails the whole decomposition rather than the one subtask. Declared per strategy rather than inferred, because "can you plan something I have not shown you" is a claim about the implementation that no caller can test without asking.

Source code in src/synthorg/engine/decomposition/protocol.py
def plans_any_task(self) -> bool:
    """Whether this strategy can plan a task it was not constructed for.

    Recursion decomposes a CHILD task, which the caller never named, so a
    strategy holding one operator-supplied plan for one parent cannot serve
    it: asked about the child, it refuses, and the refusal fails the whole
    decomposition rather than the one subtask. Declared per strategy rather
    than inferred, because "can you plan something I have not shown you" is
    a claim about the implementation that no caller can test without asking.
    """
    ...

WorkspaceInventory

Bases: Protocol

Protocol answering what a project's workspace currently holds.

Narrow on purpose. Decomposition needs one fact about the workspace and has no business reaching the provisioning service that owns it: a planner must never provision, re-provision or otherwise touch the tree it is being told about.

describe_inventory async

describe_inventory(project_id)

Describe the project's workspace contents.

Parameters:

Name Type Description Default
project_id NotBlankStr

The project being planned for.

required

Returns:

Type Description
str

A phrase naming what the workspace holds, worded so an empty one

str

reads as "there is nothing there" rather than "unknown".

Source code in src/synthorg/engine/decomposition/protocol.py
async def describe_inventory(self, project_id: NotBlankStr) -> str:
    """Describe the project's workspace contents.

    Args:
        project_id: The project being planned for.

    Returns:
        A phrase naming what the workspace holds, worded so an empty one
        reads as "there is nothing there" rather than "unknown".
    """
    ...

models

Decomposition domain models.

Frozen Pydantic models for subtask definitions, decomposition plans and the decomposition tree. The context a decomposition runs under lives in :mod:synthorg.engine.decomposition.context, and what its execution adds up to is a different question again, in :mod:synthorg.engine.decomposition.status_rollup.

SubtaskDefinition pydantic-model

Bases: BaseModel

Definition of a single subtask within a decomposition plan.

Attributes:

Name Type Description
id NotBlankStr

Unique subtask identifier (within this decomposition).

title NotBlankStr

Short subtask title.

description NotBlankStr

Detailed subtask description.

dependencies tuple[NotBlankStr, ...]

IDs of other subtasks this one depends on.

estimated_complexity Complexity

Complexity estimate for routing.

stakes Stakes

Stakes level for capability-based agent selection.

required_skills tuple[NotBlankStr, ...]

Skill IDs needed for routing.

required_tags tuple[NotBlankStr, ...]

Tags needed for multi-faceted routing match. When set, the routing scorer awards a small bonus to agents whose matched-skill tags cover every required tag. Empty tuple disables the tag-match tier.

required_role NotBlankStr | None

Optional role name for routing.

expected_artifacts tuple[NotBlankStr, ...]

Deliverables this subtask must produce. These project onto the dispatched task's artifacts_expected and arm the fail-loud zero-artifact guard, so the subtask cannot terminate a success having produced nothing. A subtask that reaches a :class:DecompositionPlan must declare one (see that model's validator); the field stays optional here because the routing scorer also builds a bare, never-dispatched proxy definition.

acceptance_criteria tuple[NotBlankStr, ...]

Per-subtask criteria that define "done" for it.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_subtask

id pydantic-field

id

Unique subtask identifier

title pydantic-field

title

Short subtask title

description pydantic-field

description

Detailed subtask description

dependencies pydantic-field

dependencies = ()

IDs of subtasks this one depends on

estimated_complexity pydantic-field

estimated_complexity = Complexity.MEDIUM

Complexity estimate for routing

stakes pydantic-field

stakes = Stakes.NORMAL

Stakes level for capability-based agent selection

required_skills pydantic-field

required_skills = ()

Skill IDs needed for routing

required_tags pydantic-field

required_tags = ()

Tags needed for multi-faceted routing match

required_role pydantic-field

required_role = None

Optional role name for routing

expected_artifacts pydantic-field

expected_artifacts = ()

Deliverables this subtask must produce

acceptance_criteria pydantic-field

acceptance_criteria = ()

Per-subtask criteria that define done

satisfies pydantic-field

satisfies = ()

Objective success criteria this subtask advances

kind pydantic-field

kind = PlanItemKind.WORK

Whether this subtask is work to execute or a decision point

options pydantic-field

options = ()

For a DECISION subtask, the options to choose among

DecompositionPlan pydantic-model

Bases: BaseModel

Plan describing how a parent task is decomposed into subtasks.

Validates subtask collection integrity at construction: non-empty, unique IDs, valid dependency references, and a declared deliverable per WORK subtask. Cycle detection is handled by DependencyGraph.validate() in the service layer.

Attributes:

Name Type Description
parent_task_id NotBlankStr

ID of the task being decomposed.

subtasks tuple[SubtaskDefinition, ...]

Ordered subtask definitions.

task_structure TaskStructure

Structure the planner declared, or AUTO when it declared none. DecompositionService resolves AUTO through the classifier before the plan leaves the service, so every plan reaching a :class:DecompositionResult names its structure.

coordination_topology CoordinationTopology

Selected coordination topology.

planning_strategy NotBlankStr | None

Which planner produced this plan. Blank means the strategy did not say; a fallback always says, so the approval gate can show the operator that what they are being asked to approve is a single-shot substitute rather than the researched plan the owner was asked for.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_subtasks

parent_task_id pydantic-field

parent_task_id

ID of the task being decomposed

subtasks pydantic-field

subtasks

Ordered subtask definitions

task_structure pydantic-field

task_structure = TaskStructure.AUTO

Structure the planner declared; AUTO means it declared nothing and the classifier heuristic decides

coordination_topology pydantic-field

coordination_topology = CoordinationTopology.AUTO

Selected coordination topology

open_questions pydantic-field

open_questions = ()

Unresolved questions the planner surfaced for the human

assumptions pydantic-field

assumptions = ()

Assumptions the plan rests on

planning_strategy pydantic-field

planning_strategy = None

Which planner produced this plan; set when a fallback produced it so the substitution is visible on the durable plan

DecompositionResult pydantic-model

Bases: BaseModel

Result of a complete task decomposition.

One level of a decomposition. A subtask the atomicity policy judged oversized is decomposed again, and its own result hangs off children, so the whole shape is a tree rather than a list.

children defaults to empty, which is exactly what a non-recursive decomposition produces, so every reader that predates recursion sees the flat result it always saw.

Attributes:

Name Type Description
plan DecompositionPlan

The decomposition plan that was executed.

created_tasks tuple[Task, ...]

Task objects created from subtask definitions.

dependency_edges tuple[tuple[NotBlankStr, NotBlankStr], ...]

Directed edges (from_id, to_id) in the DAG.

depth int

This level's nesting depth, 0 at the root. Recorded rather than derived by the reader, because the reader most likely to want it holds one node and not the tree it came from.

children tuple[DecompositionResult, ...]

The decomposition of each subtask at this level that was split further, in no particular relation to created_tasks order.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_plan_task_consistency

plan pydantic-field

plan

Executed decomposition plan

created_tasks pydantic-field

created_tasks

Task objects created from subtask definitions

dependency_edges pydantic-field

dependency_edges = ()

Directed edges (from_id, to_id) in the DAG

depth pydantic-field

depth = 0

Nesting depth of this level, 0 at the root

children pydantic-field

children = ()

Decompositions of the subtasks at this level that split

split_task_ids property

split_task_ids

Ids of this level's tasks that were decomposed further.

Returns:

Type Description
frozenset[str]

The parent task id of each child decomposition.

leaf_tasks property

leaf_tasks

Every task in the tree that nothing below it replaced.

This is what gets dispatched: a task that was split is a container for the work below it, and running it as well would do that work twice.

Returns:

Type Description
tuple[Task, ...]

The leaves, this level's first, then each child's in order.

all_tasks property

all_tasks

Every task in the tree, split containers included.

Returns:

Type Description
tuple[Task, ...]

This level's tasks, then each child's, recursively.

max_depth_reached property

max_depth_reached

The deepest level this tree actually reached.

The measured counterpart of DecompositionContext.max_depth, which is only a ceiling: a planner that never produced an oversized subtask stops well short of it.

Returns:

Type Description
int

depth when nothing split, else the deepest child's answer.

service

Decomposition service.

Orchestrates strategy, classifier, DAG validation, and task creation to decompose a parent task into executable subtasks.

DecompositionService

DecompositionService(
    strategy,
    classifier,
    stakes_assessor=None,
    *,
    config_resolver=None,
    workspace_inventory=None,
)

Service orchestrating task decomposition.

Composes a decomposition strategy with a structure classifier, DAG validator, and task factory to produce executable subtasks.

Source code in src/synthorg/engine/decomposition/service.py
def __init__(
    self,
    strategy: DecompositionStrategy,
    classifier: TaskStructureClassifier,
    stakes_assessor: StakesAssessor | None = None,
    *,
    config_resolver: ConfigResolverProtocol | None = None,
    workspace_inventory: WorkspaceInventory | None = None,
) -> None:
    self._strategy = strategy
    self._classifier = classifier
    self._stakes_assessor = stakes_assessor or build_stakes_assessor()
    self._config_resolver = config_resolver
    self._workspace_inventory = workspace_inventory

decompose_task async

decompose_task(task, context)

Decompose a task into subtasks.

  1. Call strategy.decompose().
  2. Resolve the task structure: the planner's own declaration stands; only a plan that declared none falls to the classifier.
  3. Validate DAG via DependencyGraph.
  4. Create Task objects from SubtaskDefinitions.
  5. Return DecompositionResult.

Parameters:

Name Type Description Default
task Task

The parent task to decompose.

required
context DecompositionContext

Decomposition constraints.

required

Returns:

Type Description
DecompositionResult

Decomposition result with created tasks and dependency edges.

Raises:

Type Description
DecompositionTimeoutError

When any one planning session outruns coordination.decomposition_timeout_seconds, or the whole tree outruns coordination.decomposition_tree_timeout_seconds. Its own type because neither ceiling moves on a retry.

DecompositionError

When something inside timed out on its own without either ceiling firing, which IS worth retrying, and for every other decomposition failure.

Source code in src/synthorg/engine/decomposition/service.py
async def decompose_task(
    self,
    task: Task,
    context: DecompositionContext,
) -> DecompositionResult:
    """Decompose a task into subtasks.

    1. Call strategy.decompose().
    2. Resolve the task structure: the planner's own declaration
       stands; only a plan that declared none falls to the
       classifier.
    3. Validate DAG via DependencyGraph.
    4. Create Task objects from SubtaskDefinitions.
    5. Return DecompositionResult.

    Args:
        task: The parent task to decompose.
        context: Decomposition constraints.

    Returns:
        Decomposition result with created tasks and dependency edges.

    Raises:
        DecompositionTimeoutError: When any one planning session outruns
            ``coordination.decomposition_timeout_seconds``, or the whole
            tree outruns ``coordination.decomposition_tree_timeout_seconds``.
            Its own type because neither ceiling moves on a retry.
        DecompositionError: When something inside timed out on its own
            without either ceiling firing, which IS worth retrying, and for
            every other decomposition failure.
    """
    logger.info(
        DECOMPOSITION_STARTED,
        task_id=str(task.id),
        strategy=self._strategy.get_strategy_name(),
        current_depth=context.current_depth,
    )

    budget = await resolve_recursion_budget(self._config_resolver)
    # The outer of the two ceilings, and the only one that bounds a
    # CALLER. The inner one below bounds a planning session, and a
    # recursion runs one per node, so the number of sessions is the
    # branching factor to the power of the depth and no per-session
    # budget bounds the call at all. Two of the four callers are
    # request handlers.
    scope = asyncio.timeout(await self._tree_timeout_seconds())
    try:
        async with scope:
            return await self._do_decompose(
                task, await self._grounded(task, context), budget
            )
    except TimeoutError as exc:
        # Asked of the scope, not inferred from the type: this handler also
        # sees a TimeoutError that something INSIDE raised without any
        # ceiling firing, and the two deserve opposite answers. A ceiling
        # is unchanged on the next attempt, so a retry pays it again to
        # reach the same place; a call that timed out on its own is the
        # ordinary transient a retry exists for.
        raise self._timeout_failure(
            task, exc, expired=scope.expired(), ceiling="whole-tree"
        ) from exc
    except Exception as exc:
        reraise_critical(exc)
        logger.warning(
            DECOMPOSITION_FAILED,
            task_id=str(task.id),
            strategy=self._strategy.get_strategy_name(),
            error_type=type(exc).__name__,
            error=safe_error_description(exc),
        )
        raise

set_config_resolver

set_config_resolver(resolver)

Adopt the resolver the ceiling is read through.

A setter rather than a constructor argument because the coordinator factory that builds this service is already at its approved argument count, and threading one more through it would widen a signature the repository pins. The resolver is handed over right after the coordinator is assembled, before anything can decompose.

Parameters:

Name Type Description Default
resolver ConfigResolverProtocol

The live settings resolver.

required
Source code in src/synthorg/engine/decomposition/service.py
def set_config_resolver(self, resolver: ConfigResolverProtocol) -> None:
    """Adopt the resolver the ceiling is read through.

    A setter rather than a constructor argument because the coordinator
    factory that builds this service is already at its approved argument
    count, and threading one more through it would widen a signature the
    repository pins. The resolver is handed over right after the
    coordinator is assembled, before anything can decompose.

    Args:
        resolver: The live settings resolver.
    """
    self._config_resolver = resolver

rollup_status

rollup_status(parent_task_id, subtask_statuses)

Compute status rollup for a parent task.

Parameters:

Name Type Description Default
parent_task_id NotBlankStr

The parent task identifier.

required
subtask_statuses tuple[TaskStatus, ...]

Statuses of all subtasks.

required

Returns:

Type Description
SubtaskStatusRollup

Aggregated status rollup.

Source code in src/synthorg/engine/decomposition/service.py
def rollup_status(
    self,
    parent_task_id: NotBlankStr,
    subtask_statuses: tuple[TaskStatus, ...],
) -> SubtaskStatusRollup:
    """Compute status rollup for a parent task.

    Args:
        parent_task_id: The parent task identifier.
        subtask_statuses: Statuses of all subtasks.

    Returns:
        Aggregated status rollup.
    """
    return StatusRollup.compute(parent_task_id, subtask_statuses)

Task Routing

models

Task routing domain models.

Frozen Pydantic models for routing candidates, decisions, results, and topology configuration.

RoutingCandidate pydantic-model

Bases: BaseModel

A candidate agent for a subtask with scoring details.

Attributes:

Name Type Description
agent_identity AgentIdentity

The candidate agent.

score float

Match score between 0.0 and 1.0.

matched_skills tuple[NotBlankStr, ...]

Skills that matched the subtask requirements.

reason NotBlankStr

Human-readable explanation of the score.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

agent_identity pydantic-field

agent_identity

Candidate agent

score pydantic-field

score

Match score (0.0-1.0)

matched_skills pydantic-field

matched_skills = ()

Skills that matched subtask requirements

reason pydantic-field

reason

Explanation of score

RoutingDecision pydantic-model

Bases: BaseModel

Routing decision for a single subtask.

Attributes:

Name Type Description
subtask_id NotBlankStr

ID of the subtask being routed.

selected_candidate RoutingCandidate

The chosen agent candidate.

alternatives tuple[RoutingCandidate, ...]

Other candidates considered.

topology CoordinationTopology

Coordination topology for this subtask.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_selected_not_in_alternatives

subtask_id pydantic-field

subtask_id

Subtask being routed

selected_candidate pydantic-field

selected_candidate

Chosen agent candidate

alternatives pydantic-field

alternatives = ()

Other candidates considered

topology pydantic-field

topology

Coordination topology for this subtask

RoutingResult pydantic-model

Bases: BaseModel

Result of routing all subtasks in a decomposition.

Attributes:

Name Type Description
parent_task_id NotBlankStr

ID of the parent task.

decisions tuple[RoutingDecision, ...]

Routing decisions for routable subtasks.

unroutable tuple[NotBlankStr, ...]

IDs of subtasks with no matching agent.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_unique_subtask_ids

parent_task_id pydantic-field

parent_task_id

Parent task ID

decisions pydantic-field

decisions = ()

Routing decisions

unroutable pydantic-field

unroutable = ()

Subtask IDs with no matching agent

AutoTopologyConfig pydantic-model

Bases: BaseModel

Configuration for automatic topology selection.

Attributes:

Name Type Description
sequential_override CoordinationTopology

Topology for sequential structures.

parallel_default CoordinationTopology

Topology for parallel structures.

mixed_default CoordinationTopology

Topology for mixed structures.

parallel_artifact_threshold int

Artifact count above which parallel tasks use decentralized topology.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_no_auto_defaults

sequential_override pydantic-field

sequential_override = CoordinationTopology.SAS

Topology for sequential structures

parallel_default pydantic-field

parallel_default = CoordinationTopology.CENTRALIZED

Topology for parallel structures

mixed_default pydantic-field

mixed_default = CoordinationTopology.CONTEXT_DEPENDENT

Topology for mixed structures

parallel_artifact_threshold pydantic-field

parallel_artifact_threshold = 4

Artifact count threshold for decentralized topology

service

Task routing service.

Routes decomposed subtasks to appropriate agents: the capability ladder narrows the pool to the band that best fits what each subtask demands, the scorer ranks within it, and the topology is selected once for the wave.

The ladder runs here rather than only at dispatch because assignment and dispatch must reach the same verdict. Routing a subtask to an agent the dispatch will then refuse is the two-owner shape: the quieter authority wins and the operator sees a parked task with no assignment reason.

TaskRoutingService

TaskRoutingService(scorer, topology_selector, *, capability=None)

Routes subtasks to agents by capability fit, then by score.

For each subtask in a decomposition result, narrows the available agents to the band that best fits the capability the subtask demands, scores that band, and selects the best match. Subtasks with no viable candidate are reported as unroutable.

Parameters:

Name Type Description Default
scorer AgentTaskScorer

Ranks candidates within whichever capability band answers.

required
topology_selector TopologySelector

Chooses the wave's coordination topology.

required
capability CapabilityPolicy | None

The org's one capability policy, shared with the solo assignment path and with dispatch. None (a pipeline built without one) routes on score alone.

None
Source code in src/synthorg/engine/routing/service.py
def __init__(
    self,
    scorer: AgentTaskScorer,
    topology_selector: TopologySelector,
    *,
    capability: CapabilityPolicy | None = None,
) -> None:
    self._scorer = scorer
    self._topology_selector = topology_selector
    self._capability = capability

route

route(decomposition_result, available_agents, parent_task)

Route all subtasks to appropriate agents.

For each subtask: 1. Score all available agents. 2. Select the best candidate (highest score >= min_score). 3. Select topology from parent task override or plan structure. 4. Report unroutable subtasks.

Parameters:

Name Type Description Default
decomposition_result DecompositionResult

The decomposition to route.

required
available_agents tuple[AgentIdentity, ...]

Pool of agents to consider.

required
parent_task Task

The parent task (for topology selection).

required

Returns:

Type Description
RoutingResult

Routing result with decisions and unroutable subtask IDs.

Raises:

Type Description
ValueError

When the topology cannot be resolved from the parent task's override and plan structure.

Source code in src/synthorg/engine/routing/service.py
def route(
    self,
    decomposition_result: DecompositionResult,
    available_agents: tuple[AgentIdentity, ...],
    parent_task: Task,
) -> RoutingResult:
    """Route all subtasks to appropriate agents.

    For each subtask:
    1. Score all available agents.
    2. Select the best candidate (highest score >= min_score).
    3. Select topology from parent task override or plan structure.
    4. Report unroutable subtasks.

    Args:
        decomposition_result: The decomposition to route.
        available_agents: Pool of agents to consider.
        parent_task: The parent task (for topology selection).

    Returns:
        Routing result with decisions and unroutable subtask IDs.

    Raises:
        ValueError: When the topology cannot be resolved from
            the parent task's override and plan structure.
    """
    plan = decomposition_result.plan

    if str(parent_task.id) != plan.parent_task_id:
        msg = (
            f"parent_task.id {parent_task.id!r} does not "
            f"match plan.parent_task_id "
            f"{plan.parent_task_id!r}"
        )
        logger.warning(
            TASK_ROUTING_FAILED,
            parent_task_id=parent_task.id,
            plan_parent_task_id=plan.parent_task_id,
            error=msg,
        )
        raise ValueError(msg)

    logger.info(
        TASK_ROUTING_STARTED,
        parent_task_id=plan.parent_task_id,
        subtask_count=len(plan.subtasks),
        agent_count=len(available_agents),
    )

    if not available_agents:
        logger.warning(
            TASK_ROUTING_NO_AGENTS,
            parent_task_id=plan.parent_task_id,
            subtask_count=len(plan.subtasks),
        )
        return RoutingResult(
            parent_task_id=plan.parent_task_id,
            unroutable=tuple(s.id for s in plan.subtasks),
        )

    try:
        return self._do_route(decomposition_result, available_agents, parent_task)
    except Exception as exc:
        log_exception_redacted(
            logger, TASK_ROUTING_FAILED, exc, parent_task_id=plan.parent_task_id
        )
        raise

Task Assignment

protocol

Task assignment strategy protocol.

Defines the pluggable interface for assignment strategies.

TaskAssignmentStrategy

Bases: Protocol

Protocol for task assignment strategies.

Implementations must be synchronous (pure computation, no I/O) and return an AssignmentResult with the selected agent and ranked alternatives. TaskAssignmentService calls assign() synchronously -- async implementations will NOT work correctly.

Error signaling contract:

  • ManualAssignmentStrategy raises NoEligibleAgentError when the designated agent is not found or not ACTIVE, and TaskAssignmentError when task.assigned_to is None.
  • Scoring-based strategies (the ScoringBasedAssignmentStrategy compositions for role-based, load-balanced, cost-optimized, hierarchical, and auction) return AssignmentResult(selected=None, ...) when no agent meets the minimum score threshold.

TaskAssignmentService propagates both patterns: it re-raises TaskAssignmentError (including its subclass NoEligibleAgentError) and logs a warning when result.selected is None, returning the result to the caller for handling.

name property

name

Strategy name identifier.

assign

assign(request)

Assign a task to an agent based on the strategy's algorithm.

Parameters:

Name Type Description Default
request AssignmentRequest

The assignment request with task and agent pool.

required

Returns:

Type Description
AssignmentResult

Assignment result with selected agent and alternatives.

AssignmentResult

selected may be None when no eligible agent is

AssignmentResult

found (scoring strategies) -- callers must check this.

Raises:

Type Description
TaskAssignmentError

When preconditions are violated (e.g. missing assigned_to for manual strategy).

NoEligibleAgentError

When the designated agent cannot be found or is not ACTIVE (manual strategy only).

Source code in src/synthorg/engine/assignment/protocol.py
def assign(
    self,
    request: AssignmentRequest,
) -> AssignmentResult:
    """Assign a task to an agent based on the strategy's algorithm.

    Args:
        request: The assignment request with task and agent pool.

    Returns:
        Assignment result with selected agent and alternatives.
        ``selected`` may be ``None`` when no eligible agent is
        found (scoring strategies) -- callers must check this.

    Raises:
        TaskAssignmentError: When preconditions are violated
            (e.g. missing ``assigned_to`` for manual strategy).
        NoEligibleAgentError: When the designated agent cannot
            be found or is not ACTIVE (manual strategy only).
    """
    ...

models

Task assignment domain models.

Frozen Pydantic models for assignment requests, results, agent workloads, and assignment candidates.

AgentWorkload pydantic-model

Bases: BaseModel

Snapshot of an agent's current workload.

Attributes:

Name Type Description
agent_id NotBlankStr

Unique agent identifier.

active_task_count int

Number of tasks currently in progress.

total_cost float

Total cost incurred by this agent in the configured currency.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

agent_id pydantic-field

agent_id

Agent identifier

active_task_count pydantic-field

active_task_count

Number of tasks currently in progress

total_cost pydantic-field

total_cost = 0.0

Total cost incurred by this agent in the configured currency

AssignmentCandidate pydantic-model

Bases: BaseModel

A candidate agent for task assignment with scoring details.

Attributes:

Name Type Description
agent_identity AgentIdentity

The candidate agent.

score float

Match score between 0.0 and 1.0.

matched_skills tuple[NotBlankStr, ...]

Skills that matched the assignment requirements.

reason NotBlankStr

Human-readable explanation of the score.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

agent_identity pydantic-field

agent_identity

Candidate agent

score pydantic-field

score

Match score (0.0-1.0)

matched_skills pydantic-field

matched_skills = ()

Skills that matched assignment requirements

reason pydantic-field

reason

Explanation of score

AssignmentRequest pydantic-model

Bases: BaseModel

Request for task assignment to an agent.

The required_skills and required_role fields live here (not on Task) so that scoring strategies can evaluate agent-task fit without modifying the Task model.

Attributes:

Name Type Description
task Task

The task to assign.

available_agents tuple[AgentIdentity, ...]

Pool of agents to consider (must be non-empty, unique by agent id).

workloads tuple[AgentWorkload, ...]

Current workload snapshots per agent (unique by agent_id).

min_score float

Minimum score threshold for eligibility.

required_skills tuple[NotBlankStr, ...]

Skill names needed for scoring.

required_role NotBlankStr | None

Optional role name for scoring.

stakes Stakes

How consequential the task is, gating the low-confidence band and the capability floor. Derived from task rather than carried, so the two cannot disagree.

max_concurrent_tasks int | None

Maximum concurrent tasks per agent. Agents at or above this limit are excluded from scoring. None disables the limit. Corresponds to TaskAssignmentConfig.max_concurrent_tasks_per_agent.

required_capability CapabilityLevel | None

The rung this work demands, from the stakes floor raised by substantial complexity. Not a hard filter: it is the target of the capability ladder, which prefers an exact match, then the nearest rung above, then (where the stakes allow) the nearest rung below. None imposes no requirement.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_collections

task pydantic-field

task

The task to assign

available_agents pydantic-field

available_agents

Pool of agents to consider

workloads pydantic-field

workloads = ()

Current workload snapshots per agent

min_score pydantic-field

min_score = 0.1

Minimum score threshold for eligibility

low_confidence_score pydantic-field

low_confidence_score = 0.35

Score below which a winning fit is treated as low-confidence. The fit is still assigned (never a hard-fail), but flagged: high/critical stakes log an operator-facing escalation for review, low/normal only flag. Clamped to at least min_score via effective_low_confidence_score.

required_skills pydantic-field

required_skills = ()

Skill names needed for scoring

required_role pydantic-field

required_role = None

Optional role name for scoring

max_concurrent_tasks pydantic-field

max_concurrent_tasks = None

Maximum concurrent tasks per agent. Agents at or above this limit are excluded from scoring. None = no limit.

required_capability pydantic-field

required_capability = None

Capability rung this work demands. None imposes no requirement.

stakes property

stakes

Read the task's own stakes.

Derived rather than carried so there is one owner. As a field it defaulted to NORMAL with nothing tying it to the task, so a caller could hand a critical task to assignment under a normal floor and a normal low-confidence band, and neither the floor nor the escalation would say the stakes it read were not the task's.

Returns:

Type Description
Stakes

The task's assessed stakes.

effective_low_confidence_score property

effective_low_confidence_score

The low-confidence band, clamped to at least min_score.

A raised eligibility floor above the configured band collapses the marginal zone (there is nothing between the two), so the band never sits below the floor.

Returns:

Type Description
float

max(low_confidence_score, min_score).

AssignmentResult pydantic-model

Bases: BaseModel

Result of a task assignment operation.

Attributes:

Name Type Description
task_id NotBlankStr

ID of the task that was assigned.

strategy_used NotBlankStr

Name of the strategy that produced this result.

selected AssignmentCandidate | None

The selected candidate (None if no viable agent).

alternatives tuple[AssignmentCandidate, ...]

Other candidates considered, ranked by score.

reason NotBlankStr

Human-readable explanation of the assignment decision.

low_confidence bool

Whether the selected candidate cleared eligibility but scored below the low-confidence band (a marginal fit that was assigned anyway and flagged; high/critical stakes additionally log an operator-facing escalation for review).

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_selected_not_in_alternatives

task_id pydantic-field

task_id

Task identifier

strategy_used pydantic-field

strategy_used

Name of the strategy used

selected pydantic-field

selected = None

Selected candidate (None if no viable agent)

low_confidence pydantic-field

low_confidence = False

Selected candidate scored below the low-confidence band

alternatives pydantic-field

alternatives = ()

Other candidates considered, ranked by score

reason pydantic-field

reason

Explanation of decision

service

Task assignment service.

Orchestrates task assignment by delegating to a pluggable TaskAssignmentStrategy with logging and validation.

TaskAssignmentService

TaskAssignmentService(strategy, *, capability=None)

Orchestrates task assignment via a pluggable strategy.

Validates task status and stamps the capability the work demands onto the request before delegating to the strategy. Does NOT mutate the task -- callers are responsible for any subsequent status transitions.

The requirement is derived here rather than by each caller so every assignment asks for the same rung: an agent is a fixed (role, personality, model) unit, and the answer to work that needs more capability is a different agent, so which agents are eligible must not depend on which caller assembled the request.

Parameters:

Name Type Description Default
strategy TaskAssignmentStrategy

The assignment strategy to delegate to.

required
capability CapabilityPolicy | None

The org's one capability policy. None leaves assignments ungated by capability.

None
Source code in src/synthorg/engine/assignment/service.py
def __init__(
    self,
    strategy: TaskAssignmentStrategy,
    *,
    capability: CapabilityPolicy | None = None,
) -> None:
    self._strategy = strategy
    self._capability = capability

assign

assign(request)

Assign a task to an agent using the configured strategy.

Parameters:

Name Type Description Default
request AssignmentRequest

The assignment request. Its required_capability is overwritten from the task's own stakes and complexity when a policy is wired, so a caller cannot assign consequential work under a weaker requirement by omitting it.

required

Returns:

Type Description
AssignmentResult

Assignment result from the strategy.

Raises:

Type Description
TaskAssignmentError

If the task status is not eligible for assignment.

Source code in src/synthorg/engine/assignment/service.py
def assign(self, request: AssignmentRequest) -> AssignmentResult:
    """Assign a task to an agent using the configured strategy.

    Args:
        request: The assignment request. Its ``required_capability`` is
            overwritten from the task's own stakes and complexity when a
            policy is wired, so a caller cannot assign consequential work
            under a weaker requirement by omitting it.

    Returns:
        Assignment result from the strategy.

    Raises:
        TaskAssignmentError: If the task status is not eligible
            for assignment.
    """
    task = request.task

    if self._capability is not None:
        request = request.model_copy(
            update={
                "required_capability": self._capability.required_for(
                    task.stakes, task.estimated_complexity
                ),
            },
        )

    if task.status not in _ASSIGNABLE_STATUSES:
        msg = (
            f"Task {str(task.id)!r} has status {task.status.value!r}, "
            f"expected one of "
            f"{sorted(s.value for s in _ASSIGNABLE_STATUSES)}"
        )
        logger.warning(
            TASK_ASSIGNMENT_FAILED,
            task_id=str(task.id),
            status=task.status.value,
            error=msg,
        )
        raise TaskAssignmentError(msg)

    # Stamping the requirement is not enforcing it. The strategy walks the
    # same ladder, but only when IT was also given a policy, so a service
    # holding one and delegating to a strategy without one would promise a
    # requirement and apply none. Refusing here covers the case the ladder
    # cannot: nobody the work's stakes permit at all.
    if self._capability is not None:
        unsanctioned = self._refuse_unsanctioned(request)
        if unsanctioned is not None:
            return unsanctioned

    logger.info(
        TASK_ASSIGNMENT_STARTED,
        task_id=str(task.id),
        strategy=self._strategy.name,
        agent_count=len(request.available_agents),
    )

    try:
        result = self._strategy.assign(request)
    except TaskAssignmentError:
        raise  # already logged by the strategy
    except Exception as exc:
        log_exception_redacted(
            logger,
            TASK_ASSIGNMENT_FAILED,
            exc,
            task_id=str(task.id),
            strategy=self._strategy.name,
        )
        raise

    if result.selected is not None:
        logger.info(
            TASK_ASSIGNMENT_AGENT_SELECTED,
            task_id=str(task.id),
            agent_name=result.selected.agent_identity.name,
            score=result.selected.score,
            strategy=result.strategy_used,
        )
    else:
        logger.warning(
            TASK_ASSIGNMENT_NO_ELIGIBLE,
            task_id=str(task.id),
            strategy=self._strategy.name,
            reason=result.reason,
        )

    logger.info(
        TASK_ASSIGNMENT_COMPLETE,
        task_id=str(task.id),
        strategy=result.strategy_used,
        selected=result.selected is not None,
        alternatives=len(result.alternatives),
    )

    return result

Error Classification

models

Classification result models for the error taxonomy pipeline.

Defines severity levels, individual error findings, and aggregated classification results produced by the detection pipeline.

ErrorSeverity

Bases: StrEnum

Severity level for a detected coordination error.

ErrorFinding pydantic-model

Bases: BaseModel

A single coordination error detected during classification.

Attributes:

Name Type Description
category ErrorCategory

The error category from the taxonomy.

severity ErrorSeverity

Severity level of the finding.

description NotBlankStr

Human-readable description of the error.

evidence tuple[NotBlankStr, ...]

Supporting evidence extracted from the conversation.

turn_range tuple[int, int] | None

(start, end) 0-based index range where the error was observed, or None if the error cannot be attributed to specific positions. For conversation-based detectors this is the message index; for turn-based detectors this is the index into the turns tuple.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_turn_range

category pydantic-field

category

Error taxonomy category

severity pydantic-field

severity

Severity level

description pydantic-field

description

Error description

evidence pydantic-field

evidence = ()

Supporting evidence from conversation

turn_range pydantic-field

turn_range = None

0-based index range (start, end) where error was observed. For conversation-based detectors this is the message index in the conversation tuple; for turn-based detectors this is the index into the turns tuple.

ClassificationResult pydantic-model

Bases: BaseModel

Aggregated result from the error classification pipeline.

Attributes:

Name Type Description
execution_id NotBlankStr

Unique identifier for the execution run.

agent_id NotBlankStr

Agent that was executing.

task_id NotBlankStr

Task being executed.

categories_checked tuple[ErrorCategory, ...]

Which error categories were checked.

findings tuple[ErrorFinding, ...]

All detected error findings.

classified_at AwareDatetime

Timestamp when classification completed.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_findings_match_categories

execution_id pydantic-field

execution_id

Execution run identifier

agent_id pydantic-field

agent_id

Agent identifier

task_id pydantic-field

task_id

Task identifier

categories_checked pydantic-field

categories_checked

Categories that were checked

findings pydantic-field

findings = ()

Detected error findings

classified_at pydantic-field

classified_at

Classification timestamp

finding_count property

finding_count

Total number of detected findings.

has_findings property

has_findings

Whether any error findings were detected.

pipeline

Error classification pipeline.

Orchestrates the detection of coordination errors from an execution result using the configured error taxonomy. Detectors are discovered dynamically from the ErrorTaxonomyConfig.detectors dict and dispatched via the Detector protocol. The pipeline never raises exceptions -- all errors are caught and logged.

classify_execution_errors async

classify_execution_errors(
    execution_result,
    agent_id,
    task_id,
    *,
    config,
    task_repo=None,
    provider=None,
    sinks=(),
)

Classify coordination errors from an execution result.

Discovers detectors from config.detectors, loads scope-appropriate context, runs detectors sequentially (concurrency happens inside CompositeDetector), and dispatches results to registered sinks.

Rate limiting is handled by the BaseCompletionProvider internally; semantic detectors do not take a separate rate limiter because a second limiter on the same shared instance would double-throttle.

Returns None when the taxonomy is disabled. Never raises; all exceptions except MemoryError/RecursionError are caught and logged as CLASSIFICATION_ERROR.

Parameters:

Name Type Description Default
execution_result ExecutionResult

The completed execution result to analyse.

required
agent_id NotBlankStr

Agent that executed the task.

required
task_id NotBlankStr

Task that was executed.

required
config ErrorTaxonomyConfig

Error taxonomy configuration.

required
task_repo TaskRepository | None

Optional task repository for TASK_TREE scope.

None
provider CompletionProvider | None

Optional LLM provider for semantic detectors.

None
sinks tuple[ClassificationSink, ...]

Downstream consumers to notify after classification.

()

Returns:

Type Description
ClassificationResult | None

Classification result with findings, or None if disabled.

Source code in src/synthorg/engine/classification/pipeline.py
async def classify_execution_errors(
    execution_result: ExecutionResult,
    agent_id: NotBlankStr,
    task_id: NotBlankStr,
    *,
    config: ErrorTaxonomyConfig,
    task_repo: TaskRepository | None = None,
    provider: CompletionProvider | None = None,
    sinks: tuple[ClassificationSink, ...] = (),
) -> ClassificationResult | None:
    """Classify coordination errors from an execution result.

    Discovers detectors from ``config.detectors``, loads
    scope-appropriate context, runs detectors sequentially
    (concurrency happens inside ``CompositeDetector``), and
    dispatches results to registered sinks.

    Rate limiting is handled by the ``BaseCompletionProvider``
    internally; semantic detectors do not take a separate rate limiter
    because a second limiter on the same shared instance would
    double-throttle.

    Returns ``None`` when the taxonomy is disabled.  Never raises;
    all exceptions except ``MemoryError``/``RecursionError`` are
    caught and logged as ``CLASSIFICATION_ERROR``.

    Args:
        execution_result: The completed execution result to analyse.
        agent_id: Agent that executed the task.
        task_id: Task that was executed.
        config: Error taxonomy configuration.
        task_repo: Optional task repository for TASK_TREE scope.
        provider: Optional LLM provider for semantic detectors.
        sinks: Downstream consumers to notify after classification.

    Returns:
        Classification result with findings, or ``None`` if disabled.
    """
    if not config.enabled:
        logger.debug(
            CLASSIFICATION_SKIPPED,
            agent_id=agent_id,
            task_id=task_id,
            reason="error taxonomy disabled",
        )
        return None

    execution_id = execution_result.context.execution_id
    logger.info(
        CLASSIFICATION_START,
        agent_id=agent_id,
        task_id=task_id,
        execution_id=execution_id,
        categories=tuple(c.value for c in config.categories),
    )

    result = await _classify_safely(
        execution_result,
        agent_id,
        task_id,
        execution_id=execution_id,
        config=config,
        task_repo=task_repo,
        provider=provider,
    )
    if result is None:
        return None

    await _dispatch_to_sinks(result, sinks, agent_id, task_id)
    return result

Workspace Isolation

protocol

Workspace isolation strategy protocol.

WorkspaceIsolationStrategy

Bases: Protocol

Protocol for workspace isolation strategies.

Implementations provide the ability to create, merge, and tear down isolated workspaces for concurrent agent execution.

setup_workspace async

setup_workspace(*, request)

Create an isolated workspace for an agent task.

Parameters:

Name Type Description Default
request WorkspaceRequest

Workspace creation request.

required

Returns:

Type Description
Workspace

The created workspace.

Raises:

Type Description
WorkspaceLimitError

When max concurrent worktrees reached.

WorkspaceSetupError

When git operations fail.

Source code in src/synthorg/engine/workspace/protocol.py
async def setup_workspace(
    self,
    *,
    request: WorkspaceRequest,
) -> Workspace:
    """Create an isolated workspace for an agent task.

    Args:
        request: Workspace creation request.

    Returns:
        The created workspace.

    Raises:
        WorkspaceLimitError: When max concurrent worktrees reached.
        WorkspaceSetupError: When git operations fail.
    """
    ...

teardown_workspace async

teardown_workspace(*, workspace)

Remove an isolated workspace and clean up resources.

Parameters:

Name Type Description Default
workspace Workspace

The workspace to tear down.

required

Raises:

Type Description
WorkspaceCleanupError

When git cleanup operations fail.

Source code in src/synthorg/engine/workspace/protocol.py
async def teardown_workspace(
    self,
    *,
    workspace: Workspace,
) -> None:
    """Remove an isolated workspace and clean up resources.

    Args:
        workspace: The workspace to tear down.

    Raises:
        WorkspaceCleanupError: When git cleanup operations fail.
    """
    ...

merge_workspace async

merge_workspace(*, workspace)

Merge a workspace branch back into the base branch.

Merge conflicts are returned as a MergeResult with success=False rather than raised as exceptions.

Parameters:

Name Type Description Default
workspace Workspace

The workspace to merge.

required

Returns:

Type Description
MergeResult

The merge result with conflict details if any.

Raises:

Type Description
WorkspaceMergeError

When checkout or merge abort fails.

Source code in src/synthorg/engine/workspace/protocol.py
async def merge_workspace(
    self,
    *,
    workspace: Workspace,
) -> MergeResult:
    """Merge a workspace branch back into the base branch.

    Merge conflicts are returned as a ``MergeResult`` with
    ``success=False`` rather than raised as exceptions.

    Args:
        workspace: The workspace to merge.

    Returns:
        The merge result with conflict details if any.

    Raises:
        WorkspaceMergeError: When checkout or merge abort fails.
    """
    ...

list_active_workspaces async

list_active_workspaces()

Return all currently active workspaces.

Returns:

Type Description
tuple[Workspace, ...]

Tuple of active workspaces.

Source code in src/synthorg/engine/workspace/protocol.py
async def list_active_workspaces(self) -> tuple[Workspace, ...]:
    """Return all currently active workspaces.

    Returns:
        Tuple of active workspaces.
    """
    ...

get_strategy_type

get_strategy_type()

Return the strategy type identifier.

Returns:

Type Description
str

Strategy type name.

Source code in src/synthorg/engine/workspace/protocol.py
def get_strategy_type(self) -> str:
    """Return the strategy type identifier.

    Returns:
        Strategy type name.
    """
    ...

models

Workspace isolation domain models.

WorkspaceRequest pydantic-model

Bases: BaseModel

Request to create an isolated workspace for an agent task.

Attributes:

Name Type Description
task_id NotBlankStr

Identifier of the task requiring isolation.

agent_id NotBlankStr

Identifier of the agent that will work in the workspace.

base_branch NotBlankStr

Git branch to branch from.

file_scope tuple[NotBlankStr, ...]

Optional file path hints for the workspace.

project_id NotBlankStr | None

Owning project. Selects the per-project repo tree (<base>/projects/<project_id>) the worktree branches from; None uses the strategy's singleton repo root.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

task_id pydantic-field

task_id

Task requiring isolation

agent_id pydantic-field

agent_id

Agent working in workspace

base_branch pydantic-field

base_branch = 'main'

Git branch to branch from

file_scope pydantic-field

file_scope = ()

Optional file path hints

project_id pydantic-field

project_id = None

Owning project for per-project worktree root

Workspace pydantic-model

Bases: BaseModel

An active isolated workspace backed by a git worktree.

Attributes:

Name Type Description
workspace_id NotBlankStr

Unique identifier for this workspace.

task_id NotBlankStr

Task this workspace serves.

agent_id NotBlankStr

Agent operating in this workspace.

branch_name NotBlankStr

Git branch created for this workspace.

worktree_path NotBlankStr

Filesystem path to the worktree directory.

base_branch NotBlankStr

Branch this workspace was created from.

created_at datetime

Timestamp of workspace creation.

project_id NotBlankStr | None

Owning project. Selects the per-project repo tree for merge / teardown so they run in the same repo the worktree was created from; None uses the singleton root.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

workspace_id pydantic-field

workspace_id

Unique workspace ID

task_id pydantic-field

task_id

Task this workspace serves

agent_id pydantic-field

agent_id

Agent operating in workspace

branch_name pydantic-field

branch_name

Git branch for this workspace

worktree_path pydantic-field

worktree_path

Filesystem path to worktree

base_branch pydantic-field

base_branch

Branch workspace was created from

created_at pydantic-field

created_at

Workspace creation timestamp

project_id pydantic-field

project_id = None

Owning project for per-project merge/teardown root

MergeConflict pydantic-model

Bases: BaseModel

A single merge conflict detected during workspace merge.

Attributes:

Name Type Description
file_path NotBlankStr

Path of the conflicting file.

conflict_type ConflictType

Type of conflict (e.g. textual, semantic).

ours_content str

Content from the base branch side.

theirs_content str

Content from the workspace branch side.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_semantic_description

file_path pydantic-field

file_path

Conflicting file path

conflict_type pydantic-field

conflict_type

Type of conflict detected during merge

ours_content pydantic-field

ours_content = ''

Base branch content

theirs_content pydantic-field

theirs_content = ''

Workspace branch content

description pydantic-field

description = ''

Human-readable description of the conflict

MergeResult pydantic-model

Bases: BaseModel

Result of merging a single workspace branch back.

Attributes:

Name Type Description
workspace_id NotBlankStr

Workspace that was merged.

branch_name NotBlankStr

Branch that was merged.

success bool

Whether the merge completed without conflicts.

conflicts tuple[MergeConflict, ...]

Any textual conflicts encountered during merge.

escalation ConflictEscalation | None

Escalation strategy applied, if any.

merged_commit_sha NotBlankStr | None

SHA of the merge commit, if successful.

duration_seconds float

Time taken for the merge operation.

semantic_conflicts tuple[MergeConflict, ...]

Semantic conflicts detected after merge.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

Validators:

  • _validate_success_consistency

workspace_id pydantic-field

workspace_id

Merged workspace ID

branch_name pydantic-field

branch_name

Merged branch name

success pydantic-field

success

Whether merge succeeded

conflicts pydantic-field

conflicts = ()

Conflicts encountered

escalation pydantic-field

escalation = None

Escalation strategy applied

merged_commit_sha pydantic-field

merged_commit_sha = None

Merge commit SHA if successful

duration_seconds pydantic-field

duration_seconds

Merge duration in seconds

semantic_conflicts pydantic-field

semantic_conflicts = ()

Semantic conflicts detected after successful merge

WorkspaceGroupResult pydantic-model

Bases: BaseModel

Aggregated result of merging a group of workspaces.

Attributes:

Name Type Description
group_id NotBlankStr

Identifier for this merge group.

merge_results tuple[MergeResult, ...]

Individual merge results for each workspace.

duration_seconds float

Total time for the group merge operation.

Config:

  • frozen: True
  • allow_inf_nan: False
  • extra: forbid

Fields:

group_id pydantic-field

group_id

Merge group identifier

merge_results pydantic-field

merge_results = ()

Individual merge results

duration_seconds pydantic-field

duration_seconds

Total merge duration in seconds

all_merged property

all_merged

Return True only if every workspace merged without conflict.

total_conflicts property

total_conflicts

Sum of conflicts from all merge results.

total_semantic_conflicts property

total_semantic_conflicts

Sum of semantic conflicts from all merge results.

service

Workspace isolation service.

High-level service that coordinates workspace lifecycle: setup, merge, and teardown for groups of agent workspaces.

WorkspaceIsolationService

WorkspaceIsolationService(
    *, strategy, config, git_backend=None, default_branch=_DEFAULT_BRANCH, clock=None
)

Service for managing workspace isolation lifecycle.

Coordinates creating, merging, and tearing down workspaces for groups of concurrent agent tasks.

Parameters:

Name Type Description Default
strategy WorkspaceIsolationStrategy

Workspace isolation strategy implementation.

required
config WorkspaceIsolationConfig

Workspace isolation configuration.

required
Source code in src/synthorg/engine/workspace/service.py
def __init__(
    self,
    *,
    strategy: WorkspaceIsolationStrategy,
    config: WorkspaceIsolationConfig,
    git_backend: GitBackend | None = None,
    default_branch: NotBlankStr = _DEFAULT_BRANCH,
    clock: Clock | None = None,
) -> None:
    self._clock: Clock = clock if clock is not None else SystemClock()
    self._strategy = strategy
    self._config = config
    self._git_backend = git_backend
    self._default_branch = default_branch
    self._push_queues: dict[str, PushQueueCoordinator] = {}
    self._push_queues_lock = asyncio.Lock()
    # Set by ``shutdown()`` under ``_push_queues_lock`` so
    # ``_get_or_create_queue()`` cannot resurrect a coordinator
    # after the service has begun tearing down. Without this flag
    # a queue created mid-shutdown would survive the teardown loop
    # and keep accepting merge+push work against a service that
    # claims to have stopped.
    self._shutting_down = False
    pw = config.planner_worktrees
    self._merge_orchestrator = MergeOrchestrator(
        strategy=strategy,
        merge_order=pw.merge_order,
        conflict_escalation=pw.conflict_escalation,
        cleanup_on_merge=pw.cleanup_on_merge,
        clock=self._clock,
    )

setup_group async

setup_group(*, requests)

Create workspaces for a group of agent tasks.

Rolls back all already-created workspaces if any setup fails.

Parameters:

Name Type Description Default
requests tuple[WorkspaceRequest, ...]

Workspace creation requests.

required

Returns:

Type Description
tuple[Workspace, ...]

Tuple of created workspaces.

Raises:

Type Description
WorkspaceLimitError

When max concurrent worktrees reached.

WorkspaceSetupError

When git operations fail.

Source code in src/synthorg/engine/workspace/service.py
async def setup_group(
    self,
    *,
    requests: tuple[WorkspaceRequest, ...],
) -> tuple[Workspace, ...]:
    """Create workspaces for a group of agent tasks.

    Rolls back all already-created workspaces if any setup fails.

    Args:
        requests: Workspace creation requests.

    Returns:
        Tuple of created workspaces.

    Raises:
        WorkspaceLimitError: When max concurrent worktrees reached.
        WorkspaceSetupError: When git operations fail.
    """
    logger.info(
        WORKSPACE_GROUP_SETUP_START,
        count=len(requests),
    )

    workspaces: list[Workspace] = []
    try:
        for request in requests:
            ws = await self._strategy.setup_workspace(
                request=request,
            )
            workspaces.append(ws)
    except Exception as exc:
        reraise_critical(exc)
        # Catch ``Exception`` so any setup failure -- not just the
        # documented ``WorkspaceLimitError`` / ``WorkspaceSetupError``
        # -- triggers rollback. Without this fallback an
        # unexpected error after partial setup would leak the
        # already-created workspaces.
        logger.warning(
            WORKSPACE_GROUP_SETUP_FAILED,
            count=len(requests),
            created=len(workspaces),
            error_type=type(exc).__name__,
            error=safe_error_description(exc),
        )
        await self._rollback_workspaces(workspaces)
        raise

    logger.info(
        WORKSPACE_GROUP_SETUP_COMPLETE,
        count=len(workspaces),
    )
    return tuple(workspaces)

merge_group async

merge_group(*, workspaces)

Merge all workspaces and return aggregated result.

Parameters:

Name Type Description Default
workspaces tuple[Workspace, ...]

Workspaces to merge.

required

Returns:

Type Description
WorkspaceGroupResult

Aggregated merge result for the group.

Raises:

Type Description
WorkspaceMergeError

When a merge operation fails fatally.

Source code in src/synthorg/engine/workspace/service.py
async def merge_group(
    self,
    *,
    workspaces: tuple[Workspace, ...],
) -> WorkspaceGroupResult:
    """Merge all workspaces and return aggregated result.

    Args:
        workspaces: Workspaces to merge.

    Returns:
        Aggregated merge result for the group.

    Raises:
        WorkspaceMergeError: When a merge operation fails fatally.
    """
    start = self._clock.monotonic()
    merge_results = await self._merge_orchestrator.merge_all(
        workspaces=workspaces,
    )
    elapsed = self._clock.monotonic() - start

    return WorkspaceGroupResult(
        group_id=str(uuid4()),
        merge_results=merge_results,
        duration_seconds=elapsed,
    )

merge_workspace_with_push async

merge_workspace_with_push(*, workspace, project_id, repo_root)

Merge workspace then push the default branch, serialised.

When no git backend is wired the merge still runs (via the strategy) but nothing is pushed -- this keeps the call site uniform whether or not durable backing is configured.

Parameters:

Name Type Description Default
workspace Workspace

The agent workspace to merge back.

required
project_id NotBlankStr

Owning project (selects the serial queue).

required
repo_root Path

Project working tree the push runs from.

required

Returns:

Name Type Description
The MergeResult

class:MergeResult.

Raises:

Type Description
WorkspaceMergeError

The merge failed fatally.

WorkspacePushError

The backend push failed.

Source code in src/synthorg/engine/workspace/service.py
async def merge_workspace_with_push(
    self,
    *,
    workspace: Workspace,
    project_id: NotBlankStr,
    repo_root: Path,
) -> MergeResult:
    """Merge *workspace* then push the default branch, serialised.

    When no git backend is wired the merge still runs (via the
    strategy) but nothing is pushed -- this keeps the call site
    uniform whether or not durable backing is configured.

    Args:
        workspace: The agent workspace to merge back.
        project_id: Owning project (selects the serial queue).
        repo_root: Project working tree the push runs from.

    Returns:
        The :class:`MergeResult`.

    Raises:
        WorkspaceMergeError: The merge failed fatally.
        WorkspacePushError: The backend push failed.
    """
    if self._git_backend is None:
        return await self._strategy.merge_workspace(workspace=workspace)
    queue = await self._get_or_create_queue(
        project_id=project_id,
        repo_root=repo_root,
    )
    return await queue.enqueue_merge_push(workspace=workspace)

shutdown async

shutdown()

Stop every per-project push queue (best-effort, all attempted).

Source code in src/synthorg/engine/workspace/service.py
async def shutdown(self) -> None:
    """Stop every per-project push queue (best-effort, all attempted)."""
    async with self._push_queues_lock:
        # Flip ``_shutting_down`` under the same lock that
        # ``_get_or_create_queue`` takes so a concurrent first-touch
        # cannot slip a freshly-created coordinator in behind us.
        self._shutting_down = True
        queues = tuple(self._push_queues.values())
        self._push_queues.clear()
    for queue in queues:
        try:
            await queue.stop()
        except Exception as exc:  # noqa: BLE001 -- criticals re-raised
            # lint-allow: swallow-ok -- best-effort teardown
            reraise_critical(exc)
            logger.warning(
                WORKSPACE_TEARDOWN_FAILED,
                reason="push_queue_stop_failed",
                error_type=type(exc).__name__,
                error=safe_error_description(exc),
            )

teardown_group async

teardown_group(*, workspaces)

Tear down all workspaces in a group.

Uses best-effort teardown: attempts all workspaces even if some fail, then raises a combined error.

Parameters:

Name Type Description Default
workspaces tuple[Workspace, ...]

Workspaces to tear down.

required

Raises:

Type Description
WorkspaceCleanupError

When any teardown operation fails.

Source code in src/synthorg/engine/workspace/service.py
async def teardown_group(
    self,
    *,
    workspaces: tuple[Workspace, ...],
) -> None:
    """Tear down all workspaces in a group.

    Uses best-effort teardown: attempts all workspaces even if
    some fail, then raises a combined error.

    Args:
        workspaces: Workspaces to tear down.

    Raises:
        WorkspaceCleanupError: When any teardown operation fails.
    """
    logger.info(
        WORKSPACE_GROUP_TEARDOWN_START,
        count=len(workspaces),
    )

    errors: list[str] = []
    for workspace in workspaces:
        try:
            await self._strategy.teardown_workspace(
                workspace=workspace,
            )
        except Exception as exc:  # noqa: BLE001 -- criticals re-raised
            # lint-allow: swallow-ok -- best-effort teardown
            reraise_critical(exc)
            # The ``errors`` list flows into
            # ``WorkspaceCleanupError`` which callers may log as
            # a message; raw ``exc`` text could leak DB
            # credentials or container ids. Use the same
            # scrubbed string as the warning log below.
            errors.append(
                f"workspace {workspace.workspace_id}: "
                f"{safe_error_description(exc)}",
            )
            logger.warning(
                WORKSPACE_TEARDOWN_FAILED,
                workspace_id=workspace.workspace_id,
                error_type=type(exc).__name__,
                error=safe_error_description(exc),
            )

    logger.info(
        WORKSPACE_GROUP_TEARDOWN_COMPLETE,
        count=len(workspaces),
        failures=len(errors),
    )

    if errors:
        msg = f"Failed to tear down {len(errors)} workspace(s): {'; '.join(errors)}"
        raise WorkspaceCleanupError(msg)

enums

Workspace merge domain enumerations.

MergeOrder

Bases: StrEnum

Order in which workspace branches are merged back.

Determines the sequence of merge operations when multiple agent workspaces are being merged into the base branch.

ConflictEscalation

Bases: StrEnum

Strategy for handling merge conflicts during workspace merges.

Controls whether merging stops for human review or continues with an automated review agent flagging conflicts.

ConflictType

Bases: StrEnum

Type of merge conflict detected during workspace merges.

Workflow Enums

enums

Workflow subsystem enumerations.

WorkflowType

Bases: StrEnum

Workflow type for organizing task execution.

Matches the four workflow types defined in the Engine design page (docs/design/engine.md, Workflow Types section).

WorkflowNodeType

Bases: StrEnum

Node type in a visual workflow definition.

Each node represents a step or control-flow element in the visual workflow editor.

WorkflowValueType

Bases: StrEnum

Typed value kinds for workflow I/O declarations.

Used by :class:WorkflowIODeclaration to enforce typed contracts on subworkflow inputs and outputs at save time and at runtime.

WorkflowEdgeType

Bases: StrEnum

Edge type connecting nodes in a visual workflow definition.

Encodes the relationship semantics between workflow nodes.

WorkflowExecutionStatus

Bases: StrEnum

Lifecycle status of a workflow execution instance.

Tracks the overall progress of an activated workflow definition from creation through completion or cancellation.

WorkflowNodeExecutionStatus

Bases: StrEnum

Per-node execution status within a workflow execution.

Tracks whether each node in the workflow graph has been processed, skipped (conditional branch not taken), or resulted in a concrete task.

Agent Runtime Status

ExecutionStatus

Bases: StrEnum

Runtime execution status of an agent.

Tracks whether an agent is currently executing, paused (e.g. waiting for approval), or idle. Used by AgentRuntimeState for dashboard queries and graceful-shutdown discovery.

Recovery Failure Category

FailureCategory

Bases: StrEnum

Machine-readable failure classification for recovery results.

Used by RecoveryResult to provide structured failure diagnosis that enables smarter checkpoint reconciliation and task reassignment routing. UNKNOWN is the honest default for error messages that cannot be confidently classified -- it is explicit rather than a silent TOOL_FAILURE lie.

Review Decision Outcome

DecisionOutcome

Bases: StrEnum

Outcome of a review gate decision.

Used by DecisionRecord for the auditable decisions drop-box.

Operator Intervention

enums

Operator intervention domain enumerations.

InterventionKind

Bases: StrEnum

Operator intervention applied from the mission-control cockpit.

PAUSE and KILL reuse the task lifecycle seams (transition to INTERRUPTED / cancel to CANCELLED). HINT and REDIRECT route through the steering directive: both post an INFO_REQUEST interrupt the engine consumes at the next safe turn boundary, so the operator's text reaches the running agent without corrupting state.