From 0c2151ad08808a23826998e347e004aa16eb159c Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 13 Oct 2026 01:24:24 +0200 Subject: [PATCH] Streaming tool calls tolerate argument fragments under a fresh index; empty or invalid tool arguments repaired in history (fixes HTTP 400 cascade) Co-Authored-By: Claude Sonnet 5.5 --- tai.py | 26 +++++++++++++++++++++++--- test_tai.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/tai.py b/tai.py index 4f0c327..0918401 100755 --- a/tai.py +++ b/tai.py @@ -100,7 +100,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.36.0" +VERSION = "1.36.1" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -1033,6 +1033,9 @@ def clean_history(messages): message = dict(message, tool_calls=good or None) if not good and not message.get("content"): continue + fixed = [dict(call, function=dict(call["function"], arguments=tool_args_valid(call["function"].get("arguments")))) if isinstance(call.get("function"), dict) and tool_args_valid(call["function"].get("arguments")) != call["function"].get("arguments") else call for call in (message.get("tool_calls") or [])] + if fixed: + message = dict(message, tool_calls=fixed) kept.append(message) return [message for message in kept if not (message.get("role") == "tool" and message.get("tool_call_id") in dropped)] @@ -1044,6 +1047,15 @@ def transient_failure(exc): return "CLI not found" not in str(exc) +def tool_args_valid(arguments): + """Arguments text strict backends accept: empty or non-JSON becomes an empty object.""" + try: + json.loads(arguments) + except (TypeError, ValueError): + return "{}" + return arguments + + def tool_args_text(raw_arguments): if not raw_arguments: return "{}" @@ -5580,6 +5592,7 @@ class ChatClient: content_parts = [] reasoning_parts = [] calls = {} + last_pos = 0 try: for raw in response: line = raw.decode("utf-8", "replace").strip() @@ -5604,10 +5617,16 @@ class ChatClient: if think: reasoning_parts.append(think) for call in delta.get("tool_calls") or []: - slot = calls.setdefault(call.get("index", 0), {"id": "", "name": "", "arguments": ""}) + func = call.get("function") or {} + pos = call.get("index", 0) + if pos not in calls and calls and not call.get("id") and not func.get("name"): + # Off-spec streams send an argument fragment under a fresh index + # with no id or name: it continues the open call. + pos = last_pos + last_pos = pos + slot = calls.setdefault(pos, {"id": "", "name": "", "arguments": ""}) if call.get("id"): slot["id"] = call["id"] - func = call.get("function") or {} if func.get("name"): slot["name"] += func["name"] if func.get("arguments"): @@ -5620,6 +5639,7 @@ class ChatClient: ordered = [calls[pos] for pos in sorted(calls)] for slot in ordered: slot["name"] = tool_name_clean(slot["name"]) + slot["arguments"] = tool_args_valid(slot["arguments"]) if not slot["id"]: slot["id"] = uuid.uuid4().hex return {"role": "assistant", "content": "".join(content_parts), "reasoning": "".join(reasoning_parts), "tool_calls": ordered, "backend": label} diff --git a/test_tai.py b/test_tai.py index b2449e5..e7b3eab 100644 --- a/test_tai.py +++ b/test_tai.py @@ -4809,6 +4809,38 @@ class RetrySummaryTests(unittest.TestCase): self.assertEqual(cleaned[2]["tool_call_id"], "b") self.assertEqual(len(history[1]["tool_calls"]), 2) + def test_clean_history_repairs_empty_arguments(self): + history = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "a", "type": "function", "function": {"name": "shell", "arguments": ""}}]}, + {"role": "tool", "tool_call_id": "a", "content": "x"}, + ] + cleaned = tai.clean_history(history) + self.assertEqual(cleaned[1]["tool_calls"][0]["function"]["arguments"], "{}") + self.assertEqual(history[1]["tool_calls"][0]["function"]["arguments"], "") + + def test_streaming_joins_argument_fragment_under_fresh_index(self): + chunks = [ + {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "shell", "arguments": ""}}]}}]}, + {"choices": [{"delta": {"tool_calls": [{"index": 1, "function": {"arguments": "{\"command\":\"ls\"}"}}]}}]}, + ] + body = "".join("data: %s\n\n" % json.dumps(chunk) for chunk in chunks) + "data: [DONE]\n\n" + + class Response: + def __iter__(self): + return iter(body.encode("utf-8").splitlines(True)) + + def close(self): + pass + + chat = tai.ChatClient.__new__(tai.ChatClient) + with mock.patch("urllib.request.urlopen", return_value=Response()): + reply = chat.streaming("http://x/chat/completions", "", {"model": "m"}, "t", lambda piece: None) + self.assertEqual(len(reply["tool_calls"]), 1) + self.assertEqual(reply["tool_calls"][0]["name"], "shell") + self.assertEqual(reply["tool_calls"][0]["arguments"], "{\"command\":\"ls\"}") + class TuiLayoutTests(unittest.TestCase): def make_app(self, rows=10, cols=20):