Anchor Assistant final answers after tools

This commit is contained in:
rcourtman
2026-06-07 01:09:51 +01:00
parent c25629ddf0
commit 132b466f39
3 changed files with 147 additions and 8 deletions
@@ -743,6 +743,22 @@ runtime cost control, and shared AI transport surfaces.
arrives; it must not flash as run-on prose such as
`I'llcheckthedevicenodes...` while the actual governed tool row is still
being assembled.
A pre-tool assistant preamble such as "I'll check that" does not satisfy the
final-answer contract after Pulse executes tools. The final-response guard
must require non-empty assistant text after the latest user/tool-result
anchor; otherwise it must make the bounded no-tools summary call or emit the
deterministic fallback summary. This adapts the referenced OpenCode source
at fetched `origin/dev` commit
`06d7840d1d42c9815d2d2e45e7fa4090ca4e3577`: `packages/opencode/src/session/llm/ai-sdk.ts`
maps tool input/call/result stream events into typed tool events (lines
190-246), `packages/opencode/src/session/message-v2.ts` serializes completed
tool results and reasoning as ordered provider message parts (lines
340-390), and `packages/ui/src/components/message-part.tsx` only renders
non-empty typed text/reasoning/tool parts through `renderable` and
`AssistantParts` (lines 607-728). Pulse adapts that ordered-part invariant
inside `internal/ai/chat/agentic_final.go` because Pulse's transcript is
persisted as messages plus tool-result anchors rather than OpenCode's
per-part store.
Streamed provider startup and mid-stream progress must be bounded by the
configured Assistant request timeout, the OpenAI-compatible SSE
response-header guard, and the per-chunk SSE body-read guard adapted from
+106
View File
@@ -187,6 +187,112 @@ func TestEnsureFinalTextResponse(t *testing.T) {
}
}
func TestEnsureFinalTextResponseRequiresAssistantTextAfterLatestToolResult(t *testing.T) {
provider := &stubStreamingProvider{}
loop := &AgenticLoop{provider: provider, baseSystemPrompt: "prompt"}
var emitted strings.Builder
result := loop.ensureFinalTextResponse(
context.Background(),
"session-pre-tool-preamble",
[]Message{
{Role: "user", Content: "how many devices are in this?"},
{
Role: "assistant",
Content: "I'll check the device nodes.",
ToolCalls: []ToolCall{
{ID: "call-1", Name: "pulse_read", Input: map[string]interface{}{"command": "ls /dev | wc -l"}},
},
},
{
Role: "user",
ToolResult: &ToolResult{ToolUseID: "pulse_read_0", Content: "4358", IsError: false},
},
{Role: "assistant", Content: ""},
},
[]providers.Message{
{Role: "user", Content: "how many devices are in this?"},
{
Role: "assistant",
Content: "I'll check the device nodes.",
ToolCalls: []providers.ToolCall{
{ID: "call-1", Name: "pulse_read", Input: map[string]interface{}{"command": "ls /dev | wc -l"}},
},
},
{Role: "tool", ToolResult: &providers.ToolResult{ToolUseID: "call-1", Content: "4358"}},
{Role: "assistant", Content: ""},
},
func(event StreamEvent) {
if event.Type != "content" {
return
}
var data ContentData
if err := json.Unmarshal(event.Data, &data); err == nil {
emitted.WriteString(data.Text)
}
},
)
if len(result) != 5 {
t.Fatalf("expected final summary to be appended after tool result, got %d messages", len(result))
}
if result[len(result)-1].Content != "summary" {
t.Fatalf("expected provider summary to be appended, got %q", result[len(result)-1].Content)
}
if emitted.String() != "summary" {
t.Fatalf("expected summary to be streamed, got %q", emitted.String())
}
}
func TestHasFinalAssistantText(t *testing.T) {
tests := []struct {
name string
messages []Message
want bool
}{
{
name: "assistant text without tool results is final",
messages: []Message{{Role: "assistant", Content: "done"}},
want: true,
},
{
name: "assistant text before latest user prompt is not final",
messages: []Message{
{Role: "assistant", Content: "previous answer"},
{Role: "user", Content: "new question"},
},
want: false,
},
{
name: "pre tool text before latest tool result is not final",
messages: []Message{
{Role: "user", Content: "check it"},
{Role: "assistant", Content: "I'll check", ToolCalls: []ToolCall{{Name: "pulse_query"}}},
{Role: "user", ToolResult: &ToolResult{ToolUseID: "pulse_query_0", Content: "ok"}},
},
want: false,
},
{
name: "assistant text after latest tool result is final",
messages: []Message{
{Role: "user", Content: "check it"},
{Role: "assistant", Content: "I'll check", ToolCalls: []ToolCall{{Name: "pulse_query"}}},
{Role: "user", ToolResult: &ToolResult{ToolUseID: "pulse_query_0", Content: "ok"}},
{Role: "assistant", Content: "The result is ok."},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasFinalAssistantText(tt.messages); got != tt.want {
t.Fatalf("hasFinalAssistantText() = %v, want %v", got, tt.want)
}
})
}
}
func TestEnsureFinalTextResponseAppliesRequestSanitizer(t *testing.T) {
provider := &stubStreamingProvider{}
loop := &AgenticLoop{provider: provider, baseSystemPrompt: "raw-host"}
+25 -8
View File
@@ -13,9 +13,9 @@ import (
"github.com/rs/zerolog/log"
)
// ensureFinalTextResponse checks if the result messages contain any assistant text.
// If not, it makes one last LLM call without tools so the model can summarize
// its findings.
// ensureFinalTextResponse checks if the result messages contain assistant text
// after the latest user/tool-result anchor. If not, it makes one last LLM call
// without tools so the model can summarize its findings.
// This prevents the loop from exiting silently after making tool calls without answering.
//
// cost-recording-exempt: any tokens this final summary turn consumes
@@ -30,11 +30,8 @@ func (a *AgenticLoop) ensureFinalTextResponse(
providerMessages []providers.Message,
callback StreamCallback,
) []Message {
// Check if any assistant message has text content
for i := len(resultMessages) - 1; i >= 0; i-- {
if resultMessages[i].Role == "assistant" && strings.TrimSpace(resultMessages[i].Content) != "" {
return resultMessages // Already has text — nothing to do
}
if hasFinalAssistantText(resultMessages) {
return resultMessages
}
// No text content from the model. Make a final text-only call.
@@ -147,6 +144,26 @@ func (a *AgenticLoop) ensureFinalTextResponse(
return resultMessages
}
func hasFinalAssistantText(resultMessages []Message) bool {
anchor := -1
for i, msg := range resultMessages {
if msg.ToolResult != nil {
anchor = i
continue
}
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
anchor = i
}
}
for i := len(resultMessages) - 1; i > anchor; i-- {
if resultMessages[i].Role == "assistant" && strings.TrimSpace(resultMessages[i].Content) != "" {
return true
}
}
return false
}
func buildAutomaticFallbackSummary(resultMessages []Message) string {
successCount := 0
errorCount := 0