Files
coder/coderd/x/chatd/chatadvisor/handoff.go
T
Thomas Kosiewski aa89801ee5 fix(coderd/x/chatd/chatadvisor): textualize advisor prompt tool exchanges (#27059)
Closes CODAGT-592.

## Problem

The advisor tool sometimes fails with the opaque error `advisor produced
no text output`. Live reproduction against `claude-sonnet-4-6` showed
the cause: `BuildAdvisorMessages` forwards the parent conversation's raw
`tool_use`/`tool_result` blocks into the nested advisor call, which
defines no tools. The nested model imitates the forwarded pattern and
spends its turn committing to a tool call it cannot make (captured
reasoning from a failing run: "The user wants me to make another tool
call to the advisor about writing a poem about cucumbers."), so the step
ends with reasoning-only or empty content and no advice. Because each
chat step currently rebuilds the advisor runtime and snapshot
(CODAGT-593), the second advisor call in a run reliably sees the first
call's exchange, which is why the first call succeeds and later ones
fail.

## Fix

- `BuildAdvisorMessages` rewrites tool activity as plain-text notes:
assistant tool-call parts are removed and folded, together with their
matching result, into a single user-role note of the form `[The parent
agent ran the X tool with input {...}. Result: ...]`. No raw tool blocks
and no bare call lines reach the tool-less nested request. This also
removes the provider requirement that `tool_result` blocks pair with a
`tool_use`, so results orphaned by window truncation are kept as notes
instead of dropped.
- The `advisor produced no text output` error now appends the finish
reason and content-part kinds, e.g. `advisor produced no text output
(finish_reason=stop; parts: reasoning=1)`, so field reports distinguish
tool-call mimicry, reasoning-only turns, and truncation.

Validated live by driving the production `RunAdvisor` path against
`claude-sonnet-4-6` through the dev.coder.com AI gateway: the failing
scenario went from 3/3 errors to 6/6 genuine advice (with and without
extended thinking), with the control scenario unaffected.

Related: CODAGT-593 (per-step advisor runtime recreation, addressed
separately) and CODAGT-742 (advisor tool call design).

<details>
<summary>Investigation and validation details</summary>

### Reproduction

A CLI prototype constructed the exact conversation snapshot the
generation preparer hands the advisor tool and called the real
`chatadvisor.NewRuntime` / `Runtime.RunAdvisor` / `BuildAdvisorMessages`
/ `chatloop.GenerateAssistant` chain against live `claude-sonnet-4-6`,
with a stream-teeing model wrapper capturing what `runner.go` discards
(finish reason, part kinds, reasoning text).

| Scenario (snapshot contents) | Thinking | Before fix | After fix |
|---|---|---|---|
| control: call #1 state, no prior advisor exchange | on | 3/3 advice |
2/2 advice |
| repro: call #2 state, prior advisor `tool_use`/`tool_result` pair
forwarded | on | 3/3 `advisor produced no text output` | 3/3 genuine
advice |
| repro | off | 2/3 same error, 1/3 degenerate advice ("I'll ask the
advisor...") | 3/3 genuine advice |

Every failing response was a tiny thinking block, zero text, zero
tool-call stream parts, finish reason `stop`; the model's own reasoning
text showed it deciding to "make the second tool call" in a request with
`tools=0`. The refunded `remaining_uses: 1200` in the failing
tool-result JSON matches the original issue screenshot.

### Decision log

- Tool exchanges are folded into a single user-role note per call/result
pair. A first attempt rendered assistant-authored `[tool call:
name(input)]` text lines plus separate result messages; live runs then
returned the literal `[tool call: advisor(...)]` line as the advice 6/6
times. The bare assistant call line is itself an imitable pattern, so no
assistant-authored tool artifact may survive the handoff. The folded
user-role note produced 6/6 genuine advice.
- An assistant message that carried only tool calls is dropped entirely;
the folded notes preserve the information.
- `dropOrphanToolMessages` was removed: without raw tool blocks there is
no provider pairing constraint, and an orphaned result note retains
context value.
- A reasoning-budget-starvation hypothesis (thinking budget consuming
`MaxOutputTokens`) did not reproduce on `claude-sonnet-4-6`; the model
adapts thinking length to the cap. The enriched error would identify
such cases on other models via `finish_reason=length`.
- CODAGT-593 (persisting the advisor runtime across steps) is
intentionally not addressed here; it shrinks the priming window but the
handoff fix is what removes the failure mode.

</details>

---

*This PR was generated by Coder Agents on behalf of @ThomasK33 (Linear
agent session for CODAGT-592).*
2026-07-21 13:38:20 +02:00

248 lines
9.0 KiB
Go

package chatadvisor
import (
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
"charm.land/fantasy"
)
const (
// advisorRecentMessageLimit caps how many recent non-system messages
// from the parent conversation are forwarded to the advisor. The
// advisor only needs enough tail to ground its guidance, not the full
// history.
advisorRecentMessageLimit = 20
// advisorConversationJSONByteBudget caps the combined size of the
// forwarded recent messages, measured as JSON-serialized bytes (not
// raw text runes). The JSON wrapping inflates the count relative to
// user-visible text, so the effective text budget is smaller than the
// number suggests. The walk stops at the first message that would
// overflow, trading breadth for contiguity.
advisorConversationJSONByteBudget = 12000
// advisorSystemJSONByteBudget caps the combined size of inherited
// system messages forwarded to the advisor. Without a cap, a large
// parent system prompt (long injected instructions, accumulated
// context) could push the advisor call past the model's context
// window on top of the advisor contract, the recent tail, and the
// question, surfacing as a provider error instead of advice.
advisorSystemJSONByteBudget = 12000
defaultAdvisorQuestion = "Provide concise strategic guidance for the parent agent."
)
// BuildAdvisorMessages prepares a nested advisor prompt using the recent chat
// context plus the explicit advisor question.
func BuildAdvisorMessages(
question string,
conversationSnapshot []fantasy.Message,
) []fantasy.Message {
trimmedQuestion := strings.TrimSpace(question)
if trimmedQuestion == "" {
trimmedQuestion = defaultAdvisorQuestion
}
messages := make([]fantasy.Message, 0, len(conversationSnapshot)+2)
// Place inherited system messages before AdvisorSystemPrompt so the
// advisor contract is the final system instruction the model sees.
// Later system directives win when they conflict, and the parent's
// prompt may tell the model to address the end user directly or use
// tools. The advisor must override those behaviors, not be overridden
// by them.
//
// Walk system messages newest-to-oldest when consuming the byte
// budget so that truncation preserves the most recent directives.
// The parent may have injected recent safety or user-instruction
// blocks that should win over older foundational prompts, and later
// directives override earlier ones anyway. After selection, restore
// the original order before appending so the advisor still sees the
// parent's intended directive sequence.
inheritedSystem := make([]fantasy.Message, 0)
remainingSystemBudget := advisorSystemJSONByteBudget
for i := len(conversationSnapshot) - 1; i >= 0; i-- {
msg := conversationSnapshot[i]
if msg.Role != fantasy.MessageRoleSystem {
continue
}
messageBytes := messageJSONByteCount(msg)
if messageBytes > remainingSystemBudget {
// Skip oversized inherited system messages rather
// than forwarding them wholesale. A single massive
// parent system prompt could otherwise push the
// advisor prompt past the model's context window,
// returning a provider error instead of advice.
// Continue walking so smaller older directives can
// still contribute; stopping here would drop them
// solely because a newer sibling was oversized.
continue
}
inheritedSystem = append(inheritedSystem, cloneMessage(msg))
remainingSystemBudget -= messageBytes
}
slices.Reverse(inheritedSystem)
messages = append(messages, inheritedSystem...)
messages = append(messages, textMessage(fantasy.MessageRoleSystem, AdvisorSystemPrompt))
recent := make([]fantasy.Message, 0, min(len(conversationSnapshot), advisorRecentMessageLimit))
remainingBudget := advisorConversationJSONByteBudget
for i := len(conversationSnapshot) - 1; i >= 0; i-- {
msg := conversationSnapshot[i]
if msg.Role == fantasy.MessageRoleSystem {
continue
}
if len(recent) >= advisorRecentMessageLimit {
break
}
messageBytes := messageJSONByteCount(msg)
if messageBytes > remainingBudget {
// Stop at the first message that doesn't fit so the
// advisor window stays contiguous from most recent
// backward. Skipping an oversized message would leave
// the advisor with an invisible hole in the history,
// where later messages reference context that is no
// longer present.
break
}
recent = append(recent, cloneMessage(msg))
remainingBudget -= messageBytes
}
slices.Reverse(recent)
recent = textualizeToolExchanges(recent)
messages = append(messages, recent...)
messages = append(messages, textMessage(fantasy.MessageRoleUser, trimmedQuestion))
return messages
}
// textualizeToolExchanges rewrites tool activity as inline text notes.
// Assistant tool-call parts are removed, with their inputs folded into the
// note rendered for the matching tool result, and tool-role messages become
// user-role notes. The nested advisor call defines no tools, so
// assistant-authored tool artifacts in the transcript prime the model to
// imitate them instead of answering: raw tool_use/tool_result blocks yield
// an empty step ("advisor produced no text output"), and a bare
// "[tool call: ...]" text line yields that literal line back as advice.
// Folding each exchange into a single note leaves no assistant tool-call
// pattern to complete while keeping the activity visible, and it removes
// the provider requirement that tool_result blocks pair with a tool_use in
// the same request, so results whose calls were truncated out of the
// window can be kept instead of dropped.
func textualizeToolExchanges(recent []fantasy.Message) []fantasy.Message {
// Tool results carry only the call ID, so record each call's name and
// input as the forward walk scrubs assistant messages.
type callInfo struct {
name string
input string
}
calls := make(map[string]callInfo)
result := make([]fantasy.Message, 0, len(recent))
for _, msg := range recent {
switch msg.Role {
case fantasy.MessageRoleAssistant:
parts := make([]fantasy.MessagePart, 0, len(msg.Content))
for _, part := range msg.Content {
call, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part)
if !ok {
parts = append(parts, part)
continue
}
calls[call.ToolCallID] = callInfo{name: call.ToolName, input: call.Input}
}
if len(parts) == 0 {
// The message carried only tool calls; the folded
// result notes preserve the information.
continue
}
msg.Content = parts
result = append(result, msg)
case fantasy.MessageRoleTool:
parts := make([]fantasy.MessagePart, 0, len(msg.Content))
for _, part := range msg.Content {
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
if !ok {
parts = append(parts, part)
continue
}
output := renderToolResultOutput(tr.Output)
note := fmt.Sprintf("[A tool run by the parent agent returned: %s]", output)
if call, known := calls[tr.ToolCallID]; known {
note = fmt.Sprintf(
"[The parent agent ran the %s tool with input %s. Result: %s]",
call.name, call.input, output,
)
}
parts = append(parts, fantasy.TextPart{Text: note})
}
msg.Role = fantasy.MessageRoleUser
msg.Content = parts
result = append(result, msg)
default:
result = append(result, msg)
}
}
return result
}
// renderToolResultOutput flattens a tool result payload into text for the
// advisor transcript. Media payloads are summarized instead of inlined
// because base64 data adds prompt bulk without helping a text-only advisor.
func renderToolResultOutput(output fantasy.ToolResultOutputContent) string {
switch typed := output.(type) {
case fantasy.ToolResultOutputContentText:
return typed.Text
case fantasy.ToolResultOutputContentError:
if typed.Error != nil {
return "error: " + typed.Error.Error()
}
return "error"
case fantasy.ToolResultOutputContentMedia:
if typed.Text != "" {
return fmt.Sprintf("[%s media] %s", typed.MediaType, typed.Text)
}
return fmt.Sprintf("[%s media]", typed.MediaType)
default:
return ""
}
}
func textMessage(role fantasy.MessageRole, text string) fantasy.Message {
return fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: text},
},
}
}
func cloneMessage(msg fantasy.Message) fantasy.Message {
cloned := msg
cloned.Content = append([]fantasy.MessagePart(nil), msg.Content...)
cloned.ProviderOptions = maps.Clone(msg.ProviderOptions)
return cloned
}
// messageJSONByteCount approximates the message's contribution to the
// advisor prompt using the length of its JSON serialization. The JSON
// wrapping ({"role":"...","content":[{"type":"text","text":"..."}]}) is
// counted alongside the user-visible text; the measurement is intended
// for budget accounting, not for reporting visible character counts.
func messageJSONByteCount(msg fantasy.Message) int {
data, err := json.Marshal(msg)
if err == nil {
return len(data)
}
total := 0
for _, part := range msg.Content {
partData, partErr := json.Marshal(part)
if partErr == nil {
total += len(partData)
}
}
return total
}