autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1027 @@
1
+ """The provider-neutral interaction contract and its broker (ADR 0007 D5.1, D5.2; #134).
2
+
3
+ An *interaction* is a provider's request for a human decision: a question
4
+ the agent asks, or a permission one of its tool calls needs. This module is
5
+ everything the engine, an adapter and a resolver share about one:
6
+
7
+ - the request, :class:`InteractionRequest`: a controller-assigned id, the
8
+ provider's opaque id, the kind, a bounded and redacted rendering (the
9
+ questions, or the tool, a summary of its input and the input's digest),
10
+ the agent it came from, and ``offers``, the decisions its provider can
11
+ carry for it;
12
+ - the decision: :class:`Answer`, :class:`Allow`, :class:`Deny` or
13
+ :class:`Cancel`. Deferral is not one of them: it is the broker's
14
+ directive, never an operator's decision;
15
+ - the :class:`SessionIdentity` a pause binds (#126 records it; nothing here
16
+ persists anything);
17
+ - the :class:`Resolver` the engine supplies: the terminal resolver of
18
+ :mod:`autoforge.cli`, :class:`Unattended` when there is no one to ask, or
19
+ a scripted one in tests;
20
+ - the :class:`InteractionPolicy` and the :class:`Capabilities` an adapter
21
+ declares per provider and pair (D5.3: a capability is true only once its
22
+ gate passed, ADR 0007 §3);
23
+ - the lifecycle discriminant of an invocation's outcome
24
+ (:class:`Lifecycle`, carried by :class:`InteractionOutcome` on
25
+ ``AgentExecutionResult.interaction``, D10.5).
26
+
27
+ :class:`InteractionBroker` decides what happens to each request. The engine
28
+ creates one per launch and hands it to the adapter as
29
+ ``AgentRequest.interaction``, as it hands over ``progress``; the adapter
30
+ mints each provider ask through it (:meth:`InteractionBroker.question`,
31
+ :meth:`InteractionBroker.permission`) and calls
32
+ :meth:`InteractionBroker.handle`, which returns a directive:
33
+ :class:`Deliver`, :class:`Pause` or :class:`Fail`. The broker owns the
34
+ policy (D7.1), the per-wait and aggregate limits (D7.3), the degradation of
35
+ a live wait (D7.4), the capability check (a decision the provider cannot
36
+ carry is never offered, and never delivered) and a bounded, redacted record
37
+ of the requests for the run log: one entry per request up to the aggregate
38
+ limit and the first one past it, and a count of all. It never sees a
39
+ provider record, and the adapter never sees the policy.
40
+
41
+ What this module is not (D5.4): a transport. It carries decisions, never
42
+ bytes, starts no process, reads no file and names no provider, SDK or wire
43
+ record; each helper keeps its own envelope. Every value is bounded where it
44
+ is built, with the rules of :mod:`autoforge.sdk_policy` (docs/sdk-policy.md
45
+ §10): a value that decides what a tool may do or what the agent is told (an
46
+ option label, an answer, guidance, a modified input, an identity) is
47
+ refused past its bound or when redaction would change it, never clipped; a
48
+ question's text is refused past its bound too, so an operator never decides
49
+ on part of a question, and is then cleaned and redacted; a value that is
50
+ only shown (a header, a description, a permission summary) is cleaned,
51
+ redacted and clipped. A refusal is an
52
+ :class:`~autoforge.errors.InteractionDataError` naming the field and the
53
+ rule, never the value; the adapter then answers the ask as it answers
54
+ :class:`Fail` (D10.4).
55
+
56
+ ``pause`` (D7.5), its pending record, the operator commands and recovery are
57
+ #126's. Until it lands the engine passes ``pause_available=False``, so the
58
+ broker never answers :class:`Pause`, and config validation refuses
59
+ ``interaction_policy: pause`` (:func:`autoforge.sdk_policy.interaction_settings`).
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import enum
65
+ import json
66
+ import re
67
+ import threading
68
+ import time
69
+ from collections.abc import Callable, Mapping, Sequence
70
+ from dataclasses import dataclass, field
71
+ from types import MappingProxyType
72
+ from typing import Protocol
73
+
74
+ from .errors import InteractionDataError
75
+ from .sdk_policy import (
76
+ INTERACTION_FAIL,
77
+ INTERACTION_PAUSE,
78
+ INTERACTION_PROMPT,
79
+ MAX_ANSWER_CHARS,
80
+ MAX_OPTION_CHARS,
81
+ MAX_QUESTION_CHARS,
82
+ MAX_QUESTIONS,
83
+ MAX_SHOWN_CHARS,
84
+ MAX_TOOL_NAME_CHARS,
85
+ InteractionSettings,
86
+ canonical_json,
87
+ exact_input,
88
+ exact_text,
89
+ question_options,
90
+ shown,
91
+ )
92
+
93
+ # An identity the controller compares or sends back verbatim: the
94
+ # controller's request id, the provider's, a subagent's name, a session id.
95
+ MAX_IDENTITY_CHARS = 256
96
+ # A path a session identity binds (the working directory, the session root).
97
+ MAX_PATH_CHARS = 4096
98
+ # What an operator is shown of a question's header and an option's
99
+ # description, and of a permission's input (sdk_policy.permission_summary).
100
+ MAX_HEADER_CHARS = 100
101
+ MAX_DESCRIPTION_CHARS = 500
102
+ MAX_SUMMARY_CHARS = 3 * MAX_SHOWN_CHARS
103
+ # What the run log keeps of one fail reason.
104
+ MAX_REASON_CHARS = 400
105
+
106
+ _SHA256 = re.compile(r"[0-9a-f]{64}")
107
+ _GIT_SHA = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
108
+
109
+
110
+ class RequestKind(enum.StrEnum):
111
+ """What the provider asks for."""
112
+
113
+ QUESTION = "question"
114
+ PERMISSION = "permission"
115
+
116
+
117
+ class DecisionKind(enum.StrEnum):
118
+ """A decision a provider may or may not be able to carry (ADR 0007 §3's rows)."""
119
+
120
+ ANSWER = "answer"
121
+ CUSTOM_ANSWER = "custom_answer"
122
+ ALLOW_ONCE = "allow_once"
123
+ ALLOW_SESSION = "allow_session"
124
+ MODIFIED_INPUT = "modified_input"
125
+ DENY = "deny"
126
+ DENY_GUIDANCE = "deny_guidance"
127
+ CANCEL = "cancel"
128
+
129
+
130
+ # Which decisions each kind of request can take at all.
131
+ QUESTION_DECISIONS = frozenset(
132
+ {DecisionKind.ANSWER, DecisionKind.CUSTOM_ANSWER, DecisionKind.CANCEL}
133
+ )
134
+ PERMISSION_DECISIONS = frozenset(
135
+ {
136
+ DecisionKind.ALLOW_ONCE,
137
+ DecisionKind.ALLOW_SESSION,
138
+ DecisionKind.MODIFIED_INPUT,
139
+ DecisionKind.DENY,
140
+ DecisionKind.DENY_GUIDANCE,
141
+ DecisionKind.CANCEL,
142
+ }
143
+ )
144
+
145
+
146
+ class InteractionPolicy(enum.StrEnum):
147
+ """``options.interaction_policy`` (D7.1); :mod:`autoforge.sdk_policy` validates it."""
148
+
149
+ PROMPT = INTERACTION_PROMPT
150
+ PAUSE = INTERACTION_PAUSE
151
+ FAIL = INTERACTION_FAIL
152
+
153
+
154
+ class Lifecycle(enum.StrEnum):
155
+ """How an invocation that could ask ended (D5.1)."""
156
+
157
+ COMPLETED = "completed"
158
+ FAILED = "failed"
159
+ INTERRUPTED = "interrupted"
160
+ PAUSED = "paused"
161
+
162
+
163
+ def _identity(value: object, what: str, limit: int = MAX_IDENTITY_CHARS) -> str:
164
+ """A non-blank identity :func:`autoforge.sdk_policy.exact_text` accepts, on one line."""
165
+ text = exact_text(value, what, limit)
166
+ if not text.strip() or "\n" in text or "\t" in text:
167
+ raise InteractionDataError(f"{what} is blank or spans more than one line")
168
+ return text
169
+
170
+
171
+ def _flag(value: object, what: str) -> bool:
172
+ if not isinstance(value, bool):
173
+ raise InteractionDataError(f"{what} is not a boolean")
174
+ return value
175
+
176
+
177
+ def _display(value: object, what: str, limit: int) -> str:
178
+ """A value that is only shown: a string, cleaned, redacted and clipped to ``limit``."""
179
+ if not isinstance(value, str):
180
+ raise InteractionDataError(f"{what} is not a string")
181
+ return shown(value, limit)
182
+
183
+
184
+ # -- the request ---------------------------------------------------------------
185
+ @dataclass(frozen=True)
186
+ class QuestionOption:
187
+ """One option of a question: its raw ``label`` (what an answer carries,
188
+ refused rather than adjusted) and a ``description`` that is only shown."""
189
+
190
+ label: str
191
+ description: str = ""
192
+
193
+ def __post_init__(self) -> None:
194
+ if not exact_text(self.label, "an option label", MAX_OPTION_CHARS).strip():
195
+ raise InteractionDataError("an option label is blank")
196
+ description = _display(self.description, "an option description", MAX_DESCRIPTION_CHARS)
197
+ object.__setattr__(self, "description", description)
198
+
199
+ @property
200
+ def shown(self) -> str:
201
+ """The label as the operator sees it; never sent back."""
202
+ return shown(self.label, MAX_OPTION_CHARS)
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Question:
207
+ """One question: ``text`` and ``header`` are shown; ``options`` carry the
208
+ labels an answer picks from; ``multiple`` allows several, ``custom`` an
209
+ answer of the operator's own words."""
210
+
211
+ text: str
212
+ options: tuple[QuestionOption, ...]
213
+ header: str = ""
214
+ multiple: bool = False
215
+ custom: bool = False
216
+
217
+ def __post_init__(self) -> None:
218
+ if isinstance(self.text, str) and len(self.text) > MAX_QUESTION_CHARS:
219
+ raise InteractionDataError(f"a question is longer than {MAX_QUESTION_CHARS} characters")
220
+ text = _display(self.text, "a question", MAX_QUESTION_CHARS)
221
+ if not text:
222
+ raise InteractionDataError("a question has no text")
223
+ object.__setattr__(self, "text", text)
224
+ object.__setattr__(
225
+ self, "header", _display(self.header, "a question header", MAX_HEADER_CHARS)
226
+ )
227
+ options = tuple(self.options)
228
+ if not all(isinstance(option, QuestionOption) for option in options):
229
+ raise InteractionDataError("a question option is not a QuestionOption")
230
+ # Count, exactness and that no two labels are shown alike (a person
231
+ # could not tell which one they pick).
232
+ question_options([option.label for option in options], "a question")
233
+ object.__setattr__(self, "options", options)
234
+ _flag(self.multiple, "a question's multiple")
235
+ _flag(self.custom, "a question's custom")
236
+
237
+ def option(self, label: str) -> QuestionOption | None:
238
+ return next((option for option in self.options if option.label == label), None)
239
+
240
+
241
+ @dataclass(frozen=True)
242
+ class InteractionRequest:
243
+ """One request for a decision, bounded and redacted on construction (D5.1).
244
+
245
+ ``request_id`` is the controller's, stable within the logical
246
+ invocation; ``provider_request_id`` the provider's, opaque. ``agent`` is
247
+ ``None`` for the main agent, else the subagent's name. A question
248
+ carries ``questions``; a permission ``tool_name``, a ``summary`` of its
249
+ input (:func:`autoforge.sdk_policy.permission_summary`) and
250
+ ``input_sha256``, the digest the decision is bound to
251
+ (:func:`autoforge.sdk_policy.input_digest`). ``offers`` is what the
252
+ provider can carry for it (:meth:`Capabilities.offers`).
253
+ """
254
+
255
+ request_id: str
256
+ provider_request_id: str
257
+ kind: RequestKind
258
+ offers: frozenset[DecisionKind]
259
+ agent: str | None = None
260
+ questions: tuple[Question, ...] = ()
261
+ tool_name: str = ""
262
+ summary: str = ""
263
+ input_sha256: str = ""
264
+
265
+ def __post_init__(self) -> None:
266
+ _identity(self.request_id, "a request id")
267
+ _identity(self.provider_request_id, "a provider request id")
268
+ try:
269
+ kind = RequestKind(self.kind)
270
+ except ValueError:
271
+ raise InteractionDataError("a request has an unknown kind") from None
272
+ object.__setattr__(self, "kind", kind)
273
+ if self.agent is not None:
274
+ _identity(self.agent, "a subagent name")
275
+ try:
276
+ offers = frozenset(DecisionKind(offer) for offer in self.offers)
277
+ except (TypeError, ValueError):
278
+ raise InteractionDataError("a request offers an unknown decision") from None
279
+ questions = tuple(self.questions)
280
+ if kind is RequestKind.QUESTION:
281
+ if not 1 <= len(questions) <= MAX_QUESTIONS:
282
+ raise InteractionDataError(
283
+ f"a question request carries {len(questions)} questions, not 1 to "
284
+ f"{MAX_QUESTIONS}"
285
+ )
286
+ if not all(isinstance(question, Question) for question in questions):
287
+ raise InteractionDataError("a question is not a Question")
288
+ if len({question.text.casefold() for question in questions}) != len(questions):
289
+ raise InteractionDataError("a question request asks the same question twice")
290
+ if self.tool_name or self.summary or self.input_sha256:
291
+ raise InteractionDataError("a question request carries a tool call")
292
+ if DecisionKind.CUSTOM_ANSWER in offers and not any(q.custom for q in questions):
293
+ raise InteractionDataError(
294
+ "a question request offers a custom answer no question allows"
295
+ )
296
+ possible = QUESTION_DECISIONS
297
+ else:
298
+ if questions:
299
+ raise InteractionDataError("a permission request carries questions")
300
+ _identity(self.tool_name, "a tool name", MAX_TOOL_NAME_CHARS)
301
+ object.__setattr__(
302
+ self, "summary", _display(self.summary, "a permission summary", MAX_SUMMARY_CHARS)
303
+ )
304
+ if not isinstance(self.input_sha256, str) or not _SHA256.fullmatch(self.input_sha256):
305
+ raise InteractionDataError("a permission's input digest is not a SHA-256")
306
+ possible = PERMISSION_DECISIONS
307
+ if not offers <= possible:
308
+ raise InteractionDataError(f"a {kind} request offers a decision it cannot take")
309
+ object.__setattr__(self, "questions", questions)
310
+ object.__setattr__(self, "offers", offers)
311
+
312
+ @property
313
+ def what(self) -> str:
314
+ """What was asked, on one short line for a message: ``permission: Bash``."""
315
+ if self.kind is RequestKind.PERMISSION:
316
+ text = f"permission: {self.tool_name}"
317
+ else:
318
+ header = self.questions[0].header
319
+ text = f"question: {header}" if header else "question"
320
+ if len(self.questions) > 1:
321
+ text += f" (+{len(self.questions) - 1} more)"
322
+ if self.agent is not None:
323
+ text += f", from subagent {self.agent}"
324
+ return shown(text, 2 * MAX_SHOWN_CHARS)
325
+
326
+
327
+ # -- the decision --------------------------------------------------------------
328
+ @dataclass(frozen=True)
329
+ class QuestionResponse:
330
+ """The answer to one question: the labels picked, and the operator's own
331
+ words where the question allows them."""
332
+
333
+ choices: tuple[str, ...] = ()
334
+ custom_text: str | None = None
335
+
336
+ def __post_init__(self) -> None:
337
+ choices = tuple(self.choices)
338
+ for choice in choices:
339
+ exact_text(choice, "a chosen option", MAX_OPTION_CHARS)
340
+ if len(set(choices)) != len(choices):
341
+ raise InteractionDataError("an answer picks the same option twice")
342
+ object.__setattr__(self, "choices", choices)
343
+ if (
344
+ self.custom_text is not None
345
+ and not exact_text(self.custom_text, "a custom answer", MAX_ANSWER_CHARS).strip()
346
+ ):
347
+ raise InteractionDataError("a custom answer is blank")
348
+ if not choices and self.custom_text is None:
349
+ raise InteractionDataError("an answer picks nothing")
350
+
351
+
352
+ @dataclass(frozen=True)
353
+ class Answer:
354
+ """The answer to a question request: one response per question, in order."""
355
+
356
+ responses: tuple[QuestionResponse, ...]
357
+
358
+ def __post_init__(self) -> None:
359
+ responses = tuple(self.responses)
360
+ if not all(isinstance(response, QuestionResponse) for response in responses):
361
+ raise InteractionDataError("an answer's response is not a QuestionResponse")
362
+ object.__setattr__(self, "responses", responses)
363
+
364
+
365
+ class AllowScope(enum.StrEnum):
366
+ ONCE = "once"
367
+ SESSION = "session"
368
+
369
+
370
+ def _frozen(value: object) -> object:
371
+ """A parsed JSON ``value`` with every object read-only and every array a tuple."""
372
+ if isinstance(value, dict):
373
+ return MappingProxyType({key: _frozen(item) for key, item in value.items()})
374
+ if isinstance(value, list):
375
+ return tuple(_frozen(item) for item in value)
376
+ return value
377
+
378
+
379
+ def _thawed(value: object) -> object:
380
+ """``value`` with every mapping a new dict and every sequence but a string
381
+ a new list, sharing no container with it."""
382
+ if isinstance(value, Mapping):
383
+ return {key: _thawed(item) for key, item in value.items()}
384
+ if isinstance(value, (list, tuple)):
385
+ return [_thawed(item) for item in value]
386
+ return value
387
+
388
+
389
+ @dataclass(frozen=True)
390
+ class Allow:
391
+ """Approve the tool call, ``once`` or for the ``session``; with
392
+ ``updated_input``, the call runs on that input instead, sent back
393
+ exactly as validated (:func:`autoforge.sdk_policy.exact_input`).
394
+
395
+ ``updated_input`` is kept as a read-only snapshot of the whole input,
396
+ nested values included, taken through its canonical JSON when the
397
+ decision is built: the snapshot is what is validated, and an adapter
398
+ sends :meth:`input_to_send`, a copy of it. Changing the caller's object
399
+ afterwards changes nothing the decision carries.
400
+ """
401
+
402
+ scope: AllowScope = AllowScope.ONCE
403
+ updated_input: Mapping[str, object] | None = None
404
+
405
+ def __post_init__(self) -> None:
406
+ try:
407
+ object.__setattr__(self, "scope", AllowScope(self.scope))
408
+ except ValueError:
409
+ raise InteractionDataError("an approval has an unknown scope") from None
410
+ if self.updated_input is not None:
411
+ if not isinstance(self.updated_input, Mapping):
412
+ raise InteractionDataError("a modified input is not an object")
413
+ snapshot = json.loads(canonical_json(_thawed(self.updated_input)))
414
+ exact_input(snapshot, "a modified input")
415
+ object.__setattr__(self, "updated_input", _frozen(snapshot))
416
+
417
+ def input_to_send(self) -> dict[str, object] | None:
418
+ """The modified input as a new plain JSON object, equal to the snapshot
419
+ validated, or ``None``: what an adapter sends, and all it sends."""
420
+ if self.updated_input is None:
421
+ return None
422
+ snapshot = _thawed(self.updated_input)
423
+ assert isinstance(snapshot, dict)
424
+ return snapshot
425
+
426
+
427
+ @dataclass(frozen=True)
428
+ class Deny:
429
+ """Refuse the tool call; ``guidance`` tells the agent what to do instead."""
430
+
431
+ guidance: str | None = None
432
+
433
+ def __post_init__(self) -> None:
434
+ if (
435
+ self.guidance is not None
436
+ and not exact_text(self.guidance, "guidance", MAX_ANSWER_CHARS).strip()
437
+ ):
438
+ raise InteractionDataError("guidance is blank")
439
+
440
+
441
+ @dataclass(frozen=True)
442
+ class Cancel:
443
+ """Decline to decide: the provider is told no, with nothing more."""
444
+
445
+
446
+ InteractionDecision = Answer | Allow | Deny | Cancel
447
+
448
+
449
+ def required_offers(decision: InteractionDecision) -> frozenset[DecisionKind]:
450
+ """What a request must offer for ``decision`` to be delivered."""
451
+ if isinstance(decision, Answer):
452
+ needs = {DecisionKind.ANSWER}
453
+ if any(response.custom_text is not None for response in decision.responses):
454
+ needs.add(DecisionKind.CUSTOM_ANSWER)
455
+ if all(not response.choices for response in decision.responses):
456
+ needs.discard(DecisionKind.ANSWER)
457
+ return frozenset(needs)
458
+ if isinstance(decision, Allow):
459
+ needs = {
460
+ DecisionKind.ALLOW_ONCE
461
+ if decision.scope is AllowScope.ONCE
462
+ else DecisionKind.ALLOW_SESSION
463
+ }
464
+ if decision.updated_input is not None:
465
+ needs.add(DecisionKind.MODIFIED_INPUT)
466
+ return frozenset(needs)
467
+ if isinstance(decision, Deny):
468
+ return frozenset(
469
+ {DecisionKind.DENY if decision.guidance is None else DecisionKind.DENY_GUIDANCE}
470
+ )
471
+ return frozenset({DecisionKind.CANCEL})
472
+
473
+
474
+ def decision_problem(request: InteractionRequest, decision: object) -> str | None:
475
+ """Why ``decision`` cannot be delivered for ``request``, or ``None``.
476
+
477
+ The decision must be one the request offers (the capability check, D5.2)
478
+ and fit its content: an answer for a question, one response per
479
+ question, only the question's own options, one pick unless it allows
480
+ several, the operator's own words only where it allows them; an
481
+ approval or a denial for a permission. The reason names rules, never a
482
+ value.
483
+ """
484
+ if not isinstance(decision, (Answer, Allow, Deny, Cancel)):
485
+ return "it is not a decision"
486
+ if request.kind is RequestKind.QUESTION and isinstance(decision, (Allow, Deny)):
487
+ return "a question takes an answer or a cancellation"
488
+ if request.kind is RequestKind.PERMISSION and isinstance(decision, Answer):
489
+ return "a permission takes an approval, a denial or a cancellation"
490
+ missing = required_offers(decision) - request.offers
491
+ if missing:
492
+ return f"the provider cannot carry {', '.join(sorted(missing))} for this request"
493
+ if isinstance(decision, Answer):
494
+ if len(decision.responses) != len(request.questions):
495
+ return f"{len(decision.responses)} responses for {len(request.questions)} question(s)"
496
+ for n, (question, response) in enumerate(
497
+ zip(request.questions, decision.responses, strict=True), 1
498
+ ):
499
+ if any(question.option(choice) is None for choice in response.choices):
500
+ return f"question {n}: a choice is not one of its options"
501
+ if response.custom_text is not None and not question.custom:
502
+ return f"question {n}: it allows no answer of the operator's own"
503
+ picks = len(response.choices) + (response.custom_text is not None)
504
+ if picks > 1 and not question.multiple:
505
+ return f"question {n}: it takes one answer, got {picks}"
506
+ return None
507
+
508
+
509
+ # -- resolvers -----------------------------------------------------------------
510
+ class UnavailableReason(enum.StrEnum):
511
+ """Why a resolver has no decision: the three cases a live wait degrades on (D7.4)."""
512
+
513
+ NO_TERMINAL = "no_terminal"
514
+ END_OF_INPUT = "end_of_input"
515
+ EXPIRED = "expired"
516
+
517
+
518
+ _UNAVAILABLE_TEXT = {
519
+ UnavailableReason.NO_TERMINAL: "the controller's stdin is not a terminal",
520
+ UnavailableReason.END_OF_INPUT: "the operator's input ended",
521
+ UnavailableReason.EXPIRED: "no decision came in time",
522
+ }
523
+
524
+
525
+ @dataclass(frozen=True)
526
+ class Unavailable:
527
+ """No decision, and why."""
528
+
529
+ reason: UnavailableReason
530
+
531
+ def __post_init__(self) -> None:
532
+ try:
533
+ object.__setattr__(self, "reason", UnavailableReason(self.reason))
534
+ except ValueError:
535
+ raise InteractionDataError("a resolver gave an unknown reason") from None
536
+
537
+
538
+ class Resolver(Protocol):
539
+ """Who decides (D5.1): the engine supplies one per run.
540
+
541
+ ``resolve`` returns a decision for ``request``, or :class:`Unavailable`,
542
+ by ``deadline`` (``time.monotonic()``). It checks for a terminal before
543
+ it asks. ``KeyboardInterrupt`` propagates: Ctrl-C interrupts the step,
544
+ it is not a decision (D7.7).
545
+ """
546
+
547
+ def resolve(
548
+ self, request: InteractionRequest, deadline: float
549
+ ) -> InteractionDecision | Unavailable: ...
550
+
551
+
552
+ class Unattended:
553
+ """The resolver of a run with no one to ask (a library caller, a test, a
554
+ scheduler's worker): every request is unavailable, as for a controller
555
+ whose stdin is not a terminal."""
556
+
557
+ def resolve(self, request: InteractionRequest, deadline: float) -> Unavailable:
558
+ return Unavailable(UnavailableReason.NO_TERMINAL)
559
+
560
+
561
+ # -- capabilities --------------------------------------------------------------
562
+ @dataclass(frozen=True)
563
+ class Capabilities:
564
+ """What a provider can carry, declared by its adapter per provider and
565
+ pair (D5.1). A value is true only once every ADR 0007 §3 gate of its row
566
+ passed on that pair (D5.3); the default, all false, is what every
567
+ adapter declares until then.
568
+
569
+ ``live_questions`` and ``live_permissions``: a decision reaches the
570
+ provider while it waits. ``durable_replay``: the provider replays the
571
+ same structured request after a restart. ``continuation_recovery``: the
572
+ same session continues after an explicit message. ``interrupt``: a turn
573
+ can be interrupted. ``unsupported``: the decisions the provider cannot
574
+ carry even live (a session approval, a modified input, a custom answer).
575
+ """
576
+
577
+ live_questions: bool = False
578
+ live_permissions: bool = False
579
+ durable_replay: bool = False
580
+ continuation_recovery: bool = False
581
+ interrupt: bool = False
582
+ unsupported: frozenset[DecisionKind] = field(default_factory=frozenset)
583
+
584
+ def __post_init__(self) -> None:
585
+ for name in ("live_questions", "live_permissions", "durable_replay"):
586
+ _flag(getattr(self, name), f"a capability's {name}")
587
+ _flag(self.continuation_recovery, "a capability's continuation_recovery")
588
+ _flag(self.interrupt, "a capability's interrupt")
589
+ try:
590
+ unsupported = frozenset(DecisionKind(kind) for kind in self.unsupported)
591
+ except (TypeError, ValueError):
592
+ raise InteractionDataError("a capability names an unknown decision") from None
593
+ object.__setattr__(self, "unsupported", unsupported)
594
+
595
+ def live(self, kind: RequestKind) -> bool:
596
+ return self.live_questions if kind is RequestKind.QUESTION else self.live_permissions
597
+
598
+ def offers(self, kind: RequestKind, *, custom: bool = False) -> frozenset[DecisionKind]:
599
+ """The decisions a request of ``kind`` can be offered (D5.3): none
600
+ without the live capability, never an unsupported one, and a custom
601
+ answer only where a question allows one."""
602
+ if not self.live(kind):
603
+ return frozenset()
604
+ possible = QUESTION_DECISIONS if kind is RequestKind.QUESTION else PERMISSION_DECISIONS
605
+ if not custom:
606
+ possible = possible - {DecisionKind.CUSTOM_ANSWER}
607
+ return frozenset(possible - self.unsupported)
608
+
609
+ def can_pause(self, kind: RequestKind) -> bool:
610
+ """Whether a request of ``kind`` can be paused and its decision delivered
611
+ later: the provider carries that kind's decision at all, and the session
612
+ comes back by replay or by continuation. A pause also needs #126, which
613
+ the broker is told separately (``pause_available``)."""
614
+ return self.live(kind) and (self.durable_replay or self.continuation_recovery)
615
+
616
+
617
+ # What every adapter declares until a gate passes (D5.3).
618
+ NO_CAPABILITIES = Capabilities()
619
+
620
+
621
+ # -- directives and outcome -------------------------------------------------------
622
+ @dataclass(frozen=True)
623
+ class Deliver:
624
+ """Send ``decision`` to the provider now."""
625
+
626
+ decision: InteractionDecision
627
+
628
+
629
+ @dataclass(frozen=True)
630
+ class Pause:
631
+ """End the provider's turn its own way and stop durably (#126, D7.5)."""
632
+
633
+ request: InteractionRequest
634
+
635
+
636
+ @dataclass(frozen=True)
637
+ class Fail:
638
+ """Deny or reject the ask and fail the invocation (D7.6). ``reason`` is
639
+ bounded and names no value the provider sent; the adapter reports it as
640
+ its ``provider_failure``."""
641
+
642
+ reason: str
643
+
644
+
645
+ Directive = Deliver | Pause | Fail
646
+
647
+
648
+ @dataclass(frozen=True)
649
+ class InteractionOutcome:
650
+ """``AgentExecutionResult.interaction`` (D10.5): how an invocation through
651
+ which the agent could ask ended, and, on a pause, the pending request.
652
+ #126 persists it; nothing here does."""
653
+
654
+ lifecycle: Lifecycle
655
+ pending: InteractionRequest | None = None
656
+
657
+ def __post_init__(self) -> None:
658
+ object.__setattr__(self, "lifecycle", Lifecycle(self.lifecycle))
659
+ if (self.lifecycle is Lifecycle.PAUSED) != (self.pending is not None):
660
+ raise InteractionDataError("a pending request belongs to a paused outcome only")
661
+
662
+
663
+ # -- the session a pause binds ----------------------------------------------------
664
+ @dataclass(frozen=True)
665
+ class RemoteBinding:
666
+ """The workflow identity a REMOTE launch is bound to (D5.1): repository
667
+ (``owner/name``), issue, phase, attempt, review round, and the PR and
668
+ HEAD where the phase has them."""
669
+
670
+ repository: str
671
+ issue: int
672
+ phase: str
673
+ attempt: int
674
+ review_round: int
675
+ pr_url: str = ""
676
+ head_sha: str = ""
677
+
678
+ def __post_init__(self) -> None:
679
+ _identity(self.repository, "a repository")
680
+ _identity(self.phase, "a phase")
681
+ _counter(self.issue, "an issue number", 1)
682
+ _counter(self.attempt, "an attempt", 1)
683
+ _counter(self.review_round, "a review round", 0)
684
+ if self.pr_url:
685
+ _identity(self.pr_url, "a pull request URL")
686
+ if self.head_sha and not _GIT_SHA.fullmatch(self.head_sha):
687
+ raise InteractionDataError("a HEAD binding is not a full commit id")
688
+
689
+
690
+ @dataclass(frozen=True)
691
+ class LocalBinding:
692
+ """The workflow identity a LOCAL launch is bound to (D5.1): the state
693
+ root, the phase, and the launch index of the logical invocation's first
694
+ launch."""
695
+
696
+ state_root: str
697
+ phase: str
698
+ launch_index: int
699
+
700
+ def __post_init__(self) -> None:
701
+ _path(self.state_root, "a state root")
702
+ _identity(self.phase, "a phase")
703
+ _counter(self.launch_index, "a launch index", 1)
704
+
705
+
706
+ @dataclass(frozen=True)
707
+ class SessionIdentity:
708
+ """What a pending interaction binds (D5.1, D8.4): a continuation launches
709
+ only when the current launch would produce an equal identity.
710
+
711
+ The provider and its pair's versions (D4.1, D4.2), the policy digest
712
+ (#149's ``digest()``), the profile and the digest of its routing fields
713
+ and options, the working directory and the session root (D2.7), the
714
+ provider's session id, and the workflow binding.
715
+ """
716
+
717
+ provider: str
718
+ sdk_version: str
719
+ runtime_version: str
720
+ policy_digest: str
721
+ profile: str
722
+ profile_digest: str
723
+ cwd: str
724
+ session_root: str
725
+ session_id: str
726
+ workflow: RemoteBinding | LocalBinding
727
+
728
+ def __post_init__(self) -> None:
729
+ for name in ("provider", "sdk_version", "runtime_version", "profile", "session_id"):
730
+ _identity(getattr(self, name), f"a session's {name}")
731
+ for name in ("policy_digest", "profile_digest"):
732
+ value = getattr(self, name)
733
+ if not isinstance(value, str) or not _SHA256.fullmatch(value):
734
+ raise InteractionDataError(f"a session's {name} is not a SHA-256")
735
+ _path(self.cwd, "a session's cwd")
736
+ _path(self.session_root, "a session's root")
737
+ if not isinstance(self.workflow, (RemoteBinding, LocalBinding)):
738
+ raise InteractionDataError("a session's workflow binding is of no known mode")
739
+
740
+
741
+ def _counter(value: object, what: str, low: int) -> None:
742
+ if isinstance(value, bool) or not isinstance(value, int) or value < low:
743
+ raise InteractionDataError(f"{what} is not a whole number of at least {low}")
744
+
745
+
746
+ def _path(value: object, what: str) -> None:
747
+ if not _identity(value, what, MAX_PATH_CHARS).startswith("/"):
748
+ raise InteractionDataError(f"{what} is not an absolute path")
749
+
750
+
751
+ # -- the broker ---------------------------------------------------------------------
752
+ @dataclass
753
+ class _Entry:
754
+ """What the run log keeps of one handled request."""
755
+
756
+ request: InteractionRequest | None
757
+ directive: str = ""
758
+ decision: tuple[str, ...] = ()
759
+ reason: str = ""
760
+ unavailable: str = ""
761
+ waited_seconds: float = 0.0
762
+
763
+ def record(self) -> dict[str, object]:
764
+ entry: dict[str, object] = {"directive": self.directive}
765
+ if self.request is not None:
766
+ entry.update(
767
+ request_id=self.request.request_id,
768
+ kind=str(self.request.kind),
769
+ what=self.request.what,
770
+ offers=[str(offer) for offer in sorted(self.request.offers)],
771
+ )
772
+ if self.request.kind is RequestKind.PERMISSION:
773
+ entry["input_sha256"] = self.request.input_sha256
774
+ if self.decision:
775
+ entry["decision"] = list(self.decision)
776
+ if self.reason:
777
+ entry["reason"] = self.reason
778
+ if self.unavailable:
779
+ entry["unavailable"] = self.unavailable
780
+ if self.waited_seconds:
781
+ entry["waited_seconds"] = round(self.waited_seconds, 1)
782
+ return entry
783
+
784
+
785
+ class InteractionBroker:
786
+ """Decides what happens to each request of one launch (D5.2, D7.1-D7.4).
787
+
788
+ ``settings`` is the profile's validated ``interaction_policy`` and
789
+ limits, ``capabilities`` the adapter's declaration, ``resolver`` the
790
+ engine's. ``pause_available`` is whether a durable pending-interaction
791
+ record exists to pause into: #126's, so the engine passes ``False``
792
+ until it lands. ``clock`` is ``time.monotonic``, the clock of
793
+ ``invocation_due``.
794
+
795
+ Under ``fail`` every request fails the invocation. Under ``prompt`` the
796
+ resolver is asked live, until the earlier of ``max_wait_seconds`` after
797
+ the ask and ``invocation_due``; no terminal, the end of the operator's
798
+ input or the wait's own expiry pause where the request's kind can be
799
+ paused (:meth:`Capabilities.can_pause`, and ``pause_available``) and
800
+ fail otherwise, and nothing else degrades (D7.4). A wait the
801
+ invocation's limit ended is a failure, never a pause: no time is left
802
+ for the teardown a pause needs (D7.3). Under ``pause`` a request pauses
803
+ where it can and fails otherwise. ``max_interactions`` bounds the
804
+ requests handled, answered or not; one more fails (D7.3).
805
+
806
+ A :class:`Fail` or a :class:`Pause` ends the invocation's interactions:
807
+ every later request fails without reaching the resolver. The resolver
808
+ runs on the caller's thread, under the repository lock the whole
809
+ command holds (D7.2), and one request at a time.
810
+ """
811
+
812
+ def __init__(
813
+ self,
814
+ settings: InteractionSettings,
815
+ capabilities: Capabilities,
816
+ resolver: Resolver,
817
+ *,
818
+ pause_available: bool = False,
819
+ clock: Callable[[], float] = time.monotonic,
820
+ ) -> None:
821
+ self._policy = InteractionPolicy(settings.policy)
822
+ self._max_wait = settings.max_wait_seconds
823
+ self._max_interactions = settings.max_interactions
824
+ self._capabilities = capabilities
825
+ self._resolver = resolver
826
+ self._pause_available = pause_available
827
+ self._clock = clock
828
+ self._lock = threading.RLock()
829
+ self._issued = 0
830
+ # The requests minted and not yet handled, by id: a request is
831
+ # handled once, and only as it was minted.
832
+ self._open: dict[str, InteractionRequest] = {}
833
+ self._handled = 0
834
+ # What the run log keeps: the requests up to the aggregate limit and
835
+ # the first one past it. A provider that keeps asking after that is
836
+ # failed all the same and only counted, so the record stays bounded.
837
+ self._entries: list[_Entry] = []
838
+ self._ended: Pause | Fail | None = None
839
+
840
+ @property
841
+ def handled(self) -> int:
842
+ """How many requests :meth:`handle` took, answered or not."""
843
+ return self._handled
844
+
845
+ @property
846
+ def ended(self) -> Pause | Fail | None:
847
+ """The directive that ended the invocation's interactions, if any."""
848
+ return self._ended
849
+
850
+ @property
851
+ def failure(self) -> str | None:
852
+ """The reason of the :class:`Fail` that ended them, if one did."""
853
+ return self._ended.reason if isinstance(self._ended, Fail) else None
854
+
855
+ def outcome(self) -> InteractionOutcome:
856
+ """The lifecycle the requests imply: failed, paused (with the pending
857
+ request) or completed. An adapter whose turn was interrupted reports
858
+ :attr:`Lifecycle.INTERRUPTED` instead of a completed one."""
859
+ if isinstance(self._ended, Fail):
860
+ return InteractionOutcome(Lifecycle.FAILED)
861
+ if isinstance(self._ended, Pause):
862
+ return InteractionOutcome(Lifecycle.PAUSED, pending=self._ended.request)
863
+ return InteractionOutcome(Lifecycle.COMPLETED)
864
+
865
+ # -- minting ------------------------------------------------------------
866
+ def question(
867
+ self,
868
+ provider_request_id: str,
869
+ questions: Sequence[Question],
870
+ *,
871
+ agent: str | None = None,
872
+ ) -> InteractionRequest:
873
+ """A question request with the next controller id and the offers the
874
+ capabilities allow. :class:`InteractionDataError` for data refused."""
875
+ questions = tuple(questions)
876
+ offers = self._capabilities.offers(
877
+ RequestKind.QUESTION,
878
+ custom=any(isinstance(q, Question) and q.custom for q in questions),
879
+ )
880
+ return self._mint(
881
+ lambda request_id: InteractionRequest(
882
+ request_id=request_id,
883
+ provider_request_id=provider_request_id,
884
+ kind=RequestKind.QUESTION,
885
+ offers=offers,
886
+ agent=agent,
887
+ questions=questions,
888
+ )
889
+ )
890
+
891
+ def permission(
892
+ self,
893
+ provider_request_id: str,
894
+ tool_name: str,
895
+ summary: str,
896
+ input_sha256: str,
897
+ *,
898
+ agent: str | None = None,
899
+ ) -> InteractionRequest:
900
+ """A permission request with the next controller id and the offers
901
+ the capabilities allow. :class:`InteractionDataError` for data refused."""
902
+ offers = self._capabilities.offers(RequestKind.PERMISSION)
903
+ return self._mint(
904
+ lambda request_id: InteractionRequest(
905
+ request_id=request_id,
906
+ provider_request_id=provider_request_id,
907
+ kind=RequestKind.PERMISSION,
908
+ offers=offers,
909
+ agent=agent,
910
+ tool_name=tool_name,
911
+ summary=summary,
912
+ input_sha256=input_sha256,
913
+ )
914
+ )
915
+
916
+ def _mint(self, build: Callable[[str], InteractionRequest]) -> InteractionRequest:
917
+ with self._lock:
918
+ request = build(str(self._issued + 1))
919
+ self._issued += 1
920
+ self._open[request.request_id] = request
921
+ return request
922
+
923
+ # -- deciding -------------------------------------------------------------
924
+ def handle(self, request: InteractionRequest, invocation_due: float) -> Directive:
925
+ """The directive for ``request``. ``invocation_due`` is when the
926
+ invocation's limit falls due (``DuplexChild.limit_due()``)."""
927
+ with self._lock:
928
+ self._handled += 1
929
+ entry = _Entry(request if isinstance(request, InteractionRequest) else None)
930
+ if len(self._entries) <= self._max_interactions:
931
+ self._entries.append(entry)
932
+ directive = self._decide(request, invocation_due, entry)
933
+ if isinstance(directive, Deliver):
934
+ entry.directive = "deliver"
935
+ entry.decision = tuple(
936
+ str(kind) for kind in sorted(required_offers(directive.decision))
937
+ )
938
+ elif isinstance(directive, Pause):
939
+ entry.directive = "pause"
940
+ self._ended = directive
941
+ else:
942
+ directive = Fail(shown(directive.reason, MAX_REASON_CHARS))
943
+ entry.directive = "fail"
944
+ entry.reason = directive.reason
945
+ # The first failure ends the invocation, also after a pause
946
+ # whose provider asked again instead of stopping.
947
+ if not isinstance(self._ended, Fail):
948
+ self._ended = directive
949
+ return directive
950
+
951
+ def _decide(
952
+ self, request: InteractionRequest, invocation_due: float, entry: _Entry
953
+ ) -> Directive:
954
+ if not isinstance(request, InteractionRequest):
955
+ return Fail("the adapter handed the broker something that is not a request")
956
+ asked = f"the agent asked for a decision ({request.what})"
957
+ if self._open.pop(request.request_id, None) != request:
958
+ return Fail(
959
+ f"{asked} as a request this broker did not issue, changed or already handled"
960
+ )
961
+ if self._ended is not None:
962
+ return Fail(f"{asked} after an earlier request ended this invocation's interactions")
963
+ if self._handled > self._max_interactions:
964
+ return Fail(
965
+ f"interaction limit reached: {asked} past the {self._max_interactions} an "
966
+ "invocation may make (max_interactions)"
967
+ )
968
+ if self._policy is InteractionPolicy.FAIL:
969
+ return Fail(f"{asked}; interaction_policy is fail")
970
+ if self._policy is InteractionPolicy.PAUSE:
971
+ if self._pausable(request):
972
+ return Pause(request)
973
+ return Fail(f"{asked}; interaction_policy is pause, but no pause is available for it")
974
+ if not request.offers:
975
+ return Fail(
976
+ f"{asked}; the provider cannot carry a live {request.kind} decision, so "
977
+ "interaction_policy prompt has none to offer"
978
+ )
979
+ now = self._clock()
980
+ wait_due = now + self._max_wait
981
+ deadline = min(wait_due, invocation_due)
982
+ if deadline <= now:
983
+ return Fail(f"{asked}; no time is left before the invocation's limit")
984
+ try:
985
+ outcome = self._resolver.resolve(request, deadline)
986
+ except Exception as exc: # KeyboardInterrupt is not a decision (D7.7)
987
+ return Fail(f"{asked}; asking for it failed ({type(exc).__name__})")
988
+ finally:
989
+ entry.waited_seconds = max(0.0, self._clock() - now)
990
+ if not isinstance(outcome, Unavailable) and self._clock() > deadline:
991
+ # A decision that came after the deadline is not taken: the wait's
992
+ # limit is the broker's, whatever the resolver did.
993
+ outcome = Unavailable(UnavailableReason.EXPIRED)
994
+ if isinstance(outcome, Unavailable):
995
+ reason = UnavailableReason(outcome.reason)
996
+ entry.unavailable = str(reason)
997
+ if reason is UnavailableReason.EXPIRED and invocation_due < wait_due:
998
+ return Fail(f"{asked}; the invocation's limit fell due during the wait")
999
+ if self._pausable(request):
1000
+ return Pause(request)
1001
+ text = _UNAVAILABLE_TEXT[reason]
1002
+ if reason is UnavailableReason.EXPIRED:
1003
+ text = f"no decision came within max_wait_seconds ({self._max_wait}s)"
1004
+ return Fail(f"{asked}; {text}, and no pause is available for it")
1005
+ problem = decision_problem(request, outcome)
1006
+ if problem is not None:
1007
+ return Fail(f"{asked}; the decision was not delivered: {problem}")
1008
+ return Deliver(outcome)
1009
+
1010
+ def _pausable(self, request: InteractionRequest) -> bool:
1011
+ return self._pause_available and self._capabilities.can_pause(request.kind)
1012
+
1013
+ def record(self) -> dict[str, object]:
1014
+ """A bounded, redacted account of the launch's requests for the run
1015
+ log: the policy, its limits, how many requests were handled and, per
1016
+ request up to ``max_interactions`` and the first one past it, its
1017
+ id, kind, what was asked, the offers, the directive, the kinds of
1018
+ decision delivered, a fail reason and how long the wait took. Never
1019
+ an answer's text, guidance or a modified input."""
1020
+ with self._lock:
1021
+ return {
1022
+ "policy": str(self._policy),
1023
+ "max_wait_seconds": self._max_wait,
1024
+ "max_interactions": self._max_interactions,
1025
+ "handled": self._handled,
1026
+ "requests": [entry.record() for entry in self._entries],
1027
+ }