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:
parent
36d3021aae
commit
f9b2b50274
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in New Issue