Skip to content

feat(hooks): correlate llm:request/llm:response with a request_id - #106

Merged
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
lane/miv-correlation-id-upstream
Sep 3, 2026
Merged

feat(hooks): correlate llm:request/llm:response with a request_id#106
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
lane/miv-correlation-id-upstream

Conversation

@bkrabach

@bkrabach Brian Krabach (bkrabach) commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

DONE-NOTE — model_performance-miv

UPSTREAM: emit a correlation id on llm:request and echo it on llm:response

Repo: microsoft/amplifier-core (base bc6e3c8) · Branch: lane/miv-correlation-id-upstream
Spend: $0.00 — no API calls, no DTU, no infrastructure created or torn down.


Result

HookRegistry.emit() now stamps a client-generated request_id on the LLM call
event 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.

Event Behaviour
llm:request Generates request_id (uuid4), opens the call
llm:response Echoes it exactly, then closes the call
provider:error Echoes it, then closes the call — the timeout path
provider:retry, provider:throttle Echo it, call stays open
everything else Untouched

Scoping is by contextvars, so the id follows the async task that issued the
call. 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).

llm:request     caller=agent        request_id=620c068b-044d-4b18-a100-332479278b82
llm:request     caller=naming       request_id=a32346e2-7b7e-4aa0-b127-a54612a0bdcc
llm:request     caller=summarizer   request_id=32fc2255-2544-415b-84fd-be9576d5fc59
llm:response    caller=summarizer   request_id=32fc2255-2544-415b-84fd-be9576d5fc59
provider:error  caller=naming       request_id=a32346e2-7b7e-4aa0-b127-a54612a0bdcc
llm:response    caller=agent        request_id=620c068b-044d-4b18-a100-332479278b82
tool:pre        (no request_id — event outside the family)

FIFO pairs request[0] (agent) with response[0] (summarizer) — crossed. Pairing on
request_id attributes every response to its own caller, and the timed-out naming
call stays attributable through provider:error even though no response event
ever 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:request and llm:response appear in this repo only as event-name
constants 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 error
family: crates/amplifier-core/src/retry.rs contains zero emit calls.

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

Concern file:line
Event-name constants (schema surface) crates/amplifier-core/src/events.rs:74 (LLM_REQUEST), :76 (LLM_RESPONSE), :61 (PROVIDER_RETRY), :63 (PROVIDER_ERROR), :65 (PROVIDER_THROTTLE)
Core dispatch (Rust) crates/amplifier-core/src/hooks.rs:160HookRegistry::emit()
Precedent for an infrastructure-owned field crates/amplifier-core/src/hooks.rs:196-205 — the timestamp stamp; request_id follows the same ownership model
Stamp site (the choke point) bindings/python/src/hooks.rs:145, inside PyHookRegistry::emit() (:129)
Bridge applying the policy bindings/python/src/correlation.rs:50stamp_request_id(); prefilter at :37
Policy (single authority) python/amplifier_core/correlation.py:100resolve_request_id()
Generation python/amplifier_core/correlation.py:72new_request_id() (uuid4)
Event sets 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 its
Rust 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 to
the 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 no
per-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:error or llm:timeout event in the taxonomy. The error family is
provider:error / provider:retry / provider:throttle, all defined in
events.rs and all emitted downstream, never by this kernel. All three are in the
echo set. provider:error and llm:response are 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_EVENTS unchanged (43, as before).

Consumers that ignore request_id are unaffected. They receive the same
payload with one extra key, exactly as they already receive the
infrastructure-owned timestamp. Event data is a free-form dict at the hook
boundary — 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_id at all, and the kernel does not rewrite history — nothing on
disk 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_id when both events carry it
(method="request_id", confident=True); fall through to the signature join, then
FIFO, 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_parses and
test_emit_omits_request_id_when_no_call_is_in_flight.

Providers that already set their own request_id keep it. An explicit value
always 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 Rust
prefilter means they do not even pay the cost of the policy call.


DELIVERABLE 4 — tests — DONE

tests/test_hooks_request_id.py, 21 tests:

  • Pairing — id present and non-null on llm:request; llm:response echoes it
    exactly; each new call gets a distinct id.
  • Concurrency — the measured a1-fork interleaving (agent request, summarizer
    request, summarizer response, agent response) is reproduced deterministically
    with asyncio.Event gates; asserts both that identity pairing is correct and
    that FIFO would have crossed it. A second test runs 20 genuinely-concurrent calls
    and asserts 20 distinct ids, all correctly paired.
  • Id-less streams — a llm:response with no preceding request parses cleanly
    and carries no request_id; a historical capture with no ids still pairs by the
    fallback path; unrelated events never acquire the field.
  • Error/timeoutprovider:error echoes its request's id; provider:retry /
    provider:throttle echo without closing; a closed call is never re-read.
  • Policy unit tests — exhaustive over the four event classes, explicit-id
    override, and slot lifecycle.

