Emit the user message as its own capture attribute #85
Labels
No labels
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/agent-proxy#85
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Raised while building a SigNoz log parser over the capture events, in coilyco-bridge/deploy
services/signoz-pipelines/pipelines/20-agent-proxy-capture.json.What works today
model.response.capturedis cleanly parseable. A downstream pipeline promotesfinish_reason,usage.completion_tokens,usage.prompt_tokens, andchoices[0].message.contentinto their own attributes, because every one of those sits at a fixed path. That turned a JSON read into a glance and made the truncation defect at coilyco-gaming/sirens-echo#86 visible on a dashboard.What does not
model.request.capturedis not parseable the same way. The user's actual message is the last element ofmessages, whose length varies with the system prompt, conversation history, and any tool-result rounds. In one observed turn the array had grown to about 47k prompt tokens.SigNoz pipeline processors cannot express this.
moveandcopytake fixed field paths with no array indexing at all, and theaddprocessor'sEXPR(...)escape hatch reaches a known index likeEXPR(attributes.parsed.messages[0].content)but has no last-element form.The available workarounds are all bad:
request.bodyon a dashboard defeats the purpose, since the field is dominated by the tools array and history.Ask
Emit the user message as its own attribute on
model.request.captured, alongside the existingagentproxy.*fields. Something likeagentproxy.user_message.The proxy already knows which message it is at capture time, so this is cheap where it sits and impossible everywhere downstream.
Note on safety
Whatever this field is called, it carries untrusted end-user text verbatim, so it inherits the same body-safety treatment as the existing captured bodies rather than being treated as a safe label. Flagging so it is a deliberate decision rather than a default.
Implemented on branch
aos/claude/user-message-capture, commit632745c. Needs a pull request opened, which the agent Forgejo surface cannot do.Compare: https://forgejo.coilysiren.me/coilyco-flight-deck/agent-proxy/compare/main...aos/claude/user-message-capture
What landed
model.request.capturednow carriesagentproxy.user_messagewith the verbatim text of the final user turn.last_user_message()inapp/body_capture.pyreads the messages list backwards for the lastuserrole. Reading backwards rather than taking the last element is the point: a tool-using turn ends on a tool result, so the last element is usually not the user at all. That case has its own test.Both content shapes are handled. A plain string returns as-is, and the content-parts form joins the
textparts and ignores the rest.Design decisions worth reviewing
Extraction is total and never raises. The field is a convenience projection beside the complete body, not a selected-field capture mode, so an absent, blank, or unreadable user message omits the field rather than failing a capture that would otherwise have succeeded. This is deliberately weaker than the surrounding module's fail-hard contract, because a request with no user message is legitimate and must not break capture.
The capture schema version stays at 1. Adding an optional field is backward compatible, and nothing in the repository pins the value, so a bump would signal an incompatibility that does not exist.
No truncation. A long user message is emitted whole. The complete body already carries the same text, so truncating would add a lossy field without reducing exposure.
Safety
The field carries untrusted end-user text verbatim, so it inherits the same restricted treatment as the captured bodies it sits beside. It adds no exposure that
request.bodydid not already have, and it is only emitted when capture is enabled.Verification
ward exec format-check,lint,typecheck, andtestall pass. 248 tests, 8 of them new: last-turn-not-first, trailing tool rounds, content-parts joining, an eight-case parametrized none-rather-than-raise sweep, and both emit paths.Not run:
ward exec test-container, which needs docker.Downstream
The consuming pipeline is already deployed at coilyco-bridge/deploy
services/signoz-pipelines/pipelines/20-agent-proxy-capture.json, which currently parses the response side only. Once this ships, the request half can promoteagentproxy.user_messagethe same way and the Sirens Deep console can show the question next to the answer.Extended the same branch with the response half, commit
ff28539. The open PR picks it up.Why the scope grew
The first commit derived only the input message, because that was the half a log pipeline provably cannot reach. The response fields sit at fixed paths, so a downstream pipeline handles them today and I left them there.
That reasoning was right about capability and wrong about ownership. It left turn-shape fields split across two repositories, and it meant only the SigNoz ingest path saw them. The trajectory store, evaluation joins, and any other capture consumer got the raw body and nothing else.
What the response side adds
model.response.capturednow carriesagentproxy.finish_reason,agentproxy.assistant_message,agentproxy.completion_tokens, andagentproxy.prompt_tokens, from the first choice and the usage block. The complete body still carries everything, further choices and reasoning content included.A truncated completion is now legible from the projection alone:
finish_reasonoflength,completion_tokensat the request's cap, and no assistant message. That is the coilyco-gaming/sirens-echo#86 signature without opening a body.Projection is total on both sides
An incomplete response keeps its closed-set reason and still projects whatever it does carry. A blank assistant message omits its field rather than emitting an empty string, which is what makes the truncation case read cleanly. Booleans are rejected where an integer token count is expected, since
boolis anintsubclass in Python.Verification
ward exec format-check,lint,typecheck, andtestall pass. 260 tests, up from 248, with 12 new on the response side covering the projection, the budget-exhausted shape, an eight-case omit-rather-than-raise sweep, and both the complete and incomplete emit paths.Sequencing note for whoever merges
The deploy-side parser at coilyco-bridge/deploy
services/signoz-pipelines/pipelines/20-agent-proxy-capture.jsoncurrently derives the response fields itself and is feeding a live dashboard. Do not thin it out until this ships and ser8 has rolled, or the Sirens Deep console loses those columns in the gap. Once the new image is serving, that parser can drop its extraction and the pipeline reduces to promoting the attributes Agent Proxy already emits.