Skip to content

Commit 97f9abc

Browse files
authored
Merge pull request #1930 from asimurka/wire_agents_skills
LCORE-2076: Wired agent skills into request flow
2 parents fbef706 + 0468d21 commit 97f9abc

8 files changed

Lines changed: 212 additions & 15 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ dependencies = [
8080
# Used for token estimation before LLM calls (LCORE-1569 / conversation compaction)
8181
"tiktoken>=0.8.0",
8282
# Used for Pydantic AI
83-
"pydantic-ai>=1.99.0"
83+
"pydantic-ai>=1.99.0",
84+
"pydantic-ai-skills>=0.11.0",
8485
]
8586

8687

src/configuration.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
RerankerConfiguration,
3333
RlsapiV1Configuration,
3434
ServiceConfiguration,
35+
SkillsConfiguration,
3536
SplunkConfiguration,
3637
UserDataCollection,
3738
)
@@ -504,6 +505,13 @@ def reranker(self) -> "RerankerConfiguration":
504505
raise LogicError("logic error: configuration is not loaded")
505506
return self._configuration.reranker
506507

508+
@property
509+
def skills(self) -> Optional[SkillsConfiguration]:
510+
"""Return agent skills configuration, or None if not provided."""
511+
if self._configuration is None:
512+
raise LogicError("logic error: configuration is not loaded")
513+
return self._configuration.skills
514+
507515
@property
508516
def rag_id_mapping(self) -> dict[str, str]:
509517
"""Return mapping from vector_db_id to rag_id from BYOK and OKP RAG config.

src/utils/agents/query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ async def retrieve_agent_response(
310310
llm_response=moderation_result.message,
311311
)
312312
try:
313-
agent = build_agent(client, responses_params)
313+
agent = build_agent(client, responses_params, configuration.skills)
314314
logger.debug("Starting agent non-streaming response processing")
315315
run_result = await agent.run(cast(str, responses_params.input))
316316
except (AgentRunError, APIStatusError, APIConnectionError, RuntimeError) as exc:

src/utils/pydantic_ai.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Final, cast
5+
from typing import Any, Final, Optional, cast
66

77
from llama_stack.core.library_client import AsyncLlamaStackAsLibraryClient
88
from llama_stack_client import AsyncLlamaStackClient
9-
from pydantic_ai import Agent
9+
from pydantic_ai import Agent, AgentCapability
1010
from pydantic_ai.models.openai import OpenAIResponsesModel, OpenAIResponsesModelSettings
11+
from pydantic_ai_skills import SkillsCapability
1112

1213
from models.common.responses.responses_api_params import ResponsesApiParams
14+
from models.config import SkillsConfiguration
1315
from pydantic_ai_lightspeed.llamastack import LlamaStackProvider
1416

1517
_LLS_RESPONSES_EXTRA_FIELDS: Final[frozenset[str]] = frozenset(
@@ -70,9 +72,46 @@ def _model_settings_from_responses_params(
7072
return cast(OpenAIResponsesModelSettings, settings_dict)
7173

7274

75+
def _skills_capability(
76+
skills_config: Optional[SkillsConfiguration],
77+
) -> Optional[SkillsCapability]:
78+
"""Return a skills capability when skill paths are configured.
79+
80+
Args:
81+
skills_config: Agent skills configuration from LCS, or None when skills are disabled.
82+
83+
Returns:
84+
SkillsCapability when skill paths are configured, or None when skills are disabled.
85+
"""
86+
if skills_config is None or not skills_config.paths:
87+
return None
88+
return SkillsCapability(
89+
directories=[str(path) for path in skills_config.paths],
90+
validate=False,
91+
)
92+
93+
94+
def _agent_capabilities(
95+
skills: Optional[SkillsConfiguration],
96+
) -> Optional[list[AgentCapability[None]]]:
97+
"""Assemble pydantic-ai capabilities for an LCS agent.
98+
99+
Args:
100+
skills: Agent skills configuration from LCS, or None when skills are disabled.
101+
102+
Returns:
103+
Configured capabilities, or None when no capabilities are enabled.
104+
"""
105+
capabilities: list[AgentCapability[None]] = []
106+
if skills_capability := _skills_capability(skills):
107+
capabilities.append(skills_capability)
108+
return capabilities or None
109+
110+
73111
def build_agent(
74112
client: AsyncLlamaStackClient | AsyncLlamaStackAsLibraryClient,
75113
responses_params: ResponsesApiParams,
114+
skills: Optional[SkillsConfiguration],
76115
) -> Agent[None, str]:
77116
"""Build a Pydantic AI agent that mirrors ``responses_params`` on the Llama Stack backend.
78117
@@ -84,6 +123,7 @@ def build_agent(
84123
Parameters:
85124
client: Initialized Llama Stack client from ``AsyncLlamaStackClientHolder().get_client()``.
86125
responses_params: Parameters produced by ``prepare_responses_params`` for this turn.
126+
skills: Agent skills configuration from LCS, or None when skills are disabled.
87127
88128
Returns:
89129
``Agent`` configured for ``await agent.run(...)`` (or streaming) against the same
@@ -100,5 +140,6 @@ def build_agent(
100140
return Agent(
101141
model,
102142
instructions=responses_params.instructions,
143+
capabilities=_agent_capabilities(skills),
103144
defer_model_check=True,
104145
)

tests/unit/conftest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
from __future__ import annotations
44

55
from collections.abc import Generator
6+
from pathlib import Path
67

8+
import httpx
79
import pytest
10+
from llama_stack_client import AsyncLlamaStackClient
811
from pytest_mock import AsyncMockType, MockerFixture
912

1013
from configuration import AppConfig
14+
from models.common.responses.responses_api_params import ResponsesApiParams
15+
from models.config import SkillsConfiguration
1116

1217
type AgentFixtures = Generator[
1318
tuple[
@@ -72,3 +77,41 @@ def minimal_config_fixture() -> AppConfig:
7277
}
7378
)
7479
return cfg
80+
81+
82+
@pytest.fixture(name="mock_client")
83+
def mock_client_fixture( # pylint: disable=protected-access
84+
mocker: MockerFixture,
85+
) -> AsyncLlamaStackClient:
86+
"""Remote Llama Stack client mock for build_agent tests."""
87+
client = mocker.Mock(spec=AsyncLlamaStackClient)
88+
client.base_url = "http://localhost:8321"
89+
client.api_key = "test-key"
90+
client._client = mocker.Mock(spec=httpx.AsyncClient)
91+
return client
92+
93+
94+
@pytest.fixture(name="mock_params")
95+
def mock_params_fixture() -> ResponsesApiParams:
96+
"""Minimal ResponsesApiParams for build_agent and similar utils tests."""
97+
return ResponsesApiParams(
98+
model="provider/my-model",
99+
input="test",
100+
conversation="conv-test",
101+
instructions="Be helpful.",
102+
store=False,
103+
stream=False,
104+
)
105+
106+
107+
@pytest.fixture(name="mock_skills_configuration")
108+
def mock_skills_configuration_fixture(tmp_path: Path) -> SkillsConfiguration:
109+
"""Filesystem-backed SkillsConfiguration with a single test skill."""
110+
skills_root = tmp_path / "skills"
111+
skill_dir = skills_root / "test-skill"
112+
skill_dir.mkdir(parents=True)
113+
(skill_dir / "SKILL.md").write_text(
114+
"---\nname: test-skill\ndescription: Test skill.\n---\n\nDo the thing.\n",
115+
encoding="utf-8",
116+
)
117+
return SkillsConfiguration(paths=[skills_root])

tests/unit/utils/agents/test_query.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,20 @@ def blocked_moderation_fixture() -> ShieldModerationBlocked:
116116
)
117117

118118

119+
@pytest.fixture(name="patch_query_configuration")
120+
def patch_query_configuration_fixture(mocker: MockerFixture) -> None:
121+
"""Patch query module configuration for isolated agent query tests."""
122+
mock_config = mocker.MagicMock()
123+
mock_config.skills = None
124+
mock_config.rag_id_mapping = {}
125+
mocker.patch("utils.agents.query.configuration", mock_config)
126+
127+
119128
@pytest.fixture(name="patch_recording_metrics")
120129
def patch_recording_metrics_fixture(mocker: MockerFixture) -> None:
121130
"""Patch LLM recording helpers so token usage tests stay isolated."""
122131
mock_config = mocker.MagicMock()
132+
mock_config.skills = None
123133
mock_config.rag_id_mapping = {}
124134
mocker.patch("utils.agents.query.configuration", mock_config)
125135
mocker.patch(
@@ -422,6 +432,7 @@ async def test_success_returns_turn_summary(
422432
assert summary.id == "resp-success"
423433

424434
@pytest.mark.asyncio
435+
@pytest.mark.usefixtures("patch_query_configuration")
425436
async def test_agent_connection_error_raises_http_exception(
426437
self,
427438
mocker: MockerFixture,
@@ -448,6 +459,7 @@ async def test_agent_connection_error_raises_http_exception(
448459
assert exc_info.value.status_code == 503
449460

450461
@pytest.mark.asyncio
462+
@pytest.mark.usefixtures("patch_query_configuration")
451463
async def test_api_status_error_raises_http_exception(
452464
self,
453465
mocker: MockerFixture,

tests/unit/utils/test_pydantic_ai.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@
55
import httpx
66
import pytest
77
from llama_stack.core.library_client import AsyncLlamaStackAsLibraryClient
8+
from llama_stack_client import AsyncLlamaStackClient
9+
from pydantic_ai_skills import SkillsCapability
810
from pytest_mock import MockerFixture
911

12+
from models.common.responses.responses_api_params import ResponsesApiParams
13+
from models.config import SkillsConfiguration
1014
from utils.pydantic_ai import (
1115
_LLS_RESPONSES_EXTRA_FIELDS,
16+
_agent_capabilities,
1217
_llama_stack_provider_from_client,
1318
_model_settings_from_responses_params,
19+
_skills_capability,
1420
build_agent,
1521
)
1622

@@ -196,6 +202,45 @@ def test_contains_expected_fields(self) -> None:
196202
assert expected == _LLS_RESPONSES_EXTRA_FIELDS
197203

198204

205+
class TestSkillsCapability:
206+
"""Tests for _skills_capability."""
207+
208+
def test_returns_none_when_skills_not_configured(self) -> None:
209+
"""Test that missing skills configuration returns None."""
210+
assert _skills_capability(None) is None
211+
212+
def test_returns_none_when_paths_empty(self) -> None:
213+
"""Test that an empty paths list returns None."""
214+
assert _skills_capability(SkillsConfiguration(paths=[])) is None
215+
216+
def test_returns_capability_for_configured_paths(
217+
self, mock_skills_configuration: SkillsConfiguration
218+
) -> None:
219+
"""Test that configured paths produce a SkillsCapability."""
220+
capability = _skills_capability(mock_skills_configuration)
221+
222+
assert isinstance(capability, SkillsCapability)
223+
assert list(capability.toolset.skills) == ["test-skill"]
224+
225+
226+
class TestAgentCapabilities:
227+
"""Tests for _agent_capabilities."""
228+
229+
def test_returns_none_when_no_capabilities_configured(self) -> None:
230+
"""Test that missing configuration yields None for Agent construction."""
231+
assert _agent_capabilities(None) is None
232+
assert _agent_capabilities(SkillsConfiguration(paths=[])) is None
233+
234+
def test_returns_skills_capability_when_configured(
235+
self, mock_skills_configuration: SkillsConfiguration
236+
) -> None:
237+
"""Test that configured skills are included in the capability list."""
238+
capabilities = _agent_capabilities(mock_skills_configuration) or []
239+
240+
assert len(capabilities) == 1
241+
assert isinstance(capabilities[0], SkillsCapability)
242+
243+
199244
class TestBuildAgent:
200245
"""Tests for the build_agent factory function."""
201246

@@ -220,7 +265,7 @@ def test_returns_agent_with_correct_model(self, mocker: MockerFixture) -> None:
220265
mock_params.store = False
221266
mock_params.previous_response_id = None
222267

223-
agent = build_agent(mock_client, mock_params)
268+
agent = build_agent(mock_client, mock_params, None)
224269

225270
assert agent is not None
226271

@@ -242,7 +287,7 @@ def test_agent_has_instructions(self, mocker: MockerFixture) -> None:
242287
mock_params.store = False
243288
mock_params.previous_response_id = None
244289

245-
agent = build_agent(mock_client, mock_params)
290+
agent = build_agent(mock_client, mock_params, None)
246291

247292
assert "You are a helpful assistant." in agent._instructions
248293

@@ -265,6 +310,37 @@ def test_agent_with_library_client(self, mocker: MockerFixture) -> None:
265310
mock_params.store = True
266311
mock_params.previous_response_id = None
267312

268-
agent = build_agent(mock_lib_client, mock_params)
313+
agent = build_agent(mock_lib_client, mock_params, None)
269314

270315
assert agent is not None
316+
317+
def test_agent_includes_skills_capability_when_configured(
318+
self,
319+
mock_client: AsyncLlamaStackClient,
320+
mock_params: ResponsesApiParams,
321+
mock_skills_configuration: SkillsConfiguration,
322+
) -> None:
323+
"""Test that build_agent attaches SkillsCapability when skills are passed."""
324+
agent = build_agent(
325+
mock_client,
326+
mock_params,
327+
mock_skills_configuration,
328+
)
329+
330+
capability_types = {
331+
type(capability) for capability in agent._root_capability.capabilities
332+
}
333+
assert SkillsCapability in capability_types
334+
335+
def test_agent_has_no_skills_capability_when_not_configured(
336+
self,
337+
mock_client: AsyncLlamaStackClient,
338+
mock_params: ResponsesApiParams,
339+
) -> None:
340+
"""Test that build_agent omits SkillsCapability when skills are not passed."""
341+
agent = build_agent(mock_client, mock_params, None)
342+
343+
capability_types = {
344+
type(capability) for capability in agent._root_capability.capabilities
345+
}
346+
assert SkillsCapability not in capability_types

uv.lock

Lines changed: 24 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)