Files
coder/coderd/x/chatd/chatadvisor/runner.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

170 lines
5.0 KiB
Go

package chatadvisor
import (
"context"
"fmt"
"strings"
"time"
"charm.land/fantasy"
"golang.org/x/xerrors"
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
"github.com/coder/coder/v2/codersdk"
)
// RunAdvisorOptions carries optional streaming callbacks for a
// single RunAdvisor invocation.
type RunAdvisorOptions struct {
OnAdviceDelta func(delta string)
OnAdviceReset func()
}
// RunAdvisor executes a single, tool-less nested advisor call.
func (rt *Runtime) RunAdvisor(
ctx context.Context,
question string,
conversationSnapshot []fantasy.Message,
opts *RunAdvisorOptions,
) (AdvisorResult, error) {
// Model, MaxUsesPerRun, and MaxOutputTokens are validated by NewRuntime.
// Runtime fields are unexported so callers cannot bypass that.
question = strings.TrimSpace(question)
if question == "" {
return AdvisorResult{}, xerrors.New("advisor question is required")
}
question = stringutil.Truncate(question, advisorQuestionMaxRunes)
if !rt.tryAcquire() {
return AdvisorResult{
Type: ResultTypeLimitReached,
RemainingUses: 0,
}, nil
}
// resetProviderOptionsForNestedCall mutates its argument; give it a
// clone so the Runtime's stored options stay unchanged across calls.
nestedProviderOptions := cloneProviderOptions(rt.cfg.ProviderOptions)
resetProviderOptionsForNestedCall(nestedProviderOptions)
assistantOpts := chatloop.GenerateAssistantOptions{
Model: rt.cfg.Model,
Messages: BuildAdvisorMessages(question, conversationSnapshot),
ModelConfig: rt.cfg.ModelConfig,
ProviderOptions: nestedProviderOptions,
}
if opts != nil && opts.OnAdviceDelta != nil {
assistantOpts.PublishMessagePart = func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) {
if role != codersdk.ChatMessageRoleAssistant ||
part.Type != codersdk.ChatMessagePartTypeText ||
part.Text == "" {
return
}
opts.OnAdviceDelta(part.Text)
}
}
var outcome chatloop.AssistantOutcome
if err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
var err error
outcome, err = chatloop.GenerateAssistant(retryCtx, assistantOpts)
return err
}, func(int, error, chatretry.ClassifiedError, time.Duration) {
if opts != nil && opts.OnAdviceReset != nil {
opts.OnAdviceReset()
}
}); err != nil {
// Refund the use so a transient provider failure does not
// permanently exhaust the per-run advisor budget.
rt.release()
return AdvisorResult{
Type: ResultTypeError,
Error: err.Error(),
RemainingUses: rt.RemainingUses(),
}, nil
}
advice := extractAdvisorText(outcome.Step)
if advice == "" {
// Refund: the run did not produce advice, so the contract
// "increments on every successful advisor call" treats this
// as not consuming a use.
rt.release()
return AdvisorResult{
Type: ResultTypeError,
Error: fmt.Sprintf(
"advisor produced no text output (%s)",
describeTextlessOutcome(outcome),
),
RemainingUses: rt.RemainingUses(),
}, nil
}
return AdvisorResult{
Type: ResultTypeAdvice,
Advice: advice,
AdvisorModel: rt.cfg.Model.Provider() + "/" + rt.cfg.Model.Model(),
RemainingUses: rt.RemainingUses(),
}, nil
}
func extractAdvisorText(step chatloop.PersistedStep) string {
parts := make([]string, 0, len(step.Content))
for _, content := range step.Content {
text, ok := fantasy.AsContentType[fantasy.TextContent](content)
if !ok {
continue
}
trimmed := strings.TrimSpace(text.Text)
if trimmed == "" {
continue
}
parts = append(parts, trimmed)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
// describeTextlessOutcome summarizes a step that yielded no usable advice
// text so the error pinpoints the failure mode. A reasoning-only step means
// the model spent its turn deciding on an action (such as a tool call it
// cannot perform in this tool-less run) without answering; a length finish
// means the output was truncated before any text was produced.
func describeTextlessOutcome(outcome chatloop.AssistantOutcome) string {
var text, reasoning, toolCalls, other int
for _, content := range outcome.Step.Content {
switch content.(type) {
case fantasy.TextContent:
text++
case fantasy.ReasoningContent:
reasoning++
case fantasy.ToolCallContent:
toolCalls++
default:
other++
}
}
if len(outcome.ToolCalls) > toolCalls {
toolCalls = len(outcome.ToolCalls)
}
kinds := make([]string, 0, 4)
appendKind := func(name string, count int) {
if count > 0 {
kinds = append(kinds, fmt.Sprintf("%s=%d", name, count))
}
}
// Text parts can only reach here blank, so label them accordingly.
appendKind("blank_text", text)
appendKind("reasoning", reasoning)
appendKind("tool_call", toolCalls)
appendKind("other", other)
summary := "none"
if len(kinds) > 0 {
summary = strings.Join(kinds, ", ")
}
return fmt.Sprintf("finish_reason=%s; parts: %s", outcome.FinishReason, summary)
}