The lang Stack — How Prax Uses LangChain & LangGraph
Prax is built on the LangChain 1.x / LangGraph 1.x stack, used deliberately shallowly: we take the layers that are genuine leverage (the multi-provider model layer, the tool contract, the prebuilt ReAct loop, checkpointing) and we own…
f62d7985
View source ↗
Prax is built on the LangChain 1.x / LangGraph 1.x stack, used deliberately shallowly: we take the layers that are genuine leverage (the multi-provider model layer, the tool contract, the prebuilt ReAct loop, checkpointing) and we own every seam around them, so that loop-level change — enabling middleware, adopting a new major, or one day swapping in an owned loop — is a one-module event, not a codebase sweep. This doc is the contract for keeping it that way.
What we use, from where
| Package | What we use | Where (the ONLY places) |
|---|---|---|
langchain |
create_agent (prebuilt ReAct loop) |
prax/agent/agent_loop.py |
langchain |
AgentMiddleware (in-loop hooks) |
prax/agent/loop_middleware.py |
langgraph |
Checkpointers (InMemorySaver, optional SqliteSaver) |
prax/agent/checkpoint.py (behind CheckpointManager) |
langchain_core |
@tool / BaseTool — the tool contract |
everywhere tools are defined (~60 modules; this is the deep, keep-forever coupling) |
langchain_core |
message types, callbacks (tracing) | agent + services layers |
langchain-openai / -anthropic / -google-vertexai / -ollama |
provider chat models | prax/agent/llm_factory.py (build_llm()) |
langchain-community |
DuckDuckGoSearchRun |
prax/helpers_functions.py |
What we deliberately do NOT use: custom StateGraphs, nodes/edges,
Send, subgraph composition, or any LangGraph platform/server surface. The
loop is a replaceable component, not the architecture. Adopting deeper
graph-shaped business logic would flip that and must clear the same bar as a
new dependency.
The construction seam (layer rule 4)
Every agent loop in Prax — orchestrator, spokes, one-shot sub-agents — is built by one function:
from prax.agent.agent_loop import build_agent_loop
graph = build_agent_loop(llm, tools) # spokes / sub-agents
graph = build_agent_loop(llm, tools, checkpointer=saver) # orchestrator
scripts/check_layers.py (rule 4, runs in make ci) enforces that only
agent_loop.py + loop_middleware.py import langchain.agents.*, and only
those two plus checkpoint.py import langgraph*. Do not add imports
elsewhere; if you think you need to, the thing you’re building belongs in one
of those modules.
Why this matters: before the seam existed there were 14 scattered
create_agent call sites across ten modules, and the loop was effectively
unswappable. With the seam, middleware rollout, a LangChain 2.x migration, or
an owned-loop experiment are all changes to one module with the eval suite as
the referee.
In-loop middleware (AGENT_MIDDLEWARE_ENABLED, default off)
Prax enforces safety at two layers, and they are complements, not rivals:
| Concern | Perimeter (governed_tool.py, always on) |
In-loop (loop_middleware.py, flag-gated) |
|---|---|---|
| Risk classification + HIGH confirmation | ✅ authoritative gate | — |
| Audit log, turn budgets, trifecta latch | ✅ | — |
Epistemic reliability tagging (_tag_result) |
✅ | — |
| Provenance-taint untrusted content before it re-enters model context | ✗ structurally can’t (never sees the message stream) | ✅ UntrustedContentTaint |
| Liveness signal per model step | ✗ (watches from outside the invoke) | ✅ LoopHeartbeat |
The middleware stack (LangChain 1.x AgentMiddleware, hooks like
wrap_tool_call / before_model / after_model) is assembled in
loop_middleware.default_middleware() and attached by build_agent_loop()
only when AGENT_MIDDLEWARE_ENABLED=true. Off (the default), no middleware
kwarg is passed at all — the compiled graph is identical to prior behaviour
and keyless CI stays green.
Current stack when enabled:
UntrustedContentTaint— results of untrusted-source tools (the trifecta “untrusted” leg: browser, fetch, search, RSS, transcripts, PDF/web summaries, news…) get a provenance banner before they re-enter the model’s context, framing embedded directives as data, not instructions. String content only (provider-native list content passes through), idempotent, fails open. Known limitation: classification istrifecta.is_untrusted_source()— a name-substring list. When adding ANY tool that turns a URL or external medium into model-visible text, add its name pattern to_SRC_NAMES(there’s a coverage test intests/test_agent_loop.py); the long-term fix is an explicitingests_external_contentcapability flag at tool registration.LoopHeartbeat— touches the orchestrator’sTraceHeartbeataround every model call from inside the loop (bound via a ContextVar in the invoke worker thread), upgrading stall detection from “did the invoke start” to “is the loop still stepping”. Implemented withwrap_model_call, notbefore_model/after_model— see house rule 4.
Verify-once: IdempotentToolCache (TOOL_MEMOIZE_ENABLED, default off)
A separate flag again: within one turn, an identical repeat of a pure,
side-effect-free read (web search/fetch, memory/workspace/conversation/trace
lookups — is_memoizable_read) returns the prior result via wrap_tool_call
instead of re-executing, saving the redundant fetch’s latency/call/tokens.
- Correctness rests on two invariants: (1) the cache is per-invoke — a
fresh dict bound by the orchestrator worker via
use_tool_cache()(same ContextVar-in-the-worker pattern as the heartbeat), so a read cached this turn is never reused in a later turn where the world may differ; (2) only idempotent reads are eligible —run_python, shell, writes, and browser navigation always pass through untouched (they have side effects; a cached result would be a bug). Fails open. - Scope note (honest): this removes redundant reads, not the effectful
over-verification (
run_python×6) the trace-grade A/B caught — safely deduping an effectful tool would need per-call purity metadata Prax doesn’t carry. Seedocs/research/verify-and-commit-discipline.md(M3).
Self-regulation: SteadyingCounsel (SPIRAL_RECOVERY_ENABLED, default off)
A separate flag from AGENT_MIDDLEWARE_ENABLED (they compose but neither
implies the other): SPIRAL_RECOVERY_ENABLED=true adds one more middleware,
SteadyingCounsel, to the stack. It is the structural rescue for a loop that
runs to timeout having committed nothing — the failure mode nothing was
watching in flight.
- Detection (
spiral_recovery.diagnose_spiral, pure + keyless-testable): data-driven, catches BOTH tool spirals (same tool+args ≥3×; ≥14 tool calls with no answer; ≥85% of the tool-call budget spent) and reasoning spirals (≥10 model rounds without committing; context ballooned past ~120k chars). Returns a plain-language diagnosis of what is going wrong, orNone. - Intervention (
wrap_model_call, budget-neutral): on a diagnosis it injects a calm, de-escalatingHumanMessageinto the next model call — “pause, here’s the pattern, try a genuinely different route.” Rate-limited (≥4 messages between nudges) so it nudges, not nags. - Escalated counsel (
spiral_recovery.escalated_counsel): when a smarter model is reachable, the counsel escalates to a HIGH-tier LLM (build_llm(default_tier="high")) that reviews the trajectory digest and names the likely wrong turn + one concrete different next step. Falls back to the static message if escalation is unavailable. The firing logs at WARNING (SteadyingCounsel fired (escalated|static): …) so it’s visible in prod and eval logs. - Honesty-preserving by construction (the prime directive): every counsel
variant explicitly tells the agent that an honest “I don’t know” is a valid,
good answer — it must never fabricate one to escape the loop. Pairs with the
BUDGET_AWARE_ANSWERING_ENABLEDorchestrator hint (commit the truth OR an honest “I don’t know” within budget — never bluff, which would also spike multiple-choice). - Scope note (honest): the counsel targets many-round agentic spirals (the orchestrator’s real loop failure on open-ended tasks). It structurally does not help single-pass failures — a verbose one-call over-generation or a 0-token hang (e.g. closed-book GPQA timeouts) — because there is no “next model call” to steer. Those want a per-call output/timeout cap, not the counsel; don’t over-fit the detector to make it fire on them.
House rules for middleware
- One module. All middleware lives in
loop_middleware.py; upstream hook-signature churn lands there and nowhere else. - Fail open. A middleware bug must degrade to the unmodified result plus a log line — never kill a turn.
- Additive only. Middleware may add provenance/telemetry; the
authoritative gates (confirmation, budgets, trifecta) stay in
governed_tool.py. Don’t fork enforcement into two places. - Only define the hooks you use — and prefer
wrap_*hooks.before_model/after_model/before_agent/after_agenteach add a graph node per cycle, which counts againstrecursion_limitand silently shrinks every loop’s effective tool-call budget.wrap_model_call/wrap_tool_callrun inside existing nodes and are budget-neutral (test_loop_heartbeat_uses_wrap_not_node_hookspins this). If you truly need a node hook, scaleget_recursion_limit()accordingly. - Flag-gated + eval-refereed. New middleware ships default-off and flips
only after
make eval-benchmark BENCH=injecagent LIFT=1/BENCH=sycophancy LIFT=1andmake eval-benchmark BENCH=allshow no regression (note: lift is computed per-benchmark;allruns accuracy only). Key-free tests intests/test_agent_loop.pyare mandatory. extra_middlewareis caller-owned, not flag-gated. A call site passing middleware explicitly is code-level intent; theAGENT_MIDDLEWARE_ENABLEDflag gates only the default stack. Extra middleware classes must not reuse a default-stack class name —create_agentrejects duplicate-named middleware.
Stock middleware: evaluate, don’t assume
LangChain ships stock middleware overlapping things Prax hand-rolled at the
perimeter before middleware existed: ModelFallbackMiddleware (vs our
_bind_provider failover), ModelRetryMiddleware/ToolRetryMiddleware (vs
_invoke_with_retry), ModelCallLimitMiddleware/ToolCallLimitMiddleware
(vs turn budgets), SummarizationMiddleware/ContextEditingMiddleware (vs
prepare_context). Each is a candidate to retire hand-rolled code — adopted
one at a time, flag-gated, and only where the cost axis
(pass_per_1k_tokens) says stock ≥ ours. Ours sometimes wins (the arg-bound
trifecta confirmation latch is sharper than stock human-in-the-loop).
Version policy
- Floors are tested versions.
pyproject.tomlpins>=to the exact versionsmake cilast ran green against. Bump floors with the lock, not ahead of it. - Supply-chain buffer.
[tool.uv] exclude-newer = "7 days"means the resolver never takes a release younger than a week — a routineuv lock --upgrade-package langchain --upgrade-package langgraph …lands on the newest seasoned release. Don’t fight it; it once kept a day-old release out of the tree by design. - Cadence. Minor bumps are routine maintenance gated by
make ci. Watch upstream deprecation signals for the 2.x major: because of the seam, a major is anagent_loop.py/loop_middleware.pyevent plus a lock bump — budget it as such, not as a rewrite. - Known deliberate gap.
langgraph-checkpoint-sqliteis not a dependency:CHECKPOINT_BACKEND=sqlitecurrently degrades gracefully to in-memory (seecheckpoint.py). Add the package deliberately when durable resume ships — and keep the persistence format behindCheckpointManagerso the resume store stays ours even if the runtime state is LangGraph’s. - Deprecation watch.
langchain-google-vertexai3.x deprecatesChatVertexAI(removal signalled for 4.0) and renamedrequest_timeout→timeout(the old kwarg was silently swallowed — already fixed inllm_factory.py). Budget the class migration before taking the 4.x major.
The durable-resume decision gate
Durable resume across process restarts (reliability roadmap) is the deepest lock-in commitment available on this stack: it would serialize the loop’s internal state as our persistence format. Order of operations, on purpose:
- Run the middleware rollout first (the flag + eval gate above) — it proves whether in-loop needs are met without owning the loop.
- Only then choose the durable-resume substrate, and keep it behind
CheckpointManagereither way.
History
This doc distills the 2026-07 LangChain/LangGraph coupling review, which
measured the pre-seam state (langgraph imported in exactly one file; 14
scattered create_agent call sites across ten modules, passing 3 of its 14
parameters; zero middleware usage on a 1.x engine) and set the direction
codified here: shallow, seamed, measured — the stack as leverage, never as
architecture.