Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions coworker/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .progress import bind_sink, reset_sink, wants_progress
from .providers import AssistantTurn, ProviderClient, ToolCall
from .providers.errors import friendly_model_error
from .providers.openai_provider import looks_like_unparsed_tool_call
from .tools import ToolRegistry


Expand Down Expand Up @@ -367,6 +368,25 @@ def _partial_turn() -> AssistantTurn:
if self._steering:
self._inject_steering()
continue
# The model tried to call a tool and the syntax never parsed — salvage already
# had its go. Ending as "completed" here would present a half-written call as
# the answer, which is indistinguishable from the model deciding it was done;
# the user just sees narration trailing off into stray tags. Fail loudly
# instead, on the error path so the GUI offers Retry — this is drift, not a
# deterministic failure, so retrying the same model usually works.
if looks_like_unparsed_tool_call(turn.text, self.registry.schemas() or None):
message = (
f"{self.model} replied with a tool call this endpoint couldn't parse, "
"so the turn was stopped rather than answered from a partial call. "
"Retry, or switch to a larger model — smaller local models drift off "
"the tool-call format, especially with many tools in play."
)
self._append_notice("error", message)
yield Event(
EventType.ERROR,
{"error": message, "error_type": "UnparsedToolCall"},
)
return
yield Event(
EventType.TURN_END,
{"status": "completed", "iterations": iterations},
Expand Down
55 changes: 55 additions & 0 deletions coworker/providers/openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,45 @@ def _parse_tool_calls(raw_tool_calls: Any) -> list[ToolCall]:
re.IGNORECASE | re.DOTALL,
)

# A `<function=NAME>` that never closes — the model ran out of tokens (or drifted) partway
# through writing the call. Anchored to end-of-text so it only matches a genuinely unfinished
# tail, never a well-formed block earlier in the message. Small local models hit this often on
# a large tool schema, and the turn used to end silently on the leftover text.
_FUNCTION_OPEN_TRUNCATED = re.compile(
r"<function\s*=\s*(?P<name>[^>\s]+)\s*>(?P<body>(?:(?!</function\s*>).)*)$",
re.IGNORECASE | re.DOTALL,
)

# Markers that mean "this text IS a tool call the endpoint failed to parse", used to tell a
# real answer from a leaked one. Fenced code is stripped first: a model *explaining* tool-call
# syntax in a ``` block is answering, not calling.
_LEAKED_TOOL_SYNTAX = (
"<tool_call>",
"</tool_call>",
"<function=",
"</function>",
"<parameter=",
"</parameter>",
"<function_calls>",
"<invoke ",
)
_FENCED = re.compile(r"```.*?```|~~~.*?~~~|`[^`\n]*`", re.DOTALL)


def looks_like_unparsed_tool_call(
text: Optional[str], tools: Optional[list[dict[str, Any]]] = None
) -> bool:
"""True when assistant text still carries tool-call markup that salvage couldn't turn into
a call — i.e. the model tried to call a tool and the syntax was mangled or cut off.

Only meaningful when tools were actually offered, and only over OpenAI-compatible endpoints
that parse tool calls out of the model's raw output (LM Studio, Ollama, vLLM). The caller
uses it to end the turn as a retriable error instead of presenting the fragment as an answer.
"""
if not tools or not text:
return False
return any(m in _FENCED.sub("", text).lower() for m in _LEAKED_TOOL_SYNTAX)


