You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A single logical agent turn observed across two network vantage points — where one leg is captured before a forwarding proxy and the other after it — is split into two independent AgentTurns that never get folded by the proxy_pair grouping mechanism, even though that mechanism exists precisely for this case.
Reproduction (production)
One logical opencode session (OpenAI Chat wire, GLM model) was captured as two turns with identical content but different agent_kind / session_id:
leg
profile
session_id source
session_id
agent_kind
proxy ingress (client → proxy)
opencode
x-session-affinity header
ses_…
opencode
proxy egress (proxy → upstream)
generic
tool-id synthesis (header stripped)
call_…
generic
Both turns carry ~the same calls (verified: 26 of 26 stable message-content fingerprints match across the two turns) and overlap in time, but the proxy egress leg lost the opencode session header — the forwarding proxy (LiteLLM, using the AsyncOpenAI Python SDK) rebuilds the request and drops x-session-affinity and replaces the User-Agent. With no opencode header/UA, the egress leg falls through to GenericProfile, which synthesizes a completely unrelatedsession_id from the tool-call anchor.
A secondary effect: each leg is itself captured 2× (ingress + loopback/docker bridge mirror), inflating call_count on both turns (and by different amounts — 51 vs 49 — since the mirror counts diverge).
group_all buckets candidates by this fingerprint before time-clustering. The doc comment (proxy_pair.rs:199–200) states the design assumption:
session_id is the strongest signal — agent profiles already content-hash on first user message, so cross-proxy legs land in the same session by construction.
That assumption holds when both legs hit the same profile (both keep the header, or both lose it and both synthesize identically). It breaks when one leg keeps the header (→ opencode session) and the other loses it (→ generic synthesized session): the two session_ids are unrelated strings, so the legs land in different fingerprint buckets and group_all never compares them.
Every other fingerprint field is also unstable in this case:
agent_kind differs (opencode vs generic) — same root cause.
call_count / tokens differ — mirror double-capture inflates each leg by a different amount.
Why the fix isn't trivial
The one signal that is stable across profiles and immune to double-capture is the request body content of the turn's opening call. Verified on the reproduction: both turns' first user-call body hashes to the identical d41dd507…. But:
user_input_preview (the existing content column on agent_turns) is NULL on both turns here — the opening call's extract_user_input returns None for the tool-result-tail continuation shape (same class of issue as the eBPF opening-call-missed case in fix(turn): keep eBPF-sourced turns whose opening call was missed #168), so the existing column can't carry it.
PairCandidate is a narrow DB projection with no body / body-hash field today.
So a fix needs a new stable content signal persisted on the turn (e.g. a first_call_content_hash column populated at build_turn time), projected into PairCandidate, and used in content_fingerprint in place of session_id/agent_kind. That touches schema (migration) + tracker + storage projection across all three backends — non-trivial, hence filing before implementing.
Open questions (before implementing)
Frequency. This is one observed case (a LiteLLM-fronted opencode deployment). Is the header-stripping proxy leg common enough across deployments to justify a schema migration, or should a lighter-weight fix be preferred (e.g. body re-read at pair-query time, no new column)?
Double-capture scope. The 2× mirror inflation per leg is a separate capture-layer concern. This issue is about the cross-turn split only; the mirror inflation should likely be its own issue if it warrants work.
Summary
A single logical agent turn observed across two network vantage points — where one leg is captured before a forwarding proxy and the other after it — is split into two independent
AgentTurns that never get folded by theproxy_pairgrouping mechanism, even though that mechanism exists precisely for this case.Reproduction (production)
One logical opencode session (OpenAI Chat wire, GLM model) was captured as two turns with identical content but different
agent_kind/session_id:session_idagent_kindopencodex-session-affinityheaderses_…genericcall_…Both turns carry ~the same calls (verified: 26 of 26 stable message-content fingerprints match across the two turns) and overlap in time, but the proxy egress leg lost the opencode session header — the forwarding proxy (LiteLLM, using the AsyncOpenAI Python SDK) rebuilds the request and drops
x-session-affinityand replaces theUser-Agent. With no opencode header/UA, the egress leg falls through toGenericProfile, which synthesizes a completely unrelatedsession_idfrom the tool-call anchor.A secondary effect: each leg is itself captured 2× (ingress + loopback/docker bridge mirror), inflating
call_counton both turns (and by different amounts — 51 vs 49 — since the mirror counts diverge).Root cause
server/h-turn/src/proxy_pair.rs::content_fingerprint:group_allbuckets candidates by this fingerprint before time-clustering. The doc comment (proxy_pair.rs:199–200) states the design assumption:That assumption holds when both legs hit the same profile (both keep the header, or both lose it and both synthesize identically). It breaks when one leg keeps the header (→
opencodesession) and the other loses it (→genericsynthesized session): the twosession_ids are unrelated strings, so the legs land in different fingerprint buckets andgroup_allnever compares them.Every other fingerprint field is also unstable in this case:
agent_kinddiffers (opencodevsgeneric) — same root cause.call_count/ tokens differ — mirror double-capture inflates each leg by a different amount.Why the fix isn't trivial
The one signal that is stable across profiles and immune to double-capture is the request body content of the turn's opening call. Verified on the reproduction: both turns' first user-call body hashes to the identical
d41dd507…. But:user_input_preview(the existing content column onagent_turns) isNULLon both turns here — the opening call'sextract_user_inputreturnsNonefor the tool-result-tail continuation shape (same class of issue as the eBPF opening-call-missed case in fix(turn): keep eBPF-sourced turns whose opening call was missed #168), so the existing column can't carry it.PairCandidateis a narrow DB projection with no body / body-hash field today.So a fix needs a new stable content signal persisted on the turn (e.g. a
first_call_content_hashcolumn populated atbuild_turntime), projected intoPairCandidate, and used incontent_fingerprintin place ofsession_id/agent_kind. That touches schema (migration) + tracker + storage projection across all three backends — non-trivial, hence filing before implementing.Open questions (before implementing)
Related
has_user_startfallback; this one isproxy_pairfingerprint scope.