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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skilltest-sdk
3
- Version: 0.3.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, report.describe_failures()
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.assistant_text()
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skilltest-sdk"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Python SDK for the skilltest CLI: run AI-skill tests and natural-language evals from Python, with a typed report contract."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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(..., description="Kind-specific verdict detail.")
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.")