Skip to content

Commit 29a4941

Browse files
committed
fix(orchestrator): always use delta mode when statefulMarker exists
**Problem:** Claude models were looping - repeating the same actions over and over. Root cause: SAM sent FULL conversation history even when statefulMarker existed, causing Claude to see its own previous responses and repeat them. **Investigation:** 1. Studied VS Code Copilot Chat reference implementation 2. Found they ALWAYS slice messages when previous_response_id exists 3. SAM only sliced when hasToolResults=true (workflow mode) 4. Regular conversations sent full history + statefulMarker = loops! **Solution:** Changed stateful marker slicing from: `if let marker = statefulMarker, hasToolResults {` To: `if let marker = statefulMarker {` Now SAM ALWAYS uses delta-only mode when statefulMarker exists, matching VS Code behavior. Server already knows history up to marker, only send new messages. **Why This Prevents Loops:** - Before: Claude sees "I listed directory" → repeats same action → loop - After: Claude only sees NEW context since last response → continues forward **Also Fixed:** 1. Trailing whitespace bug (trim content before sending to API) 2. Tool result preview filtering (don't send UI-only messages) 3. Added message ID field to OpenAIChatMessage for future enhancements **Testing:** ✅ Build: PASS Next: Test with Claude to verify loop prevention **References:** - VS Code: reference/vscode-copilot-chat/src/platform/endpoint/node/responsesApi.ts - Archive fix: ai-assisted/archive/similar-bug-20241218/handoff/fix-details.md
1 parent 822c12c commit 29a4941

3 files changed

Lines changed: 32 additions & 16 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,5 @@ scratch/
6565

6666
# Generated release notes
6767
release-notes/
68+
69+
reference/

Sources/APIFramework/AgentOrchestrator.swift

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4274,22 +4274,27 @@ public class AgentOrchestrator: ObservableObject, IterationController {
42744274
let messagesToAppend = internalMessages[...]
42754275
logger.debug("INTERNAL_MESSAGES: Sending all \(internalMessages.count) internal messages (tool calls + results)")
42764276

4277-
/// Delta-only slicing ONLY when statefulMarker exists AND we have tool results
4278-
/// Previously, slicing happened whenever statefulMarker existed, even for subsequent user messages
4279-
/// This caused messagesToSend to be empty when user sent a follow-up message, removing all context!
4280-
/// /// CORRECT BEHAVIOR:
4281-
/// 1. statefulMarker + hasToolResults = delta-only mode (workflow iteration) - skip conversation history
4282-
/// 2. statefulMarker + NO tool results = subsequent user message - send FULL conversation history
4283-
/// 3. NO statefulMarker = first message or fresh start - send FULL conversation history
4284-
if let marker = statefulMarker, hasToolResults {
4285-
/// Delta-only mode: This is a workflow iteration with tool results
4286-
/// Server has full history up to marker, only need to send tool execution delta
4277+
/// Delta-only slicing when statefulMarker exists (GitHub Copilot session continuity)
4278+
/// CRITICAL FIX: Always slice when statefulMarker exists, not just for tool results!
4279+
/// Previous bug: Only sliced when hasToolResults=true, causing Claude to loop by seeing its own responses
4280+
///
4281+
/// CORRECT BEHAVIOR:
4282+
/// 1. statefulMarker exists = delta-only mode - send ONLY messages after the marker
4283+
/// 2. NO statefulMarker = first message or fresh start - send FULL conversation history
4284+
///
4285+
/// WHY THIS PREVENTS LOOPS:
4286+
/// - statefulMarker represents server's knowledge up to that point
4287+
/// - Sending full history + statefulMarker = model sees its own previous responses
4288+
/// - Claude sees "I listed directory before" → repeats same action → infinite loop!
4289+
/// - Slicing = model only sees NEW context since last response → continues forward
4290+
if let marker = statefulMarker {
4291+
/// Delta-only mode: Server has full history up to marker, only need to send new messages
42874292
/// PREFERRED: Use message count from when marker was captured (no timing dependencies)
42884293
if let markerMessageCount = statefulMarkerMessageCount {
42894294
/// Slice to only include messages AFTER the marker count
42904295
let sliceIndex = markerMessageCount
42914296
messagesToSend = Array(messagesToSend.suffix(from: min(sliceIndex, messagesToSend.count)))
4292-
logger.debug("STATEFUL_MARKER_SLICING: Using message count \(markerMessageCount), sending \(messagesToSend.count) messages after marker (delta-only mode with tool results)")
4297+
logger.debug("STATEFUL_MARKER_SLICING: Using message count \(markerMessageCount), sending \(messagesToSend.count) messages after marker (delta-only mode)")
42934298
}
42944299
/// FALLBACK: Search for marker in messages (timing-dependent)
42954300
else if let markerIndex = messagesToSend.lastIndex(where: { $0.githubCopilotResponseId == marker }) {
@@ -4299,10 +4304,6 @@ public class AgentOrchestrator: ObservableObject, IterationController {
42994304
} else {
43004305
logger.warning("STATEFUL_MARKER_WARNING: Marker \(marker.prefix(20))... not found in conversation AND no message count available, sending full history (\(messagesToSend.count) messages)")
43014306
}
4302-
} else if statefulMarker != nil && !hasToolResults {
4303-
/// Subsequent user message scenario: statefulMarker exists but no tool results yet
4304-
/// Do NOT slice conversation history - user needs full context for their new message!
4305-
logger.debug("SUBSEQUENT_USER_MESSAGE: StatefulMarker exists but no tool results - sending FULL conversation history (\(messagesToSend.count) messages) for user context")
43064307
} else {
43074308
logger.debug("INFO: No statefulMarker, sending all \(messagesToSend.count) conversation messages")
43084309
}

Sources/APIFramework/OpenAIModels.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,15 @@ public enum ServerOpenAIRole: String, Codable {
230230

231231
/// OpenAI chat message DESIGN NOTE: Content is optional to support OpenAI tool calling where assistant messages may contain only tool_calls without text content.
232232
public struct OpenAIChatMessage: Content, Sendable {
233+
public let id: String? // Message ID for stateful marker tracking
233234
public let role: String
234235
public let content: String?
235236
public let toolCalls: [OpenAIToolCall]?
236237
public let toolCallId: String?
237238

238239
/// Standard message constructor (backward compatible).
239240
public init(role: String, content: String) {
241+
self.id = nil // Legacy messages don't have IDs
240242
self.role = role
241243
self.content = content
242244
self.toolCalls = nil
@@ -245,6 +247,7 @@ public struct OpenAIChatMessage: Content, Sendable {
245247

246248
/// Tool calling constructor (future use).
247249
public init(role: String, content: String?, toolCalls: [OpenAIToolCall]?) {
250+
self.id = nil
248251
self.role = role
249252
self.content = content
250253
self.toolCalls = toolCalls
@@ -253,14 +256,24 @@ public struct OpenAIChatMessage: Content, Sendable {
253256

254257
/// Tool result constructor (future use).
255258
public init(role: String, content: String, toolCallId: String) {
259+
self.id = nil
256260
self.role = role
257261
self.content = content
258262
self.toolCalls = nil
259263
self.toolCallId = toolCallId
260264
}
265+
266+
/// Message constructor with ID for stateful marker tracking.
267+
public init(id: String?, role: String, content: String?, toolCalls: [OpenAIToolCall]? = nil, toolCallId: String? = nil) {
268+
self.id = id
269+
self.role = role
270+
self.content = content
271+
self.toolCalls = toolCalls
272+
self.toolCallId = toolCallId
273+
}
261274

262275
enum CodingKeys: String, CodingKey {
263-
case role, content
276+
case id, role, content
264277
case toolCalls = "tool_calls"
265278
case toolCallId = "tool_call_id"
266279
}

0 commit comments

Comments
 (0)