def _coerce_param(raw: str) -> Any:
"""Keep free-text verbatim (the common case: file content), but recover real JSON values when
Expand Down Expand Up @@ -478,6 +517,22 @@ def _salvage_tool_calls_from_text(
if calls:
return _renumber(calls)

# 1c) A TRUNCATED XML call: `<function=NAME>` with no closing tag, because the model ran
# out of tokens mid-call. Take the name plus every parameter that DID close; a trailing
# unterminated `<parameter=…>` is dropped rather than guessed, so a half-written path or
# file body can never reach a tool. If that leaves a required argument missing the call
# fails validation and the model gets a corrective tool error — which is the agent loop
# working, and strictly better than the turn ending on the leftover fragment.
tm = _FUNCTION_OPEN_TRUNCATED.search(text)
if tm:
name = tm.group("name").strip()
if names is None or name in names:
args = {
pm.group("key").strip(): _coerce_param(pm.group("val"))
for pm in _PARAM_BLOCK.finditer(tm.group("body"))
}
return _renumber([ToolCall(id="", name=name, arguments=args)])

# 2) Embedded {"name": …, "arguments": …} objects, even surrounded by prose.
for sub in _iter_top_objects(text):
d = _loads(sub)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,39 @@ def capabilities(self, model):
assert all(p["type"] != "image_url" for p in parts)
assert "not viewable" in parts[-1]["text"]
assert engine.messages[-1]["content"][1]["type"] == "image_url" # history untouched


def test_leaked_tool_call_ends_the_turn_as_a_retriable_error(tmp_path):
"""A tool call the endpoint couldn't parse must not pass as an answer. Ending "completed"
made a half-written call indistinguishable from the model deciding it was done — the user
saw narration trailing off into stray tags (owner report 2026-07-26, qwen3.5-9b on LM
Studio). It ends on the error path so the GUI offers Retry; the drift is probabilistic, so
retrying the same model usually works."""
leaked = "Let me read the key files.\n<tool_call>\n<function=nope_not_a_tool>\n<parameter="
engine, _ = _engine(tmp_path, [_text_turn(leaked)])
events = _collect(engine, "explore the codebase")

assert EventType.ERROR in _types(events)
assert EventType.TURN_END not in _types(events)
err = next(ev for ev in events if ev.type == EventType.ERROR)
assert err.data["error_type"] == "UnparsedToolCall"
assert "couldn't parse" in err.data["error"]
# Persisted as an error notice, which is what unlocks retry().
assert engine.messages[-1] == {
**engine.messages[-1],
"role": "notice",
"kind": "error",
}
assert engine._tail_is_retriable_error() is True


def test_ordinary_text_answer_still_completes(tmp_path):
"""Guard the other side: prose that merely mentions tool syntax inside code fences is a
real answer and must still complete normally."""
engine, _ = _engine(
tmp_path,
[_text_turn("Qwen writes calls like:\n```\n<tool_call><function=x>\n```\nThat's it.")],
)
events = _collect(engine, "how does qwen format tool calls?")
assert EventType.ERROR not in _types(events)
assert next(ev for ev in events if ev.type == EventType.TURN_END).data["status"] == "completed"
53 changes: 52 additions & 1 deletion tests/test_provider_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
_normalize_ollama_url,
build_provider_client,
)
from coworker.providers.openai_provider import _salvage_tool_calls_from_text
from coworker.providers.openai_provider import (
_salvage_tool_calls_from_text,
looks_like_unparsed_tool_call,
)


# -- base_url passthrough -------------------------------------------------------
Expand Down Expand Up @@ -274,6 +277,21 @@ def test_salvage_ignores_prose():
]


_GREP_TOOL = [
{
"type": "function",
"function": {
"name": "grep",
"parameters": {
"type": "object",
"properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
"required": ["pattern"],
},
},
}
]


def test_salvage_mixed_prose_and_object():
# The model wrote prose THEN a bare-JSON tool call in one message.
text = 'It seems the workspace is empty. {"name": "list_files", "arguments": {"recursive": true}}'
Expand Down Expand Up @@ -309,6 +327,39 @@ def test_salvage_filters_unknown_tool_name():
assert _salvage_tool_calls_from_text(text, _TODO_TOOLS) == []


def test_salvage_truncated_xml_call_keeps_only_complete_parameters():
"""A local model that runs out of tokens mid-call leaves `<function=…>` unclosed. Take the
name and every parameter that DID close; NEVER the half-written trailing one — a truncated
path or file body reaching a tool is worse than no call at all."""
text = "<tool_call>\n<function=grep>\n<parameter=pattern>TODO</parameter>\n<parameter=path>sr"
calls = _salvage_tool_calls_from_text(text, _TODO_TOOLS + _GREP_TOOL)
assert len(calls) == 1 and calls[0].name == "grep"
assert calls[0].arguments == {"pattern": "TODO"} # the partial `path` is gone


def test_salvage_truncated_xml_prefers_a_complete_call_and_filters_unknown_names():
complete_then_cut = (
"<tool_call><function=list_files><parameter=recursive>true</parameter>"
"</function></tool_call>\n<tool_call>\n<function=grep>"
)
calls = _salvage_tool_calls_from_text(complete_then_cut, _TODO_TOOLS + _GREP_TOOL)
assert [c.name for c in calls] == ["list_files"] # the finished one wins
# An unfinished call naming something we never offered stays text (no false positives).
assert _salvage_tool_calls_from_text("<function=rm_rf>\n<parameter=p>/", _TODO_TOOLS) == []


def test_looks_like_unparsed_tool_call_ignores_code_and_needs_tools():
"""Distinguishes a leaked call from a model *explaining* tool syntax — the latter is a real
answer and must not be turned into an error."""
leaked = "Let me read the files.\n</parameter>\n</function>\n</tool_call>"
assert looks_like_unparsed_tool_call(leaked, _TODO_TOOLS) is True
assert looks_like_unparsed_tool_call("A CLI that greets people.", _TODO_TOOLS) is False
fenced = "Qwen writes calls like:\n```\n<tool_call><function=x>\n```\nThat's the shape."
assert looks_like_unparsed_tool_call(fenced, _TODO_TOOLS) is False
assert looks_like_unparsed_tool_call("The `<tool_call>` wrapper.", _TODO_TOOLS) is False
assert looks_like_unparsed_tool_call(leaked, None) is False # no tools offered → not a call


def test_salvage_nested_braces_in_tag():
text = '<tool_call>{"name": "todo_write", "arguments": {"items": [{"content": "a", "status": "pending"}]}}</tool_call>'
calls = _salvage_tool_calls_from_text(text, _TODO_TOOLS)
Expand Down