fix: record the user turn an agent renders from its template - #367
Merged
Conversation
Traces from a template-rendering agent carried the system prompt and the completion but not the user message. That is the half an evaluation scores: what the model was actually asked. The gap was easy to miss precisely because the other two attributes were present, so a trace looked populated. The instrumentation serializes prompt_options[:messages] — the turns a caller passed explicitly. An agent written the idiomatic way, `instructions:` plus `locals:` with the user turn in the action's ERB, has none at that point: the rendering happens later, in prepare_prompt_parameters. So the message reached the model and never reached the trace. Falls back to the rendered parameters when no explicit messages exist. prepare_prompt_parameters is a pure function of prompt_options — it deep_dups its input and mutates no instance state — so reading it here is safe. It does re-render the templates, which is why the fallback is only reached when there is nothing else to record. It never raises: an agent whose templates need context this call cannot supply loses the attribute, not the generation. Also handles message objects that respond to #content, not just hashes and strings, since that is what the rendered parameters contain. Verified against a self-hosted dashboard: a document-enrichment agent whose user turn is a rendered page-text template now shows System / User / Assistant in the trace's Conversation tab, where the User row was previously absent. An app-side workaround for this in ApplicationAgent has been removed and the behaviour holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TonsOfFun
marked this pull request as ready for review
August 19, 2026 03:50
TonsOfFun
added a commit
that referenced
this pull request
Aug 19, 2026
Ships the rendered-user-turn capture (#367) under a version whose tag actually produces it. 1.3.0 was published from a tree that already carried that fix while the PR was still open, so the released gem and the v1.3.0 tag disagree: the tag's source does not reproduce the gem. Anyone auditing the release, or building from the tag, gets a different artifact than the one on rubygems. There is no functional change relative to the published 1.3.0 gem. main is byte-identical to it across lib/, verified file by file. This release exists so the tag, main and the published gem agree again, and upgrading is optional. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
A trace from a template-rendering agent carries
prompt.input.instructionsandllm.output.messagebut notprompt.input.messages. The user turn — what the model was actually asked — is missing.Easy to miss, because the other two attributes are there: the trace looks populated, the Conversation tab renders System and Assistant, and only the User row is absent. It is also the half an evaluation scores.
Why
The instrumentation serializes
prompt_options[:messages], which holds turns a caller passed explicitly. An agent written the idiomatic way —— has none at that point. Its user turn is rendered later, in
prepare_prompt_parameters. So the message reaches the model and never reaches the trace.The fix
Fall back to the rendered parameters when there are no explicit messages.
prepare_prompt_parametersis a pure function ofprompt_options— itdeep_dups its input and mutates no instance state — so reading it is safe. It does re-render the templates, which is why the fallback is only reached when there is nothing else to record. It never raises: an agent whose templates need context this call cannot supply loses the attribute, not the generation.Also handles message objects responding to
#content, not just hashes and strings, since that is what the rendered parameters contain.Verification
Against a self-hosted dashboard, same agent, stock gems before and after:
prompt.input.instructionsprompt.input.messagesllm.output.messageThe captured turn is the real rendered content (
"Title: Emergency shutdown procedure\nDocument id: …"), and the trace's Conversation tab now shows System / User / Assistant.An app-side workaround for exactly this had accumulated in a host app's
ApplicationAgent— hookingprepare_prompt_parametersand writing the gem's own attribute name. Removing it and relying on this change reproduces the same result, which is the outcome that matters: apps should not each have to discover and patch this.Tests
test/telemetry/rendered_messages_test.rbcovers reading the rendered messages, failing safe when rendering raises, and skipping agents that do not implementprepare_prompt_parameters.ActiveSupport::MessageEncryptor::InvalidMessage, no master key), which breaks existing tests on a clean tree too. The three behaviours were verified directly against the module in a host app's console instead; CI should be the judge of the file itself.Risk
Confined to the telemetry path and only reached when
prompt_options[:messages]is blank — an agent that passes messages explicitly takes the existing branch unchanged. The extra template render costs one pass, and only for agents that were previously recording nothing.🤖 Generated with Claude Code