agent: plumb image ContentBlocks from tool results through to backend messages

toolExecutor now returns (string, []ContentBlock, error).
extractToolResult parses image blocks from structured tool output.
Session.update populates ContentBlocks on tool messages.
file_read detects PNG/JPEG/GIF/WEBP and returns base64-encoded image blocks.
This commit is contained in:
ollie 2026-05-25 18:02:21 +02:00
parent adb686bc1d
commit 02df4b8dd5
13 changed files with 229 additions and 147 deletions

View File

@ -98,20 +98,20 @@ func BuildRuntime(cfg *config.Config, d tools.Dispatcher, cwd string, env []stri
}
}
exec := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
exec := func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) {
server, ok := serverOf[name]
if !ok {
return "", fmt.Errorf("unknown tool: %s", name)
return "", nil, fmt.Errorf("unknown tool: %s", name)
}
raw, err := d.Dispatch(ctx, server, name, args)
if err != nil {
return "", err
return "", nil, err
}
text, isErr := extractToolResult(raw)
text, blocks, isErr := extractToolResult(raw)
if isErr {
return "", fmt.Errorf("%s", text)
return "", nil, fmt.Errorf("%s", text)
}
return text, nil
return text, blocks, nil
}
var classify toolClassifier
@ -1209,22 +1209,34 @@ func toolInfosToBackend(infos []tools.ToolInfo) []backend.Tool {
return out
}
func extractToolResult(raw json.RawMessage) (text string, isError bool) {
func extractToolResult(raw json.RawMessage) (text string, contentBlocks []backend.ContentBlock, isError bool) {
var result struct {
IsError bool `json:"isError"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
MediaType string `json:"media_type"`
Data string `json:"data"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return string(raw), false
return string(raw), nil, false
}
var parts []string
for _, c := range result.Content {
if c.Type == "text" {
switch c.Type {
case "text":
parts = append(parts, c.Text)
case "image":
contentBlocks = append(contentBlocks, backend.ContentBlock{
Type: "image",
ImageSource: &backend.ImageSource{
Type: "base64",
MediaType: c.MediaType,
Data: c.Data,
},
})
}
}
return strings.Join(parts, "\n"), result.IsError
return strings.Join(parts, "\n"), contentBlocks, result.IsError
}

View File

@ -185,6 +185,7 @@ func TestSubmit_StateTransitions(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -222,16 +223,18 @@ func TestSubmit_ToolCallStateTransitions(t *testing.T) {
}
close(ch)
return ch, nil
}
return textStream("done"), nil
}
c := newCore(t, be, nil)
var stateAtExec string
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
stateAtExec = c.State()
return `{}`, nil
return `{}`, nil, nil
}
collectEvents(context.Background(), c, "run tool")
@ -253,6 +256,7 @@ func TestSubmit_PreTurnHookBlocks(t *testing.T) {
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
callCount++
return textStream("should not be called"), nil
}
c := newCore(t, be, Hooks{HookPreTurn: []string{"exit 2"}})
@ -305,6 +309,7 @@ func TestSubmit_PostTurnHookContinue(t *testing.T) {
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
callCount++
return textStream(fmt.Sprintf("response %d", callCount)), nil
}
c := newCore(t, be, Hooks{HookPostTurn: []string{hookScript}})
@ -371,6 +376,7 @@ func TestInterrupt_Running(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -413,6 +419,7 @@ func TestSubmit_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -450,6 +457,7 @@ func TestManualCompact(t *testing.T) {
callCount++
stateAtCompact = c.State()
return textStream("summary or answer"), nil
}
// Seed a session with more than hotTailSize+warmIndexSize messages so compact() doesn't short-circuit.
@ -501,8 +509,10 @@ func TestAutoCompact(t *testing.T) {
if callCount == 1 {
stateAtCompact = c.State()
return textStream("summary text for compaction"), nil
}
return textStream("answer"), nil
}
c.session = newSession("goal")
@ -729,6 +739,7 @@ func TestCommand_Compact_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -797,6 +808,7 @@ func TestCommand_Clear_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -878,6 +890,7 @@ func TestRun_StreamInterrupted(t *testing.T) {
ch := make(chan backend.StreamEvent)
close(ch) // close without sending Done=true
return ch, nil
}
c := newCore(t, be, nil)
evs := collectEvents(context.Background(), c, "hello")
@ -901,6 +914,7 @@ func TestRun_UnknownStopReason(t *testing.T) {
ch <- backend.StreamEvent{Done: true, StopReason: "max_completion_tokens", Content: "partial"}
close(ch)
return ch, nil
}
c := newCore(t, be, nil)
evs := collectEvents(context.Background(), c, "hello")
@ -931,8 +945,10 @@ func TestRun_ToolEmptyName(t *testing.T) {
}
close(ch)
return ch, nil
}
return textStream("done"), nil
}
c := newCore(t, be, nil)
collectEvents(context.Background(), c, "run empty tool")
@ -960,8 +976,10 @@ func TestRun_NoExec(t *testing.T) {
}
close(ch)
return ch, nil
}
return textStream("done"), nil
}
c := newCore(t, be, nil)
c.runtime.Exec = nil // no executor
@ -1019,6 +1037,7 @@ func TestCommand_Backend_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -1075,6 +1094,7 @@ func TestCommand_Model_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -1157,6 +1177,7 @@ func TestCommand_Agent_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -1485,6 +1506,7 @@ func TestRun_ToolCancelledBeforeExec(t *testing.T) {
}
close(ch)
return ch, nil
}
c := newCore(t, be, nil)
c.Submit(ctx, "hello")
@ -1530,13 +1552,14 @@ func TestRun_ExecCancelledWithInject(t *testing.T) {
}
close(ch)
return ch, nil
}
c := newCore(t, be, nil)
inject := "user interrupt"
c.pendingInject.Store(&inject)
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
cancel() // ctx cancelled during exec
return "", fmt.Errorf("exec cancelled")
return "", nil, fmt.Errorf("exec cancelled")
}
c.Submit(ctx, "hello")
if callCount != 1 {
@ -1560,8 +1583,10 @@ func TestAutoCompact_WithHookContext(t *testing.T) {
callCount++
if callCount == 1 {
return textStream("summary text for compaction"), nil
}
return textStream("answer"), nil
}
c.session = newSession("goal")
for range 15 {
@ -1629,6 +1654,7 @@ func TestCore_SetGenerationParams_WhileRunning(t *testing.T) {
be := defaultBE()
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return blockedStream(ctx, unblock), nil
}
c := newCore(t, be, nil)
@ -1856,6 +1882,7 @@ func TestManualCompact_EmptySummary(t *testing.T) {
ch <- backend.StreamEvent{Done: true, StopReason: "stop", Content: " "}
close(ch)
return ch, nil
}
c.session = newSession("goal")
for i := range 15 {
@ -1883,6 +1910,7 @@ func TestManualCompact_WithToolMessages(t *testing.T) {
c := newCore(t, be, nil)
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return textStream("summary"), nil
}
c.session = newSession("goal")
// Seed enough messages to pass the compaction threshold.
@ -1992,7 +2020,7 @@ func TestCommand_Backend_Error(t *testing.T) {
func TestExtractToolResult_Success(t *testing.T) {
raw := json.RawMessage(`{"isError":false,"content":[{"type":"text","text":"hello"}]}`)
text, isErr := extractToolResult(raw)
text, _, isErr := extractToolResult(raw)
if text != "hello" {
t.Errorf("text = %q; want %q", text, "hello")
}
@ -2003,7 +2031,7 @@ func TestExtractToolResult_Success(t *testing.T) {
func TestExtractToolResult_IsError(t *testing.T) {
raw := json.RawMessage(`{"isError":true,"content":[{"type":"text","text":"something failed"}]}`)
text, isErr := extractToolResult(raw)
text, _, isErr := extractToolResult(raw)
if text != "something failed" {
t.Errorf("text = %q; want %q", text, "something failed")
}
@ -2014,7 +2042,7 @@ func TestExtractToolResult_IsError(t *testing.T) {
func TestExtractToolResult_MultipleContentItems(t *testing.T) {
raw := json.RawMessage(`{"isError":false,"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`)
text, _ := extractToolResult(raw)
text, _, _ := extractToolResult(raw)
if text != "a\nb" {
t.Errorf("text = %q; want %q", text, "a\nb")
}
@ -2022,15 +2050,33 @@ func TestExtractToolResult_MultipleContentItems(t *testing.T) {
func TestExtractToolResult_NonTextItemsSkipped(t *testing.T) {
raw := json.RawMessage(`{"isError":false,"content":[{"type":"image","text":"ignored"},{"type":"text","text":"kept"}]}`)
text, _ := extractToolResult(raw)
text, _, _ := extractToolResult(raw)
if text != "kept" {
t.Errorf("text = %q; want %q", text, "kept")
}
}
func TestExtractToolResult_ImageBlock(t *testing.T) {
raw := json.RawMessage(`{"isError":false,"content":[{"type":"image","media_type":"image/png","data":"iVBOR"},{"type":"text","text":"screenshot above"}]}`)
text, blocks, isErr := extractToolResult(raw)
if text != "screenshot above" {
t.Errorf("text = %q; want %q", text, "screenshot above")
}
if isErr {
t.Error("isError should be false")
}
if len(blocks) != 1 {
t.Fatalf("blocks len = %d; want 1", len(blocks))
}
b := blocks[0]
if b.Type != "image" || b.ImageSource == nil || b.ImageSource.MediaType != "image/png" || b.ImageSource.Data != "iVBOR" {
t.Errorf("block = %+v", b)
}
}
func TestExtractToolResult_InvalidJSON(t *testing.T) {
raw := json.RawMessage(`not json`)
text, isErr := extractToolResult(raw)
text, _, isErr := extractToolResult(raw)
if text != "not json" {
t.Errorf("text = %q; want raw input on parse failure", text)
}
@ -2297,7 +2343,7 @@ func TestBuildRuntime_ExecUnknownTool(t *testing.T) {
d := tools.NewDispatcher()
env := BuildRuntime(nil, d, t.TempDir(), nil)
_, err := env.Exec(context.Background(), "no_such_tool", json.RawMessage(`{}`))
_, _, err := env.Exec(context.Background(), "no_such_tool", json.RawMessage(`{}`))
if err == nil || !strings.Contains(err.Error(), "unknown tool") {
t.Errorf("expected unknown tool error; got %v", err)
}
@ -2398,7 +2444,7 @@ func TestBuildRuntime_ExecDispatchSuccess(t *testing.T) {
},
}
env := BuildRuntime(nil, d, t.TempDir(), nil)
result, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
result, _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
if err != nil {
t.Fatal(err)
}
@ -2416,7 +2462,7 @@ func TestBuildRuntime_ExecDispatchError(t *testing.T) {
},
}
env := BuildRuntime(nil, d, t.TempDir(), nil)
_, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
_, _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
t.Errorf("err = %v", err)
}
@ -2431,7 +2477,7 @@ func TestBuildRuntime_ExecToolResultIsError(t *testing.T) {
},
}
env := BuildRuntime(nil, d, t.TempDir(), nil)
_, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
_, _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
if err == nil || !strings.Contains(err.Error(), "bad thing") {
t.Errorf("err = %v", err)
}

View File

@ -40,7 +40,7 @@ const stallThreshold = 5
// task state re-injection into the conversation.
const planReinjectInterval = 10
type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, error)
type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error)
type agentConfig struct {
Backend backend.Backend
@ -287,9 +287,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
}
}
var result string
var resultBlocks []backend.ContentBlock
var isErr bool
if cfg.Exec != nil {
out, err := cfg.Exec(ctx, tc.Name, tc.Arguments)
out, blocks, err := cfg.Exec(ctx, tc.Name, tc.Arguments)
if err != nil {
isErr = true
if ctx.Err() != nil {
@ -305,6 +306,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
result = fmt.Sprintf("error: %v", err)
} else {
result = out
resultBlocks = blocks
}
} else {
result = "error: no tool executor configured"
@ -337,7 +339,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
if !isErr && cfg.ClassifyTier != nil {
tier = cfg.ClassifyTier(tc.Name)
}
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: isErr, Tier: tier}, false
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
}
isParallelSafe := func(name string) bool {

View File

@ -21,9 +21,9 @@ func TestResultCache_HitSkipsExec(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(string) bool { return true }
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "contents of a.txt", nil
return "contents of a.txt", nil, nil
}
evs := collectEvents(context.Background(), c, "dup read")
@ -54,9 +54,9 @@ func TestResultCache_DifferentArgsMiss(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(string) bool { return true }
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "result for " + string(args), nil
return "result for " + string(args), nil, nil
}
collectEvents(context.Background(), c, "different args")
@ -78,9 +78,9 @@ func TestResultCache_SerialToolNotCached(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(name string) bool { return false } // all serial
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "ok", nil
return "ok", nil, nil
}
collectEvents(context.Background(), c, "serial tool")
@ -102,8 +102,10 @@ func multiTurnToolsStream(turns [][]backend.ToolCall) func(context.Context, []ba
ch <- backend.StreamEvent{ToolCalls: turns[i], Done: true, StopReason: "tool_calls"}
close(ch)
return ch, nil
}
return textStream("done"), nil
}
}
@ -121,12 +123,12 @@ func TestResultCache_ErrorNotCached(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(string) bool { return true }
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
n := atomic.AddInt32(&execCount, 1)
if n == 1 {
return "", &mockErr{"transient failure"}
return "", nil, &mockErr{"transient failure"}
}
return "ok now", nil
return "ok now", nil, nil
}
evs := collectEvents(context.Background(), c, "error then ok")
@ -156,9 +158,9 @@ func TestResultCache_IdenticalParallelReads(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(string) bool { return true } // both parallel-safe → same batch
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "file contents", nil
return "file contents", nil, nil
}
evs := collectEvents(context.Background(), c, "identical parallel")

View File

@ -24,6 +24,7 @@ func alwaysFailStream() func(context.Context, []backend.Message, []backend.Tool,
}
close(ch)
return ch, nil
}
}
@ -32,8 +33,8 @@ func TestConsecutiveErrors_HardLimit(t *testing.T) {
be.respond = alwaysFailStream()
c := newCore(t, be, nil)
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return "", fmt.Errorf("always fails")
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return "", nil, fmt.Errorf("always fails")
}
evs := collectEvents(context.Background(), c, "do something")
@ -63,6 +64,7 @@ func TestConsecutiveErrors_SoftLimitNudge(t *testing.T) {
for _, m := range msgs {
if m.Role == "user" && strings.Contains(m.Content, "your last several tool calls all failed") {
return textStream("giving up"), nil
}
}
// Nudge not found — keep going (will hit hard limit if broken).
@ -75,6 +77,7 @@ func TestConsecutiveErrors_SoftLimitNudge(t *testing.T) {
}
close(ch)
return ch, nil
}
ch := make(chan backend.StreamEvent, 1)
ch <- backend.StreamEvent{
@ -85,11 +88,12 @@ func TestConsecutiveErrors_SoftLimitNudge(t *testing.T) {
}
close(ch)
return ch, nil
}
c := newCore(t, be, nil)
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return "", fmt.Errorf("always fails")
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return "", nil, fmt.Errorf("always fails")
}
evs := collectEvents(context.Background(), c, "do something")
@ -125,9 +129,11 @@ func TestConsecutiveErrors_ResetOnSuccess(t *testing.T) {
}
close(ch)
return ch, nil
}
if n >= 11 {
return textStream("done"), nil
}
ch := make(chan backend.StreamEvent, 1)
ch <- backend.StreamEvent{
@ -138,14 +144,15 @@ func TestConsecutiveErrors_ResetOnSuccess(t *testing.T) {
}
close(ch)
return ch, nil
}
c := newCore(t, be, nil)
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
if name == "good_tool" {
return "ok", nil
return "ok", nil, nil
}
return "", fmt.Errorf("fails")
return "", nil, fmt.Errorf("fails")
}
evs := collectEvents(context.Background(), c, "do something")

