Skip to content

symfonic.kernel.context

context

RequestContext — everything an invocation did (RCX).

The plan's twin. It is created per invocation, never stored on an agent, never shared between runs, and never outlives the run that made it. That is the whole rule, and it is what retires the module-global per-run state pattern: state that lives on a module survives the run, and state that survives the run is state the next run can read.

RequestContext

RequestContext(plan: InvocationPlan, *, run_id: str | None = None)

Request-local mutable state, with one writer per slot (RCX-2).

Source code in src/symfonic/kernel/context.py
def __init__(self, plan: InvocationPlan, *, run_id: str | None = None) -> None:
    self.plan = plan
    self.run_id = run_id or uuid.uuid4().hex
    self.started = time.perf_counter()
    self.usage = UsageDelta()
    self.answer: list[str] = []
    self.messages: tuple[Any, ...] = ()
    self.tool_outcomes: list[Any] = []
    self.stop_reason: str | None = None
    self.cancelled = False
    self._used_call_ids: set[str] = set()
    self._generation: str | None = None
    self._scratch: dict[str, dict[str, Any]] = {}
    self._resolved: Any = None
    self._lifecycle = RunLifecycle(
        self.run_id,
        deadline_seconds=plan.limits.deadline_seconds,
        grace_seconds=plan.limits.teardown_grace_seconds,
    )

lifecycle property

lifecycle: RunLifecycle

The run's lifecycle coordinator — resources, work, and teardown.

resolved property

resolved: Any

The turn's resolved inputs, or None before prompt assembly ends.

Read-only by construction: ResolvedInputs has no mutating method and freezes its payloads, so handing it to a later phase hands a fact rather than a channel.

bind_generation

bind_generation(generation: str) -> None

Record the generation this invocation runs against, exactly once.

Write-once is what keeps a long-lived agent from mixing generations mid-invocation when a cutover flips underneath it (T2.3.6): the plan holds the binding stage, and the context holds the binding result.

Source code in src/symfonic/kernel/context.py
def bind_generation(self, generation: str) -> None:
    """Record the generation this invocation runs against, exactly once.

    Write-once is what keeps a long-lived agent from mixing generations
    mid-invocation when a cutover flips underneath it (T2.3.6): the plan
    holds the binding *stage*, and the context holds the binding *result*.
    """
    if self._generation is not None:
        raise ContractViolationError(
            f"the generation vector is already bound to {self._generation!r}; "
            "a run observes exactly one generation (RCX-5)."
        )
    self._generation = generation

bind_resolved

bind_resolved(snapshot: Any) -> None

Record the turn's resolved-input snapshot, exactly once (#24).

Before this, the snapshot was a local in run_prompt_assembly: built by the resolution pass, handed to the compilation pass, and unreachable the moment that function returned. A value published by a prompt-assembly stage could not be read by a later phase at all — not because the envelope forbade it, but because nothing carried it.

Write-once for the same reason bind_generation is, and the reason matters more here. scratch(namespace) could also transport a value, and it is the wrong carrier: it hands back the live dict, so any holder can rewrite what a resolution stage decided. A governance verdict read three phases later must have one answer per turn, not a last-writer-wins one. ResolvedInputs is already deeply immutable and refuses payloads it cannot freeze, so the only mutability left to remove was the binding itself.

Source code in src/symfonic/kernel/context.py
def bind_resolved(self, snapshot: Any) -> None:
    """Record the turn's resolved-input snapshot, exactly once (#24).

    Before this, the snapshot was a local in ``run_prompt_assembly``: built
    by the resolution pass, handed to the compilation pass, and unreachable
    the moment that function returned. A value published by a
    prompt-assembly stage could not be read by a later phase at all — not
    because the envelope forbade it, but because nothing carried it.

    Write-once for the same reason ``bind_generation`` is, and the reason
    matters more here. ``scratch(namespace)`` could also transport a value,
    and it is the wrong carrier: it hands back the live dict, so any holder
    can rewrite what a resolution stage decided. A governance verdict read
    three phases later must have one answer per turn, not a
    last-writer-wins one. ``ResolvedInputs`` is already deeply immutable and
    refuses payloads it cannot freeze, so the only mutability left to remove
    was the binding itself.
    """
    if self._resolved is not None:
        raise ContractViolationError(
            "the resolved-input snapshot is already bound; a turn resolves "
            "its inputs once, and a second binding would make 'what did the "
            "resolution pass produce?' depend on who asked last."
        )
    self._resolved = snapshot

reserve

reserve(request: ToolRequest) -> ToolRequest

Return request with a run-unique call_id.

Source code in src/symfonic/kernel/context.py
def reserve(self, request: ToolRequest) -> ToolRequest:
    """Return ``request`` with a run-unique ``call_id``."""
    call_id = self.reserve_call_id(request.call_id)
    if call_id == request.call_id:
        return request
    return ToolRequest(call_id=call_id, name=request.name, arguments=request.arguments)

reserve_call_id

reserve_call_id(proposed: str | None) -> str

Return a run-unique tool-call id, keeping the provider's when usable.

Source code in src/symfonic/kernel/context.py
def reserve_call_id(self, proposed: str | None) -> str:
    """Return a run-unique tool-call id, keeping the provider's when usable."""
    call_id = proposed
    while not call_id or call_id in self._used_call_ids:
        call_id = uuid.uuid4().hex
    self._used_call_ids.add(call_id)
    return call_id

scratch

scratch(namespace: str) -> dict[str, Any]

Per-run scratch for one capability. Namespaced so nobody collides.

Source code in src/symfonic/kernel/context.py
def scratch(self, namespace: str) -> dict[str, Any]:
    """Per-run scratch for one capability. Namespaced so nobody collides."""
    return self._scratch.setdefault(namespace, {})

spawn

spawn(work: Any, *, owner: str, purpose: str, deadline_seconds: float | None = None) -> asyncio.Task[Any]

Create run-owned background work (RCX-8/BP-8); never fire-and-forget.

Source code in src/symfonic/kernel/context.py
def spawn(
    self,
    work: Any,
    *,
    owner: str,
    purpose: str,
    deadline_seconds: float | None = None,
) -> asyncio.Task[Any]:
    """Create run-owned background work (RCX-8/BP-8); never fire-and-forget."""
    return self._lifecycle.spawn(
        work, owner=owner, purpose=purpose, deadline_seconds=deadline_seconds
    )

teardown async

teardown(reason: TeardownReason = 'completed') -> TeardownReport

Unwind this run's lifecycle exactly once and return its record.

Source code in src/symfonic/kernel/context.py
async def teardown(self, reason: TeardownReason = "completed") -> TeardownReport:
    """Unwind this run's lifecycle exactly once and return its record."""
    return await self._lifecycle.teardown(reason)