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,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