pytest-skill-engineering 1.0.2__tar.gz → 1.0.3__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.2 → pytest_skill_engineering-1.0.3}/PKG-INFO +1 -1
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/pyproject.toml +1 -1
- pytest_skill_engineering-1.0.3/src/pytest_skill_engineering/copilot/config.py +133 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/contracts.py +8 -21
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/eval.py +15 -28
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/events.py +72 -3
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/fixtures.py +43 -43
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/personas.py +23 -25
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/result.py +57 -5
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/runner.py +6 -1
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/evals.py +34 -5
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/plugin.py +2 -85
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/result.py +36 -8
- pytest_skill_engineering-1.0.3/src/pytest_skill_engineering/core/serialization.py +128 -0
- pytest_skill_engineering-1.0.3/src/pytest_skill_engineering/core/validation.py +83 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/cost.py +5 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/servers.py +9 -9
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/plugin.py +57 -29
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/collector.py +10 -4
- pytest_skill_engineering-1.0.2/src/pytest_skill_engineering/copilot/config.py +0 -57
- pytest_skill_engineering-1.0.2/src/pytest_skill_engineering/core/serialization.py +0 -309
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/.gitignore +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/LICENSE +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/README.md +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/api.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/client.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/controls.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/requests.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/copilot/skills.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/errors.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/prompt.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/skill.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/skill_evals.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/core/skill_grading.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/rate_limiter.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/execution/skill_tools.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/fixtures/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/fixtures/factories.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/fixtures/iteration.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/onboarding.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/plugin_options.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/plugin_report.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/generator.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/reporting/schema.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/__init__.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/banking.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/banking_mcp.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/todo.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/src/pytest_skill_engineering/testing/todo_mcp.py +0 -0
- {pytest_skill_engineering-1.0.2 → pytest_skill_engineering-1.0.3}/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.3
|
|
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.3"
|
|
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" }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Configuration file loaders for Copilot and Claude Code projects.
|
|
2
|
+
|
|
3
|
+
Provides utilities for loading MCP server configurations from standard
|
|
4
|
+
config files (``.mcp.json``, ``.vscode/mcp.json``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from pytest_skill_engineering.core.plugin import _read_json
|
|
14
|
+
from pytest_skill_engineering.core.validation import _validate_mcp_servers
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pytest_skill_engineering.copilot.contracts import CopilotEvalConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def snapshot_session_configuration(
|
|
21
|
+
agent: CopilotEvalConfig, session_config: dict[str, Any]
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Capture prepared settings without handlers, credentials, or server environments."""
|
|
24
|
+
system = session_config.get("system_message") or {}
|
|
25
|
+
snapshot = {
|
|
26
|
+
"name": agent.name,
|
|
27
|
+
"model": session_config.get("model"),
|
|
28
|
+
"instructions": system.get("content"),
|
|
29
|
+
"system_message_mode": system.get("mode"),
|
|
30
|
+
"reasoning_effort": session_config.get("reasoning_effort"),
|
|
31
|
+
"client_mode": agent.client_mode,
|
|
32
|
+
"image_detail": agent.image_detail,
|
|
33
|
+
"audit_requests": agent.audit_requests,
|
|
34
|
+
"max_tool_calls": agent.max_tool_calls,
|
|
35
|
+
"max_turns": agent.max_turns,
|
|
36
|
+
"timeout_s": agent.timeout_s,
|
|
37
|
+
"auto_confirm": agent.auto_confirm,
|
|
38
|
+
"allowed_tools": session_config.get("available_tools"),
|
|
39
|
+
"excluded_tools": session_config.get("excluded_tools"),
|
|
40
|
+
"skill_directories": session_config.get("skill_directories", []),
|
|
41
|
+
"disabled_skills": session_config.get("disabled_skills", []),
|
|
42
|
+
"active_agent": session_config.get("agent"),
|
|
43
|
+
"working_directory": session_config.get("working_directory"),
|
|
44
|
+
"persona": type(agent.persona).__name__,
|
|
45
|
+
"tools": [
|
|
46
|
+
{
|
|
47
|
+
"name": tool.name,
|
|
48
|
+
"description": tool.description,
|
|
49
|
+
"parameters": tool.parameters,
|
|
50
|
+
"metadata": tool.metadata,
|
|
51
|
+
}
|
|
52
|
+
for tool in session_config.get("tools") or []
|
|
53
|
+
],
|
|
54
|
+
"mcp_servers": {
|
|
55
|
+
name: {"type": config.get("type"), "tools": config.get("tools")}
|
|
56
|
+
for name, config in (session_config.get("mcp_servers") or {}).items()
|
|
57
|
+
},
|
|
58
|
+
"custom_agents": [
|
|
59
|
+
{
|
|
60
|
+
key: value
|
|
61
|
+
for key, value in custom.items()
|
|
62
|
+
if key
|
|
63
|
+
in {
|
|
64
|
+
"name",
|
|
65
|
+
"prompt",
|
|
66
|
+
"description",
|
|
67
|
+
"display_name",
|
|
68
|
+
"model",
|
|
69
|
+
"reasoning_effort",
|
|
70
|
+
"tools",
|
|
71
|
+
"infer",
|
|
72
|
+
"skills",
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for custom in session_config.get("custom_agents") or []
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
for key in (
|
|
79
|
+
"enable_config_discovery",
|
|
80
|
+
"enable_file_hooks",
|
|
81
|
+
"enable_on_demand_instruction_discovery",
|
|
82
|
+
"skip_custom_instructions",
|
|
83
|
+
"enable_managed_settings",
|
|
84
|
+
"enable_skills",
|
|
85
|
+
):
|
|
86
|
+
if key in session_config:
|
|
87
|
+
snapshot[key] = session_config[key]
|
|
88
|
+
return deepcopy(snapshot)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_mcp_config(path: str | Path) -> dict[str, dict[str, Any]]:
|
|
92
|
+
"""Load MCP server configs from a ``.mcp.json`` file.
|
|
93
|
+
|
|
94
|
+
Supports both Claude Code / root-level ``.mcp.json`` and VS Code
|
|
95
|
+
``.vscode/mcp.json`` formats. Handles two common top-level keys:
|
|
96
|
+
|
|
97
|
+
* ``mcpServers`` — Claude Code / standard MCP convention
|
|
98
|
+
* ``servers`` — VS Code ``mcp.json`` convention
|
|
99
|
+
|
|
100
|
+
Exactly one supported key must be present. An explicit empty mapping is valid;
|
|
101
|
+
missing keys, conflicting keys, and malformed server definitions raise errors.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dict of ``server_name → config`` compatible with
|
|
105
|
+
``CopilotEval(mcp_servers=...)``.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
FileNotFoundError: If the file does not exist.
|
|
109
|
+
ValueError: If the file is not valid JSON or has unexpected structure.
|
|
110
|
+
|
|
111
|
+
Example::
|
|
112
|
+
|
|
113
|
+
from pytest_skill_engineering.copilot.config import load_mcp_config
|
|
114
|
+
|
|
115
|
+
servers = load_mcp_config(".mcp.json")
|
|
116
|
+
agent = CopilotEval(mcp_servers=servers)
|
|
117
|
+
"""
|
|
118
|
+
path = Path(path)
|
|
119
|
+
if not path.is_file():
|
|
120
|
+
msg = f"MCP config file not found: {path}"
|
|
121
|
+
raise FileNotFoundError(msg)
|
|
122
|
+
|
|
123
|
+
raw = _read_json(path)
|
|
124
|
+
|
|
125
|
+
if not isinstance(raw, dict):
|
|
126
|
+
msg = f"{path}: MCP config must be a JSON object, got {type(raw).__name__}"
|
|
127
|
+
raise ValueError(msg)
|
|
128
|
+
|
|
129
|
+
fields = [key for key in ("mcpServers", "servers") if key in raw]
|
|
130
|
+
if len(fields) != 1:
|
|
131
|
+
raise ValueError(f"{path}: specify exactly one of 'mcpServers' and 'servers'")
|
|
132
|
+
|
|
133
|
+
return _validate_mcp_servers(raw[fields[0]], path, field=fields[0])
|
|
@@ -3,24 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
-
from dataclasses import dataclass
|
|
7
6
|
from typing import Any, Literal, Protocol, TypeAlias
|
|
8
7
|
|
|
9
8
|
from copilot.session import CustomAgentConfig, MCPServerConfig, ReasoningEffort, SessionHooks
|
|
10
9
|
|
|
10
|
+
from pytest_skill_engineering.copilot.result import CopilotResult, CopilotResultAgent
|
|
11
|
+
|
|
11
12
|
CopilotCustomAgentConfig: TypeAlias = CustomAgentConfig
|
|
12
13
|
CopilotMCPServerConfig: TypeAlias = MCPServerConfig
|
|
13
14
|
CopilotReasoningEffort: TypeAlias = ReasoningEffort
|
|
14
15
|
CopilotSessionHooks: TypeAlias = SessionHooks
|
|
15
|
-
SubagentStatus: TypeAlias = Literal["selected", "started", "completed", "failed"]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class CopilotResultAgent(Protocol):
|
|
19
|
-
"""Minimal agent surface stored on :class:`CopilotResult`."""
|
|
20
|
-
|
|
21
|
-
@property
|
|
22
|
-
def working_directory(self) -> str | None:
|
|
23
|
-
raise NotImplementedError
|
|
24
16
|
|
|
25
17
|
|
|
26
18
|
class CopilotRunResult(Protocol):
|
|
@@ -53,6 +45,11 @@ class CopilotEventMapper(Protocol):
|
|
|
53
45
|
def record_subagent_failed(self, *, invocation_id: str, name: str) -> None:
|
|
54
46
|
raise NotImplementedError
|
|
55
47
|
|
|
48
|
+
def record_subagent_result(
|
|
49
|
+
self, *, invocation_id: str, name: str, result: CopilotResult
|
|
50
|
+
) -> None:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
56
53
|
|
|
57
54
|
class CopilotEvalConfig(CopilotResultAgent, Protocol):
|
|
58
55
|
"""Configuration surface consumed by the runner and personas."""
|
|
@@ -141,7 +138,7 @@ class CopilotEvalConfig(CopilotResultAgent, Protocol):
|
|
|
141
138
|
|
|
142
139
|
CopilotNestedRunner: TypeAlias = Callable[
|
|
143
140
|
[CopilotEvalConfig, str],
|
|
144
|
-
Awaitable[
|
|
141
|
+
Awaitable["CopilotResult"],
|
|
145
142
|
]
|
|
146
143
|
|
|
147
144
|
|
|
@@ -158,16 +155,6 @@ class CopilotPersona(Protocol):
|
|
|
158
155
|
raise NotImplementedError
|
|
159
156
|
|
|
160
157
|
|
|
161
|
-
@dataclass(slots=True)
|
|
162
|
-
class SubagentInvocation:
|
|
163
|
-
"""A single custom-agent dispatch observed during a run."""
|
|
164
|
-
|
|
165
|
-
invocation_id: str
|
|
166
|
-
name: str
|
|
167
|
-
status: SubagentStatus
|
|
168
|
-
duration_ms: float | None = None
|
|
169
|
-
|
|
170
|
-
|
|
171
158
|
def declared_agent_tools(
|
|
172
159
|
agent_config: CopilotCustomAgentConfig,
|
|
173
160
|
) -> list[str] | None:
|
|
@@ -18,7 +18,6 @@ from pytest_skill_engineering.copilot.personas import ClaudeCodePersona, VSCodeP
|
|
|
18
18
|
from pytest_skill_engineering.core.evals import load_custom_agent, load_instruction_file
|
|
19
19
|
from pytest_skill_engineering.core.plugin import (
|
|
20
20
|
_discover_skills,
|
|
21
|
-
_validate_mcp_servers,
|
|
22
21
|
_validate_skill_reference_names,
|
|
23
22
|
)
|
|
24
23
|
|
|
@@ -34,30 +33,20 @@ def _parse_agent_file(path: Path) -> CopilotCustomAgentConfig:
|
|
|
34
33
|
fields and uses the Markdown body as the custom agent's system prompt.
|
|
35
34
|
Parsing and metadata errors retain the definition's file path.
|
|
36
35
|
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
agent: CopilotCustomAgentConfig = {
|
|
40
|
-
"name": frontmatter.get("name", loaded["name"]),
|
|
41
|
-
"prompt": loaded["prompt"],
|
|
42
|
-
}
|
|
43
|
-
for key in (
|
|
44
|
-
"description",
|
|
45
|
-
"display_name",
|
|
46
|
-
"infer",
|
|
47
|
-
"skills",
|
|
48
|
-
"model",
|
|
49
|
-
"reasoning_effort",
|
|
50
|
-
"tools",
|
|
51
|
-
):
|
|
52
|
-
if key in frontmatter:
|
|
53
|
-
agent[key] = frontmatter[key]
|
|
54
|
-
if "mcp-servers" in frontmatter:
|
|
55
|
-
agent["mcp_servers"] = cast(
|
|
56
|
-
dict[str, CopilotMCPServerConfig],
|
|
57
|
-
_validate_mcp_servers(frontmatter["mcp-servers"], path, field="mcp-servers"),
|
|
58
|
-
)
|
|
36
|
+
return cast(CopilotCustomAgentConfig, load_custom_agent(path))
|
|
37
|
+
|
|
59
38
|
|
|
60
|
-
|
|
39
|
+
def _parse_agent_files(paths: list[Path]) -> list[CopilotCustomAgentConfig]:
|
|
40
|
+
agents: list[CopilotCustomAgentConfig] = []
|
|
41
|
+
names: set[str] = set()
|
|
42
|
+
for path in paths:
|
|
43
|
+
agent = _parse_agent_file(path)
|
|
44
|
+
name = require_custom_agent_name(agent)
|
|
45
|
+
if name in names:
|
|
46
|
+
raise ValueError(f"{path}: Duplicate custom agent name '{name}'")
|
|
47
|
+
names.add(name)
|
|
48
|
+
agents.append(agent)
|
|
49
|
+
return agents
|
|
61
50
|
|
|
62
51
|
|
|
63
52
|
def _default_persona() -> CopilotPersona:
|
|
@@ -361,8 +350,7 @@ class CopilotEval:
|
|
|
361
350
|
if agents_dir.exists():
|
|
362
351
|
if not agents_dir.is_dir():
|
|
363
352
|
raise ValueError(f"{agents_dir}: custom agents path must be a directory")
|
|
364
|
-
|
|
365
|
-
agents.append(_parse_agent_file(agent_file))
|
|
353
|
+
agents = _parse_agent_files(sorted(agents_dir.rglob("*.agent.md")))
|
|
366
354
|
|
|
367
355
|
config: dict[str, Any] = {
|
|
368
356
|
"instructions": instructions,
|
|
@@ -527,8 +515,7 @@ class CopilotEval:
|
|
|
527
515
|
if not agents_dir.is_dir():
|
|
528
516
|
raise ValueError(f"{agents_dir}: custom agents path must be a directory")
|
|
529
517
|
# Claude Code uses plain .md files for agents
|
|
530
|
-
|
|
531
|
-
agents.append(_parse_agent_file(agent_file))
|
|
518
|
+
agents = _parse_agent_files(sorted(agents_dir.glob("*.md")))
|
|
532
519
|
|
|
533
520
|
# 3. Discover skill directories from .claude/skills/
|
|
534
521
|
skills = _discover_skills(claude_dir)
|
|
@@ -56,20 +56,23 @@ SDK Event Types (38 values) grouped by what they map to:
|
|
|
56
56
|
|
|
57
57
|
from __future__ import annotations
|
|
58
58
|
|
|
59
|
+
import base64
|
|
60
|
+
import binascii
|
|
59
61
|
import logging
|
|
60
62
|
import time
|
|
61
63
|
from collections import deque
|
|
62
64
|
from datetime import timedelta
|
|
63
65
|
from typing import TYPE_CHECKING, Any
|
|
64
66
|
|
|
65
|
-
from pytest_skill_engineering.copilot.contracts import SubagentStatus
|
|
66
67
|
from pytest_skill_engineering.copilot.result import (
|
|
67
68
|
CopilotResult,
|
|
68
69
|
SubagentInvocation,
|
|
70
|
+
SubagentStatus,
|
|
69
71
|
ToolCall,
|
|
70
72
|
Turn,
|
|
71
73
|
UsageInfo,
|
|
72
74
|
)
|
|
75
|
+
from pytest_skill_engineering.core.result import ImageContent
|
|
73
76
|
|
|
74
77
|
if TYPE_CHECKING:
|
|
75
78
|
from copilot.generated.session_events import SessionEvent
|
|
@@ -162,7 +165,12 @@ class EventMapper:
|
|
|
162
165
|
permissions=self._permissions,
|
|
163
166
|
model_used=self._model_used,
|
|
164
167
|
raw_events=self._raw_events,
|
|
165
|
-
total_premium_requests=self._total_premium_requests
|
|
168
|
+
total_premium_requests=self._total_premium_requests
|
|
169
|
+
+ sum(
|
|
170
|
+
invocation.result.total_premium_requests
|
|
171
|
+
for invocation in resolved_subagents
|
|
172
|
+
if invocation.result is not None
|
|
173
|
+
),
|
|
166
174
|
)
|
|
167
175
|
|
|
168
176
|
# ── Assistant events ──
|
|
@@ -342,22 +350,73 @@ class EventMapper:
|
|
|
342
350
|
self._early_tool_completions.setdefault(call_id, []).append(event)
|
|
343
351
|
return
|
|
344
352
|
result_text: str | None = None
|
|
353
|
+
images: list[ImageContent] = []
|
|
345
354
|
if result_data is not None and hasattr(result_data, "content"):
|
|
346
355
|
result_text = result_data.content
|
|
347
356
|
elif isinstance(result_data, str):
|
|
348
357
|
result_text = result_data
|
|
349
358
|
elif result_data is not None:
|
|
350
359
|
result_text = str(result_data)
|
|
360
|
+
text_parts: list[str] = []
|
|
361
|
+
blocks = getattr(result_data, "contents", None) or []
|
|
362
|
+
for block in blocks:
|
|
363
|
+
block_type = getattr(block, "type", None)
|
|
364
|
+
if block_type == "image":
|
|
365
|
+
try:
|
|
366
|
+
data = getattr(block, "data", None)
|
|
367
|
+
media_type = getattr(block, "mime_type", None)
|
|
368
|
+
if (
|
|
369
|
+
not isinstance(data, str)
|
|
370
|
+
or not isinstance(media_type, str)
|
|
371
|
+
or not media_type
|
|
372
|
+
):
|
|
373
|
+
raise ValueError("image data and media type are required")
|
|
374
|
+
images.append(ImageContent(base64.b64decode(data, validate=True), media_type))
|
|
375
|
+
except (ValueError, binascii.Error) as exc:
|
|
376
|
+
self._record_contract_error(f"Tool call {call_id} has an invalid image: {exc}")
|
|
377
|
+
elif block_type == "text" and result_text is None:
|
|
378
|
+
text = getattr(block, "text", None)
|
|
379
|
+
if text is not None:
|
|
380
|
+
text_parts.append(text)
|
|
381
|
+
if result_text is None and text_parts:
|
|
382
|
+
result_text = "\n".join(text_parts)
|
|
383
|
+
content_images = list(images)
|
|
384
|
+
for block in getattr(result_data, "binary_results_for_llm", None) or []:
|
|
385
|
+
block_type = getattr(block, "type", None)
|
|
386
|
+
if getattr(block_type, "value", block_type) != "image":
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
data, media_type = block.data, block.mime_type
|
|
390
|
+
if not isinstance(data, str) or not isinstance(media_type, str) or not media_type:
|
|
391
|
+
raise ValueError("image data and media type are required")
|
|
392
|
+
image = ImageContent(base64.b64decode(data, validate=True), media_type)
|
|
393
|
+
if image not in content_images:
|
|
394
|
+
images.append(image)
|
|
395
|
+
except (ValueError, binascii.Error) as exc:
|
|
396
|
+
self._record_contract_error(f"Tool call {call_id} has an invalid image: {exc}")
|
|
351
397
|
success = _get_data_field(event, "success", None)
|
|
352
398
|
error = _stringify_tool_error(_get_data_field(event, "error", None))
|
|
353
399
|
if tc.completion_received:
|
|
354
|
-
|
|
400
|
+
captured_images = (
|
|
401
|
+
[ImageContent(tc.image_content, tc.image_media_type or "image/png")]
|
|
402
|
+
if tc.image_content is not None
|
|
403
|
+
else []
|
|
404
|
+
) + tc.additional_images
|
|
405
|
+
if (tc.result, tc.success, tc.error, captured_images) != (
|
|
406
|
+
result_text,
|
|
407
|
+
success,
|
|
408
|
+
error,
|
|
409
|
+
images,
|
|
410
|
+
):
|
|
355
411
|
self._record_contract_error(f"Conflicting completion for tool call {call_id}")
|
|
356
412
|
return
|
|
357
413
|
tc.completion_received = True
|
|
358
414
|
tc.success = success
|
|
359
415
|
tc.result = result_text
|
|
360
416
|
tc.error = error
|
|
417
|
+
if images:
|
|
418
|
+
tc.image_content, tc.image_media_type = images[0].data, images[0].media_type
|
|
419
|
+
tc.additional_images = images[1:]
|
|
361
420
|
if tc.success is None:
|
|
362
421
|
self._record_contract_error(f"Tool call {call_id} completion is missing success")
|
|
363
422
|
start = self._pending_tool_start_times.pop(call_id, None)
|
|
@@ -382,6 +441,16 @@ class EventMapper:
|
|
|
382
441
|
|
|
383
442
|
# ── Subagent recording (used by runSubagent tool handler) ──
|
|
384
443
|
|
|
444
|
+
def record_subagent_result(
|
|
445
|
+
self, *, invocation_id: str, name: str, result: CopilotResult
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Link a framework-owned child without mixing its conversation into this one."""
|
|
448
|
+
invocation = self._ensure_subagent_invocation(invocation_id=invocation_id, name=name)
|
|
449
|
+
if invocation.result is not None and invocation.result is not result:
|
|
450
|
+
self._record_contract_error(f"Conflicting child result for invocation {invocation_id}")
|
|
451
|
+
return
|
|
452
|
+
invocation.result = result
|
|
453
|
+
|
|
385
454
|
def record_subagent_start(self, *, invocation_id: str, name: str) -> None:
|
|
386
455
|
"""Record a subagent invocation dispatched via a tool call."""
|
|
387
456
|
invocation = self._ensure_subagent_invocation(invocation_id=invocation_id, name=name)
|
|
@@ -83,7 +83,7 @@ def _convert_to_aitest(
|
|
|
83
83
|
"""
|
|
84
84
|
from dataclasses import dataclass
|
|
85
85
|
|
|
86
|
-
from pytest_skill_engineering.core.result import EvalResult
|
|
86
|
+
from pytest_skill_engineering.core.result import EvalResult, SubagentInvocation
|
|
87
87
|
from pytest_skill_engineering.execution.cost import estimate_cost
|
|
88
88
|
|
|
89
89
|
# Estimate USD cost from captured token usage and pricing.toml.
|
|
@@ -96,7 +96,7 @@ def _convert_to_aitest(
|
|
|
96
96
|
usage.output_tokens,
|
|
97
97
|
usage.cache_read_tokens or 0,
|
|
98
98
|
)
|
|
99
|
-
for usage in result.
|
|
99
|
+
for usage in result.all_usage
|
|
100
100
|
if usage.input_tokens is not None and usage.output_tokens is not None
|
|
101
101
|
)
|
|
102
102
|
|
|
@@ -108,7 +108,9 @@ def _convert_to_aitest(
|
|
|
108
108
|
duration_ms=result.duration_ms,
|
|
109
109
|
token_usage=result.token_usage,
|
|
110
110
|
cost_usd=cost_usd,
|
|
111
|
-
effective_system_prompt=
|
|
111
|
+
effective_system_prompt=(
|
|
112
|
+
result.configuration.get("instructions") if result.configuration is not None else None
|
|
113
|
+
),
|
|
112
114
|
premium_requests=result.total_premium_requests,
|
|
113
115
|
evidence_complete=result.evidence_complete,
|
|
114
116
|
capture_errors=list(result.capture_errors),
|
|
@@ -117,49 +119,39 @@ def _convert_to_aitest(
|
|
|
117
119
|
usage=list(result.usage),
|
|
118
120
|
tool_calls_admitted=result.tool_calls_admitted,
|
|
119
121
|
skill_discovery=deepcopy(result.skill_discovery),
|
|
120
|
-
configuration=deepcopy(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"system_message_mode": agent.system_message_mode,
|
|
126
|
-
"reasoning_effort": agent.reasoning_effort,
|
|
127
|
-
"client_mode": agent.client_mode,
|
|
128
|
-
"image_detail": agent.image_detail,
|
|
129
|
-
"audit_requests": agent.audit_requests,
|
|
130
|
-
"max_tool_calls": agent.max_tool_calls,
|
|
131
|
-
"tools": [
|
|
132
|
-
{
|
|
133
|
-
"name": tool.name,
|
|
134
|
-
"description": tool.description,
|
|
135
|
-
"parameters": tool.parameters,
|
|
136
|
-
"metadata": tool.metadata,
|
|
137
|
-
}
|
|
138
|
-
for tool in agent.extra_config.get("tools", [])
|
|
139
|
-
],
|
|
140
|
-
"allowed_tools": agent.allowed_tools,
|
|
141
|
-
"excluded_tools": agent.excluded_tools,
|
|
142
|
-
"max_turns": agent.max_turns,
|
|
143
|
-
"timeout_s": agent.timeout_s,
|
|
144
|
-
"auto_confirm": agent.auto_confirm,
|
|
145
|
-
"mcp_servers": {
|
|
146
|
-
name: {"type": config.get("type"), "tools": config.get("tools")}
|
|
147
|
-
for name, config in agent.mcp_servers.items()
|
|
148
|
-
},
|
|
149
|
-
"skill_directories": agent.skill_directories,
|
|
150
|
-
"disabled_skills": agent.disabled_skills,
|
|
151
|
-
"active_agent": agent.active_agent,
|
|
152
|
-
"persona": type(agent.persona).__name__,
|
|
153
|
-
}
|
|
154
|
-
),
|
|
122
|
+
configuration=deepcopy(result.configuration),
|
|
123
|
+
model_used=result.model_used,
|
|
124
|
+
reasoning_traces=list(result.reasoning_traces),
|
|
125
|
+
permission_requested=result.permission_requested,
|
|
126
|
+
permissions=deepcopy(result.permissions),
|
|
155
127
|
)
|
|
128
|
+
for invocation in result.subagent_invocations:
|
|
129
|
+
child_result = None
|
|
130
|
+
if invocation.result is not None:
|
|
131
|
+
from pytest_skill_engineering.copilot.eval import CopilotEval
|
|
132
|
+
|
|
133
|
+
child_agent = invocation.result.agent
|
|
134
|
+
if not isinstance(child_agent, CopilotEval):
|
|
135
|
+
raise ValueError(f"Child invocation {invocation.invocation_id!r} has no eval")
|
|
136
|
+
converted_child = _convert_to_aitest(child_agent, invocation.result)
|
|
137
|
+
assert converted_child is not None
|
|
138
|
+
child_result = converted_child[0]
|
|
139
|
+
aitest_result.subagent_invocations.append(
|
|
140
|
+
SubagentInvocation(
|
|
141
|
+
invocation_id=invocation.invocation_id,
|
|
142
|
+
name=invocation.name,
|
|
143
|
+
status=invocation.status,
|
|
144
|
+
duration_ms=invocation.duration_ms,
|
|
145
|
+
result=child_result,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
156
148
|
|
|
157
149
|
# Create a minimal wrapper with just the fields needed by plugin.py
|
|
158
|
-
@dataclass
|
|
150
|
+
@dataclass(slots=True)
|
|
159
151
|
class Provider:
|
|
160
|
-
model: str
|
|
152
|
+
model: str | None
|
|
161
153
|
|
|
162
|
-
@dataclass
|
|
154
|
+
@dataclass(slots=True)
|
|
163
155
|
class AgentWrapper:
|
|
164
156
|
name: str
|
|
165
157
|
id: str
|
|
@@ -176,7 +168,15 @@ def _convert_to_aitest(
|
|
|
176
168
|
aitest_agent = AgentWrapper(
|
|
177
169
|
name=agent.name,
|
|
178
170
|
id=agent.name,
|
|
179
|
-
provider=Provider(
|
|
171
|
+
provider=Provider(
|
|
172
|
+
model=result.model_used
|
|
173
|
+
if result.model_used is not None
|
|
174
|
+
else (
|
|
175
|
+
result.configuration.get("model")
|
|
176
|
+
if result.configuration is not None
|
|
177
|
+
else agent.extra_config.get("model", agent.model)
|
|
178
|
+
)
|
|
179
|
+
),
|
|
180
180
|
system_prompt_name=None, # CopilotEval doesn't have named prompts
|
|
181
181
|
mcp_servers=[], # Could convert agent.mcp_servers if needed
|
|
182
182
|
allowed_tools=agent.allowed_tools,
|
|
@@ -211,7 +211,7 @@ def stash_on_item(
|
|
|
211
211
|
report_name = f"{agent.name} ({comparison_role})"
|
|
212
212
|
converted[1].name = report_name
|
|
213
213
|
converted[1].id = report_name
|
|
214
|
-
converted[0].
|
|
214
|
+
converted[0].comparison_role = comparison_role
|
|
215
215
|
item._aitest_result = converted[0] # type: ignore[attr-defined]
|
|
216
216
|
item._aitest_agent = converted[1] # type: ignore[attr-defined]
|
|
217
217
|
runs = getattr(item, "_aitest_runs", None)
|
|
@@ -437,6 +437,10 @@ def _make_subagent_dispatch_tool(
|
|
|
437
437
|
)
|
|
438
438
|
|
|
439
439
|
sub_result = await nested_runner(sub_agent, prompt_text)
|
|
440
|
+
sub_result.agent = sub_agent
|
|
441
|
+
mapper.record_subagent_result(
|
|
442
|
+
invocation_id=invocation.tool_call_id, name=agent_slug, result=sub_result
|
|
443
|
+
)
|
|
440
444
|
|
|
441
445
|
if sub_result.success:
|
|
442
446
|
mapper.record_subagent_complete(
|
|
@@ -501,32 +505,26 @@ def _inject_skill_reference_tools(
|
|
|
501
505
|
|
|
502
506
|
Only injected when at least one skill directory has reference documents.
|
|
503
507
|
"""
|
|
504
|
-
|
|
505
|
-
|
|
508
|
+
from pytest_skill_engineering.copilot.skills import requested_skill_files
|
|
509
|
+
from pytest_skill_engineering.core.plugin import _validate_skill_reference_names
|
|
510
|
+
from pytest_skill_engineering.core.skill import Skill
|
|
506
511
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
for sub in skill_dir.iterdir():
|
|
524
|
-
if sub.is_dir() and (sub / "SKILL.md").exists():
|
|
525
|
-
refs_dir = sub / "references"
|
|
526
|
-
if refs_dir.is_dir():
|
|
527
|
-
for ref_file in refs_dir.iterdir():
|
|
528
|
-
if ref_file.is_file() and ref_file.suffix in _EXTS:
|
|
529
|
-
reference_files[ref_file.name] = ref_file
|
|
512
|
+
configured = (
|
|
513
|
+
session_config if "skill_directories" in session_config else agent.build_session_config()
|
|
514
|
+
)
|
|
515
|
+
directories = configured.get("skill_directories")
|
|
516
|
+
if not directories:
|
|
517
|
+
return
|
|
518
|
+
disabled = configured.get("disabled_skills") or []
|
|
519
|
+
skills = [
|
|
520
|
+
Skill.from_path(path)
|
|
521
|
+
for path, name in requested_skill_files(directories).items()
|
|
522
|
+
if name not in disabled
|
|
523
|
+
]
|
|
524
|
+
_validate_skill_reference_names(skills)
|
|
525
|
+
reference_files = {
|
|
526
|
+
name: skill.path / "references" / name for skill in skills for name in skill.references
|
|
527
|
+
}
|
|
530
528
|
|
|
531
529
|
if not reference_files:
|
|
532
530
|
return
|