fix(adapter): ejecutar tool calls que DeepSeek emite como texto DSML
Tercer modo de fallo del conector OpenAI (distinto de followup_mode y de finish_reason=stop): DeepSeek a veces emite las tool calls en su formato interno DSML (<||DSML||tool_calls>…, con U+FF5C) como TEXTO en el content, en vez de como tool_calls nativos. El endpoint OpenAI no lo convierte, asi que el adapter lo trataba como texto y el agente "se paraba" mostrando DSML inerte (0 tools). Fix en OpenAIAdapter.stream: reutiliza el parser del claude_adapter (_parse_xml_tool_calls / _TOOL_CALL_OPEN_RE). Acumula el content; si detecta el inicio de un tool call en texto deja de emitirlo al usuario (DSML no debe verse); al cerrar el turno, si no hubo tool_calls nativos, parsea el content y emite los tool calls encontrados como tool_use para que el engine los ejecute. Validado: el DSML real de la sesion (2x acai_grep) se parsea correctamente. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,9 +55,19 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
|
|
||||||
stream = await self._client.chat.completions.create(**kwargs)
|
stream = await self._client.chat.completions.create(**kwargs)
|
||||||
|
|
||||||
|
# Fallback de tool-calls-en-texto: DeepSeek a veces emite las tool calls
|
||||||
|
# en su formato interno DSML como TEXTO (en el content) en vez de como
|
||||||
|
# tool_calls nativos. El endpoint OpenAI no lo convierte, asi que sin
|
||||||
|
# esto el agente "se para" mostrando DSML inerte. Reutilizamos el parser
|
||||||
|
# del claude_adapter.
|
||||||
|
from .claude_adapter import _parse_xml_tool_calls, _TOOL_CALL_OPEN_RE
|
||||||
|
|
||||||
tool_calls_acc: dict[int, dict[str, str]] = {}
|
tool_calls_acc: dict[int, dict[str, str]] = {}
|
||||||
|
|
||||||
final_usage: dict[str, int] = {}
|
final_usage: dict[str, int] = {}
|
||||||
|
full_content = "" # content acumulado (para el fallback DSML)
|
||||||
|
emitted_chars = 0 # cuanto de full_content ya se emitio como delta
|
||||||
|
suppress_text = False # tras detectar un tool-call-en-texto, no emitir mas
|
||||||
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
# With include_usage, the last chunk has usage but no choices
|
# With include_usage, the last chunk has usage but no choices
|
||||||
@@ -79,7 +89,19 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
|
|
||||||
# Text content
|
# Text content
|
||||||
if delta and delta.content:
|
if delta and delta.content:
|
||||||
yield StreamChunk(delta=delta.content)
|
full_content += delta.content
|
||||||
|
if not suppress_text:
|
||||||
|
# Si arranca un tool call en texto (DSML/XML), emitimos lo
|
||||||
|
# previo y dejamos de emitir el resto (el DSML no debe verse).
|
||||||
|
m = _TOOL_CALL_OPEN_RE.search(full_content, emitted_chars)
|
||||||
|
if m:
|
||||||
|
suppress_text = True
|
||||||
|
if m.start() > emitted_chars:
|
||||||
|
yield StreamChunk(delta=full_content[emitted_chars:m.start()])
|
||||||
|
emitted_chars = len(full_content)
|
||||||
|
else:
|
||||||
|
yield StreamChunk(delta=full_content[emitted_chars:])
|
||||||
|
emitted_chars = len(full_content)
|
||||||
|
|
||||||
# Tool calls
|
# Tool calls
|
||||||
if delta and delta.tool_calls:
|
if delta and delta.tool_calls:
|
||||||
@@ -127,12 +149,27 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
if final_usage:
|
if final_usage:
|
||||||
yield StreamChunk(usage=final_usage)
|
yield StreamChunk(usage=final_usage)
|
||||||
else:
|
else:
|
||||||
yield StreamChunk(
|
# Fallback: DeepSeek pudo emitir las tool calls como TEXTO
|
||||||
finish_reason="end_turn"
|
# (DSML/XML) en vez de nativas. Parseamos el content y, si hay
|
||||||
if choice.finish_reason in ("stop", "tool_calls")
|
# tool calls, las ejecutamos igual; si no, cerramos el turno.
|
||||||
else choice.finish_reason,
|
text_calls = _parse_xml_tool_calls(full_content) if full_content else []
|
||||||
usage=final_usage,
|
if text_calls:
|
||||||
)
|
for c in text_calls:
|
||||||
|
yield StreamChunk(
|
||||||
|
tool_call_id=c["id"],
|
||||||
|
tool_name=c["name"],
|
||||||
|
tool_arguments=json.dumps(c.get("arguments", {}), ensure_ascii=False),
|
||||||
|
finish_reason="tool_use",
|
||||||
|
)
|
||||||
|
if final_usage:
|
||||||
|
yield StreamChunk(usage=final_usage)
|
||||||
|
else:
|
||||||
|
yield StreamChunk(
|
||||||
|
finish_reason="end_turn"
|
||||||
|
if choice.finish_reason in ("stop", "tool_calls")
|
||||||
|
else choice.finish_reason,
|
||||||
|
usage=final_usage,
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Non-streaming
|
# Non-streaming
|
||||||
|
|||||||
Reference in New Issue
Block a user