skilltest-sdk 0.3.0__tar.gz → 0.5.0__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.
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/PKG-INFO +39 -4
- skilltest_sdk-0.5.0/README.md +65 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/pyproject.toml +1 -1
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/skilltest_sdk/__init__.py +36 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/skilltest_sdk/_report.py +93 -2
- skilltest_sdk-0.5.0/skilltest_sdk/mock.py +534 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/skilltest_sdk/models.py +22 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/skilltest_sdk/runner.py +86 -11
- skilltest_sdk-0.5.0/skilltest_sdk/stream.py +151 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/tests/test_api.py +8 -0
- skilltest_sdk-0.5.0/tests/test_mock.py +145 -0
- skilltest_sdk-0.5.0/tests/test_oneharness.py +80 -0
- skilltest_sdk-0.5.0/tests/test_stream.py +37 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/uv.lock +1 -1
- skilltest_sdk-0.3.0/README.md +0 -30
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/.gitignore +0 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/project.json +0 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/skilltest_sdk/_validation.py +0 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/skilltest_sdk/errors.py +0 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/tests/conftest.py +0 -0
- {skilltest_sdk-0.3.0 → skilltest_sdk-0.5.0}/tests/test_resolve.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skilltest-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python SDK for the skilltest CLI: run AI-skill tests and natural-language evals from Python, with a typed report contract.
|
|
5
5
|
Author: Nick DeRobertis
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,17 +17,52 @@ integrations build on it — use [`skilltest-pytest`](../../plugins/pytest) if y
|
|
|
17
17
|
want pytest collection; use this package directly from any other Python code.
|
|
18
18
|
|
|
19
19
|
```python
|
|
20
|
-
from skilltest_sdk import run_skill, validate_skill
|
|
20
|
+
from skilltest_sdk import run_skill, validate_skill, describe_failures, assistant_text
|
|
21
21
|
|
|
22
22
|
report = run_skill("cases/greet.yaml")
|
|
23
|
-
assert report.passed,
|
|
23
|
+
assert report.passed, describe_failures(report)
|
|
24
24
|
# Mix in deterministic checks on the transcript:
|
|
25
|
-
assert "Dr. Smith" in report.runs[0].transcript
|
|
25
|
+
assert "Dr. Smith" in assistant_text(report.runs[0].transcript)
|
|
26
26
|
|
|
27
27
|
result = validate_skill("skills/greeter")
|
|
28
28
|
assert result.valid
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
### Tool events
|
|
32
|
+
|
|
33
|
+
Each assistant turn carries the normalized tool events the skill took (shell
|
|
34
|
+
commands, file edits, tool uses), lifted from oneharness's `--events`. Assert on
|
|
35
|
+
*what the skill did* with `tool_calls` (the `tool_call` events across a
|
|
36
|
+
transcript, in order); each `ToolEvent` has `kind`, `name`, `input`, `output`,
|
|
37
|
+
`index`:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from skilltest_sdk import run_skill, tool_calls
|
|
41
|
+
|
|
42
|
+
report = run_skill("cases/edit.skilltest.yaml")
|
|
43
|
+
calls = tool_calls(report.runs[0].transcript)
|
|
44
|
+
assert any("git commit" in str(c.input) for c in calls)
|
|
45
|
+
assert not any("rm -rf" in str(c.input) for c in calls) # never destructive
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Streaming (opt-in)
|
|
49
|
+
|
|
50
|
+
`stream_skill` returns a `SkillStream` you iterate with `async for` to receive
|
|
51
|
+
each event live, and `break` to **short-circuit** — closing the stream tears the
|
|
52
|
+
harness down, so a bad turn is cut off instead of paid for in full. `.report`
|
|
53
|
+
holds the final report once the stream runs to completion:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from skilltest_sdk import stream_skill
|
|
57
|
+
|
|
58
|
+
async def guard():
|
|
59
|
+
stream = stream_skill("cases/edit.skilltest.yaml")
|
|
60
|
+
async for ev in stream: # ev: StreamEvent — .case/.platform/.model/.turn/.event
|
|
61
|
+
if ev.event.name == "bash" and "rm -rf" in str(ev.event.input):
|
|
62
|
+
break
|
|
63
|
+
return stream.report
|
|
64
|
+
```
|
|
65
|
+
|
|
31
66
|
The `skilltest` binary is resolved from the `bin=` argument, the
|
|
32
67
|
`SKILLTEST_BIN` env var, or `PATH`; a provider override comes from `provider=`
|
|
33
68
|
or `SKILLTEST_PROVIDER`. A failing eval is *reported* (`report.passed` is
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# skilltest-sdk
|
|
2
|
+
|
|
3
|
+
The Python SDK for the [`skilltest`](https://github.com/nickderobertis/skilltest)
|
|
4
|
+
CLI. A thin, typed wrapper and nothing else: it runs the CLI as a subprocess and
|
|
5
|
+
parses the stable `--format json` contract into Pydantic models. Test-framework
|
|
6
|
+
integrations build on it — use [`skilltest-pytest`](../../plugins/pytest) if you
|
|
7
|
+
want pytest collection; use this package directly from any other Python code.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from skilltest_sdk import run_skill, validate_skill, describe_failures, assistant_text
|
|
11
|
+
|
|
12
|
+
report = run_skill("cases/greet.yaml")
|
|
13
|
+
assert report.passed, describe_failures(report)
|
|
14
|
+
# Mix in deterministic checks on the transcript:
|
|
15
|
+
assert "Dr. Smith" in assistant_text(report.runs[0].transcript)
|
|
16
|
+
|
|
17
|
+
result = validate_skill("skills/greeter")
|
|
18
|
+
assert result.valid
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Tool events
|
|
22
|
+
|
|
23
|
+
Each assistant turn carries the normalized tool events the skill took (shell
|
|
24
|
+
commands, file edits, tool uses), lifted from oneharness's `--events`. Assert on
|
|
25
|
+
*what the skill did* with `tool_calls` (the `tool_call` events across a
|
|
26
|
+
transcript, in order); each `ToolEvent` has `kind`, `name`, `input`, `output`,
|
|
27
|
+
`index`:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from skilltest_sdk import run_skill, tool_calls
|
|
31
|
+
|
|
32
|
+
report = run_skill("cases/edit.skilltest.yaml")
|
|
33
|
+
calls = tool_calls(report.runs[0].transcript)
|
|
34
|
+
assert any("git commit" in str(c.input) for c in calls)
|
|
35
|
+
assert not any("rm -rf" in str(c.input) for c in calls) # never destructive
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Streaming (opt-in)
|
|
39
|
+
|
|
40
|
+
`stream_skill` returns a `SkillStream` you iterate with `async for` to receive
|
|
41
|
+
each event live, and `break` to **short-circuit** — closing the stream tears the
|
|
42
|
+
harness down, so a bad turn is cut off instead of paid for in full. `.report`
|
|
43
|
+
holds the final report once the stream runs to completion:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from skilltest_sdk import stream_skill
|
|
47
|
+
|
|
48
|
+
async def guard():
|
|
49
|
+
stream = stream_skill("cases/edit.skilltest.yaml")
|
|
50
|
+
async for ev in stream: # ev: StreamEvent — .case/.platform/.model/.turn/.event
|
|
51
|
+
if ev.event.name == "bash" and "rm -rf" in str(ev.event.input):
|
|
52
|
+
break
|
|
53
|
+
return stream.report
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The `skilltest` binary is resolved from the `bin=` argument, the
|
|
57
|
+
`SKILLTEST_BIN` env var, or `PATH`; a provider override comes from `provider=`
|
|
58
|
+
or `SKILLTEST_PROVIDER`. A failing eval is *reported* (`report.passed` is
|
|
59
|
+
false), not raised; bad input raises `SkilltestUsageError` (CLI exit 2) and
|
|
60
|
+
provider problems raise `SkilltestProviderError` (exit 3).
|
|
61
|
+
|
|
62
|
+
The Pydantic models are **generated** from `schemas/report.schema.json` /
|
|
63
|
+
`schemas/validation.schema.json` — themselves generated from the CLI's own
|
|
64
|
+
types — via `just gen-contract`, and a drift gate in CI fails if anything is
|
|
65
|
+
stale, so the models cannot diverge from the binary.
|
|
@@ -16,14 +16,30 @@ so they cannot drift from the binary. Test frameworks build on this —
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
from .errors import SkilltestError, SkilltestProviderError, SkilltestUsageError
|
|
19
|
+
from .mock import (
|
|
20
|
+
Matcher,
|
|
21
|
+
ToolCall,
|
|
22
|
+
ToolMock,
|
|
23
|
+
ToolSpy,
|
|
24
|
+
anything,
|
|
25
|
+
contains,
|
|
26
|
+
deny,
|
|
27
|
+
matching,
|
|
28
|
+
rewrite,
|
|
29
|
+
spy,
|
|
30
|
+
stub,
|
|
31
|
+
)
|
|
19
32
|
from .models import (
|
|
20
33
|
BooleanDetail,
|
|
34
|
+
CallsDetail,
|
|
21
35
|
CaseRun,
|
|
22
36
|
EvalOutcome,
|
|
23
37
|
Message,
|
|
38
|
+
MockCall,
|
|
24
39
|
NumericDetail,
|
|
25
40
|
Report,
|
|
26
41
|
Summary,
|
|
42
|
+
ToolEvent,
|
|
27
43
|
Transcript,
|
|
28
44
|
Usage,
|
|
29
45
|
ValidationFinding,
|
|
@@ -32,30 +48,50 @@ from .models import (
|
|
|
32
48
|
describe_failures,
|
|
33
49
|
failed_evals,
|
|
34
50
|
failed_runs,
|
|
51
|
+
tool_calls,
|
|
35
52
|
)
|
|
36
53
|
from .runner import ENV_BIN, ENV_PROVIDER, run_skill, validate_skill
|
|
54
|
+
from .stream import SkillStream, StreamEvent, stream_skill
|
|
37
55
|
|
|
38
56
|
__all__ = [
|
|
39
57
|
"ENV_BIN",
|
|
40
58
|
"ENV_PROVIDER",
|
|
41
59
|
"BooleanDetail",
|
|
60
|
+
"CallsDetail",
|
|
42
61
|
"CaseRun",
|
|
43
62
|
"EvalOutcome",
|
|
63
|
+
"Matcher",
|
|
44
64
|
"Message",
|
|
65
|
+
"MockCall",
|
|
45
66
|
"NumericDetail",
|
|
46
67
|
"Report",
|
|
68
|
+
"SkillStream",
|
|
47
69
|
"SkilltestError",
|
|
48
70
|
"SkilltestProviderError",
|
|
49
71
|
"SkilltestUsageError",
|
|
72
|
+
"StreamEvent",
|
|
50
73
|
"Summary",
|
|
74
|
+
"ToolCall",
|
|
75
|
+
"ToolEvent",
|
|
76
|
+
"ToolMock",
|
|
77
|
+
"ToolSpy",
|
|
51
78
|
"Transcript",
|
|
52
79
|
"Usage",
|
|
53
80
|
"ValidationFinding",
|
|
54
81
|
"ValidationReport",
|
|
82
|
+
"anything",
|
|
55
83
|
"assistant_text",
|
|
84
|
+
"contains",
|
|
85
|
+
"deny",
|
|
56
86
|
"describe_failures",
|
|
57
87
|
"failed_evals",
|
|
58
88
|
"failed_runs",
|
|
89
|
+
"matching",
|
|
90
|
+
"rewrite",
|
|
59
91
|
"run_skill",
|
|
92
|
+
"spy",
|
|
93
|
+
"stream_skill",
|
|
94
|
+
"stub",
|
|
95
|
+
"tool_calls",
|
|
60
96
|
"validate_skill",
|
|
61
97
|
]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# filename: report.schema.json
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
|
-
from typing import Literal
|
|
5
|
+
from typing import Any, Literal
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
8
|
|
|
@@ -35,17 +35,100 @@ class NumericDetail(BaseModel):
|
|
|
35
35
|
value: float
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
class CallsDetail(BaseModel):
|
|
39
|
+
"""
|
|
40
|
+
The deterministic `called`/`not_called` verdict: how many observed calls
|
|
41
|
+
matched, against what expectation.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
count: int = Field(..., description="Matching calls observed.", ge=0)
|
|
45
|
+
kind: Literal["calls"]
|
|
46
|
+
negated: bool | None = Field(
|
|
47
|
+
False, description="True for `not_called` (the eval required absence)."
|
|
48
|
+
)
|
|
49
|
+
times: int | None = Field(
|
|
50
|
+
None,
|
|
51
|
+
description='The exact count required (`times`); `null` means "at least one"\n(or, with `negated`, "none").',
|
|
52
|
+
ge=0,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
38
56
|
class EvalOutcome(BaseModel):
|
|
39
57
|
"""
|
|
40
58
|
The result of running one eval against a transcript.
|
|
41
59
|
"""
|
|
42
60
|
|
|
43
|
-
detail: BooleanDetail | NumericDetail = Field(
|
|
61
|
+
detail: BooleanDetail | NumericDetail | CallsDetail = Field(
|
|
62
|
+
..., description="Kind-specific verdict detail."
|
|
63
|
+
)
|
|
44
64
|
label: str = Field(..., description="The eval's label (name or criterion).")
|
|
45
65
|
passed: bool = Field(..., description="Whether the eval passed.")
|
|
46
66
|
reason: str = Field(..., description="The judge's stated reason.")
|
|
47
67
|
|
|
48
68
|
|
|
69
|
+
class MockCall(BaseModel):
|
|
70
|
+
"""
|
|
71
|
+
One observed tool call, as recorded by the mock/spy channel: the harness
|
|
72
|
+
hook's spy log for real runs, or the provider's `mock_calls` response for
|
|
73
|
+
the command protocol. Carries the **original, pre-rewrite** input — the
|
|
74
|
+
transcript's `events` show post-rewrite reality (the stub that actually
|
|
75
|
+
ran); this shows what the skill *attempted*.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
action: str = Field(
|
|
79
|
+
...,
|
|
80
|
+
description="The verdict applied: `allow` (fell through every rule), `deny`,\n`rewrite`, or `stub`.",
|
|
81
|
+
)
|
|
82
|
+
input: Any | None = Field(
|
|
83
|
+
None, description="The tool's original input arguments; `null` when the event carried none."
|
|
84
|
+
)
|
|
85
|
+
mock: str | None = Field(
|
|
86
|
+
None,
|
|
87
|
+
description="Name of the mock declaration the intercepting rule came from; `null`\nfor `allow`. Filled by the runner, so SDKs bind records to mock objects\nby name instead of re-deriving rule indices.",
|
|
88
|
+
)
|
|
89
|
+
rule: int | None = Field(
|
|
90
|
+
None, description="Index of the compiled rule that intercepted; `null` for `allow`.", ge=0
|
|
91
|
+
)
|
|
92
|
+
tool: str | None = Field(
|
|
93
|
+
None, description="Tool name as the harness reported it; `null` when the event named none."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ToolEvent(BaseModel):
|
|
98
|
+
"""
|
|
99
|
+
One normalized tool-call / action event the skill took during a turn, lifted
|
|
100
|
+
from oneharness's `events` array (its `--events` output). Harness-agnostic, so
|
|
101
|
+
a consumer can inspect *what the skill did* — shell commands, file edits, tool
|
|
102
|
+
uses — across any harness, not just the final text. Mirrors the oneharness
|
|
103
|
+
action-event shape; `input` is the structured, tool-shaped args so a consumer
|
|
104
|
+
can match on the command string or file path without re-parsing.
|
|
105
|
+
|
|
106
|
+
`input` is a free-form JSON value, so `Message`/`Transcript` are `PartialEq`
|
|
107
|
+
but not `Eq`.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
index: int | None = Field(
|
|
111
|
+
0,
|
|
112
|
+
description='Position within the turn, so ordering ("did X before Y") is expressible.',
|
|
113
|
+
ge=0,
|
|
114
|
+
)
|
|
115
|
+
input: Any | None = Field(
|
|
116
|
+
None,
|
|
117
|
+
description="Structured tool arguments (the command, the file path); `null` when none.",
|
|
118
|
+
)
|
|
119
|
+
kind: str = Field(
|
|
120
|
+
...,
|
|
121
|
+
description="`tool_call` (the skill invoked a tool) or `tool_result` (the observation).",
|
|
122
|
+
)
|
|
123
|
+
name: str | None = Field(
|
|
124
|
+
None,
|
|
125
|
+
description="Normalized tool name where knowable (e.g. `bash`, `edit_file`); `null` for\na `tool_result` or when the harness did not name it.",
|
|
126
|
+
)
|
|
127
|
+
output: str | None = Field(
|
|
128
|
+
None, description="The result/observation text, when the transcript exposed it."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
49
132
|
class Usage(BaseModel):
|
|
50
133
|
"""
|
|
51
134
|
Token / cost usage for one provider call.
|
|
@@ -67,6 +150,10 @@ class Message(BaseModel):
|
|
|
67
150
|
"""
|
|
68
151
|
|
|
69
152
|
content: str
|
|
153
|
+
events: list[ToolEvent] | None = Field(
|
|
154
|
+
None,
|
|
155
|
+
description="The normalized tool events the skill took producing this turn (assistant\nturns only, and only when the harness exposed a tool transcript via\noneharness `--events`). Empty otherwise. Surfaced for post-hoc analysis\nand streamed live for short-circuiting.",
|
|
156
|
+
)
|
|
70
157
|
role: Literal["user", "assistant", "system"] = Field(..., description="Who produced a message.")
|
|
71
158
|
|
|
72
159
|
|
|
@@ -101,6 +188,10 @@ class CaseRun(BaseModel):
|
|
|
101
188
|
|
|
102
189
|
case: str = Field(..., description="The test case name.")
|
|
103
190
|
evals: list[EvalOutcome] = Field(..., description="Per-eval outcomes, in declaration order.")
|
|
191
|
+
mock_calls: list[MockCall] | None = Field(
|
|
192
|
+
None,
|
|
193
|
+
description='Every tool call the mock/spy channel observed, in order, with the\noriginal (pre-rewrite) input and the verdict applied. `null` when the\nchannel was off for this run (no `mocks`, no `spy`); an empty array\nmeans the channel was on and the skill made no tool calls — SDKs use\nthat distinction so a spy on a channel-less run errs instead of reading\nas "zero calls".',
|
|
194
|
+
)
|
|
104
195
|
model: str = Field(..., description="The model this run used.")
|
|
105
196
|
passed: bool = Field(..., description="True iff every eval in this run passed.")
|
|
106
197
|
platform: str = Field(..., description="The harness platform this run used.")
|