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:
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user