pytest-skill-engineering 1.0.0__tar.gz → 1.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/PKG-INFO +1 -1
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/pyproject.toml +1 -1
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/__init__.py +3 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/eval.py +17 -1
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/fixtures.py +1 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/result.py +8 -1
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/runner.py +29 -2
- pytest_skill_engineering-1.0.2/src/pytest_skill_engineering/copilot/skills.py +104 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/__init__.py +4 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/plugin.py +1 -1
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/result.py +26 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/serialization.py +23 -1
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill.py +53 -18
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/.gitignore +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/LICENSE +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/README.md +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/__init__.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/api.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/client.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/config.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/contracts.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/controls.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/events.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/personas.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/requests.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/errors.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/evals.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/prompt.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill_evals.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill_grading.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/__init__.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/cost.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/rate_limiter.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/servers.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/skill_tools.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/__init__.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/factories.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/iteration.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/onboarding.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin_options.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin_report.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/__init__.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/collector.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/generator.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/schema.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/__init__.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/banking.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/banking_mcp.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/todo.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/todo_mcp.py +0 -0
- {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pytest-skill-engineering
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Reliable Copilot execution and evidence for coding-agent-led testing of MCP servers, CLI tools, skills, and custom agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sbroenne/pytest-skill-engineering
|
|
6
6
|
Project-URL: Repository, https://github.com/sbroenne/pytest-skill-engineering
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-skill-engineering"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.2"
|
|
8
8
|
description = "Reliable Copilot execution and evidence for coding-agent-led testing of MCP servers, CLI tools, skills, and custom agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -22,11 +22,14 @@ from pytest_skill_engineering.copilot.personas import (
|
|
|
22
22
|
from pytest_skill_engineering.copilot.requests import RequestAudit
|
|
23
23
|
from pytest_skill_engineering.copilot.result import CopilotResult, UsageInfo
|
|
24
24
|
from pytest_skill_engineering.core.evals import load_custom_agent, load_custom_agents
|
|
25
|
+
from pytest_skill_engineering.core.result import DiscoveredSkill, SkillDiscovery
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"CopilotEval",
|
|
28
29
|
"CopilotResult",
|
|
30
|
+
"DiscoveredSkill",
|
|
29
31
|
"RequestAudit",
|
|
32
|
+
"SkillDiscovery",
|
|
30
33
|
"UsageInfo",
|
|
31
34
|
"ClaudeCodePersona",
|
|
32
35
|
"CopilotCLIPersona",
|
|
@@ -139,7 +139,7 @@ class CopilotEval:
|
|
|
139
139
|
# display_name, tools, mcp_servers, infer)
|
|
140
140
|
custom_agents: list[CopilotCustomAgentConfig] = field(default_factory=list)
|
|
141
141
|
|
|
142
|
-
#
|
|
142
|
+
# Explicit skills are enabled and checked before model execution.
|
|
143
143
|
skill_directories: list[str] = field(default_factory=list)
|
|
144
144
|
disabled_skills: list[str] = field(default_factory=list)
|
|
145
145
|
|
|
@@ -235,8 +235,24 @@ class CopilotEval:
|
|
|
235
235
|
config["hooks"] = self.hooks
|
|
236
236
|
|
|
237
237
|
# Apply extra_config passthrough
|
|
238
|
+
if (
|
|
239
|
+
self.skill_directories
|
|
240
|
+
and "skill_directories" in self.extra_config
|
|
241
|
+
and self.extra_config["skill_directories"] != self.skill_directories
|
|
242
|
+
):
|
|
243
|
+
raise ValueError("extra_config must not override explicitly supplied skill_directories")
|
|
238
244
|
config.update(self.extra_config)
|
|
239
245
|
|
|
246
|
+
if config.get("skill_directories"):
|
|
247
|
+
if config.get("enable_skills") is False:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
"Explicit skill_directories conflict with enable_skills=False; "
|
|
250
|
+
"remove the directories for a no-skill baseline, or enable skills"
|
|
251
|
+
)
|
|
252
|
+
if config.get("enable_skills") is not None and config["enable_skills"] is not True:
|
|
253
|
+
raise ValueError("enable_skills must be a boolean or None")
|
|
254
|
+
config["enable_skills"] = True
|
|
255
|
+
|
|
240
256
|
if self.client_mode == "empty":
|
|
241
257
|
isolated = {
|
|
242
258
|
"enable_config_discovery": False,
|
|
@@ -12,7 +12,13 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from pytest_skill_engineering.copilot.contracts import CopilotResultAgent, SubagentInvocation
|
|
14
14
|
from pytest_skill_engineering.copilot.requests import RequestAudit
|
|
15
|
-
from pytest_skill_engineering.core.result import
|
|
15
|
+
from pytest_skill_engineering.core.result import (
|
|
16
|
+
SkillDiscovery,
|
|
17
|
+
StopReason,
|
|
18
|
+
ToolCall,
|
|
19
|
+
Turn,
|
|
20
|
+
UsageInfo,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
__all__ = [
|
|
18
24
|
"CopilotResult",
|
|
@@ -68,6 +74,7 @@ class CopilotResult:
|
|
|
68
74
|
request_audit: list[RequestAudit] = field(default_factory=list)
|
|
69
75
|
stop_reason: StopReason | None = None
|
|
70
76
|
tool_calls_admitted: int = 0
|
|
77
|
+
skill_discovery: SkillDiscovery | None = None
|
|
71
78
|
|
|
72
79
|
@property
|
|
73
80
|
def evidence_complete(self) -> bool:
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import asyncio
|
|
16
16
|
import logging
|
|
17
|
+
from pathlib import Path
|
|
17
18
|
from tempfile import TemporaryDirectory
|
|
18
19
|
from typing import TYPE_CHECKING
|
|
19
20
|
|
|
@@ -27,6 +28,8 @@ from pytest_skill_engineering.copilot.controls import RunControls
|
|
|
27
28
|
from pytest_skill_engineering.copilot.events import EventMapper
|
|
28
29
|
from pytest_skill_engineering.copilot.requests import RequestAuditHandler
|
|
29
30
|
from pytest_skill_engineering.copilot.result import CopilotResult, StopReason
|
|
31
|
+
from pytest_skill_engineering.copilot.skills import discover_requested_skills, requested_skill_files
|
|
32
|
+
from pytest_skill_engineering.core.result import SkillDiscovery
|
|
30
33
|
|
|
31
34
|
if TYPE_CHECKING:
|
|
32
35
|
from copilot.client import CopilotClient
|
|
@@ -80,8 +83,20 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
|
|
|
80
83
|
reason: StopReason = "completed"
|
|
81
84
|
error: str | None = None
|
|
82
85
|
cleanup_errors: list[str] = []
|
|
86
|
+
skill_discovery: SkillDiscovery | None = None
|
|
87
|
+
send_started = False
|
|
83
88
|
try:
|
|
84
89
|
session_config = agent.build_session_config()
|
|
90
|
+
skill_directories = session_config.get("skill_directories") or []
|
|
91
|
+
requested_skills: dict[Path, str] = {}
|
|
92
|
+
if skill_directories:
|
|
93
|
+
skill_discovery = SkillDiscovery()
|
|
94
|
+
if not isinstance(skill_directories, list):
|
|
95
|
+
raise ValueError("skill_directories must be a list of directory paths")
|
|
96
|
+
requested_skills = requested_skill_files(skill_directories)
|
|
97
|
+
session_config["skill_directories"] = [
|
|
98
|
+
str(Path(directory).resolve()) for directory in skill_directories
|
|
99
|
+
]
|
|
85
100
|
caller_hooks = dict(session_config.get("hooks") or {})
|
|
86
101
|
session_config["hooks"] = dict(caller_hooks)
|
|
87
102
|
# Empty mode must not read persona instruction files or inject tools.
|
|
@@ -123,12 +138,20 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
|
|
|
123
138
|
)
|
|
124
139
|
|
|
125
140
|
async def execute() -> None:
|
|
126
|
-
nonlocal session
|
|
141
|
+
nonlocal session, send_started
|
|
127
142
|
assert client is not None
|
|
128
143
|
async with asyncio.timeout(min(60, agent.timeout_s)):
|
|
129
144
|
await client.start()
|
|
130
145
|
async with asyncio.timeout(min(30, agent.timeout_s)):
|
|
131
146
|
session = await client.create_session(**session_config)
|
|
147
|
+
if skill_discovery is not None:
|
|
148
|
+
await discover_requested_skills(
|
|
149
|
+
session,
|
|
150
|
+
requested_skills,
|
|
151
|
+
session_config.get("disabled_skills") or [],
|
|
152
|
+
skill_discovery,
|
|
153
|
+
)
|
|
154
|
+
send_started = True
|
|
132
155
|
await session.send_and_wait(prompt, timeout=agent.timeout_s)
|
|
133
156
|
|
|
134
157
|
execution = asyncio.create_task(execute())
|
|
@@ -158,6 +181,9 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
|
|
|
158
181
|
except Exception as exc:
|
|
159
182
|
logger.error("Copilot execution failed: %s", exc)
|
|
160
183
|
reason, error = "execution_error", str(exc)
|
|
184
|
+
if skill_discovery is not None and not skill_discovery.complete:
|
|
185
|
+
error = f"Skill setup failed before model execution: {exc}"
|
|
186
|
+
skill_discovery.errors.append(error)
|
|
161
187
|
finally:
|
|
162
188
|
controls.closed = True
|
|
163
189
|
for watcher in watchers:
|
|
@@ -210,12 +236,13 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
|
|
|
210
236
|
|
|
211
237
|
result = mapper.build()
|
|
212
238
|
result.agent = agent
|
|
239
|
+
result.skill_discovery = skill_discovery
|
|
213
240
|
result.tool_calls_admitted = controls.admitted
|
|
214
241
|
if result.capture_errors:
|
|
215
242
|
result.capture_errors.extend(controls.incomplete_diagnostics(abort_phase=reason))
|
|
216
243
|
if audit is not None and capture_requests:
|
|
217
244
|
result.request_audit = list(audit.records)
|
|
218
|
-
if audit.error or (
|
|
245
|
+
if audit.error or (send_started and not audit.observed_requests):
|
|
219
246
|
audit_error = audit.error or "No outbound model requests captured; audit unsupported"
|
|
220
247
|
result.capture_errors.append(audit_error)
|
|
221
248
|
if reason == "completed" or audit.error:
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Validate explicit skill inputs and verify native SDK availability before sending."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pytest_skill_engineering.core.result import DiscoveredSkill, SkillDiscovery
|
|
10
|
+
from pytest_skill_engineering.core.skill import Skill, SkillError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from copilot.session import CopilotSession
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def requested_skill_files(directories: list[str]) -> dict[Path, str]:
|
|
19
|
+
"""Validate individual skill directories or parents of immediate skill directories.
|
|
20
|
+
|
|
21
|
+
Relative directories are resolved from the Python caller's current directory,
|
|
22
|
+
then passed to the SDK as absolute paths.
|
|
23
|
+
"""
|
|
24
|
+
requested: dict[Path, str] = {}
|
|
25
|
+
names: dict[str, Path] = {}
|
|
26
|
+
for raw_directory in directories:
|
|
27
|
+
if not isinstance(raw_directory, str) or not raw_directory.strip():
|
|
28
|
+
raise SkillError("skill_directories entries must be non-empty directory paths")
|
|
29
|
+
directory = Path(raw_directory).resolve()
|
|
30
|
+
if not directory.is_dir():
|
|
31
|
+
raise SkillError(
|
|
32
|
+
f"Requested skill directory does not exist or is not a directory: {directory}"
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
candidates = (
|
|
36
|
+
[directory]
|
|
37
|
+
if (directory / "SKILL.md").exists()
|
|
38
|
+
else [
|
|
39
|
+
child
|
|
40
|
+
for child in sorted(directory.iterdir())
|
|
41
|
+
if child.is_dir() and (child / "SKILL.md").exists()
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
if not candidates:
|
|
45
|
+
raise SkillError(f"No SKILL.md found in requested skill directory: {directory}")
|
|
46
|
+
for candidate in candidates:
|
|
47
|
+
skill = Skill.from_path(candidate)
|
|
48
|
+
path = (skill.path / "SKILL.md").resolve()
|
|
49
|
+
if skill.name in names and names[skill.name] != path:
|
|
50
|
+
raise SkillError(
|
|
51
|
+
f"Duplicate requested skill name '{skill.name}': "
|
|
52
|
+
f"{names[skill.name]} and {path}"
|
|
53
|
+
)
|
|
54
|
+
names[skill.name] = path
|
|
55
|
+
requested[path] = skill.name
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise SkillError(f"Cannot read requested skill directory {directory}: {exc}") from exc
|
|
58
|
+
return requested
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def discover_requested_skills(
|
|
62
|
+
session: CopilotSession,
|
|
63
|
+
requested: dict[Path, str],
|
|
64
|
+
disabled: list[str],
|
|
65
|
+
evidence: SkillDiscovery,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Record SDK discovery and reject missing or unexpectedly disabled requested skills."""
|
|
68
|
+
await session.rpc.skills.ensure_loaded()
|
|
69
|
+
diagnostics = await session.rpc.skills.reload()
|
|
70
|
+
evidence.warnings = list(diagnostics.warnings)
|
|
71
|
+
evidence.errors = list(diagnostics.errors)
|
|
72
|
+
for warning in evidence.warnings:
|
|
73
|
+
logger.warning("SDK skill discovery: %s", warning)
|
|
74
|
+
listed = await session.rpc.skills.list()
|
|
75
|
+
evidence.skills = [
|
|
76
|
+
DiscoveredSkill(
|
|
77
|
+
name=skill.name,
|
|
78
|
+
description=skill.description,
|
|
79
|
+
source=skill.source.value,
|
|
80
|
+
enabled=skill.enabled,
|
|
81
|
+
user_invocable=skill.user_invocable,
|
|
82
|
+
path=skill.path,
|
|
83
|
+
plugin_name=skill.plugin_name,
|
|
84
|
+
command_name=skill.command_name,
|
|
85
|
+
argument_hint=skill.argument_hint,
|
|
86
|
+
)
|
|
87
|
+
for skill in listed.skills
|
|
88
|
+
]
|
|
89
|
+
evidence.complete = True
|
|
90
|
+
available = {
|
|
91
|
+
(Path(skill.path).resolve(), skill.name): skill
|
|
92
|
+
for skill in evidence.skills
|
|
93
|
+
if skill.path is not None
|
|
94
|
+
}
|
|
95
|
+
for path, name in requested.items():
|
|
96
|
+
skill = available.get((path, name))
|
|
97
|
+
if skill is None:
|
|
98
|
+
evidence.errors.append(
|
|
99
|
+
f"Requested skill '{name}' was not discovered by the SDK: {path}"
|
|
100
|
+
)
|
|
101
|
+
elif not skill.enabled and name not in disabled:
|
|
102
|
+
evidence.errors.append(f"Requested skill '{name}' is unexpectedly disabled: {path}")
|
|
103
|
+
if evidence.errors:
|
|
104
|
+
raise SkillError("Skill setup failed before model execution: " + "; ".join(evidence.errors))
|
|
@@ -17,10 +17,12 @@ from pytest_skill_engineering.core.prompt import (
|
|
|
17
17
|
load_system_prompts,
|
|
18
18
|
)
|
|
19
19
|
from pytest_skill_engineering.core.result import (
|
|
20
|
+
DiscoveredSkill,
|
|
20
21
|
EvalResult,
|
|
21
22
|
ImageContent,
|
|
22
23
|
MCPPrompt,
|
|
23
24
|
MCPPromptArgument,
|
|
25
|
+
SkillDiscovery,
|
|
24
26
|
SkillInfo,
|
|
25
27
|
SubagentInvocation,
|
|
26
28
|
ToolCall,
|
|
@@ -38,6 +40,7 @@ from pytest_skill_engineering.core.skill_grading import export_grading
|
|
|
38
40
|
__all__ = [
|
|
39
41
|
"AITestError",
|
|
40
42
|
"EvalResult",
|
|
43
|
+
"DiscoveredSkill",
|
|
41
44
|
"EngineTimeoutError",
|
|
42
45
|
"HookDefinition",
|
|
43
46
|
"ImageContent",
|
|
@@ -48,6 +51,7 @@ __all__ = [
|
|
|
48
51
|
"Prompt",
|
|
49
52
|
"ServerStartError",
|
|
50
53
|
"Skill",
|
|
54
|
+
"SkillDiscovery",
|
|
51
55
|
"SkillError",
|
|
52
56
|
"SkillInfo",
|
|
53
57
|
"SkillMetadata",
|
|
@@ -341,7 +341,7 @@ def _discover_skills(plugin_dir: Path) -> list[Skill]:
|
|
|
341
341
|
|
|
342
342
|
|
|
343
343
|
def _validate_skill_reference_names(skills: list[Skill]) -> None:
|
|
344
|
-
"""Reject duplicate reference
|
|
344
|
+
"""Reject duplicate relative reference paths across loaded skills."""
|
|
345
345
|
owners: dict[str, str] = {}
|
|
346
346
|
for skill in skills:
|
|
347
347
|
for reference_name in skill.references:
|
|
@@ -181,6 +181,31 @@ class SkillInfo:
|
|
|
181
181
|
return f"SkillInfo({self.name}{refs})"
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
@dataclass(slots=True, frozen=True)
|
|
185
|
+
class DiscoveredSkill:
|
|
186
|
+
"""Skill metadata actually returned by the public SDK, not observed use."""
|
|
187
|
+
|
|
188
|
+
name: str
|
|
189
|
+
description: str
|
|
190
|
+
source: str
|
|
191
|
+
enabled: bool
|
|
192
|
+
user_invocable: bool
|
|
193
|
+
path: str | None = None
|
|
194
|
+
plugin_name: str | None = None
|
|
195
|
+
command_name: str | None = None
|
|
196
|
+
argument_hint: str | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(slots=True)
|
|
200
|
+
class SkillDiscovery:
|
|
201
|
+
"""Pre-execution discovery evidence, including incomplete or failed checks."""
|
|
202
|
+
|
|
203
|
+
skills: list[DiscoveredSkill] = field(default_factory=list)
|
|
204
|
+
warnings: list[str] = field(default_factory=list)
|
|
205
|
+
errors: list[str] = field(default_factory=list)
|
|
206
|
+
complete: bool = False
|
|
207
|
+
|
|
208
|
+
|
|
184
209
|
@dataclass(slots=True)
|
|
185
210
|
class ClarificationStats:
|
|
186
211
|
"""Statistics about clarification requests detected during execution.
|
|
@@ -280,6 +305,7 @@ class EvalResult:
|
|
|
280
305
|
# Recorded interface metadata.
|
|
281
306
|
available_tools: list[ToolInfo] = field(default_factory=list)
|
|
282
307
|
skill_info: SkillInfo | None = None
|
|
308
|
+
skill_discovery: SkillDiscovery | None = None
|
|
283
309
|
effective_system_prompt: str = ""
|
|
284
310
|
mcp_prompts: list[MCPPrompt] = field(default_factory=list)
|
|
285
311
|
prompt_name: str | None = None
|
|
@@ -56,7 +56,14 @@ def deserialize_suite_report(data: dict[str, Any]) -> SuiteReport:
|
|
|
56
56
|
Reconstructs the full dataclass hierarchy from the serialized format.
|
|
57
57
|
"""
|
|
58
58
|
from pytest_skill_engineering.copilot.requests import RequestAudit
|
|
59
|
-
from pytest_skill_engineering.core.result import
|
|
59
|
+
from pytest_skill_engineering.core.result import (
|
|
60
|
+
DiscoveredSkill,
|
|
61
|
+
EvalResult,
|
|
62
|
+
SkillDiscovery,
|
|
63
|
+
ToolCall,
|
|
64
|
+
Turn,
|
|
65
|
+
UsageInfo,
|
|
66
|
+
)
|
|
60
67
|
from pytest_skill_engineering.reporting.collector import SuiteReport, TestReport
|
|
61
68
|
|
|
62
69
|
# Reconstruct tests
|
|
@@ -214,6 +221,20 @@ def deserialize_suite_report(data: dict[str, Any]) -> SuiteReport:
|
|
|
214
221
|
)
|
|
215
222
|
|
|
216
223
|
# Reconstruct agent result
|
|
224
|
+
discovery_data = _require_key(ar_data, "skill_discovery", context="EvalResult")
|
|
225
|
+
skill_discovery = None
|
|
226
|
+
if discovery_data is not None:
|
|
227
|
+
skill_discovery = SkillDiscovery(
|
|
228
|
+
skills=[
|
|
229
|
+
DiscoveredSkill(**skill)
|
|
230
|
+
for skill in _require_key(
|
|
231
|
+
discovery_data, "skills", context="SkillDiscovery"
|
|
232
|
+
)
|
|
233
|
+
],
|
|
234
|
+
warnings=_require_key(discovery_data, "warnings", context="SkillDiscovery"),
|
|
235
|
+
errors=_require_key(discovery_data, "errors", context="SkillDiscovery"),
|
|
236
|
+
complete=_require_key(discovery_data, "complete", context="SkillDiscovery"),
|
|
237
|
+
)
|
|
217
238
|
eval_result = EvalResult(
|
|
218
239
|
turns=turns,
|
|
219
240
|
success=_require_key(ar_data, "success", context="EvalResult"),
|
|
@@ -228,6 +249,7 @@ def deserialize_suite_report(data: dict[str, Any]) -> SuiteReport:
|
|
|
228
249
|
assertions=assertions,
|
|
229
250
|
available_tools=available_tools,
|
|
230
251
|
skill_info=skill_info,
|
|
252
|
+
skill_discovery=skill_discovery,
|
|
231
253
|
effective_system_prompt=_require_key(
|
|
232
254
|
ar_data, "effective_system_prompt", context="EvalResult"
|
|
233
255
|
),
|
|
@@ -28,6 +28,7 @@ Example SKILL.md:
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
import re
|
|
31
|
+
import stat
|
|
31
32
|
from dataclasses import dataclass, field
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
|
|
@@ -163,7 +164,8 @@ class Skill:
|
|
|
163
164
|
path: Path to skill directory or SKILL.md file
|
|
164
165
|
|
|
165
166
|
Returns:
|
|
166
|
-
Loaded Skill instance
|
|
167
|
+
Loaded Skill instance. Reference keys are slash-separated paths
|
|
168
|
+
relative to references/, including any nested subdirectories.
|
|
167
169
|
|
|
168
170
|
Raises:
|
|
169
171
|
SkillError: If skill cannot be loaded or is invalid
|
|
@@ -197,7 +199,7 @@ class Skill:
|
|
|
197
199
|
|
|
198
200
|
for name in ("references", "scripts", "assets"):
|
|
199
201
|
component = skill_dir / name
|
|
200
|
-
if component.exists() and not component.is_dir():
|
|
202
|
+
if (component.exists() or component.is_symlink()) and not component.is_dir():
|
|
201
203
|
raise SkillError(f"{component}: skill component must be a directory")
|
|
202
204
|
|
|
203
205
|
# Load references if directory exists
|
|
@@ -320,27 +322,59 @@ def _parse_skill_md(content: str) -> tuple[SkillMetadata, str]:
|
|
|
320
322
|
|
|
321
323
|
|
|
322
324
|
def _load_references(refs_dir: Path) -> dict[str, str]:
|
|
323
|
-
"""Load
|
|
325
|
+
"""Load Markdown files recursively, without escaping references/ or following cycles.
|
|
324
326
|
|
|
325
327
|
Returns:
|
|
326
|
-
Dict mapping
|
|
328
|
+
Dict mapping slash-separated paths relative to references/ to content
|
|
327
329
|
"""
|
|
330
|
+
root = _resolve_reference_path(refs_dir, refs_dir.parent.resolve())
|
|
328
331
|
references: dict[str, str] = {}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
raise SkillError(f"Invalid references entry (must be a file): {file_path}")
|
|
333
|
-
if file_path.suffix.lower() != ".md":
|
|
334
|
-
raise SkillError(f"Invalid reference file '{file_path}': only .md files are allowed")
|
|
332
|
+
pending = [(refs_dir, frozenset({root}))]
|
|
333
|
+
while pending:
|
|
334
|
+
directory, ancestors = pending.pop()
|
|
335
335
|
try:
|
|
336
|
-
|
|
337
|
-
except
|
|
338
|
-
raise SkillError(f"
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
336
|
+
entries = sorted(directory.iterdir())
|
|
337
|
+
except OSError as exc:
|
|
338
|
+
raise SkillError(f"Cannot read references directory {directory}: {exc}") from exc
|
|
339
|
+
for file_path in entries:
|
|
340
|
+
resolved = _resolve_reference_path(file_path, root)
|
|
341
|
+
try:
|
|
342
|
+
mode = resolved.stat().st_mode
|
|
343
|
+
except OSError as exc:
|
|
344
|
+
raise SkillError(f"Cannot inspect reference entry {file_path}: {exc}") from exc
|
|
345
|
+
if stat.S_ISDIR(mode):
|
|
346
|
+
if resolved in ancestors:
|
|
347
|
+
raise SkillError(f"Reference directory cycle: {file_path}")
|
|
348
|
+
pending.append((file_path, ancestors | {resolved}))
|
|
349
|
+
continue
|
|
350
|
+
if not stat.S_ISREG(mode):
|
|
351
|
+
raise SkillError(f"Invalid references entry (must be a file): {file_path}")
|
|
352
|
+
if file_path.suffix.lower() != ".md":
|
|
353
|
+
raise SkillError(
|
|
354
|
+
f"Invalid reference file '{file_path}': only .md files are allowed"
|
|
355
|
+
)
|
|
356
|
+
try:
|
|
357
|
+
content = resolved.read_text(encoding="utf-8")
|
|
358
|
+
except UnicodeDecodeError as exc:
|
|
359
|
+
raise SkillError(f"Reference file must be valid UTF-8 text: {file_path}") from exc
|
|
360
|
+
except OSError as exc:
|
|
361
|
+
raise SkillError(f"Cannot read reference file {file_path}: {exc}") from exc
|
|
362
|
+
if not content.strip():
|
|
363
|
+
raise SkillError(f"Reference file must not be empty: {file_path}")
|
|
364
|
+
references[file_path.relative_to(refs_dir).as_posix()] = content
|
|
365
|
+
|
|
366
|
+
return dict(sorted(references.items()))
|
|
342
367
|
|
|
343
|
-
|
|
368
|
+
|
|
369
|
+
def _resolve_reference_path(path: Path, root: Path) -> Path:
|
|
370
|
+
"""Resolve links before traversal or reads, keeping their targets within the root."""
|
|
371
|
+
try:
|
|
372
|
+
resolved = path.resolve(strict=True)
|
|
373
|
+
except (OSError, RuntimeError) as exc:
|
|
374
|
+
raise SkillError(f"Cannot resolve reference path {path}: {exc}") from exc
|
|
375
|
+
if not resolved.is_relative_to(root):
|
|
376
|
+
raise SkillError(f"Reference path escapes its root {root}: {path}")
|
|
377
|
+
return resolved
|
|
344
378
|
|
|
345
379
|
|
|
346
380
|
_SCRIPT_EXTENSIONS = frozenset({".py", ".sh", ".js", ".bash"})
|
|
@@ -375,7 +409,8 @@ def _load_scripts(scripts_dir: Path) -> dict[str, str]:
|
|
|
375
409
|
def load_skill(path: Path | str) -> Skill:
|
|
376
410
|
"""Load a skill from a path.
|
|
377
411
|
|
|
378
|
-
Convenience function wrapping Skill.from_path().
|
|
412
|
+
Convenience function wrapping Skill.from_path(). This is the runner's local
|
|
413
|
+
skill validation, without SDK startup or model execution.
|
|
379
414
|
|
|
380
415
|
Args:
|
|
381
416
|
path: Path to skill directory or SKILL.md file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|