Skip to content

Commit 4060b02

Browse files
committed
feat(skills): skills impacts /tools
1 parent 513df21 commit 4060b02

4 files changed

Lines changed: 195 additions & 2 deletions

File tree

‎src/app/endpoints/tools.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
mcp_headers_dependency,
2929
)
3030
from utils.mcp_oauth_probe import check_mcp_auth
31+
from utils.pydantic_ai import get_agent_capability_tools
3132
from utils.tool_formatter import format_tools_list
3233

3334
logger = get_logger(__name__)
@@ -238,11 +239,25 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-st
238239
server_source,
239240
)
240241

242+
existing_tool_ids = {
243+
tool.get("identifier") for tool in consolidated_tools if tool.get("identifier")
244+
}
245+
capability_tools = get_agent_capability_tools(configuration.skills)
246+
for tool_dict in capability_tools:
247+
identifier = tool_dict.get("identifier")
248+
if identifier and identifier not in existing_tool_ids:
249+
consolidated_tools.append(tool_dict)
250+
existing_tool_ids.add(identifier)
251+
252+
builtin_tool_count = len(
253+
[t for t in consolidated_tools if t.get("server_source") == "builtin"]
254+
)
255+
mcp_tool_count = len(consolidated_tools) - builtin_tool_count
241256
logger.info(
242257
"Retrieved total of %d tools (%d from built-in toolgroups, %d from MCP servers)",
243258
len(consolidated_tools),
244-
len([t for t in consolidated_tools if t.get("server_source") == "builtin"]),
245-
len([t for t in consolidated_tools if t.get("server_source") != "builtin"]),
259+
builtin_tool_count,
260+
mcp_tool_count,
246261
)
247262

248263
# Format tools with structured description parsing

‎src/utils/pydantic_ai.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import re
56
from typing import Any, Final, Optional, cast
67

78
from llama_stack.core.library_client import AsyncLlamaStackAsLibraryClient
@@ -18,6 +19,11 @@
1819
LlamaStackResponsesModel,
1920
)
2021

