Skip to content

Commit 01b92a8

Browse files
evalstateclaude
andauthored
Implement agent skills standard support (#564)
* Update Agent Skills to match agentskills.io standard - Update XML format to use standard <skill>/<name>/<description>/<location> elements instead of custom <agent-skill> attributes - Use absolute paths for skill locations per specification requirement - Add read_skill tool for non-ACP contexts (ACP uses read_text_file) - Parse optional spec fields: license, compatibility, metadata, allowed-tools - Update format_skills_for_prompt with read_tool_name parameter - Remove relative_path from SkillManifest (now always uses absolute path) - Update tests to verify standard compliance * path handling tests * multi-directory support * update tests * quality --------- Co-authored-by: Claude <[email protected]>
1 parent 91d2c61 commit 01b92a8

File tree

15 files changed

+723
-250
lines changed

15 files changed

+723
-250
lines changed

src/fast_agent/acp/server/agent_acp_server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import uuid
1010
from dataclasses import dataclass, field
1111
from importlib.metadata import version as get_version
12-
from typing import Any, Awaitable, Callable
12+
from pathlib import Path
13+
from typing import Any, Awaitable, Callable, Sequence
1314

1415
from acp import Agent as ACPAgent
1516
from acp import (
@@ -187,7 +188,7 @@ def __init__(
187188
instance_scope: str,
188189
server_name: str = "fast-agent-acp",
189190
server_version: str | None = None,
190-
skills_directory_override: str | None = None,
191+
skills_directory_override: Sequence[str | Path] | str | Path | None = None,
191192
permissions_enabled: bool = True,
192193
) -> None:
193194
"""

src/fast_agent/acp/slash_commands.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import time
1616
from importlib.metadata import version as get_version
1717
from pathlib import Path
18-
from typing import TYPE_CHECKING
18+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
1919

2020
from acp.schema import (
2121
AvailableCommand,
@@ -37,6 +37,16 @@
3737
from mcp.types import ListToolsResult, Tool
3838

3939
from fast_agent.core.fastagent import AgentInstance
40+
from fast_agent.skills.registry import SkillRegistry
41+
42+
43+
@runtime_checkable
44+
class WarningAwareAgent(Protocol):
45+
@property
46+
def warnings(self) -> list[str]: ...
47+
48+
@property
49+
def skill_registry(self) -> "SkillRegistry | None": ...
4050

4151

4252
class SlashCommandHandler:
@@ -482,6 +492,10 @@ async def _handle_status(self, arguments: str | None = None) -> str:
482492
status_lines.extend(["", f"ACP Agent Uptime: {format_duration(uptime_seconds)}"])
483493
status_lines.extend(["", "## Error Handling"])
484494
status_lines.extend(self._get_error_handling_report(agent))
495+
warning_report = self._get_warning_report(agent)
496+
if warning_report:
497+
status_lines.append("")
498+
status_lines.extend(warning_report)
485499

486500
return "\n".join(status_lines)
487501

@@ -1001,6 +1015,31 @@ def _get_error_handling_report(self, agent, max_entries: int = 3) -> list[str]:
10011015

10021016
return ["_No errors recorded_"]
10031017

1018+
def _get_warning_report(self, agent, max_entries: int = 5) -> list[str]:
1019+
warnings: list[str] = []
1020+
if isinstance(agent, WarningAwareAgent):
1021+
warnings.extend(agent.warnings)
1022+
if agent.skill_registry:
1023+
warnings.extend(agent.skill_registry.warnings)
1024+
1025+
cleaned: list[str] = []
1026+
seen: set[str] = set()
1027+
for warning in warnings:
1028+
message = str(warning).strip()
1029+
if message and message not in seen:
1030+
cleaned.append(message)
1031+
seen.add(message)
1032+
1033+
if not cleaned:
1034+
return []
1035+
1036+
lines = ["Warnings:"]
1037+
for message in cleaned[:max_entries]:
1038+
lines.append(f"- {message}")
1039+
if len(cleaned) > max_entries:
1040+
lines.append(f"- ... ({len(cleaned) - max_entries} more)")
1041+
return lines
1042+
10041043
def _context_usage_line(self, summary: ConversationSummary, agent) -> str:
10051044
"""Generate a context usage line with token estimation and fallbacks."""
10061045
# Prefer usage accumulator when available (matches enhanced/interactive prompt display)

src/fast_agent/agents/agent_types.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,14 @@ class AgentConfig:
4040
tools: dict[str, list[str]] = field(default_factory=dict) # filters for tools
4141
resources: dict[str, list[str]] = field(default_factory=dict) # filters for resources
4242
prompts: dict[str, list[str]] = field(default_factory=dict) # filters for prompts
43-
skills: SkillManifest | SkillRegistry | Path | str | None = None
43+
skills: (
44+
SkillManifest
45+
| SkillRegistry
46+
| Path
47+
| str
48+
| list[SkillManifest | SkillRegistry | Path | str | None]
49+
| None
50+
) = None
4451
skill_manifests: list[SkillManifest] = field(default_factory=list, repr=False)
4552
model: str | None = None
4653
use_history: bool = True

src/fast_agent/agents/mcp_agent.py

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,15 @@
4848
is_namespaced_name,
4949
)
5050
from fast_agent.mcp.mcp_aggregator import MCPAggregator, NamespacedTool, ServerStatus
51-
from fast_agent.skills.registry import format_skills_for_prompt
51+
from fast_agent.skills import SkillManifest
52+
from fast_agent.skills.registry import SkillRegistry, format_skills_for_prompt
5253
from fast_agent.tools.elicitation import (
5354
get_elicitation_tool,
5455
run_elicitation_form,
5556
set_elicitation_input_callback,
5657
)
5758
from fast_agent.tools.shell_runtime import ShellRuntime
59+
from fast_agent.tools.skill_reader import SkillReader
5860
from fast_agent.types import PromptMessageExtended, RequestParams
5961
from fast_agent.ui import console
6062

@@ -69,7 +71,6 @@
6971

7072
from fast_agent.context import Context
7173
from fast_agent.llm.usage_tracking import UsageAccumulator
72-
from fast_agent.skills import SkillManifest
7374

7475

7576
class McpAgent(ABC, ToolAgent):
@@ -109,17 +110,23 @@ def __init__(
109110
self.executor = context.executor if context else None
110111
self.logger = get_logger(f"{__name__}.{self._name}")
111112
manifests: list[SkillManifest] = list(getattr(self.config, "skill_manifests", []) or [])
112-
if not manifests and context and getattr(context, "skill_registry", None):
113+
if not manifests and context and context.skill_registry:
113114
try:
114115
manifests = list(context.skill_registry.load_manifests()) # type: ignore[assignment]
115116
except Exception:
116117
manifests = []
117118

118-
self._skill_manifests = list(manifests)
119-
self._skill_map: dict[str, SkillManifest] = {
120-
manifest.name: manifest for manifest in manifests
121-
}
122-
self._agent_skills_warning_shown = False
119+
self._skill_manifests: list[SkillManifest] = []
120+
self._skill_map: dict[str, SkillManifest] = {}
121+
self._skill_reader: SkillReader | None = None
122+
self.set_skill_manifests(manifests)
123+
self.skill_registry: SkillRegistry | None = None
124+
if isinstance(self.config.skills, SkillRegistry):
125+
self.skill_registry = self.config.skills
126+
elif self.config.skills is None and context and context.skill_registry:
127+
self.skill_registry = context.skill_registry
128+
self._warnings: list[str] = []
129+
self._warning_messages_seen: set[str] = set()
123130
shell_flag_requested = bool(context and getattr(context, "shell_runtime", False))
124131
skills_configured = bool(self._skill_manifests)
125132
self._shell_runtime_activation_reason: str | None = None
@@ -285,21 +292,12 @@ async def _apply_instruction_templates(self) -> None:
285292
self.instruction = await self._instruction_builder.build()
286293

287294
# Warn if skills configured but placeholder missing
288-
if (
289-
self._skill_manifests
290-
and not self._agent_skills_warning_shown
291-
and "{{agentSkills}}" not in self._instruction_builder.template
292-
):
295+
if self._skill_manifests and "{{agentSkills}}" not in self._instruction_builder.template:
293296
warning_message = (
294297
"Agent skills are configured but the system prompt does not include {{agentSkills}}. "
295298
"Skill descriptions will not be added to the system prompt."
296299
)
297-
self.logger.warning(warning_message)
298-
try:
299-
console.console.print(f"[yellow]{warning_message}[/yellow]")
300-
except Exception: # pragma: no cover - console fallback
301-
pass
302-
self._agent_skills_warning_shown = True
300+
self._record_warning(warning_message)
303301

304302
# Update default request params to match
305303
if self._default_request_params:
@@ -322,8 +320,36 @@ async def _resolve_server_instructions(self) -> str:
322320

323321
async def _resolve_agent_skills(self) -> str:
324322
"""Resolver for {{agentSkills}} placeholder."""
325-
self._agent_skills_warning_shown = True
326-
return format_skills_for_prompt(self._skill_manifests)
323+
# Determine which tool to reference in the preamble
324+
# ACP context provides read_text_file; otherwise use read_skill
325+
if self._filesystem_runtime and hasattr(self._filesystem_runtime, "tools"):
326+
read_tool_name = "read_text_file"
327+
else:
328+
read_tool_name = "read_skill"
329+
return format_skills_for_prompt(self._skill_manifests, read_tool_name=read_tool_name)
330+
331+
def set_skill_manifests(self, manifests: Sequence[SkillManifest]) -> None:
332+
self._skill_manifests = list(manifests)
333+
self._skill_map = {manifest.name: manifest for manifest in self._skill_manifests}
334+
if self._skill_manifests:
335+
self._skill_reader = SkillReader(self._skill_manifests, self.logger)
336+
else:
337+
self._skill_reader = None
338+
339+
def _record_warning(self, message: str) -> None:
340+
if message in self._warning_messages_seen:
341+
return
342+
self._warning_messages_seen.add(message)
343+
self._warnings.append(message)
344+
self.logger.warning(message)
345+
try:
346+
console.console.print(f"[yellow]{message}[/yellow]")
347+
except Exception: # pragma: no cover - console fallback
348+
pass
349+
350+
@property
351+
def warnings(self) -> list[str]:
352+
return list(self._warnings)
327353

328354
def set_instruction_context(self, context: dict[str, str]) -> None:
329355
"""
@@ -585,6 +611,10 @@ async def call_tool(
585611
arguments, tool_use_id
586612
)
587613

614+
# Check skill reader (non-ACP context with skills)
615+
if self._skill_reader and name == "read_skill":
616+
return await self._skill_reader.execute(arguments)
617+
588618
# Fall back to shell runtime
589619
if self._shell_runtime.tool and name == self._shell_runtime.tool.name:
590620
return await self._shell_runtime.execute(arguments)
@@ -909,12 +939,16 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend
909939
and hasattr(self._filesystem_runtime, "tools")
910940
and any(tool.name == tool_name for tool in self._filesystem_runtime.tools)
911941
)
942+
is_skill_reader_tool = (
943+
self._skill_reader and self._skill_reader.enabled and tool_name == "read_skill"
944+
)
912945

913946
tool_available = (
914947
tool_name == HUMAN_INPUT_TOOL_NAME
915948
or (self._shell_runtime.tool and tool_name == self._shell_runtime.tool.name)
916949
or is_external_runtime_tool
917950
or is_filesystem_runtime_tool
951+
or is_skill_reader_tool
918952
or namespaced_tool is not None
919953
or local_tool is not None
920954
or candidate_namespaced_tool is not None
@@ -1208,6 +1242,12 @@ async def list_tools(self) -> ListToolsResult:
12081242
if fs_tool and fs_tool.name not in existing_names:
12091243
merged_tools.append(fs_tool)
12101244
existing_names.add(fs_tool.name)
1245+
elif self._skill_reader and self._skill_reader.enabled:
1246+
# Non-ACP context with skills: provide read_skill tool
1247+
skill_tool = self._skill_reader.tool
1248+
if skill_tool.name not in existing_names:
1249+
merged_tools.append(skill_tool)
1250+
existing_names.add(skill_tool.name)
12111251

12121252
if self.config.human_input:
12131253
human_tool = getattr(self, "_human_input_tool", None)

src/fast_agent/cli/commands/check_config.py

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def get_config_summary(config_path: Path | None) -> dict:
169169
"step_seconds": default_settings.mcp_timeline.step_seconds,
170170
},
171171
"mcp_servers": [],
172-
"skills_directory": None,
172+
"skills_directories": None,
173173
}
174174

