Fix malformed native Responses tool output

Keep Agent Zero wrapper examples out of native function descriptions and expose the response text schema.\n\nRoute concatenated tool envelopes through repair before the plain response hook can render them as final text.

Alessandro committed Jul 28, 2026 at 12:52 UTC 4b0feac1f45702fc15f5b0d42838ce75edbfef4e
8 files changed +39 -5
extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py
+4 -1
@@ -23,7 +23,10 @@ class LogPlainResponses(Extension):
23 message = call_args[1]
24 if not isinstance(message, str) or not message:
25 return
26 - if extract_tools.extract_tool_request(message) is not None:
26 + if (
27 + extract_tools.extract_tool_request(message) is not None
28 + or extract_tools.is_misformatted_tool_request(message)
29 + ):
30 return
31
32 params = getattr(getattr(self.agent, "loop_data", None), "params_temporary", None)
helpers/extract_tools.py
+9
@@ -38,6 +38,15 @@ def is_misformatted_tool_request(content: str) -> bool:
38 return False
39
40 content = content.strip()
41 + roots = extract_json_root_strings(content)
42 + if (
43 + len(roots) > 1
44 + and content.startswith("{")
45 + and content.endswith("}")
46 + and any(extract_tool_request(root) is not None for root in roots)
47 + ):
48 + return True
49 +
50 for fenced_content in re.findall(
51 r"```(?:json)?\s*(.*?)```", content, flags=re.IGNORECASE | re.DOTALL
52 ):
helpers/extract_tools.py.dox.md
+1 -1
@@ -29,7 +29,7 @@
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.
32 +- `is_misformatted_tool_request` identifies a tool request wrapped in a JSON code fence, concatenated complete roots containing tool intent, 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`.
helpers/responses_tools.py
+1 -1
@@ -157,7 +157,7 @@ def _description_from_prompt(prompt: str, *, fallback: str) -> str:
157 in_fence = False
158 for raw_line in (prompt or "").splitlines():
159 line = raw_line.strip()
160 - if line.startswith("```"):
160 + if line.startswith(("```", "~~~")):
161 in_fence = not in_fence
162 continue
163 if in_fence or not line:
helpers/responses_tools.py.dox.md
+1
@@ -15,6 +15,7 @@
15 - Build local function tools from enabled `agent.system.tool.*.md` prompt files.
16 - Local prompt-derived function names prefer explicit `"tool_name"` examples, then the first prompt heading, and only fall back to the prompt filename when the prompt declares no callable name.
17 - Function parameter schemas are object schemas with an explicit `properties` object so OpenAI-compatible servers that validate chat-style tool payloads accept permissive tools.
18 +- Native tool descriptions omit both backtick- and tilde-fenced usage examples so Agent Zero text envelopes are not presented as function arguments.
19 - Preserve original Agent Zero tool names through the native Responses name map.
20 - Keep MCP tool schemas merged after local prompt-derived tools.
21 - Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.
prompts/agent.system.tool.response.md
+2 -2
@@ -1,7 +1,7 @@
1 ### response:
2 final answer to user
3 ends task processing use only when done or no task active
4 -put result in text arg
4 +args: `text`
5 default to balanced, concise answers: informative but tight, not terse and not verbose.
6 usage:
7 ~~~json
@@ -17,4 +17,4 @@ usage:
17 }
18 ~~~
19
20 -{{ include "agent.system.response_tool_tips.md" }}
\ No newline at end of file
20 +{{ include "agent.system.response_tool_tips.md" }}
tests/test_responses_tools.py
+13
@@ -146,3 +146,16 @@ def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
146 },
147 }
148 ]
149 +
150 +
151 +def test_response_tool_native_contract_omits_wrapper_and_exposes_text():
152 + prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.response.md").read_text(
153 + encoding="utf-8"
154 + )
155 +
156 + description = responses_tools._description_from_prompt(prompt, fallback="response")
157 + schema = responses_tools._schema_from_prompt(prompt)
158 +
159 + assert '"tool_name"' not in description
160 + assert "~~~" not in description
161 + assert schema["properties"] == {"text": {"type": "string"}}
tests/test_tool_request_normalization.py
+8
@@ -131,6 +131,12 @@ def test_extract_tool_request_requires_a_complete_tool_message() -> None:
131
132 def test_is_misformatted_tool_request_requires_agent_tool_envelope() -> None:
133 request = '{"tool_name":"response","tool_args":{"text":"ok"}}'
134 + concatenated = (
135 + '{"thoughts":[],"headline":"Inspecting","tool_name":"code_execution_tool",'
136 + '"tool_args":{"code":"pwd"}}'
137 + '{"thoughts":[],"headline":"Answering","tool_name":"response",'
138 + '"tool_args":{"text":"done"}}'
139 + )
140 malformed = (
141 '{"thoughts":["Plan the work", "Run the tools", '
142 '"headline":"Save results", "tool_name":"parallel", '
@@ -140,6 +146,8 @@ def test_is_misformatted_tool_request_requires_agent_tool_envelope() -> None:
146
147 assert extract_tool_request(malformed) is None
148 assert is_misformatted_tool_request(malformed) is True
149 + assert extract_tool_request(concatenated) is None
150 + assert is_misformatted_tool_request(concatenated) is True
151 assert is_misformatted_tool_request(f"Intro\n```json\n{request}\n```") is True
152 assert is_misformatted_tool_request('{"status":"planning"}') is False
153 assert is_misformatted_tool_request(f"Example: {request}") is False