Known limits (stated, not hidden)

  1. Out-of-process providers are not covered automatically. gRPC and WASM
    modules emit through the Rust core (grpc_server.rs), which has no access to the
    kernel's Python context. They must supply request_id themselves on both
    events. Documented in PROVIDER_CONTRACT.md.
  2. A provider emitting its response from a different async task than its request
    will not correlate.
    The response gets no id rather than a wrong one; such a
    provider must pass request_id explicitly. Documented.
  3. The :debug / :raw variants are deliberately not stamped. Their emission
    order relative to the canonical llm:request is unspecified, so stamping them
    risks attaching a stale id. Absent beats wrong. A provider wanting them
    correlated can pass request_id explicitly.
  4. cargo test -p amplifier-core-py cannot link on this host without
    RUSTFLAGS="-L <uv-python>/lib"libpython3.12.so is absent from the system
    lib 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::*.
  5. uv.lock on main is stale — it pins amplifier-core 1.5.1 while
    pyproject.toml declares 1.6.1; uv sync rewrites it. Reverted from this
    branch to keep the PR focused. Worth a separate one-line fix.

Verification

Check Result
pytest -m "not slow" (full suite) 1050 passed, 1 skipped, 6 deselected
tests/test_hooks_request_id.py 21 passed
cargo test -p amplifier-core 19 passed
cargo test -p amplifier-core-py (with lib path) 19 passed, incl. 2 new
cargo clippy -p amplifier-core -p amplifier-core-py -- -D warnings clean
cargo fmt -p amplifier-core -p amplifier-core-py --check clean
Live 3-caller concurrency + timeout demo correct pairing (trace above)

Decisions taken without escalation

  1. Field name request_id (not correlation_id) — the item's acceptance
    criteria name data.request_id explicitly.
  2. Value format: bare uuid4 string, no prefix — imposes no parsing assumptions
    on consumers.
  3. Client-generated, not provider-echoed — a provider-assigned id (resp_…)
    only exists after the response returns, too late to stamp on the request and
    absent entirely when the call times out.
  4. Policy in Python, application in Rust — one authority for which events carry
    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.
  5. Terminal events close the call — prevents a later unrelated event in the same
    task from inheriting a stale id.
  6. Did not touch the evals harness — the coordination boundary with shx is
    respected. Analyzer guidance for a mixed stream is written above rather than
    implemented there.

What remains open

  • In-process provider modules need no change; out-of-process providers do if
    their calls are to be correlated (limit 1).
  • shx's analyzer should add the request_id path as its primary join and demote
    the signature join to fallback. Once provider captures carry the id, the fork arm
    and Anthropic become measurable for the first time.
  • The PR is a draft: it needs maintainer review and a version-bump decision
    before merge.

`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.
@bkrabach
Brian Krabach (bkrabach) marked this pull request as ready for review September 3, 2026 00:45
@bkrabach

Copy link
Copy Markdown
Collaborator Author

Merge-queue verification — lane miv

Fresh scratch clone (scratch/merge9/amplifier-core), built via maturin build --release and installed into a clean venv, then ran the suite exactly as CI does.

Gate Method Result
(a) id present and matches across a request/response pair Ran test_request_generates_and_response_echoes, test_emit_pairs_request_and_response_by_id, test_error_path_echoes_the_request_id / test_emit_carries_request_id_onto_the_error_path (the timeout/no-response case) PASS
(b) concurrent calls get distinct ids, genuinely exercising concurrency Read test_concurrent_calls_get_distinct_ids_and_pair_correctly — confirmed it forces the interleaving with asyncio.Event gates across two real asyncio.create_tasks (agent req → summarizer req → summarizer resp → agent resp), then asserts FIFO would have crossed the pairing (requests[0] != responses[0]) while request_id pairs correctly. test_many_concurrent_calls_all_pair_correctly additionally runs 20 genuinely concurrent tasks via asyncio.gather and asserts 20 distinct ids, all correctly paired. Not sequential calls dressed up as concurrent. PASS
(c) event stream without ids still parses (backward compat for existing captures) test_historical_capture_without_request_id_still_parses, test_emit_omits_request_id_when_no_call_is_in_flight PASS
(d) consumers that ignore the field are unaffected test_id_ignoring_consumer_sees_an_otherwise_identical_payload — asserts the only new keys are request_id/timestamp PASS
(e) full suite green pytest tests/ bindings/python/tests/ -v -m "not slow"1050 passed, 1 skipped, 6 deselected (incl. all 21 new tests in test_hooks_request_id.py). cargo test -p amplifier-core --verbose → all green (474/4/4/4/19 across the sub-suites, 0 failed). cargo check -p amplifier-core -p amplifier-core-py clean. cargo fmt --check clean. cargo clippy -- -D warnings clean. PASS
Diff scope matches title git diff bc6e3c8 pr-head --stat: 8 files — correlation.rs (new), hooks.rs (stamp call site), lib.rs (module registration), 3 docs files, correlation.py (policy), test_hooks_request_id.py. Purely additive (691 insertions, 0 deletions). No provider repos touched, matching the PR's own "kernel-only" claim. PASS
Performance/measurement claims The PR's "12–31 pairs crossed per run" / "25.7–35.1% vs 2.4–10.9%" figures belong to the upstream analyzer work (lane shx), not asserted as measured by this PR itself; this PR's own evidence (the 3-caller concurrency trace) is reproduced deterministically by the new tests, not a perf claim. Nothing here required labeling as unmeasured. N/A — no unvalidated claim

All gates pass. Backward compatibility is tested (gate explicitly required this before merge).

Merging with --admin (required-review ruleset on this repo; I am the PR author).

@bkrabach
Brian Krabach (bkrabach) merged commit 6d4cd21 into main Sep 3, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants