agent-zero/tools/skills_tool.py
Alessandro daf95ec3ab Normalize tool contracts and slim prompt surface
Standardize multi-action tools around tool_args.action while keeping parser compatibility for older tool/args, tool_name:action, and method-shaped requests. This keeps new prompts clean without breaking agents that learned the previous dialect.

Move A0 connector remote execution/file tools into stable standard prompts, make remote targeting independent of the active chat context, and skill-gate beta computer-use remote so it no longer weighs down the always-on tool list.

Align text editor, scheduler, skills, office artifact, memory, notify, and browser prompts/tools around the canonical action contract. Add scheduler update/timezone handling, skills_tool read_file, text editor patch coverage, and fixes for memory_forget, behaviour_adjustment, and code execution progress warnings.

Reduce default prompt pressure by compacting browser and scheduler prompts into skill-backed manifests, shortening skill catalog descriptions, and pruning noisy framework knowledge. Remove obsolete connector prompt stubs and root tool-call knowledge examples.

Tests: conda run -n a0 pytest tests/test_a0_connector_prompt_gating.py tests/test_tool_action_contracts.py tests/test_task_scheduler_timezone.py tests/test_text_editor_context_patch.py tests/test_tool_request_normalization.py tests/test_office_document_store.py::test_odf_is_advertised_and_docx_remains_explicit_compatibility tests/test_office_document_store.py::test_document_artifact_accepts_method_alias_for_ods_create tests/test_skills_runtime.py tests/test_default_prompt_budget.py::test_a0_small_profile_removed_and_prompt_text_generic -q
2026-05-09 21:54:43 +02:00

