workflowval 0.1.0__py3-none-any.whl

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.
Files changed (59) hide show
  1. workflowval/__init__.py +3 -0
  2. workflowval/cli.py +584 -0
  3. workflowval/gen/__init__.py +194 -0
  4. workflowval/gen/source.py +109 -0
  5. workflowval/gen/style.md +17 -0
  6. workflowval/gen/templates/.gitkeep +0 -0
  7. workflowval/gen/templates/conftest.py.tmpl +332 -0
  8. workflowval/loop/__init__.py +55 -0
  9. workflowval/loop/runner.py +488 -0
  10. workflowval/loop/stats.py +178 -0
  11. workflowval/loop/trace.py +29 -0
  12. workflowval/models/__init__.py +19 -0
  13. workflowval/models/base.py +68 -0
  14. workflowval/models/fake.py +55 -0
  15. workflowval/models/litellm_adapter.py +153 -0
  16. workflowval/pipeline.py +906 -0
  17. workflowval/planner/__init__.py +62 -0
  18. workflowval/prompts/__init__.py +34 -0
  19. workflowval/prompts/planner.md +37 -0
  20. workflowval/prompts/reviewer.md +30 -0
  21. workflowval/prompts/task_answer_review.md +14 -0
  22. workflowval/prompts/task_finding.md +17 -0
  23. workflowval/prompts/task_fix_test.md +6 -0
  24. workflowval/prompts/task_fix_workflow.md +7 -0
  25. workflowval/prompts/task_patch.md +9 -0
  26. workflowval/prompts/task_plan.md +13 -0
  27. workflowval/prompts/task_plan_gaps.md +6 -0
  28. workflowval/prompts/task_report.md +9 -0
  29. workflowval/prompts/task_review_plan.md +17 -0
  30. workflowval/prompts/task_review_results.md +19 -0
  31. workflowval/prompts/task_triage.md +9 -0
  32. workflowval/prompts/writer.md +11 -0
  33. workflowval/py.typed +0 -0
  34. workflowval/redact.py +97 -0
  35. workflowval/report/__init__.py +162 -0
  36. workflowval/report/curl.py +75 -0
  37. workflowval/schemas/__init__.py +78 -0
  38. workflowval/schemas/_base.py +43 -0
  39. workflowval/schemas/auth.py +66 -0
  40. workflowval/schemas/labels.py +31 -0
  41. workflowval/schemas/results.py +222 -0
  42. workflowval/schemas/review.py +94 -0
  43. workflowval/schemas/state.py +32 -0
  44. workflowval/schemas/stats.py +36 -0
  45. workflowval/schemas/workflow.py +148 -0
  46. workflowval/spec/__init__.py +38 -0
  47. workflowval/spec/graph.py +196 -0
  48. workflowval/spec/loader.py +102 -0
  49. workflowval/spec/validation.py +98 -0
  50. workflowval/testrun.py +159 -0
  51. workflowval/tools/__init__.py +6 -0
  52. workflowval/tools/base.py +49 -0
  53. workflowval/tools/registry.py +637 -0
  54. workflowval/triage/__init__.py +199 -0
  55. workflowval-0.1.0.dist-info/METADATA +215 -0
  56. workflowval-0.1.0.dist-info/RECORD +59 -0
  57. workflowval-0.1.0.dist-info/WHEEL +4 -0
  58. workflowval-0.1.0.dist-info/entry_points.txt +2 -0
  59. workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,78 @@
