feat(hooks): correlate llm:request/llm:response with a request_id - #106
Merged
Merged
Conversation
`llm:request` and `llm:response` shared no field identifying the call they belonged to, so every consumer had to pair them positionally (FIFO over the event stream). That silently mis-attributes whenever a second LLM call is in flight -- a background summarizer, a session-naming hook, a forked sub-agent. Both events still parse and the counts still look plausible, so the error is invisible. Measured on real captures, FIFO crossed 12-31 pairs per run and put a summarizer's cost on the agent; the resulting figure survived six probes before anyone noticed it was wrong. `HookRegistry.emit()` now stamps a client-generated correlation id: llm:request -> generates request_id (uuid4), opens the call llm:response, provider:error -> echo it, then close the call provider:retry, provider:throttle -> echo it, call stays open everything else -> untouched The error path matters as much as the happy one: a call that times out never emits a response at all, which is precisely the case positional pairing gets most wrong. `provider:error` now carries the id of the request it belongs to. Scoping is by contextvars, so the id follows the async task that issued the call -- two concurrent calls hold two independent slots. The stamp happens in the PyO3 bridge, on the caller's Python stack, because the spawned future runs off-thread and no longer has the emitting task's context. Providers need no change. The policy lives in `amplifier_core.correlation` and is the single authority on which events carry an id; the Rust bridge only applies it, behind a cheap `llm:`/`provider:` prefix filter. Backward compatible in both directions: - Consumers that ignore `request_id` see an otherwise identical payload; nothing was renamed, moved, or removed. - Captures already on disk have no `request_id` and remain readable exactly as before -- the kernel does not rewrite history. Consumers treat the field as optional and keep their prior heuristic as the fallback. - A provider supplying its own `request_id` keeps it; the kernel adopts that value for the rest of the call and never overwrites it. - When a response fires with no matching request in its context, no id is stamped. An absent id is always preferable to a wrong one. Out-of-process (gRPC/WASM) providers do not share the kernel's Python context and must supply `request_id` themselves; documented in PROVIDER_CONTRACT.md.
Brian Krabach (bkrabach)
marked this pull request as ready for review
September 3, 2026 00:45
Collaborator
Author
Merge-queue verification — lane mivFresh scratch clone (
All gates pass. Backward compatibility is tested (gate explicitly required this before merge). Merging with |
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.
DONE-NOTE — model_performance-miv
UPSTREAM: emit a correlation id on
llm:requestand echo it onllm:responseRepo:
microsoft/amplifier-core(basebc6e3c8) · Branch:lane/miv-correlation-id-upstreamSpend: $0.00 — no API calls, no DTU, no infrastructure created or torn down.
Result
HookRegistry.emit()now stamps a client-generatedrequest_idon the LLM callevent family. Providers need no change — the kernel does it on the emit path,
so existing provider modules get correct pairing for free, including the fork arm
and the Anthropic case where shx's signature join is unavailable entirely.
llm:requestrequest_id(uuid4), opens the callllm:responseprovider:errorprovider:retry,provider:throttleScoping is by
contextvars, so the id follows the async task that issued thecall. Two concurrent calls run in two tasks and hold two independent slots —
exactly the case positional pairing gets wrong.
Measured demonstration
Live run against the built kernel: three concurrent callers, one of which times
out (agent 30 ms, summarizer 10 ms, naming call 20 ms then
provider:error).FIFO pairs request[0] (agent) with response[0] (summarizer) — crossed. Pairing on
request_idattributes every response to its own caller, and the timed-out namingcall stays attributable through
provider:erroreven though no response eventever arrives. That is egh's "15 requests, 13 responses" case, closed.
DELIVERABLE 1 — DRAFT PR — DONE
#106 (draft), commit
14079ed. Suite green — see Verification.DELIVERABLE 2 — emission/echo sites at file:line — DONE
The honest finding first: amplifier-core never emits these events
llm:requestandllm:responseappear in this repo only as event-nameconstants and documentation. There is no emission site here to modify — the events
are emitted by provider modules living in separate repos (reference pattern:
docs/contracts/PROVIDER_CONTRACT.md:230). The same holds for the whole errorfamily:
crates/amplifier-core/src/retry.rscontains zeroemitcalls.That is why the fix sits on the emit path rather than at an emission site: it
is the only point inside amplifier-core that every provider's events pass through,
and it is what makes this work without touching a single provider repo.
Where the id is generated and echoed
crates/amplifier-core/src/events.rs:74(LLM_REQUEST),:76(LLM_RESPONSE),:61(PROVIDER_RETRY),:63(PROVIDER_ERROR),:65(PROVIDER_THROTTLE)crates/amplifier-core/src/hooks.rs:160—HookRegistry::emit()crates/amplifier-core/src/hooks.rs:196-205— thetimestampstamp;request_idfollows the same ownership modelbindings/python/src/hooks.rs:145, insidePyHookRegistry::emit()(:129)bindings/python/src/correlation.rs:50—stamp_request_id(); prefilter at:37python/amplifier_core/correlation.py:100—resolve_request_id()python/amplifier_core/correlation.py:72—new_request_id()(uuid4)python/amplifier_core/correlation.py:55(request),:59(terminal),:62(interim)Why the stamp is in the PyO3 bridge, not the Rust core
PyHookRegistry::emit()is the only emit path Python providers can reach, and itsRust body runs on the caller's Python stack, before the work is handed to a
spawned tokio future. That matters: the in-flight call lives in a
contextvars.ContextVar, and the spawned future runs off-thread with no access tothe emitting task's context. Stamping any later would lose the very thing that
makes concurrent calls separable.
The core
HookRegistry::emit()(Rust) was deliberately not modified. It has noper-task state — a registry-level slot would be shared across every concurrent
call, which is the original bug wearing a different hat.
Error/timeout paths — they exist, and they are covered
There is no
llm:errororllm:timeoutevent in the taxonomy. The error family isprovider:error/provider:retry/provider:throttle, all defined inevents.rsand all emitted downstream, never by this kernel. All three are in theecho set.
provider:errorandllm:responseare terminal — they close the call,so a later unrelated event in the same task cannot inherit a stale id.
DELIVERABLE 3 — backward compatibility — DONE
The change is purely additive. No field renamed, moved, removed, or retyped; no
event added or removed;
ALL_EVENTSunchanged (43, as before).Consumers that ignore
request_idare unaffected. They receive the samepayload with one extra key, exactly as they already receive the
infrastructure-owned
timestamp. Event data is a free-form dict at the hookboundary — there is no strict schema a new key can violate. Pinned by
test_id_ignoring_consumer_sees_an_otherwise_identical_payload.Analyzers reading captures already on disk keep working. Every existing capture
has no
request_idat all, and the kernel does not rewrite history — nothing ondisk changes. Consumers must read the field as optional
(
data.get("request_id")) and keep their existing heuristic as the fallback.Concretely, for shx's analyzer: pair on
request_idwhen both events carry it(
method="request_id",confident=True); fall through to the signature join, thenFIFO, when either side lacks it. Old and new captures then read through one code
path, and a mixed stream — the realistic case while provider modules pick up the
new kernel — degrades per call rather than per file. Pinned by
test_historical_capture_without_request_id_still_parsesandtest_emit_omits_request_id_when_no_call_is_in_flight.Providers that already set their own
request_idkeep it. An explicit valuealways wins; the kernel adopts it for the rest of the call and never overwrites.
Pinned by
test_emit_preserves_an_explicit_request_id.Absence stays meaningful. If a response-family event fires with no matching
request in its context, no id is stamped at all. An absent id is always preferable
to a wrong one — a wrong one is precisely the failure this item exists to retire.
Events outside the
llm:/provider:families are never touched, and the Rustprefilter means they do not even pay the cost of the policy call.
DELIVERABLE 4 — tests — DONE
tests/test_hooks_request_id.py, 21 tests:llm:request;llm:responseechoes itexactly; each new call gets a distinct id.
request, summarizer response, agent response) is reproduced deterministically
with
asyncio.Eventgates; asserts both that identity pairing is correct andthat FIFO would have crossed it. A second test runs 20 genuinely-concurrent calls
and asserts 20 distinct ids, all correctly paired.
llm:responsewith no preceding request parses cleanlyand carries no
request_id; a historical capture with no ids still pairs by thefallback path; unrelated events never acquire the field.
provider:errorechoes its request's id;provider:retry/provider:throttleecho without closing; a closed call is never re-read.override, and slot lifecycle.
Known limits (stated, not hidden)
modules emit through the Rust core (
grpc_server.rs), which has no access to thekernel's Python context. They must supply
request_idthemselves on bothevents. Documented in
PROVIDER_CONTRACT.md.will not correlate. The response gets no id rather than a wrong one; such a
provider must pass
request_idexplicitly. Documented.:debug/:rawvariants are deliberately not stamped. Their emissionorder relative to the canonical
llm:requestis unspecified, so stamping themrisks attaching a stale id. Absent beats wrong. A provider wanting them
correlated can pass
request_idexplicitly.cargo test -p amplifier-core-pycannot link on this host withoutRUSTFLAGS="-L <uv-python>/lib"—libpython3.12.sois absent from the systemlib path. Pre-existing and unrelated (CI does not run that target either). With
the flag it passes: 19 tests, including the two new
correlation::tests::*.uv.lockonmainis stale — it pinsamplifier-core 1.5.1whilepyproject.tomldeclares1.6.1;uv syncrewrites it. Reverted from thisbranch to keep the PR focused. Worth a separate one-line fix.
Verification
pytest -m "not slow"(full suite)tests/test_hooks_request_id.pycargo test -p amplifier-corecargo test -p amplifier-core-py(with lib path)cargo clippy -p amplifier-core -p amplifier-core-py -- -D warningscargo fmt -p amplifier-core -p amplifier-core-py --checkDecisions taken without escalation
request_id(notcorrelation_id) — the item's acceptancecriteria name
data.request_idexplicitly.on consumers.
resp_…)only exists after the response returns, too late to stamp on the request and
absent entirely when the call times out.
an id, unit-testable without a kernel rebuild; the Rust prefilter is deliberately
broader than the real set so the two cannot drift into disagreement.
task from inheriting a stale id.
respected. Analyzer guidance for a mixed stream is written above rather than
implemented there.
What remains open
their calls are to be correlated (limit 1).
request_idpath as its primary join and demotethe signature join to fallback. Once provider captures carry the id, the fork arm
and Anthropic become measurable for the first time.
before merge.