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.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The pipeline runner: budgets, stop rules, per-role tool limits and checkpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from workflowval.loop.runner import (
|
|
6
|
+
TASK_CALL_LIMIT,
|
|
7
|
+
Budget,
|
|
8
|
+
CodeStep,
|
|
9
|
+
FixLoop,
|
|
10
|
+
Pipeline,
|
|
11
|
+
ReviewLoop,
|
|
12
|
+
Role,
|
|
13
|
+
RoleStep,
|
|
14
|
+
RunContext,
|
|
15
|
+
RunResult,
|
|
16
|
+
StopReason,
|
|
17
|
+
StopRun,
|
|
18
|
+
TaskLimitReached,
|
|
19
|
+
TestOutcome,
|
|
20
|
+
run,
|
|
21
|
+
)
|
|
22
|
+
from workflowval.loop.trace import Tracer
|
|
23
|
+
from workflowval.models.base import Model
|
|
24
|
+
from workflowval.prompts import load_prompt
|
|
25
|
+
from workflowval.tools import ROLE_TOOLS, TOOLS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_role(name: str, model: Model, only: frozenset[str] | None = None) -> Role:
|
|
29
|
+
"""Build a pipeline role from its prompt file and its tools, optionally fewer of them."""
|
|
30
|
+
if name not in ROLE_TOOLS:
|
|
31
|
+
raise ValueError(f"unknown role {name!r}; roles are {sorted(ROLE_TOOLS)}")
|
|
32
|
+
allowed = ROLE_TOOLS[name] if only is None else ROLE_TOOLS[name] & only
|
|
33
|
+
tools = tuple(TOOLS[tool] for tool in sorted(allowed))
|
|
34
|
+
return Role(name, load_prompt(name), tools, model)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"TASK_CALL_LIMIT",
|
|
39
|
+
"Budget",
|
|
40
|
+
"CodeStep",
|
|
41
|
+
"FixLoop",
|
|
42
|
+
"Pipeline",
|
|
43
|
+
"ReviewLoop",
|
|
44
|
+
"Role",
|
|
45
|
+
"RoleStep",
|
|
46
|
+
"RunContext",
|
|
47
|
+
"RunResult",
|
|
48
|
+
"StopReason",
|
|
49
|
+
"StopRun",
|
|
50
|
+
"TaskLimitReached",
|
|
51
|
+
"TestOutcome",
|
|
52
|
+
"Tracer",
|
|
53
|
+
"make_role",
|
|
54
|
+
"run",
|
|
55
|
+
]
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""The pipeline runner: runs steps in order, enforces budgets and stop rules, checkpoints state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from workflowval.loop.stats import StatsCollector, append_history, read_history, suggest_max_steps
|
|
15
|
+
from workflowval.loop.trace import Tracer
|
|
16
|
+
from workflowval.models.base import Message, Model, ModelError, Reply, ToolCall, ToolSpec, Usage
|
|
17
|
+
from workflowval.redact import redact_text
|
|
18
|
+
from workflowval.schemas import Label, RunState, RunStats, save_json
|
|
19
|
+
from workflowval.tools import ROLE_TOOLS, TOOLS, Tool, ToolContext, ToolError
|
|
20
|
+
|
|
21
|
+
MAX_INVALID_RETRIES = 2
|
|
22
|
+
# Measured on 24 runs: fix and review tasks normally use under 12 calls, and stuck ones over 60.
|
|
23
|
+
TASK_CALL_LIMIT = 20
|
|
24
|
+
PASSED = "passed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StopReason(StrEnum):
|
|
28
|
+
"""Why a run ended. Everything but COMPLETED names a limit or failure that was hit."""
|
|
29
|
+
|
|
30
|
+
COMPLETED = "completed"
|
|
31
|
+
MAX_ITERATIONS = "max_iterations"
|
|
32
|
+
MAX_STEPS = "max_steps"
|
|
33
|
+
MAX_COST = "max_cost"
|
|
34
|
+
NO_PROGRESS = "no_progress"
|
|
35
|
+
INVALID_OUTPUT = "invalid_output"
|
|
36
|
+
MODEL_ERROR = "model_error"
|
|
37
|
+
API_UNREACHABLE = "api_unreachable"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Budget:
|
|
42
|
+
"""Hard limits for a run. One step is one model call, so review rounds count toward it."""
|
|
43
|
+
|
|
44
|
+
max_review_rounds: int = 2
|
|
45
|
+
max_fix_attempts: int = 3
|
|
46
|
+
max_iterations: int = 10
|
|
47
|
+
max_steps: int = 200
|
|
48
|
+
no_progress_limit: int = 3
|
|
49
|
+
max_cost_usd: float | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Role:
|
|
54
|
+
"""A prompt plus a restricted tool set. A known role can't be built with a forbidden tool."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
prompt: str
|
|
58
|
+
tools: tuple[Tool, ...]
|
|
59
|
+
model: Model
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
allowed = ROLE_TOOLS.get(self.name)
|
|
63
|
+
if allowed is None:
|
|
64
|
+
return
|
|
65
|
+
forbidden = sorted({t.name for t in self.tools} - allowed)
|
|
66
|
+
if forbidden:
|
|
67
|
+
raise ValueError(f"role {self.name!r} may not have tools: {forbidden}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class TestOutcome:
|
|
72
|
+
"""One test's result from the Runner. `message` is empty when it passed."""
|
|
73
|
+
|
|
74
|
+
__test__ = False
|
|
75
|
+
|
|
76
|
+
test_id: str
|
|
77
|
+
passed: bool
|
|
78
|
+
message: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class RoleStep:
|
|
83
|
+
"""Run a role once. `workflow_id` credits its calls to that workflow in the stats."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
role: Role
|
|
87
|
+
instruction: str
|
|
88
|
+
workflow_id: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class CodeStep:
|
|
93
|
+
"""Run plain code, with no model."""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
fn: Callable[[RunContext], None]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ReviewLoop:
|
|
101
|
+
"""Review, and revise while the check asks for it, up to `max_review_rounds` rounds."""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
review: RoleStep
|
|
105
|
+
revise: RoleStep
|
|
106
|
+
needs_revision: Callable[[RunContext], bool]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class FixLoop:
|
|
111
|
+
"""Run tests, label failures, fix those labeled TEST_BUG, and re-run the fixed ones.
|
|
112
|
+
|
|
113
|
+
`execute` gets the ids to run, or None for all. `label` returns one label per failure, and a
|
|
114
|
+
failure it leaves out becomes UNRESOLVED. `fix` is only ever called for a TEST_BUG. `first_run`
|
|
115
|
+
picks the tests for the first pass, and the loop does nothing if it picks none.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
name: str
|
|
119
|
+
execute: Callable[[RunContext, frozenset[str] | None], Sequence[TestOutcome]]
|
|
120
|
+
label: Callable[[RunContext, Sequence[TestOutcome]], Mapping[str, Label]]
|
|
121
|
+
fix: Callable[[RunContext, TestOutcome], None]
|
|
122
|
+
first_run: Callable[[RunContext], frozenset[str]] | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
Step = RoleStep | CodeStep | ReviewLoop | FixLoop
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Pipeline:
|
|
130
|
+
"""The ordered steps of a run."""
|
|
131
|
+
|
|
132
|
+
steps: tuple[Step, ...]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class RunResult:
|
|
137
|
+
"""How a run ended. Tests still open when a budget hit are UNRESOLVED, never lost."""
|
|
138
|
+
|
|
139
|
+
stop_reason: StopReason
|
|
140
|
+
detail: str
|
|
141
|
+
tests: dict[str, str]
|
|
142
|
+
state: RunState
|
|
143
|
+
stats: RunStats
|
|
144
|
+
suggestion: str | None = None
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def summary(self) -> str:
|
|
148
|
+
"""One line of tokens and cost to print for every run (NFR-4)."""
|
|
149
|
+
s = self.state
|
|
150
|
+
cost = f"${s.cost_usd:.4f}"
|
|
151
|
+
if not s.cost_tracked:
|
|
152
|
+
cost = "not tracked"
|
|
153
|
+
elif s.unpriced_calls:
|
|
154
|
+
cost += f" (plus {s.unpriced_calls} calls with no known price)"
|
|
155
|
+
line = (
|
|
156
|
+
f"stopped: {self.stop_reason.value}; steps: {s.steps_used}; "
|
|
157
|
+
f"tokens: {s.input_tokens} in, {s.output_tokens} out; cost: {cost}"
|
|
158
|
+
)
|
|
159
|
+
return f"{line}\n{self.suggestion}" if self.suggestion else line
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TaskLimitReached(Exception):
|
|
163
|
+
"""One task used all its model calls. Only that task stops, and the run carries on."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class StopRun(Exception):
|
|
167
|
+
"""Raised by steps and the loop to end a run with a stated reason."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, reason: StopReason, detail: str) -> None:
|
|
170
|
+
super().__init__(detail)
|
|
171
|
+
self.reason = reason
|
|
172
|
+
self.detail = detail
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class RunContext:
|
|
177
|
+
"""Run state shared with code steps and callbacks."""
|
|
178
|
+
|
|
179
|
+
budget: Budget
|
|
180
|
+
workdir: Path
|
|
181
|
+
tracer: Tracer
|
|
182
|
+
state: RunState = field(default_factory=RunState)
|
|
183
|
+
tool_context: ToolContext | None = None
|
|
184
|
+
stats: StatsCollector = field(init=False)
|
|
185
|
+
|
|
186
|
+
def __post_init__(self) -> None:
|
|
187
|
+
self.stats = StatsCollector(self.budget.max_steps, self.state.spec_hash)
|
|
188
|
+
self.dearest_call_usd = 0.0
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def state_path(self) -> Path:
|
|
192
|
+
"""Where checkpoints go."""
|
|
193
|
+
return self.workdir / ".workflowval" / "state.json"
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def stats_path(self) -> Path:
|
|
197
|
+
"""This run's stats."""
|
|
198
|
+
return self.workdir / ".workflowval" / "stats.json"
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def history_path(self) -> Path:
|
|
202
|
+
"""One line per finished run."""
|
|
203
|
+
return self.workdir / ".workflowval" / "stats_history.jsonl"
|
|
204
|
+
|
|
205
|
+
def checkpoint(self) -> None:
|
|
206
|
+
"""Save the resume point and the stats so far."""
|
|
207
|
+
save_json(self.state, self.state_path)
|
|
208
|
+
save_json(self.stats.snapshot(), self.stats_path)
|
|
209
|
+
|
|
210
|
+
def run_role(
|
|
211
|
+
self,
|
|
212
|
+
role: Role,
|
|
213
|
+
instruction: str,
|
|
214
|
+
workflow_id: str | None = None,
|
|
215
|
+
*,
|
|
216
|
+
max_calls: int = TASK_CALL_LIMIT,
|
|
217
|
+
) -> str:
|
|
218
|
+
"""Talk to a role until it answers without tool calls, and return that answer.
|
|
219
|
+
|
|
220
|
+
With `workflow_id`, every call counts toward that workflow in the stats. Without it, each
|
|
221
|
+
tool call counts toward the workflow its arguments name, or else toward the run. A task that
|
|
222
|
+
makes `max_calls` model calls without finishing raises TaskLimitReached, so that one stuck
|
|
223
|
+
task can't spend a whole run's budget.
|
|
224
|
+
"""
|
|
225
|
+
calls = 0
|
|
226
|
+
messages = [
|
|
227
|
+
Message("system", role.prompt),
|
|
228
|
+
Message("user", instruction),
|
|
229
|
+
]
|
|
230
|
+
tools = {tool.name: tool for tool in role.tools}
|
|
231
|
+
specs = [tool.spec() for tool in role.tools]
|
|
232
|
+
invalid_streak = 0
|
|
233
|
+
while True:
|
|
234
|
+
if calls >= max_calls:
|
|
235
|
+
self.state.tasks_stopped.append(
|
|
236
|
+
f"{role.name}, {workflow_id or 'run'}: stopped at {max_calls} calls"
|
|
237
|
+
)
|
|
238
|
+
self.tracer.event("task_limit", role=role.name, workflow=workflow_id, calls=calls)
|
|
239
|
+
self.checkpoint()
|
|
240
|
+
raise TaskLimitReached(f"{role.name} used all {max_calls} calls for one task")
|
|
241
|
+
calls += 1
|
|
242
|
+
reply = self._call_model(role, messages, specs, workflow_id)
|
|
243
|
+
self.stats.record_reply(
|
|
244
|
+
workflow_id, reply.tool_calls, {*TOOLS, *tools}, role.name, reply.usage.cost_usd
|
|
245
|
+
)
|
|
246
|
+
messages.append(Message("assistant", reply.text, tool_calls=reply.tool_calls))
|
|
247
|
+
if not reply.tool_calls:
|
|
248
|
+
return reply.text
|
|
249
|
+
any_invalid = False
|
|
250
|
+
for call in reply.tool_calls:
|
|
251
|
+
result, invalid = self._call_tool(role, tools, call)
|
|
252
|
+
any_invalid = any_invalid or invalid
|
|
253
|
+
messages.append(Message("tool", result, tool_call_id=call.id))
|
|
254
|
+
invalid_streak = invalid_streak + 1 if any_invalid else 0
|
|
255
|
+
if invalid_streak > MAX_INVALID_RETRIES:
|
|
256
|
+
raise StopRun(
|
|
257
|
+
StopReason.INVALID_OUTPUT,
|
|
258
|
+
f"role {role.name} sent invalid tool calls {invalid_streak} times in a row",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _call_model(
|
|
262
|
+
self,
|
|
263
|
+
role: Role,
|
|
264
|
+
messages: list[Message],
|
|
265
|
+
specs: list[ToolSpec],
|
|
266
|
+
workflow_id: str | None,
|
|
267
|
+
) -> Reply:
|
|
268
|
+
if self.state.steps_used >= self.budget.max_steps:
|
|
269
|
+
raise StopRun(StopReason.MAX_STEPS, f"used all {self.budget.max_steps} steps")
|
|
270
|
+
self._check_cost_limit()
|
|
271
|
+
self.state.steps_used += 1
|
|
272
|
+
try:
|
|
273
|
+
reply = role.model.complete(messages, specs)
|
|
274
|
+
except ModelError as exc:
|
|
275
|
+
self.stats.record_failed_call(workflow_id, role.name)
|
|
276
|
+
self.tracer.event("model_error", role=role.name, model=role.model.name, error=str(exc))
|
|
277
|
+
raise StopRun(StopReason.MODEL_ERROR, str(exc)) from exc
|
|
278
|
+
self._add_usage(reply.usage)
|
|
279
|
+
self.tracer.event(
|
|
280
|
+
"model_call",
|
|
281
|
+
role=role.name,
|
|
282
|
+
model=role.model.name,
|
|
283
|
+
messages=len(messages),
|
|
284
|
+
reply_size=len(reply.text),
|
|
285
|
+
tool_calls=[call.name for call in reply.tool_calls],
|
|
286
|
+
input_tokens=reply.usage.input_tokens,
|
|
287
|
+
output_tokens=reply.usage.output_tokens,
|
|
288
|
+
cost_usd=reply.usage.cost_usd,
|
|
289
|
+
)
|
|
290
|
+
return reply
|
|
291
|
+
|
|
292
|
+
def _check_cost_limit(self) -> None:
|
|
293
|
+
"""Stop before a call that could pass the limit, judging by the dearest call so far."""
|
|
294
|
+
limit = self.budget.max_cost_usd
|
|
295
|
+
if limit is None:
|
|
296
|
+
return
|
|
297
|
+
if self.state.unpriced_calls:
|
|
298
|
+
raise StopRun(
|
|
299
|
+
StopReason.MAX_COST, "a call had no known price, so the cost limit can't be kept"
|
|
300
|
+
)
|
|
301
|
+
if self.state.cost_usd + self.dearest_call_usd > limit:
|
|
302
|
+
raise StopRun(
|
|
303
|
+
StopReason.MAX_COST,
|
|
304
|
+
f"spent ${self.state.cost_usd:.4f}; another call could pass the ${limit:.2f} limit",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _add_usage(self, usage: Usage) -> None:
|
|
308
|
+
self.state.input_tokens += usage.input_tokens
|
|
309
|
+
self.state.output_tokens += usage.output_tokens
|
|
310
|
+
if usage.cost_usd is None:
|
|
311
|
+
self.state.unpriced_calls += 1
|
|
312
|
+
else:
|
|
313
|
+
self.state.cost_usd += usage.cost_usd
|
|
314
|
+
self.dearest_call_usd = max(self.dearest_call_usd, usage.cost_usd)
|
|
315
|
+
|
|
316
|
+
def _call_tool(self, role: Role, tools: dict[str, Tool], call: ToolCall) -> tuple[str, bool]:
|
|
317
|
+
"""Return the text for the model, and whether the call counts as invalid output."""
|
|
318
|
+
invalid = True
|
|
319
|
+
tool = tools.get(call.name)
|
|
320
|
+
if tool is None:
|
|
321
|
+
result = f"Error: role {role.name} can't call {call.name!r}. Tools: {sorted(tools)}"
|
|
322
|
+
elif call.parse_error is not None:
|
|
323
|
+
result = f"Error: {call.parse_error}"
|
|
324
|
+
else:
|
|
325
|
+
try:
|
|
326
|
+
result = tool.invoke(call.arguments, self.tool_context or ToolContext(self.workdir))
|
|
327
|
+
invalid = False
|
|
328
|
+
except ValidationError as exc:
|
|
329
|
+
# include_input=False: the rejected value may be a secret.
|
|
330
|
+
errors = exc.errors(include_url=False, include_input=False, include_context=False)
|
|
331
|
+
result = f"Error: invalid arguments for {call.name}: {json.dumps(errors)}"
|
|
332
|
+
except ToolError as exc:
|
|
333
|
+
result = f"Error: {exc}"
|
|
334
|
+
invalid = False
|
|
335
|
+
result = redact_text(result)
|
|
336
|
+
self.tracer.event(
|
|
337
|
+
"tool_call",
|
|
338
|
+
role=role.name,
|
|
339
|
+
tool=call.name,
|
|
340
|
+
arguments=call.arguments,
|
|
341
|
+
result_size=len(result),
|
|
342
|
+
invalid=invalid,
|
|
343
|
+
error=result if result.startswith("Error:") else None,
|
|
344
|
+
)
|
|
345
|
+
return result, invalid
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _run_review_loop(ctx: RunContext, step: ReviewLoop) -> None:
|
|
349
|
+
while ctx.state.review_rounds < ctx.budget.max_review_rounds:
|
|
350
|
+
ctx.run_role(step.review.role, step.review.instruction)
|
|
351
|
+
ctx.state.review_rounds += 1
|
|
352
|
+
ctx.checkpoint()
|
|
353
|
+
if not step.needs_revision(ctx):
|
|
354
|
+
return
|
|
355
|
+
ctx.run_role(step.revise.role, step.revise.instruction)
|
|
356
|
+
ctx.checkpoint()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _close(ctx: RunContext, test_id: str, status: str) -> None:
|
|
360
|
+
ctx.state.closed_tests[test_id] = status
|
|
361
|
+
if test_id in ctx.state.open_tests:
|
|
362
|
+
ctx.state.open_tests.remove(test_id)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _run_fix_loop(ctx: RunContext, step: FixLoop) -> None:
|
|
366
|
+
state, budget = ctx.state, ctx.budget
|
|
367
|
+
to_run = step.first_run(ctx) if step.first_run is not None else None
|
|
368
|
+
previous: tuple[int, frozenset[tuple[str, str]]] | None = None
|
|
369
|
+
stalled = 0
|
|
370
|
+
while to_run is None or to_run:
|
|
371
|
+
if state.iterations >= budget.max_iterations:
|
|
372
|
+
raise StopRun(StopReason.MAX_ITERATIONS, f"used all {budget.max_iterations} iterations")
|
|
373
|
+
state.iterations += 1
|
|
374
|
+
outcomes = list(step.execute(ctx, to_run))
|
|
375
|
+
# A test asked for but not reported must not count as passing.
|
|
376
|
+
reported = {outcome.test_id for outcome in outcomes}
|
|
377
|
+
outcomes += [
|
|
378
|
+
TestOutcome(test_id, False, "the Runner reported no result")
|
|
379
|
+
for test_id in sorted((to_run or frozenset()) - reported)
|
|
380
|
+
]
|
|
381
|
+
failures = [outcome for outcome in outcomes if not outcome.passed]
|
|
382
|
+
state.open_tests = sorted({*state.open_tests, *(f.test_id for f in failures)})
|
|
383
|
+
for outcome in outcomes:
|
|
384
|
+
if outcome.passed:
|
|
385
|
+
_close(ctx, outcome.test_id, PASSED)
|
|
386
|
+
|
|
387
|
+
labels = step.label(ctx, failures) if failures else {}
|
|
388
|
+
to_fix: list[TestOutcome] = []
|
|
389
|
+
for failure in failures:
|
|
390
|
+
label = labels.get(failure.test_id, Label.UNRESOLVED)
|
|
391
|
+
attempts = state.fix_attempts.get(failure.test_id, 0)
|
|
392
|
+
if label is not Label.TEST_BUG:
|
|
393
|
+
_close(ctx, failure.test_id, label.value)
|
|
394
|
+
elif attempts >= budget.max_fix_attempts:
|
|
395
|
+
_close(ctx, failure.test_id, Label.UNRESOLVED.value)
|
|
396
|
+
else:
|
|
397
|
+
to_fix.append(failure)
|
|
398
|
+
|
|
399
|
+
# Progress: a test closed, or the failures changed. The first pass sets the baseline.
|
|
400
|
+
current = (
|
|
401
|
+
len(state.closed_tests),
|
|
402
|
+
frozenset((f.test_id, f.message) for f in failures),
|
|
403
|
+
)
|
|
404
|
+
stalled = stalled + 1 if current == previous else 0
|
|
405
|
+
previous = current
|
|
406
|
+
ctx.checkpoint()
|
|
407
|
+
if to_fix and stalled >= budget.no_progress_limit:
|
|
408
|
+
raise StopRun(StopReason.NO_PROGRESS, f"no progress in {stalled} iterations")
|
|
409
|
+
|
|
410
|
+
retry: list[TestOutcome] = []
|
|
411
|
+
for failure in to_fix:
|
|
412
|
+
state.fix_attempts[failure.test_id] = state.fix_attempts.get(failure.test_id, 0) + 1
|
|
413
|
+
try:
|
|
414
|
+
step.fix(ctx, failure)
|
|
415
|
+
retry.append(failure)
|
|
416
|
+
except TaskLimitReached:
|
|
417
|
+
# A fix that couldn't finish isn't trustworthy, so it doesn't get another go.
|
|
418
|
+
_close(ctx, failure.test_id, Label.UNRESOLVED.value)
|
|
419
|
+
ctx.checkpoint()
|
|
420
|
+
to_run = frozenset(failure.test_id for failure in retry)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _run_step(ctx: RunContext, step: Step) -> None:
|
|
424
|
+
if isinstance(step, RoleStep):
|
|
425
|
+
ctx.run_role(step.role, step.instruction, step.workflow_id)
|
|
426
|
+
elif isinstance(step, CodeStep):
|
|
427
|
+
step.fn(ctx)
|
|
428
|
+
elif isinstance(step, ReviewLoop):
|
|
429
|
+
_run_review_loop(ctx, step)
|
|
430
|
+
else:
|
|
431
|
+
_run_fix_loop(ctx, step)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def run(
|
|
435
|
+
pipeline: Pipeline,
|
|
436
|
+
budget: Budget,
|
|
437
|
+
*,
|
|
438
|
+
workdir: Path,
|
|
439
|
+
spec_hash: str = "",
|
|
440
|
+
verbose: bool = False,
|
|
441
|
+
tool_context: ToolContext | None = None,
|
|
442
|
+
cost_tracked: bool = True,
|
|
443
|
+
resume: RunState | None = None,
|
|
444
|
+
) -> RunResult:
|
|
445
|
+
"""Run a pipeline. It always terminates, and the result says which limit stopped it, if any.
|
|
446
|
+
|
|
447
|
+
With `resume`, it carries on from that state, skipping the steps already completed. Call counts
|
|
448
|
+
in the stats start again, while tokens, cost and steps used carry over.
|
|
449
|
+
"""
|
|
450
|
+
tracer = Tracer(workdir / ".workflowval" / "trace.jsonl", echo=verbose)
|
|
451
|
+
state = resume or RunState(spec_hash=spec_hash, cost_tracked=cost_tracked)
|
|
452
|
+
ctx = RunContext(budget, workdir, tracer, state, tool_context)
|
|
453
|
+
reason, detail = StopReason.COMPLETED, ""
|
|
454
|
+
tracer.event("run_start", spec_hash=spec_hash, steps=[step.name for step in pipeline.steps])
|
|
455
|
+
ctx.checkpoint()
|
|
456
|
+
try:
|
|
457
|
+
for step in pipeline.steps:
|
|
458
|
+
if step.name in ctx.state.completed_steps:
|
|
459
|
+
continue
|
|
460
|
+
# A stuck task ends that step's work, and later steps can still run.
|
|
461
|
+
with contextlib.suppress(TaskLimitReached):
|
|
462
|
+
_run_step(ctx, step)
|
|
463
|
+
ctx.state.completed_steps.append(step.name)
|
|
464
|
+
ctx.checkpoint()
|
|
465
|
+
except StopRun as stop:
|
|
466
|
+
reason, detail = stop.reason, stop.detail
|
|
467
|
+
# Whatever is still open failed without a final label, and must show up as UNRESOLVED.
|
|
468
|
+
for test_id in list(ctx.state.open_tests):
|
|
469
|
+
_close(ctx, test_id, Label.UNRESOLVED.value)
|
|
470
|
+
ctx.state.stop_reason = reason.value
|
|
471
|
+
ctx.checkpoint()
|
|
472
|
+
stats = ctx.stats.snapshot(reason.value)
|
|
473
|
+
save_json(stats, ctx.stats_path)
|
|
474
|
+
append_history(stats, ctx.history_path)
|
|
475
|
+
suggestion = suggest_max_steps(read_history(ctx.history_path), budget.max_steps)
|
|
476
|
+
result = RunResult(reason, detail, dict(ctx.state.closed_tests), ctx.state, stats, suggestion)
|
|
477
|
+
tracer.event(
|
|
478
|
+
"run_end",
|
|
479
|
+
stop_reason=reason.value,
|
|
480
|
+
detail=detail,
|
|
481
|
+
steps_used=ctx.state.steps_used,
|
|
482
|
+
input_tokens=ctx.state.input_tokens,
|
|
483
|
+
output_tokens=ctx.state.output_tokens,
|
|
484
|
+
cost_usd=ctx.state.cost_usd,
|
|
485
|
+
unpriced_calls=ctx.state.unpriced_calls,
|
|
486
|
+
suggestion=suggestion,
|
|
487
|
+
)
|
|
488
|
+
return result
|