175175
if not config_path:
@@ -282,9 +282,11 @@ def get_config_summary(config_path: Path | None) -> dict:
282282
# Skills directory override
283283
skills_cfg = config.get("skills") if isinstance(config, dict) else None
284284
if isinstance(skills_cfg, dict):
285-
directory_value = skills_cfg.get("directory")
286-
if isinstance(directory_value, str) and directory_value.strip():
287-
result["skills_directory"] = directory_value.strip()
285+
directory_value = skills_cfg.get("directories")
286+
if isinstance(directory_value, list):
287+
cleaned = [str(value).strip() for value in directory_value if str(value).strip()]
288+
if cleaned:
289+
result["skills_directories"] = cleaned
288290

289291
except Exception as e:
290292
# File exists but has parse errors
@@ -402,10 +404,14 @@ def _relative_path(path: Path) -> str:
402404
except ValueError:
403405
return str(path)
404406

405-
skills_override = config_summary.get("skills_directory")
406-
override_directory = Path(skills_override).expanduser() if skills_override else None
407-
skills_registry = SkillRegistry(base_dir=cwd, override_directory=override_directory)
408-
skills_dir = skills_registry.directory
407+
skills_override = config_summary.get("skills_directories")
408+
override_directories = (
409+
[Path(entry).expanduser() for entry in skills_override]
410+
if isinstance(skills_override, list)
411+
else None
412+
)
413+
skills_registry = SkillRegistry(base_dir=cwd, directories=override_directories)
414+
skills_dirs = skills_registry.directories
409415
skills_manifests, skill_errors = skills_registry.load_manifests_with_errors()
410416

