main
py 369 lines 11.7 KB
Raw
1 from __future__ import annotations
2
3 from dataclasses import dataclass
4 import os
5 import re
6 from typing import Any
7
8 from helpers import files, plugins, subagents
9 from helpers.errors import RepairableException
10
11
12 PLUGIN_NAME = "_tool_access"
13 PROMPT_PREFIX = "agent.system.tool."
14 PROMPT_SUFFIX = ".md"
15 NON_CONFIGURABLE_TOOLS = frozenset({"response", "vision_load"})
16
17
18 @dataclass(frozen=True)
19 class ToolPolicyDecision:
20 allowed: bool
21 tool_id: str
22 source: str
23 mode: str
24 reason: str = ""
25
26
27 def normalize_policy(config: Any) -> dict[str, Any]:
28 raw = dict(config) if isinstance(config, dict) else {}
29 mode = str(raw.get("mode") or "inherit").strip().lower()
30 default = str(raw.get("default") or "allow").strip().lower()
31 mcp_default = str(raw.get("mcp_default") or "allow").strip().lower()
32 raw["mode"] = "custom" if mode == "custom" else "inherit"
33 raw["default"] = "block" if default == "block" else "allow"
34 raw["mcp_default"] = "block" if mcp_default == "block" else "allow"
35 raw["allowed"] = _normalize_ids(raw.get("allowed"))
36 raw["blocked"] = _normalize_ids(raw.get("blocked"))
37 return raw
38
39
40 def get_policy(agent: Any) -> dict[str, Any]:
41 from helpers import projects
42
43 project_name = projects.get_context_project_name(agent.context) or ""
44 profile = str(getattr(agent.config, "profile", "") or "")
45 for asset in plugins.find_plugin_assets(
46 plugins.CONFIG_FILE_NAME,
47 plugin_name=PLUGIN_NAME,
48 project_name=project_name,
49 agent_profile=profile,
50 only_first=False,
51 ):
52 config = files.read_file_json(asset["path"])
53 if not isinstance(config, dict) or not any(
54 key in config
55 for key in ("mode", "default", "mcp_default", "allowed", "blocked")
56 ):
57 continue
58 policy = normalize_policy(config)
59 if policy["mode"] == "custom":
60 return policy
61 return normalize_policy(plugins.get_default_plugin_config(PLUGIN_NAME))
62
63
64 def get_tool_catalog(agent: Any) -> list[dict[str, Any]]:
65 tool_paths = _local_tool_paths(agent)
66 descriptions = _tool_descriptions(agent, set(tool_paths))
67 catalog: list[dict[str, Any]] = []
68 seen: set[str] = set()
69 for name, tool_path in tool_paths.items():
70 if name in NON_CONFIGURABLE_TOOLS:
71 continue
72 tool_id, origin = _canonical_from_path(tool_path, name)
73 if tool_id in seen:
74 continue
75 seen.add(tool_id)
76 catalog.append(
77 {
78 "id": tool_id,
79 "name": name,
80 "label": name.replace("_", " ").title(),
81 "origin": origin,
82 "description": descriptions.get(name, ""),
83 "available": True,
84 }
85 )
86
87 try:
88 from helpers.mcp_handler import MCPConfig
89
90 for item in MCPConfig.get_for_agent(agent).get_tools():
91 qualified, tool = next(iter(item.items()))
92 tool_id = canonical_mcp_id(qualified)
93 if tool_id in seen:
94 continue
95 server_name, _, tool_name = qualified.partition(".")
96 seen.add(tool_id)
97 catalog.append(
98 {
99 "id": tool_id,
100 "name": qualified,
101 "label": " · ".join(
102 part.replace("_", " ").strip().title()
103 for part in (
104 server_name,
105 str(tool.get("title") or tool.get("name") or tool_name),
106 )
107 if part
108 ),
109 "description": str(tool.get("description") or ""),
110 "origin": f"MCP · {str(tool.get('server') or '').strip()}",
111 "available": True,
112 }
113 )
114 except Exception:
115 pass
116
117 policy = get_policy(agent)
118 for tool_id in [*policy["allowed"], *policy["blocked"]]:
119 if (
120 tool_id in seen
121 or _tool_name_from_id(tool_id) in NON_CONFIGURABLE_TOOLS
122 ):
123 continue
124 seen.add(tool_id)
125 name = _tool_name_from_id(tool_id)
126 catalog.append(
127 {
128 "id": tool_id,
129 "name": name,
130 "label": name.replace("_", " ").title(),
131 "description": "",
132 "origin": "Unavailable",
133 "available": False,
134 }
135 )
136
137 catalog.sort(key=lambda item: (item["label"].casefold(), item["id"]))
138 return catalog
139
140
141 def canonical_mcp_id(tool_name: str) -> str:
142 server, separator, name = str(tool_name or "").partition(".")
143 return f"mcp:{server}:{name}" if separator and server and name else ""
144
145
146 def _canonical_tool_id(agent: Any, tool_name: str) -> str:
147 if mcp_id := canonical_mcp_id(tool_name):
148 try:
149 from helpers.mcp_handler import MCPConfig
150
151 if MCPConfig.get_for_agent(agent).has_tool(tool_name):
152 return mcp_id
153 except Exception:
154 pass
155
156 paths = subagents.get_paths(agent, "tools", f"{tool_name}.py")
157 path = next((candidate for candidate in paths if files.exists(candidate)), "")
158 return _canonical_from_path(path, tool_name)[0] if path else f"local:{tool_name}"
159
160
161 def resolve_tool(
162 agent: Any,
163 tool_name: str,
164 *,
165 canonical_id: str = "",
166 ) -> ToolPolicyDecision:
167 tool_id = canonical_id or _canonical_tool_id(agent, tool_name)
168 requested = str(tool_name or "").strip()
169 name = _tool_name_from_id(tool_id) if requested == tool_id else requested
170 if name in NON_CONFIGURABLE_TOOLS:
171 source = "framework-required" if name == "response" else "runtime-config"
172 return ToolPolicyDecision(True, tool_id, source, "invariant")
173
174 policy = get_policy(agent)
175 if policy["mode"] != "custom":
176 return ToolPolicyDecision(True, tool_id, "inherited", "inherit")
177
178 if tool_id in policy["blocked"]:
179 return ToolPolicyDecision(
180 False, tool_id, "scoped-policy", "custom", "blocked explicitly"
181 )
182 if tool_id in policy["allowed"]:
183 return ToolPolicyDecision(True, tool_id, "scoped-policy", "custom")
184
185 default_key = "mcp_default" if tool_id.startswith("mcp:") else "default"
186 is_allowed = policy[default_key] == "allow"
187 return ToolPolicyDecision(
188 is_allowed,
189 tool_id,
190 "scoped-default",
191 "custom",
192 "blocked by default" if not is_allowed else "",
193 )
194
195
196 def ensure_tool_allowed(
197 agent: Any,
198 tool_name: str,
199 *,
200 canonical_id: str = "",
201 ) -> ToolPolicyDecision:
202 decision = resolve_tool(agent, tool_name, canonical_id=canonical_id)
203 if decision.allowed:
204 return decision
205 profile = str(getattr(getattr(agent, "config", None), "profile", "") or "default")
206 raise RepairableException(
207 f'Tool "{tool_name}" is blocked for agent profile "{profile}".'
208 )
209
210
211 def filter_tool_prompt(agent: Any, prompt_file: str, prompt: str) -> str:
212 if get_policy(agent)["mode"] != "custom":
213 return prompt
214
215 known_names = _policy_tool_names(agent)
216 names = _prompt_tool_names(prompt_file, prompt, known_names)
217 if names and not any(resolve_tool(agent, name).allowed for name in names):
218 return ""
219
220 blocked_names = {
221 name
222 for name in known_names
223 if not resolve_tool(agent, name).allowed
224 }
225 if not blocked_names:
226 return prompt
227 patterns = [
228 re.compile(
229 rf"(?:`{re.escape(name)}`|[\"']{re.escape(name)}[\"']|"
230 rf"(?<![A-Za-z0-9_-]){re.escape(name)}\s+tool\b)",
231 re.IGNORECASE,
232 )
233 for name in sorted(blocked_names, key=len, reverse=True)
234 ]
235 prompt = re.sub(
236 r"^[ \t]*(?P<fence>`{3,}|~{3,})[ \t]*json\b[^\r\n]*\r?\n"
237 r".*?^[ \t]*(?P=fence)[ \t]*(?:\r?\n|$)",
238 lambda match: (
239 ""
240 if any(pattern.search(match.group(0)) for pattern in patterns)
241 else match.group(0)
242 ),
243 prompt,
244 flags=re.IGNORECASE | re.MULTILINE | re.DOTALL,
245 )
246 return "".join(
247 line
248 for line in prompt.splitlines(keepends=True)
249 if not any(pattern.search(line) for pattern in patterns)
250 )
251
252
253 def _local_tool_paths(agent: Any) -> dict[str, str]:
254 result: dict[str, str] = {}
255 for path in files.get_unique_filenames_in_dirs(
256 subagents.get_paths(agent, "tools"), "*.py"
257 ):
258 name = os.path.splitext(os.path.basename(path))[0]
259 if name not in {"__init__", "unknown"}:
260 result[name] = path
261 return result
262
263
264 def _policy_tool_names(agent: Any) -> set[str]:
265 names = set(_local_tool_paths(agent))
266 policy = get_policy(agent)
267 names.update(
268 _tool_name_from_id(tool_id)
269 for tool_id in [*policy["allowed"], *policy["blocked"]]
270 if not tool_id.startswith("mcp:")
271 )
272 return names
273
274
275 def _prompt_tool_names(
276 prompt_file: str, prompt: str, known_names: set[str]
277 ) -> list[str]:
278 fallback = _prompt_name(prompt_file)
279 declared = [
280 name for name in sorted(known_names) if _prompt_declares_tool(prompt, name)
281 ]
282 if fallback in known_names:
283 return list(dict.fromkeys([fallback, *declared]))
284 return declared or ([fallback] if fallback else [])
285
286
287 def _prompt_declares_tool(prompt: str, name: str) -> bool:
288 escaped = re.escape(name)
289 return bool(
290 re.search(
291 rf"^\s{{0,3}}#{{1,6}}\s+`?{escaped}`?(?:\s|:|$)",
292 prompt or "",
293 re.IGNORECASE | re.MULTILINE,
294 )
295 or re.search(
296 rf"^\s*-\s+`{escaped}`\s*:",
297 prompt or "",
298 re.IGNORECASE | re.MULTILINE,
299 )
300 )
301
302
303 def _prompt_name(prompt_file: str) -> str:
304 basename = os.path.basename(prompt_file)
305 if basename.startswith(PROMPT_PREFIX) and basename.endswith(PROMPT_SUFFIX):
306 return basename[len(PROMPT_PREFIX) : -len(PROMPT_SUFFIX)]
307 return ""
308
309
310 def _tool_descriptions(agent: Any, tool_names: set[str]) -> dict[str, str]:
311 descriptions: dict[str, str] = {}
312 prompt_files = files.get_unique_filenames_in_dirs(
313 subagents.get_paths(agent, "prompts"), f"{PROMPT_PREFIX}*{PROMPT_SUFFIX}"
314 )
315 for prompt_file in prompt_files:
316 try:
317 prompt = agent.read_prompt(os.path.basename(prompt_file))
318 except Exception:
319 continue
320 for name in _prompt_tool_names(prompt_file, prompt, tool_names):
321 if name in tool_names and name not in descriptions:
322 descriptions[name] = tool_prompt_description(prompt, name)[:512]
323 return descriptions
324
325
326 def _canonical_from_path(path: str, name: str) -> tuple[str, str]:
327 if plugin_id := plugins.get_plugin_name_from_path(path):
328 return f"plugin:{plugin_id}:{name}", f"Plugin · {plugin_id}"
329 return f"local:{name}", "Agent Zero"
330
331
332 def _normalize_ids(raw: Any) -> list[str]:
333 if not isinstance(raw, list):
334 return []
335 result: list[str] = []
336 for value in raw:
337 tool_id = str(value or "").strip()
338 if tool_id and tool_id not in result:
339 result.append(tool_id)
340 return result
341
342
343 def tool_prompt_description(
344 prompt: str,
345 name: str,
346 *,
347 fallback: str = "",
348 ) -> str:
349 declaration = re.search(
350 rf"^\s*-\s+`{re.escape(name)}`:\s+(.+)$",
351 prompt or "",
352 re.IGNORECASE | re.MULTILINE,
353 )
354 if declaration:
355 return declaration.group(1).strip()
356 in_fence = False
357 for raw_line in (prompt or "").splitlines():
358 line = raw_line.strip()
359 if line.startswith(("```", "~~~")):
360 in_fence = not in_fence
361 continue
362 if in_fence or not line or line.startswith("#"):
363 continue
364 return line
365 return fallback or name.replace("_", " ").strip().capitalize()
366
367
368 def _tool_name_from_id(tool_id: str) -> str:
369 return str(tool_id or "").rsplit(":", 1)[-1]