fix(agentic): DeepSeek llama tools de forma fiable (conector OpenAI + followup_mode)

Dos bugs encadenados impedían que el agente ejecutara tools (emitía los tool
calls como texto sin ejecutarlos, y degradaba el contexto):

1. Conector: el OpenAIAdapter pasaba los mensajes en formato Anthropic (bloques
   tool_use/tool_result) que la API OpenAI de DeepSeek rechaza, y defaulteaba el
   modelo a "gpt-4o". Añade `_to_openai_messages()` (assistant.tool_use →
   tool_calls; user.tool_result → role:tool con tool_call_id) y `_blocks_text()`,
   y usa `settings.default_model_id`. Con esto DeepSeek devuelve tool_calls
   nativos vía https://api.deepseek.com (endpoint OpenAI), sin parsear texto y
   sin la degradación que sufría el endpoint Anthropic-compat.

2. followup_mode: `_classify_followup_mode` marcaba como "transform" cualquier
   PRIMER mensaje que contuviera un marker ("resumen", "estructura", "busca",
   "adapta"…), y `_get_allowed_tools` devuelve [] en modo transform → el agente
   se quedaba SIN tools. Un follow-up no tiene sentido sin turno anterior, así
   que ahora solo se clasifica si hay task_history/recent_messages.

claude_adapter: parser DSML/DeepSeek para tool calls como texto (fallback del
endpoint Anthropic-compat, ya no es la vía principal).

Validado: el prompt de análisis de estilos ("Guarda un resumen…") ahora explora
los módulos y escribe docs/project-styles.md vía save_project_styles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordan Diaz
2026-06-05 11:01:54 +00:00
parent 9854960c7c
commit 454b51b45d
3 changed files with 127 additions and 11 deletions

View File

@@ -43,10 +43,10 @@ class OpenAIAdapter(ModelAdapter):
)
kwargs: dict[str, Any] = {
"model": config.model_id or "gpt-4o",
"model": config.model_id or settings.default_model_id or "gpt-4o",
"max_tokens": config.max_tokens,
"temperature": config.temperature,
"messages": messages,
"messages": self._to_openai_messages(messages),
"stream": True,
"stream_options": {"include_usage": True},
}
@@ -145,10 +145,10 @@ class OpenAIAdapter(ModelAdapter):
)
kwargs: dict[str, Any] = {
"model": config.model_id or "gpt-4o",
"model": config.model_id or settings.default_model_id or "gpt-4o",
"max_tokens": config.max_tokens,
"temperature": config.temperature,
"messages": messages,
"messages": self._to_openai_messages(messages),
}
if tools:
kwargs["tools"] = self._format_tools(tools)
@@ -220,3 +220,77 @@ class OpenAIAdapter(ModelAdapter):
}
)
return formatted
@staticmethod
def _blocks_text(content: Any) -> str:
"""Extrae texto plano de un content que puede ser str o lista de bloques."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict):
parts.append(b.get("text") or b.get("content") or "")
else:
parts.append(str(b))
return "\n".join(p for p in parts if p)
return str(content)
def _to_openai_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convierte los mensajes del formato interno (Anthropic-style, con bloques
`tool_use` / `tool_result`) al formato de la API OpenAI (`tool_calls` en el
assistant, mensajes `role: tool` con `tool_call_id`). El contexto se construye
en formato Anthropic, así que sin esto la API OpenAI de DeepSeek rechaza el
body ('unknown variant tool_use')."""
out: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
content = msg.get("content")
if role == "system":
out.append({"role": "system", "content": content if isinstance(content, str) else self._blocks_text(content)})
continue
if not isinstance(content, list):
out.append({"role": role, "content": content if isinstance(content, str) else str(content or "")})
continue
if role == "assistant":
text_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
for b in content:
if not isinstance(b, dict):
continue
t = b.get("type")
if t == "text":
text_parts.append(b.get("text", ""))
elif t == "tool_use":
tool_calls.append({
"id": b.get("id", ""),
"type": "function",
"function": {
"name": b.get("name", ""),
"arguments": json.dumps(b.get("input", {}), ensure_ascii=False),
},
})
# thinking / otros bloques: se ignoran (OpenAI no los soporta)
m: dict[str, Any] = {"role": "assistant", "content": ("\n".join(p for p in text_parts if p) or None)}
if tool_calls:
m["tool_calls"] = tool_calls
out.append(m)
else: # user (puede traer tool_result blocks)
text_parts = []
for b in content:
if not isinstance(b, dict):
continue
t = b.get("type")
if t == "tool_result":
out.append({
"role": "tool",
"tool_call_id": b.get("tool_use_id", ""),
"content": self._blocks_text(b.get("content")),
})
elif t == "text":
text_parts.append(b.get("text", ""))
if text_parts:
out.append({"role": "user", "content": "\n".join(text_parts)})
return out