sequential-hooks 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.
- plugins/agy/_sequential_hooks/__init__.py +1 -0
- plugins/agy/_sequential_hooks/_adapter.py +566 -0
- plugins/agy/_sequential_hooks/_doctor.py +413 -0
- plugins/claude/_sequential_hooks/__init__.py +1 -0
- plugins/claude/_sequential_hooks/_adapter.py +560 -0
- plugins/claude/_sequential_hooks/_doctor.py +455 -0
- plugins/codex/_sequential_hooks/__init__.py +1 -0
- plugins/codex/_sequential_hooks/_adapter.py +466 -0
- plugins/codex/_sequential_hooks/_doctor.py +563 -0
- sequential_hooks/__init__.py +3 -0
- sequential_hooks/__main__.py +5 -0
- sequential_hooks/_arguments.py +222 -0
- sequential_hooks/_cleanup.py +88 -0
- sequential_hooks/_cli.py +440 -0
- sequential_hooks/_containment/__init__.py +410 -0
- sequential_hooks/_containment/_posix.py +236 -0
- sequential_hooks/_containment/_uncontained.py +239 -0
- sequential_hooks/_containment/_windows.py +853 -0
- sequential_hooks/_containment/_windows_api.py +570 -0
- sequential_hooks/_containment/_windows_launcher.py +189 -0
- sequential_hooks/_diagnostics.py +265 -0
- sequential_hooks/_doctor/__init__.py +342 -0
- sequential_hooks/_doctor/_command.py +469 -0
- sequential_hooks/_doctor/_common.py +448 -0
- sequential_hooks/_doctor/_types.py +94 -0
- sequential_hooks/_downstream.py +181 -0
- sequential_hooks/_executable.py +187 -0
- sequential_hooks/_executor.py +825 -0
- sequential_hooks/_registry.py +103 -0
- sequential_hooks/_runner.py +48 -0
- sequential_hooks/_types.py +125 -0
- sequential_hooks/hosts/__init__.py +130 -0
- sequential_hooks/hosts/_contract.py +434 -0
- sequential_hooks/hosts/_inspection.py +78 -0
- sequential_hooks/hosts/_json.py +150 -0
- sequential_hooks/hosts/_records.py +273 -0
- sequential_hooks/hosts/_skeleton.py +1326 -0
- sequential_hooks/py.typed +0 -0
- sequential_hooks-0.1.0.dist-info/METADATA +90 -0
- sequential_hooks-0.1.0.dist-info/RECORD +43 -0
- sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
- sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
- sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
"""Compose ordered child results into one native response for any host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, NoReturn
|
|
9
|
+
|
|
10
|
+
from sequential_hooks.hosts._contract import (
|
|
11
|
+
FAILURE_KINDS,
|
|
12
|
+
ChainControl,
|
|
13
|
+
Diagnostics,
|
|
14
|
+
FinalResponse,
|
|
15
|
+
HostInputError,
|
|
16
|
+
JsonObject,
|
|
17
|
+
JsonValue,
|
|
18
|
+
Step,
|
|
19
|
+
StepResult,
|
|
20
|
+
)
|
|
21
|
+
from sequential_hooks.hosts._json import JsonProtocolError, dump_object, load_object
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Collection, Mapping
|
|
25
|
+
|
|
26
|
+
DIAGNOSTIC_LIMIT_CHARACTERS = 1_000
|
|
27
|
+
_DIAGNOSTIC_TRUNCATION_MARKER = '...[truncated]'
|
|
28
|
+
FINAL_RESPONSE_LIMIT_BYTES = 1024 * 1024
|
|
29
|
+
_GENERIC_TERMINAL_ATTRIBUTION = (
|
|
30
|
+
'native feedback attribution omitted because its step label is too large'
|
|
31
|
+
)
|
|
32
|
+
_MINIMUM_DIAGNOSTIC_MESSAGE_CHARACTERS = 200
|
|
33
|
+
TERMINATION_FAILURES = frozenset({'output_limit', 'output_worker_timeout', 'timeout'})
|
|
34
|
+
TRUNCATION_MARKER = '\n[truncated by sequential-hooks]'
|
|
35
|
+
_UNCONTAINED_DEDUPLICATION_KEY = 'uncontained-termination'
|
|
36
|
+
UNCONTAINED_WARNING = 'step ran without containment; descendant cleanup is not guaranteed'
|
|
37
|
+
_INFRASTRUCTURE_FAILURES = FAILURE_KINDS
|
|
38
|
+
type OutcomeKind = Literal[
|
|
39
|
+
'developer',
|
|
40
|
+
'failure',
|
|
41
|
+
'native_exit',
|
|
42
|
+
'neutral',
|
|
43
|
+
'ordinary_exit',
|
|
44
|
+
'plain',
|
|
45
|
+
'structured',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OutputFailureKind(StrEnum):
|
|
50
|
+
"""Classify rejected child or final adapter output.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
MALFORMED: Output with invalid native syntax, types, or required shape.
|
|
54
|
+
UNSUPPORTED: Well-formed output outside the supported host contract.
|
|
55
|
+
OVERSIZED: Output that cannot fit its documented size budget.
|
|
56
|
+
UNDELIVERABLE_MUTATION: Replacement candidate that cannot be delivered
|
|
57
|
+
exactly. On pre-tool events, `--allow-on-failure` cannot override
|
|
58
|
+
this category.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
MALFORMED = 'malformed'
|
|
62
|
+
UNSUPPORTED = 'unsupported'
|
|
63
|
+
OVERSIZED = 'oversized'
|
|
64
|
+
UNDELIVERABLE_MUTATION = 'undeliverable_mutation'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AdapterOutputError(ValueError):
|
|
68
|
+
"""Report a classified adapter output rejection.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
kind: Stable adapter output failure category.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
kind: OutputFailureKind
|
|
75
|
+
|
|
76
|
+
def __init__(self, kind: OutputFailureKind, message: str) -> None:
|
|
77
|
+
"""Retain the failure category and rejection detail.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
kind: Stable malformed, unsupported, oversized, or undeliverable-
|
|
81
|
+
mutation category.
|
|
82
|
+
message: Rejection detail.
|
|
83
|
+
"""
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
self.kind = kind
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Contribution:
|
|
90
|
+
"""Carry one validated structured contribution from a child step.
|
|
91
|
+
|
|
92
|
+
Every field is optional; a host's `parse_structured` fills the fields its
|
|
93
|
+
protocol defines and leaves the rest at their defaults.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
additional_context: Model-facing context to append.
|
|
97
|
+
continue_value: Explicit global continue flag, when present.
|
|
98
|
+
decision: Host-native decision value, when present.
|
|
99
|
+
decision_reason: Reason attached to `decision`, when present.
|
|
100
|
+
mutation: Replacement value for the event's mutation field.
|
|
101
|
+
mutation_present: Whether the contribution carries a mutation.
|
|
102
|
+
stop_reason: Reason attached to `continue_value` false.
|
|
103
|
+
suppress_output: Explicit output suppression flag, when present.
|
|
104
|
+
system_message: User-facing message to append.
|
|
105
|
+
terminal_sequence: Terminal control sequence, when present.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
additional_context: str | None = None
|
|
109
|
+
continue_value: bool | None = None
|
|
110
|
+
decision: str | None = None
|
|
111
|
+
decision_reason: str | None = None
|
|
112
|
+
mutation: JsonValue = None
|
|
113
|
+
mutation_present: bool = False
|
|
114
|
+
stop_reason: str | None = None
|
|
115
|
+
suppress_output: bool | None = None
|
|
116
|
+
system_message: str | None = None
|
|
117
|
+
terminal_sequence: str | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Outcome:
|
|
122
|
+
"""Carry one classified child result before it changes adapter state.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
kind: Classification the composer dispatches on.
|
|
126
|
+
developer_text: Text for the developer tier when `kind` is `developer`.
|
|
127
|
+
failure: Failure category when `kind` is `failure`.
|
|
128
|
+
message: Failure detail or native exit feedback.
|
|
129
|
+
plain: Native plain text when `kind` is `plain`.
|
|
130
|
+
returncode: Child status when `kind` is `ordinary_exit`.
|
|
131
|
+
structured: Validated contribution when `kind` is `structured`.
|
|
132
|
+
uncontained: Whether the failed step ran without containment.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
kind: OutcomeKind
|
|
136
|
+
developer_text: str = ''
|
|
137
|
+
failure: str | None = None
|
|
138
|
+
message: str = ''
|
|
139
|
+
plain: str = ''
|
|
140
|
+
returncode: int | None = None
|
|
141
|
+
structured: Contribution | None = None
|
|
142
|
+
uncontained: bool = False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class NativeResponse:
|
|
147
|
+
"""Carry one final native response and its wrapper exit status.
|
|
148
|
+
|
|
149
|
+
Attributes:
|
|
150
|
+
stdout: Exact native response bytes.
|
|
151
|
+
exit_code: Wrapper process exit status.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
stdout: bytes
|
|
155
|
+
exit_code: int = 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class UserContribution:
|
|
160
|
+
"""Carry one ordered user-tier contribution and its shedding ownership.
|
|
161
|
+
|
|
162
|
+
Attributes:
|
|
163
|
+
mandatory: Whether the message survives message shedding.
|
|
164
|
+
message: Labeled text emitted at full size.
|
|
165
|
+
deduplication_key: Key that suppresses a repeated mandatory report.
|
|
166
|
+
minimal_message: Unlabeled text emitted under the minimal-report stage;
|
|
167
|
+
`None` for a non-mandatory message.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
mandatory: bool
|
|
171
|
+
message: str
|
|
172
|
+
deduplication_key: str | None = None
|
|
173
|
+
minimal_message: str | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class CompositionState:
|
|
178
|
+
"""Declare every versioned composition value the rollback snapshot covers.
|
|
179
|
+
|
|
180
|
+
The snapshot shallow-copies every list, set, and dict value; every other
|
|
181
|
+
value is replaced, never mutated, so the reference itself is the version.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
contexts: Labeled model-facing contexts in step order.
|
|
185
|
+
continue_false: Whether a step committed a global stop.
|
|
186
|
+
decision: Winning host-native decision, when any.
|
|
187
|
+
decision_reasons: Labeled reasons at the winning rank.
|
|
188
|
+
decision_report: Mandatory report that owns a failure-caused deny.
|
|
189
|
+
messages: User-tier contributions in acceptance order.
|
|
190
|
+
minimal_reports: Whether mandatory reports emit their minimal text.
|
|
191
|
+
payload: Current native payload after accepted mutations.
|
|
192
|
+
plain: Native plain contributions awaiting finalization.
|
|
193
|
+
plain_dropped_reported: Step indexes whose dropped plain output was
|
|
194
|
+
already diagnosed.
|
|
195
|
+
raw_input: Current native input bytes for the next child.
|
|
196
|
+
response_bytes: Last structured response that fit, or `None` before any
|
|
197
|
+
structured result.
|
|
198
|
+
shed_contexts: Whether contexts are shed from the current response.
|
|
199
|
+
shed_messages: Whether non-mandatory messages are shed.
|
|
200
|
+
stop_reason: Labeled reason attached to the global stop.
|
|
201
|
+
stop_reason_limit: Character budget applied to `stop_reason`.
|
|
202
|
+
structured_seen: Whether any structured or failed result was applied.
|
|
203
|
+
suppress_output: Sticky output suppression flag, when set.
|
|
204
|
+
terminal_sequence: First accepted terminal sequence.
|
|
205
|
+
terminal_sequence_owner: Contribution that supplied it.
|
|
206
|
+
updated_input: Accepted pre-tool mutation.
|
|
207
|
+
updated_input_present: Whether `updated_input` was accepted.
|
|
208
|
+
updated_output: Accepted post-tool mutation.
|
|
209
|
+
updated_output_present: Whether `updated_output` was accepted.
|
|
210
|
+
winning_reason_limit: Character budget applied to `decision_reasons`.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
payload: JsonObject
|
|
214
|
+
raw_input: bytes
|
|
215
|
+
contexts: list[str] = field(default_factory=list[str])
|
|
216
|
+
continue_false: bool = False
|
|
217
|
+
decision: str | None = None
|
|
218
|
+
decision_reasons: list[str] = field(default_factory=list[str])
|
|
219
|
+
decision_report: UserContribution | None = None
|
|
220
|
+
messages: list[UserContribution] = field(default_factory=list[UserContribution])
|
|
221
|
+
minimal_reports: bool = False
|
|
222
|
+
plain: list[tuple[Step, str]] = field(default_factory=list[tuple[Step, str]])
|
|
223
|
+
plain_dropped_reported: set[int] = field(default_factory=set[int])
|
|
224
|
+
response_bytes: bytes | None = None
|
|
225
|
+
shed_contexts: bool = False
|
|
226
|
+
shed_messages: bool = False
|
|
227
|
+
stop_reason: str | None = None
|
|
228
|
+
stop_reason_limit: int | None = None
|
|
229
|
+
structured_seen: bool = False
|
|
230
|
+
suppress_output: bool | None = None
|
|
231
|
+
terminal_sequence: str | None = None
|
|
232
|
+
terminal_sequence_owner: Contribution | None = None
|
|
233
|
+
updated_input: JsonValue = None
|
|
234
|
+
updated_input_present: bool = False
|
|
235
|
+
updated_output: JsonValue = None
|
|
236
|
+
updated_output_present: bool = False
|
|
237
|
+
winning_reason_limit: int | None = None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Composer:
|
|
241
|
+
"""Compose ordered child results for one validated native invocation.
|
|
242
|
+
|
|
243
|
+
Subclass it per host and override the protocol surfaces. The runner sees
|
|
244
|
+
only the `HostAdapter` interface. Subclasses set `pre_tool` and any
|
|
245
|
+
event-dependent `mutation_field` before calling `super().__init__`;
|
|
246
|
+
fixed protocol facts override the class defaults below.
|
|
247
|
+
|
|
248
|
+
Attributes:
|
|
249
|
+
allow_on_failure: Whether approved pre-tool failures may continue.
|
|
250
|
+
blocking_exit: Child exit status that carries terminal native feedback.
|
|
251
|
+
diagnostics: Developer-tier diagnostic sink.
|
|
252
|
+
field_limit: Character cap the host documents for composed text fields,
|
|
253
|
+
or `None` when only the final byte limit applies.
|
|
254
|
+
mutation_field: Payload field an accepted mutation replaces, or `None`
|
|
255
|
+
when the event has no mutation.
|
|
256
|
+
pre_tool: Whether the event decides a proposed tool call.
|
|
257
|
+
state: Versioned composition state covered by the rollback snapshot.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
blocking_exit = 2
|
|
261
|
+
field_limit: int | None = None
|
|
262
|
+
mutation_field: str | None = None
|
|
263
|
+
pre_tool: bool
|
|
264
|
+
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
event: str,
|
|
268
|
+
raw_input: bytes,
|
|
269
|
+
payload: JsonObject,
|
|
270
|
+
diagnostics: Diagnostics,
|
|
271
|
+
*,
|
|
272
|
+
allow_on_failure: bool,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Bind the host facts and take ownership of the native payload.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
event: Trusted native event.
|
|
278
|
+
raw_input: Original strict JSON bytes received from the host.
|
|
279
|
+
payload: Decoded strict JSON object matching `raw_input`.
|
|
280
|
+
diagnostics: Developer-tier diagnostic sink.
|
|
281
|
+
allow_on_failure: Whether approved pre-tool failures may continue.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
HostInputError: If copying the payload exceeds the recursion limit,
|
|
285
|
+
which means the input nests too deeply to compose.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
owned_payload = deepcopy(payload)
|
|
289
|
+
except RecursionError as error:
|
|
290
|
+
raise HostInputError('native input nests too deeply') from error
|
|
291
|
+
self.allow_on_failure = allow_on_failure
|
|
292
|
+
self.diagnostics = diagnostics
|
|
293
|
+
self.state = CompositionState(owned_payload, raw_input)
|
|
294
|
+
self._event = event
|
|
295
|
+
|
|
296
|
+
def _add_mandatory_report(
|
|
297
|
+
self,
|
|
298
|
+
step: Step,
|
|
299
|
+
message: str,
|
|
300
|
+
*,
|
|
301
|
+
deduplication_key: str | None = None,
|
|
302
|
+
minimal_message: str | None = None,
|
|
303
|
+
) -> tuple[UserContribution, bool]:
|
|
304
|
+
"""Record one mandatory report in global acceptance order."""
|
|
305
|
+
if deduplication_key is not None:
|
|
306
|
+
for contribution in self.state.messages:
|
|
307
|
+
if contribution.deduplication_key == deduplication_key:
|
|
308
|
+
return contribution, False
|
|
309
|
+
report = UserContribution(
|
|
310
|
+
mandatory=True,
|
|
311
|
+
message=labeled(step, message),
|
|
312
|
+
deduplication_key=deduplication_key,
|
|
313
|
+
minimal_message=message if minimal_message is None else minimal_message,
|
|
314
|
+
)
|
|
315
|
+
self.state.messages.append(report)
|
|
316
|
+
return report, True
|
|
317
|
+
|
|
318
|
+
def _apply_mutation(self, value: Contribution) -> None:
|
|
319
|
+
"""Replace only the event-owned input field for an accepted mutation."""
|
|
320
|
+
if not value.mutation_present:
|
|
321
|
+
return
|
|
322
|
+
if self.mutation_field is None:
|
|
323
|
+
raise RuntimeError('validated mutation has no mutation field')
|
|
324
|
+
mutation = deepcopy(value.mutation)
|
|
325
|
+
candidate_payload = dict(self.state.payload)
|
|
326
|
+
candidate_payload[self.mutation_field] = mutation
|
|
327
|
+
try:
|
|
328
|
+
candidate_input = dump_object(candidate_payload)
|
|
329
|
+
except (JsonProtocolError, UnicodeEncodeError) as error:
|
|
330
|
+
raise AdapterOutputError(
|
|
331
|
+
OutputFailureKind.MALFORMED,
|
|
332
|
+
'mutated native input is not valid UTF-8 text',
|
|
333
|
+
) from error
|
|
334
|
+
self.state.payload = candidate_payload
|
|
335
|
+
self.state.raw_input = candidate_input
|
|
336
|
+
if self.pre_tool:
|
|
337
|
+
self.state.updated_input = mutation
|
|
338
|
+
self.state.updated_input_present = True
|
|
339
|
+
else:
|
|
340
|
+
self.state.updated_output = mutation
|
|
341
|
+
self.state.updated_output_present = True
|
|
342
|
+
|
|
343
|
+
def _plain_discard_diagnostic(
|
|
344
|
+
self,
|
|
345
|
+
text: str,
|
|
346
|
+
*,
|
|
347
|
+
reason: str,
|
|
348
|
+
) -> str:
|
|
349
|
+
"""Return one bounded diagnostic containing discarded plain output."""
|
|
350
|
+
return bounded_diagnostic(f'plain stdout omitted because {reason}: ', text)
|
|
351
|
+
|
|
352
|
+
def _replace_terminal_attribution(
|
|
353
|
+
self,
|
|
354
|
+
owner: Contribution | None,
|
|
355
|
+
terminal_reason: str | None,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Replace an unrepresentable label while retaining its bounded reason."""
|
|
358
|
+
state = self.state
|
|
359
|
+
if state.decision in ('deny', 'block'):
|
|
360
|
+
reason = terminal_reason
|
|
361
|
+
if reason is None and owner is not None:
|
|
362
|
+
reason = owner.decision_reason
|
|
363
|
+
state.decision_reasons = [reason] if reason is not None else []
|
|
364
|
+
state.decision_report = None
|
|
365
|
+
if state.continue_false:
|
|
366
|
+
state.stop_reason = owner.stop_reason if owner is not None else None
|
|
367
|
+
|
|
368
|
+
def _replace_terminal_attribution_with_generic(self) -> None:
|
|
369
|
+
"""Use minimal terminal feedback when even the unlabeled reason cannot fit."""
|
|
370
|
+
state = self.state
|
|
371
|
+
if state.decision in ('deny', 'block'):
|
|
372
|
+
state.decision_reasons = [_GENERIC_TERMINAL_ATTRIBUTION]
|
|
373
|
+
state.decision_report = None
|
|
374
|
+
if state.continue_false:
|
|
375
|
+
state.stop_reason = None
|
|
376
|
+
|
|
377
|
+
def _restore(self, snapshot: dict[str, object]) -> None:
|
|
378
|
+
"""Restore composition state after a rejected candidate publication."""
|
|
379
|
+
for name, value in snapshot.items():
|
|
380
|
+
setattr(self.state, name, value)
|
|
381
|
+
|
|
382
|
+
def _snapshot(self) -> dict[str, object]:
|
|
383
|
+
"""Shallow-copy every list, set, and dict value of the declared state."""
|
|
384
|
+
snapshot: dict[str, object] = {}
|
|
385
|
+
for name, value in vars(self.state).items():
|
|
386
|
+
snapshot[name] = value.copy() if isinstance(value, (list, set, dict)) else value
|
|
387
|
+
return snapshot
|
|
388
|
+
|
|
389
|
+
def apply_decision(self, step: Step, value: Contribution) -> None:
|
|
390
|
+
"""Compose one native decision under the host's precedence rule.
|
|
391
|
+
|
|
392
|
+
The default accepts no decision; a host with decisions overrides it.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
step: Child that supplied `value`.
|
|
396
|
+
value: Validated contribution whose `decision` may be set.
|
|
397
|
+
"""
|
|
398
|
+
del step
|
|
399
|
+
if value.decision is not None:
|
|
400
|
+
raise RuntimeError('host accepted a decision it does not compose')
|
|
401
|
+
|
|
402
|
+
def _discard_plain(
|
|
403
|
+
self,
|
|
404
|
+
pending_diagnostics: list[tuple[Step, str]] | None = None,
|
|
405
|
+
*,
|
|
406
|
+
reason: str = 'structured output survived',
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Discard pending plain output and diagnose each discarded text."""
|
|
409
|
+
state = self.state
|
|
410
|
+
if not state.plain:
|
|
411
|
+
return
|
|
412
|
+
for plain_step, text in state.plain:
|
|
413
|
+
diagnostic = self._plain_discard_diagnostic(text, reason=reason)
|
|
414
|
+
if pending_diagnostics is None:
|
|
415
|
+
self.write_diagnostic(plain_step, diagnostic)
|
|
416
|
+
else:
|
|
417
|
+
pending_diagnostics.append((plain_step, diagnostic))
|
|
418
|
+
state.plain.clear()
|
|
419
|
+
|
|
420
|
+
def bounded_contexts(self) -> str | None:
|
|
421
|
+
"""Return the model-facing contexts after shedding, if any.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Bounded labeled contexts, or `None` when shed or absent.
|
|
425
|
+
"""
|
|
426
|
+
state = self.state
|
|
427
|
+
if not state.contexts or state.shed_contexts:
|
|
428
|
+
return None
|
|
429
|
+
return self.bounded(state.contexts)
|
|
430
|
+
|
|
431
|
+
def bounded_decision_reasons(self) -> str | None:
|
|
432
|
+
"""Return the winning decision reasons after shedding, if any.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Bounded labeled reasons, the unlabeled minimal report under the
|
|
436
|
+
minimal-report stage, or `None` when no reason survives.
|
|
437
|
+
"""
|
|
438
|
+
state = self.state
|
|
439
|
+
reasons = state.decision_reasons
|
|
440
|
+
if state.minimal_reports and state.decision_report is not None:
|
|
441
|
+
minimal = state.decision_report.minimal_message
|
|
442
|
+
reasons = [minimal] if minimal is not None else []
|
|
443
|
+
if not reasons:
|
|
444
|
+
return None
|
|
445
|
+
return self.bounded(reasons, state.winning_reason_limit)
|
|
446
|
+
|
|
447
|
+
def bounded_messages(self) -> str | None:
|
|
448
|
+
"""Return the user-tier messages after shedding, if any.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Bounded messages in acceptance order, or `None` when none survive.
|
|
452
|
+
"""
|
|
453
|
+
state = self.state
|
|
454
|
+
messages: list[str] = []
|
|
455
|
+
for contribution in state.messages:
|
|
456
|
+
if not contribution.mandatory and state.shed_messages:
|
|
457
|
+
continue
|
|
458
|
+
if state.minimal_reports and contribution.mandatory:
|
|
459
|
+
if contribution.minimal_message is None:
|
|
460
|
+
raise RuntimeError('mandatory contribution omits minimal text')
|
|
461
|
+
messages.append(contribution.minimal_message)
|
|
462
|
+
else:
|
|
463
|
+
messages.append(contribution.message)
|
|
464
|
+
if not messages:
|
|
465
|
+
return None
|
|
466
|
+
return self.bounded(messages)
|
|
467
|
+
|
|
468
|
+
def bounded_stop_reason(self) -> str | None:
|
|
469
|
+
"""Return the global stop reason after shedding, if any.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
Bounded labeled stop reason, or `None`.
|
|
473
|
+
"""
|
|
474
|
+
state = self.state
|
|
475
|
+
if state.stop_reason is None:
|
|
476
|
+
return None
|
|
477
|
+
return self.bounded([state.stop_reason], state.stop_reason_limit)
|
|
478
|
+
|
|
479
|
+
def build_response(self) -> JsonObject:
|
|
480
|
+
"""Build the current structured native response.
|
|
481
|
+
|
|
482
|
+
A host overrides this with its response shape, reading the bounded
|
|
483
|
+
accessors and `state`. An empty object means no structured response.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Native response object before encoding.
|
|
487
|
+
"""
|
|
488
|
+
return {}
|
|
489
|
+
|
|
490
|
+
def _response_candidate(self) -> bytes | None:
|
|
491
|
+
"""Build and encode one current structured response candidate."""
|
|
492
|
+
# A label that cannot fit its host field is a rejected candidate and
|
|
493
|
+
# propagates; only an oversized encoding is a no-fit.
|
|
494
|
+
response = self.build_response()
|
|
495
|
+
if not response:
|
|
496
|
+
return b''
|
|
497
|
+
try:
|
|
498
|
+
return encode_response(response)
|
|
499
|
+
except AdapterOutputError as error:
|
|
500
|
+
if error.kind is OutputFailureKind.OVERSIZED:
|
|
501
|
+
return None
|
|
502
|
+
raise
|
|
503
|
+
|
|
504
|
+
def _response_fits(self) -> bool:
|
|
505
|
+
"""Encode and retain the current response when it fits."""
|
|
506
|
+
response = self._response_candidate()
|
|
507
|
+
if response is None:
|
|
508
|
+
return False
|
|
509
|
+
self.state.response_bytes = response
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
def _fit_labeled_field(
|
|
513
|
+
self,
|
|
514
|
+
contributions: list[str],
|
|
515
|
+
field_name: Literal['stop_reason_limit', 'winning_reason_limit'],
|
|
516
|
+
) -> bool:
|
|
517
|
+
"""Fit one labeled field by binary search while retaining every label."""
|
|
518
|
+
if not contributions:
|
|
519
|
+
return False
|
|
520
|
+
labels = [contribution.partition('\n')[0] for contribution in contributions]
|
|
521
|
+
low = len('\n\n'.join(labels)) + len(TRUNCATION_MARKER)
|
|
522
|
+
joined = len('\n\n'.join(contributions))
|
|
523
|
+
high = joined if self.field_limit is None else min(self.field_limit, joined)
|
|
524
|
+
best: int | None = None
|
|
525
|
+
while low <= high:
|
|
526
|
+
middle = (low + high) // 2
|
|
527
|
+
setattr(self.state, field_name, middle)
|
|
528
|
+
if self._response_fits():
|
|
529
|
+
best = middle
|
|
530
|
+
low = middle + 1
|
|
531
|
+
else:
|
|
532
|
+
high = middle - 1
|
|
533
|
+
setattr(self.state, field_name, low if best is None else best)
|
|
534
|
+
return best is not None
|
|
535
|
+
|
|
536
|
+
def classify_unparsable(self, text: str, stripped: bytes, error: str) -> Outcome:
|
|
537
|
+
"""Classify exit-zero stdout that is not one strict JSON object.
|
|
538
|
+
|
|
539
|
+
The default treats every such text as malformed. Hosts with a plain or
|
|
540
|
+
developer text tier override it.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
text: Decoded stdout.
|
|
544
|
+
stripped: Stdout without leading ASCII whitespace.
|
|
545
|
+
error: Parser detail for the malformed classification.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
Plain, developer, or failure outcome.
|
|
549
|
+
"""
|
|
550
|
+
del text, stripped
|
|
551
|
+
return Outcome('failure', failure=OutputFailureKind.MALFORMED.value, message=error)
|
|
552
|
+
|
|
553
|
+
def fit_response(
|
|
554
|
+
self,
|
|
555
|
+
owner: Contribution | None = None,
|
|
556
|
+
*,
|
|
557
|
+
minimal_reports: bool = False,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Shed monotonically until the structured response fits.
|
|
560
|
+
|
|
561
|
+
The stages run in a fixed order: contexts, non-mandatory messages,
|
|
562
|
+
minimal reports, the winning-reason field, then the stop-reason field.
|
|
563
|
+
A field shed to its label-only minimum stays there.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
owner: Contribution being published, when the fit follows one.
|
|
567
|
+
minimal_reports: Whether to start with mandatory reports already
|
|
568
|
+
reduced to their minimal text.
|
|
569
|
+
|
|
570
|
+
Raises:
|
|
571
|
+
AdapterOutputError: If no stage fits; `UNDELIVERABLE_MUTATION` when
|
|
572
|
+
`undeliverable_owner(owner)` holds, else `OVERSIZED`.
|
|
573
|
+
"""
|
|
574
|
+
state = self.state
|
|
575
|
+
state.minimal_reports = minimal_reports
|
|
576
|
+
state.shed_contexts = False
|
|
577
|
+
state.shed_messages = False
|
|
578
|
+
state.stop_reason_limit = None
|
|
579
|
+
state.winning_reason_limit = None
|
|
580
|
+
if self._response_fits():
|
|
581
|
+
return
|
|
582
|
+
state.shed_contexts = True
|
|
583
|
+
if self._response_fits():
|
|
584
|
+
return
|
|
585
|
+
state.shed_messages = True
|
|
586
|
+
if self._response_fits():
|
|
587
|
+
return
|
|
588
|
+
if not state.minimal_reports:
|
|
589
|
+
state.minimal_reports = True
|
|
590
|
+
if self._response_fits():
|
|
591
|
+
return
|
|
592
|
+
if self._fit_labeled_field(state.decision_reasons, 'winning_reason_limit'):
|
|
593
|
+
return
|
|
594
|
+
stop_reasons = [state.stop_reason] if state.stop_reason is not None else []
|
|
595
|
+
if self._fit_labeled_field(stop_reasons, 'stop_reason_limit'):
|
|
596
|
+
return
|
|
597
|
+
kind = (
|
|
598
|
+
OutputFailureKind.UNDELIVERABLE_MUTATION
|
|
599
|
+
if owner is not None and self.undeliverable_owner(owner)
|
|
600
|
+
else OutputFailureKind.OVERSIZED
|
|
601
|
+
)
|
|
602
|
+
raise AdapterOutputError(
|
|
603
|
+
kind,
|
|
604
|
+
(
|
|
605
|
+
'mandatory decision-bearing output cannot be delivered within '
|
|
606
|
+
'the final response limit'
|
|
607
|
+
if kind is OutputFailureKind.UNDELIVERABLE_MUTATION
|
|
608
|
+
else 'structured contribution exceeds the final response limit'
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def failure_texts(self, category: str, message: str) -> tuple[str, str, str]:
|
|
613
|
+
"""Return the developer diagnostic, user report, and minimal report for a failure.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
category: Failure category with underscores replaced by spaces.
|
|
617
|
+
message: Failure detail, possibly empty.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
Diagnostic text, report text awaiting step attribution, and minimal
|
|
621
|
+
report text emitted without step attribution.
|
|
622
|
+
"""
|
|
623
|
+
detail = message or f'{category} child result'
|
|
624
|
+
return (
|
|
625
|
+
f'{category}: {detail}',
|
|
626
|
+
f'{category}: {detail}',
|
|
627
|
+
f'{category}: see developer diagnostics',
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def fits_without_terminal_sequence(self) -> bool:
|
|
631
|
+
"""Return whether the current response fits once the terminal sequence is dropped.
|
|
632
|
+
|
|
633
|
+
The probe restores the sequence before returning.
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
Whether a response without the terminal sequence fits.
|
|
637
|
+
"""
|
|
638
|
+
state = self.state
|
|
639
|
+
terminal_sequence = state.terminal_sequence
|
|
640
|
+
state.terminal_sequence = None
|
|
641
|
+
try:
|
|
642
|
+
return self._response_fits()
|
|
643
|
+
finally:
|
|
644
|
+
state.terminal_sequence = terminal_sequence
|
|
645
|
+
|
|
646
|
+
def ordinary_exit_texts(self, returncode: int) -> tuple[str, str]:
|
|
647
|
+
"""Return the diagnostic and user report for a non-blocking non-zero exit.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
returncode: Child status.
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
Diagnostic text and report text.
|
|
654
|
+
"""
|
|
655
|
+
detail = f'exited with status {returncode}'
|
|
656
|
+
return detail, detail
|
|
657
|
+
|
|
658
|
+
def parse_structured(self, value: JsonObject) -> Contribution:
|
|
659
|
+
"""Validate one whole structured output object.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
value: Strict JSON object a child wrote to stdout.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Validated contribution.
|
|
666
|
+
|
|
667
|
+
Raises:
|
|
668
|
+
AdapterOutputError: If the object is outside the host contract.
|
|
669
|
+
"""
|
|
670
|
+
del value
|
|
671
|
+
raise AdapterOutputError(
|
|
672
|
+
OutputFailureKind.UNSUPPORTED,
|
|
673
|
+
'structured output is not supported by this host',
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
def classify_stdout(self, stdout: bytes) -> Outcome:
|
|
677
|
+
"""Classify exit-zero stdout under the host's native boundary.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
stdout: Bounded child stdout.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Neutral, structured, plain, developer, or failure outcome.
|
|
684
|
+
"""
|
|
685
|
+
stripped = stdout.lstrip(b' \t\r\n')
|
|
686
|
+
if not stripped:
|
|
687
|
+
return Outcome('neutral')
|
|
688
|
+
try:
|
|
689
|
+
text = stdout.decode()
|
|
690
|
+
except UnicodeDecodeError:
|
|
691
|
+
return Outcome(
|
|
692
|
+
'failure',
|
|
693
|
+
failure=OutputFailureKind.MALFORMED.value,
|
|
694
|
+
message='stdout is not valid UTF-8',
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
value = load_object(stdout)
|
|
698
|
+
except JsonProtocolError as error:
|
|
699
|
+
return self.classify_unparsable(text, stripped, str(error))
|
|
700
|
+
try:
|
|
701
|
+
dump_object(value)
|
|
702
|
+
structured = self.parse_structured(value)
|
|
703
|
+
except (JsonProtocolError, UnicodeEncodeError):
|
|
704
|
+
return Outcome(
|
|
705
|
+
'failure',
|
|
706
|
+
failure=OutputFailureKind.MALFORMED.value,
|
|
707
|
+
message='structured stdout is not valid UTF-8 text',
|
|
708
|
+
)
|
|
709
|
+
except AdapterOutputError as error:
|
|
710
|
+
return Outcome('failure', failure=error.kind.value, message=str(error))
|
|
711
|
+
return Outcome('structured', structured=structured) if value else Outcome('neutral')
|
|
712
|
+
|
|
713
|
+
def _classify(self, result: StepResult) -> Outcome:
|
|
714
|
+
"""Classify infrastructure and exit control before stdout."""
|
|
715
|
+
if result.failure is not None:
|
|
716
|
+
return Outcome(
|
|
717
|
+
'failure',
|
|
718
|
+
failure=str(result.failure.kind),
|
|
719
|
+
message=result.failure.message,
|
|
720
|
+
uncontained=str(result.containment) == 'uncontained',
|
|
721
|
+
)
|
|
722
|
+
if result.returncode == self.blocking_exit:
|
|
723
|
+
return Outcome('native_exit', message=result.blocking_stderr)
|
|
724
|
+
if result.returncode not in (None, 0):
|
|
725
|
+
return Outcome('ordinary_exit', returncode=result.returncode)
|
|
726
|
+
return self.classify_stdout(result.stdout)
|
|
727
|
+
|
|
728
|
+
def _fit_terminal(
|
|
729
|
+
self,
|
|
730
|
+
step: Step,
|
|
731
|
+
owner: Contribution | None,
|
|
732
|
+
*,
|
|
733
|
+
terminal_reason: str | None = None,
|
|
734
|
+
) -> None:
|
|
735
|
+
"""Fit terminal control, replacing its attribution before giving up."""
|
|
736
|
+
state = self.state
|
|
737
|
+
try:
|
|
738
|
+
self.fit_response(owner)
|
|
739
|
+
except AdapterOutputError as error:
|
|
740
|
+
failure = error
|
|
741
|
+
else:
|
|
742
|
+
return
|
|
743
|
+
state.contexts.clear()
|
|
744
|
+
state.messages = [message for message in state.messages if message.mandatory]
|
|
745
|
+
self._replace_terminal_attribution(owner, terminal_reason)
|
|
746
|
+
try:
|
|
747
|
+
self.fit_response(owner, minimal_reports=True)
|
|
748
|
+
except AdapterOutputError:
|
|
749
|
+
pass
|
|
750
|
+
else:
|
|
751
|
+
return
|
|
752
|
+
self._replace_terminal_attribution_with_generic()
|
|
753
|
+
try:
|
|
754
|
+
self.fit_response(owner, minimal_reports=True)
|
|
755
|
+
except AdapterOutputError:
|
|
756
|
+
pass
|
|
757
|
+
else:
|
|
758
|
+
return
|
|
759
|
+
if owner is None and self.terminal_fallback(step):
|
|
760
|
+
return
|
|
761
|
+
raise failure
|
|
762
|
+
|
|
763
|
+
def step_input(self) -> bytes:
|
|
764
|
+
"""Return native JSON input for the next child.
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
Original input until an accepted mutation changes its owned field.
|
|
768
|
+
"""
|
|
769
|
+
return self.state.raw_input
|
|
770
|
+
|
|
771
|
+
def terminal_fallback(self, step: Step) -> bool:
|
|
772
|
+
"""Apply the host's last-resort stage for a blocking native exit.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
step: Child whose exit is being delivered.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
Whether the response now fits.
|
|
779
|
+
"""
|
|
780
|
+
del step
|
|
781
|
+
return False
|
|
782
|
+
|
|
783
|
+
def undeliverable_owner(self, owner: Contribution) -> bool:
|
|
784
|
+
"""Return whether an unfittable owner carries mandatory output.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
owner: Contribution whose publication cannot fit.
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
`True` when dropping the owner would run an operation without its
|
|
791
|
+
required mutation; the default checks only the mutation.
|
|
792
|
+
"""
|
|
793
|
+
return owner.mutation_present
|
|
794
|
+
|
|
795
|
+
def _apply_failure(
|
|
796
|
+
self,
|
|
797
|
+
step: Step,
|
|
798
|
+
failure: str,
|
|
799
|
+
message: str,
|
|
800
|
+
*,
|
|
801
|
+
uncontained: bool = False,
|
|
802
|
+
) -> ChainControl:
|
|
803
|
+
"""Publish one matrix failure through both diagnostic tiers."""
|
|
804
|
+
state = self.state
|
|
805
|
+
self._discard_plain(reason='a step failure ended the chain')
|
|
806
|
+
if uncontained and failure in TERMINATION_FAILURES:
|
|
807
|
+
_warning, warning_added = self._add_mandatory_report(
|
|
808
|
+
step,
|
|
809
|
+
UNCONTAINED_WARNING,
|
|
810
|
+
deduplication_key=_UNCONTAINED_DEDUPLICATION_KEY,
|
|
811
|
+
)
|
|
812
|
+
if warning_added:
|
|
813
|
+
self.write_diagnostic(step, UNCONTAINED_WARNING)
|
|
814
|
+
category = failure.replace('_', ' ')
|
|
815
|
+
diagnostic, report_text, minimal = self.failure_texts(category, message)
|
|
816
|
+
self.write_diagnostic(step, diagnostic)
|
|
817
|
+
report, _added = self._add_mandatory_report(step, report_text, minimal_message=minimal)
|
|
818
|
+
state.structured_seen = True
|
|
819
|
+
should_continue = continue_after_failure(
|
|
820
|
+
self.pre_tool,
|
|
821
|
+
failure,
|
|
822
|
+
allow_on_failure=self.allow_on_failure,
|
|
823
|
+
)
|
|
824
|
+
if not should_continue and self.pre_tool:
|
|
825
|
+
state.decision = 'deny'
|
|
826
|
+
state.decision_reasons = [report.message]
|
|
827
|
+
state.decision_report = report
|
|
828
|
+
try:
|
|
829
|
+
self.fit_response()
|
|
830
|
+
except AdapterOutputError:
|
|
831
|
+
# The report's own label may exceed the host field; reduce every
|
|
832
|
+
# mandatory report to its minimal text and keep the matrix decision.
|
|
833
|
+
self.fit_response(minimal_reports=True)
|
|
834
|
+
return 'continue' if should_continue else 'stop'
|
|
835
|
+
|
|
836
|
+
def bounded(self, contributions: list[str], limit: int | None = None) -> str:
|
|
837
|
+
"""Bound labeled contributions under the host field cap.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
contributions: Labeled contributions in step order.
|
|
841
|
+
limit: Explicit character budget from a fitted field, if any.
|
|
842
|
+
|
|
843
|
+
Returns:
|
|
844
|
+
Joined contributions, truncated with every label retained.
|
|
845
|
+
|
|
846
|
+
Raises:
|
|
847
|
+
AdapterOutputError: If truncation is required and the labels plus
|
|
848
|
+
the truncation marker cannot fit within the budget.
|
|
849
|
+
"""
|
|
850
|
+
if limit is None:
|
|
851
|
+
limit = (
|
|
852
|
+
len('\n\n'.join(contributions)) if self.field_limit is None else self.field_limit
|
|
853
|
+
)
|
|
854
|
+
return bounded_contributions(contributions, limit)
|
|
855
|
+
|
|
856
|
+
def write_diagnostic(self, step: Step, message: str) -> None:
|
|
857
|
+
"""Write one step diagnostic whose complete attributed line is bounded.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
step: Child the message belongs to.
|
|
861
|
+
message: Diagnostic text; truncated with a marker when the labeled
|
|
862
|
+
line would exceed `DIAGNOSTIC_LIMIT_CHARACTERS`.
|
|
863
|
+
"""
|
|
864
|
+
intended = f'sequential-hooks: step {step.index} ({step.name}): {message}\n'
|
|
865
|
+
if len(intended) <= DIAGNOSTIC_LIMIT_CHARACTERS:
|
|
866
|
+
self.diagnostics.write_step(step, message)
|
|
867
|
+
return
|
|
868
|
+
index = step.index
|
|
869
|
+
fixed_characters = len(f'sequential-hooks: step {index} (): \n')
|
|
870
|
+
if (
|
|
871
|
+
fixed_characters + _MINIMUM_DIAGNOSTIC_MESSAGE_CHARACTERS
|
|
872
|
+
>= DIAGNOSTIC_LIMIT_CHARACTERS
|
|
873
|
+
):
|
|
874
|
+
index = 0
|
|
875
|
+
fixed_characters = len('sequential-hooks: step 0 (): \n')
|
|
876
|
+
name_budget = max(
|
|
877
|
+
0,
|
|
878
|
+
DIAGNOSTIC_LIMIT_CHARACTERS
|
|
879
|
+
- fixed_characters
|
|
880
|
+
- min(len(message), _MINIMUM_DIAGNOSTIC_MESSAGE_CHARACTERS),
|
|
881
|
+
)
|
|
882
|
+
safe_name = _truncate(step.name, name_budget)
|
|
883
|
+
message_budget = max(0, DIAGNOSTIC_LIMIT_CHARACTERS - fixed_characters - len(safe_name))
|
|
884
|
+
self.diagnostics.write_step(
|
|
885
|
+
_DiagnosticStep(index, (safe_name,)),
|
|
886
|
+
_truncate(message, message_budget),
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
@property
|
|
890
|
+
def event(self) -> str:
|
|
891
|
+
"""Return the validated native event.
|
|
892
|
+
|
|
893
|
+
Returns:
|
|
894
|
+
Trusted event bound at construction.
|
|
895
|
+
"""
|
|
896
|
+
return self._event
|
|
897
|
+
|
|
898
|
+
@property
|
|
899
|
+
def should_run_steps(self) -> bool:
|
|
900
|
+
"""Return whether this invocation should execute child steps.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
`True` for every composed invocation.
|
|
904
|
+
"""
|
|
905
|
+
return True
|
|
906
|
+
|
|
907
|
+
def _apply_native_exit(self, step: Step, message: str) -> ChainControl:
|
|
908
|
+
"""Translate the host's blocking exit into terminal native feedback."""
|
|
909
|
+
state = self.state
|
|
910
|
+
self._discard_plain()
|
|
911
|
+
native_reason = message or f'exited with status {self.blocking_exit}'
|
|
912
|
+
state.structured_seen = True
|
|
913
|
+
state.decision = 'deny' if self.pre_tool else 'block'
|
|
914
|
+
state.decision_reasons = [labeled(step, native_reason)]
|
|
915
|
+
state.decision_report = None
|
|
916
|
+
self._fit_terminal(step, None, terminal_reason=native_reason)
|
|
917
|
+
return 'stop'
|
|
918
|
+
|
|
919
|
+
def _apply_ordinary_exit(self, step: Step, returncode: int) -> ChainControl:
|
|
920
|
+
"""Report one documented non-blocking child exit."""
|
|
921
|
+
self._discard_plain()
|
|
922
|
+
diagnostic, report = self.ordinary_exit_texts(returncode)
|
|
923
|
+
self.write_diagnostic(step, diagnostic)
|
|
924
|
+
self._add_mandatory_report(step, report)
|
|
925
|
+
self.state.structured_seen = True
|
|
926
|
+
self.fit_response()
|
|
927
|
+
return 'continue'
|
|
928
|
+
|
|
929
|
+
def _apply_structured(
|
|
930
|
+
self,
|
|
931
|
+
step: Step,
|
|
932
|
+
value: Contribution,
|
|
933
|
+
) -> tuple[bool, list[tuple[Step, str]]]:
|
|
934
|
+
"""Apply one validated contribution and report whether it is terminal."""
|
|
935
|
+
state = self.state
|
|
936
|
+
pending_diagnostics: list[tuple[Step, str]] = []
|
|
937
|
+
state.structured_seen = True
|
|
938
|
+
self._discard_plain(pending_diagnostics)
|
|
939
|
+
if value.system_message is not None:
|
|
940
|
+
state.messages.append(
|
|
941
|
+
UserContribution(mandatory=False, message=labeled(step, value.system_message))
|
|
942
|
+
)
|
|
943
|
+
if value.suppress_output is not None:
|
|
944
|
+
state.suppress_output = (
|
|
945
|
+
value.suppress_output
|
|
946
|
+
if state.suppress_output is None
|
|
947
|
+
else state.suppress_output or value.suppress_output
|
|
948
|
+
)
|
|
949
|
+
if value.terminal_sequence is not None:
|
|
950
|
+
if state.terminal_sequence is None:
|
|
951
|
+
state.terminal_sequence = value.terminal_sequence
|
|
952
|
+
state.terminal_sequence_owner = value
|
|
953
|
+
else:
|
|
954
|
+
pending_diagnostics.append(
|
|
955
|
+
(step, 'terminalSequence dropped because an earlier step supplied one')
|
|
956
|
+
)
|
|
957
|
+
if value.additional_context is not None:
|
|
958
|
+
state.contexts.append(labeled(step, value.additional_context))
|
|
959
|
+
self._apply_mutation(value)
|
|
960
|
+
self.apply_decision(step, value)
|
|
961
|
+
if value.continue_value is False:
|
|
962
|
+
state.continue_false = True
|
|
963
|
+
state.stop_reason = (
|
|
964
|
+
labeled(step, value.stop_reason) if value.stop_reason is not None else None
|
|
965
|
+
)
|
|
966
|
+
return True, pending_diagnostics
|
|
967
|
+
return value.decision in ('deny', 'block'), pending_diagnostics
|
|
968
|
+
|
|
969
|
+
def _plain_response(self, *, diagnose: bool) -> bytes:
|
|
970
|
+
"""Build the longest whole-prefix plain response that fits."""
|
|
971
|
+
state = self.state
|
|
972
|
+
if not state.plain:
|
|
973
|
+
return b''
|
|
974
|
+
if len(state.plain) == 1:
|
|
975
|
+
return encode_plain_response(state.plain[0][1])
|
|
976
|
+
for retained_count in range(len(state.plain), 0, -1):
|
|
977
|
+
joined = '\n\n'.join(
|
|
978
|
+
labeled(step, text) for step, text in state.plain[:retained_count]
|
|
979
|
+
)
|
|
980
|
+
try:
|
|
981
|
+
response = encode_plain_response(joined)
|
|
982
|
+
except AdapterOutputError:
|
|
983
|
+
continue
|
|
984
|
+
if diagnose:
|
|
985
|
+
for dropped_step, text in state.plain[retained_count:]:
|
|
986
|
+
if dropped_step.index not in state.plain_dropped_reported:
|
|
987
|
+
self.write_diagnostic(
|
|
988
|
+
dropped_step,
|
|
989
|
+
self._plain_discard_diagnostic(
|
|
990
|
+
text,
|
|
991
|
+
reason='it exceeds the final response limit',
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
state.plain_dropped_reported.add(dropped_step.index)
|
|
995
|
+
return response
|
|
996
|
+
raise AdapterOutputError(
|
|
997
|
+
OutputFailureKind.OVERSIZED,
|
|
998
|
+
'first labeled plain contribution exceeds final response limit',
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
def _apply_plain(self, step: Step, text: str) -> ChainControl:
|
|
1002
|
+
"""Publish one plain contribution or its classified size failure."""
|
|
1003
|
+
state = self.state
|
|
1004
|
+
if state.structured_seen:
|
|
1005
|
+
self.write_diagnostic(
|
|
1006
|
+
step,
|
|
1007
|
+
self._plain_discard_diagnostic(text, reason='structured output survived'),
|
|
1008
|
+
)
|
|
1009
|
+
return 'continue'
|
|
1010
|
+
try:
|
|
1011
|
+
encode_plain_response(text)
|
|
1012
|
+
except AdapterOutputError as error:
|
|
1013
|
+
return self._apply_failure(step, error.kind.value, str(error))
|
|
1014
|
+
state.plain.append((step, text))
|
|
1015
|
+
try:
|
|
1016
|
+
self._plain_response(diagnose=False)
|
|
1017
|
+
except AdapterOutputError as error:
|
|
1018
|
+
state.plain.pop()
|
|
1019
|
+
return self._apply_failure(step, error.kind.value, str(error))
|
|
1020
|
+
return 'continue'
|
|
1021
|
+
|
|
1022
|
+
def _publish_structured(self, step: Step, value: Contribution) -> ChainControl:
|
|
1023
|
+
"""Apply one validated structured candidate atomically."""
|
|
1024
|
+
snapshot = self._snapshot()
|
|
1025
|
+
try:
|
|
1026
|
+
terminal, pending_diagnostics = self._apply_structured(step, value)
|
|
1027
|
+
if terminal:
|
|
1028
|
+
self._fit_terminal(step, value)
|
|
1029
|
+
else:
|
|
1030
|
+
self.fit_response(value)
|
|
1031
|
+
except AdapterOutputError as error:
|
|
1032
|
+
self._restore(snapshot)
|
|
1033
|
+
return self._apply_failure(step, error.kind.value, str(error))
|
|
1034
|
+
for diagnostic_step, diagnostic in pending_diagnostics:
|
|
1035
|
+
self.write_diagnostic(diagnostic_step, diagnostic)
|
|
1036
|
+
return 'stop' if terminal else 'continue'
|
|
1037
|
+
|
|
1038
|
+
def consume(self, step: Step, result: StepResult) -> ChainControl:
|
|
1039
|
+
"""Apply one child result and choose whether the runner continues.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
step: Child that produced `result`.
|
|
1043
|
+
result: Bounded process outcome to interpret.
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
`'continue'` or `'stop'`.
|
|
1047
|
+
|
|
1048
|
+
Raises:
|
|
1049
|
+
AdapterOutputError: If a failure report cannot be represented even
|
|
1050
|
+
after every shedding stage; the wrapper handles this propagated
|
|
1051
|
+
error through Tier 3.
|
|
1052
|
+
"""
|
|
1053
|
+
outcome = self._classify(result)
|
|
1054
|
+
if outcome.kind in ('neutral', 'developer'):
|
|
1055
|
+
if outcome.kind == 'developer':
|
|
1056
|
+
self.write_diagnostic(
|
|
1057
|
+
step,
|
|
1058
|
+
bounded_diagnostic('developer stdout omitted: ', outcome.developer_text),
|
|
1059
|
+
)
|
|
1060
|
+
return 'continue'
|
|
1061
|
+
if outcome.kind == 'plain':
|
|
1062
|
+
return self._apply_plain(step, outcome.plain)
|
|
1063
|
+
if outcome.kind == 'failure':
|
|
1064
|
+
if outcome.failure is None:
|
|
1065
|
+
raise RuntimeError('classified failure omits its category')
|
|
1066
|
+
return self._apply_failure(
|
|
1067
|
+
step,
|
|
1068
|
+
outcome.failure,
|
|
1069
|
+
outcome.message,
|
|
1070
|
+
uncontained=outcome.uncontained,
|
|
1071
|
+
)
|
|
1072
|
+
if outcome.kind == 'native_exit':
|
|
1073
|
+
return self._apply_native_exit(step, outcome.message)
|
|
1074
|
+
if outcome.kind == 'ordinary_exit':
|
|
1075
|
+
if outcome.returncode is None:
|
|
1076
|
+
raise RuntimeError('classified exit omits its status')
|
|
1077
|
+
return self._apply_ordinary_exit(step, outcome.returncode)
|
|
1078
|
+
if outcome.structured is None:
|
|
1079
|
+
raise RuntimeError('structured outcome omits its value')
|
|
1080
|
+
return self._publish_structured(step, outcome.structured)
|
|
1081
|
+
|
|
1082
|
+
def finalize(self) -> FinalResponse:
|
|
1083
|
+
"""Return the final native host response.
|
|
1084
|
+
|
|
1085
|
+
Returns:
|
|
1086
|
+
The last structured response that fit, otherwise the plain
|
|
1087
|
+
response, otherwise empty stdout.
|
|
1088
|
+
"""
|
|
1089
|
+
if self.state.response_bytes is not None:
|
|
1090
|
+
return NativeResponse(self.state.response_bytes)
|
|
1091
|
+
return NativeResponse(self._plain_response(diagnose=True))
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
@dataclass(frozen=True)
|
|
1095
|
+
class _DiagnosticStep:
|
|
1096
|
+
"""Stand in for a step whose name was truncated for one diagnostic line."""
|
|
1097
|
+
|
|
1098
|
+
index: int
|
|
1099
|
+
argv: tuple[str, ...]
|
|
1100
|
+
|
|
1101
|
+
@property
|
|
1102
|
+
def name(self) -> str:
|
|
1103
|
+
"""Return the truncated name."""
|
|
1104
|
+
return self.argv[0]
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _truncate(value: str, limit: int) -> str:
|
|
1108
|
+
"""Truncate one diagnostic component within its exact budget."""
|
|
1109
|
+
if len(value) <= limit:
|
|
1110
|
+
return value
|
|
1111
|
+
if limit <= len(_DIAGNOSTIC_TRUNCATION_MARKER):
|
|
1112
|
+
return value[:limit]
|
|
1113
|
+
return f'{value[: limit - len(_DIAGNOSTIC_TRUNCATION_MARKER)]}{_DIAGNOSTIC_TRUNCATION_MARKER}'
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def bounded_contributions(contributions: Collection[str], limit: int) -> str:
|
|
1117
|
+
"""Fit labeled contributions into one character budget.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
contributions: Contributions in accepted step order, each with its
|
|
1121
|
+
label on the first line and its body on subsequent lines.
|
|
1122
|
+
limit: Maximum returned character count.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
Contributions joined in input order. When truncation is required, every
|
|
1126
|
+
label survives, remaining body space goes to earlier contributions
|
|
1127
|
+
first, and `TRUNCATION_MARKER` is appended.
|
|
1128
|
+
|
|
1129
|
+
Raises:
|
|
1130
|
+
AdapterOutputError: If truncation is required and the labels plus
|
|
1131
|
+
`TRUNCATION_MARKER` cannot fit within the budget.
|
|
1132
|
+
"""
|
|
1133
|
+
joined = '\n\n'.join(contributions)
|
|
1134
|
+
if len(joined) <= limit:
|
|
1135
|
+
return joined
|
|
1136
|
+
labeled_content = [contribution.partition('\n') for contribution in contributions]
|
|
1137
|
+
labels_with_marker = (
|
|
1138
|
+
'\n\n'.join(label for label, _separator, _content in labeled_content) + TRUNCATION_MARKER
|
|
1139
|
+
)
|
|
1140
|
+
if len(labels_with_marker) > limit:
|
|
1141
|
+
raise AdapterOutputError(
|
|
1142
|
+
OutputFailureKind.OVERSIZED,
|
|
1143
|
+
'labeled contributions cannot fit within the response field limit',
|
|
1144
|
+
)
|
|
1145
|
+
remaining = limit - len(labels_with_marker)
|
|
1146
|
+
retained: list[str] = []
|
|
1147
|
+
for label, _separator, content in labeled_content:
|
|
1148
|
+
if content and remaining > 1:
|
|
1149
|
+
retained_characters = min(len(content), remaining - 1)
|
|
1150
|
+
retained.append(f'{label}\n{content[:retained_characters]}')
|
|
1151
|
+
remaining -= retained_characters + 1
|
|
1152
|
+
else:
|
|
1153
|
+
retained.append(label)
|
|
1154
|
+
return '\n\n'.join(retained) + TRUNCATION_MARKER
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def bounded_diagnostic(prefix: str, detail: str) -> str:
|
|
1158
|
+
"""Prefix a repr-quoted detail and truncate it against the diagnostic budget.
|
|
1159
|
+
|
|
1160
|
+
Args:
|
|
1161
|
+
prefix: Text retained in full before the quoted detail.
|
|
1162
|
+
detail: Text to quote and truncate.
|
|
1163
|
+
|
|
1164
|
+
Returns:
|
|
1165
|
+
Prefixed diagnostic, with `...[truncated]` appended when truncation is
|
|
1166
|
+
needed. The result fits `DIAGNOSTIC_LIMIT_CHARACTERS` when the prefix
|
|
1167
|
+
leaves room for that marker; longer prefixes can exceed the limit.
|
|
1168
|
+
"""
|
|
1169
|
+
representation = repr(detail)
|
|
1170
|
+
if len(prefix) + len(representation) <= DIAGNOSTIC_LIMIT_CHARACTERS:
|
|
1171
|
+
return f'{prefix}{representation}'
|
|
1172
|
+
retained = DIAGNOSTIC_LIMIT_CHARACTERS - len(prefix) - len(_DIAGNOSTIC_TRUNCATION_MARKER)
|
|
1173
|
+
return f'{prefix}{representation[:retained]}{_DIAGNOSTIC_TRUNCATION_MARKER}'
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def continue_after_failure(
|
|
1177
|
+
pre_tool: bool,
|
|
1178
|
+
failure: str,
|
|
1179
|
+
*,
|
|
1180
|
+
allow_on_failure: bool,
|
|
1181
|
+
) -> bool:
|
|
1182
|
+
"""Apply the one authoritative failure-override scope.
|
|
1183
|
+
|
|
1184
|
+
Args:
|
|
1185
|
+
pre_tool: Whether the event permits a proposed tool call.
|
|
1186
|
+
failure: Infrastructure or output failure category.
|
|
1187
|
+
allow_on_failure: Whether the explicit pre-tool override is active.
|
|
1188
|
+
|
|
1189
|
+
Returns:
|
|
1190
|
+
Whether a later step may run after the failure.
|
|
1191
|
+
|
|
1192
|
+
Raises:
|
|
1193
|
+
TypeError: If `failure` is not a matrix category.
|
|
1194
|
+
"""
|
|
1195
|
+
if failure not in _INFRASTRUCTURE_FAILURES and failure not in OutputFailureKind:
|
|
1196
|
+
raise TypeError('failure policy requires a matrix condition')
|
|
1197
|
+
if not pre_tool:
|
|
1198
|
+
return True
|
|
1199
|
+
return allow_on_failure and (
|
|
1200
|
+
failure in _INFRASTRUCTURE_FAILURES
|
|
1201
|
+
or (failure in OutputFailureKind and failure != OutputFailureKind.UNDELIVERABLE_MUTATION)
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def encode_plain_response(text: str) -> bytes:
|
|
1206
|
+
"""Encode one plain-text response within the final byte budget.
|
|
1207
|
+
|
|
1208
|
+
Args:
|
|
1209
|
+
text: Native successful plain-text contribution.
|
|
1210
|
+
|
|
1211
|
+
Returns:
|
|
1212
|
+
UTF-8 bytes with a trailing line feed. A final CR or CRLF is normalized
|
|
1213
|
+
to LF; existing additional trailing line feeds are preserved.
|
|
1214
|
+
|
|
1215
|
+
Raises:
|
|
1216
|
+
AdapterOutputError: If the text is not UTF-8 encodable or exceeds the
|
|
1217
|
+
final response budget.
|
|
1218
|
+
"""
|
|
1219
|
+
if text.endswith('\r\n'):
|
|
1220
|
+
text = f'{text[:-2]}\n'
|
|
1221
|
+
elif text.endswith('\r'):
|
|
1222
|
+
text = f'{text[:-1]}\n'
|
|
1223
|
+
elif not text.endswith('\n'):
|
|
1224
|
+
text = f'{text}\n'
|
|
1225
|
+
try:
|
|
1226
|
+
encoded = text.encode()
|
|
1227
|
+
except UnicodeEncodeError as error:
|
|
1228
|
+
raise AdapterOutputError(
|
|
1229
|
+
OutputFailureKind.MALFORMED,
|
|
1230
|
+
'plain response is not valid UTF-8 text',
|
|
1231
|
+
) from error
|
|
1232
|
+
if len(encoded) > FINAL_RESPONSE_LIMIT_BYTES:
|
|
1233
|
+
raise AdapterOutputError(
|
|
1234
|
+
OutputFailureKind.OVERSIZED,
|
|
1235
|
+
'plain response exceeds final response limit',
|
|
1236
|
+
)
|
|
1237
|
+
return encoded
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def encode_response(value: JsonObject) -> bytes:
|
|
1241
|
+
"""Encode one response and enforce the final byte budget.
|
|
1242
|
+
|
|
1243
|
+
Args:
|
|
1244
|
+
value: Complete native response object.
|
|
1245
|
+
|
|
1246
|
+
Returns:
|
|
1247
|
+
Strict compact JSON bytes ending in one line feed.
|
|
1248
|
+
|
|
1249
|
+
Raises:
|
|
1250
|
+
AdapterOutputError: With `MALFORMED` if JSON serialization rejects the
|
|
1251
|
+
object or its text cannot be encoded as UTF-8; with `OVERSIZED` if
|
|
1252
|
+
the encoded response exceeds the final byte budget.
|
|
1253
|
+
"""
|
|
1254
|
+
try:
|
|
1255
|
+
encoded = dump_object(value)
|
|
1256
|
+
except (JsonProtocolError, UnicodeEncodeError) as error:
|
|
1257
|
+
raise AdapterOutputError(
|
|
1258
|
+
OutputFailureKind.MALFORMED,
|
|
1259
|
+
'structured response is not valid UTF-8 text',
|
|
1260
|
+
) from error
|
|
1261
|
+
if len(encoded) > FINAL_RESPONSE_LIMIT_BYTES:
|
|
1262
|
+
raise AdapterOutputError(
|
|
1263
|
+
OutputFailureKind.OVERSIZED,
|
|
1264
|
+
'structured response exceeds final response limit',
|
|
1265
|
+
)
|
|
1266
|
+
return encoded
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def labeled(step: Step, text: str) -> str:
|
|
1270
|
+
"""Return one stable step-attributed contribution.
|
|
1271
|
+
|
|
1272
|
+
Args:
|
|
1273
|
+
step: Child that supplied the text.
|
|
1274
|
+
text: Native or wrapper-generated contribution.
|
|
1275
|
+
|
|
1276
|
+
Returns:
|
|
1277
|
+
Stable label followed by the original text.
|
|
1278
|
+
"""
|
|
1279
|
+
return f'[step {step.index}: {step.name}]\n{text}'
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def optional_string(value: Mapping[str, object], field_name: str) -> str | None:
|
|
1283
|
+
"""Validate and return one optional native string field.
|
|
1284
|
+
|
|
1285
|
+
Args:
|
|
1286
|
+
value: Native object to read.
|
|
1287
|
+
field_name: Field that must be a string when present.
|
|
1288
|
+
|
|
1289
|
+
Returns:
|
|
1290
|
+
The string, or `None` when the field is absent.
|
|
1291
|
+
|
|
1292
|
+
Raises:
|
|
1293
|
+
AdapterOutputError: If the field is present and not a string.
|
|
1294
|
+
"""
|
|
1295
|
+
item = value.get(field_name)
|
|
1296
|
+
if field_name in value and not isinstance(item, str):
|
|
1297
|
+
raise AdapterOutputError(OutputFailureKind.UNSUPPORTED, f'{field_name} must be a string')
|
|
1298
|
+
return item if isinstance(item, str) else None
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def reject_unsupported(message: str) -> NoReturn:
|
|
1302
|
+
"""Raise one unsupported-output classification.
|
|
1303
|
+
|
|
1304
|
+
Args:
|
|
1305
|
+
message: Rejection detail.
|
|
1306
|
+
|
|
1307
|
+
Raises:
|
|
1308
|
+
AdapterOutputError: Always, with the `UNSUPPORTED` category.
|
|
1309
|
+
"""
|
|
1310
|
+
raise AdapterOutputError(OutputFailureKind.UNSUPPORTED, message)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def unsupported_fields(value: Mapping[str, object], allowed: Collection[str], label: str) -> None:
|
|
1314
|
+
"""Reject the first unknown field of a native object.
|
|
1315
|
+
|
|
1316
|
+
Args:
|
|
1317
|
+
value: Native object to check.
|
|
1318
|
+
allowed: Field names the host accepts.
|
|
1319
|
+
label: Prefix for the rejection detail.
|
|
1320
|
+
|
|
1321
|
+
Raises:
|
|
1322
|
+
AdapterOutputError: If `value` has a field outside `allowed`.
|
|
1323
|
+
"""
|
|
1324
|
+
unknown = set(value) - set(allowed)
|
|
1325
|
+
if unknown:
|
|
1326
|
+
reject_unsupported(f'{label}: {min(unknown)}')
|