1
+ """Pydantic models for every data format. Reviewed by the user before M1."""
2
+
3
+ from workflowval.schemas._base import atomic_write_text, load_json, save_json
4
+ from workflowval.schemas.auth import ApiKeyAuth, AuthConfig, BasicAuth, BearerAuth, LoginAuth
5
+ from workflowval.schemas.labels import Labels, SeededBug
6
+ from workflowval.schemas.results import (
7
+ Action,
8
+ ActionKind,
9
+ Confidence,
10
+ Evidence,
11
+ Finding,
12
+ HttpExchange,
13
+ Label,
14
+ RunResults,
15
+ TestResult,
16
+ TestStatus,
17
+ Verdict,
18
+ )
19
+ from workflowval.schemas.review import (
20
+ Answer,
21
+ Decision,
22
+ MissingJourney,
23
+ Review,
24
+ ReviewLog,
25
+ WorkflowFix,
26
+ )
27
+ from workflowval.schemas.state import RunState
28
+ from workflowval.schemas.stats import CallCounts, RunStats, StatsTotal
29
+ from workflowval.schemas.workflow import (
30
+ Basis,
31
+ CleanupStep,
32
+ Expect,
33
+ KnownIssue,
34
+ Source,
35
+ Step,
36
+ Workflow,
37
+ )
38
+
39
+ __all__ = [
40
+ "Action",
41
+ "ActionKind",
42
+ "Answer",
43
+ "ApiKeyAuth",
44
+ "AuthConfig",
45
+ "BasicAuth",
46
+ "Basis",
47
+ "BearerAuth",
48
+ "CallCounts",
49
+ "CleanupStep",
50
+ "Confidence",
51
+ "Decision",
52
+ "Evidence",
53
+ "Expect",
54
+ "Finding",
55
+ "HttpExchange",
56
+ "KnownIssue",
57
+ "Label",
58
+ "Labels",
59
+ "LoginAuth",
60
+ "MissingJourney",
61
+ "Review",
62
+ "ReviewLog",
63
+ "RunResults",
64
+ "RunState",
65
+ "RunStats",
66
+ "SeededBug",
67
+ "Source",
68
+ "StatsTotal",
69
+ "Step",
70
+ "TestResult",
71
+ "TestStatus",
72
+ "Verdict",
73
+ "Workflow",
74
+ "WorkflowFix",
75
+ "atomic_write_text",
76
+ "load_json",
77
+ "save_json",
78
+ ]
@@ -0,0 +1,43 @@
1
+ """Shared base model and JSON file helpers for every data format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TypeVar
10
+
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+ T = TypeVar("T", bound=BaseModel)
14
+
15
+
16
+ class Strict(BaseModel):
17
+ """Base for on-disk formats: unknown fields are errors, so typos fail loudly."""
18
+
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+
22
+ def atomic_write_text(path: Path, text: str) -> None:
23
+ """Write text so that a killed process never leaves a half-written file behind."""
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
26
+ try:
27
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
28
+ handle.write(text)
29
+ os.replace(tmp, path)
30
+ except BaseException:
31
+ Path(tmp).unlink(missing_ok=True)
32
+ raise
33
+
34
+
35
+ def save_json(model: BaseModel, path: Path) -> None:
36
+ """Write a model as indented JSON, atomically."""
37
+ payload = json.loads(model.model_dump_json(exclude_none=True))
38
+ atomic_write_text(path, json.dumps(payload, indent=2) + "\n")
39
+
40
+
41
+ def load_json(cls: type[T], path: Path) -> T:
42
+ """Read and validate a JSON file. Raises pydantic.ValidationError on bad data."""
43
+ return cls.model_validate_json(path.read_text(encoding="utf-8"))
@@ -0,0 +1,66 @@
1
+ """The auth config file (FR-2). It names env vars and never holds a secret value (NFR-7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field, StringConstraints
8
+
9
+ from workflowval.schemas._base import Strict
10
+
11
+ EnvVarName = Annotated[str, StringConstraints(pattern=r"^[A-Z_][A-Z0-9_]*$")]
12
+
13
+
14
+ class ApiKeyAuth(Strict):
15
+ """An API key sent in a header or query parameter."""
16
+
17
+ type: Literal["api_key"]
18
+ name: str = Field(min_length=1, description="Header or query parameter name")
19
+ location: Literal["header", "query"]
20
+ value_env: EnvVarName
21
+
22
+
23
+ class BearerAuth(Strict):
24
+ """A bearer token."""
25
+
26
+ type: Literal["bearer"]
27
+ token_env: EnvVarName
28
+
29
+
30
+ class BasicAuth(Strict):
31
+ """HTTP basic auth."""
32
+
33
+ type: Literal["basic"]
34
+ username_env: EnvVarName
35
+ password_env: EnvVarName
36
+
37
+
38
+ class LoginAuth(Strict):
39
+ """Call a login operation, extract a token, reuse it and refresh it on 401.
40
+
41
+ Leave `token_header` and `token_prefix` out and the agent works out how to send the token, from
42
+ the spec's security section, then the docs, then the login response and what the API accepts.
43
+ Values set here always win. The agent never sees the secret values.
44
+ """
45
+
46
+ type: Literal["login"]
47
+ op: str = Field(min_length=1, description="Login operationId")
48
+ body_env: dict[str, EnvVarName] = Field(
49
+ description="Request body field to env var name. `user.email` nests the field.",
50
+ )
51
+ token_path: str = Field(pattern=r"^\$", description="JSONPath of the token in the response")
52
+ token_header: str | None = Field(default=None, description="Override, e.g. Cookie")
53
+ token_prefix: str | None = Field(default=None, description="Override, e.g. token=")
54
+ refresh_on_401: bool = True
55
+
56
+
57
+ AuthScheme = Annotated[
58
+ ApiKeyAuth | BearerAuth | BasicAuth | LoginAuth,
59
+ Field(discriminator="type"),
60
+ ]
61
+
62
+
63
+ class AuthConfig(Strict):
64
+ """Top level of the auth config file."""
65
+
66
+ scheme: AuthScheme
@@ -0,0 +1,31 @@
1
+ """`benchmarks/seeded/labels.json`: ground truth for the seeded-bug app (Evaluation)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from workflowval.schemas._base import Strict
10
+
11
+
12
+ class SeededBug(Strict):
13
+ """One planted defect or conflict and the label it should get."""
14
+
15
+ id: str = Field(min_length=1)
16
+ description: str = Field(min_length=1)
17
+ expected_label: Literal["API_BUG", "SPEC_DRIFT", "CONFLICT"]
18
+ operations: list[str] = Field(min_length=1, description="operationIds where it can be seen")
19
+
20
+
21
+ class Labels(Strict):
22
+ """The whole ground-truth file."""
23
+
24
+ bugs: list[SeededBug] = Field(min_length=1)
25
+
26
+ @model_validator(mode="after")
27
+ def _ids_are_unique(self) -> Labels:
28
+ ids = [bug.id for bug in self.bugs]
29
+ if len(ids) != len(set(ids)):
30
+ raise ValueError("bug ids must be unique")
31
+ return self
@@ -0,0 +1,222 @@
1
+ """`results.json`: per-test outcomes and failure verdicts (FR-6, FR-8, FR-11)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from enum import StrEnum
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import Field, computed_field, model_validator
10
+
11
+ from workflowval.schemas._base import Strict
12
+ from workflowval.schemas.review import Review
13
+ from workflowval.schemas.workflow import Basis
14
+
15
+
16
+ class Label(StrEnum):
17
+ """Every failure ends as exactly one label."""
18
+
19
+ TEST_BUG = "TEST_BUG"
20
+ API_BUG = "API_BUG"
21
+ SPEC_DRIFT = "SPEC_DRIFT"
22
+ UNRESOLVED = "UNRESOLVED"
23
+ CONFLICT = "CONFLICT"
24
+
25
+
26
+ class Confidence(StrEnum):
27
+ """How sure the verdict is."""
28
+
29
+ LOW = "low"
30
+ MED = "med"
31
+ HIGH = "high"
32
+
33
+
34
+ class TestStatus(StrEnum):
35
+ """What pytest reported."""
36
+
37
+ __test__ = False
38
+
39
+ PASSED = "passed"
40
+ FAILED = "failed"
41
+ ERROR = "error"
42
+ SKIPPED = "skipped"
43
+ XFAILED = "xfailed"
44
+ XPASSED = "xpassed"
45
+
46
+
47
+ _OK_STATUSES = frozenset({TestStatus.PASSED, TestStatus.XFAILED, TestStatus.SKIPPED})
48
+
49
+
50
+ class ActionKind(StrEnum):
51
+ """What was done about a failure."""
52
+
53
+ PATCHED_TEST = "patched_test"
54
+ BUG_REPORT = "bug_report"
55
+ SPEC_PATCH = "spec_patch"
56
+ NONE = "none"
57
+
58
+
59
+ class HttpExchange(Strict):
60
+ """One request/response pair. Secrets must be redacted before this is built."""
61
+
62
+ method: str
63
+ url: str
64
+ request_headers: dict[str, str] = Field(default_factory=dict)
65
+ request_body: Any = None
66
+ status: int | None = Field(default=None, description="None when no response arrived")
67
+ response_headers: dict[str, str] = Field(default_factory=dict)
68
+ response_body: Any = None
69
+
70
+
71
+ class Evidence(Strict):
72
+ """What a verdict rests on."""
73
+
74
+ exchanges: list[HttpExchange] = Field(default_factory=list)
75
+ spec_excerpt: str | None = None
76
+ docs_excerpt: str | None = None
77
+
78
+
79
+ class Action(Strict):
80
+ """The outcome of a verdict, with the file it produced."""
81
+
82
+ kind: ActionKind
83
+ path: str | None = None
84
+
85
+
86
+ class Verdict(Strict):
87
+ """One label for one failure, with confidence, evidence and an action."""
88
+
89
+ label: Label
90
+ confidence: Confidence
91
+ reason: str = Field(min_length=1)
92
+ evidence: Evidence = Field(default_factory=Evidence)
93
+ action: Action = Field(default_factory=lambda: Action(kind=ActionKind.NONE))
94
+ fix_target: Literal["test", "workflow"] | None = Field(
95
+ default=None, description="For a TEST_BUG: what's wrong, the test code or the workflow"
96
+ )
97
+
98
+
99
+ class Finding(Strict):
100
+ """A response that breaks the spec, once however many tests saw it (FR-8, FR-10).
101
+
102
+ `label` is None until the Reviewer rules on it. `TEST_BUG` means it ruled this isn't a real
103
+ problem, for example a spec that's merely loose.
104
+ """
105
+
106
+ id: str = Field(min_length=1)
107
+ message: str = Field(min_length=1)
108
+ operations: list[str] = Field(default_factory=list)
109
+ tests: list[str] = Field(default_factory=list)
110
+ label: Label | None = None
111
+ confidence: Confidence | None = None
112
+ reason: str | None = None
113
+ action: Action = Field(default_factory=lambda: Action(kind=ActionKind.NONE))
114
+
115
+
116
+ class TestResult(Strict):
117
+ """The result of one test, keeping the last 3 HTTP exchanges (FR-6)."""
118
+
119
+ __test__ = False
120
+
121
+ id: str = Field(min_length=1, description="pytest node id")
122
+ workflow_id: str
123
+ basis: Basis
124
+ status: TestStatus
125
+ duration_s: float = Field(ge=0)
126
+ failing_assertion: str | None = None
127
+ stdout: str = ""
128
+ exchanges: list[HttpExchange] = Field(default_factory=list, max_length=3)
129
+ fix_attempts: int = Field(default=0, ge=0)
130
+ verdict: Verdict | None = None
131
+
132
+ @model_validator(mode="after")
133
+ def _assumed_rules_cap_at_medium(self) -> TestResult:
134
+ if (
135
+ self.basis is Basis.ASSUMED
136
+ and self.verdict is not None
137
+ and self.verdict.confidence is Confidence.HIGH
138
+ ):
139
+ raise ValueError("a failure on an assumed workflow is capped at medium confidence")
140
+ return self
141
+
142
+
143
+ class RunResults(Strict):
144
+ """Everything a run reports."""
145
+
146
+ stop_reason: str
147
+ stop_detail: str | None = None
148
+ tests: list[TestResult] = Field(default_factory=list)
149
+ findings: list[Finding] = Field(default_factory=list)
150
+ reviews: list[Review] = Field(default_factory=list)
151
+ tasks_stopped: list[str] = Field(
152
+ default_factory=list,
153
+ description="Tasks that hit their call limit, so may be incomplete",
154
+ )
155
+ uncovered_operations: list[str] = Field(
156
+ default_factory=list, description="Spec operations no workflow calls"
157
+ )
158
+ input_tokens: int = Field(default=0, ge=0)
159
+ output_tokens: int = Field(default=0, ge=0)
160
+ cost_usd: float = Field(default=0.0, ge=0, description="Sum over calls with a known price")
161
+ unpriced_calls: int = Field(default=0, ge=0, description="Calls missing from cost_usd")
162
+
163
+ @model_validator(mode="before")
164
+ @classmethod
165
+ def _ignore_computed_fields(cls, data: Any) -> Any:
166
+ """`label_counts` is written for readers and computed again on load."""
167
+ if isinstance(data, dict):
168
+ return {key: value for key, value in data.items() if key != "label_counts"}
169
+ return data
170
+
171
+ @property
172
+ def has_unexpected_failures(self) -> bool:
173
+ """True when a test failed in a way nobody has reviewed and marked as expected."""
174
+ return any(t.status not in _OK_STATUSES for t in self.tests)
175
+
176
+ def ci_failures(self, *, max_api_bugs: int, max_spec_drift: int) -> list[str]:
177
+ """Why `--ci` should fail the build. Expected failures still count toward the thresholds."""
178
+ reasons = []
179
+ unresolved = sum(
180
+ t.status not in _OK_STATUSES
181
+ or (t.verdict is not None and t.verdict.label is Label.UNRESOLVED)
182
+ for t in self.tests
183
+ )
184
+ if unresolved:
185
+ reasons.append(f"{unresolved} unresolved")
186
+ counts = self.label_counts
187
+ if counts.get("API_BUG", 0) > max_api_bugs:
188
+ reasons.append(f"{counts['API_BUG']} API_BUG, above the limit of {max_api_bugs}")
189
+ if counts.get("SPEC_DRIFT", 0) > max_spec_drift:
190
+ reasons.append(
191
+ f"{counts['SPEC_DRIFT']} SPEC_DRIFT, above the limit of {max_spec_drift}"
192
+ )
193
+ return reasons
194
+
195
+ def summary(self) -> str:
196
+ """One line of counts, keeping expected failures apart from passes."""
197
+ passed = sum(t.status is TestStatus.PASSED for t in self.tests)
198
+ parts = [f"{passed} passed"]
199
+ expected = Counter(
200
+ t.verdict.label.value
201
+ for t in self.tests
202
+ if t.status is TestStatus.XFAILED and t.verdict is not None
203
+ )
204
+ parts += [f"{n} expected to fail ({label})" for label, n in sorted(expected.items())]
205
+ skipped = sum(t.status is TestStatus.SKIPPED for t in self.tests)
206
+ if skipped:
207
+ parts.append(f"{skipped} skipped")
208
+ failed = len(self.tests) - passed - skipped - sum(expected.values())
209
+ if failed:
210
+ parts.append(f"{failed} failed")
211
+ confirmed = sum(f.label not in {None, Label.TEST_BUG} for f in self.findings)
212
+ if confirmed:
213
+ parts.append(f"{confirmed} spec mismatch{'es' if confirmed > 1 else ''}")
214
+ return ", ".join(parts)
215
+
216
+ @computed_field # type: ignore[prop-decorator]
217
+ @property
218
+ def label_counts(self) -> dict[str, int]:
219
+ """Number of tests per label."""
220
+ labels = [t.verdict.label.value for t in self.tests if t.verdict is not None]
221
+ labels += [f.label.value for f in self.findings if f.label not in {None, Label.TEST_BUG}]
222
+ return dict(Counter(labels))
@@ -0,0 +1,94 @@
1
+ """`review.json`: the Reviewer's fixes and missing journeys, with the Planner's answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from workflowval.schemas._base import Strict
10
+ from workflowval.schemas.workflow import Basis
11
+
12
+
13
+ class Decision(StrEnum):
14
+ """How the Planner answered a review item."""
15
+
16
+ ACCEPTED = "accepted"
17
+ REJECTED = "rejected"
18
+
19
+
20
+ class Answer(Strict):
21
+ """The Planner's answer. Nothing is dropped silently, so a rejection needs a reason."""
22
+
23
+ decision: Decision
24
+ reason: str | None = None
25
+ workflow_id: str | None = Field(default=None, description="File that carries out the answer")
26
+
27
+ @model_validator(mode="after")
28
+ def _check(self) -> Answer:
29
+ if self.decision is Decision.REJECTED and not self.reason:
30
+ raise ValueError("a rejection must give a reason")
31
+ if self.decision is Decision.ACCEPTED and not self.workflow_id:
32
+ raise ValueError("an accepted item must name its workflow_id")
33
+ return self
34
+
35
+
36
+ class WorkflowFix(Strict):
37
+ """A workflow that is wrong or weak."""
38
+
39
+ id: str = Field(min_length=1)
40
+ workflow_id: str
41
+ reason: str = Field(min_length=1)
42
+ answer: Answer | None = None
43
+
44
+
45
+ class MissingJourney(Strict):
46
+ """A user flow the workflows do not cover."""
47
+
48
+ id: str = Field(min_length=1)
49
+ user: str = Field(min_length=1, description="The user named in the docs, or 'API consumer'")
50
+ steps: list[str] = Field(min_length=1)
51
+ rule: str = Field(min_length=1)
52
+ basis: Basis
53
+ answer: Answer | None = None
54
+
55
+
56
+ class Review(Strict):
57
+ """One review round's output."""
58
+
59
+ round: int = Field(ge=1)
60
+ after_tests: bool = Field(default=False, description="True if it followed a test run")
61
+ fixes: list[WorkflowFix] = Field(default_factory=list)
62
+ missing_journeys: list[MissingJourney] = Field(default_factory=list)
63
+
64
+ @model_validator(mode="after")
65
+ def _ids_are_unique(self) -> Review:
66
+ ids = [item.id for item in self._items()]
67
+ if len(ids) != len(set(ids)):
68
+ raise ValueError("review item ids must be unique")
69
+ return self
70
+
71
+ def unanswered(self) -> list[str]:
72
+ """Ids of items the Planner has not answered yet."""
73
+ return [item.id for item in self._items() if item.answer is None]
74
+
75
+ def _items(self) -> list[WorkflowFix | MissingJourney]:
76
+ return [*self.fixes, *self.missing_journeys]
77
+
78
+ def items(self) -> list[WorkflowFix | MissingJourney]:
79
+ """Every fix and journey in this round."""
80
+ return self._items()
81
+
82
+
83
+ class ReviewLog(Strict):
84
+ """`review.json`: every round, so nothing proposed is ever dropped."""
85
+
86
+ rounds: list[Review] = Field(default_factory=list)
87
+
88
+ def item(self, item_id: str) -> WorkflowFix | MissingJourney | None:
89
+ """Find an item in any round."""
90
+ return next((i for r in self.rounds for i in r.items() if i.id == item_id), None)
91
+
92
+ def unanswered(self) -> list[str]:
93
+ """Ids of items the Planner has not answered yet, in any round."""
94
+ return [item_id for review in self.rounds for item_id in review.unanswered()]
@@ -0,0 +1,32 @@
1
+ """`.workflowval/state.json`: the resume point and spec hash (FR-13)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import Field
8
+
9
+ from workflowval.schemas._base import Strict
10
+
11
+
12
+ class RunState(Strict):
13
+ """A checkpoint, rewritten atomically as the run advances."""
14
+
15
+ format_version: Literal[1] = 1
16
+ spec_hash: str = ""
17
+ completed_steps: list[str] = Field(default_factory=list)
18
+ review_rounds: int = Field(default=0, ge=0)
19
+ iterations: int = Field(default=0, ge=0)
20
+ steps_used: int = Field(default=0, ge=0)
21
+ fix_attempts: dict[str, int] = Field(default_factory=dict)
22
+ closed_tests: dict[str, str] = Field(
23
+ default_factory=dict, description="Test id to 'passed' or its final label"
24
+ )
25
+ open_tests: list[str] = Field(default_factory=list)
26
+ input_tokens: int = Field(default=0, ge=0)
27
+ output_tokens: int = Field(default=0, ge=0)
28
+ cost_usd: float = Field(default=0.0, ge=0)
29
+ unpriced_calls: int = Field(default=0, ge=0)
30
+ tasks_stopped: list[str] = Field(default_factory=list)
31
+ cost_tracked: bool = True
32
+ stop_reason: str | None = None
@@ -0,0 +1,36 @@
1
+ """`.workflowval/stats.json` and `stats_history.jsonl`: names and counts, never arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+
7
+ from workflowval.schemas._base import Strict
8
+
9
+
10
+ class CallCounts(Strict):
11
+ """Model calls, and tool calls by tool name, including refused and invalid attempts."""
12
+
13
+ model_calls: int = Field(default=0, ge=0)
14
+ tool_calls: dict[str, int] = Field(default_factory=dict)
15
+ cost_usd: float = Field(default=0.0, ge=0, description="Sum over calls with a known price")
16
+
17
+
18
+ class StatsTotal(Strict):
19
+ """Totals for a run. `model_calls` is what `--max-steps` limits."""
20
+
21
+ model_calls: int = Field(default=0, ge=0)
22
+ tool_calls: int = Field(default=0, ge=0)
23
+ cost_usd: float = Field(default=0.0, ge=0)
24
+ limit: int = Field(ge=1, description="The --max-steps value for this run")
25
+
26
+
27
+ class RunStats(Strict):
28
+ """One run's calls. `run` holds work that belongs to no single workflow."""
29
+
30
+ run: CallCounts = Field(default_factory=CallCounts)
31
+ workflows: dict[str, CallCounts] = Field(default_factory=dict)
32
+ roles: dict[str, CallCounts] = Field(default_factory=dict)
33
+ total: StatsTotal
34
+ spec_hash: str = ""
35
+ stop_reason: str | None = None
36
+ finished_at: str | None = None