From 5c0e4df79a544dcfcead0a5fc52959777f81761a Mon Sep 17 00:00:00 2001
From: Alexxigang <37231458+Alexxigang@users.noreply.github.com>
Date: Sun, 15 Mar 2026 12:58:44 +0800
Subject: [PATCH 1/3] fix(local-models): support OpenAI-style tool calls
---
src/copaw/local_models/tag_parser.py | 66 ++++++++++++++++++++--------
1 file changed, 47 insertions(+), 19 deletions(-)
diff --git a/src/copaw/local_models/tag_parser.py b/src/copaw/local_models/tag_parser.py
index b77af3e32..550f34a22 100644
--- a/src/copaw/local_models/tag_parser.py
+++ b/src/copaw/local_models/tag_parser.py
@@ -92,6 +92,28 @@ def _generate_call_id() -> str:
return f"call_{uuid.uuid4().hex[:12]}"
+def _normalize_tool_arguments(arguments: object) -> tuple[dict, str]:
+ if isinstance(arguments, str):
+ try:
+ parsed = json.loads(arguments)
+ except (json.JSONDecodeError, TypeError):
+ return {}, arguments
+ return (parsed if isinstance(parsed, dict) else {}), arguments
+
+ if isinstance(arguments, dict):
+ return arguments, json.dumps(arguments, ensure_ascii=False)
+
+ if arguments is None:
+ return {}, ""
+
+ try:
+ raw_arguments = json.dumps(arguments, ensure_ascii=False)
+ except TypeError:
+ raw_arguments = ""
+
+ return {}, raw_arguments
+
+
def _parse_single_tool_call(raw_text: str) -> ParsedToolCall | None:
"""
Parse the JSON content between a ```` / ```` pair.
@@ -106,23 +128,29 @@ def _parse_single_tool_call(raw_text: str) -> ParsedToolCall | None:
logger.warning("Failed to parse tool call JSON: %s", raw_text[:200])
return None
- name = data.get("name", "")
+ if not isinstance(data, dict):
+ logger.warning("Tool call JSON must decode to an object: %s", raw_text[:200])
+ return None
+
+ function_data = data.get("function")
+ if isinstance(function_data, dict):
+ name = function_data.get("name", "")
+ arguments_value = function_data.get("arguments", {})
+ else:
+ name = data.get("name", "")
+ arguments_value = data.get("arguments", {})
+
if not name:
logger.warning("Tool call missing 'name' field: %s", raw_text[:200])
return None
- arguments = data.get("arguments", {})
- if isinstance(arguments, str):
- try:
- arguments = json.loads(arguments)
- except (json.JSONDecodeError, TypeError):
- arguments = {}
+ arguments, raw_arguments = _normalize_tool_arguments(arguments_value)
return ParsedToolCall(
- id=_generate_call_id(),
+ id=data.get("id") or _generate_call_id(),
name=name,
arguments=arguments,
- raw_arguments=json.dumps(arguments, ensure_ascii=False),
+ raw_arguments=raw_arguments,
)
@@ -141,9 +169,9 @@ def extract_thinking_from_text(text: str) -> TextWithThinking:
Returns a :class:`TextWithThinking` with:
- * ``thinking`` – the reasoning content (empty if none found)
- * ``remaining_text`` – everything outside the think tags
- * ``has_open_tag`` – ``True`` if ```` opened but not closed yet
+ * ``thinking`` the reasoning content (empty if none found)
+ * ``remaining_text`` everything outside the think tags
+ * ``has_open_tag`` ``True`` if ```` opened but not closed yet
"""
match = _THINK_RE.search(text)
if match:
@@ -154,7 +182,7 @@ def extract_thinking_from_text(text: str) -> TextWithThinking:
remaining_text=remaining,
)
- # No complete block — check for an unclosed .
+ # No complete block; check for an unclosed .
open_idx = text.find(THINK_START)
if open_idx != -1:
remaining = text[:open_idx].strip()
@@ -178,17 +206,17 @@ def parse_tool_calls_from_text(text: str) -> TextWithToolCalls:
Returns a :class:`TextWithToolCalls` with:
- * ``text_before`` – all text before the first ```` tag
- * ``text_after`` – all text after the last ```` tag
- * ``tool_calls`` – successfully parsed tool calls
- * ``has_open_tag`` – whether there is an unclosed ````
+ * ``text_before`` all text before the first ```` tag
+ * ``text_after`` all text after the last ```` tag
+ * ``tool_calls`` successfully parsed tool calls
+ * ``has_open_tag`` whether there is an unclosed ````
(streaming)
- * ``partial_tool_text`` – content after the unclosed tag
+ * ``partial_tool_text`` content after the unclosed tag
"""
matches = list(_TOOL_CALL_RE.finditer(text))
if not matches:
- # No complete blocks. Check for an unclosed opening tag.
+ # No complete blocks. Check for an unclosed opening tag.
open_idx = text.rfind(TOOL_CALL_START)
if open_idx != -1:
return TextWithToolCalls(
From 69ad3c452029b9564d453e919f98af25cc485f94 Mon Sep 17 00:00:00 2001
From: Alexxigang <37231458+Alexxigang@users.noreply.github.com>
Date: Sun, 15 Mar 2026 13:00:58 +0800
Subject: [PATCH 2/3] Fix string formatting and improfix(local-models): merge
streamed tool callsve argument handling
---
src/copaw/local_models/chat_model.py | 32 ++++++++++++++++++++++------
1 file changed, 26 insertions(+), 6 deletions(-)
diff --git a/src/copaw/local_models/chat_model.py b/src/copaw/local_models/chat_model.py
index f48132ab8..119bd879b 100644
--- a/src/copaw/local_models/chat_model.py
+++ b/src/copaw/local_models/chat_model.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# pylint:disable=too-many-branches,too-many-statements
-"""LocalChatModel — ChatModelBase implementation for local backends."""
+"""LocalChatModel - ChatModelBase implementation for local backends."""
from __future__ import annotations
@@ -36,11 +36,22 @@ def _json_loads_safe(s: str) -> dict:
return {}
+def _stringify_tool_arguments(arguments: Any) -> str:
+ if arguments is None:
+ return ""
+ if isinstance(arguments, str):
+ return arguments
+ try:
+ return json.dumps(arguments, ensure_ascii=False)
+ except TypeError:
+ return ""
+
+
class LocalChatModel(ChatModelBase):
"""ChatModelBase implementation for local model backends.
Wraps any ``LocalBackend`` (llama.cpp, future MLX) and presents it
- through the agentscope ``ChatModelBase`` interface. Since backends are
+ through the agentscope ``ChatModelBase`` interface. Since backends are
synchronous, inference runs in a thread executor for async compatibility.
"""
@@ -158,12 +169,19 @@ def _produce() -> None:
if idx not in tool_calls:
tool_calls[idx] = {
"id": tc.get("id", f"call_{idx}"),
- "name": (tc.get("function") or {}).get("name", ""),
+ "name": "",
"arguments": "",
}
- tool_calls[idx]["arguments"] += (tc.get("function") or {}).get(
- "arguments",
- ) or ""
+ if tc.get("id"):
+ tool_calls[idx]["id"] = tc["id"]
+
+ function_data = tc.get("function") or {}
+ if function_data.get("name"):
+ tool_calls[idx]["name"] = function_data["name"]
+
+ tool_calls[idx]["arguments"] += _stringify_tool_arguments(
+ function_data.get("arguments"),
+ )
# Build content blocks
contents: list = []
@@ -231,6 +249,8 @@ def _produce() -> None:
)
for tc_data in tool_calls.values():
+ if not tc_data["name"]:
+ continue
contents.append(
ToolUseBlock(
type="tool_use",
From c9e4fe5e23958a8e346db79e945f53b0548114e4 Mon Sep 17 00:00:00 2001
From: Alexxigang <37231458+Alexxigang@users.noreply.github.com>
Date: Sun, 15 Mar 2026 13:03:26 +0800
Subject: [PATCH 3/3] Implement tests for tool call parsing and streaming
Add unit tests for LocalChatModel and tool call parsing.
---
.../test_local_model_tool_calls.py | 142 ++++++++++++++++++
1 file changed, 142 insertions(+)
create mode 100644 tests/unit/local_models/test_local_model_tool_calls.py
diff --git a/tests/unit/local_models/test_local_model_tool_calls.py b/tests/unit/local_models/test_local_model_tool_calls.py
new file mode 100644
index 000000000..10f1a8896
--- /dev/null
+++ b/tests/unit/local_models/test_local_model_tool_calls.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import datetime
+from typing import Any
+
+from copaw.local_models.backends.base import LocalBackend
+from copaw.local_models.chat_model import LocalChatModel
+from copaw.local_models.tag_parser import parse_tool_calls_from_text
+
+
+class DummyLocalBackend(LocalBackend):
+ def __init__(
+ self,
+ model_path: str = "",
+ *,
+ stream_chunks: list[dict[str, Any]] | None = None,
+ **_: Any,
+ ) -> None:
+ self._stream_chunks = stream_chunks or []
+ self._loaded = True
+
+ def chat_completion(
+ self,
+ messages: list[dict],
+ tools: list[dict] | None = None,
+ tool_choice: str | None = None,
+ structured_model: Any = None,
+ **kwargs: Any,
+ ) -> dict:
+ return {"choices": [], "usage": None}
+
+ def chat_completion_stream(
+ self,
+ messages: list[dict],
+ tools: list[dict] | None = None,
+ tool_choice: str | None = None,
+ **kwargs: Any,
+ ):
+ yield from self._stream_chunks
+
+ def unload(self) -> None:
+ self._loaded = False
+
+ @property
+ def is_loaded(self) -> bool:
+ return self._loaded
+
+
+def _make_stream_chunk(tool_calls: list[dict[str, Any]]) -> dict[str, Any]:
+ return {
+ "choices": [
+ {
+ "delta": {
+ "content": None,
+ "reasoning_content": None,
+ "tool_calls": tool_calls,
+ },
+ },
+ ],
+ }
+
+
+def test_parse_tool_calls_from_text_supports_openai_function_format() -> None:
+ tool_call = {
+ "id": "call_abc123",
+ "type": "function",
+ "function": {
+ "name": "execute_shell_command",
+ "arguments": json.dumps({"command": "ls -la"}),
+ },
+ }
+ text = (
+ "prefix\n"
+ f"\n{json.dumps(tool_call)}\n\n"
+ "suffix"
+ )
+
+ parsed = parse_tool_calls_from_text(text)
+
+ assert parsed.text_before == "prefix"
+ assert parsed.text_after == "suffix"
+ assert len(parsed.tool_calls) == 1
+ assert parsed.tool_calls[0].id == "call_abc123"
+ assert parsed.tool_calls[0].name == "execute_shell_command"
+ assert parsed.tool_calls[0].arguments == {"command": "ls -la"}
+ assert parsed.tool_calls[0].raw_arguments == "{\"command\": \"ls -la\"}"
+
+
+def test_stream_response_waits_for_non_empty_tool_name() -> None:
+ backend = DummyLocalBackend(
+ stream_chunks=[
+ _make_stream_chunk(
+ [
+ {
+ "index": 0,
+ "id": "call_stream",
+ "function": {"arguments": '{"command": '},
+ },
+ ],
+ ),
+ _make_stream_chunk(
+ [
+ {
+ "index": 0,
+ "function": {
+ "name": "execute_shell_command",
+ "arguments": '"ls -la"}',
+ },
+ },
+ ],
+ ),
+ ],
+ )
+ model = LocalChatModel("dummy", backend, stream=True)
+
+ async def _collect_responses() -> list[Any]:
+ responses = []
+ async for response in model._stream_response(
+ messages=[],
+ tools=None,
+ tool_choice=None,
+ start_datetime=datetime.now(),
+ ):
+ responses.append(response)
+ return responses
+
+ responses = asyncio.run(_collect_responses())
+
+ tool_blocks = [
+ block
+ for response in responses
+ for block in response.content
+ if block.get("type") == "tool_use"
+ ]
+
+ assert tool_blocks
+ assert [block["name"] for block in tool_blocks] == ["execute_shell_command"]
+ assert tool_blocks[0]["id"] == "call_stream"
+ assert tool_blocks[0]["input"] == {"command": "ls -la"}
+ assert tool_blocks[0]["raw_input"] == '{"command": "ls -la"}'