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,178 @@
1
+ """Counts of model and tool calls per workflow, and a step limit suggested from past runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections import Counter
8
+ from collections.abc import Collection, Iterable, Mapping
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from workflowval.models.base import ToolCall
17
+ from workflowval.schemas import CallCounts, RunStats, StatsTotal
18
+
19
+ MIN_RUNS_FOR_SUGGESTION = 5
20
+ RECENT_RUNS = 20
21
+ MIN_SUGGESTED_LIMIT = 10
22
+ UNKNOWN_TOOL = "<unknown>"
23
+ MAX_STEPS_REASON = "max_steps"
24
+ # Runs that ended for other reasons stopped early, so they'd make the limit look too generous.
25
+ USABLE_STOP_REASONS = frozenset({"completed", MAX_STEPS_REASON, "max_iterations", "no_progress"})
26
+
27
+ _WORKFLOW_ID = re.compile(r"[a-z0-9_]+")
28
+ _TEST_FILE = re.compile(r"(?:^|/)test_([a-z0-9_]+)\.py(?:::|$)")
29
+
30
+
31
+ @dataclass
32
+ class _Tally:
33
+ model_calls: int = 0
34
+ cost_usd: float = 0.0
35
+ tools: Counter[str] = field(default_factory=Counter)
36
+
37
+ def counts(self) -> CallCounts:
38
+ return CallCounts(
39
+ model_calls=self.model_calls,
40
+ tool_calls=dict(sorted(self.tools.items())),
41
+ cost_usd=self.cost_usd,
42
+ )
43
+
44
+
45
+ def workflow_of(arguments: Mapping[str, Any]) -> str | None:
46
+ """Return the workflow a tool call's arguments name, if they name one validly."""
47
+ workflow = arguments.get("workflow")
48
+ candidates = [
49
+ arguments.get("workflow_id"),
50
+ workflow.get("id") if isinstance(workflow, Mapping) else None,
51
+ ]
52
+ for candidate in candidates:
53
+ if isinstance(candidate, str) and _WORKFLOW_ID.fullmatch(candidate):
54
+ return candidate
55
+ test_id = arguments.get("test_id")
56
+ if isinstance(test_id, str) and (match := _TEST_FILE.search(test_id)):
57
+ return match.group(1)
58
+ return None
59
+
60
+
61
+ class StatsCollector:
62
+ """Collects one run's counts and costs, per workflow and per role."""
63
+
64
+ def __init__(self, limit: int, spec_hash: str = "") -> None:
65
+ self.limit = limit
66
+ self.spec_hash = spec_hash
67
+ self._owners: dict[str | None, _Tally] = {}
68
+ self._roles: dict[str, _Tally] = {}
69
+
70
+ def _tallies(self, owner: str | None, role: str | None) -> list[_Tally]:
71
+ tallies = [self._owners.setdefault(owner, _Tally())]
72
+ if role is not None:
73
+ tallies.append(self._roles.setdefault(role, _Tally()))
74
+ return tallies
75
+
76
+ def record_failed_call(self, scope: str | None, role: str | None = None) -> None:
77
+ """Count a model call that failed, which still used a step."""
78
+ for tally in self._tallies(scope, role):
79
+ tally.model_calls += 1
80
+
81
+ def record_reply(
82
+ self,
83
+ scope: str | None,
84
+ calls: Iterable[ToolCall],
85
+ known_tools: Collection[str],
86
+ role: str | None = None,
87
+ cost_usd: float | None = None,
88
+ ) -> None:
89
+ """Count one model call, its cost and its tool calls.
90
+
91
+ A model can invent tool names and they aren't safe to store, so those count as unknown.
92
+ """
93
+ owners: set[str | None] = set()
94
+ for call in calls:
95
+ owner = scope or workflow_of(call.arguments)
96
+ owners.add(owner)
97
+ name = call.name if call.name in known_tools else UNKNOWN_TOOL
98
+ for tally in self._tallies(owner, role):
99
+ tally.tools[name] += 1
100
+ model_owner = owners.pop() if scope is None and len(owners) == 1 else scope
101
+ for tally in self._tallies(model_owner, role):
102
+ tally.model_calls += 1
103
+ tally.cost_usd += cost_usd or 0.0
104
+
105
+ def snapshot(self, stop_reason: str | None = None) -> RunStats:
106
+ """Return the counts so far."""
107
+ tallies = list(self._owners.values())
108
+ return RunStats(
109
+ run=self._owners.get(None, _Tally()).counts(),
110
+ workflows={
111
+ owner: tally.counts()
112
+ for owner, tally in sorted(
113
+ (item for item in self._owners.items() if item[0] is not None),
114
+ key=lambda item: str(item[0]),
115
+ )
116
+ if owner is not None
117
+ },
118
+ roles={role: self._roles[role].counts() for role in sorted(self._roles)},
119
+ total=StatsTotal(
120
+ model_calls=sum(tally.model_calls for tally in tallies),
121
+ tool_calls=sum(sum(tally.tools.values()) for tally in tallies),
122
+ cost_usd=sum(tally.cost_usd for tally in tallies),
123
+ limit=self.limit,
124
+ ),
125
+ spec_hash=self.spec_hash,
126
+ stop_reason=stop_reason,
127
+ finished_at=datetime.now(UTC).isoformat() if stop_reason else None,
128
+ )
129
+
130
+
131
+ def append_history(stats: RunStats, path: Path) -> None:
132
+ """Add a finished run to the history."""
133
+ path.parent.mkdir(parents=True, exist_ok=True)
134
+ with path.open("a", encoding="utf-8") as handle:
135
+ handle.write(stats.model_dump_json(exclude_none=True) + "\n")
136
+
137
+
138
+ def read_history(path: Path) -> list[RunStats]:
139
+ """Read past runs. Bad lines are skipped, since they must never stop a run from finishing."""
140
+ if not path.exists():
141
+ return []
142
+ runs = []
143
+ for line in path.read_text(encoding="utf-8").splitlines():
144
+ try:
145
+ runs.append(RunStats.model_validate_json(line))
146
+ except ValidationError:
147
+ continue
148
+ return runs
149
+
150
+
151
+ def _one_and_a_half_times(value: int) -> int:
152
+ """Return 1.5 times a value, rounded up to the next 10."""
153
+ return math.ceil(value * 15 / 100) * 10
154
+
155
+
156
+ def suggest_max_steps(history: Iterable[RunStats], limit: int) -> str | None:
157
+ """Suggest a `--max-steps` value from past runs. This only ever advises; it changes nothing."""
158
+ usable = [run for run in history if run.stop_reason in USABLE_STOP_REASONS][-RECENT_RUNS:]
159
+ if len(usable) < MIN_RUNS_FOR_SUGGESTION:
160
+ return None
161
+ # A run that hit a lower, older limit says nothing against the current limit.
162
+ hits = [
163
+ run for run in usable if run.stop_reason == MAX_STEPS_REASON and run.total.limit >= limit
164
+ ]
165
+ if hits:
166
+ return (
167
+ f"{len(hits)} of your last {len(usable)} runs hit the step limit of {limit}; "
168
+ f"consider --max-steps {_one_and_a_half_times(limit)}"
169
+ )
170
+ largest = max(run.total.model_calls for run in usable)
171
+ target = max(_one_and_a_half_times(largest), MIN_SUGGESTED_LIMIT)
172
+ if target == limit:
173
+ return None
174
+ steps = "step" if largest == 1 else "steps"
175
+ return (
176
+ f"the largest of your last {len(usable)} runs used {largest} {steps}; "
177
+ f"consider --max-steps {target}"
178
+ )
@@ -0,0 +1,29 @@
1
+ """`.workflowval/trace.jsonl`: every model and tool call (NFR-6), with secrets removed (NFR-7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from workflowval.redact import redact
12
+
13
+
14
+ class Tracer:
15
+ """Appends one JSON object per event, flushed at once so a killed run keeps its trace."""
16
+
17
+ def __init__(self, path: Path, *, echo: bool = False) -> None:
18
+ self.path = path
19
+ self.echo = echo
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+
22
+ def event(self, kind: str, **fields: Any) -> None:
23
+ """Record one event."""
24
+ record = {"ts": datetime.now(UTC).isoformat(), "kind": kind, **redact(fields)}
25
+ line = json.dumps(record, default=str)
26
+ with self.path.open("a", encoding="utf-8") as handle:
27
+ handle.write(line + "\n")
28
+ if self.echo:
29
+ print(line, file=sys.stderr)
@@ -0,0 +1,19 @@
1
+ """Provider adapters behind one interface. LiteLLM is imported only when a call is made."""
2
+
3
+ from workflowval.models.base import Message, Model, ModelError, Reply, ToolCall, ToolSpec, Usage
4
+ from workflowval.models.fake import FakeModel, FakeScriptExhausted, call
5
+ from workflowval.models.litellm_adapter import LiteLLMModel
6
+
7
+ __all__ = [
8
+ "FakeModel",
9
+ "FakeScriptExhausted",
10
+ "LiteLLMModel",
11
+ "Message",
12
+ "Model",
13
+ "ModelError",
14
+ "Reply",
15
+ "ToolCall",
16
+ "ToolSpec",
17
+ "Usage",
18
+ "call",
19
+ ]
@@ -0,0 +1,68 @@
1
+ """The one interface the rest of workflowval uses to talk to a model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, Protocol
8
+
9
+
10
+ class ModelError(Exception):
11
+ """A provider call failed. The message is already redacted."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ToolCall:
16
+ """A tool call from the model. `parse_error` is set when its arguments weren't valid JSON."""
17
+
18
+ id: str
19
+ name: str
20
+ arguments: dict[str, Any] = field(default_factory=dict)
21
+ parse_error: str | None = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Message:
26
+ """One conversation message."""
27
+
28
+ role: Literal["system", "user", "assistant", "tool"]
29
+ content: str = ""
30
+ tool_calls: tuple[ToolCall, ...] = ()
31
+ tool_call_id: str | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ToolSpec:
36
+ """What the model sees of a tool."""
37
+
38
+ name: str
39
+ description: str
40
+ parameters: dict[str, Any]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Usage:
45
+ """Tokens and cost of one call. `cost_usd` is None when the model has no known price."""
46
+
47
+ input_tokens: int = 0
48
+ output_tokens: int = 0
49
+ cost_usd: float | None = None
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Reply:
54
+ """A model's answer."""
55
+
56
+ text: str = ""
57
+ tool_calls: tuple[ToolCall, ...] = ()
58
+ usage: Usage = field(default_factory=Usage)
59
+
60
+
61
+ class Model(Protocol):
62
+ """Anything that can answer a conversation."""
63
+
64
+ name: str
65
+
66
+ def complete(self, messages: Sequence[Message], tools: Sequence[ToolSpec]) -> Reply:
67
+ """Return the model's reply. Raises ModelError if the provider call fails."""
68
+ ...
@@ -0,0 +1,55 @@
1
+ """A scripted model, which makes the loop fully deterministic in tests and CI (NFR-8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from workflowval.models.base import Message, Reply, ToolCall, ToolSpec, Usage
12
+
13
+
14
+ class FakeScriptExhausted(RuntimeError):
15
+ """The code under test asked for more replies than were scripted."""
16
+
17
+
18
+ def call(name: str, /, **arguments: Any) -> Reply:
19
+ """Build a reply holding one tool call."""
20
+ return Reply(tool_calls=(ToolCall(id=f"call_{name}", name=name, arguments=arguments),))
21
+
22
+
23
+ @dataclass
24
+ class FakeModel:
25
+ """Replays replies in order and records every request it received."""
26
+
27
+ script: Iterable[Reply] = ()
28
+ name: str = "fake/scripted"
29
+ requests: list[tuple[list[Message], list[ToolSpec]]] = field(default_factory=list)
30
+
31
+ def __post_init__(self) -> None:
32
+ self._replies = iter(self.script)
33
+
34
+ def complete(self, messages: Sequence[Message], tools: Sequence[ToolSpec]) -> Reply:
35
+ """Return the next scripted reply."""
36
+ self.requests.append((list(messages), list(tools)))
37
+ try:
38
+ return next(self._replies)
39
+ except StopIteration:
40
+ raise FakeScriptExhausted(
41
+ f"{self.name}: script ran out after {len(self.requests) - 1} replies"
42
+ ) from None
43
+
44
+ @classmethod
45
+ def from_file(cls, path: Path) -> FakeModel:
46
+ """Load a script: a JSON list of {text, tool_calls: [{id, name, arguments}], usage}."""
47
+ replies = [
48
+ Reply(
49
+ text=item.get("text", ""),
50
+ tool_calls=tuple(ToolCall(**tc) for tc in item.get("tool_calls", [])),
51
+ usage=Usage(**item.get("usage", {})),
52
+ )
53
+ for item in json.loads(path.read_text(encoding="utf-8"))
54
+ ]
55
+ return cls(script=replies)
@@ -0,0 +1,153 @@
1
+ """The default adapter. This is the only module that imports a provider library (NFR-5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from collections.abc import Callable, Sequence
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from workflowval.models.base import Message, ModelError, Reply, ToolCall, ToolSpec, Usage
13
+ from workflowval.redact import redact_text
14
+
15
+
16
+ def _load_litellm() -> Any:
17
+ # Without this, importing litellm fetches its price list from the network.
18
+ os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
19
+ import litellm
20
+
21
+ return litellm
22
+
23
+
24
+ # Seconds to wait before each new try. A temporary provider error shouldn't end a whole run.
25
+ RETRY_DELAYS = (2.0, 4.0, 8.0, 16.0, 32.0)
26
+ _TEMPORARY_ERRORS = frozenset(
27
+ {
28
+ "APIConnectionError",
29
+ "APITimeoutError",
30
+ "InternalServerError",
31
+ "RateLimitError",
32
+ "ServiceUnavailableError",
33
+ "Timeout",
34
+ }
35
+ )
36
+
37
+
38
+ def _is_temporary(exc: Exception) -> bool:
39
+ """True for rate limits, provider outages and dropped connections, which may clear."""
40
+ status = getattr(exc, "status_code", None)
41
+ if isinstance(status, int):
42
+ return status in (408, 429) or status >= 500
43
+ return type(exc).__name__ in _TEMPORARY_ERRORS
44
+
45
+
46
+ def _to_wire(message: Message) -> dict[str, Any]:
47
+ wire: dict[str, Any] = {"role": message.role, "content": message.content}
48
+ if message.tool_calls:
49
+ wire["tool_calls"] = [
50
+ {
51
+ "id": call.id,
52
+ "type": "function",
53
+ "function": {"name": call.name, "arguments": json.dumps(call.arguments)},
54
+ }
55
+ for call in message.tool_calls
56
+ ]
57
+ if message.tool_call_id is not None:
58
+ wire["tool_call_id"] = message.tool_call_id
59
+ return wire
60
+
61
+
62
+ def _from_wire(raw: Any) -> ToolCall:
63
+ name = raw.function.name
64
+ try:
65
+ parsed = json.loads(raw.function.arguments or "{}")
66
+ except json.JSONDecodeError as exc:
67
+ return ToolCall(id=raw.id, name=name, parse_error=f"arguments are not valid JSON: {exc}")
68
+ if not isinstance(parsed, dict):
69
+ return ToolCall(id=raw.id, name=name, parse_error="arguments must be a JSON object")
70
+ return ToolCall(id=raw.id, name=name, arguments=parsed)
71
+
72
+
73
+ @dataclass
74
+ class LiteLLMModel:
75
+ """Any provider LiteLLM supports, named `provider/model`. Keys come from env vars."""
76
+
77
+ name: str
78
+ temperature: float | None = None
79
+ seed: int | None = None
80
+ extra: dict[str, Any] = field(default_factory=dict)
81
+ track_cost: bool = True
82
+ sleep: Callable[[float], None] = time.sleep
83
+
84
+ def complete(self, messages: Sequence[Message], tools: Sequence[ToolSpec]) -> Reply:
85
+ """Call the provider and translate its reply. Failures raise a redacted ModelError."""
86
+ litellm = _load_litellm()
87
+ params: dict[str, Any] = {
88
+ "model": self.name,
89
+ "messages": [_to_wire(m) for m in messages],
90
+ # Drop params a provider doesn't support (e.g. seed) instead of failing the call.
91
+ "drop_params": True,
92
+ **self.extra,
93
+ }
94
+ if tools:
95
+ params["tools"] = [
96
+ {
97
+ "type": "function",
98
+ "function": {
99
+ "name": t.name,
100
+ "description": t.description,
101
+ "parameters": t.parameters,
102
+ },
103
+ }
104
+ for t in tools
105
+ ]
106
+ if self.temperature is not None:
107
+ params["temperature"] = self.temperature
108
+ if self.seed is not None:
109
+ params["seed"] = self.seed
110
+ response = self._call_with_retries(litellm, params)
111
+ message = response.choices[0].message
112
+ usage = getattr(response, "usage", None)
113
+ input_tokens = getattr(usage, "prompt_tokens", 0) or 0
114
+ output_tokens = getattr(usage, "completion_tokens", 0) or 0
115
+ return Reply(
116
+ text=message.content or "",
117
+ tool_calls=tuple(_from_wire(c) for c in message.tool_calls or ()),
118
+ usage=Usage(
119
+ input_tokens=input_tokens,
120
+ output_tokens=output_tokens,
121
+ cost_usd=self._cost(litellm, response, input_tokens + output_tokens),
122
+ ),
123
+ )
124
+
125
+ def _call_with_retries(self, litellm: Any, params: dict[str, Any]) -> Any:
126
+ delays = iter(RETRY_DELAYS)
127
+ while True:
128
+ try:
129
+ return litellm.completion(**params)
130
+ except Exception as exc:
131
+ delay = next(delays, None) if _is_temporary(exc) else None
132
+ if delay is None:
133
+ # "from None": a chained provider exception could carry request headers.
134
+ detail = redact_text(str(exc))
135
+ raise ModelError(f"{self.name}: {type(exc).__name__}: {detail}") from None
136
+ self.sleep(delay)
137
+
138
+ def _cost(self, litellm: Any, response: Any, tokens: int) -> float | None:
139
+ """Prefer the price the provider reported, since LiteLLM's estimate can be wrongly 0."""
140
+ if not self.track_cost:
141
+ return None
142
+ hidden = getattr(response, "_hidden_params", None) or {}
143
+ reported = hidden.get("response_cost")
144
+ if reported is None:
145
+ reported = getattr(getattr(response, "usage", None), "cost", None)
146
+ if reported is None:
147
+ try:
148
+ reported = litellm.completion_cost(completion_response=response)
149
+ except Exception:
150
+ return None # An unpriced model (e.g. a brand-new or local one) is not an error.
151
+ cost = float(reported)
152
+ # A call that used tokens wasn't free: the price is unknown, so don't report it as $0.
153
+ return None if cost == 0.0 and tokens else cost