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

@@ -17,20 +17,22 @@ from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
logger = logging.getLogger(__name__)
# Algunos fine-tunes (sobre todo MiniMax) ocasionalmente emiten las tool calls
# como texto literal en lugar de usar los `tool_use` blocks nativos. Vistos
# tres formatos:
# Algunos fine-tunes (sobre todo MiniMax y DeepSeek) ocasionalmente emiten las
# tool calls como texto literal en lugar de usar los `tool_use` blocks nativos.
# Vistos cuatro formatos:
# 1) <minimax:tool_call><invoke name="X"><parameter name="P">V</parameter></invoke></minimax:tool_call>
# 2) <invoke name="X"><parameter name="P">V</parameter></invoke> (sin minimax wrapper)
# 3) <tool_call>{"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}</tool_call>
# (multiples tool calls JSON-encoded dentro de un solo wrapper)
# 4) <DSMLtool_calls><DSMLinvoke name="X"><DSMLparameter name="P" string="true">V</DSMLparameter></DSMLinvoke></DSMLtool_calls>
# (formato DSML de DeepSeek — usa U+FF5C fullwidth vertical line como separador)
#
# Cuando eso pasa el orquestador ve "texto" y la tool nunca se ejecuta — el
# usuario ve el markup crudo en el chat. Detectamos y convertimos a tool_use
# sintetico mientras streameamos. Es un parche defensivo: el caso normal
# (tool_use blocks) sigue por el camino estandar.
_TOOL_CALL_OPEN_RE = re.compile(
r"<(?:minimax:tool_call|invoke\s+name|tool_call\s*>)|\[TOOL_CALL\]",
r"<(?:minimax:tool_call|invoke\s+name|tool_call[\s>]|use_mcp_tool|mm_special)|\[TOOL_CALL\]|<DSML",
re.IGNORECASE,
)
_INVOKE_RE = re.compile(
@@ -65,6 +67,16 @@ _PERL_ARGS_BLOCK_RE = re.compile(
_PERL_KV_RE = re.compile(
r"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(\"[^\"]*\"|\'[^\']*\'|-?\d+(?:\.\d+)?|true|false|null)",
)
# Formato 5 (DeepSeek DSML): <DSMLinvoke name="X"><DSMLparameter name="P" ...>V</DSMLparameter></DSMLinvoke>
# U+FF5C = (fullwidth vertical line)
_DSML_INVOKE_RE = re.compile(
r"<DSMLinvoke\s+name=\"([^\"]+)\"[^>]*>(.*?)</DSMLinvoke\s*>",
re.IGNORECASE | re.DOTALL,
)
_DSML_PARAM_RE = re.compile(
r"<DSMLparameter\s+name=\"([^\"]+)\"[^>]*>(.*?)</DSMLparameter\s*>",
re.IGNORECASE | re.DOTALL,
)
def _safe_emit_split(buf: str) -> str:
@@ -91,8 +103,8 @@ def _safe_emit_split(buf: str) -> str:
# Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo.
if ">" in tail:
return buf
# Si el tail puede ser inicio de tool_call/invoke/tool_call_json, retenerlo.
candidates = ("<minimax:tool_call", "<invoke", "<tool_call")
# Si el tail puede ser inicio de tool_call/invoke/tool_call_json/dsml, retenerlo.
candidates = ("<minimax:tool_call", "<invoke", "<tool_call", "<dsml")
for cand in candidates:
if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()):
return buf[:idx]
@@ -212,6 +224,21 @@ def _parse_xml_tool_calls(text: str) -> list[dict[str, Any]]:
"arguments": args,
})
# Formato 5 (DeepSeek DSML):
# <DSMLinvoke name="X"><DSMLparameter name="P" string="true">V</DSMLparameter></DSMLinvoke>
for m in _DSML_INVOKE_RE.finditer(text):
name = m.group(1).strip()
body = m.group(2)
args_dsml: dict[str, Any] = {}
for p in _DSML_PARAM_RE.finditer(body):
args_dsml[p.group(1).strip()] = p.group(2).strip()
if name:
calls.append({
"id": "xml_{}".format(uuid.uuid4().hex[:12]),
"name": name,
"arguments": args_dsml,
})
return calls

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

View File

@@ -936,7 +936,22 @@ class ContextEngine:
else:
base_user_content = "Awaiting task assignment."
followup_mode = self._classify_followup_mode(base_user_content)
# Un follow-up (transform/fetch_more/ambiguous) SOLO tiene sentido si hay
# un turno anterior al que referirse. En una sesión fresca / primer mensaje
# no hay nada que transformar, así que NO clasificamos: de lo contrario un
# primer prompt que casualmente contenga un marker ("resumen", "estructura",
# "busca", "adapta"…) se marcaría como `transform` y `_get_allowed_tools`
# devolvería [] — el agente se quedaría SIN tools y emitiría los tool calls
# como texto sin ejecutarlos (caso real: el prompt de análisis de estilos
# que dice "Guarda un resumen…").
has_prior_turn = bool(session.task_history) or bool(
getattr(session, "recent_messages", [])
)
followup_mode = (
self._classify_followup_mode(base_user_content)
if has_prior_turn
else "none"
)
resolved_context = ""
if session.task_history and followup_mode != "none":
resolved_context = self._build_followup_resolution(session.task_history[-1])