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

83 lines
2.9 KiB
Go

package chatadvisor
import (
"context"
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"charm.land/fantasy"
)
// ToolName is the identifier the advisor tool registers under. The parent
// agent's exclusive-tool policy and the advisor-guidance block both reference
// this name, so keeping them synchronized requires a single source of truth.
const ToolName = "advisor"
// advisorQuestionMaxRunes caps the parent agent's question at a length
// that leaves room in the advisor prompt for system preamble and recent
// conversation context.
const advisorQuestionMaxRunes = 2000
// ToolOptions configures the built-in advisor tool.
type ToolOptions struct {
Runtime *Runtime
GetConversationSnapshot func() []fantasy.Message
PublishAdviceDelta func(toolCallID string, delta string)
PublishAdviceReset func(toolCallID string)
}
// Tool returns a fantasy.AgentTool that asks a nested model for concise
// strategic guidance. The nested advisor sees recent conversation
// context, runs without tools, and is limited to a single model step.
func Tool(opts ToolOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
ToolName,
"Ask a separate advisor pass for strategic guidance about planning, architecture, tradeoffs, or debugging strategy. Provide a brief question. The advisor sees recent conversation context, runs without tools for a single step, and responds to the parent agent rather than the end user.",
func(ctx context.Context, args AdvisorArgs, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
if opts.Runtime == nil {
return fantasy.NewTextErrorResponse("advisor runtime is not configured"), nil
}
if opts.GetConversationSnapshot == nil {
return fantasy.NewTextErrorResponse("conversation snapshot provider is not configured"), nil
}
question := strings.TrimSpace(args.Question)
if question == "" {
return fantasy.NewTextErrorResponse("question is required"), nil
}
if utf8.RuneCountInString(question) > advisorQuestionMaxRunes {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("question must be %d runes or fewer", advisorQuestionMaxRunes),
), nil
}
var runOpts *RunAdvisorOptions
if call.ID != "" && (opts.PublishAdviceDelta != nil || opts.PublishAdviceReset != nil) {
runOpts = &RunAdvisorOptions{}
if opts.PublishAdviceDelta != nil {
runOpts.OnAdviceDelta = func(delta string) {
opts.PublishAdviceDelta(call.ID, delta)
}
}
if opts.PublishAdviceReset != nil {
runOpts.OnAdviceReset = func() {
opts.PublishAdviceReset(call.ID)
}
}
}
result, err := opts.Runtime.RunAdvisor(ctx, question, opts.GetConversationSnapshot(), runOpts)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
data, err := json.Marshal(result)
if err != nil {
return fantasy.NewTextResponse("{}"), nil
}
return fantasy.NewTextResponse(string(data)), nil
},
)
}