mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
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).*
248 lines
9.0 KiB
Go
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
|
|
}
|