Skip to content

Commit b01dba2

Browse files
committed
Preserve structured tool errors during compaction
1 parent 20c777d commit b01dba2

5 files changed

Lines changed: 51 additions & 1 deletion

File tree

‎internal/agent/compaction_projection.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ func projectCompactionInput(messages []zeroruntime.Message) []zeroruntime.Messag
6464
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
6565
}
6666
case zeroruntime.MessageRoleTool:
67-
if !isLikelyToolError(message.Content) {
67+
// New tool-result messages carry the execution status directly, as the
68+
// source ToolResult does. Keep the text check for older session history
69+
// created before Message exposed IsError.
70+
if !message.IsError && !isLikelyToolError(message.Content) {
6871
continue
6972
}
7073
name := toolNameForResult(messages, index)

‎internal/agent/compaction_projection_test.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
3535
}
3636
}
3737

38+
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
39+
messages := []zeroruntime.Message{
40+
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
41+
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
42+
}
43+
44+
projected := projectCompactionInput(messages)
45+
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
46+
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
47+
}
48+
}
49+
3850
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
3951
calls := make([]zeroruntime.ToolCall, 12)
4052
for index := range calls {

‎internal/agent/loop.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
722722
Role: zeroruntime.MessageRoleTool,
723723
Content: toolResult.Output,
724724
ToolCallID: toolResult.ToolCallID,
725+
IsError: toolResult.Status == tools.StatusError,
725726
})
726727
// Images ride a following USER message rather than the tool result
727728
// above. Every provider drops images on a tool-role message —
@@ -3285,6 +3286,7 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
32853286
Role: zeroruntime.MessageRoleTool,
32863287
Content: abortedToolResultNotice,
32873288
ToolCallID: call.ID,
3289+
IsError: true,
32883290
})
32893291
}
32903292
return messages

‎internal/agent/loop_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3592,6 +3592,11 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
35923592
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
35933593
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
35943594
}
3595+
for _, message := range result.Messages {
3596+
if message.ToolCallID == "flaky-2" && !message.IsError {
3597+
t.Fatalf("aborted placeholder must carry error status: %#v", message)
3598+
}
3599+
}
35953600

35963601
// Every tool_use in the final assistant message must have a matching result.
35973602
for _, message := range result.Messages {
@@ -3606,6 +3611,33 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
36063611
}
36073612
}
36083613

3614+
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
3615+
registry := tools.NewRegistry()
3616+
registry.Register(alwaysFailingTool{})
3617+
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
3618+
{
3619+
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
3620+
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
3621+
{Type: zeroruntime.StreamEventDone},
3622+
},
3623+
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
3624+
}}
3625+
3626+
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
3627+
if err != nil {
3628+
t.Fatal(err)
3629+
}
3630+
for _, message := range result.Messages {
3631+
if message.ToolCallID == "failed-call" {
3632+
if !message.IsError {
3633+
t.Fatalf("failed tool result lost its structured status: %#v", message)
3634+
}
3635+
return
3636+
}
3637+
}
3638+
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
3639+
}
3640+
36093641
type secretEmittingTool struct{ output string }
36103642

36113643
func (t secretEmittingTool) Name() string { return "leak" }

‎internal/zeroruntime/types.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ type Message struct {
9797
Content string
9898
ToolCalls []ToolCall
9999
ToolCallID string
100+
IsError bool // tool-result status; ignored for non-tool messages
100101
Images []ImageBlock // optional; nil for text-only messages
101102
Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay
102103
}

0 commit comments

Comments
 (0)