feat(langgraph): record which node ran, and the ones that call no model - #92
Merged
Merged
Conversation
A LangGraph run's shape is not on the wire. Searching a recorded three-node run for the node
names, the graph's name, `langgraph_node` and `langgraph_step` finds none of them in any request.
`orcareplay-langgraph` is the second framework adapter, and it puts that shape in the trace
without the user editing their graph.
graph.node.start plan
model.request / model.response
graph.node.end plan
graph.node.start validate <- makes no request of any kind
graph.node.end validate
graph.node.start answer
graph.node.end answer
`validate` is the point rather than a curiosity. Validators, state reducers, routers and writers
call no model, so without this a proxy sees a graph with those nodes and a graph without them as
the same run. The same goes for a parallel fan-out, which reaches the wire as two ordinary
consecutive turns, and for a node that raised — `graph.node.end` carries the exception's class,
which no request can.
## How a node is recognised, which is the whole design
`on_chain_start` fires for everything a graph runs, and at that moment a node, a runnable *inside*
a node, a subgraph and a conditional edge all look alike: same inherited `langgraph_node`, same
`langgraph_step`, an arbitrary `name`. Measured on one graph: `name='INNER-RUNNABLE'` carrying
`langgraph_node='first'`, `name='router'` carrying `langgraph_node='validate_only'`, and
`name='the_graph'` carrying no `langgraph_node` at all. Reporting on `name` reports all four.
So a node is reported only when `name` **equals** `metadata['langgraph_node']` — compared, never
copied. That rule is also what keeps the file safe: `langgraph_node` is the name the author wrote
in `add_node(...)`, while `name` is whatever the caller supplied, including a `run_name`
interpolated from a customer record. The two are equal only for an author-defined node.
## Lazy, because the bootstrap is in front of every Python process
Importing the module that holds langchain-core's hook registry costs **617 ms** against an
interpreter that starts in about 50, and `orca record` loads this before `python --version`. So
`install()` arms a `sys.meta_path` finder and returns; registration happens the first time
anything under `langgraph` is imported, in a process already paying a second for it.
Waiting for `langchain_core.tracers.context` to appear on its own was the first design and does
not work: measured across the 164 `find_spec` calls of one `from langgraph.graph import
StateGraph`, it is loaded for none of them — `_configure` imports it at the first invoke, already
too late. And the trigger is the `langgraph` root only, which is a correctness constraint: the
finder runs inside `_find_and_load_unlocked`, past its `sys.modules` check, so importing
langchain-core from a `langchain_core.*` lookup re-enters that load. Measured against a finder
widened to watch both, `langchain_core` was executed **twice**, leaving a stale module object.
## A bug this found in the drain
The drain stamped events from `span.started_at` alone. A record that *closes* something carries
only `ended_at`, so every one of them got the drain's own clock — which runs after the agent has
exited. Measured: all three `graph.node.end` events landed within a millisecond of each other,
600 ms late, so the first node appeared to close after the second had opened. The bound in
`readAgentSpans` is widened to match, or it would be a hole the size of the one it closed.
## Reporting a missing adapter: used, not installed
With a second adapter the old rule broke. It asked `importlib.metadata` at startup whether the
framework was *installed*, and on an ordinary machine that is yes for frameworks the run never
touches — one venv here has both langgraph and openai-agents, so every recorded run in it was told
to install two adapters it had no relationship to, and the count grows with every adapter.
The bootstrap now reports only once the agent actually imports the framework, still confirmed by
`importlib.metadata` — so the false positive #83 was about, a project's own `agents.py`, stays
excluded. `PYTHON_ADAPTERS` is one declaration that generates the bootstrap's Python and the
operator's warning, so a package can no longer be reported with another package's explanation.
## Proof
- 46 tests in the package; the integration half builds real graphs and invokes them, because the
unit half only asserts what langchain-core is *believed* to pass.
- 26 mutants, 26 killed, 0 survived.
- `langgraph-nodes` integration check: recorded, 2 exchanges, replayed exact with the origin down,
and asserts `plan`/`validate`/`answer` present with `INNER-RUNNABLE`/`route`/`the_graph` absent.
- 63 events checked by `scripts/conformance.mjs`, 0 failures, every declared type exercised.
- Full suite 2431 passed, with the 10 known Windows failures unchanged.
`ignore_chain` is worth naming: langchain-core reads every `ignore_*` with a bare `getattr`, and a
missing one raises per callback, which it logs and swallows. Omitting it gave a graph that ran to
completion, exit 0, four `Error in ... callback` lines, and **nothing recorded**. A test now
compares the whole set against `BaseCallbackHandler` so the next one added is caught by a test
rather than by an empty trace.
## Not fixed here
A `model.request` is stamped when orca persists it — after the response and after a workspace
snapshot, measured 95 ms late on one call and 31 ms on the next — while a node record carries the
instant the callback fired, exact against the agent's own clock. So node boundaries are
trustworthy and a model call within about a tenth of a second of one can sit on the wrong side.
Fixing it means giving `RecordedExchange` its own timestamps and threading them through the proxy,
which is its own change. The README and `docs/integrations.md` say so rather than overclaiming.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🐳 OrcaCode Review
✅ No findings — nothing to flag in this PR. Great work!
OrcaCode Review — Route Smarter. Ship Safer. Spend Less.
Engine-reported: 1141 calls · 170.8M tokens · 99% cached
❤️ Share · Install OrcaCode Review
Free on GitHub — the review runs on your own OrcaRouter key. If it helped, a shout-out goes a long way.
Share: X · Reddit · LinkedIn
Follow: X · Discord · LinkedIn · OrcaRouter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Orca-Code-Review — push 1
✅ no blocking findings
A LangGraph run's shape is not on the wire. Searching a recorded three-node run for the node names, the graph's name,
langgraph_nodeandlanggraph_stepfinds none of them in any request.orcareplay-langgraphis the second framework adapter, and it puts that shape in the trace without anyone editing their graph.validateis the point rather than a curiosity. Validators, state reducers, routers and writers call no model, so without this a proxy sees a graph with those nodes and a graph without them as the same run. The same goes for a parallel fan-out, which reaches the wire as two ordinary consecutive turns, and for a node that raised —graph.node.endcarries the exception's class, which no request can.How a node is recognised, which is the whole design
on_chain_startfires for everything a graph runs, and at that moment a node, a runnable inside a node, a subgraph and a conditional edge all look alike: same inheritedlanggraph_node, samelanggraph_step, an arbitraryname. Measured on one graph:name='INNER-RUNNABLE'carryinglanggraph_node='first',name='router'carryinglanggraph_node='validate_only', andname='the_graph'carrying nolanggraph_nodeat all. Reporting onnamereports all four.So a node is reported only when
nameequalsmetadata['langgraph_node']— compared, never copied. That rule is also what keeps the file safe:langgraph_nodeis the name the author wrote inadd_node(...), whilenameis whatever the caller supplied, including.with_config({"run_name": f"lookup for {customer.email}"}). The two are equal only for an author-defined node, so a runtime string cannot reach the transport through this path.Two records per node and nothing else —
node,step, the run ids that pair them, the instants, anderror. No state, no inputs, no outputs, no messages, and no exception message. The model exchanges stay with the proxy, which already has them byte for byte.Lazy, because the bootstrap is in front of every Python process
Importing the module that holds langchain-core's hook registry costs 617 ms against an interpreter that starts in about 50, and
orca recordloads this beforepython --version. Soinstall()arms asys.meta_pathfinder and returns; registration happens the first time anything underlanggraphis imported, in a process already paying a second for it. Measured: arming costs under 100 ms and leaks no langchain module into a process that never touches a graph.Waiting for
langchain_core.tracers.contextto appear on its own was the first design and does not work — measured across the 164find_speccalls of onefrom langgraph.graph import StateGraph, it is loaded for none of them, because_configureimports it at the first invoke, already too late to register in.The trigger is the
langgraphroot only, and that is a correctness constraint rather than a preference. The finder runs inside_find_and_load_unlocked, past itssys.modulescheck, so importing langchain-core from alangchain_core.*lookup re-enters that load — and for a top-level name CPython does not re-checksys.modulesbefore executing. Measured against a finder widened to watch both:langchain_coreexecuted twice, leaving one stale module object. A test reproduces it from the only direction that reaches it.A bug this found in the drain
The drain stamped events from
span.started_atalone. A record that closes something carries onlyended_at, so every one of them got the drain's own clock — which runs after the agent has exited. Measured: all threegraph.node.endevents landed within a millisecond of each other, 600 ms after the last node finished, so the first node appeared to close after the second had opened.occurredAtis also whatturnAtreads, so the same mistake filed them under the wrong turn.The bound in
readAgentSpansis widened to match. It exists to keep an instant adate-timecannot write down out ofTraceWriter.append, where the trace is being sealed and the transport has already been deleted; a fallback field the bound did not cover would have been a hole the size of the one it closed. Both directions are tested.Reporting a missing adapter: used, not installed
With a second adapter the old rule broke. It asked
importlib.metadataat startup whether the framework was installed, and on an ordinary machine that is yes for frameworks the run never touches — one venv here has both langgraph and openai-agents, so every recorded run in it was told topip installtwo adapters it had no relationship to, and the count grows with every adapter shipped. A warning that fires when nothing is wrong is how warnings stop being read.The bootstrap now reports only once the agent actually imports the framework, still confirmed by
importlib.metadata— so the false positive #83 was about, a project's ownagents.py, stays excluded. Eight behaviour cases were checked against a real interpreter: not recording, imports nothing, imports langgraph, importslanggraph.graphonly, importsagents, imports both, many submodules reported once, and a same-named module with no metadata.PYTHON_ADAPTERSis now one declaration that generates the bootstrap's Python and the operator's warning prose, so a package can no longer be reported with another package's explanation — which is exactly what happened the moment a second adapter existed and that text was still a constant.Format
graph.node.start/graph.node.end, soSCHEMA_VERSIONgoes 0.2.0 → 0.3.0 in both the TypeScript constants and the Python reader. The spec gains the two rows and a normative sentence theagent.*family also needed: these types are handed to orca by code inside the agent's interpreter rather than observed, so an implementation MUST keep them to structure the harness names for itself and MUST NOT carry prompts, model output, tool arguments, tool results or exception messages.The example trace gains one of each, because
scripts/conformance.mjsreports every declared type no shipped trace exercises. Checkpoint and turn-span goldens were recomputed by runningderiveCheckpointsandturnsOfover the new events rather than adjusted by hand.Proof
closing(),run_inlineoff, a droppedignore_chain, the widened finder trigger, and an eagerinstall.langgraph-nodesintegration check: 2 exchanges, replayed exact with the origin down, assertingplan/validate/answerpresent andINNER-RUNNABLE/route/the_graphabsent. The runner grows anexpectNodesoption, because the event types alone do not make that claim.scripts/conformance.mjs: 63 events, 0 failures, every declared type exercised.openai-agents-handoffstill passes against the rewritten bootstrap.twine checkPASSED on both.ignore_chainis worth naming. langchain-core reads everyignore_*with a baregetattr, and a missing one raises per callback, which it logs and swallows. Omitting it gave a graph that ran to completion, exit 0, fourError in ... callbacklines, and nothing recorded — a silent miss arriving through the one attribute that had to beFalse. A test now compares the whole set againstBaseCallbackHandler, so a release that adds an eighth is caught by a test rather than by an empty trace.Not fixed here
A
model.requestis stamped when orca persists it — after the response and after a workspace snapshot, measured 95 ms late on one call and 31 ms on the next — while a node record carries the instant the callback fired, exact against the agent's own clock. So node boundaries are trustworthy, and a model call within about a tenth of a second of one can sit on the wrong side of it. Fixing that means givingRecordedExchangeits own timestamps and threading them through the proxy, which is its own change. The README anddocs/integrations.mdsay so rather than overclaiming attribution this does not deliver.Publishing needs a one-time PyPI pending publisher for
orcareplay-langgraph(release-langgraph.yml, environmentpypi); until it exists the upload step fails and nothing else is affected.🤖 Generated with Claude Code