prettyplay 0.0.2__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 (75) hide show
  1. prettyplay/.usages/lifecycle.md +117 -0
  2. prettyplay/.usages/steps.md +169 -0
  3. prettyplay/__init__.py +18 -0
  4. prettyplay/cache/.usages/addressing.md +44 -0
  5. prettyplay/cache/.usages/budgets.md +51 -0
  6. prettyplay/cache/.usages/storage.md +32 -0
  7. prettyplay/cache/__init__.py +8 -0
  8. prettyplay/cache/budgets.py +93 -0
  9. prettyplay/cache/models.py +55 -0
  10. prettyplay/cache/store.py +276 -0
  11. prettyplay/cache/text.py +36 -0
  12. prettyplay/config/.usages/configuration.md +246 -0
  13. prettyplay/config/__init__.py +6 -0
  14. prettyplay/config/loader.py +336 -0
  15. prettyplay/config/models.py +288 -0
  16. prettyplay/driver/.usages/error_kinds.md +26 -0
  17. prettyplay/driver/.usages/plumbing.md +52 -0
  18. prettyplay/driver/__init__.py +11 -0
  19. prettyplay/driver/errors.py +76 -0
  20. prettyplay/driver/page.py +175 -0
  21. prettyplay/driver/session.py +330 -0
  22. prettyplay/engine/.usages/generation.md +157 -0
  23. prettyplay/engine/.usages/healing.md +61 -0
  24. prettyplay/engine/__init__.py +19 -0
  25. prettyplay/engine/attempts.py +80 -0
  26. prettyplay/engine/classification.py +70 -0
  27. prettyplay/engine/compliance.py +125 -0
  28. prettyplay/engine/execution.py +48 -0
  29. prettyplay/engine/generator.py +1110 -0
  30. prettyplay/engine/groups/.usages/recovery.md +71 -0
  31. prettyplay/engine/groups/__init__.py +7 -0
  32. prettyplay/engine/groups/diagnosis.py +128 -0
  33. prettyplay/engine/groups/outcome.py +55 -0
  34. prettyplay/engine/groups/recovery.py +491 -0
  35. prettyplay/engine/healer.py +149 -0
  36. prettyplay/engine/polling/.usages/settle.md +55 -0
  37. prettyplay/engine/polling/__init__.py +6 -0
  38. prettyplay/engine/polling/settle.py +98 -0
  39. prettyplay/engine/polling/window.py +72 -0
  40. prettyplay/engine/renderer/.usages/memory.md +24 -0
  41. prettyplay/engine/renderer/.usages/rendering.md +44 -0
  42. prettyplay/engine/renderer/.usages/validation.md +20 -0
  43. prettyplay/engine/renderer/__init__.py +7 -0
  44. prettyplay/engine/renderer/memory.py +37 -0
  45. prettyplay/engine/renderer/render.py +184 -0
  46. prettyplay/engine/renderer/validation.py +76 -0
  47. prettyplay/engine/steering/.usages/steering.md +95 -0
  48. prettyplay/engine/steering/__init__.py +5 -0
  49. prettyplay/engine/steering/steering.py +765 -0
  50. prettyplay/engine/text.py +41 -0
  51. prettyplay/executor.py +654 -0
  52. prettyplay/failures/.usages/taxonomy.md +131 -0
  53. prettyplay/failures/__init__.py +25 -0
  54. prettyplay/failures/errors.py +359 -0
  55. prettyplay/groups.py +276 -0
  56. prettyplay/llm/.usages/classification.md +31 -0
  57. prettyplay/llm/.usages/providers.md +127 -0
  58. prettyplay/llm/.usages/transport.md +81 -0
  59. prettyplay/llm/__init__.py +38 -0
  60. prettyplay/llm/_request.py +823 -0
  61. prettyplay/llm/anthropic_provider.py +468 -0
  62. prettyplay/llm/models.py +273 -0
  63. prettyplay/llm/openai_provider.py +444 -0
  64. prettyplay/llm/provider.py +279 -0
  65. prettyplay/reporting/.usages/hooks.md +61 -0
  66. prettyplay/reporting/__init__.py +6 -0
  67. prettyplay/reporting/hooks.py +47 -0
  68. prettyplay/reporting/reporter.py +70 -0
  69. prettyplay/runtime.py +81 -0
  70. prettyplay/scenario.py +469 -0
  71. prettyplay-0.0.2.dist-info/METADATA +515 -0
  72. prettyplay-0.0.2.dist-info/RECORD +75 -0
  73. prettyplay-0.0.2.dist-info/WHEEL +5 -0
  74. prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
  75. prettyplay-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,41 @@
