Skip to content

Commit cab416b

Browse files
committed
feat(skills): skills impacts /tools
1 parent 8733dfe commit cab416b

4 files changed

Lines changed: 198 additions & 3 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: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
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
89
from llama_stack_client import AsyncLlamaStackClient
910
from pydantic_ai.agent import Agent
10-
from pydantic_ai.capabilities import AgentCapability
11+
from pydantic_ai.capabilities import AbstractCapability, AgentCapability
1112
from pydantic_ai.models.openai import OpenAIResponsesModelSettings
1213
from pydantic_ai_skills import SkillsCapability
1314

@@ -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,94 @@ 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)
179+
if not capabilities:
180+
return []
181+
182+
tools: list[dict[str, Any]] = []
183+
for capability in capabilities:
184+
if not isinstance(capability, AbstractCapability):
185+
continue
186+
toolset = capability.get_toolset()
187+
if toolset is None:
188+
continue
189+
tools.extend(_capability_tools_from_toolset(toolset))
190+
return tools
191+
192+
99193
def _agent_capabilities(
100194
skills: Optional[SkillsConfiguration],
101195
) -> Optional[list[AgentCapability[Any]]]:

‎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

@@ -344,3 +345,46 @@ def test_agent_has_no_skills_capability_when_not_configured(
344345
type(capability) for capability in agent._root_capability.capabilities
345346
}
346347
assert SkillsCapability not in capability_types
348+
349+
350+
class TestGetAgentCapabilityTools:
351+
"""Tests for get_agent_capability_tools."""
352+
353+
def test_returns_empty_list_when_skills_not_configured(self) -> None:
354+
"""Test that missing skills configuration yields no capability tools."""
355+
assert get_agent_capability_tools(None) == []
356+
assert get_agent_capability_tools(SkillsConfiguration(paths=[])) == []
357+
358+
def test_returns_skills_tools_when_configured(
359+
self, mock_skills_configuration: SkillsConfiguration
360+
) -> None:
361+
"""Test that configured skills expose pydantic-ai skill tools."""
362+
tools = get_agent_capability_tools(mock_skills_configuration)
363+
364+
assert [tool["identifier"] for tool in tools] == [
365+
"list_skills",
366+
"load_skill",
367+
"read_skill_resource",
368+
"run_skill_script",
369+
]
370+
assert all(
371+
tool["provider_id"] == "agent-skills"
372+
and tool["toolgroup_id"] == "builtin::agent-skills"
373+
and tool["server_source"] == "builtin"
374+
and tool["type"] == "tool"
375+
for tool in tools
376+
)
377+
378+
load_skill = next(tool for tool in tools if tool["identifier"] == "load_skill")
379+
assert load_skill["parameters"] == [
380+
{
381+
"name": "skill_name",
382+
"description": (
383+
"Exact name from your available skills list.\n"
384+
'Must match exactly (e.g., "data-analysis" not "data analysis").'
385+
),
386+
"parameter_type": "string",
387+
"required": True,
388+
"default": None,
389+
}
390+
]

0 commit comments

Comments
 (0)