View File

@ -21,6 +21,7 @@ func sequentialStream(responses []mockResponse) func(context.Context, []backend.
i := int(atomic.AddInt32(&n, 1)) - 1
if i >= len(responses) {
return textStream("done"), nil
}
r := responses[i]
ch := make(chan backend.StreamEvent, 1)
@ -32,6 +33,7 @@ func sequentialStream(responses []mockResponse) func(context.Context, []backend.
}
close(ch)
return ch, nil
}
}

View File

@ -25,9 +25,9 @@ func TestMaxStepsZeroUnlimited(t *testing.T) {
Backend: mb,
Tools: []backend.Tool{{Name: "tool"}},
MaxSteps: 0,
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
steps++
return "ok", nil
return "ok", nil, nil
},
}
if err := run(context.Background(), cfg, newState()); err != nil {
@ -60,8 +60,8 @@ func TestMaxStepsSoftNudge(t *testing.T) {
Backend: mb,
Tools: []backend.Tool{{Name: "tool"}},
MaxSteps: 1,
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return "ok", nil
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return "ok", nil, nil
},
Output: func(ev Event) {
if ev.Role == "maxsteps" {
@ -117,9 +117,9 @@ func TestMaxStepsExactBoundary(t *testing.T) {
Backend: mb,
Tools: []backend.Tool{{Name: "tool"}},
MaxSteps: limit,
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
toolRounds++
return "ok", nil
return "ok", nil, nil
},
}
if err := run(context.Background(), cfg, newState()); err != nil {
@ -149,8 +149,8 @@ func TestTaskStateReinjection(t *testing.T) {
cfg := agentConfig{
Backend: mb,
Tools: []backend.Tool{{Name: "tool"}},
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return "ok", nil
Exec: func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return "ok", nil, nil
},
}

View File

@ -22,8 +22,10 @@ func toolsStream(calls []backend.ToolCall) func(context.Context, []backend.Messa
ch <- backend.StreamEvent{ToolCalls: calls, Done: true, StopReason: "tool_calls"}
close(ch)
return ch, nil
}
return textStream("done"), nil
}
}
@ -51,13 +53,13 @@ func TestParallel_ConcurrentExecution(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(string) bool { return true }
c.runtime.Exec = func(ctx context.Context, name string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(ctx context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
started <- struct{}{}
select {
case <-gate:
return name + "-result", nil
return name + "-result", nil, nil
case <-ctx.Done():
return "", ctx.Err()
return "", nil, ctx.Err()
}
}
@ -93,11 +95,11 @@ func TestParallel_SerialToolBreaksBatch(t *testing.T) {
c.runtime.ClassifyTool = func(name string) bool {
return name == "read_a" || name == "read_c"
}
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
mu.Lock()
order = append(order, name)
mu.Unlock()
return name + "-result", nil
return name + "-result", nil, nil
}
collectEvents(context.Background(), c, "mixed tools")
@ -134,10 +136,10 @@ func TestParallel_CancellationFillsRemaining(t *testing.T) {
c.runtime.ClassifyTool = func(name string) bool {
return name == "tool_a" || name == "tool_b"
}
c.runtime.Exec = func(execCtx context.Context, name string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(execCtx context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
cancel() // cancel on first execution; propagates to all
<-execCtx.Done()
return "", execCtx.Err()
return "", nil, execCtx.Err()
}
evs := collectEvents(ctx, c, "cancel mid-batch")
@ -175,11 +177,11 @@ func TestParallel_NilClassifyToolIsSerial(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = nil // no classifier → all serial
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
mu.Lock()
order = append(order, name)
mu.Unlock()
return name + "-result", nil
return name + "-result", nil, nil
}
evs := collectEvents(context.Background(), c, "serial fallback")

View File

@ -19,9 +19,11 @@ func TestTextToolCall_ParsedAndExecuted(t *testing.T) {
if m.Role == "tool" {
// Second call after tool result: respond normally
return textStream("done"), nil
}
}
return textStream(`call_tool: {"calls":[{"tool":"file_read","args":["/tmp/test.go"]}]}`), nil
}
var execCalled bool
@ -30,12 +32,12 @@ func TestTextToolCall_ParsedAndExecuted(t *testing.T) {
{Name: "call_tool", Description: "run tools"},
{Name: "execute_code", Description: "run code"},
}
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) {
execCalled = true
if name != "call_tool" {
t.Errorf("expected tool name 'call_tool', got %q", name)
}
return "file contents here", nil
return "file contents here", nil, nil
}
evs := collectEvents(context.Background(), c, "read a file")
@ -56,9 +58,11 @@ func TestTextToolCall_RelaxedJSON(t *testing.T) {
for _, m := range msgs {
if m.Role == "tool" {
return textStream("done"), nil
}
}
return textStream(`execute_code: steps=[{code: "date"}]`), nil
}
var execCalled bool
@ -67,12 +71,12 @@ func TestTextToolCall_RelaxedJSON(t *testing.T) {
{Name: "execute_code", Description: "run code"},
{Name: "call_tool", Description: "run tools"},
}
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) {
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) {
execCalled = true
if name != "execute_code" {
t.Errorf("expected tool name 'execute_code', got %q", name)
}
return "Sat May 23 11:00:00 UTC 2026", nil
return "Sat May 23 11:00:00 UTC 2026", nil, nil
}
collectEvents(context.Background(), c, "what time is it")
@ -88,6 +92,7 @@ func TestTextToolCall_NoFalsePositive(t *testing.T) {
be := defaultBE()
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return textStream("Here is how execute_code works in general."), nil
}
c := newCore(t, be, nil)
@ -111,6 +116,7 @@ func TestTextToolCall_NoToolsConfigured(t *testing.T) {
be := defaultBE()
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return textStream(`execute_code: steps=[{code: "print('hi')"}]`), nil
}
c := newCore(t, be, nil)

View File

@ -23,8 +23,8 @@ func TestTruncation_LargeResultTruncated(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = nil
c.runtime.ToolResultMaxBytes = maxBytes
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return large, nil
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return large, nil, nil
}
evs := collectEvents(context.Background(), c, "big result")
@ -55,8 +55,8 @@ func TestTruncation_SmallResultNotTruncated(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ToolResultMaxBytes = 8192
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return "short result", nil
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return "short result", nil, nil
}
evs := collectEvents(context.Background(), c, "small result")
@ -82,8 +82,8 @@ func TestTruncation_ZeroDisabled(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ToolResultMaxBytes = 0 // disabled
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return large, nil
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return large, nil, nil
}
evs := collectEvents(context.Background(), c, "no limit")
@ -110,8 +110,8 @@ func TestTruncation_ErrorNotTruncated(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ToolResultMaxBytes = maxBytes
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return "", &mockErr{longErr}
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return "", nil, &mockErr{longErr}
}
evs := collectEvents(context.Background(), c, "error result")
@ -140,8 +140,8 @@ func TestTruncation_CachedResultAlreadyTruncated(t *testing.T) {
c := newCore(t, be, nil)
c.runtime.ClassifyTool = func(string) bool { return true } // read-safe → cacheable
c.runtime.ToolResultMaxBytes = maxBytes
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) {
return large, nil
c.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
return large, nil, nil
}
evs := collectEvents(context.Background(), c, "cached truncated")

View File

@ -24,6 +24,7 @@ func errThenOKStream(err error) func(context.Context, []backend.Message, []backe
return nil, err
}
return textStream("recovered"), nil
}
}

View File

@ -220,6 +220,7 @@ func (s *Session) update(assistant backend.Message, results []toolResult) {
s.messages = append(s.messages, backend.Message{
Role: "tool",
Content: content,
ContentBlocks: r.ContentBlocks,
ToolCallID: r.ToolCallID,
})
}

View File

@ -16,6 +16,7 @@ type toolResult struct {
ToolCallID string // may be empty (Ollama does not always set this)
Name string
Content string
ContentBlocks []backend.ContentBlock // image blocks extracted from tool output
IsError bool
Tier ResultTier // default TierHot; tools may self-classify
}