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:
@@ -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) <||DSML||tool_calls><||DSML||invoke name="X"><||DSML||parameter name="P" string="true">V</||DSML||parameter></||DSML||invoke></||DSML||tool_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): <||DSML||invoke name="X"><||DSML||parameter name="P" ...>V</||DSML||parameter></||DSML||invoke>
|
||||
# U+FF5C = | (fullwidth vertical line)
|
||||
_DSML_INVOKE_RE = re.compile(
|
||||
r"<||DSML||invoke\s+name=\"([^\"]+)\"[^>]*>(.*?)</||DSML||invoke\s*>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_DSML_PARAM_RE = re.compile(
|
||||
r"<||DSML||parameter\s+name=\"([^\"]+)\"[^>]*>(.*?)</||DSML||parameter\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):
|
||||
# <||DSML||invoke name="X"><||DSML||parameter name="P" string="true">V</||DSML||parameter></||DSML||invoke>
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user