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
prettyplay/groups.py ADDED
@@ -0,0 +1,276 @@
1
+ """The authoring group block of the facade: one coherent mini-scenario with a shared goal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from collections.abc import Callable
8
+ from types import TracebackType
9
+ from typing import TYPE_CHECKING
10
+
11
+ from .failures import PrettyplayError
12
+
13
+ if TYPE_CHECKING:
14
+ from .driver import PageFacade
15
+ from .engine.groups import GroupStepOutcome
16
+ from .executor import StepExecutor
17
+ from .reporting import StepReporter
18
+
19
+
20
+ def _validate_tries(tries: int | None) -> None:
21
+ """Validate the declared retry count of one step; ``None`` — the global polling settings.
22
+
23
+ Args:
24
+ tries: the declared total number of executions of the step's code,
25
+ the first execution included.
26
+
27
+ Raises:
28
+ PrettyplayError: the value is a bool, not an integer or below one —
29
+ the loud failure names the parameter, the received value and the
30
+ allowed form; a bad value never reaches the executor.
31
+ """
32
+ if tries is None:
33
+ return
34
+
35
+ if isinstance(tries, bool) or not isinstance(tries, int) or tries < 1:
36
+ raise PrettyplayError(
37
+ f"invalid tries {tries!r} — a positive integer, keyword-only "
38
+ "(the total number of executions of the step's code, the first execution included)"
39
+ )
40
+
41
+
42
+ def _validate_delay(delay: float | None) -> None:
43
+ """Validate the declared quiet pause of one step or group; ``None`` — no pause.
44
+
45
+ Args:
46
+ delay: the declared pause in seconds — non-negative, fractional
47
+ allowed, bools never coerced.
48
+
49
+ Raises:
50
+ PrettyplayError: the value is a bool, not a number, not finite or
51
+ negative — the loud failure names the parameter, the received
52
+ value and the allowed form.
53
+ """
54
+ if delay is None:
55
+ return
56
+
57
+ if isinstance(delay, bool) or not isinstance(delay, (int, float)) or not math.isfinite(delay) or delay < 0:
58
+ raise PrettyplayError(f"invalid delay {delay!r} — a non-negative number of seconds, keyword-only")
59
+
60
+
61
+ def _validate_vars(vars: dict[str, str] | None) -> None:
62
+ """Validate the call-local string inputs of one step; ``None`` — no inputs.
63
+
64
+ Args:
65
+ vars: the declared input mapping of the step — every value must be
66
+ a string, bools never coerced; the names reach the template's
67
+ ``vars`` namespace at render time.
68
+
69
+ Raises:
70
+ PrettyplayError: the value is not a mapping or some value is not a
71
+ string — the loud failure names the parameter, the received
72
+ value and the allowed form.
73
+ """
74
+ if vars is None:
75
+ return
76
+
77
+ if not isinstance(vars, dict):
78
+ raise PrettyplayError(f"invalid vars {vars!r} — a mapping of input names to string values, keyword-only")
79
+
80
+ for value in vars.values():
81
+ if not isinstance(value, str): # a bool is never a string — no coercion
82
+ raise PrettyplayError(f"invalid vars value {value!r} — every value must be a string, keyword-only")
83
+
84
+
85
+ class StepGroup:
86
+ """The authoring group object yielded by :meth:`PrettyPlay.group`: one coherent mini-scenario with a shared goal.
87
+
88
+ Attributes:
89
+ _speed: the group pace, 0-100; ``None`` — no between-step pauses;
90
+ the pace pause is a plain library wait, never ``slow_mo``.
91
+ _delay: the quiet pause before the group's first step, seconds;
92
+ ``None`` — no entry pause.
93
+ _executor: the step cycle executor of the owning test — every step
94
+ delegates to it with this group as the context.
95
+ _reporter: the visibility point of the owning test — the group
96
+ lifecycle events dispatch through it.
97
+ _traces: the verbatim trace records of the group's steps, appended
98
+ by the executor once per executed group step.
99
+ _delegated: the internal first-step marker — set immediately before
100
+ a step delegates to the executor, never read from the traces: a
101
+ first step that dies without a trace record must not re-arm the
102
+ entry pause for a later step of the same group.
103
+ _open_page: the owning test's lazy page opener, bound by
104
+ :meth:`PrettyPlay.group`; the declared constructor stays
105
+ five-parameter, the group is only ever constructed there.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ prompt: str,
111
+ speed: int | None,
112
+ delay: float | None,
113
+ executor: StepExecutor,
114
+ reporter: StepReporter,
115
+ ) -> None:
116
+ """Keep the group framing, the pace settings and the owning executor and reporter.
117
+
118
+ Args:
119
+ prompt: the group prompt, verbatim — reaches generation and
120
+ diagnosis requests and the lifecycle events.
121
+ speed: the group pace, 0-100; ``None`` — no between-step pauses.
122
+ delay: the quiet pause before the group's first step, seconds;
123
+ ``None`` — no pause.
124
+ executor: the step cycle executor of the owning test.
125
+ reporter: the visibility point of the owning test — the group
126
+ lifecycle events dispatch through it.
127
+ """
128
+ self._prompt = prompt
129
+ self._speed = speed
130
+ self._delay = delay
131
+ self._executor = executor
132
+ self._reporter = reporter
133
+ self._traces: list[GroupStepOutcome] = []
134
+ self._delegated = False
135
+ self._open_page: Callable[[], PageFacade] | None = None
136
+
137
+ @property
138
+ def prompt(self) -> str:
139
+ """The group prompt, verbatim."""
140
+ return self._prompt
141
+
142
+ @property
143
+ def traces(self) -> list[GroupStepOutcome]:
144
+ """The verbatim trace records of the group's steps, in execution order — never rewritten."""
145
+ return self._traces
146
+
147
+ def __enter__(self) -> StepGroup:
148
+ """Enter the group block: the on_group_started event with the group prompt verbatim.
149
+
150
+ Returns:
151
+ This group object. The entry pause is applied lazily immediately
152
+ before the group's first executed step — a zero-step group is a
153
+ quiet no-op: the lifecycle events and nothing else.
154
+ """
155
+ self._reporter.emit("on_group_started", {"group_prompt": self._prompt})
156
+
157
+ return self
158
+
159
+ def __exit__(
160
+ self,
161
+ exc_type: type[BaseException] | None,
162
+ exc_value: BaseException | None,
163
+ traceback: TracebackType | None,
164
+ ) -> None:
165
+ """Leave the group block: the outcome event, then the closing event; never suppresses.
166
+
167
+ Args:
168
+ exc_type: the type of the block exception, if any.
169
+ exc_value: the block exception, if any.
170
+ traceback: the traceback of the block exception, if any.
171
+
172
+ Returns:
173
+ Nothing — a falsy return keeps the exception propagating.
174
+ ``on_group_failed`` fires when the block exits through an
175
+ exception, ``on_group_passed`` when it completes without one —
176
+ a healed step counts as success; ``on_group_finished`` closes
177
+ exactly once.
178
+ """
179
+ outcome_event = "on_group_failed" if exc_type is not None else "on_group_passed"
180
+
181
+ self._reporter.emit(outcome_event, {"group_prompt": self._prompt})
182
+ self._reporter.emit("on_group_finished", {"group_prompt": self._prompt})
183
+
184
+ def step(
185
+ self,
186
+ text: str,
187
+ *,
188
+ tries: int | None = None,
189
+ delay: float | None = None,
190
+ vars: dict[str, str] | None = None,
191
+ ) -> None:
192
+ """Execute the action step ``text`` inside the group.
193
+
194
+ Args:
195
+ text: the sentence of the action as written by the engineer.
196
+ tries: keyword-only — the total number of executions of the
197
+ step's code, the first execution included; ``None`` — the
198
+ time-bounded settle mode of the polling settings.
199
+ delay: keyword-only — the quiet pre-step pause in seconds;
200
+ ``None`` — no pause.
201
+ vars: keyword-only — the call-local string inputs of the step;
202
+ every value must be a string. ``None`` — no inputs; a
203
+ separate namespace from memory — ``{{ vars.name }}`` reads
204
+ these values at render time.
205
+
206
+ Raises:
207
+ PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid —
208
+ the loud failure names the parameter, the received value
209
+ and the allowed form; the executor is never reached.
210
+ """
211
+ self._delegate(text, "action", tries, delay, vars)
212
+
213
+ def expect(
214
+ self,
215
+ text: str,
216
+ *,
217
+ tries: int | None = None,
218
+ delay: float | None = None,
219
+ vars: dict[str, str] | None = None,
220
+ ) -> None:
221
+ """Execute the assertion step ``text`` inside the group — the same delegation, assertion kind.
222
+
223
+ Args:
224
+ text: the sentence of the assertion as written by the engineer.
225
+ tries: keyword-only — the total number of executions of the
226
+ step's code, the first execution included; ``None`` — the
227
+ time-bounded settle mode of the polling settings.
228
+ delay: keyword-only — the quiet pre-step pause in seconds;
229
+ ``None`` — no pause.
230
+ vars: keyword-only — the call-local string inputs of the step;
231
+ every value must be a string. ``None`` — no inputs; a
232
+ separate namespace from memory — ``{{ vars.name }}`` reads
233
+ these values at render time.
234
+
235
+ Raises:
236
+ PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid —
237
+ the loud failure names the parameter, the received value
238
+ and the allowed form; the executor is never reached.
239
+ """
240
+ self._delegate(text, "assertion", tries, delay, vars)
241
+
242
+ def _delegate(
243
+ self,
244
+ text: str,
245
+ step_type: str,
246
+ tries: int | None,
247
+ delay: float | None,
248
+ vars: dict[str, str] | None,
249
+ ) -> None:
250
+ """Validate, pause and delegate one group step to the executor.
251
+
252
+ Args:
253
+ text: the raw sentence of the step, verbatim.
254
+ step_type: action or assertion.
255
+ tries: the declared retry count of this execution; ``None`` —
256
+ the global polling settings govern the step.
257
+ delay: the declared start pause of this execution; ``None`` —
258
+ no pause.
259
+ vars: the validated call-local string inputs of this
260
+ execution; ``None`` — no inputs.
261
+
262
+ Raises:
263
+ PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid.
264
+ """
265
+ _validate_tries(tries)
266
+ _validate_delay(delay)
267
+ _validate_vars(vars)
268
+
269
+ if not self._delegated:
270
+ if self._delay is not None:
271
+ time.sleep(self._delay) # the lazy entry pause — quiet, library-level
272
+ elif self._speed is not None:
273
+ time.sleep(int((100 - self._speed) * 30) / 1000) # the between-step pace — never slow_mo
274
+
275
+ self._delegated = True
276
+ self._executor.execute(text, step_type, self._open_page(), group=self, tries=tries, delay=delay, vars=vars)
@@ -0,0 +1,31 @@
1
+ # Failure classification
2
+
3
+ Domain: classifying a failed cached step before healing. Audience: engineers reasoning about healing decisions.
4
+
5
+ ## Categories
6
+
7
+ | Category | Meaning | Consequence |
8
+ |---|---|---|
9
+ | rot | the UI changed: selectors, texts, structure | the step is regenerated from the current page and retried |
10
+ | product_defect | the expectation legitimately failed | the test fails loudly — never healed green |
11
+ | fixable | the step code is at fault (ambiguous or wrong locator/strategy); the intent is satisfiable | the step is regenerated for the same intent, the request carrying the classification recommendation |
12
+ | incurable | regeneration cannot help: budget exhausted, text no longer matches reality, ambiguity | the incurable failure carries step, reason, recommendation |
13
+
14
+ An unrecognized label of the model answer parses to `incurable` — the protective fallback: an unknown verdict never grants a regeneration.
15
+
16
+ ## Call
17
+
18
+ ```python
19
+ classification = provider.classify_step_failure(
20
+ prompt=system_prompt, # the system prompt text comes from the calling engine
21
+ user_instructions="", # the classification instructions from the classification_prompt setting; empty — no block
22
+ step_text="click the «Sign in» button",
23
+ code=step_code,
24
+ error="element not found: button «Sign in»",
25
+ snapshot=snapshot_text,
26
+ screenshot=None,
27
+ )
28
+ print(classification.category, classification.explanation, classification.recommendation)
29
+ ```
30
+
31
+ A non-empty `user_instructions` renders as a separate USER INSTRUCTIONS block in the request — the final block of the user content, after all classification inputs. It comes from the classification_prompt setting of the project; generation requests never see it, and classification requests never see the generation instructions.
@@ -0,0 +1,127 @@
1
+ # Providers
2
+
3
+ Domain: LLM provider selection and parity. Audience: integrators choosing a provider and setting models.
4
+
5
+ ## Select a provider
6
+
7
+ ```python
8
+ from prettyplay.config import Config
9
+ from prettyplay.llm import create_provider
10
+
11
+ config = Config(provider="anthropic", model="claude-sonnet-4-5")
12
+ provider = create_provider(config)
13
+ ```
14
+
15
+ The provider is a project setting: openai or anthropic; env override PRETTYPLAY_PROVIDER. API keys come only from environment variables: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic.
16
+
17
+ ## Models
18
+
19
+ | Setting | Purpose | Fallback |
20
+ |---|---|---|
21
+ | model | the default model for all four operations | — |
22
+ | generation_model | code generation only | model |
23
+ | classification_model | failure classification, group diagnosis and the compliance gate | model |
24
+
25
+ The instruction compliance gate runs on the effective classification model (classification_model
26
+ or model). Set classification_model to a different model when the verdict should come from
27
+ a model other than the one that wrote the candidate; the defaults can select the same model.
28
+
29
+ base_url overrides the provider endpoint when set.
30
+
31
+ ## Parity
32
+
33
+ Both providers expose the same four operations — generate_step_code, classify_step_failure, classify_group_failure and check_instruction_compliance — with identical inputs, identical output shapes and the identical failure taxonomy: a provider transport failure resends the identical request inside one logical attempt (bounded transport retries) and raises LLMUnavailableError only on exhaustion, a permanent rejection or an over-cap Retry-After; cached step code never depends on the provider. One logical attempt per engine attempt; attempt budgets belong to the calling engine and are never consumed by transport retries.
34
+
35
+ The classification operation is named `classify_step_failure` — the former `classify_failure`, a nominal rename.
36
+
37
+ User instructions parity: each operation carries its own instructions — generation requests render the generation_prompt setting, classification requests render the classification_prompt setting — as a verbatim USER INSTRUCTIONS block with identical placement semantics in both providers. A parity requirement, not a capability difference.
38
+
39
+ Scenario-context parity: the previous steps of a generation request arrive as typed records — each carrying the raw sentence, prepared instruction and permanent group membership — and render their instruction fields as the PREVIOUS STEPS block with the group entries marked; the group prompt of the current step renders as the GROUP PROMPT block before PREVIOUS STEPS. A parity requirement, not a capability difference.
40
+
41
+ Step-type parity: every generation request and every compliance verdict request renders the STEP TYPE line — action or assertion — immediately before the STEP line, identically in both providers. A parity requirement, not a capability difference.
42
+
43
+ Attempt-history parity: a generation request may carry the attempt history of the step — attempt_history, a list of complete multi-line records composed by the calling engine (the attempt outcome, the URL before -> after line, the complete candidate code, the complete error; the original cached code anchors the list as record 0 when it exists). Non-empty — rendered as the HISTORY block after the USER INSTRUCTIONS block, every record verbatim, never collapsed, never size-limited; the block replaces the former CODE/ERROR request pair and the former steering-only history. The request tail order is fixed: HISTORY, RECOMMENDATION (the classification diagnosis), USER GUIDANCE (the engineer message of the interactive steering). Both providers render every non-empty block identically at the same position. A parity requirement, not a capability difference.
44
+
45
+ Page-URL parity: a generation request with a non-empty page URL renders it as its own PAGE URL line immediately after the PAGE SNAPSHOT block — identically in both providers. The URL reaches every generation and regeneration request of the engine and the guided requests of the steering. A parity requirement, not a capability difference.
46
+
47
+ Cheat-sheet parity: every generation request renders the CHEAT SHEET block after the scenario inputs and
48
+ immediately before the USER INSTRUCTIONS block — the compact standard Playwright sync API reference supplied by the
49
+ calling engine; guidance, not an allowlist. Both providers render it identically at the same position.
50
+
51
+ Prepared-instruction parity: every generation request renders the prepared instruction — the plain-text sentence with actual values produced by rendering — as the STEP block; the raw template sentence never appears. Scenario records render their instruction field; the model never sees raw Jinja. A parity requirement, not a capability difference.
52
+
53
+ Input-bindings and result-declarations parity: a non-empty inputs mapping renders as the INPUTS block immediately after the STEP line (one name = value line per binding); a non-empty declarations list renders as the RESULTS block immediately after INPUTS stating the result contract — the code returns a dictionary of exactly the declared names to non-blank observed page strings; empty inputs — no INPUTS block, empty declarations — no RESULTS block and the success-without-result form. Both providers render the blocks identically at the same positions. A parity requirement, not a capability difference.
54
+
55
+ Compliance parity: the gate request uses the same prepared instruction, INPUTS and RESULTS representation in both providers. The reviewer sees the exact result contract next to the STEP line before ATTEMPT HISTORY and CODE; it never sees the raw template sentence.
56
+
57
+ Classification parity: ordinary classification renders its `step_text` input as the prepared instruction in the STEP block, identically in both providers. Group diagnosis renders prior scenario and group trace instruction fields, plus the prepared failed STEP. Raw template sentences stay local to cache addressing and re-rendering.
58
+
59
+ ## Transport retries
60
+
61
+ Transient provider outages no longer kill a logical attempt: every SDK call of all four
62
+ operations passes through the shared bounded retry mechanism inside the port.
63
+
64
+ - Retryable: connection failures, timeouts, HTTP 408/429 and any 5xx — the identical request is
65
+ resent after a computed pause (base 1, 2, 4, 8, 10, 10… s + 0–25% jitter, cap 10 s; a valid
66
+ Retry-After ≤ 10 s lifts the pause to the asked wait)
67
+ - Permanent — authentication, authorization, invalid request, an explicitly exhausted quota —
68
+ fails immediately; an explicit permanent cause wins over a retryable status
69
+ - Retry-After above 10 s terminates with an actionable LLMUnavailableError — never an early
70
+ resend, never a cap-breaking wait
71
+ - The send budget per logical attempt: llm_request_attempts (default 3, initial send included,
72
+ 1 disables retries); SDK built-in retries are off (max_retries=0) — the budget is also the
73
+ maximum number of physical sends
74
+ - Each retry logs one WARNING (provider, operation, attempt, category, delay) to the logger
75
+ prettyplay — no secrets, no payloads; KeyboardInterrupt during a wait stops the retrying
76
+ - Generation, healing and group budgets are untouched: one logical LLM attempt per budget
77
+ attempt, with or without transport retries
78
+
79
+ ## The compliance operation
80
+
81
+ check_instruction_compliance is the verdict request of the two-dimension gate — instruction
82
+ compliance and step adequacy judged in one request: the engine calls it once per successfully
83
+ executed candidate before caching — never for replayed cached code, never when generation_approve
84
+ is off or generation_prompt is empty (zero calls).
85
+
86
+ The request carries the gate system prompt and INSTRUCTIONS, STEP (with its STEP TYPE line),
87
+ optional INPUTS and RESULTS, ATTEMPT HISTORY and CODE blocks; the answer is a JSON list of findings:
88
+
89
+ [{"instruction": "...", "priority": "high", "explanation": "...", "dimension": "instruction"}]
90
+
91
+ - the dimension is instruction or adequacy: instruction findings quote the violated project
92
+ instruction; adequacy findings quote the fragment of the step sentence the code fails to
93
+ accomplish, or — for behavior exceeding the step — the code line performing it — judged
94
+ from the step type and the attempt history
95
+ - the priority is high, medium or low; only high blocks the candidate, in either dimension —
96
+ the calling engine owns that decision
97
+ - the model behind the call is the effective classification model (classification_model or model)
98
+ - a JSON syntax glitch of the answer is salvaged once (the json-repair library) before the
99
+ strict validation — a model dropping a quote, a comma or a bracket does not fail the run; an
100
+ answer that still is not the required shape — including an old-shaped finding without a
101
+ dimension, or an emptiness reached only through the salvage — raises
102
+ ComplianceVerdictError and a provider failure raises LLMUnavailableError — both hard: the
103
+ candidate is not cached unchecked
104
+
105
+ ## The group diagnosis operation
106
+
107
+ The fourth port operation `classify_group_failure` — one logical request per diagnosis, with
108
+ bounded resends of the identical SDK request inside it, through the
109
+ effective classification model, both providers in full parity:
110
+
111
+ - inputs: the group prompt (verbatim), prior scenario instructions with group membership, the
112
+ group step traces (prepared instruction, outcome, URL transition — one per step), the failed
113
+ step's prepared instruction, its rendered attempt history, the page snapshot and
114
+ the optional screenshot; the classification instructions of the project reach it exactly as a
115
+ classification request
116
+ - the answer parses strictly through `parse_group_failure_classification`: JSON with the four
117
+ fields, one json-repair salvage of a syntax glitch, the closed label set
118
+ recoverable | product_defect | incurable
119
+ - a garbage or incomplete answer degrades conservatively to incurable with the raw answer logged —
120
+ never a granted regeneration
121
+ - provider unavailability raises LLMUnavailableError; one logical request per diagnosis, with
122
+ bounded resends of the identical SDK request inside it, budgets belong to
123
+ the calling engine
124
+
125
+ ## Answer shape
126
+
127
+ generate_step_code returns step code of the fixed form. Models often answer with a fenced python block (```python … ```); the provider unwraps the first fenced block before returning, so the engine receives clean code either way — an answer with no closed fence is returned verbatim and, if unparsable, keeps failing downstream in execution.
@@ -0,0 +1,81 @@
1
+ # Transport helpers
2
+
3
+ Domain: using the LLM cell's transport facade. Audience: provider adapter authors and
4
+ engineers diagnosing SDK failures. Built-in providers already apply this policy; configure
5
+ them with `Config(llm_request_attempts=3)` rather than wrapping a provider operation again.
6
+
7
+ ## Inspect a failure without sending a request
8
+
9
+ ```python
10
+ from prettyplay.llm import (
11
+ TransportFailureClassification,
12
+ classify_anthropic_failure,
13
+ classify_openai_failure,
14
+ compute_transport_pause,
15
+ )
16
+
17
+ failure: TransportFailureClassification = classify_openai_failure(ValueError("unknown failure"))
18
+ assert failure.category == "invalid_request"
19
+ assert failure.retryable is False
20
+ assert classify_anthropic_failure(ValueError("unknown failure")).retryable is False
21
+
22
+ # When planning a retry after the first transient failure:
23
+ pause = compute_transport_pause(failed_attempt=1, retry_after=5.0)
24
+ assert pause == 5.0
25
+ ```
26
+
27
+ Pass the original SDK exception to the corresponding classifier. The returned verdict exposes
28
+ `category`, `retry_after` and the computed `retryable` property. Quota evidence can reside in
29
+ the SDK exception attributes or its dictionary response body, including the nested error
30
+ object; older SDKs need not provide parsed attributes. Unknown failures are permanent.
31
+
32
+ `compute_transport_pause` draws random jitter but does not sleep. Pass a positive, one-based
33
+ failed attempt number. A Retry-After above 10 seconds is a terminal condition for the retry
34
+ loop; do not call the pause helper to turn that value into a shorter wait.
35
+
36
+ ## Wrap one SDK send
37
+
38
+ ```python
39
+ import os
40
+
41
+ from openai import OpenAI
42
+
43
+ from prettyplay.config import Config
44
+ from prettyplay.failures import LLMUnavailableError
45
+ from prettyplay.llm import classify_openai_failure, send_with_retries
46
+
47
+ config = Config(model="gpt-5", llm_request_attempts=3)
48
+ client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], max_retries=0)
49
+ request = {
50
+ "model": config.effective_generation_model,
51
+ "messages": [{"role": "user", "content": "Return a short greeting."}],
52
+ }
53
+
54
+ try:
55
+ response = send_with_retries(
56
+ provider="openai",
57
+ operation="generation",
58
+ attempts=config.llm_request_attempts,
59
+ classify=classify_openai_failure,
60
+ send=lambda: client.chat.completions.create(**request),
61
+ )
62
+ except LLMUnavailableError as error:
63
+ # The original SDK failure is available through error.__cause__.
64
+ raise
65
+ else:
66
+ # Validate successful response content after the transport loop.
67
+ text = response.choices[0].message.content
68
+ finally:
69
+ client.close()
70
+ ```
71
+
72
+ The closure performs exactly one send and reuses the same request. Supply a positive total
73
+ send budget; `1` disables retries. For Anthropic, use an `Anthropic(max_retries=0, ...)`
74
+ client, `classify_anthropic_failure`, and a closure calling `client.messages.create`.
75
+ The adapter supplies the provider's usual request parameters, including Anthropic's
76
+ required `max_tokens`.
77
+
78
+ The helper sleeps synchronously between transient failures and logs one WARNING per retry.
79
+ Permanent rejection, excessive Retry-After and exhaustion raise `LLMUnavailableError` with
80
+ the original cause. `KeyboardInterrupt` propagates. Keep engine budget accounting and
81
+ successful-response validation outside the closure.
@@ -0,0 +1,38 @@
1
+ """Facade of the prettyplay.llm cell: the LLM port and its implementations."""
2
+
3
+ from ._request import (
4
+ TransportFailureClassification,
5
+ classify_anthropic_failure,
6
+ classify_openai_failure,
7
+ compute_transport_pause,
8
+ send_with_retries,
9
+ )
10
+ from .anthropic_provider import AnthropicProvider
11
+ from .models import (
12
+ ComplianceFinding,
13
+ FailureClassification,
14
+ GroupFailureClassification,
15
+ ScenarioStep,
16
+ parse_compliance_verdict,
17
+ parse_group_failure_classification,
18
+ )
19
+ from .openai_provider import OpenAIProvider
20
+ from .provider import LLMProvider, create_provider
21
+
22
+ __all__ = [
23
+ "AnthropicProvider",
24
+ "ComplianceFinding",
25
+ "FailureClassification",
26
+ "GroupFailureClassification",
27
+ "LLMProvider",
28
+ "OpenAIProvider",
29
+ "ScenarioStep",
30
+ "TransportFailureClassification",
31
+ "classify_anthropic_failure",
32
+ "classify_openai_failure",
33
+ "compute_transport_pause",
34
+ "create_provider",
35
+ "parse_compliance_verdict",
36
+ "parse_group_failure_classification",
37
+ "send_with_retries",
38
+ ]