Agent Zero AI framework

stop tool dispatch at first completed json object

Tool execution no longer waits for the full streamed assistant text. We now detect the first explicitly closed top-level JSON object, freeze that snapshot as the canonical tool request, and stop the model stream there for dispatch. To make that safe, DirtyJson completion semantics are tightened so completed=true only means the root object was explicitly closed, not that parsing hit end of file. I also restricted the new extraction path to object roots only, since tool calls are always brace-delimited objects, and added tests for parser completion and early stream stop.

Alessandro committed Apr 3, 2026 at 16:56 UTC 5a2223596a4aaea93d24e628d15c9d2d30b8ec35
6 files changed +256 -40
agent.py
+25 -1
@@ -388,6 +388,7 @@ class Agent:
388 self.context.streaming_agent = self # mark self as current streamer
389 self.loop_data.iteration += 1
390 self.loop_data.params_temporary = {} # clear temporary params
391 + last_response_stream_full = ""
392
393 # call message_loop_start extensions
394 await extension.call_extensions_async(
@@ -425,12 +426,32 @@ class Agent:
426 await self.handle_reasoning_stream(stream_data["full"])
427
428 async def stream_callback(chunk: str, full: str):
429 + nonlocal last_response_stream_full
430 await self.handle_intervention()
431 # output the agent response stream
432 if chunk == full:
433 printer.print("Response: ") # start of response
434 # Pass chunk and full data to extensions for processing
435 stream_data = {"chunk": chunk, "full": full}
436 + stop_response: str | None = None
437 +
438 + snapshot = extract_tools.extract_json_root_string(full)
439 + if snapshot:
440 + parsed_snapshot = extract_tools.json_parse_dirty(snapshot)
441 + if parsed_snapshot is not None:
442 + try:
443 + await self.validate_tool_request(parsed_snapshot)
444 + except Exception:
445 + pass
446 + else:
447 + previous_full = last_response_stream_full
448 + stream_data["full"] = snapshot
449 + if snapshot.startswith(previous_full):
450 + stream_data["chunk"] = snapshot[len(previous_full) :]
451 + else:
452 + stream_data["chunk"] = snapshot
453 + stop_response = snapshot
454 +
455 await extension.call_extensions_async(
456 "response_stream_chunk",
457 self,
@@ -442,6 +463,9 @@ class Agent:
463 printer.stream(stream_data["chunk"])
464 # Use the potentially modified full text for downstream processing
465 await self.handle_response_stream(stream_data["full"])
466 + last_response_stream_full = stream_data["full"]
467 + if stop_response is not None:
468 + return stop_response
469
470 # call main LLM
471 agent_response, _reasoning = await self.call_chat_model(
@@ -770,7 +794,7 @@ class Agent:
794 async def call_chat_model(
795 self,
796 messages: list[BaseMessage],
773 - response_callback: Callable[[str, str], Awaitable[None]] | None = None,
797 + response_callback: Callable[[str, str], Awaitable[str | None]] | None = None,
798 reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
799 background: bool = False,
800 explicit_caching: bool = True,
helpers/dirty_json.py
+16 -8
@@ -28,10 +28,10 @@ class DirtyJson:
28 self.completed = False
29 self._parsing_started = False
30
31 - def _pop_stack(self):
32 - """Pop from the parsing stack and mark completed if root structure is closed."""
31 + def _pop_stack(self, root_closed: bool = False):
32 + """Pop from the parsing stack and mark completed only on an explicit root close."""
33 self.stack.pop()
34 - if self._parsing_started and not self.stack:
34 + if root_closed and self._parsing_started and not self.stack:
35 self.completed = True
36
37 @staticmethod
@@ -103,6 +103,8 @@ class DirtyJson:
103 self._advance()
104
105 def _parse(self):
106 + if self.completed and not self.stack:
107 + return
108 if self.result is None:
109 self.result = self._parse_value()
110 else:
@@ -110,6 +112,8 @@ class DirtyJson:
112
113 def _continue_parsing(self):
114 while self.current_char is not None:
115 + if self.completed and not self.stack:
116 + return
117 if isinstance(self.result, dict):
118 self._parse_object_content()
119 elif isinstance(self.result, list):
@@ -122,7 +126,9 @@ class DirtyJson:
126 def _parse_value(self):
127 self._skip_whitespace()
128 if self.current_char == "{":
125 - if self._peek(1) == "{": # Handle {{
129 + # Only treat doubled braces as a wrapper at the root; nested objects
130 + # must keep their closing braces paired correctly.
131 + if not self.stack and self._peek(1) == "{": # Handle {{
132 self._advance(2)
133 return self._parse_object()
134 elif self.current_char == "[":
@@ -169,11 +175,13 @@ class DirtyJson:
175 while self.current_char is not None:
176 self._skip_whitespace()
177 if self.current_char == "}":
172 - if self._peek(1) == "}": # Handle }}
178 + # Root-level wrapper outputs may end in "}}"; nested objects must
179 + # still close one brace at a time.
180 + if len(self.stack) == 1 and self._peek(1) == "}": # Handle }}
181 self._advance(2)
182 else:
183 self._advance()
176 - self._pop_stack()
184 + self._pop_stack(root_closed=True)
185 return
186 if self.current_char is None:
187 self._pop_stack()
@@ -234,7 +242,7 @@ class DirtyJson:
242 self._skip_whitespace()
243 if self.current_char == "]":
244 self._advance()
237 - self._pop_stack()
245 + self._pop_stack(root_closed=True)
246 return
247 value = self._parse_value()
248 self.stack[-1].append(value)
@@ -246,7 +254,7 @@ class DirtyJson:
254 if self.current_char is None or self.current_char == "]":
255 if self.current_char == "]":
256 self._advance()
249 - self._pop_stack()
257 + self._pop_stack(root_closed=True)
258 return
259 elif self.current_char != "]":
260 self._pop_stack()
helpers/extract_tools.py
+22
@@ -19,6 +19,28 @@ def json_parse_dirty(json: str) -> dict[str, Any] | None:
19 return None
20 return None
21
22 +def extract_json_root_string(content: str) -> str | None:
23 + if not content or not isinstance(content, str):
24 + return None
25 +
26 + start = content.find("{")
27 + if start == -1:
28 + return None
29 + first_array = content.find("[")
30 + if first_array != -1 and first_array < start:
31 + return None
32 +
33 + parser = DirtyJson()
34 + try:
35 + parser.parse(content[start:])
36 + except Exception:
37 + return None
38 +
39 + if not parser.completed:
40 + return None
41 +
42 + return content[start : start + parser.index]
43 +
44
45 def extract_json_object_string(content):
46 start = content.find("{")
models.py
+41 -31
@@ -475,7 +475,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
475 system_message="",
476 user_message="",
477 messages: List[BaseMessage] | None = None,
478 - response_callback: Callable[[str, str], Awaitable[None]] | None = None,
478 + response_callback: Callable[[str, str], Awaitable[str | None]] | None = None,
479 reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
480 tokens_callback: Callable[[str, int], Awaitable[None]] | None = None,
481 rate_limiter_callback: (
@@ -526,36 +526,46 @@ class LiteLLMChatWrapper(SimpleChatModel):
526
527 if stream:
528 # iterate over chunks
529 - async for chunk in _completion: # type: ignore
530 - got_any_chunk = True
531 - # parse chunk
532 - parsed = _parse_chunk(chunk)
533 - output = result.add_chunk(parsed)
534 -
535 - # collect reasoning delta and call callbacks
536 - if output["reasoning_delta"]:
537 - if reasoning_callback:
538 - await reasoning_callback(output["reasoning_delta"], result.reasoning)
539 - if tokens_callback:
540 - await tokens_callback(
541 - output["reasoning_delta"],
542 - approximate_tokens(output["reasoning_delta"]),
543 - )
544 - # Add output tokens to rate limiter if configured
545 - if limiter:
546 - limiter.add(output=approximate_tokens(output["reasoning_delta"]))
547 - # collect response delta and call callbacks
548 - if output["response_delta"]:
549 - if response_callback:
550 - await response_callback(output["response_delta"], result.response)
551 - if tokens_callback:
552 - await tokens_callback(
553 - output["response_delta"],
554 - approximate_tokens(output["response_delta"]),
555 - )
556 - # Add output tokens to rate limiter if configured
557 - if limiter:
558 - limiter.add(output=approximate_tokens(output["response_delta"]))
529 + stop_response: str | None = None
530 + try:
531 + async for chunk in _completion: # type: ignore
532 + got_any_chunk = True
533 + # parse chunk
534 + parsed = _parse_chunk(chunk)
535 + output = result.add_chunk(parsed)
536 +
537 + # collect reasoning delta and call callbacks
538 + if output["reasoning_delta"]:
539 + if reasoning_callback:
540 + await reasoning_callback(output["reasoning_delta"], result.reasoning)
541 + if tokens_callback:
542 + await tokens_callback(
543 + output["reasoning_delta"],
544 + approximate_tokens(output["reasoning_delta"]),
545 + )
546 + # Add output tokens to rate limiter if configured
547 + if limiter:
548 + limiter.add(output=approximate_tokens(output["reasoning_delta"]))
549 + # collect response delta and call callbacks
550 + if output["response_delta"]:
551 + if response_callback:
552 + stop_response = await response_callback(
553 + output["response_delta"], result.response
554 + )
555 + if tokens_callback:
556 + await tokens_callback(
557 + output["response_delta"],
558 + approximate_tokens(output["response_delta"]),
559 + )
560 + # Add output tokens to rate limiter if configured
561 + if limiter:
562 + limiter.add(output=approximate_tokens(output["response_delta"]))
563 + if stop_response is not None:
564 + result.response = stop_response
565 + break
566 + finally:
567 + if stop_response is not None and hasattr(_completion, "aclose"):
568 + await _completion.aclose() # type: ignore[attr-defined]
569
570 # non-stream response
571 else:
tests/test_dirty_json.py new
+56
@@ -0,0 +1,56 @@
1 +from __future__ import annotations
2 +
3 +import sys
4 +from pathlib import Path
5 +
6 +import pytest
7 +
8 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 +if str(PROJECT_ROOT) not in sys.path:
10 + sys.path.insert(0, str(PROJECT_ROOT))
11 +
12 +from helpers.dirty_json import DirtyJson
13 +
14 +
15 +@pytest.mark.parametrize(
16 + ("payload", "expected"),
17 + [
18 + (
19 + '{"tool_name":"x","tool_args":{}}',
20 + {"tool_name": "x", "tool_args": {}},
21 + ),
22 + ("[1, 2, 3]", [1, 2, 3]),
23 + ],
24 +)
25 +def test_completed_true_when_root_is_explicitly_closed(payload, expected) -> None:
26 + parser = DirtyJson()
27 +
28 + assert parser.parse(payload) == expected
29 + assert parser.completed is True
30 +
31 +
32 +def test_completed_false_when_root_hits_eof_before_closing() -> None:
33 + parser = DirtyJson()
34 +
35 + assert parser.parse('{"tool_name":"x","tool_args":{}') == {
36 + "tool_name": "x",
37 + "tool_args": {},
38 + }
39 + assert parser.completed is False
40 +
41 +
42 +def test_completed_remains_true_after_trailing_content() -> None:
43 + parser = DirtyJson()
44 +
45 + assert parser.feed('{"tool_name":"x","tool_args":{}}') == {
46 + "tool_name": "x",
47 + "tool_args": {},
48 + }
49 + assert parser.completed is True
50 +
51 + assert parser.feed(" trailing noise") == {
52 + "tool_name": "x",
53 + "tool_args": {},
54 + }
55 +
56 + assert parser.completed is True
tests/test_stream_tool_early_stop.py new
+96
@@ -0,0 +1,96 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +import pytest
5 +
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +import models
12 +from helpers import extract_tools
13 +
14 +
15 +def _chunk(content: str) -> dict:
16 + return {"choices": [{"delta": {"content": content}, "message": {}}]}
17 +
18 +
19 +class _AsyncChunkStream:
20 + def __init__(self, chunks: list[dict]):
21 + self._chunks = chunks
22 + self.index = 0
23 +
24 + def __aiter__(self):
25 + return self
26 +
27 + async def __anext__(self):
28 + if self.index >= len(self._chunks):
29 + raise StopAsyncIteration
30 + chunk = self._chunks[self.index]
31 + self.index += 1
32 + return chunk
33 +
34 +
35 +def test_extract_json_root_string_returns_canonical_snapshot():
36 + text = (
37 + 'prefix {"tool_name":"response","tool_args":{"text":"brace } inside"}} '
38 + "trailing noise"
39 + )
40 +
41 + root = extract_tools.extract_json_root_string(text)
42 +
43 + assert root == '{"tool_name":"response","tool_args":{"text":"brace } inside"}}'
44 + assert extract_tools.json_parse_dirty(root)["tool_args"]["text"] == "brace } inside"
45 + assert extract_tools.extract_json_root_string(
46 + '{"tool_name":"response","tool_args":{"text":"missing"'
47 + ) is None
48 + assert extract_tools.extract_json_root_string('[{"tool_name":"response"}]') is None
49 +
50 +
51 +@pytest.mark.asyncio
52 +async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
53 + stream = _AsyncChunkStream(
54 + [
55 + _chunk(
56 + '{"tool_name":"response","tool_args":{"text":"hello"}} trailing text'
57 + ),
58 + _chunk(" unreachable"),
59 + ]
60 + )
61 +
62 + async def fake_acompletion(*args, **kwargs):
63 + assert kwargs["stream"] is True
64 + return stream
65 +
66 + async def fake_rate_limiter(*args, **kwargs):
67 + return None
68 +
69 + monkeypatch.setattr(models, "acompletion", fake_acompletion)
70 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
71 +
72 + wrapper = models.LiteLLMChatWrapper(
73 + model="test-model",
74 + provider="openai",
75 + model_config=None,
76 + )
77 +
78 + seen: list[tuple[str, str]] = []
79 +
80 + async def response_callback(chunk: str, full: str):
81 + seen.append((chunk, full))
82 + snapshot = extract_tools.extract_json_root_string(full)
83 + if snapshot:
84 + return snapshot
85 + return None
86 +
87 + response, reasoning = await wrapper.unified_call(
88 + messages=[],
89 + response_callback=response_callback,
90 + )
91 +
92 + assert response == '{"tool_name":"response","tool_args":{"text":"hello"}}'
93 + assert reasoning == ""
94 + assert stream.index == 1
95 + assert len(seen) == 1
96 + assert seen[0][1] == '{"tool_name":"response","tool_args":{"text":"hello"}} trailing text'