diff --git a/agent/chat/service.py b/agent/chat/service.py index d3712dbf..1afe1691 100644 --- a/agent/chat/service.py +++ b/agent/chat/service.py @@ -166,10 +166,56 @@ class ChatService: logger.info("[ChatService] Cleared agent message history after executor recovery") raise - # Append only the NEW messages from this execution (thread-safe) + # Sync executor messages back to agent (thread-safe). + # The executor may have trimmed context, making its list shorter than + # original_length. In that case we must replace entirely — just + # appending would leave stale pre-trim messages in agent.messages + # and cause the same trim to fire on every subsequent request. with agent.messages_lock: - new_messages = executor.messages[original_length:] - agent.messages.extend(new_messages) + trimmed = len(executor.messages) < original_length + if trimmed: + # Context was trimmed: the executor appended the new user + # query *before* trimming, so the new messages (user + + # assistant + tools) sit at the tail of the trimmed list. + # We cannot simply slice at original_length (it exceeds the + # list length). Instead, count how many messages the + # executor added on top of the post-trim baseline. + # + # Timeline inside executor.run_stream: + # 1. messages had `original_length` items + # 2. append user query → original_length + 1 + # 3. _trim_messages() → some smaller number (includes the + # user query because it belongs to the last turn) + # 4. LLM replies / tool calls appended + # + # The user query message is always the first message of the + # last turn (it cannot be trimmed away), so we locate it to + # find where "new" messages begin. + new_start = original_length # fallback + for idx in range(len(executor.messages) - 1, -1, -1): + msg = executor.messages[idx] + if msg.get("role") == "user": + content = msg.get("content", []) + is_user_query = False + if isinstance(content, list): + has_text = any( + isinstance(b, dict) and b.get("type") == "text" + for b in content + ) + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" + for b in content + ) + is_user_query = has_text and not has_tool_result + elif isinstance(content, str): + is_user_query = True + if is_user_query: + new_start = idx + break + new_messages = list(executor.messages[new_start:]) + else: + new_messages = list(executor.messages[original_length:]) + agent.messages = list(executor.messages) # Persist new messages to SQLite so they survive restarts and # can be queried via the HISTORY interface.