diff --git a/docs/openapi.json b/docs/openapi.json index ade470712..59e33ad4e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -12081,6 +12081,18 @@ "$ref": "#/components/schemas/RerankerConfiguration", "title": "Reranker configuration", "description": "Configuration for neural reranking of RAG chunks using cross-encoder." + }, + "skills": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillsConfiguration" + }, + { + "type": "null" + } + ], + "title": "Agent skills", + "description": "Agent skills configuration. Specifies paths to skill directories." } }, "additionalProperties": false, @@ -19381,6 +19393,23 @@ } ] }, + "SkillsConfiguration": { + "properties": { + "paths": { + "items": { + "type": "string", + "format": "path" + }, + "type": "array", + "title": "Skill paths", + "description": "Paths to skill directories or directories containing skill subdirectories." + } + }, + "additionalProperties": false, + "type": "object", + "title": "SkillsConfiguration", + "description": "Agent skills configuration.\n\nSpecifies paths to skill directories. Skill metadata (name, description)\nis read from SKILL.md frontmatter at startup.\n\nEach path can point to either:\n- A directory containing a SKILL.md file (single skill)\n- A directory containing subdirectories with SKILL.md files (multiple skills)\n\nPaths are validated at startup to ensure they exist and contain valid SKILL.md files." + }, "SolrVectorSearchRequest": { "properties": { "mode": { diff --git a/examples/lightspeed-stack-skills.yaml b/examples/lightspeed-stack-skills.yaml new file mode 100644 index 000000000..3f33488b2 --- /dev/null +++ b/examples/lightspeed-stack-skills.yaml @@ -0,0 +1,31 @@ +name: Lightspeed Core Service (LCS) with Skills +service: + host: localhost + port: 8080 + auth_enabled: false + workers: 1 + color_log: true + access_log: true +llama_stack: + use_as_library_client: true + library_client_config_path: run.yaml +user_data_collection: + feedback_enabled: true + feedback_storage: "/tmp/data/feedback" + transcripts_enabled: true + transcripts_storage: "/tmp/data/transcripts" +authentication: + module: "noop" +# Agent skills configuration +# Skills provide domain-specific instructions and reference materials +# that the LLM can load on demand when relevant to the current task +skills: + paths: + # Option A: Directory containing multiple skill subdirectories + # Each subdirectory must contain a SKILL.md file + - "/var/skills/" + + # Option B: Individual skill paths for fine-grained control + # - "/var/skills/openshift-troubleshooting/" + # - "/var/skills/code-review/" + # - "/opt/custom-skills/deployment-guide/" diff --git a/src/models/config.py b/src/models/config.py index c245a4da3..76a80fb3c 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -1944,6 +1944,26 @@ class AzureEntraIdConfiguration(ConfigurationBase): ) +class SkillsConfiguration(ConfigurationBase): + """Agent skills configuration. + + Specifies paths to skill directories. Skill metadata (name, description) + is read from SKILL.md frontmatter at startup. + + Each path can point to either: + - A directory containing a SKILL.md file (single skill) + - A directory containing subdirectories with SKILL.md files (multiple skills) + + Paths are validated at startup to ensure they exist and contain valid SKILL.md files. + """ + + paths: list[Path] = Field( + default_factory=list, + title="Skill paths", + description="Paths to skill directories or directories containing skill subdirectories.", + ) + + class Configuration(ConfigurationBase): """Global service configuration.""" @@ -2110,6 +2130,12 @@ class Configuration(ConfigurationBase): description="Configuration for neural reranking of RAG chunks using cross-encoder.", ) + skills: Optional[SkillsConfiguration] = Field( + default=None, + title="Agent skills", + description="Agent skills configuration. Specifies paths to skill directories.", + ) + @model_validator(mode="after") def validate_mcp_auth_headers(self) -> Self: """ diff --git a/tests/unit/models/config/test_dump_configuration.py b/tests/unit/models/config/test_dump_configuration.py index 371e0d459..164b5869b 100644 --- a/tests/unit/models/config/test_dump_configuration.py +++ b/tests/unit/models/config/test_dump_configuration.py @@ -21,6 +21,7 @@ QuotaLimiterConfiguration, QuotaSchedulerConfiguration, ServiceConfiguration, + SkillsConfiguration, TLSConfiguration, UserDataCollection, ) @@ -235,6 +236,7 @@ def test_dump_configuration(tmp_path: Path) -> None: "enabled": False, "model": "cross-encoder/ms-marco-MiniLM-L6-v2", }, + "skills": None, } @@ -606,6 +608,7 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None: "enabled": False, "model": "cross-encoder/ms-marco-MiniLM-L6-v2", }, + "skills": None, } @@ -853,6 +856,7 @@ def test_dump_configuration_with_quota_limiters_different_values( "enabled": False, "model": "cross-encoder/ms-marco-MiniLM-L6-v2", }, + "skills": None, } @@ -1075,6 +1079,7 @@ def test_dump_configuration_byok(tmp_path: Path) -> None: "enabled": False, "model": "cross-encoder/ms-marco-MiniLM-L6-v2", }, + "skills": None, } @@ -1282,4 +1287,84 @@ def test_dump_configuration_pg_namespace(tmp_path: Path) -> None: "enabled": False, "model": "cross-encoder/ms-marco-MiniLM-L6-v2", }, + "skills": None, + } + + +def test_dump_configuration_with_skills(tmp_path: Path) -> None: + """ + Test that Configuration with skills paths can be serialized to JSON. + + Verifies that skills paths are properly dumped and serialized as strings. + """ + cfg = Configuration( + name="test_name", + service=ServiceConfiguration( + tls_config=TLSConfiguration( + tls_certificate_path=Path("tests/configuration/server.crt"), + tls_key_path=Path("tests/configuration/server.key"), + tls_key_password=Path("tests/configuration/password"), + ), + cors=CORSConfiguration( + allow_origins=["foo_origin", "bar_origin", "baz_origin"], + allow_credentials=False, + allow_methods=["foo_method", "bar_method", "baz_method"], + allow_headers=["foo_header", "bar_header", "baz_header"], + ), + ), + llama_stack=LlamaStackConfiguration( + use_as_library_client=True, + library_client_config_path="tests/configuration/run.yaml", + api_key=SecretStr("whatever"), + ), + user_data_collection=UserDataCollection( + feedback_enabled=False, feedback_storage=None + ), + database=DatabaseConfiguration( + sqlite=None, + postgres=PostgreSQLDatabaseConfiguration( + db="lightspeed_stack", + user="ls_user", + password=SecretStr("ls_password"), + port=5432, + ca_cert_path=None, + ssl_mode="require", + gss_encmode="disable", + ), + ), + mcp_servers=[], + customization=None, + inference=InferenceConfiguration( + default_provider="default_provider", + default_model="default_model", + ), + skills=SkillsConfiguration( + paths=[ + "/var/skills/openshift-troubleshooting", + "/var/skills/code-review", + "/opt/custom-skills", + ] + ), + ) + assert cfg is not None + dump_file = tmp_path / "test.json" + cfg.dump(dump_file) + + with open(dump_file, "r", encoding="utf-8") as fin: + content = json.load(fin) + # content should be loaded + assert content is not None + + # skills section must exist + assert "skills" in content + assert content["skills"] is not None + assert "paths" in content["skills"] + + # verify skills paths are properly serialized + assert content["skills"] == { + "paths": [ + "/var/skills/openshift-troubleshooting", + "/var/skills/code-review", + "/opt/custom-skills", + ] } diff --git a/tests/unit/models/config/test_skills_configuration.py b/tests/unit/models/config/test_skills_configuration.py new file mode 100644 index 000000000..074c69e05 --- /dev/null +++ b/tests/unit/models/config/test_skills_configuration.py @@ -0,0 +1,49 @@ +"""Unit tests for SkillsConfiguration model.""" + +# pylint: disable=no-member +# Pydantic Field(default_factory=...) pattern confuses pylint's static analysis + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from models.config import SkillsConfiguration + + +class TestSkillsConfiguration: + """Tests for SkillsConfiguration model.""" + + def test_empty_paths_list(self) -> None: + """Test that an explicit empty paths list is allowed.""" + config = SkillsConfiguration(paths=[]) + assert config.paths == [] + + def test_no_unknown_fields_allowed(self) -> None: + """Test that SkillsConfiguration rejects unknown fields.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + SkillsConfiguration(unknown_field="value") # type: ignore[call-arg] + + def test_skill_paths(self) -> None: + """Test configuration with multiple skill paths.""" + config = SkillsConfiguration( + paths=[ + "/var/skills/openshift-troubleshooting", + "/var/skills/code-review", + "/opt/custom-skills", + ] + ) + assert len(config.paths) == 3 + assert Path("/var/skills/openshift-troubleshooting") in config.paths + assert Path("/var/skills/code-review") in config.paths + assert Path("/opt/custom-skills") in config.paths + + def test_mixed_absolute_and_relative_paths(self) -> None: + """Test that both absolute and relative paths can be mixed.""" + config = SkillsConfiguration( + paths=["/var/skills", "./local-skills", "/opt/skills"] + ) + assert len(config.paths) == 3 + assert Path("/var/skills") in config.paths + assert Path("./local-skills") in config.paths + assert Path("/opt/skills") in config.paths