22+
_AGENT_SKILLS_PROVIDER_ID: Final[str] = "agent-skills"
23+
_AGENT_SKILLS_TOOLGROUP_ID: Final[str] = "builtin::agent-skills"
24+
_BUILTIN_CAPABILITY_SERVER_SOURCE: Final[str] = "builtin"
25+
_CAPABILITY_TOOL_TYPE: Final[str] = "tool"
26+
2127
_LLS_RESPONSES_EXTRA_FIELDS: Final[frozenset[str]] = frozenset(
2228
{
2329
"conversation",
@@ -96,6 +102,92 @@ def _skills_capability(
96102
)
97103

98104

105+
def _json_schema_to_parameters(
106+
schema: Optional[dict[str, Any]],
107+
) -> list[dict[str, Any]]:
108+
"""Convert a JSON Schema object to the flat parameter list used by ``/tools``."""
109+
if not schema or "properties" not in schema:
110+
return []
111+
112+
required_params = set(schema.get("required", []))
113+
parameters: list[dict[str, Any]] = []
114+
for name, prop in schema["properties"].items():
115+
parameter_type = prop.get("type")
116+
if parameter_type is None and "anyOf" in prop:
117+
for option in prop["anyOf"]:
118+
if isinstance(option, dict) and option.get("type") not in (
119+
None,
120+
"null",
121+
):
122+
parameter_type = option["type"]
123+
break
124+
parameters.append(
125+
{
126+
"name": name,
127+
"description": prop.get("description", ""),
128+
"parameter_type": parameter_type or "string",
129+
"required": name in required_params,
130+
"default": prop.get("default"),
131+
}
132+
)
133+
return parameters
134+
135+
136+
def _capability_tool_description(description: str) -> str:
137+
"""Extract a user-facing description from pydantic-ai tool docstrings."""
138+
if match := re.search(r"<summary>(.*?)</summary>", description, re.DOTALL):
139+
return match.group(1).strip()
140+
return description.strip()
141+
142+
143+
def _capability_tools_from_toolset(toolset: Any) -> list[dict[str, Any]]:
144+
"""Serialize tools registered on a pydantic-ai capability toolset."""
145+
raw_tools = getattr(toolset, "tools", None)
146+
if not raw_tools:
147+
return []
148+
149+
tool_dicts: list[dict[str, Any]] = []
150+
for tool in raw_tools.values():
151+
tool_dicts.append(
152+
{
153+
"identifier": tool.name,
154+
"description": _capability_tool_description(tool.description or ""),
155+
"parameters": _json_schema_to_parameters(
156+
tool.function_schema.json_schema
157+
),
158+
"provider_id": _AGENT_SKILLS_PROVIDER_ID,
159+
"toolgroup_id": _AGENT_SKILLS_TOOLGROUP_ID,
160+
"server_source": _BUILTIN_CAPABILITY_SERVER_SOURCE,
161+
"type": _CAPABILITY_TOOL_TYPE,
162+
}
163+
)
164+
return tool_dicts
165+
166+
167+
def get_agent_capability_tools(
168+
skills: Optional[SkillsConfiguration],
169+
) -> list[dict[str, Any]]:
170+
"""Return tool metadata for pydantic-ai capabilities configured for LCS agents.
171+
172+
Parameters:
173+
skills: Agent skills configuration from LCS, or None when skills are disabled.
174+
175+
Returns:
176+
Tool dictionaries compatible with the ``/tools`` endpoint response format.
177+
"""
178+
capabilities = _agent_capabilities(skills) or []
179+
180+
tools: list[dict[str, Any]] = []
181+
for capability in capabilities:
182+
if not isinstance(capability, AbstractCapability):
183+
continue
184+
toolset = capability.get_toolset()
185+
if toolset is None:
186+
continue
187+
tools.extend(_capability_tools_from_toolset(toolset))
188+
return tools
189+
190+
99191
def _agent_capabilities(
100192
skills: Optional[SkillsConfiguration],
101193
no_tools: bool = False,

‎tests/unit/app/endpoints/test_tools.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
LlamaStackConfiguration,
2424
ModelContextProtocolServer,
2525
ServiceConfiguration,
26+
SkillsConfiguration,
2627
TLSConfiguration,
2728
UserDataCollection,
2829
)
@@ -1135,3 +1136,44 @@ async def test_tools_endpoint_empty_legacy_fields_overridden(
11351136
assert tool["parameters"][0]["name"] == "query"
11361137
assert tool["parameters"][0]["parameter_type"] == "string"
11371138
assert tool["parameters"][0]["required"] is True
1139+
1140+
1141+
@pytest.mark.asyncio
1142+
async def test_tools_endpoint_includes_agent_capability_tools(
1143+
mocker: MockerFixture,
1144+
mock_configuration: Configuration, # pylint: disable=redefined-outer-name
1145+
mock_skills_configuration: SkillsConfiguration,
1146+
) -> None:
1147+
"""Test that configured pydantic-ai capabilities appear in /tools output."""
1148+
config_with_skills = mock_configuration.model_copy(
1149+
update={"skills": mock_skills_configuration}
1150+
)
1151+
app_config = AppConfig()
1152+
app_config._configuration = config_with_skills
1153+
mocker.patch("app.endpoints.tools.configuration", app_config)
1154+
mocker.patch("app.endpoints.tools.authorize", lambda _: lambda func: func)
1155+
1156+
mock_client_holder = mocker.patch("app.endpoints.tools.AsyncLlamaStackClientHolder")
1157+
mock_client = mocker.AsyncMock()
1158+
mock_client_holder.return_value.get_client.return_value = mock_client
1159+
mock_client.toolgroups.list.return_value = []
1160+
1161+
mock_request = mocker.Mock()
1162+
mock_auth = MOCK_AUTH
1163+
1164+
response = await tools.tools_endpoint_handler.__wrapped__( # pyright: ignore
1165+
mock_request, mock_auth, {}
1166+
)
1167+
1168+
tool_ids = [tool["identifier"] for tool in response.tools]
1169+
assert "list_skills" in tool_ids
1170+
assert "load_skill" in tool_ids
1171+
assert "read_skill_resource" in tool_ids
1172+
assert "run_skill_script" in tool_ids
1173+
1174+
list_skills = next(
1175+
tool for tool in response.tools if tool["identifier"] == "list_skills"
1176+
)
1177+
assert list_skills["provider_id"] == "agent-skills"
1178+
assert list_skills["toolgroup_id"] == "builtin::agent-skills"
1179+
assert list_skills["server_source"] == "builtin"

‎tests/unit/utils/test_pydantic_ai.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_model_settings_from_responses_params,
1818
_skills_capability,
1919
build_agent,
20+
get_agent_capability_tools,
2021
llama_stack_provider_from_client,
2122
)
2223

@@ -363,3 +364,46 @@ def test_agent_excludes_tool_capabilities_when_no_tools(
363364
type(capability) for capability in agent._root_capability.capabilities
364365
}
365366
assert SkillsCapability not in capability_types
367+
368+
369+
class TestGetAgentCapabilityTools:
370+
"""Tests for get_agent_capability_tools."""
371+
372+
def test_returns_empty_list_when_skills_not_configured(self) -> None:
373+
"""Test that missing skills configuration yields no capability tools."""
374+
assert not get_agent_capability_tools(None)
375+
assert not get_agent_capability_tools(SkillsConfiguration(paths=[]))
376+
377+
def test_returns_skills_tools_when_configured(
378+
self, mock_skills_configuration: SkillsConfiguration
379+
) -> None:
380+
"""Test that configured skills expose pydantic-ai skill tools."""
381+
tools = get_agent_capability_tools(mock_skills_configuration)
382+
383+
assert [tool["identifier"] for tool in tools] == [
384+
"list_skills",
385+
"load_skill",
386+
"read_skill_resource",
387+
"run_skill_script",
388+
]
389+
assert all(
390+
tool["provider_id"] == "agent-skills"
391+
and tool["toolgroup_id"] == "builtin::agent-skills"
392+
and tool["server_source"] == "builtin"
393+
and tool["type"] == "tool"
394+
for tool in tools
395+
)
396+
397+
load_skill = next(tool for tool in tools if tool["identifier"] == "load_skill")
398+
assert load_skill["parameters"] == [
399+
{
400+
"name": "skill_name",
401+
"description": (
402+
"Exact name from your available skills list.\n"
403+
'Must match exactly (e.g., "data-analysis" not "data analysis").'
404+
),
405+
"parameter_type": "string",
406+
"required": True,
407+
"default": None,
408+
}
409+
]

0 commit comments

Comments
 (0)