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"]