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 +# (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 +# U+FF5C = | (fullwidth vertical line) +_DSML_INVOKE_RE = re.compile( + r"<||DSML||invoke\s+name=\"([^\"]+)\"[^>]*>(.*?)", + re.IGNORECASE | re.DOTALL, +) +_DSML_PARAM_RE = re.compile( + r"<||DSML||parameter\s+name=\"([^\"]+)\"[^>]*>(.*?)", + 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 + 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])