Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"maps"
"slices"
"strings"
"sync"

"charm.land/fantasy/jsonrepair"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

Expand Down
161 changes: 161 additions & 0 deletions agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ func TestRetryWithExponentialBackoff_ConnectionErrors(t *testing.T) {
}
return 42, nil
})

if err != nil {
t.Fatalf("expected no error, got %v", err)
}
Expand Down
Loading