Files
coder/coderd/x/chatd/chatadvisor/runner.go
T
Cian Johnston b21e0717d5 feat: remove chat chain mode (#26980)
Removes OpenAI Responses "chain mode" from chatd. Closes CODAGT-445.

- Deletes `chatopenai/responses.go` (chain detection, activation, prompt filtering, response ID extraction) and its tests.
- Deletes the `ChainBroken` classification in `chaterror` and the chatloop retry bookkeeping that disabled chain mode mid-generation.
- Drops the `chain_broken` label from the `coderd_chatd_stream_retries_total` metric.
- Stops reading and writing `chat_messages.provider_response_id`
- Deletes the dead `ClearChatMessageProviderResponseIDsByChatID` query. Dropping the column is a follow-up migration.
- Deletes three chatloop hooks no caller sets (`ReloadMessages`, `DisableChainMode`, `PrepareMessages`), the dead `const AgentChatContextSentinelPath`, and stale chain-mode comments.

🤖 Generated by Coder Agents on behalf of @johnstcn.
2026-07-06 11:57:12 +01:00

124 lines
3.6 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
}
// 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: "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"))
}