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) }