411417
# Logger Settings panel with two-column layout
@@ -634,8 +640,13 @@ def format_provider_row(provider, status):
634640
console.print(servers_table)
635641

636642
_print_section_header("Agent Skills", color="blue")
637-
if skills_dir:
638-
console.print(f"Directory: [green]{_relative_path(skills_dir)}[/green]")
643+
if skills_dirs:
644+
if len(skills_dirs) == 1:
645+
console.print(f"Directory: [green]{_relative_path(skills_dirs[0])}[/green]")
646+
else:
647+
console.print("Directories:")
648+
for directory in skills_dirs:
649+
console.print(f"- [green]{_relative_path(directory)}[/green]")
639650

640651
if skills_manifests or skill_errors:
641652
skills_table = Table(show_header=True, box=None)
@@ -650,11 +661,7 @@ def _truncate(text: str, length: int = 70) -> str:
650661
return text[: length - 3] + "..."
651662

652663
for manifest in skills_manifests:
653-
try:
654-
relative_source = manifest.path.parent.relative_to(skills_dir)
655-
source_display = str(relative_source) if relative_source != Path(".") else "."
656-
except ValueError:
657-
source_display = _relative_path(manifest.path.parent)
664+
source_display = _relative_path(manifest.path.parent)
658665

659666
skills_table.add_row(
660667
manifest.name,
@@ -668,11 +675,7 @@ def _truncate(text: str, length: int = 70) -> str:
668675
source_display = "[dim]n/a[/dim]"
669676
if error_path_str:
670677
error_path = Path(error_path_str)
671-
try:
672-
relative_error = error_path.parent.relative_to(skills_dir)
673-
source_display = str(relative_error) if relative_error != Path(".") else "."
674-
except ValueError:
675-
source_display = _relative_path(error_path.parent)
678+
source_display = _relative_path(error_path.parent)
676679
message = error.get("error", "Failed to parse skill manifest")
677680
skills_table.add_row(
678681
"[red]—[/red]",
@@ -683,19 +686,11 @@ def _truncate(text: str, length: int = 70) -> str:
683686

684687
console.print(skills_table)
685688
else:
686-
console.print("[yellow]No skills found in the directory[/yellow]")
689+
console.print("[yellow]No skills found in the configured directories[/yellow]")
687690
else:
688-
if skills_registry.override_failed and override_directory:
689-
console.print(
690-
f"[red]Override directory not found:[/red] {_relative_path(override_directory)}"
691-
)
692-
console.print(
693-
"[yellow]Default folders were not loaded because the override failed[/yellow]"
694-
)
695-
else:
696-
console.print(
697-
"[dim]Agent Skills not configured. Go to https://fast-agent.ai/agents/skills/[/dim]"
698-
)
691+
console.print(
692+
"[dim]Agent Skills not configured. Go to https://fast-agent.ai/agents/skills/[/dim]"
693+
)
699694

700695
# Show help tips
701696
if config_status == "not_found" or secrets_status == "not_found":

src/fast_agent/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _coerce_steps(cls, value: Any) -> int:
105105
class SkillsSettings(BaseModel):
106106
"""Configuration for the skills directory override."""
107107

108-
directory: str | None = None
108+
directories: list[str] | None = None
109109

110110
model_config = ConfigDict(extra="ignore")
111111

src/fast_agent/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,9 @@
3838
3939
The current date is {{currentDate}}."""
4040

41+
DEFAULT_SKILLS_PATHS = [
42+
".fast-agent/skills",
43+
".claude/skills",
44+
]
45+
4146
CONTROL_MESSAGE_SAVE_HISTORY = "***SAVE_HISTORY"

0 commit comments

Comments
 (0)