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`.