From c07a496e7cb2ceeb547139f54a9fa2095205b51e Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 15 Jun 2026 15:58:50 -0400 Subject: [PATCH] fix(agent): use last text step as AgentResult.Response When the agent's final step was tool-only (no text content), AgentResult.Response copied that empty step, causing callers to see no output even though earlier steps produced text. finalResponse now walks backwards to find the most recent step with non-blank text. Also clarifies that Text() returns the first text block. --- agent.go | 40 ++++++++++++- agent_test.go | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++ model.go | 2 +- retry_test.go | 1 - 4 files changed, 199 insertions(+), 5 deletions(-) diff --git a/agent.go b/agent.go index 2ce313577..c426db166 100644 --- a/agent.go +++ b/agent.go @@ -9,6 +9,7 @@ import ( "fmt" "maps" "slices" + "strings" "sync" "charm.land/fantasy/jsonrepair" @@ -300,11 +301,44 @@ type AgentStreamCall struct { // AgentResult represents the result of an agent execution. type AgentResult struct { Steps []StepResult - // Final response + // Final response. When the last step is tool-only (no text content), + // this is the response from the most recent step that contained text, + // so callers always see meaningful output without walking Steps manually. Response Response TotalUsage Usage } +// finalResponse picks the best Response from a slice of steps. It walks +// backwards to find the most recent step with non-blank text content. If no +// step has text content (e.g. all steps were tool calls), the last step's +// response is returned as-is. +func finalResponse(steps []StepResult) Response { + for i := len(steps) - 1; i >= 0; i-- { + if hasNonBlankText(steps[i].Content) { + return steps[i].Response + } + } + if len(steps) > 0 { + return steps[len(steps)-1].Response + } + return Response{} +} + +// hasNonBlankText reports whether content contains at least one text block +// with non-whitespace characters. +func hasNonBlankText(content ResponseContent) bool { + for _, c := range content { + if c.GetType() == ContentTypeText { + if tc, ok := AsContentType[TextContent](c); ok { + if strings.TrimSpace(tc.Text) != "" { + return true + } + } + } + } + return false +} + // Agent represents an AI agent that can generate responses and stream responses. type Agent interface { Generate(context.Context, AgentCall) (*AgentResult, error) @@ -555,7 +589,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err agentResult := &AgentResult{ Steps: steps, - Response: steps[len(steps)-1].Response, + Response: finalResponse(steps), TotalUsage: totalUsage, } return agentResult, nil @@ -954,7 +988,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult, // Finish agent stream agentResult := &AgentResult{ Steps: steps, - Response: steps[len(steps)-1].Response, + Response: finalResponse(steps), TotalUsage: totalUsage, } diff --git a/agent_test.go b/agent_test.go index da10747cd..6b626754a 100644 --- a/agent_test.go +++ b/agent_test.go @@ -850,6 +850,167 @@ func TestResponseContent_Getters_MultipleItems(t *testing.T) { require.Equal(t, "image/png", files[1].MediaType) } +func TestHasNonBlankText(t *testing.T) { + t.Parallel() + + t.Run("single text block", func(t *testing.T) { + content := ResponseContent{TextContent{Text: "hello"}} + require.True(t, hasNonBlankText(content)) + }) + + t.Run("text among tool calls", func(t *testing.T) { + content := ResponseContent{ + TextContent{Text: "preamble"}, + ToolCallContent{ToolCallID: "c1", ToolName: "t", Input: "{}"}, + TextContent{Text: "final answer"}, + } + require.True(t, hasNonBlankText(content)) + }) + + t.Run("whitespace-only is blank", func(t *testing.T) { + content := ResponseContent{ + TextContent{Text: " "}, + } + require.False(t, hasNonBlankText(content)) + }) + + t.Run("mixed whitespace and real text", func(t *testing.T) { + content := ResponseContent{ + TextContent{Text: " "}, + TextContent{Text: "real"}, + } + require.True(t, hasNonBlankText(content)) + }) + + t.Run("empty content", func(t *testing.T) { + require.False(t, hasNonBlankText(ResponseContent{})) + }) + + t.Run("no text blocks", func(t *testing.T) { + content := ResponseContent{ + ToolCallContent{ToolCallID: "c1", ToolName: "t", Input: "{}"}, + } + require.False(t, hasNonBlankText(content)) + }) +} + +func TestFinalResponse(t *testing.T) { + t.Parallel() + + t.Run("last step has text", func(t *testing.T) { + steps := []StepResult{ + {Response: Response{Content: ResponseContent{TextContent{Text: "earlier"}}}}, + {Response: Response{Content: ResponseContent{TextContent{Text: "final"}}}}, + } + resp := finalResponse(steps) + require.Equal(t, "final", resp.Content.Text()) + }) + + t.Run("last step tool-only falls back to earlier text", func(t *testing.T) { + steps := []StepResult{ + {Response: Response{Content: ResponseContent{TextContent{Text: "has text"}}}}, + {Response: Response{Content: ResponseContent{ + ToolCallContent{ToolCallID: "c1", ToolName: "t", Input: "{}"}, + }}}, + } + resp := finalResponse(steps) + require.Equal(t, "has text", resp.Content.Text()) + }) + + t.Run("all steps tool-only returns last", func(t *testing.T) { + steps := []StepResult{ + {Response: Response{Content: ResponseContent{ + ToolCallContent{ToolCallID: "c1", ToolName: "t1", Input: "{}"}, + }}}, + {Response: Response{Content: ResponseContent{ + ToolCallContent{ToolCallID: "c2", ToolName: "t2", Input: "{}"}, + }}}, + } + resp := finalResponse(steps) + toolCalls := resp.Content.ToolCalls() + require.Len(t, toolCalls, 1) + require.Equal(t, "c2", toolCalls[0].ToolCallID) + }) + + t.Run("empty steps", func(t *testing.T) { + resp := finalResponse(nil) + require.Equal(t, "", resp.Content.Text()) + }) + + t.Run("single step with text", func(t *testing.T) { + steps := []StepResult{ + {Response: Response{Content: ResponseContent{TextContent{Text: "only"}}}}, + } + resp := finalResponse(steps) + require.Equal(t, "only", resp.Content.Text()) + }) + + t.Run("skips whitespace-only steps", func(t *testing.T) { + steps := []StepResult{ + {Response: Response{Content: ResponseContent{TextContent{Text: "real"}}}}, + {Response: Response{Content: ResponseContent{TextContent{Text: " "}}}}, + } + resp := finalResponse(steps) + require.Equal(t, "real", resp.Content.Text()) + }) +} + +func TestAgent_Generate_ToolOnlyFinalStep_PreservesEarlierText(t *testing.T) { + t.Parallel() + + type TestInput struct { + Value string `json:"value" description:"Test value"` + } + + tool := NewAgentTool( + "mytool", + "A test tool", + func(ctx context.Context, input TestInput, _ ToolCall) (ToolResponse, error) { + return ToolResponse{Content: "done", IsError: false}, nil + }, + ) + + callCount := 0 + model := &mockLanguageModel{ + generateFunc: func(ctx context.Context, call Call) (*Response, error) { + callCount++ + switch callCount { + case 1: + // First step: text + tool call (model explains then acts). + return &Response{ + Content: []Content{ + TextContent{Text: "Let me check that."}, + ToolCallContent{ + ToolCallID: "call-1", + ToolName: "mytool", + Input: `{"value":"x"}`, + }, + }, + FinishReason: FinishReasonToolCalls, + }, nil + case 2: + // Second step: tool result only, no text. Model stops here. + return &Response{ + Content: []Content{}, + FinishReason: FinishReasonStop, + }, nil + default: + t.Fatalf("unexpected call count: %d", callCount) + return nil, nil + } + }, + } + + agent := NewAgent(model, WithTools(tool)) + result, err := agent.Generate(context.Background(), AgentCall{Prompt: "test"}) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Steps, 2) + + // Response should carry text from step 1 even though step 2 is empty. + require.Equal(t, "Let me check that.", result.Response.Content.Text()) +} + func TestStopConditions(t *testing.T) { t.Parallel() diff --git a/model.go b/model.go index 16980da10..2811a4d04 100644 --- a/model.go +++ b/model.go @@ -31,7 +31,7 @@ func (u Usage) String() string { // ResponseContent represents the content of a model response. type ResponseContent []Content -// Text returns the text content of the response. +// Text returns the first text content of the response. func (r ResponseContent) Text() string { for _, c := range r { if c.GetType() == ContentTypeText { diff --git a/retry_test.go b/retry_test.go index 81dce507c..e6a26da8a 100644 --- a/retry_test.go +++ b/retry_test.go @@ -87,7 +87,6 @@ func TestRetryWithExponentialBackoff_ConnectionErrors(t *testing.T) { } return 42, nil }) - if err != nil { t.Fatalf("expected no error, got %v", err) }