1
+ """The error-text policy of the engine: full typed failure text for reports and requests."""
2
+
3
+ import re
4
+
5
+ #: The header of the bulky page-state section Playwright appends to a failed
6
+ #: expectation message — column zero; the section runs to the message end.
7
+ _ARIA_SNAPSHOT_HEADER = re.compile(r"^Aria snapshot:[ \t]*$", re.MULTILINE)
8
+
9
+
10
+ def _strip_aria_snapshot(text: str) -> str:
11
+ """Drop the aria-snapshot section Playwright appends to a failed check message.
12
+
13
+ Args:
14
+ text: the full message text of the step-code exception.
15
+
16
+ Returns:
17
+ The text before the column-zero ``Aria snapshot:`` header, trailing
18
+ whitespace trimmed; text without the header returns unchanged.
19
+ """
20
+ header = _ARIA_SNAPSHOT_HEADER.search(text)
21
+
22
+ return text[: header.start()].rstrip() if header is not None else text
23
+
24
+
25
+ def format_step_error(exc: Exception) -> str:
26
+ """Format the full failure text of a step-code exception.
27
+
28
+ Args:
29
+ exc: the exception raised by the failed step or candidate branch.
30
+
31
+ Returns:
32
+ The full formatted failure text — a failed check yields its message
33
+ with no prefix, the trailing aria-snapshot section excluded; any
34
+ other failure carries its type name; a message-less check yields an
35
+ empty text, a message-less error the bare type name.
36
+ """
37
+ text = _strip_aria_snapshot(str(exc))
38
+ if isinstance(exc, AssertionError):
39
+ return text
40
+
41
+ return f"{type(exc).__name__}: {text}" if text else type(exc).__name__
prettyplay/executor.py ADDED
@@ -0,0 +1,654 @@
1
+ """The step cycle: cache hit — settle; miss — generate or the strict stop; failure — heal, recover, steer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import TYPE_CHECKING, NoReturn
8
+
9
+ from .cache import CachedStep, RunBudgets, StepCache, StepIdentity, normalize_step_text
10
+ from .config import PrettyConfig
11
+ from .driver import PageFacade
12
+ from .engine import StepGenerator, StepHealer, classify_step_failure, format_step_error, run_step_code
13
+ from .engine.attempts import OUTCOME_ORIGINAL, StepAttempt
14
+ from .engine.execution import bind_step_inputs
15
+ from .engine.groups import GroupRecovery, GroupStepOutcome
16
+ from .engine.polling import SettleWindow, settle
17
+ from .engine.renderer import PreparedStep, StepMemory, render_step, validate_step_result
18
+ from .engine.steering import StepSteering
19
+ from .failures import FailureVerdict, IncurableStepError, LLMUnavailableError, ProductDefectError
20
+ from .llm import LLMProvider, ScenarioStep
21
+ from .reporting import StepReporter
22
+
23
+ if TYPE_CHECKING:
24
+ from .groups import StepGroup
25
+
26
+ logger = logging.getLogger("prettyplay")
27
+
28
+ #: Strict-mode cache miss — generation is forbidden, so the miss is incurable.
29
+ _STRICT_MISS_REASON = "strict mode forbids generation — the step is missing from the cache"
30
+
31
+ #: Strict-mode terminal failure produced by the quiet verdict skip.
32
+ _STRICT_NO_VERDICT_REASON = "the step failed in strict mode without an llm verdict"
33
+
34
+
35
+ def _read_url(page: PageFacade) -> str:
36
+ """Read the page URL for the replay bracket; never kills the step.
37
+
38
+ Args:
39
+ page: the page facade handle of the current test.
40
+
41
+ Returns:
42
+ The current URL of the page, or the empty string when the read
43
+ fails — the step itself proceeds normally.
44
+ """
45
+ try:
46
+ return page.url
47
+ except Exception:
48
+ return ""
49
+
50
+
51
+ class StepExecutor:
52
+ """The owner of the step cycle of one test.
53
+
54
+ Attributes:
55
+ cache_key: the context key of the owning test object.
56
+ _cache: the step cache of the test.
57
+ _generator: the generation engine of the cycle.
58
+ _healer: the healing engine of the cycle — ordinary steps only.
59
+ _steering: the interactive steering of terminally stuck steps.
60
+ _recovery: the group recovery engine of failed group steps.
61
+ _reporter: the visibility point of the test.
62
+ _config: project settings; the strict switch picks the replay-only
63
+ path, the interactive switch the steering gate.
64
+ _provider: the LLM port implementation of the strict classification.
65
+ _scenario: the typed records of the previous steps of this test.
66
+ _memory: the per-test memory of captured observations — constructed
67
+ here once, read by every render, grown only at acceptance points.
68
+ """
69
+
70
+ def __init__( # noqa: PLR0913, PLR0917 — the signature is fixed by the root cell contract
71
+ self,
72
+ cache_key: str,
73
+ cache: StepCache,
74
+ generator: StepGenerator,
75
+ healer: StepHealer,
76
+ steering: StepSteering,
77
+ recovery: GroupRecovery,
78
+ budgets: RunBudgets, # noqa: ARG002 — spent by the engine; kept for contract symmetry
79
+ reporter: StepReporter,
80
+ config: PrettyConfig,
81
+ provider: LLMProvider,
82
+ ) -> None:
83
+ """Keep the collaborators of the step cycle, reset the scenario context and build the test memory.
84
+
85
+ Args:
86
+ cache_key: the context key of the owning test object.
87
+ cache: the step cache of the test.
88
+ generator: the generation engine of the cycle.
89
+ healer: the healing engine of the cycle — invoked only for
90
+ ordinary steps of a non-strict run.
91
+ steering: the interactive steering of terminally stuck steps —
92
+ invoked only on a non-strict interactive run.
93
+ recovery: the group recovery engine of failed group steps —
94
+ invoked only on a non-strict run for a step executed inside
95
+ a group.
96
+ budgets: the per-test attempt registry; attempts are spent
97
+ by the engine, not by the executor.
98
+ reporter: the visibility point of the test.
99
+ config: project settings; the strict switch picks the
100
+ replay-only path, the interactive switch the steering gate,
101
+ and the classification prompt feeds the strict path.
102
+ provider: the LLM port implementation of the strict classification;
103
+ a lightweight object — no SDK client, no credentials.
104
+ """
105
+ self.cache_key = cache_key
106
+ self._cache = cache
107
+ self._generator = generator
108
+ self._healer = healer
109
+ self._steering = steering
110
+ self._recovery = recovery
111
+ self._reporter = reporter
112
+ self._config = config
113
+ self._provider = provider
114
+ self._scenario: list[ScenarioStep] = [] # test scenario context
115
+ self._memory = StepMemory() # the per-test memory of captured observations
116
+
117
+ def execute( # noqa: PLR0913, PLR0917 — the signature is fixed by the root cell contract
118
+ self,
119
+ step_text: str,
120
+ step_type: str,
121
+ page: PageFacade,
122
+ group: StepGroup | None = None,
123
+ tries: int | None = None,
124
+ delay: float | None = None,
125
+ vars: dict[str, str] | None = None,
126
+ ) -> None:
127
+ """Run one step through the full cycle.
128
+
129
+ Args:
130
+ step_text: the sentence of the step as written by the engineer —
131
+ the template source and the addressing artifact; never
132
+ rendered into a request.
133
+ step_type: the kind of the step sentence ({action, assertion}).
134
+ page: the live page facade of the current test.
135
+ group: the authoring group the step executes inside; ``None`` —
136
+ an ordinary step. A group step carries the group framing and
137
+ its failures route to the group recovery; strict mode keeps
138
+ the classification-only path for every step alike.
139
+ tries: the declared retry count of this execution; ``None`` —
140
+ the time-bounded settle mode; a declared count switches the
141
+ settle window to the count-bounded mode.
142
+ delay: the declared quiet start pause in seconds; ``None`` — no
143
+ pause; passes quietly right after the started event, before
144
+ the render.
145
+ vars: the validated call-local string inputs passed by the
146
+ facade/group methods; rendered into the step's
147
+ ``PreparedStep`` and never published to memory.
148
+
149
+ Raises:
150
+ PrettyplayError: the template is an authoring error — malformed,
151
+ an invalid or duplicated capture name, an unavailable name or
152
+ an expectation declaring captures; raised before any browser
153
+ execution, cache access or identity construction.
154
+ ProductDefectError: the healed step verdict says the expectation
155
+ of the step is genuinely broken in the product; in strict
156
+ mode, a product_defect classification of a failed cached
157
+ step — or an assertion step failing without a verdict.
158
+ IncurableStepError: the step never generated successfully, or the
159
+ verdict says regeneration cannot help; in strict mode, any
160
+ cache miss or non-product-defect classification. Never raised
161
+ by an interactive non-strict step the steering dialog healed.
162
+ LLMUnavailableError: the provider service failed; no retry.
163
+ ComplianceVerdictError: the compliance gate could not obtain a
164
+ usable verdict — the provider answer did not parse into
165
+ findings; the executed candidate is never cached.
166
+ """
167
+ outcome = "failed"
168
+ traced = False # this step's group trace record — appended exactly once, failed or passed
169
+ try:
170
+ self._reporter.emit("on_step_started", {"step_text": step_text, "step_type": step_type})
171
+ if delay is not None:
172
+ time.sleep(delay) # the quiet pre-step pause — no event, no log record
173
+
174
+ prepared = render_step(step_text, step_type, self._memory, vars) # authoring errors precede addressing
175
+ identity = StepIdentity(
176
+ cache_key=self.cache_key,
177
+ step_type=step_type,
178
+ normalized_text=normalize_step_text(step_text),
179
+ )
180
+ window = SettleWindow(self._config.polling_timeout, self._config.polling_delay, tries)
181
+ attempt_history: list[StepAttempt] = [] # the per-step history — created here, dies with the step
182
+ group_url_before = _read_url(page) if group is not None else "" # the group bracket opens
183
+ cached = self._cache.load(identity)
184
+
185
+ if cached is not None:
186
+ url_before = group_url_before if group is not None else _read_url(page)
187
+ try:
188
+ with bind_step_inputs(self._memory.snapshot(), prepared.inputs):
189
+ result = settle(run_step_code, cached.code, page, window)
190
+ captures = validate_step_result(prepared, result)
191
+ except Exception as error: # the replay failed or its result violated the contract — the mode reacts
192
+ traced = self._replay_failure(
193
+ cached,
194
+ format_step_error(error),
195
+ url_before,
196
+ _read_url(page), # the bracket closes on the failure record 0 anchors
197
+ prepared,
198
+ step_text,
199
+ step_type,
200
+ group,
201
+ tries,
202
+ delay,
203
+ identity,
204
+ attempt_history,
205
+ page,
206
+ window,
207
+ )
208
+ else:
209
+ # acceptance = the validated successful execution — the gate never runs on replay
210
+ self._memory.publish(captures)
211
+ elif self._config.strict:
212
+ raise IncurableStepError(prepared.instruction, _STRICT_MISS_REASON, "", code="")
213
+ else:
214
+ traced = self._generate_step(
215
+ prepared,
216
+ step_text,
217
+ step_type,
218
+ group,
219
+ tries,
220
+ delay,
221
+ identity,
222
+ group_url_before,
223
+ attempt_history,
224
+ page,
225
+ window,
226
+ )
227
+
228
+ self._scenario.append(
229
+ ScenarioStep(
230
+ sentence=step_text,
231
+ instruction=prepared.instruction,
232
+ group_prompt=group.prompt if group is not None else "",
233
+ )
234
+ )
235
+ if group is not None and not traced:
236
+ # the green step's own record — the bracket closes after the execution
237
+ self._append_group_trace(
238
+ group,
239
+ prepared,
240
+ step_text,
241
+ step_type,
242
+ tries,
243
+ delay,
244
+ identity,
245
+ group_url_before,
246
+ _read_url(page),
247
+ "passed",
248
+ )
249
+ self._reporter.emit("on_step_passed", {"step_text": step_text, "step_type": step_type})
250
+ outcome = "passed"
251
+ except Exception as error:
252
+ self._reporter.emit(
253
+ "on_step_failed",
254
+ {"step_text": step_text, "step_type": step_type, "error": str(error)}, # the render, verbatim
255
+ )
256
+
257
+ if isinstance(error, (ProductDefectError, IncurableStepError)) and error.verdict is not None:
258
+ self._reporter.emit(
259
+ "on_step_verdict",
260
+ {
261
+ "step_text": step_text,
262
+ "category": error.verdict.category,
263
+ "explanation": error.verdict.explanation,
264
+ "recommendation": error.verdict.recommendation,
265
+ },
266
+ )
267
+ raise
268
+ finally:
269
+ # the closing event of every step — exactly once, after every other
270
+ # event, on pass, on failure and on the strict miss alike
271
+ self._reporter.emit(
272
+ "on_step_finished",
273
+ {"step_text": step_text, "step_type": step_type, "outcome": outcome},
274
+ )
275
+
276
+ def _append_group_trace( # noqa: PLR0913, PLR0917 — the record fields are fixed by the groups cell contract
277
+ self,
278
+ group: StepGroup,
279
+ prepared: PreparedStep,
280
+ step_text: str,
281
+ step_type: str,
282
+ tries: int | None,
283
+ delay: float | None,
284
+ identity: StepIdentity,
285
+ url_before: str,
286
+ url_after: str,
287
+ outcome_label: str,
288
+ ) -> None:
289
+ """Append this step's verbatim trace record to the traces of its group.
290
+
291
+ Args:
292
+ group: the authoring group the step executes inside.
293
+ prepared: the render product of the step — its instruction and
294
+ call-local inputs ride the record verbatim.
295
+ step_text: the raw sentence of the step, verbatim — the
296
+ re-render source of the row mechanics.
297
+ step_type: action or assertion.
298
+ tries: the declared retry count of this execution; ``None`` —
299
+ the global polling settings governed the step.
300
+ delay: the declared start pause of this execution; ``None`` —
301
+ no pause.
302
+ identity: the cache address of the step.
303
+ url_before: the page URL read before the execution.
304
+ url_after: the page URL read after the execution — on the
305
+ failure or the green completion.
306
+ outcome_label: passed or failed — exactly one immutable record
307
+ per executed group step.
308
+ """
309
+ group.traces.append(
310
+ GroupStepOutcome(
311
+ sentence=step_text,
312
+ instruction=prepared.instruction,
313
+ step_type=step_type,
314
+ tries=tries,
315
+ delay=delay,
316
+ vars=prepared.inputs,
317
+ outcome=outcome_label,
318
+ url_before=url_before,
319
+ url_after=url_after,
320
+ identity=identity,
321
+ )
322
+ )
323
+
324
+ def _replay_failure( # noqa: PLR0913, PLR0917 — the threading is fixed by the root cell contract
325
+ self,
326
+ step: CachedStep,
327
+ error_text: str,
328
+ url_before: str,
329
+ url_after: str,
330
+ prepared: PreparedStep,
331
+ step_text: str,
332
+ step_type: str,
333
+ group: StepGroup | None,
334
+ tries: int | None,
335
+ delay: float | None,
336
+ identity: StepIdentity,
337
+ attempt_history: list[StepAttempt],
338
+ page: PageFacade,
339
+ window: SettleWindow,
340
+ ) -> bool:
341
+ """React to a failed cached execution by mode: strict classifies, a group recovers, ordinary heals.
342
+
343
+ Args:
344
+ step: the cached step whose code failed.
345
+ error_text: the full formatted error text of the failure — a
346
+ result-contract violation arrives as the deterministic
347
+ violation text.
348
+ url_before: the page URL read before the cached execution.
349
+ url_after: the page URL read on the failure — the bracket of
350
+ record 0 and the failed trace record.
351
+ prepared: the render product of the failed step — every request
352
+ and raised failure carries the prepared instruction, never
353
+ the raw template sentence.
354
+ step_text: the raw sentence of the step, verbatim.
355
+ step_type: action or assertion.
356
+ group: the authoring group the step executes inside;
357
+ ``None`` — an ordinary step.
358
+ tries: the declared retry count of this execution.
359
+ delay: the declared start pause of this execution.
360
+ identity: the cache address of the step.
361
+ attempt_history: the per-step attempt history record 0 anchors.
362
+ page: the live page facade of the test.
363
+ window: the settle window of this step's execution.
364
+
365
+ Returns:
366
+ Whether this step's group trace record was appended; the strict
367
+ path never returns, it raises.
368
+
369
+ Raises:
370
+ ProductDefectError: the strict classification or the healed
371
+ verdict says the expectation is genuinely broken.
372
+ IncurableStepError: the strict classification, the unrecoverable
373
+ group verdict or the unrecoverable heal — the steering
374
+ decline propagates the original failure object.
375
+ LLMUnavailableError: the provider service failed; no retry.
376
+ """
377
+ if self._config.strict:
378
+ self._strict_failure(prepared.instruction, step_type, step, error_text, page) # always raises
379
+
380
+ if group is not None:
381
+ # the failed group step is traced once, then the group recovery decides
382
+ self._append_group_trace(
383
+ group, prepared, step_text, step_type, tries, delay, identity, url_before, url_after, "failed"
384
+ )
385
+ # record 0 — the anchor of the recovery: the original cached code with its
386
+ # replay error and the replay URL pair, composed before the recovery delegation
387
+ attempt_history.append(
388
+ StepAttempt(
389
+ code=step.code,
390
+ error=error_text,
391
+ outcome=OUTCOME_ORIGINAL,
392
+ url_before=url_before,
393
+ url_after=url_after,
394
+ )
395
+ )
396
+ self._recover_or_steer(group, prepared, step_type, identity, attempt_history, page, window)
397
+
398
+ return True
399
+
400
+ # record 0 — the anchor of the healing: the original cached code with its
401
+ # replay error and the replay URL pair, composed before the heal delegation
402
+ attempt_history.append(
403
+ StepAttempt(
404
+ code=step.code,
405
+ error=error_text,
406
+ outcome=OUTCOME_ORIGINAL,
407
+ url_before=url_before,
408
+ url_after=url_after,
409
+ )
410
+ )
411
+ try:
412
+ # healed = re-executed
413
+ self._healer.heal(
414
+ step,
415
+ error_text,
416
+ prepared,
417
+ step_type,
418
+ self._scenario,
419
+ page,
420
+ attempt_history,
421
+ window,
422
+ self._memory,
423
+ )
424
+ except IncurableStepError as failure: # the only kind the steering intercept serves
425
+ self._steer_or_raise(failure, identity, prepared, step_type, self._scenario, None, page, attempt_history)
426
+
427
+ return False
428
+
429
+ def _generate_step( # noqa: PLR0913, PLR0917 — the threading is fixed by the root cell contract
430
+ self,
431
+ prepared: PreparedStep,
432
+ step_text: str,
433
+ step_type: str,
434
+ group: StepGroup | None,
435
+ tries: int | None,
436
+ delay: float | None,
437
+ identity: StepIdentity,
438
+ url_before: str,
439
+ attempt_history: list[StepAttempt],
440
+ page: PageFacade,
441
+ window: SettleWindow,
442
+ ) -> bool:
443
+ """Generate a missing step; a group step's terminal failure routes to the recovery.
444
+
445
+ Args:
446
+ prepared: the render product of the step — every request carries
447
+ the prepared instruction with its INPUTS and RESULTS blocks;
448
+ the accepted captures publish inside the engine loop.
449
+ step_text: the raw sentence of the step, verbatim.
450
+ step_type: action or assertion.
451
+ group: the authoring group the step executes inside;
452
+ ``None`` — an ordinary step.
453
+ tries: the declared retry count of this execution.
454
+ delay: the declared start pause of this execution.
455
+ identity: the cache address of the step.
456
+ url_before: the page URL read before the cycle — the opening
457
+ side of the group bracket.
458
+ attempt_history: the per-step attempt history the generation
459
+ loop grows on its failures.
460
+ page: the live page facade of the test.
461
+ window: the settle window of this step's execution.
462
+
463
+ Returns:
464
+ Whether this step's group trace record was appended. A group
465
+ step's generation failures arrive unclassified — the recovery
466
+ decides; an ordinary step's terminal failure goes to the
467
+ steering gate.
468
+
469
+ Raises:
470
+ ProductDefectError: a generation failure classified as a
471
+ genuine product defect.
472
+ IncurableStepError: the generation never succeeded — the
473
+ steering decline propagates the original failure object.
474
+ LLMUnavailableError: the provider service failed; no retry.
475
+ """
476
+ try:
477
+ self._generator.generate(
478
+ identity,
479
+ prepared,
480
+ step_type,
481
+ self._scenario,
482
+ group.prompt if group is not None else None,
483
+ page,
484
+ attempt_history,
485
+ window,
486
+ self._memory,
487
+ )
488
+
489
+ return False
490
+ except IncurableStepError as failure: # the only kind the steering intercept serves
491
+ if group is None:
492
+ self._steer_or_raise(
493
+ failure, identity, prepared, step_type, self._scenario, None, page, attempt_history
494
+ )
495
+
496
+ return False
497
+
498
+ # the unclassified generation failure of a group step — the recovery decides
499
+ self._append_group_trace(
500
+ group, prepared, step_text, step_type, tries, delay, identity, url_before, _read_url(page), "failed"
501
+ )
502
+ self._recover_or_steer(group, prepared, step_type, identity, attempt_history, page, window)
503
+
504
+ return True
505
+
506
+ def _recover_or_steer( # noqa: PLR0913, PLR0917 — the threading is fixed by the root cell contract
507
+ self,
508
+ group: StepGroup,
509
+ prepared: PreparedStep,
510
+ step_type: str,
511
+ identity: StepIdentity,
512
+ attempt_history: list[StepAttempt],
513
+ page: PageFacade,
514
+ window: SettleWindow,
515
+ ) -> None:
516
+ """Delegate a failed group step to the recovery; a healed return continues the step as a success.
517
+
518
+ Args:
519
+ group: the authoring group of the failed step.
520
+ prepared: the render product of the failed step — the active
521
+ failure's instruction rides the diagnosis; the per-test
522
+ memory threads into every row re-render and publication.
523
+ step_type: action or assertion.
524
+ identity: the address of the failed step.
525
+ attempt_history: the per-step attempt history anchored by
526
+ record 0 on the cached-replay path, grown by the generation
527
+ loop on the miss path.
528
+ page: the live page facade of the test.
529
+ window: the settle window of this step's execution.
530
+ """
531
+ try:
532
+ self._recovery.recover(
533
+ group.prompt,
534
+ group.traces,
535
+ prepared,
536
+ step_type,
537
+ self._scenario,
538
+ identity,
539
+ attempt_history,
540
+ page,
541
+ window,
542
+ self._memory,
543
+ )
544
+ except IncurableStepError as failure: # the only kind the steering intercept serves
545
+ self._steer_or_raise(
546
+ failure, identity, prepared, step_type, self._scenario, group.prompt, page, attempt_history
547
+ )
548
+
549
+ def _steer_or_raise( # noqa: PLR0913, PLR0917 — the threading is fixed by the root cell contract
550
+ self,
551
+ failure: IncurableStepError,
552
+ identity: StepIdentity,
553
+ prepared: PreparedStep,
554
+ step_type: str,
555
+ previous_steps: list[ScenarioStep],
556
+ group_prompt: str | None,
557
+ page: PageFacade,
558
+ attempt_history: list[StepAttempt],
559
+ ) -> CachedStep:
560
+ """Offer a terminal step failure to the steering dialog before it propagates.
561
+
562
+ Args:
563
+ failure: the terminal failure about to propagate.
564
+ identity: the address of the stuck step — the healed step is
565
+ written back under it.
566
+ prepared: the render product of the stuck step — the banner and
567
+ every guided request carry the prepared instruction; the
568
+ memory threads into the validated green turn's publication.
569
+ step_type: action or assertion — carried into every guided
570
+ regeneration request.
571
+ previous_steps: the typed scenario records of the previous steps
572
+ of the test, in execution order.
573
+ group_prompt: the group prompt of the stuck step's group;
574
+ ``None`` — an ordinary step.
575
+ page: the live page facade of the test.
576
+ attempt_history: the per-step attempt history already grown by
577
+ the engine loop — the dialog joins it and appends the
578
+ records of its own turns.
579
+
580
+ Returns:
581
+ The healed cached step — the step continues as a success.
582
+
583
+ Raises:
584
+ IncurableStepError: the dialog was declined or never opened —
585
+ the original failure object, unchanged. Only a non-strict
586
+ interactive run opens the dialog; the intercept wraps
587
+ exactly ``IncurableStepError``.
588
+ """
589
+ if not (self._config.interactive and not self._config.strict):
590
+ raise failure
591
+
592
+ healed = self._steering.steer(
593
+ failure,
594
+ identity,
595
+ prepared,
596
+ step_type,
597
+ previous_steps,
598
+ group_prompt,
599
+ page,
600
+ attempt_history,
601
+ self._memory,
602
+ )
603
+ if healed is None: # quit, EOF, SIGINT, an unreadable stdin or a dead provider
604
+ raise failure
605
+
606
+ return healed
607
+
608
+ def _strict_failure(
609
+ self,
610
+ instruction: str,
611
+ step_type: str,
612
+ step: CachedStep,
613
+ error_text: str,
614
+ page: PageFacade,
615
+ ) -> NoReturn:
616
+ """Turn the failure of a cached step into a terminal error by classification only.
617
+
618
+ Args:
619
+ instruction: the prepared instruction of the failed step — the
620
+ classification request and the raised failure carry it,
621
+ never the raw template sentence.
622
+ step_type: the kind of the step sentence ({action, assertion}).
623
+ step: the cached step whose code failed.
624
+ error_text: the full formatted error text of the failure.
625
+ page: the live page facade of the current test.
626
+
627
+ Raises:
628
+ Always: ProductDefectError or IncurableStepError — the strict
629
+ terminal verdict. Strict mode never regenerates and never
630
+ heals: the classification is the only LLM call; an
631
+ unavailable LLM is skipped quietly and the step type alone
632
+ picks the kind.
633
+ """
634
+ try:
635
+ classification = classify_step_failure(
636
+ self._config, self._provider, instruction, step.code, error_text, page
637
+ )
638
+ except LLMUnavailableError:
639
+ logger.warning("verdict skipped: llm unavailable")
640
+
641
+ # from None: the skip is logged; the step failure itself travels in the error field
642
+ if step_type == "assertion":
643
+ raise ProductDefectError(instruction, _STRICT_NO_VERDICT_REASON, error_text, None) from None
644
+ raise IncurableStepError(instruction, _STRICT_NO_VERDICT_REASON, error_text, code=step.code) from None
645
+
646
+ verdict = FailureVerdict(
647
+ category=classification.category,
648
+ explanation=classification.explanation,
649
+ recommendation=classification.recommendation,
650
+ )
651
+
652
+ if classification.category == "product_defect":
653
+ raise ProductDefectError(instruction, classification.explanation, error_text, verdict)
654
+ raise IncurableStepError(instruction, classification.explanation, error_text, code=step.code, verdict=verdict)