Files
coder/coderd/x/chatd/chatadvisor/runner.go
T
Thomas Kosiewski e56381eb61 feat: stream advisor tool output (#25032)
Stream advisor output into the advisor tool card while the nested
advisor call is still running.

This keeps the advisor implementation intentionally advisor-specific:
the parent model still receives the same final structured tool result,
while the frontend receives transient `tool-result.result_delta` parts
to render partial advisor text in the expanded card. The final persisted
chat history remains unchanged.

Refs CODAGT-322.

Generated by Coder Agents.

<details>
<summary>Implementation plan</summary>

- Publish advisor text deltas from the nested `chatloop.Run` via
`RunAdvisorOptions.OnAdviceDelta`.
- Forward those deltas through `chatadvisor.Tool` with the parent
advisor tool call ID.
- Emit transient `ChatMessagePartTypeToolResult` websocket parts with
`ResultDelta` from `chatd`.
- Add `result_delta` to the generated tool-result TypeScript variant.
- Accumulate tool result deltas in frontend stream state and keep the
tool running until the final result arrives.
- Render streamed advisor advice in the existing advisor card using
streaming markdown mode, while retaining the updated advisor UI.

</details>
2026-05-11 20:18:49 +02:00

125 lines
3.6 KiB
Go

package chatadvisor
import (
"context"
"strings"
"time"
"charm.land/fantasy"
"golang.org/x/xerrors"
"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.
if strings.TrimSpace(question) == "" {
return AdvisorResult{}, xerrors.New("advisor question is required")
}
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"))
}