fix(response): keep completions on the tool path

Route native Responses output text through the normalized response-tool executor instead of ending the monologue directly. Preserve active goal overrides and Responses state cleanup, wait for complete native streams, and reject empty response payloads.

Alessandro committed Jul 25, 2026 at 00:44 UTC c6136da08bc08e5fae4351af96dd5162ed4e4071
9 files changed +239 -76
AGENTS.md
+1
@@ -28,6 +28,7 @@
28 - Preserve authentication and CSRF protections.
29 - Use Linux paths and commands in examples.
30 - When a live Dockerized Agent Zero target is explicitly named, verify that exact runtime instead of assuming a fixed localhost port.
31 +- Message-loop completion flows through a response tool with `break_loop`; plain or malformed Chat Completions text enters repair, and native Responses output text is normalized through the same response-tool path.
32 - Copy live core-plugin changes back into tracked source under `plugins/`.
33 - Develop new custom plugins under ignored `usr/plugins/`; tracked bundled plugins live under `plugins/`.
34 - Use the framework runtime for backend and plugin-hook verification, not the separate agent execution runtime.
agent.py
+12 -4
@@ -1119,10 +1119,18 @@ class Agent:
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
1122 + if (
1123 + llm_result.mode == "responses"
1124 + and isinstance(message, str)
1125 + and bool(message.strip())
1126 + and extract_tools.extract_tool_request(message) is None
1127 + and not extract_tools.is_misformatted_tool_request(message)
1128 + ):
1129 + return await self._execute_tool_request(
1130 + tool_name="response",
1131 + tool_args={"text": message},
1132 + message=message,
1133 + )
1134 return await self.process_tools(message)
1135
1136 async def _execute_tool_request(
models.py
+5 -2
@@ -738,7 +738,10 @@ class LiteLLMChatWrapper(SimpleChatModel):
738 output["response_delta"]
739 )
740 )
741 - if stop_response is not None:
741 + if (
742 + stop_response is not None
743 + and not transport.policy.using_responses
744 + ):
745 result.response = stop_response
746 break
747 else:
@@ -761,7 +764,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
764 provider_model_key=self.model_name,
765 capability=transport._capability_metadata(),
766 )
764 - if result.output()["response_delta"]:
767 + if result.output()["response_delta"] and not llm_result.function_calls:
768 llm_result.response = result.output()["response_delta"]
769 if result.output()["reasoning_delta"]:
770 llm_result.reasoning = result.output()["reasoning_delta"]
plugins/_goal/tests/test_goal_plugin.py
+53 -1
@@ -5,7 +5,10 @@ from types import SimpleNamespace
5
6 import pytest
7
8 -from helpers import files
8 +from agent import Agent, LoopData
9 +from helpers import extension, files, mcp_handler
10 +from helpers.llm_result import LLMResult
11 +from helpers.log import Log
12 from plugins._goal.api.goal import Goal as GoalApi
13 from plugins._goal.commands import goal_command
14 from plugins._goal.tools import goal
@@ -180,3 +183,52 @@ async def test_active_goal_keeps_response_tool_running(context_id: str):
183 response = await tool.execute()
184 assert response.break_loop is True
185 assert response.message == "Can you decide?"
186 +
187 +
188 +@pytest.mark.asyncio
189 +async def test_native_responses_text_uses_active_goal_response_override(
190 + context_id: str,
191 + monkeypatch,
192 +):
193 + goal.create_goal(context_id, "Keep going")
194 + recorded = []
195 +
196 + async def no_op(*args, **kwargs):
197 + return None
198 +
199 + class NoMcpTools:
200 + def get_tool(self, agent, tool_name):
201 + return None
202 +
203 + agent = object.__new__(Agent)
204 + agent.context = SimpleNamespace(id=context_id, log=Log())
205 + agent.loop_data = LoopData()
206 + agent.data = {}
207 + agent.handle_intervention = no_op
208 + agent._log_response_builtin_items = no_op
209 + agent.hist_add_tool_result = lambda *args, **kwargs: recorded.append((args, kwargs))
210 +
211 + def get_tool(name, method, args, message, loop_data, **kwargs):
212 + return ResponseTool(agent, name, method, args, message, loop_data)
213 +
214 + agent.get_tool = get_tool
215 + monkeypatch.setattr(extension, "call_extensions_async", no_op)
216 + monkeypatch.setattr(mcp_handler.MCPConfig, "get_instance", lambda: NoMcpTools())
217 +
218 + result = await Agent.process_llm_result_tools(
219 + agent,
220 + LLMResult(response="Checkpoint for the user."),
221 + )
222 +
223 + assert result is None
224 + assert recorded[0][0][0] == "response"
225 + assert recorded[0][0][1].startswith("Goal still active.")
226 + assert recorded[0][1] == {}
227 +
228 + goal.update_goal(context_id, status="complete")
229 + result = await Agent.process_llm_result_tools(
230 + agent,
231 + LLMResult(response="Finished."),
232 + )
233 +
234 + assert result == "Finished."
tests/test_response_tool_validation.py
+36 -1
@@ -23,6 +23,41 @@ async def test_response_tool_accepts_text_or_message(args) -> None:
23 assert response.break_loop is True
24
25
26 +@pytest.mark.asyncio
27 +async def test_response_tool_uses_non_empty_legacy_message_fallback() -> None:
28 + tool = ResponseTool(
29 + None,
30 + "response",
31 + None,
32 + {"text": "", "message": "legacy"},
33 + "",
34 + None,
35 + )
36 +
37 + response = await tool.execute()
38 +
39 + assert response.message == "legacy"
40 + assert response.break_loop is True
41 +
42 +
43 +@pytest.mark.asyncio
44 +@pytest.mark.parametrize(
45 + "args",
46 + [
47 + {},
48 + {"text": ""},
49 + {"text": " "},
50 + {"text": None},
51 + {"message": "\n\t"},
52 + ],
53 +)
54 +async def test_response_tool_rejects_empty_arguments(args) -> None:
55 + tool = ResponseTool(None, "response", None, args, "", None)
56 +
57 + with pytest.raises(RepairableException, match="non-empty top-level"):
58 + await tool.execute()
59 +
60 +
61 @pytest.mark.asyncio
62 async def test_response_tool_rejects_nested_response_args() -> None:
63 tool = ResponseTool(
@@ -39,5 +74,5 @@ async def test_response_tool_rejects_nested_response_args() -> None:
74 None,
75 )
76
42 - with pytest.raises(RepairableException, match="top-level text or message"):
77 + with pytest.raises(RepairableException, match="non-empty top-level"):
78 await tool.execute()
tests/test_responses_architecture.py
+114 -47
@@ -1,4 +1,4 @@
1 -import asyncio
1 +import json
2 import sys
3 from pathlib import Path
4
@@ -44,28 +44,6 @@ 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 -
47 def test_llm_result_persists_only_durable_responses_metadata():
48 result = LLMResult.from_response(
49 {
@@ -340,13 +318,58 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
318
319
320 @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}
321 +async def test_unified_turn_waits_for_completed_native_responses_calls(monkeypatch):
322 + calls = [
323 + {
324 + "type": "function_call",
325 + "id": "fc_1",
326 + "call_id": "call_1",
327 + "name": "lookup",
328 + "arguments": '{"q":"a0"}',
329 + },
330 + {
331 + "type": "function_call",
332 + "id": "fc_2",
333 + "call_id": "call_2",
334 + "name": "summarize",
335 + "arguments": '{"style":"short"}',
336 + },
337 + ]
338 + stream = _AsyncEventStream(
339 + [
340 + {
341 + "type": "response.output_item.added",
342 + "output_index": 0,
343 + "item": {**calls[0], "arguments": ""},
344 + },
345 + {
346 + "type": "response.function_call_arguments.done",
347 + "item_id": "fc_1",
348 + "output_index": 0,
349 + "name": "lookup",
350 + "arguments": calls[0]["arguments"],
351 + },
352 + {
353 + "type": "response.output_item.added",
354 + "output_index": 1,
355 + "item": {**calls[1], "arguments": ""},
356 + },
357 + {
358 + "type": "response.function_call_arguments.done",
359 + "item_id": "fc_2",
360 + "output_index": 1,
361 + "name": "summarize",
362 + "arguments": calls[1]["arguments"],
363 + },
364 + {
365 + "type": "response.completed",
366 + "response": {
367 + "id": "resp_parallel",
368 + "output": calls,
369 + "usage": {"input_tokens": 10, "output_tokens": 5},
370 + },
371 + },
372 + ]
373 )
374
375 async def fake_aresponses(*args, **kwargs):
@@ -367,16 +390,26 @@ async def test_unified_turn_stops_responses_stream_after_callback_stop(monkeypat
390 async def response_callback(chunk: str, full: str):
391 return full if extract_tools.extract_tool_request(full) else None
392
370 - result = await asyncio.wait_for(
371 - wrapper.unified_turn(
372 - messages=[HumanMessage(content="hi")],
373 - response_callback=response_callback,
374 - ),
375 - timeout=1,
393 + result = await wrapper.unified_turn(
394 + messages=[HumanMessage(content="hi")],
395 + response_callback=response_callback,
396 )
397
378 - assert result.response == message
379 - assert stream.closed is True
398 + assert stream.index == 5
399 + assert stream.closed is False
400 + assert result.mode == "responses"
401 + assert result.response_id == "resp_parallel"
402 + assert result.usage == {"input_tokens": 10, "output_tokens": 5}
403 + assert [call.name for call in result.function_calls] == ["lookup", "summarize"]
404 + assert json.loads(result.response) == {
405 + "tool_name": "parallel_tool_calls",
406 + "tool_args": {
407 + "calls": [
408 + {"tool_name": "lookup", "tool_args": {"q": "a0"}},
409 + {"tool_name": "summarize", "tool_args": {"style": "short"}},
410 + ]
411 + },
412 + }
413
414
415 def test_collect_response_ids_from_agent_state_and_history_metadata():
@@ -477,47 +510,81 @@ async def test_agent_executes_native_responses_function_call_and_records_output(
510
511
512 @pytest.mark.asyncio
480 -async def test_agent_routes_only_complete_text_tool_requests() -> None:
513 +async def test_agent_routes_chat_retries_and_native_responses_text() -> None:
514 agent = object.__new__(Agent)
515 processed: list[str] = []
516 + executed: list[dict] = []
517
518 async def log_builtin_items(result):
519 return None
520
521 async def process_tools(message):
522 processed.append(message)
489 - return message
523 + return None
524 +
525 + async def execute_tool_request(**kwargs):
526 + executed.append(kwargs)
527 + return None
528
529 agent._log_response_builtin_items = log_builtin_items
530 agent.process_tools = process_tools
531 + agent._execute_tool_request = execute_tool_request
532
533 tool_request = '{"type":"function","name":"response","parameters":{"text":"ok"}}'
495 - for message in (
534 + chat_messages = (
535 "Plain final answer.",
536 '{"status":"planning"}',
537 f"Example tool JSON: {tool_request}",
499 - ):
538 + f"∂\n{tool_request}",
539 + (
540 + '{"thoughts":["Done"],"headline":"Done","tool_args":'
541 + '{"text":"ok","tool_name":"response"}'
542 + ),
543 + )
544 + for message in chat_messages:
545 assert await Agent.process_llm_result_tools(
546 agent, LLMResult.from_chat(response=message)
502 - ) == message
547 + ) is None
548 + assert processed == list(chat_messages)
549 +
550 + processed.clear()
551 + responses_messages = (
552 + "Plain final answer.",
553 + '{"status":"planning"}',
554 + f"Example tool JSON: {tool_request}",
555 + )
556 + for message in responses_messages:
557 + assert await Agent.process_llm_result_tools(
558 + agent, LLMResult(response=message)
559 + ) is None
560 assert processed == []
561 + assert executed == [
562 + {
563 + "tool_name": "response",
564 + "tool_args": {"text": message},
565 + "message": message,
566 + }
567 + for message in responses_messages
568 + ]
569
570 + processed.clear()
571 + executed.clear()
572 assert await Agent.process_llm_result_tools(
573 agent, LLMResult.from_chat(response=tool_request)
507 - ) == tool_request
574 + ) is None
575 assert processed == [tool_request]
576
577 processed.clear()
578 assert await Agent.process_llm_result_tools(
579 agent, LLMResult(response="", reasoning=tool_request)
513 - ) == tool_request
580 + ) is None
581 assert processed == [tool_request]
582
583 processed.clear()
584 assert await Agent.process_llm_result_tools(
585 agent, LLMResult(response="", reasoning='{"status":"planning"}')
519 - ) == ""
520 - assert processed == []
586 + ) is None
587 + assert processed == [""]
588
589
590 @pytest.mark.asyncio
tests/test_stream_tool_early_stop.py
+14 -18
@@ -235,25 +235,22 @@ def test_provider_defaults_do_not_freeze_litellm_global_kwargs(monkeypatch):
235
236
237 @pytest.mark.asyncio
238 -async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
238 +async def test_unified_call_stops_chat_after_canonical_root_snapshot(monkeypatch):
239 stream = _AsyncChunkStream(
240 [
241 - {"type": "response.created"},
242 - _response_event('{"tool_name":"response","tool_args":{"text":"hello"}}'),
243 - _response_event(" unreachable"),
241 + _chunk('{"tool_name":"response","tool_args":{"text":"hello"}}'),
242 + _chunk(" unreachable"),
243 ]
244 )
245
247 - async def fake_aresponses(*args, **kwargs):
246 + async def fake_acompletion(*args, **kwargs):
247 assert kwargs["stream"] is True
249 - assert kwargs["input"] == ""
250 - assert kwargs["store"] is True
248 return stream
249
250 async def fake_rate_limiter(*args, **kwargs):
251 return None
252
256 - monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
253 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
254 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
255 monkeypatch.setattr(
256 models.settings,
@@ -265,6 +262,7 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
262 model="test-model",
263 provider="openai",
264 model_config=None,
265 + a0_api_mode="chat",
266 )
267
268 seen: list[tuple[str, str]] = []
@@ -280,7 +278,7 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
278
279 assert response == '{"tool_name":"response","tool_args":{"text":"hello"}}'
280 assert reasoning == ""
283 - assert stream.index == 2
281 + assert stream.index == 1
282 assert stream.closed is True
283 assert len(seen) == 1
284 assert seen[0][1] == '{"tool_name":"response","tool_args":{"text":"hello"}}'
@@ -290,25 +288,22 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
288 async def test_unified_call_does_not_stop_for_embedded_tool_json(monkeypatch):
289 stream = _AsyncChunkStream(
290 [
293 - {"type": "response.created"},
294 - _response_event('Preamble {"note":"not the tool"}.\n'),
295 - _response_event(
291 + _chunk('Preamble {"note":"not the tool"}.\n'),
292 + _chunk(
293 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text'
294 ),
298 - _response_event(" unreachable"),
295 + _chunk(" unreachable"),
296 ]
297 )
298
302 - async def fake_aresponses(*args, **kwargs):
299 + async def fake_acompletion(*args, **kwargs):
300 assert kwargs["stream"] is True
304 - assert kwargs["input"] == ""
305 - assert kwargs["store"] is True
301 return stream
302
303 async def fake_rate_limiter(*args, **kwargs):
304 return None
305
311 - monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
306 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
307 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
308 monkeypatch.setattr(
309 models.settings,
@@ -320,6 +315,7 @@ async def test_unified_call_does_not_stop_for_embedded_tool_json(monkeypatch):
315 model="test-model",
316 provider="openai",
317 model_config=None,
318 + a0_api_mode="chat",
319 )
320
321 seen: list[tuple[str, str]] = []
@@ -338,7 +334,7 @@ async def test_unified_call_does_not_stop_for_embedded_tool_json(monkeypatch):
334 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text unreachable'
335 )
336 assert reasoning == ""
341 - assert stream.index == 4
337 + assert stream.index == 3
338 assert stream.closed is False
339 assert len(seen) == 3
340 assert seen[0][1] == 'Preamble {"note":"not the tool"}.\n'
tools/response.py
+2 -2
@@ -7,10 +7,10 @@ class ResponseTool(Tool):
7 async def execute(self, **kwargs):
8 for key in ("text", "message"):
9 message = self.args.get(key)
10 - if isinstance(message, str):
10 + if isinstance(message, str) and message.strip():
11 return Response(message=message, break_loop=True)
12 raise RepairableException(
13 - "response tool requires a top-level text or message string argument"
13 + "response tool requires a non-empty top-level text or message string argument"
14 )
15
16 async def before_execution(self, **kwargs):
tools/response.py.dox.md
+2 -1
@@ -22,7 +22,8 @@
22 - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
23 - `ResponseTool` is a `Tool`.
24 - `ResponseTool` defines `execute(...)`.
25 -- `ResponseTool` requires a top-level string `text` or legacy `message` argument.
25 +- `ResponseTool` requires a non-empty top-level string `text` or legacy `message`
26 + argument, preferring `text` and falling back to `message` when `text` is blank.
27 Invalid arguments raise `RepairableException` so the agent can surface a correction
28 warning and retry rather than crash.
29 - Imported dependency areas include: `helpers.errors`, `helpers.tool`.