feat(sdk): add hexgate.messages wire contract and content caps - #182
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
1d26f22 to
71984da
Compare
Review: two issues on the new message caps1.
|
guillaume-hexamind
left a comment
There was a problem hiding this comment.
2 Issues to investigate above; otherwise I approve the design and this first step
|
guillaume-hexamind
left a comment
There was a problem hiding this comment.
Rebase before merge, but good to me
Adds the fourth audit scope to the OTLP wire contract shared with the platform enricher: SCOPE_MESSAGES plus the official GenAI content names (gen_ai.input.messages / output.messages / system_instructions) and the Hexgate-specific sec_ai.message_seq / turn_key / resynced / truncated. Adds the three message byte caps beside MAX_ARGS_BYTES and a head+tail truncation pipeline (truncate_head_tail, cap_json_head_tail) that keeps the JSON shape of a message list and cuts the middle of its largest string leaf, so the start and end of an oversized RAG message both survive. The platform imports these the way it already imports truncate_json. No emitter yet; nothing sends this scope.
The per-leaf target subtracted the whole document's JSON overage from one leaf's UTF-8 length, so escape-heavy content collapsed to the 64-byte floor and the overage was billed to whichever leaf happened to be biggest. Search for one shared allowance instead, detect the structure-dominated case up front, and keep the preview budget positive so a small cap terminates.
32 KiB is ~7,000 tokens of ASCII, which 20 retrieved chunks already exceed, so the cap fired on exactly the RAG calls the message log exists to explain. The bound is the OTLP record size rather than storage, and the message path's topic carries max.message.bytes=8 MiB, so a quarter-megabyte field costs nothing operationally. Output and system instructions stay at 8 KiB.
copy.deepcopy raised on message objects holding a lock or a socket, so small lists failed where large ones — rebuilt leaf by leaf — went through. Rebuild via _cap_leaves with a limit the document already fits, which cuts nothing and copies the containers. Also corrects the input cap rationale: it cites pipeline limits that are not deployed yet, so name them as the prerequisite they are rather than as existing config.
7e66a3f to
d840c8b
Compare
Wire contract only — no emitter, no platform change; nothing sends this scope yet.
What is Changing
hexgate/tracing/semconv.py—SCOPE_MESSAGES = "hexgate.messages", the official GenAI content namesgen_ai.input.messages/gen_ai.output.messages/gen_ai.system_instructions(verbatim, never coined), and the Hexgate-specificsec_ai.message_seq/turn_key/resynced/truncated. The module docstring gains the message rules: JSON-string content, one event per LLM call carrying only the messages new to that call,message_seqperturn_keyfor gap detection.hexgate/audit.py—MAX_INPUT_MESSAGES_BYTES(256 KiB),MAX_OUTPUT_MESSAGES_BYTES(8 KiB),MAX_SYSTEM_INSTRUCTIONS_BYTES(8 KiB) beside the decision caps, plustruncate_head_tail()andcap_json_head_tail(). The latter shrinks every string leaf head+tail to one shared byte allowance — the largest that makes the serialized JSON fit, found by binary search — so roles and message boundaries survive and only the middle of oversized text is lost. Falls back to the existingtruncate_jsonpreview wrapper when the JSON structure alone exceeds the cap. Pure; measures withjson.dumps(default=str)like the platform.Why is this change necessary?
semconv.pyis the one file both packages import, so the wire contract lands first and the platform and SDK lanes can then proceed in parallel.Why the caps and helpers live in the SDK. The SDK applies the cap before export — the design's blast-radius argument (one oversized span must not take its batch down) only holds upstream of the batch — and the platform enricher will import the same functions as enforcement, exactly as
enforcement.pyimportstruncate_jsontoday.Why the input cap is 256 KiB and not the 32 KiB first proposed. 32 KiB is ~7,000 tokens of ASCII (~5,400 CJK characters, since JSON escapes each to 6 bytes), which 20 retrieved chunks of 500 tokens already exceed — the cap would have fired on exactly the RAG calls the message log exists to explain. What bounds it is the OTLP record size rather than storage, and the message path's topic is raised to
max.message.bytes=8 MiBanyway, so a quarter-megabyte field costs nothing operationally.Why one shared allowance rather than a per-leaf budget. Deriving each leaf's target from the document's overage is wrong twice over: the overage is measured on escaped JSON while a leaf is measured in UTF-8, so escape-heavy content over-cuts to the floor (70k CJK characters kept 42 of a 32 KiB cap); and charging one leaf for the whole overage makes the outcome depend on which message happens to be biggest (two equal messages came out 64 / 8,004). A single allowance is order-independent and leaves no cap unspent.
Tests
tests/tracing/test_semconv.py— new scope and names in the prefix / collision / official-name invariants.tests/audit/test_message_caps.py— head+tail on ASCII and multibyte text, exact omitted-byte count, largest-leaf-first, both-leaves-cut with balance and utilization bounds, escape-heavy content, tuple-shaped message lists (LangChain's[("system", …), ("human", …)]), caps below the preview wrapper's headroom, no input mutation, structure-only overflow fallback, bare string, CJK under the JSON measure.Known gap
Inline base64 image parts are capped as ordinary string leaves, so a large
data:image/...URL survives as a useless fragment eating most of the input budget. Tracked in #198 — image parts should be placeholder-substituted before the cap runs.Design
LLM message logging design · implementation spec
🤖 Generated with Claude Code