feat(sdk): capture OpenAI Agents messages in HexgateUsageHooks - #191
victorludvig wants to merge 8 commits into
Conversation
899c4ef to
d4edd46
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
f211da2 to
dc3d7e7
Compare
89bd448 to
0444fda
Compare
0444fda to
1472f6b
Compare
1472f6b to
9b3d7ca
Compare
Review — 2 issuesVerified against the branch tip, 1.
|
guillaume-hexamind
left a comment
There was a problem hiding this comment.
2 comments above; the logic look good otherwise
Both fixed in |
on_llm_start stashes (system_prompt, input_items) per turn key (id(context) + agent name, so a handoff's own list is tracked separately); on_llm_end converts the Responses-API items into the OTel GenAI role/parts shape, asks MessageCursor what is new, and emits usage and messages from one call site. Only the delta reaches the wire — the input list is the whole conversation so far — and tool results ride with it, since a policy_decision row records a tool call but never its return value. HEXGATE_LOG_MESSAGES=0 skips the stash and the conversion, not only the emit, so an opted-out process pays nothing.
Review fixes on the OpenAI message hooks: - turn_key is Hexgate's run id, not id(context). The run context is freed at run end and CPython hands the next run the same address, so a process serving many runs filed unrelated conversations under one turn_key, each restarting message_seq at 0 — silently, since the rows still insert. - _output_messages routed reasoning items through the content branch, whose value is None on them, emitting an empty message. Their text lives under summary; the last turn's reasoning was recorded nowhere. - The cursor is advanced only once the conversion has succeeded, so a failed conversion no longer spends a seq on an event that never goes out. - TOOL_CALL_JSON_KEYS gains "response": frameworks stringify a tool's return value, so a tool that serialises its own result landed a JSON string whose secrets redaction never opened — the claim already in tracing/messages.py that tool results get the arguments rule. - log_messages_enabled is public (one name, not an alias) so a hook can skip stashing and converting, not just emitting.
The existing case proves the rows reach ClickHouse; this one proves they
come back out of GET /v1/projects/{id}/audit/llm-messages — a wrong column
name or a broken ORDER BY in that query is invisible to a direct
ClickHouse read.
Scoped by run_id rather than session_id: the run is the stronger key, and
it is the scope that exists for the common SDK caller who never sets a
session. project_id comes off the api key's own envelope, so the tests
need no second source of truth and no import from the platform package.
The read endpoints are cookie-authed dashboard reads, so the fty_live_ key
the SDK exports with does not open them. require_dashboard_login skips on
the HEXGATE_SMOKE_* pair the OTLP smoke script already reads, keeping the
default integration run — infra plus one API key — working unchanged.
adapters/openai/messages.py takes the Responses-API → GenAI shape translation; usage.py keeps the RunHooks pair, the turn key and the model resolution both events share. 341 lines become 202 + 161. The hooks stay together on purpose — on_llm_end is the one callback carrying both the token counts and the completion, and both events take the same _resolve_model(agent), so splitting those would duplicate it and let the two disagree about the model for one call. The converters share none of that: they are pure functions over plain dicts that decide nothing about when or whether to emit. Done now because PRs 11-13 each need the same layer against their own framework's message types, and whatever this PR does is the pattern they copy. Tests split the same way: test_messages.py for the conversion cases, test_usage.py for the hooks.
_Prompt (NamedTuple) replaces tuple[str | None, list[Any]] for the on_llm_start stash, and list[TResponseInputItem] replaces list[Any] on both the stash and the hook signature — the SDK's own type, so Any is now gone from usage.py entirely. Comment sweep in the same pass: six blocks that justified an *absence* (why no on_agent_end reset, why no json.loads on arguments, why convert before advancing) were costing a reader more than they saved, since there is no code to anchor them to. Kept where someone would plausibly try the alternative and break something; cut to a line otherwise. The handoff keying trade-off moves to issue #215, which is where a decision still in flight belongs. usage.py goes 47% comment lines to 38%, in line with the rest of the package rather than above it.
The rebase onto #190 brought in 50f5725/3d985373: session_id is the second column of the storage sort key, so the endpoint now asks callers to send it even blank — pinning it lets the scan stop at limit + offset rows instead of reading every session in the project and sorting the whole match. llm_messages_via_api omitted it, and the endpoint test omitted it while holding a real session, so the one read we exercise took the slow path the base had just documented against. The helper now always sends session_id, defaulting to blank, and the test passes both scopes — which narrows to their intersection and is the stronger assertion anyway. Also corrected two claims the re-review falsified: the converters do not sit "beside its hooks" for pydantic-ai, which has no per-call hook, and the conftest ordering comment called (occurred_at, message_seq) the sort key's tail when it drops the event_id tiebreak.
MessageCursor assumes each hook call carries the whole conversation and subtracts the prefix it emitted last time. That holds until a caller passes conversation_id, previous_response_id or auto_previous_response_id: the SDK then builds an OpenAIServerConversationTracker and sends the model — and on_llm_start — only the items the server has not seen. Diffing a delta against a prefix that was never re-sent matches nothing, so every event from turn two on went out resynced, for the life of the run. The content was right (the short list is the delta); the flag was not. semconv defines RESYNCED as "restates the whole list", so a reader honouring it drops every earlier row and renders a two-message conversation — a transcript present in storage and invisible in the tool an auditor uses. Latent only because nothing consumes the column yet. The hook cannot see the tracker, but the runner sees the kwargs that create it, so _merge_hooks passes framework_sends_deltas down and the hook skips the diff, emitting the items as they arrive with a seq counted per turn_key. resynced stays false: the flag means "earlier rows are superseded", which is as untrue here as for an ordinary extension. Also corrects the docstrings that asserted input_items is always the whole conversation — that claim is what made this invisible on a read-through — and guards the kwarg names against an upstream rename, which would silently restore the bug.
turn_preparation.get_model gives run_config.model precedence over agent.model in both its str and Model forms, so a run started with RunConfig(model="gpt-4o-mini") against Agent(model="gpt-4o") is served by gpt-4o-mini. _resolve_model read agent.model alone and recorded gpt-4o — a model that never answered — and this PR carries that onto llm_message as well as llm_usage, so the wrong name now lands on two streams instead of one. RunContextWrapper holds context, usage, turn_input and the approval/tool state, not the run config, so the hook cannot recover it from its callback arguments. The runner already receives run_config and now passes it to HexgateUsageHooks alongside framework_sends_deltas, and _resolve_model mirrors get_model's precedence. The "default" placeholder for a model unset on both sides is unchanged.
b3bbfaa to
ac0bcac
Compare
Design: LLM message logging design · implementation spec. Plan target: merge by Mon 14 Sep.
Stacked on #190 (
vl/feat/llm_messages_read), notmain— the read endpoint is what the second integration case verifies through.What is changing
adapters/openai/usage.py:on_llm_startstashes(system_prompt, input_items)perturn_key;on_llm_endemits usage and messages from one call site, then clears the stash. The runner already forwardson_llm_start. The Responses-API →gen_ai.*shape translation lives in the newadapters/openai/messages.py, so each of PRs 11–13 gets the same layer for its own framework.turn_keyisf"{run_id}:{agent.name}"— Hexgate's run id, notid(context): that address is reused once the run context is freed, so two unrelated runs in one process would share a key and both restartmessage_seqat 0.TOOL_CALL_JSON_KEYSgains"response": frameworks stringify a tool's return value, so a tool serialising its own result landed a JSON string whose secrets redaction never opened.Why is this change necessary
The response hook carries no prompt, so the pair is needed. Only the delta reaches the wire — the input list is the whole conversation so far — and tool results ride with it, since a
policy_decisionrow records a tool call but never its return value.Tests
tests/adapters/openai/test_usage.py(hooks) andtest_messages.py(conversion);tests/audit/test_message_caps.pyfor the new redaction key. Two integration cases: one asserting the rows concatenate back to the conversation with contiguousmessage_seq, one reading the same transcript back through #190'sGET /v1/projects/{id}/audit/llm-messages(cookie-authed, so it skips withoutHEXGATE_SMOKE_EMAIL/_PASSWORD).Run locally against the full stack — Postgres, ClickHouse, Redpanda, platform-api, Collector, span-enricher:
ruff check+format --checkKnown and tracked, not fixed here: #215 (should a handoff continue one transcript or start a new one — the handoff integration case lands with that decision) and #216 (
Agent.as_toolnested runs get no hooks, so no usage or message rows).🤖 Generated with Claude Code