agent: preserve completed work on interrupt/error

- On interrupt or error, keep user message, assistant response, and
  completed tool results (success or error)
- Only remove cancelled/interrupted tool results
- Use deep copy for message snapshots to prevent aliasing bugs
- Snapshot now only used for context overflow retry
This commit is contained in:
Levi Neely 2026-05-04 10:23:56 +02:00
parent 36d3021aae
commit f9b2b50274
2 changed files with 47 additions and 7 deletions

View File

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

View File

@ -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