Files
Michael Suchacz 47b90afce6 fix(coderd/x/chatd/chatadvisor): truncate oversized advisor questions (#25489)
Advisor tool calls currently reject questions over 2000 runes, which can
leave the parent model retrying the same invalid call.

This documents the limit in the advisor tool schema and guidance, then
truncates oversized questions rune-safely before building the nested
advisor prompt.

> Mux working on behalf of Mike.
2026-05-19 17:57:14 +02:00

128 lines
3.8 KiB
Go

package chatadvisor
import (
"context"
"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
}
// Clone per invocation and reset inherited state so chatloop cannot
// mutate the Runtime's stored options across calls, and so the nested
// call never runs as a chain-mode continuation against stale parent
// state or persists an orphan stored response on the provider side.
nestedProviderOptions := cloneProviderOptions(rt.cfg.ProviderOptions)
resetProviderOptionsForNestedCall(nestedProviderOptions)
var persistedStep chatloop.PersistedStep
chatLoopOpts := chatloop.RunOptions{
Model: rt.cfg.Model,
Messages: BuildAdvisorMessages(question, conversationSnapshot),
MaxSteps: 1,
ModelConfig: rt.cfg.ModelConfig,
ProviderOptions: nestedProviderOptions,
PersistStep: func(_ context.Context, step chatloop.PersistedStep) error {
persistedStep = step
return nil
},
}
if opts != nil && opts.OnAdviceDelta != nil {
chatLoopOpts.PublishMessagePart = func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) {
if role != codersdk.ChatMessageRoleAssistant ||
part.Type != codersdk.ChatMessagePartTypeText ||
part.Text == "" {
return
}
opts.OnAdviceDelta(part.Text)
}
}
if opts != nil && opts.OnAdviceReset != nil {
chatLoopOpts.OnRetry = func(int, error, chatretry.ClassifiedError, time.Duration) {
opts.OnAdviceReset()
}
}
if err := chatloop.Run(ctx, chatLoopOpts); 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(persistedStep)
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: "advisor produced no text output",
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"))
}