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 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 01:24:24 +02:00
co-authored by Claude Sonnet 5.5
parent a103fa0611
commit 0c2151ad08
2 changed files with 55 additions and 3 deletions
+23 -3
View File
@@ -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}
+32
View File
@@ -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):