253 lines
8.4 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import List
from helpers.tool import Tool, Response
from helpers import skills as skills_helper
from helpers.print_style import PrintStyle
DATA_NAME_LOADED_SKILLS = skills_helper.AGENT_DATA_NAME_LOADED_SKILLS
class SkillsTool(Tool):
"""
Manage and use SKILL.md-based Skills (Anthropic open standard).
Actions (tool_args.action):
- list
- search (query)
- load (skill_name)
- read_file (skill_name, file_path)
Script execution is handled by code_execution_tool directly.
"""
def _current_action(self) -> str:
return (
str(
self.args.get("action")
or ""
)
.strip()
.lower()
.replace("-", "_")
)
@staticmethod
def _normalize_skill_name(skill_name: str) -> str:
skill_name = skill_name.strip()
if skill_name.startswith("**") and skill_name.endswith("**"):
skill_name = skill_name[2:-2]
return skill_name.strip()
def get_log_object(self):
import uuid
if self._current_action() == "load":
skill_name = self._normalize_skill_name(
str(self.args.get("skill_name") or "")
)
heading = (
f"icon://construction Loading skill {skill_name}"
if skill_name
else "icon://construction Loading skill"
)
return self.agent.context.log.log(
type="tool",
heading=heading,
content="",
kvps={"_tool_name": self.name},
id=str(uuid.uuid4()),
)
return super().get_log_object()
async def before_execution(self, **kwargs):
if self._current_action() != "load":
await super().before_execution(**kwargs)
return
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
)
label = f"{self.name} action {self._current_action()}"
if skill_name:
PrintStyle(
font_color="#1B4F72",
padding=True,
background_color="white",
bold=True,
).print(f"{self.agent.agent_name}: Loading skill '{skill_name}'")
else:
PrintStyle(
font_color="#1B4F72",
padding=True,
background_color="white",
bold=True,
).print(f"{self.agent.agent_name}: Using tool '{label}'")
self.log = self.get_log_object()
async def execute(self, **kwargs) -> Response:
action = (
str(
kwargs.get("action")
or self.args.get("action")
or ""
)
.strip()
.lower()
.replace("-", "_")
)
try:
if action == "list":
return Response(message=self._list(), break_loop=False)
if action == "search":
query = str(kwargs.get("query") or self.args.get("query") or "").strip()
return Response(message=self._search(query), break_loop=False)
if action == "load":
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
)
return Response(message=self._load(skill_name), break_loop=False)
if action == "read_file":
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
)
file_path = str(
kwargs.get("file_path") or self.args.get("file_path") or ""
).strip()
return Response(
message=self._read_file(skill_name, file_path),
break_loop=False,
)
return Response(
message=(
"Error: missing/invalid 'action'. Supported actions: "
"list, search, load, read_file."
),
break_loop=False,
)
except (
Exception
) as e: # keep tool robust; return error instead of crashing loop
return Response(message=f"Error in skills_tool: {e}", break_loop=False)
def _list(self) -> str:
skills = skills_helper.list_skills(
agent=self.agent,
include_content=False,
)
if not skills:
return "No skills found."
# Stable output: sort by name
skills_sorted = sorted(skills, key=lambda s: s.name.lower())
lines: List[str] = []
lines.append(f"Available skills ({len(skills_sorted)}):")
for s in skills_sorted:
tags = f" tags={','.join(s.tags)}" if s.tags else ""
ver = f" v{s.version}" if s.version else ""
desc = (s.description or "").strip()
if len(desc) > 200:
desc = desc[:200].rstrip() + ""
lines.append(f"- {s.name}{ver}{tags}: {desc}")
lines.append("")
lines.append("Tip: use skills_tool action=search or action=load for details.")
return "\n".join(lines)
def _search(self, query: str) -> str:
if not query:
return "Error: 'query' is required for action=search."
results = skills_helper.search_skills(
query,
limit=25,
agent=self.agent,
)
if not results:
return f"No skills matched query: {query!r}"
lines: List[str] = []
lines.append(f"Skills matching {query!r} ({len(results)}):")
for s in results:
desc = (s.description or "").strip()
if len(desc) > 200:
desc = desc[:200].rstrip() + ""
lines.append(f"- {s.name}: {desc}")
lines.append("")
lines.append(
"Tip: use skills_tool action=load skill_name=<name> to load full instructions."
)
return "\n".join(lines)
def _load(self, skill_name: str) -> str:
skill_name = self._normalize_skill_name(skill_name)
if not skill_name:
return "Error: 'skill_name' is required for action=load."
# Verify skill exists
skill = skills_helper.find_skill(
skill_name,
include_content=False,
agent=self.agent,
)
if not skill:
return f"Error: skill not found: {skill_name!r}. Try skills_tool action=list or action=search."
# Store skill name for fresh loading each turn
if not self.agent.data.get(DATA_NAME_LOADED_SKILLS):
self.agent.data[DATA_NAME_LOADED_SKILLS] = []
loaded = self.agent.data[DATA_NAME_LOADED_SKILLS]
if skill.name in loaded:
loaded.remove(skill.name)
loaded.append(skill.name)
self.agent.data[DATA_NAME_LOADED_SKILLS] = loaded[-max_loaded_skills():]
return f"Loaded skill '{skill.name}' into EXTRAS."
def _read_file(self, skill_name: str, file_path: str) -> str:
if not skill_name:
return "Error: 'skill_name' is required for action=read_file."
if not file_path:
return "Error: 'file_path' is required for action=read_file."
skill = skills_helper.find_skill(
skill_name,
include_content=False,
agent=self.agent,
)
if not skill:
return f"Error: skill not found: {skill_name!r}."
skill_root = skill.path.resolve()
target = Path(file_path)
if not target.is_absolute():
target = skill_root / target
try:
resolved = target.resolve()
resolved.relative_to(skill_root)
except Exception:
return "Error: file_path must stay inside the skill directory."
if not resolved.is_file():
return f"Error: skill file not found: {file_path!r}."
content = resolved.read_text(encoding="utf-8", errors="replace")
if len(content) > 24000:
content = content[:24000].rstrip() + "\n\n[truncated]"
return (
f"Skill file: {skill.name}/{resolved.relative_to(skill_root)}\n\n"
f"{content}"
)
def max_loaded_skills() -> int:
return skills_helper.MAX_ACTIVE_SKILLS