Skip to content

proxy-pair grouping misses turns split across profiles by a header-stripping proxy leg #169

Description

@vaderyang

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 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 unrelated session_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).

Root cause

server/h-turn/src/proxy_pair.rs::content_fingerprint:

fn content_fingerprint(c: &PairCandidate) -> (&str, &str, u32, u64, u64) {
    (c.session_id, c.agent_kind, c.call_count, c.total_input_tokens, c.total_output_tokens)
}

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)

  1. 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)?
  2. 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.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:trywiwi auto-implement queuebugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions