Fix native Responses tool metadata

Expose grouped memory tools and conditional vision support while applying prompt render variables before deriving native metadata. Stop guessing complex schemas from prose and keep native descriptions to compact discovery text, reducing the duplicated Responses tool payload.

Alessandro committed Jul 29, 2026 at 14:52 UTC 1d88a82b55239e5fff0c8f2a5b7087922f14a928
3 files changed +164 -40
helpers/responses_tools.py
+55 -32
@@ -14,9 +14,18 @@ TOOL_NAME_EXAMPLE_PATTERN = re.compile(
14 r"""["']tool_name["']\s*:\s*["']([A-Za-z0-9_-]{1,64})["']"""
15 )
16 TOOL_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", re.MULTILINE)
17 +TOOL_DECLARATION_PATTERN = re.compile(
18 + r"^\s*-\s+`([A-Za-z0-9_-]{1,64})`:\s+(args?\b.*)$",
19 + re.IGNORECASE | re.MULTILINE,
20 +)
21 +SIMPLE_ARGS_PATTERN = re.compile(
22 + r"^\s*args?:\s*`([A-Za-z_][A-Za-z0-9_-]*)`\s*$",
23 + re.IGNORECASE | re.MULTILINE,
24 +)
25 TOOL_PROMPT_PREFIX = "agent.system.tool."
26 TOOL_PROMPT_SUFFIX = ".md"
27 MAX_TOOL_DESCRIPTION_CHARS = 1024
28 +TOOL_PROMPT_KWARGS_KEY = "_tool_prompt_kwargs"
29
30
31 def build_responses_function_tools(agent: Any) -> tuple[list[dict[str, Any]], dict[str, str]]:
@@ -63,6 +72,9 @@ def _local_tool_prompts(agent: Any) -> list[tuple[str, str]]:
72 tool_files = files.get_unique_filenames_in_dirs(
73 prompt_dirs, f"{TOOL_PROMPT_PREFIX}*{TOOL_PROMPT_SUFFIX}"
74 )
75 + get_data = getattr(agent, "get_data", None)
76 + tool_kwargs = get_data(TOOL_PROMPT_KWARGS_KEY) if callable(get_data) else {}
77 + tool_kwargs = tool_kwargs if isinstance(tool_kwargs, dict) else {}
78 result: list[tuple[str, str]] = []
79 for tool_file in tool_files:
80 basename = os.path.basename(tool_file)
@@ -70,19 +82,33 @@ def _local_tool_prompts(agent: Any) -> list[tuple[str, str]]:
82 if not fallback_name:
83 continue
84 try:
73 - prompt = agent.read_prompt(basename)
85 + prompt = agent.read_prompt(basename, **tool_kwargs.get(basename, {}))
86 except Exception:
87 try:
88 prompt = files.read_file(tool_file)
89 except Exception:
90 prompt = ""
79 - tool_name = _tool_name_from_prompt(prompt, fallback=fallback_name)
80 - if not _include_local_tool_prompt(agent, tool_name):
81 - continue
82 - result.append((tool_name, prompt))
91 + for tool_name in _tool_names_from_prompt(prompt, fallback=fallback_name):
92 + if _include_local_tool_prompt(agent, tool_name):
93 + result.append((tool_name, prompt))
94 +
95 + vision_prompt = _vision_tool_prompt(agent)
96 + if vision_prompt:
97 + result.append(("vision_load", vision_prompt))
98 return result
99
100
101 +def _vision_tool_prompt(agent: Any) -> str:
102 + try:
103 + from plugins._model_config.helpers.model_config import get_chat_model_config
104 +
105 + if not get_chat_model_config(agent).get("vision", False):
106 + return ""
107 + return agent.read_prompt("agent.system.tools_vision.md")
108 + except Exception:
109 + return ""
110 +
111 +
112 def _include_local_tool_prompt(agent: Any, tool_name: str) -> bool:
113 try:
114 from plugins._a0_connector.helpers.remote_tool_prompts import (
@@ -135,6 +161,15 @@ def _tool_name_from_prompt(prompt: str, *, fallback: str) -> str:
161 return fallback
162
163
164 +def _tool_names_from_prompt(prompt: str, *, fallback: str) -> list[str]:
165 + declarations = [
166 + match.group(1) for match in TOOL_DECLARATION_PATTERN.finditer(prompt or "")
167 + ]
168 + if declarations:
169 + return list(dict.fromkeys(declarations))
170 + return [_tool_name_from_prompt(prompt, fallback=fallback)]
171 +
172 +
173 def _tool_name_from_heading(heading: str) -> str:
174 token = (heading or "").strip().split(None, 1)[0] if heading else ""
175 name = token.strip("`'\" :")
@@ -153,7 +188,10 @@ def _native_tool_name(tool_name: str) -> str:
188
189
190 def _description_from_prompt(prompt: str, *, fallback: str) -> str:
156 - lines: list[str] = []
191 + for match in TOOL_DECLARATION_PATTERN.finditer(prompt or ""):
192 + if match.group(1) == fallback:
193 + return _truncate(match.group(2))
194 +
195 in_fence = False
196 for raw_line in (prompt or "").splitlines():
197 line = raw_line.strip()
@@ -163,21 +201,23 @@ def _description_from_prompt(prompt: str, *, fallback: str) -> str:
201 if in_fence or not line:
202 continue
203 if line.startswith("#"):
166 - line = line.lstrip("#").strip()
167 - if line.lower() == fallback.lower():
168 - continue
169 - lines.append(line)
170 - if sum(len(part) for part in lines) >= MAX_TOOL_DESCRIPTION_CHARS:
171 - break
172 - description = " ".join(lines).strip() or fallback
173 - return _truncate(description)
204 + continue
205 + return _truncate(line)
206 + return fallback
207
208
209 def _schema_from_prompt(prompt: str) -> dict[str, Any]:
210 schema = _schema_from_embedded_json(prompt)
211 if schema:
212 return schema
180 - return _schema_from_args_line(prompt)
213 + match = SIMPLE_ARGS_PATTERN.search(prompt or "")
214 + if match:
215 + return {
216 + "type": "object",
217 + "properties": {match.group(1): {"type": "string"}},
218 + "additionalProperties": True,
219 + }
220 + return _permissive_schema()
221
222
223 def _schema_from_embedded_json(prompt: str) -> dict[str, Any]:
@@ -196,23 +236,6 @@ def _schema_from_embedded_json(prompt: str) -> dict[str, Any]:
236 return {}
237
238
199 -def _schema_from_args_line(prompt: str) -> dict[str, Any]:
200 - properties: dict[str, Any] = {}
201 - for line in (prompt or "").splitlines():
202 - normalized = line.strip()
203 - if "args:" not in normalized.lower() and "argument:" not in normalized.lower():
204 - continue
205 - for name in re.findall(r"`([A-Za-z_][A-Za-z0-9_-]*)`", normalized):
206 - properties.setdefault(name, {"type": "string"})
207 - if properties:
208 - return {
209 - "type": "object",
210 - "properties": properties,
211 - "additionalProperties": True,
212 - }
213 - return _permissive_schema()
214 -
215 -
239 def _schema_from_any(schema: Any) -> dict[str, Any]:
240 if isinstance(schema, dict):
241 normalized = dict(schema)
helpers/responses_tools.py.dox.md
+5 -4
@@ -12,10 +12,11 @@
12
13 ## Local Contracts
14
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.
15 +- Build local function tools from enabled `agent.system.tool.*.md` prompt files and include `vision_load` only when the active chat model enables the matching vision prompt.
16 +- Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
17 +- Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates.
18 +- Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types.
19 +- Native local-tool descriptions use the matching compact multi-tool declaration or the first prose line, not a duplicate copy of the full tool manual or fenced examples.
20 - Preserve original Agent Zero tool names through the native Responses name map.
21 - Keep MCP tool schemas merged after local prompt-derived tools.
22 - Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.
tests/test_responses_tools.py
+104 -4
@@ -10,11 +10,18 @@ from helpers import responses_tools
10
11
12 class FakeAgent:
13 - def __init__(self, prompt_root: Path):
13 + def __init__(self, prompt_root: Path, data=None):
14 self.prompt_root = prompt_root
15 + self.data = data or {}
16
17 def read_prompt(self, file: str, **kwargs) -> str:
17 - return (self.prompt_root / file).read_text(encoding="utf-8")
18 + prompt = (self.prompt_root / file).read_text(encoding="utf-8")
19 + for key, value in kwargs.items():
20 + prompt = prompt.replace("{{" + key + "}}", str(value))
21 + return prompt
22 +
23 + def get_data(self, key: str):
24 + return self.data.get(key)
25
26
27 def _write_prompt(prompt_root: Path, basename: str, content: str) -> None:
@@ -41,6 +48,10 @@ def test_responses_function_tools_use_prompt_declared_names(monkeypatch, tmp_pat
48 """
49 ## memory tools
50 durable memory operations
51 + - `memory_load`: args `query`, optional `threshold`, `limit`, `filter`
52 + - `memory_save`: args `text`, optional `area`
53 + - `memory_delete`: arg `ids`
54 + - `memory_forget`: args `query`, optional `threshold`, `filter`
55 ```json
56 {"tool_name": "memory_load", "tool_args": {"query": "responses naming"}}
57 ```
@@ -91,6 +102,9 @@ def test_responses_function_tools_use_prompt_declared_names(monkeypatch, tmp_pat
102 assert {
103 "code_execution_tool",
104 "memory_load",
105 + "memory_save",
106 + "memory_delete",
107 + "memory_forget",
108 "call_subordinate",
109 "behaviour_adjustment",
110 "filename_only",
@@ -98,6 +112,9 @@ def test_responses_function_tools_use_prompt_declared_names(monkeypatch, tmp_pat
112 assert not {"code_exe", "memory", "call_sub", "behaviour"} & names
113 assert name_map["code_execution_tool"] == "code_execution_tool"
114 assert name_map["memory_load"] == "memory_load"
115 + assert name_map["memory_save"] == "memory_save"
116 + assert name_map["memory_delete"] == "memory_delete"
117 + assert name_map["memory_forget"] == "memory_forget"
118 assert name_map["call_subordinate"] == "call_subordinate"
119 assert name_map["behaviour_adjustment"] == "behaviour_adjustment"
120 assert name_map["filename_only"] == "filename_only"
@@ -156,6 +173,89 @@ def test_response_tool_native_contract_omits_wrapper_and_exposes_text():
173 description = responses_tools._description_from_prompt(prompt, fallback="response")
174 schema = responses_tools._schema_from_prompt(prompt)
175
159 - assert '"tool_name"' not in description
160 - assert "~~~" not in description
176 + assert description == "final answer to user"
177 assert schema["properties"] == {"text": {"type": "string"}}
178 +
179 +
180 +def test_complex_prompt_args_are_not_guessed_as_string_schemas():
181 + for path in (
182 + PROJECT_ROOT / "prompts" / "agent.system.tool.scheduler.md",
183 + PROJECT_ROOT / "prompts" / "agent.system.tool.parallel.md",
184 + ):
185 + schema = responses_tools._schema_from_prompt(path.read_text(encoding="utf-8"))
186 +
187 + assert schema == {
188 + "type": "object",
189 + "properties": {},
190 + "additionalProperties": True,
191 + }
192 +
193 +
194 +def test_responses_function_tools_include_vision_prompt(monkeypatch, tmp_path):
195 + prompt_root = tmp_path / "prompts"
196 + prompt_root.mkdir()
197 + _write_prompt(
198 + prompt_root,
199 + "agent.system.tools_vision.md",
200 + """
201 + ## multimodal vision tools
202 + ### vision_load
203 + load images into the model for visual reasoning
204 + args: `paths` list of absolute image paths
205 + """,
206 + )
207 + agent = FakeAgent(prompt_root)
208 +
209 + monkeypatch.setattr(responses_tools.subagents, "get_paths", lambda *args: [])
210 + monkeypatch.setattr(
211 + responses_tools,
212 + "_vision_tool_prompt",
213 + lambda _agent: agent.read_prompt("agent.system.tools_vision.md"),
214 + )
215 + monkeypatch.setattr(responses_tools, "_mcp_tools", lambda _agent: [])
216 +
217 + tools, name_map = responses_tools.build_responses_function_tools(agent)
218 +
219 + assert [tool["name"] for tool in tools] == ["vision_load"]
220 + assert tools[0]["description"] == "load images into the model for visual reasoning"
221 + assert tools[0]["parameters"]["properties"] == {}
222 + assert name_map == {"vision_load": "vision_load"}
223 +
224 +
225 +def test_local_tool_prompts_use_registered_render_kwargs(monkeypatch, tmp_path):
226 + prompt_root = tmp_path / "prompts"
227 + prompt_root.mkdir()
228 + basename = "agent.system.tool.text_editor.md"
229 + _write_prompt(
230 + prompt_root,
231 + basename,
232 + """
233 + ### text_editor
234 + read {{default_line_count}} lines by default
235 + """,
236 + )
237 + agent = FakeAgent(
238 + prompt_root,
239 + data={
240 + responses_tools.TOOL_PROMPT_KWARGS_KEY: {
241 + basename: {"default_line_count": 200}
242 + }
243 + },
244 + )
245 +
246 + monkeypatch.setattr(
247 + responses_tools.subagents,
248 + "get_paths",
249 + lambda *args, **kwargs: [str(prompt_root)],
250 + )
251 + monkeypatch.setattr(responses_tools, "_vision_tool_prompt", lambda _agent: "")
252 + monkeypatch.setattr(
253 + responses_tools,
254 + "_include_local_tool_prompt",
255 + lambda _agent, _tool_name: True,
256 + )
257 +
258 + prompts = dict(responses_tools._local_tool_prompts(agent))
259 +
260 + assert "{{default_line_count}}" not in prompts["text_editor"]
261 + assert "read 200 lines by default" in prompts["text_editor"]