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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/interaction.py
ADDED
|
@@ -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
|
+
}
|