diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 34e5a44..8f4162d 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -801,7 +801,7 @@ func (s *agent) executeTurn(ctx context.Context, input string, handler EventHand snapSession := s.session var snapMessages []backend.Message if s.session != nil { - snapMessages = slices.Clone(s.session.messages) + snapMessages = cloneMessages(s.session.messages) } if s.session == nil { @@ -990,12 +990,10 @@ func (s *agent) executeTurn(ctx context.Context, input string, handler EventHand s.setState("idle") if err != nil { - // Both interrupts and real errors roll back to the pre-turn snapshot - // so that cancelled/partial tool results don't poison the context. - if snapSession == nil { - s.session = nil - } else { - s.session.messages = snapMessages + // Keep completed work — only remove cancelled tool results. + // Error results are valuable feedback for the agent. + if s.session != nil { + s.session.removeCancelledToolResults() } if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) { s.auditLog.Debug("turn: interrupted session=%s", s.sessionID) diff --git a/pkg/agent/session.go b/pkg/agent/session.go index 9424a1e..e448c1d 100644 --- a/pkg/agent/session.go +++ b/pkg/agent/session.go @@ -146,6 +146,25 @@ func (s *Session) update(assistant backend.Message, results []toolResult) { } } +// removeCancelledToolResults filters out tool results that were cancelled due +// to interrupt, keeping completed work (user messages, assistant, successful +// and errored tool results). +func (s *Session) removeCancelledToolResults() { + filtered := s.messages[:0] + for _, m := range s.messages { + if m.Role == "tool" && isCancelledToolResult(m.Content) { + continue + } + filtered = append(filtered, m) + } + s.messages = filtered +} + +func isCancelledToolResult(content string) bool { + return strings.Contains(content, `"status":"cancelled"`) || + strings.Contains(content, "tool execution interrupted by user") +} + // PreCompactionSnapshot returns a copy of the current messages for persistence // before compaction. Call this before compact(). func (s *Session) PreCompactionSnapshot() []backend.Message { @@ -239,6 +258,29 @@ func (s *Session) appendUserMessage(content string) { s.messages = append(s.messages, backend.Message{Role: "user", Content: content}) } +// cloneMessages returns a deep copy of the message slice. +func cloneMessages(msgs []backend.Message) []backend.Message { + out := make([]backend.Message, len(msgs)) + for i, m := range msgs { + out[i] = backend.Message{ + Role: m.Role, + Content: m.Content, + ToolCallID: m.ToolCallID, + } + if len(m.ToolCalls) > 0 { + out[i].ToolCalls = make([]backend.ToolCall, len(m.ToolCalls)) + for j, tc := range m.ToolCalls { + out[i].ToolCalls[j] = backend.ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: append(json.RawMessage(nil), tc.Arguments...), + } + } + } + } + return out +} + // estimateTokens returns a rough token count (~4 chars per token). func (s *Session) estimateTokens() int { chars := 0