fix(tools): harden malformed text tool handling

Require complete standalone text requests for execution while accepting supported text-tool formats. Route fenced and structurally broken tool intent through the existing misformat repair path so streamed chats do not remain in a thinking state.

Alessandro committed Jul 23, 2026 at 11:34 UTC 6b7302f6642e9f4e68f8bb8afbfc8b2550511c06
10 files changed +480 -73
agent.py
+24 -28
@@ -447,24 +447,15 @@ class Agent:
447 printer.print("Response: ") # start of response
448 # Pass chunk and full data to extensions for processing
449 stream_data = {"chunk": chunk, "full": full}
450 - stop_response: str | None = None
451 -
452 - snapshot = extract_tools.extract_json_root_string(full)
453 - if snapshot:
454 - parsed_snapshot = extract_tools.json_parse_dirty(snapshot)
455 - if parsed_snapshot is not None:
456 - try:
457 - await self.validate_tool_request(parsed_snapshot)
458 - except Exception:
459 - pass
460 - else:
461 - previous_full = last_response_stream_full
462 - stream_data["full"] = snapshot
463 - if snapshot.startswith(previous_full):
464 - stream_data["chunk"] = snapshot[len(previous_full) :]
465 - else:
466 - stream_data["chunk"] = snapshot
467 - stop_response = snapshot
450 + tool_request = extract_tools.extract_tool_request(full)
451 + if tool_request is not None:
452 + try:
453 + await self.validate_tool_request(tool_request)
454 + except Exception:
455 + pass
456 + else:
457 + await self.handle_response_stream(full)
458 + return full.strip()
459
460 await extension.call_extensions_async(
461 "response_stream_chunk",
@@ -478,8 +469,6 @@ class Agent:
469 # Use the potentially modified full text for downstream processing
470 await self.handle_response_stream(stream_data["full"])
471 last_response_stream_full = stream_data["full"]
481 - if stop_response is not None:
482 - return stop_response
472
473 # call main LLM
474 llm_result = await self.call_chat_model_turn(
@@ -1123,13 +1112,18 @@ class Agent:
1112 return None
1113 if llm_result.builtin_items and not llm_result.response:
1114 return None
1126 - if (
1127 - llm_result.mode == "responses"
1128 - and llm_result.response
1129 - and extract_tools.json_parse_dirty(llm_result.response) is None
1130 - ):
1131 - return llm_result.response
1132 - return await self.process_tools(llm_result.response)
1115 + message = llm_result.response
1116 + if not message and llm_result.reasoning:
1117 + if (
1118 + extract_tools.extract_tool_request(llm_result.reasoning) is not None
1119 + or extract_tools.is_misformatted_tool_request(llm_result.reasoning)
1120 + ):
1121 + message = llm_result.reasoning
1122 + if extract_tools.extract_tool_request(message) is None:
1123 + if extract_tools.is_misformatted_tool_request(message):
1124 + return await self.process_tools(message)
1125 + return message
1126 + return await self.process_tools(message)
1127
1128 async def _execute_tool_request(
1129 self,
@@ -1408,7 +1402,7 @@ class Agent:
1402 @extension.extensible
1403 async def process_tools(self, msg: str):
1404 # search for tool usage requests in agent message
1411 - tool_request = extract_tools.json_parse_dirty(msg)
1405 + tool_request = extract_tools.extract_tool_request(msg)
1406
1407 raw_tool_name = ""
1408 tool_args = {}
@@ -1417,6 +1411,7 @@ class Agent:
1411 # block was found - the misformat warning path below handles that.
1412 if tool_request is not None:
1413 try:
1414 + await self.validate_tool_request(tool_request)
1415 raw_tool_name, tool_args = extract_tools.normalize_tool_request(
1416 tool_request
1417 )
@@ -1458,6 +1453,7 @@ class Agent:
1453 )
1454
1455 if tool:
1456 + tool.args = tool_args
1457 self.loop_data.current_tool = tool # type: ignore
1458 try:
1459 await self.handle_intervention()
extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py
+1 -1
@@ -23,7 +23,7 @@ class LogPlainResponses(Extension):
23 message = call_args[1]
24 if not isinstance(message, str) or not message:
25 return
26 - if extract_tools.json_parse_dirty(message) is not None:
26 + if extract_tools.extract_tool_request(message) is not None:
27 return
28
29 params = getattr(getattr(self.agent, "loop_data", None), "params_temporary", None)
extensions/python/response_stream/_20_live_response.py
+4 -8
@@ -1,4 +1,5 @@
1 from helpers import persist_chat, tokens
2 +from helpers import extract_tools
3 from helpers.extension import Extension
4 from agent import LoopData
5 import asyncio
@@ -19,13 +20,8 @@ class LiveResponse(Extension):
20 return
21
22 try:
22 - if (
23 - not "tool_name" in parsed
24 - or parsed["tool_name"] != "response"
25 - or "tool_args" not in parsed
26 - or "text" not in parsed["tool_args"]
27 - or not parsed["tool_args"]["text"]
28 - ):
23 + tool_name, tool_args = extract_tools.normalize_tool_request(parsed)
24 + if tool_name != "response" or not tool_args.get("text"):
25 return # not a response
26
27 # create log message and store it in loop data temporary params
@@ -43,6 +39,6 @@ class LiveResponse(Extension):
39
40 # update log message
41 log_item = loop_data.params_temporary["log_item_response"]
46 - log_item.update(content=parsed["tool_args"]["text"])
42 + log_item.update(content=tool_args["text"])
43 except Exception as e:
44 pass
helpers/extract_tools.py
+71
@@ -20,17 +20,88 @@ def json_parse_dirty(json: str) -> dict[str, Any] | None:
20 return first_data
21
22
23 +def extract_tool_request(content: str) -> dict[str, Any] | None:
24 + if not content or not isinstance(content, str):
25 + return None
26 +
27 + content = content.strip()
28 + root = extract_json_root_string(content)
29 + if root != content:
30 + return None
31 +
32 + request = _parse_json_root_object(root)
33 + return request if request is not None and _is_tool_request(request) else None
34 +
35 +
36 +def is_misformatted_tool_request(content: str) -> bool:
37 + if not content or not isinstance(content, str):
38 + return False
39 +
40 + content = content.strip()
41 + for fenced_content in re.findall(
42 + r"```(?:json)?\s*(.*?)```", content, flags=re.IGNORECASE | re.DOTALL
43 + ):
44 + request = json_parse_dirty(fenced_content)
45 + if isinstance(request, dict) and _is_tool_request(request):
46 + return True
47 +
48 + if (
49 + not content.endswith("}")
50 + or re.match(r'^\{\s*"thoughts"\s*:', content) is None
51 + ):
52 + return False
53 +
54 + request = json_parse_dirty(content)
55 + thoughts = request.get("thoughts") if isinstance(request, dict) else None
56 + thoughts_text = (
57 + "\n".join(thought for thought in thoughts if isinstance(thought, str))
58 + if isinstance(thoughts, list)
59 + else ""
60 + )
61 + return (
62 + isinstance(thoughts, list)
63 + and all(
64 + f'{field}\":' in thoughts_text
65 + for field in ("headline", "tool_name", "tool_args")
66 + )
67 + )
68 +
69 +
70 def normalize_tool_request(tool_request: Any) -> tuple[str, dict]:
71 if not isinstance(tool_request, dict):
72 raise ValueError("Tool request must be a dictionary")
73 + if (
74 + not tool_request.get("tool_name")
75 + and not tool_request.get("tool")
76 + and "actions" in tool_request
77 + ):
78 + actions = tool_request["actions"]
79 + # Text tool calls allow one request per turn; do not silently discard extras.
80 + if (
81 + not isinstance(actions, list)
82 + or len(actions) != 1
83 + or not isinstance(actions[0], dict)
84 + ):
85 + raise ValueError(
86 + "Tool request actions wrapper must contain exactly one dictionary"
87 + )
88 + tool_request = actions[0]
89 +
90 tool_name = tool_request.get("tool_name")
91 if not tool_name or not isinstance(tool_name, str):
92 tool_name = tool_request.get("tool")
93 + if (
94 + (not tool_name or not isinstance(tool_name, str))
95 + and tool_request.get("type") == "function"
96 + ):
97 + tool_name = tool_request.get("name")
98 if not tool_name or not isinstance(tool_name, str):
99 raise ValueError("Tool request must have a tool_name (type string) field")
100 tool_args = tool_request.get("tool_args")
101 if not isinstance(tool_args, dict):
102 tool_args = tool_request.get("args")
103 + if not isinstance(tool_args, dict) and tool_request.get("type") == "function":
104 + tool_args = tool_request.get("parameters")
105 if not isinstance(tool_args, dict):
106 raise ValueError("Tool request must have a tool_args (type dictionary) field")
107 tool_args = dict(tool_args)
helpers/extract_tools.py.dox.md
+7 -2
@@ -12,6 +12,8 @@
12 - `extract_tools.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 - Top-level functions:
14 - `json_parse_dirty(json: str) -> dict[str, Any] | None`
15 +- `extract_tool_request(content: str) -> dict[str, Any] | None`
16 +- `is_misformatted_tool_request(content: str) -> bool`
17 - `normalize_tool_request(tool_request: Any) -> tuple[str, dict]`
18 - `extract_json_root_string(content: str) -> str | None`
19 - `extract_json_root_strings(content: str) -> list[str]`
@@ -24,8 +26,11 @@
26 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
27 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
28 - Observed side-effect areas: settings/state persistence.
27 -- Dirty parsing scans complete JSON object roots in prose and prefers the first object that normalizes as a valid tool request, so a leading text preamble or incidental non-tool object does not force a misformat warning when a valid tool call follows.
28 -- Streaming tool snapshots use the same valid-tool preference through `extract_json_root_string`, while preserving the first complete object fallback when no valid tool-call object is present.
29 +- Dirty parsing scans complete JSON object roots in prose and prefers the first object that normalizes as a valid tool request for permissive repair and legacy callers.
30 + Normalization accepts canonical `tool_name`/`tool_args`, legacy `tool`/`args`, native `type="function"` `name`/`parameters`, and a single-item `actions` wrapper; malformed or multi-action wrappers are rejected.
31 +- `extract_tool_request` is the execution boundary: it accepts a request only when the complete trimmed content is one valid tool object. Plain text, ordinary JSON, and tool-shaped JSON embedded in prose remain final text.
32 +- `is_misformatted_tool_request` identifies either a tool request wrapped in a JSON code fence or a complete Agent Zero envelope that starts with `thoughts` and whose dirty parser has absorbed `headline`, `tool_name`, and `tool_args` into that list. It routes that output to the existing repair prompt without executing it.
33 +- Streaming tool snapshots use `extract_tool_request`; the permissive root helpers remain available for repair and legacy callers, not tool execution.
34 - Root extraction ignores objects nested inside an open parent object, so streamed wrapper tools such as `parallel` cannot stop early on the first nested `tool_calls` item.
35 - Imported dependency areas include: `dirty_json`, `helpers.modules`, `re`, `regex`, `typing`.
36
models.py
+1 -6
@@ -738,14 +738,9 @@ class LiteLLMChatWrapper(SimpleChatModel):
738 output["response_delta"]
739 )
740 )
741 - if (
742 - stop_response is not None
743 - and not transport.policy.using_responses
744 - ):
745 - result.response = stop_response
746 - break
741 if stop_response is not None:
742 result.response = stop_response
743 + break
744 else:
745 parsed = await transport.acomplete()
746 output = result.add_chunk(parsed)
tests/test_plain_response_logging.py
+41
@@ -1,6 +1,8 @@
1 +import pytest
2 from types import SimpleNamespace
3
4 from helpers.log import Log
5 +from extensions.python.response_stream._20_live_response import LiveResponse
6 from extensions.python._functions.agent.Agent.hist_add_ai_response.end._10_log_plain_responses import (
7 LogPlainResponses,
8 )
@@ -50,6 +52,20 @@ def test_responses_tool_json_keeps_generating_log_as_agent_step():
52 assert "log_item_response" not in agent.loop_data.params_temporary
53
54
55 +def test_responses_plain_json_completion_finishes_generating_log_as_response():
56 + agent, item = _agent_with_generating_log()
57 + data = {
58 + "args": (agent, '{"status":"ok"}'),
59 + "kwargs": {"id": "msg-1", "llm_result": SimpleNamespace(mode="responses")},
60 + }
61 +
62 + LogPlainResponses(agent=agent).execute(data=data)
63 +
64 + assert item.type == "response"
65 + assert item.content == '{"status":"ok"}'
66 + assert agent.loop_data.params_temporary["log_item_response"] is item
67 +
68 +
69 def test_responses_plain_text_completion_does_not_replace_live_response_log():
70 agent, item = _agent_with_generating_log()
71 live_response = Log().log(type="response", content="Already live")
@@ -64,3 +80,28 @@ def test_responses_plain_text_completion_does_not_replace_live_response_log():
80 assert item.type == "agent"
81 assert item.content == ""
82 assert agent.loop_data.params_temporary["log_item_response"] is live_response
83 +
84 +
85 +@pytest.mark.asyncio
86 +async def test_live_response_renders_single_action_wrapper():
87 + log = Log()
88 + generating = log.log(type="agent", id="msg-1")
89 + loop_data = SimpleNamespace(params_temporary={"log_item_generating": generating})
90 + agent = SimpleNamespace(
91 + context=SimpleNamespace(log=log),
92 + agent_name="A0",
93 + )
94 +
95 + await LiveResponse(agent=agent).execute(
96 + loop_data=loop_data,
97 + parsed={
98 + "actions": [
99 + {"tool_name": "response", "tool_args": {"text": "wrapper works"}}
100 + ]
101 + },
102 + )
103 +
104 + response = loop_data.params_temporary["log_item_response"]
105 + assert response.type == "response"
106 + assert response.content == "wrapper works"
107 + assert response.id == "msg-1"
tests/test_responses_architecture.py
+200 -3
@@ -1,3 +1,4 @@
1 +import asyncio
2 import sys
3 from pathlib import Path
4
@@ -11,7 +12,7 @@ if str(PROJECT_ROOT) not in sys.path:
12
13 import models
14 from agent import Agent, AgentConfig, AgentContextType, LoopData
14 -from helpers import history, litellm_transport
15 +from helpers import extract_tools, history, litellm_transport
16 from helpers.log import Log
17 from helpers.llm_result import LLMResult, result_from_metadata
18 from helpers.persist_chat import _collect_response_ids
@@ -43,6 +44,28 @@ class _AsyncEventStream:
44 self.closed = True
45
46
47 +class _StallingAsyncEventStream:
48 + def __init__(self, event: dict):
49 + self.event = event
50 + self.sent = False
51 + self.closed = False
52 + self._stalled = asyncio.Event()
53 +
54 + def __aiter__(self):
55 + return self
56 +
57 + async def __anext__(self):
58 + if not self.sent:
59 + self.sent = True
60 + return self.event
61 + await self._stalled.wait()
62 + raise StopAsyncIteration
63 +
64 + async def aclose(self):
65 + self.closed = True
66 + self._stalled.set()
67 +
68 +
69 def test_llm_result_persists_only_durable_responses_metadata():
70 result = LLMResult.from_response(
71 {
@@ -248,7 +271,7 @@ async def test_transport_downgrades_unsupported_builtin_tools(monkeypatch):
271
272
273 @pytest.mark.asyncio
251 -async def test_unified_turn_keeps_stream_open_to_capture_response_id(monkeypatch):
274 +async def test_unified_turn_captures_response_id_without_stop_request(monkeypatch):
275 stream = _AsyncEventStream(
276 [
277 {
@@ -303,7 +326,7 @@ async def test_unified_turn_keeps_stream_open_to_capture_response_id(monkeypatch
326 )
327
328 async def response_callback(chunk: str, full: str):
306 - return full
329 + return None
330
331 result = await wrapper.unified_turn(
332 messages=[HumanMessage(content="hi")],
@@ -316,6 +339,46 @@ async def test_unified_turn_keeps_stream_open_to_capture_response_id(monkeypatch
339 assert result.function_calls[0].call_id == "call_1"
340
341
342 +@pytest.mark.asyncio
343 +async def test_unified_turn_stops_responses_stream_after_callback_stop(monkeypatch):
344 + message = (
345 + '{"thoughts":["test"],"actions":['
346 + '{"tool_name":"response","tool_args":{"text":"ok"}}]}'
347 + )
348 + stream = _StallingAsyncEventStream(
349 + {"type": "response.output_text.delta", "delta": message}
350 + )
351 +
352 + async def fake_aresponses(*args, **kwargs):
353 + return stream
354 +
355 + async def fake_rate_limiter(*args, **kwargs):
356 + return None
357 +
358 + monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
359 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
360 +
361 + wrapper = models.LiteLLMChatWrapper(
362 + model="test-model",
363 + provider="openai",
364 + model_config=None,
365 + )
366 +
367 + async def response_callback(chunk: str, full: str):
368 + return full if extract_tools.extract_tool_request(full) else None
369 +
370 + result = await asyncio.wait_for(
371 + wrapper.unified_turn(
372 + messages=[HumanMessage(content="hi")],
373 + response_callback=response_callback,
374 + ),
375 + timeout=1,
376 + )
377 +
378 + assert result.response == message
379 + assert stream.closed is True
380 +
381 +
382 def test_collect_response_ids_from_agent_state_and_history_metadata():
383 payload = {
384 "agents": [
@@ -411,3 +474,137 @@ async def test_agent_executes_native_responses_function_call_and_records_output(
474 "output": "done:a0",
475 }
476 ]
477 +
478 +
479 +@pytest.mark.asyncio
480 +async def test_agent_routes_only_complete_text_tool_requests() -> None:
481 + agent = object.__new__(Agent)
482 + processed: list[str] = []
483 +
484 + async def log_builtin_items(result):
485 + return None
486 +
487 + async def process_tools(message):
488 + processed.append(message)
489 + return message
490 +
491 + agent._log_response_builtin_items = log_builtin_items
492 + agent.process_tools = process_tools
493 +
494 + tool_request = '{"type":"function","name":"response","parameters":{"text":"ok"}}'
495 + for message in (
496 + "Plain final answer.",
497 + '{"status":"planning"}',
498 + f"Example tool JSON: {tool_request}",
499 + ):
500 + assert await Agent.process_llm_result_tools(
501 + agent, LLMResult.from_chat(response=message)
502 + ) == message
503 + assert processed == []
504 +
505 + assert await Agent.process_llm_result_tools(
506 + agent, LLMResult.from_chat(response=tool_request)
507 + ) == tool_request
508 + assert processed == [tool_request]
509 +
510 + processed.clear()
511 + assert await Agent.process_llm_result_tools(
512 + agent, LLMResult(response="", reasoning=tool_request)
513 + ) == tool_request
514 + assert processed == [tool_request]
515 +
516 + processed.clear()
517 + assert await Agent.process_llm_result_tools(
518 + agent, LLMResult(response="", reasoning='{"status":"planning"}')
519 + ) == ""
520 + assert processed == []
521 +
522 +
523 +@pytest.mark.asyncio
524 +async def test_agent_routes_misformatted_tool_intent_to_repair() -> None:
525 + agent = object.__new__(Agent)
526 + processed: list[str] = []
527 +
528 + async def log_builtin_items(result):
529 + return None
530 +
531 + async def process_tools(message):
532 + processed.append(message)
533 + return None
534 +
535 + agent._log_response_builtin_items = log_builtin_items
536 + agent.process_tools = process_tools
537 +
538 + malformed = (
539 + '{"thoughts":["Plan the work", "Run the tools", '
540 + '"headline":"Save results", "tool_name":"parallel", '
541 + '"tool_args":{"tool_calls":[{"tool_name":"memory_save",'
542 + '"tool_args":{"text":"ok"}}],"wait":true}}'
543 + )
544 +
545 + assert await Agent.process_llm_result_tools(
546 + agent, LLMResult.from_chat(response=malformed)
547 + ) is None
548 + assert processed == [malformed]
549 +
550 + processed.clear()
551 + assert await Agent.process_llm_result_tools(
552 + agent, LLMResult(response="", reasoning=malformed)
553 + ) is None
554 + assert processed == [malformed]
555 +
556 + fenced = (
557 + "I will call the tool.\n\n```json\n"
558 + '{"tool_name":"response","tool_args":{"text":"ok"}}\n```'
559 + )
560 + processed.clear()
561 + assert await Agent.process_llm_result_tools(
562 + agent, LLMResult.from_chat(response=fenced)
563 + ) is None
564 + assert processed == [fenced]
565 +
566 +
567 +@pytest.mark.asyncio
568 +async def test_text_tool_execution_uses_normalized_tool_args(monkeypatch) -> None:
569 + class DummyMCPConfig:
570 + def get_tool(self, agent, tool_name):
571 + return None
572 +
573 + class DummyTool:
574 + def __init__(self):
575 + self.args = {}
576 +
577 + async def before_execution(self, **kwargs):
578 + assert self.args == {"text": "ok"}
579 +
580 + async def execute(self, **kwargs):
581 + assert kwargs == {"text": "ok"}
582 + return Response(message=self.args["text"], break_loop=True)
583 +
584 + async def after_execution(self, response):
585 + return None
586 +
587 + async def no_extension(*args, **kwargs):
588 + return None
589 +
590 + async def no_intervention(*args, **kwargs):
591 + return None
592 +
593 + import agent as agent_module
594 + from helpers import mcp_handler
595 +
596 + monkeypatch.setattr(
597 + mcp_handler.MCPConfig, "get_instance", lambda: DummyMCPConfig()
598 + )
599 + monkeypatch.setattr(agent_module.extension, "call_extensions_async", no_extension)
600 +
601 + tool = DummyTool()
602 + agent = object.__new__(Agent)
603 + agent.data = {}
604 + agent.loop_data = LoopData()
605 + agent.handle_intervention = no_intervention
606 + agent.get_tool = lambda **kwargs: tool
607 +
608 + assert await Agent.process_tools(
609 + agent, '{"actions":[{"tool_name":"response","tool_args":{"text":"ok"}}]}'
610 + ) == "ok"
tests/test_stream_tool_early_stop.py
+50 -24
@@ -239,9 +239,7 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
239 stream = _AsyncChunkStream(
240 [
241 {"type": "response.created"},
242 - _response_event(
243 - '{"tool_name":"response","tool_args":{"text":"hello"}} trailing text'
244 - ),
242 + _response_event('{"tool_name":"response","tool_args":{"text":"hello"}}'),
243 _response_event(" unreachable"),
244 ]
245 )
@@ -273,10 +271,7 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
271
272 async def response_callback(chunk: str, full: str):
273 seen.append((chunk, full))
276 - snapshot = extract_tools.extract_json_root_string(full)
277 - if snapshot:
278 - return snapshot
279 - return None
274 + return full.strip() if extract_tools.extract_tool_request(full) else None
275
276 response, reasoning = await wrapper.unified_call(
277 messages=[],
@@ -288,11 +283,11 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
283 assert stream.index == 2
284 assert stream.closed is True
285 assert len(seen) == 1
291 - assert seen[0][1] == '{"tool_name":"response","tool_args":{"text":"hello"}} trailing text'
286 + assert seen[0][1] == '{"tool_name":"response","tool_args":{"text":"hello"}}'
287
288
289 @pytest.mark.asyncio
295 -async def test_unified_call_stops_after_tool_root_with_incidental_json(monkeypatch):
290 +async def test_unified_call_does_not_stop_for_embedded_tool_json(monkeypatch):
291 stream = _AsyncChunkStream(
292 [
293 {"type": "response.created"},
@@ -331,28 +326,21 @@ async def test_unified_call_stops_after_tool_root_with_incidental_json(monkeypat
326
327 async def response_callback(chunk: str, full: str):
328 seen.append((chunk, full))
334 - snapshot = extract_tools.extract_json_root_string(full)
335 - if not snapshot:
336 - return None
337 - parsed_snapshot = extract_tools.json_parse_dirty(snapshot)
338 - if parsed_snapshot is None:
339 - return None
340 - try:
341 - extract_tools.normalize_tool_request(parsed_snapshot)
342 - except ValueError:
343 - return None
344 - return snapshot
329 + return full.strip() if extract_tools.extract_tool_request(full) else None
330
331 response, reasoning = await wrapper.unified_call(
332 messages=[],
333 response_callback=response_callback,
334 )
335
351 - assert response == '{"tool_name":"response","tool_args":{"text":"ok"}}'
336 + assert response == (
337 + 'Preamble {"note":"not the tool"}.\n'
338 + '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text unreachable'
339 + )
340 assert reasoning == ""
353 - assert stream.index == 3
354 - assert stream.closed is True
355 - assert len(seen) == 2
341 + assert stream.index == 4
342 + assert stream.closed is False
343 + assert len(seen) == 3
344 assert seen[0][1] == 'Preamble {"note":"not the tool"}.\n'
345 assert (
346 seen[1][1]
@@ -437,6 +425,44 @@ async def test_chat_completions_escape_hatch_still_uses_acompletion(monkeypatch)
425 assert calls == ["chat"]
426
427
428 +@pytest.mark.asyncio
429 +async def test_unified_turn_stops_chat_stream_after_text_tool_request(monkeypatch):
430 + message = (
431 + '{"thoughts":["test"],"actions":['
432 + '{"tool_name":"response","tool_args":{"text":"ok"}}]}'
433 + )
434 + stream = _AsyncChunkStream([_chunk(message), _chunk(" unreachable")])
435 +
436 + async def fake_acompletion(*args, **kwargs):
437 + assert kwargs["stream"] is True
438 + return stream
439 +
440 + async def fake_rate_limiter(*args, **kwargs):
441 + return None
442 +
443 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
444 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
445 +
446 + wrapper = models.LiteLLMChatWrapper(
447 + model="test-model",
448 + provider="openai",
449 + model_config=None,
450 + a0_api_mode="chat",
451 + )
452 +
453 + async def response_callback(chunk: str, full: str):
454 + return full if extract_tools.extract_tool_request(full) else None
455 +
456 + result = await wrapper.unified_turn(
457 + messages=[],
458 + response_callback=response_callback,
459 + )
460 +
461 + assert result.response == message
462 + assert stream.index == 1
463 + assert stream.closed is True
464 +
465 +
466 @pytest.mark.asyncio
467 async def test_unified_call_retries_responses_with_high_reasoning(monkeypatch):
468 validation_error = ValueError(
tests/test_tool_request_normalization.py
+81 -1
@@ -9,7 +9,12 @@ 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.extract_tools import normalize_tool_request
12 +from helpers.extract_tools import (
13 + extract_tool_request,
14 + is_misformatted_tool_request,
15 + json_parse_dirty,
16 + normalize_tool_request,
17 +)
18 from helpers import parallel_tools
19
20
@@ -68,6 +73,81 @@ def test_normalize_tool_request_rejects_missing_args() -> None:
73 normalize_tool_request({"tool_name": "response"})
74
75
76 +def test_normalize_tool_request_accepts_native_function_format() -> None:
77 + request = {
78 + "type": "function",
79 + "name": "search_engine",
80 + "parameters": {"query": "latest Agent Zero release"},
81 + }
82 +
83 + assert json_parse_dirty(str(request)) == request
84 + assert normalize_tool_request(request) == (
85 + "search_engine",
86 + {"query": "latest Agent Zero release"},
87 + )
88 +
89 +
90 +def test_normalize_tool_request_accepts_single_action_wrapper() -> None:
91 + request = {
92 + "thoughts": ["Read the requested file."],
93 + "actions": [
94 + {
95 + "tool_name": "text_editor",
96 + "tool_args": {"action": "read", "path": "README.md"},
97 + }
98 + ],
99 + }
100 +
101 + assert json_parse_dirty(str(request)) == request
102 + assert normalize_tool_request(request) == (
103 + "text_editor",
104 + {"action": "read", "path": "README.md"},
105 + )
106 +
107 +
108 +def test_normalize_tool_request_rejects_multiple_wrapped_actions() -> None:
109 + with pytest.raises(ValueError, match="exactly one"):
110 + normalize_tool_request(
111 + {
112 + "actions": [
113 + {"tool_name": "response", "tool_args": {"text": "first"}},
114 + {"tool_name": "response", "tool_args": {"text": "second"}},
115 + ]
116 + }
117 + )
118 +
119 +
120 +def test_extract_tool_request_requires_a_complete_tool_message() -> None:
121 + request = '{"tool_name":"response","tool_args":{"text":"ok"}}'
122 +
123 + assert extract_tool_request(request) == {
124 + "tool_name": "response",
125 + "tool_args": {"text": "ok"},
126 + }
127 + assert extract_tool_request('{"status":"ok"}') is None
128 + assert extract_tool_request(f"Example: {request}") is None
129 + assert extract_tool_request(f"{request} trailing text") is None
130 +
131 +
132 +def test_is_misformatted_tool_request_requires_agent_tool_envelope() -> None:
133 + request = '{"tool_name":"response","tool_args":{"text":"ok"}}'
134 + malformed = (
135 + '{"thoughts":["Plan the work", "Run the tools", '
136 + '"headline":"Save results", "tool_name":"parallel", '
137 + '"tool_args":{"tool_calls":[{"tool_name":"memory_save",'
138 + '"tool_args":{"text":"ok"}}],"wait":true}}'
139 + )
140 +
141 + assert extract_tool_request(malformed) is None
142 + assert is_misformatted_tool_request(malformed) is True
143 + assert is_misformatted_tool_request(f"Intro\n```json\n{request}\n```") is True
144 + assert is_misformatted_tool_request('{"status":"planning"}') is False
145 + assert is_misformatted_tool_request(f"Example: {request}") is False
146 + assert is_misformatted_tool_request(
147 + malformed.replace('{"thoughts"', '{"status":"planning","thoughts"')
148 + ) is False
149 +
150 +
151 def test_normalize_parallel_tool_calls_accepts_full_agent_reply_shape() -> None:
152 calls = parallel_tools.normalize_parallel_tool_calls(
153 [