diff --git a/src/adapters/claude_adapter.py b/src/adapters/claude_adapter.py
index f2b6c5e..89d7581 100644
--- a/src/adapters/claude_adapter.py
+++ b/src/adapters/claude_adapter.py
@@ -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) V
# 2) V (sin minimax wrapper)
# 3) {"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}
# (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 = (" 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
diff --git a/src/adapters/openai_adapter.py b/src/adapters/openai_adapter.py
index 8726a3d..d2e0a50 100644
--- a/src/adapters/openai_adapter.py
+++ b/src/adapters/openai_adapter.py
@@ -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
diff --git a/src/context/engine.py b/src/context/engine.py
index 3577bfa..b756ff9 100644
--- a/src/context/engine.py
+++ b/src/context/engine.py
@@ -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])