mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Anchor Assistant final answers after tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user