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:
@@ -444,4 +444,106 @@ class OpenAIAdapter(ModelAdapter):
|
||||
text_parts.append(b.get("text", ""))
|
||||
if text_parts:
|
||||
out.append({"role": "user", "content": "\n".join(text_parts)})
|
||||
return out
|
||||
# Guard defensivo: el compactor ya garantiza el invariante tool_use ↔
|
||||
# tool_result (`_enforce_tool_pairing`), pero si algo se escapa el
|
||||
# proveedor devuelve 400 y la sesion queda bloqueada. Cinturon y tirantes.
|
||||
return self._repair_tool_sequence(out)
|
||||
|
||||
@staticmethod
|
||||
def _repair_tool_sequence(out: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Garantiza el contrato OpenAI sobre la secuencia ya convertida:
|
||||
|
||||
- Todo `role: tool` debe responder a un tool_call_id del assistant
|
||||
inmediatamente anterior (o de su bloque contiguo de tool messages).
|
||||
Si no → se convierte a user con placeholder.
|
||||
- Todo assistant con `tool_calls` debe tener respuesta para CADA id.
|
||||
Los tool_calls sin respuesta se eliminan; si la lista queda vacia se
|
||||
elimina la key (y se asegura `content` no-None — "content or
|
||||
tool_calls must be set").
|
||||
|
||||
No deberia activarse nunca (el compactor repara antes); si se activa,
|
||||
loguea warning para detectar regresiones del compactor.
|
||||
"""
|
||||
repaired: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
n = len(out)
|
||||
while i < n:
|
||||
msg = out[i]
|
||||
role = msg.get("role")
|
||||
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
# Bloque contiguo de tool messages que responden a este assistant.
|
||||
j = i + 1
|
||||
block: list[dict[str, Any]] = []
|
||||
while j < n and out[j].get("role") == "tool":
|
||||
block.append(out[j])
|
||||
j += 1
|
||||
answered = {t.get("tool_call_id", "") for t in block}
|
||||
kept_calls = [
|
||||
tc for tc in msg["tool_calls"] if tc.get("id", "") in answered
|
||||
]
|
||||
dropped = [
|
||||
tc for tc in msg["tool_calls"] if tc.get("id", "") not in answered
|
||||
]
|
||||
new_msg = dict(msg)
|
||||
if dropped:
|
||||
for tc in dropped:
|
||||
logger.warning(
|
||||
"repaired unanswered tool_call at index %d (tool_call_id=%s)",
|
||||
i,
|
||||
tc.get("id", ""),
|
||||
)
|
||||
if kept_calls:
|
||||
new_msg["tool_calls"] = kept_calls
|
||||
else:
|
||||
new_msg.pop("tool_calls", None)
|
||||
if new_msg.get("content") is None:
|
||||
# Promover reasoning a content si existe (mismo
|
||||
# criterio que el quirk DeepSeek de arriba); si no,
|
||||
# placeholder para no enviar content=None sin tools.
|
||||
rc = new_msg.pop("reasoning_content", None)
|
||||
new_msg["content"] = rc or "[ASSISTANT COMPACTADO]"
|
||||
repaired.append(new_msg)
|
||||
valid_ids = {tc.get("id", "") for tc in kept_calls}
|
||||
converted: list[dict[str, Any]] = []
|
||||
for t in block:
|
||||
if t.get("tool_call_id", "") in valid_ids:
|
||||
repaired.append(t)
|
||||
else:
|
||||
logger.warning(
|
||||
"repaired orphan tool message (tool_call_id=%s)",
|
||||
t.get("tool_call_id", ""),
|
||||
)
|
||||
converted.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Resultado de herramienta (contexto compactado)]: "
|
||||
+ str(t.get("content", ""))[:500],
|
||||
}
|
||||
)
|
||||
# Los huerfanos convertidos van DESPUES del bloque de tools
|
||||
# validos para no romper la contiguidad assistant → tools.
|
||||
repaired.extend(converted)
|
||||
i = j
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Tool message sin assistant con tool_calls delante → huerfano.
|
||||
logger.warning(
|
||||
"repaired orphan tool message at index %d (tool_call_id=%s)",
|
||||
i,
|
||||
msg.get("tool_call_id", ""),
|
||||
)
|
||||
repaired.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Resultado de herramienta (contexto compactado)]: "
|
||||
+ str(msg.get("content", ""))[:500],
|
||||
}
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
repaired.append(msg)
|
||||
i += 1
|
||||
return repaired
|
||||
|
||||
Reference in New Issue
Block a user