Compactor: garantizar emparejamiento tool_use/tool_result (sesiones largas bloqueadas)
Las sesiones largas con DeepSeek quedaban bloqueadas permanentemente con 400 "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'": el paso de ultimo recurso del compactor colapsaba assistants con tool_use a un string placeholder dejando huerfanos los tool_result del user siguiente. - compactor: paso de ultimo recurso pair-aware + _enforce_tool_pairing como invariante final (matching por IDs, ambas direcciones, repara tambien historiales ya corruptos persistidos). - openai_adapter: _repair_tool_sequence como guard defensivo del contrato del proveedor (tool huerfano -> user; tool_call sin respuesta -> fuera), con warning para detectar regresiones. - recent_messages: trim por presupuesto de tokens al persistir (AGENTIC_RECENT_MESSAGES_MAX_TOKENS, default 60k) sin cortar pares; cierra el crecimiento sin limite que empujaba al paso destructivo. - tests/test_tool_pairing_real.py: 23 tests que importan el codigo REAL (a diferencia de los tests standalone existentes). Suite completa: 92 ok. Verificado offline contra los recent_messages reales de la sesion bloqueada en prod: 0 violaciones con presupuesto normal y agresivo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -294,11 +294,27 @@ class TestTaskHistoryTrim:
|
||||
class TestConversationCompaction:
|
||||
def test_compactor_preserves_last_user_and_compacts_old_tool_results(self):
|
||||
compactor = ContextCompactor(max_tokens=999999)
|
||||
# Los assistants llevan sus tool_calls: sin ellos los `role: tool`
|
||||
# serian huerfanos y `_enforce_tool_pairing` los convertiria a user.
|
||||
messages = [
|
||||
{"role": "user", "content": "Contexto anterior " * 10},
|
||||
{"role": "assistant", "content": "Voy a revisar el modulo ahora mismo. " * 6},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Voy a revisar el modulo ahora mismo. " * 6,
|
||||
"tool_calls": [
|
||||
{"id": "tool-1", "type": "function",
|
||||
"function": {"name": "t", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
|
||||
{"role": "assistant", "content": "He visto el resultado anterior. " * 6},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "He visto el resultado anterior. " * 6,
|
||||
"tool_calls": [
|
||||
{"id": "tool-2", "type": "function",
|
||||
"function": {"name": "t", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
|
||||
{"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."},
|
||||
]
|
||||
@@ -358,9 +374,18 @@ class TestConversationCompaction:
|
||||
|
||||
def test_compactor_only_touches_user_messages_as_last_resort(self):
|
||||
compactor = ContextCompactor(max_tokens=999999)
|
||||
# tool_calls en el assistant para que el `role: tool` no sea huerfano
|
||||
# (el invariante `_enforce_tool_pairing` convertiria un huerfano a user).
|
||||
messages = [
|
||||
{"role": "user", "content": "Contexto previo del usuario " * 8},
|
||||
{"role": "assistant", "content": "Respuesta previa del asistente " * 6},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Respuesta previa del asistente " * 6,
|
||||
"tool_calls": [
|
||||
{"id": "tool-1", "type": "function",
|
||||
"function": {"name": "t", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80},
|
||||
{"role": "user", "content": "Ultimo mensaje del usuario"},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user