alphabrief / apps /api /app /graph /context.py
Abdr007's picture
AlphaBrief β€” deployed tree
69e310f
Raw
History Blame Contribute Delete
3.68 kB
"""Per-run dependencies handed to graph nodes through LangGraph's runtime context.
Nodes stay pure functions of ``(state, config)``; everything with a lifecycle β€”
the MCP session, the model engine, the event bus, the budget guard β€” travels in
:class:`RunContext` so a node never reaches for a global.
Delivery uses LangGraph's first-class ``context`` channel
(``StateGraph(..., context_schema=RunContext)`` plus ``ainvoke(..., context=ctx)``).
This matters: values placed in ``config["configurable"]`` are checkpoint state and
are filtered when a checkpointer is attached, whereas the runtime context is
explicitly *not* persisted β€” which is exactly right for live handles like an open
MCP session.
A ``config``-based fallback is retained so a node can be unit-tested in isolation
by invoking it directly with ``ctx.to_config()``.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.runtime import get_runtime
from app.core.budget import BudgetGuard
from app.core.claude import LLMEngine
from app.core.events import EventBus, EventKind
from app.core.settings import Settings
from app.core.tracing import Tracer
from app.mcp_server.client import McpToolClient
CONTEXT_KEY = "alphabrief_context"
@dataclass(slots=True)
class RunContext:
"""Everything a node needs that is not state."""
run_id: str
settings: Settings
engine: LLMEngine
mcp: McpToolClient
bus: EventBus
tracer: Tracer
budget: BudgetGuard
extras: dict[str, Any] = field(default_factory=dict)
async def emit(
self,
kind: EventKind,
message: str,
payload: dict[str, Any] | None = None,
) -> None:
"""Publish one telemetry row for this run."""
await self.bus.publish(self.run_id, kind, message, payload)
def to_config(self, **extra: Any) -> RunnableConfig:
"""Thread config for the graph. Identity only β€” deps travel in `context`."""
configurable: dict[str, Any] = {"thread_id": self.run_id, CONTEXT_KEY: self}
configurable.update(extra)
return RunnableConfig(configurable=configurable)
#: Test-only override, so a node can be invoked directly without a live graph.
_override: ContextVar[RunContext | None] = ContextVar("alphabrief_run_context", default=None)
@contextmanager
def use_context(ctx: RunContext) -> Iterator[RunContext]:
"""Bind a :class:`RunContext` for direct node invocation (tests, eval)."""
token = _override.set(ctx)
try:
yield ctx
finally:
_override.reset(token)
def current_context() -> RunContext:
"""The :class:`RunContext` the current node is executing under.
Nodes take no ``config`` parameter: LangGraph's runtime context is the
supported channel for per-run dependencies, and a ``config`` parameter
annotated under ``from __future__ import annotations`` is a string at
inspection time, which LangGraph cannot match β€” it would be silently ignored.
"""
override = _override.get()
if override is not None:
return override
try:
runtime = get_runtime(RunContext)
except Exception: # noqa: BLE001 - not running inside a graph
runtime = None
if runtime is not None and isinstance(runtime.context, RunContext):
return runtime.context
raise RuntimeError(
"RunContext unavailable β€” invoke the graph with `context=ctx`, or wrap a "
"direct node call in `with use_context(ctx): ...`."
)