fullstop 0.2.1__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.
- fullstop/__init__.py +8 -0
- fullstop/__main__.py +8 -0
- fullstop/activity.py +241 -0
- fullstop/agent.py +463 -0
- fullstop/cli.py +262 -0
- fullstop/gate.py +230 -0
- fullstop/manifest.py +305 -0
- fullstop/policy.py +184 -0
- fullstop/protocol.py +212 -0
- fullstop/provider.py +188 -0
- fullstop/redact.py +52 -0
- fullstop/resolve.py +98 -0
- fullstop/sandbox.py +66 -0
- fullstop/state.py +92 -0
- fullstop/tools/__init__.py +41 -0
- fullstop/tools/base.py +65 -0
- fullstop/tools/browser.py +25 -0
- fullstop/tools/file.py +178 -0
- fullstop/tools/note.py +59 -0
- fullstop/tools/shell.py +82 -0
- fullstop/tools/web.py +135 -0
- fullstop/types.py +63 -0
- fullstop/ui_approver.py +258 -0
- fullstop/ui_assets.py +955 -0
- fullstop/ui_model.py +187 -0
- fullstop/ui_server.py +310 -0
- fullstop/ui_strings.py +327 -0
- fullstop/ui_wizard.py +302 -0
- fullstop-0.2.1.dist-info/METADATA +445 -0
- fullstop-0.2.1.dist-info/RECORD +33 -0
- fullstop-0.2.1.dist-info/WHEEL +5 -0
- fullstop-0.2.1.dist-info/licenses/LICENSE +21 -0
- fullstop-0.2.1.dist-info/top_level.txt +1 -0
fullstop/agent.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""The agent loop: plan -> act -> observe, with guards, checkpoints, resume.
|
|
2
|
+
|
|
3
|
+
Denial codes (normative):
|
|
4
|
+
- decide() hard DENY -> error_code "denied_by_policy", error=reason
|
|
5
|
+
- operator denial -> error_code "denied_by_operator",
|
|
6
|
+
error="denied by operator"
|
|
7
|
+
- no approver configured -> error_code "denied_by_operator",
|
|
8
|
+
error="no approver configured"
|
|
9
|
+
The denied call is NEVER executed; the model sees it in the observation.
|
|
10
|
+
|
|
11
|
+
v0.1.1 (FIXLIST items 1, 2, 3, 6, 10, 15):
|
|
12
|
+
- crash-resume: mid-loop checkpoints carry the live script cursor, and on
|
|
13
|
+
resume a replay ledger is built from the hash-chained log: tool_call
|
|
14
|
+
events after the last completed boundary whose outcome shows they ran
|
|
15
|
+
(or were approved) are "may have executed" — the replayed call gets a
|
|
16
|
+
synthesized observation, is NEVER re-executed and NEVER re-prompted;
|
|
17
|
+
a call whose approval was still pending at crash time is re-prompted
|
|
18
|
+
through the faithful full-payload display (persist requests, never
|
|
19
|
+
verdicts);
|
|
20
|
+
- a ``config_loaded`` event with the manifest/policy hashes is appended at
|
|
21
|
+
every start and resume, anchoring WHICH rules the run was judged by;
|
|
22
|
+
- provider context errors and the ``max_context_chars`` near-limit estimate
|
|
23
|
+
stop the run in ``stopped_context`` instead of a generic failure loop;
|
|
24
|
+
- an approval demand no human can hear (no approver, or a non-tty
|
|
25
|
+
interactive approver) pauses the run in ``stopped_approval`` instead of
|
|
26
|
+
spinning to max_steps;
|
|
27
|
+
- approver prompts render the complete request (protocol.render_request);
|
|
28
|
+
- observations delimit embedded tool output with a per-session random
|
|
29
|
+
token so model-visible content cannot forge harness framing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import secrets
|
|
35
|
+
import sys
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Callable, Protocol, TextIO
|
|
39
|
+
|
|
40
|
+
from .activity import ActivityLog
|
|
41
|
+
from .gate import Gate
|
|
42
|
+
from .manifest import Manifest
|
|
43
|
+
from .policy import Policy
|
|
44
|
+
from .protocol import (format_observation, parse_tool_calls,
|
|
45
|
+
build_system_prompt, render_request)
|
|
46
|
+
from .provider import ModelProvider, ProviderError, ScriptedModelExhausted
|
|
47
|
+
from .redact import Redactor
|
|
48
|
+
from .state import RunState, activity_path, checkpoint_path, save_checkpoint
|
|
49
|
+
from .tools import build_registry
|
|
50
|
+
from .tools.base import ToolRegistry
|
|
51
|
+
from .types import Action, GateDecision, ToolCall, ToolResult
|
|
52
|
+
|
|
53
|
+
SYNTHESIZED_MAY_HAVE_EXECUTED = (
|
|
54
|
+
"this call may have executed before the crash; it was not re-executed")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _call_key(name, args) -> str:
|
|
58
|
+
"""Canonical identity of a request — identical to the gate token binding
|
|
59
|
+
(minus the per-process secret), and reconstructible from log events."""
|
|
60
|
+
return name + json.dumps(args, sort_keys=True, separators=(",", ":"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _args_sha256(args) -> str:
|
|
64
|
+
"""Stable digest of the RAW args (v0.1.2, FIXLIST2 item 1).
|
|
65
|
+
|
|
66
|
+
The replay ledger is rebuilt from LOG events, but the log writes args
|
|
67
|
+
through the scrub/truncate pipeline first (activity._scrub: redaction,
|
|
68
|
+
then log_truncate_chars), so a key derived from the logged args cannot
|
|
69
|
+
match a live call whose args are large or contain a credential — the
|
|
70
|
+
already-executed call was re-prompted and re-executed after a crash.
|
|
71
|
+
The digest of the raw args is written ALONGSIDE the (scrubbed) args in
|
|
72
|
+
every tool_call event; it survives scrub/truncate (hex never carries a
|
|
73
|
+
secret verbatim) and never reveals the args (preimage resistance — the
|
|
74
|
+
sanctioned fix shape named by FIXLIST2 item 1)."""
|
|
75
|
+
canonical = json.dumps(args, sort_keys=True, separators=(",", ":"))
|
|
76
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _replay_keys(name, args) -> tuple[str, ...]:
|
|
80
|
+
"""Every ledger key a live call may match: the raw-args digest first,
|
|
81
|
+
then the legacy logged-args key (v0.1.1 logs have no digest field, and
|
|
82
|
+
for un-truncated, un-redacted args it still matches)."""
|
|
83
|
+
return (_args_sha256(args), _call_key(name, args))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_context_error(text: str) -> bool:
|
|
87
|
+
low = text.lower()
|
|
88
|
+
return ("context length" in low
|
|
89
|
+
or "context_length_exceeded" in low
|
|
90
|
+
or ("context" in low and ("too long" in low or "exceeded" in low)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Approver(Protocol):
|
|
94
|
+
def approve(self, call: ToolCall, decision: GateDecision) -> bool: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InteractiveApprover:
|
|
98
|
+
"""Prompts a human on a tty. Never blocks on a closed or non-tty stdin.
|
|
99
|
+
|
|
100
|
+
Shows the COMPLETE request (protocol.render_request) — the human layer
|
|
101
|
+
decides on what would actually execute, not a 40-char rendering. When no
|
|
102
|
+
interactive terminal is available the denial is marked ``unattended`` so
|
|
103
|
+
the loop can pause cleanly instead of spinning (FIXLIST item 10)."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
106
|
+
self._stream = stream if stream is not None else sys.stdin
|
|
107
|
+
self.unattended = False
|
|
108
|
+
|
|
109
|
+
def approve(self, call: ToolCall, decision: GateDecision) -> bool:
|
|
110
|
+
prompt = f"APPROVE? {render_request(call)} [y/N]: "
|
|
111
|
+
try:
|
|
112
|
+
if not self._stream.isatty():
|
|
113
|
+
self.unattended = True
|
|
114
|
+
print(f"{prompt}n (no interactive terminal)", flush=True)
|
|
115
|
+
return False
|
|
116
|
+
answer = input(prompt)
|
|
117
|
+
except EOFError:
|
|
118
|
+
return False
|
|
119
|
+
return answer.strip().lower() == "y"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ScriptedApprover:
|
|
123
|
+
"""Test approver: pops canned responses and records every prompt — the
|
|
124
|
+
faithful render, exactly what a human would see."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, responses: list[bool]) -> None:
|
|
127
|
+
self._responses = list(responses)
|
|
128
|
+
self.prompts: list[str] = []
|
|
129
|
+
|
|
130
|
+
def approve(self, call: ToolCall, decision: GateDecision) -> bool:
|
|
131
|
+
self.prompts.append(render_request(call))
|
|
132
|
+
if not self._responses:
|
|
133
|
+
return False
|
|
134
|
+
return self._responses.pop(0)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AgentLoop:
|
|
138
|
+
def __init__(self, manifest: Manifest, policy: Policy, provider: ModelProvider,
|
|
139
|
+
log: ActivityLog, redactor: Redactor,
|
|
140
|
+
approver: Approver | None = None,
|
|
141
|
+
fetch: Callable[[str, float], tuple[int, str, bytes]] | None = None,
|
|
142
|
+
config_hashes: dict[str, str] | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
self.manifest = manifest
|
|
145
|
+
self.policy = policy
|
|
146
|
+
self.provider = provider
|
|
147
|
+
self.log = log
|
|
148
|
+
self.redactor = redactor
|
|
149
|
+
self.approver = approver
|
|
150
|
+
self.home = Path(manifest.identity.home)
|
|
151
|
+
self.gate = Gate(policy, self.home, log)
|
|
152
|
+
self._config_hashes = config_hashes
|
|
153
|
+
# Per-session random observation delimiter token (FIXLIST item 15):
|
|
154
|
+
# embedded tool output cannot guess or counterfeit the frame.
|
|
155
|
+
self._obs_token = secrets.token_hex(8)
|
|
156
|
+
# Replay ledger (FIXLIST item 1): canonical request key -> True for
|
|
157
|
+
# calls that already ran (or were approved) after the last completed
|
|
158
|
+
# boundary of a crashed run.
|
|
159
|
+
self._replay_skips: dict[str, bool] = {}
|
|
160
|
+
self._replay_active = False
|
|
161
|
+
scrub_env = tuple(manifest.credential_env_vars)
|
|
162
|
+
if manifest.provider.api_key_env:
|
|
163
|
+
scrub_env += (manifest.provider.api_key_env,)
|
|
164
|
+
self.registry: ToolRegistry = build_registry(
|
|
165
|
+
self.home, policy, redactor, self.gate.verify,
|
|
166
|
+
fetch=fetch, scrub_env=scrub_env)
|
|
167
|
+
self._system_prompt = build_system_prompt(
|
|
168
|
+
manifest, policy, self.registry.schemas())
|
|
169
|
+
|
|
170
|
+
def new_state(self) -> RunState:
|
|
171
|
+
state = RunState(
|
|
172
|
+
run_id=_new_run_id(),
|
|
173
|
+
manifest_path="",
|
|
174
|
+
goal=self.manifest.goal,
|
|
175
|
+
status="running",
|
|
176
|
+
messages=[
|
|
177
|
+
{"role": "system", "content": self._system_prompt},
|
|
178
|
+
# Some OpenAI-compatible endpoints (Z.ai: HTTP 400 code
|
|
179
|
+
# 1214) reject a system-only message list, so the goal
|
|
180
|
+
# opens as a user turn. Reads manifest.goal after the
|
|
181
|
+
# CLI's --goal override has replaced it.
|
|
182
|
+
{"role": "user", "content": f"Goal: {self.manifest.goal}"},
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
# Non-persisted marker: a brand-new state logs run_start even when the
|
|
186
|
+
# caller passes it explicitly (the common `loop.run(loop.new_state())`
|
|
187
|
+
# pattern, e.g. after setting manifest_path / a --goal override).
|
|
188
|
+
state._fresh = True # noqa: SLF001
|
|
189
|
+
return state
|
|
190
|
+
|
|
191
|
+
# -- config anchoring ------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _log_config_loaded(self) -> None:
|
|
194
|
+
hashes = self._config_hashes or self._fallback_config_hashes()
|
|
195
|
+
self.log.append("config_loaded", manifest_sha256=hashes["manifest_sha256"],
|
|
196
|
+
policy_sha256=hashes["policy_sha256"])
|
|
197
|
+
|
|
198
|
+
def _fallback_config_hashes(self) -> dict[str, str]:
|
|
199
|
+
"""Deterministic hashes when the caller (tests, embeddings) did not
|
|
200
|
+
supply the on-disk config bytes."""
|
|
201
|
+
m = self.manifest
|
|
202
|
+
manifest_repr = json.dumps({
|
|
203
|
+
"identity": {"name": m.identity.name, "role": m.identity.role,
|
|
204
|
+
"home": str(m.identity.home)},
|
|
205
|
+
"goal": m.goal,
|
|
206
|
+
"limits": {"max_steps": m.limits.max_steps,
|
|
207
|
+
"max_cost_usd": m.limits.max_cost_usd,
|
|
208
|
+
"max_tool_calls_per_step": m.limits.max_tool_calls_per_step,
|
|
209
|
+
"max_context_chars": m.limits.max_context_chars},
|
|
210
|
+
"log_truncate_chars": m.log_truncate_chars,
|
|
211
|
+
"provider": {"type": m.provider.type},
|
|
212
|
+
}, sort_keys=True, separators=(",", ":"))
|
|
213
|
+
p = self.policy
|
|
214
|
+
policy_repr = json.dumps({
|
|
215
|
+
"protected_paths": list(p.protected_paths),
|
|
216
|
+
"write_preapproved": list(p.write_preapproved),
|
|
217
|
+
"shell": {"allow": [e if isinstance(e, str) else dict(e)
|
|
218
|
+
for e in p.shell.allow],
|
|
219
|
+
"deny": list(p.shell.deny),
|
|
220
|
+
"timeout_s": p.shell.timeout_s},
|
|
221
|
+
"web": {"allow_domains": list(p.web.allow_domains),
|
|
222
|
+
"deny_domains": list(p.web.deny_domains),
|
|
223
|
+
"timeout_s": p.web.timeout_s,
|
|
224
|
+
"max_bytes": p.web.max_bytes},
|
|
225
|
+
}, sort_keys=True, separators=(",", ":"))
|
|
226
|
+
return {
|
|
227
|
+
"manifest_sha256": hashlib.sha256(manifest_repr.encode()).hexdigest(),
|
|
228
|
+
"policy_sha256": hashlib.sha256(policy_repr.encode()).hexdigest(),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# -- replay ledger ------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _build_replay_ledger(self) -> dict[str, bool]:
|
|
234
|
+
"""tool_call events AFTER the last completed boundary (the last
|
|
235
|
+
``checkpoint`` event) whose outcome proves execution or approval.
|
|
236
|
+
A tool_result, an approval_response(approved) or a gate ALLOW with
|
|
237
|
+
no later pending approval counts; a lone approval_request does not
|
|
238
|
+
(persist requests, never verdicts — the pending call re-prompts).
|
|
239
|
+
|
|
240
|
+
Keys (v0.1.2, FIXLIST2 item 1): each entry is keyed by BOTH the
|
|
241
|
+
``args_sha256`` digest of the RAW args (written alongside the
|
|
242
|
+
scrubbed args in the tool_call event — immune to the scrub/truncate
|
|
243
|
+
the log applies) and, for v0.1.1 logs without the digest, the
|
|
244
|
+
legacy key over the LOGGED args."""
|
|
245
|
+
try:
|
|
246
|
+
entries = list(self.log.entries())
|
|
247
|
+
except Exception:
|
|
248
|
+
return {}
|
|
249
|
+
boundary = 0
|
|
250
|
+
for i, entry in enumerate(entries):
|
|
251
|
+
if entry.get("event") == "checkpoint":
|
|
252
|
+
boundary = i + 1
|
|
253
|
+
ledger: dict[str, bool] = {}
|
|
254
|
+
current_keys: tuple[str, ...] | None = None
|
|
255
|
+
current_tool: str | None = None
|
|
256
|
+
for entry in entries[boundary:]:
|
|
257
|
+
event = entry.get("event")
|
|
258
|
+
tool = entry.get("tool")
|
|
259
|
+
if event == "tool_call":
|
|
260
|
+
digest = entry.get("args_sha256")
|
|
261
|
+
keys = []
|
|
262
|
+
if isinstance(digest, str) and digest:
|
|
263
|
+
keys.append(digest)
|
|
264
|
+
keys.append(_call_key(tool, entry.get("args", {})))
|
|
265
|
+
current_keys = tuple(keys)
|
|
266
|
+
current_tool = tool
|
|
267
|
+
for key in keys:
|
|
268
|
+
ledger.setdefault(key, False)
|
|
269
|
+
elif current_keys is not None and tool == current_tool:
|
|
270
|
+
if event == "gate_decision" and entry.get("action") == "allow":
|
|
271
|
+
for key in current_keys:
|
|
272
|
+
ledger[key] = True
|
|
273
|
+
elif event == "approval_request":
|
|
274
|
+
for key in current_keys:
|
|
275
|
+
ledger[key] = False
|
|
276
|
+
elif event == "approval_response":
|
|
277
|
+
ran = bool(entry.get("approved"))
|
|
278
|
+
for key in current_keys:
|
|
279
|
+
ledger[key] = ran
|
|
280
|
+
elif event == "tool_result":
|
|
281
|
+
for key in current_keys:
|
|
282
|
+
ledger[key] = True
|
|
283
|
+
return {key: True for key, ran in ledger.items() if ran}
|
|
284
|
+
|
|
285
|
+
# -- the loop --------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def run(self, state: RunState | None = None) -> RunState:
|
|
288
|
+
if state is None:
|
|
289
|
+
state = self.new_state()
|
|
290
|
+
if getattr(state, "_fresh", False):
|
|
291
|
+
state._fresh = False # noqa: SLF001
|
|
292
|
+
self.log.append("run_start", run_id=state.run_id, goal=state.goal)
|
|
293
|
+
self._log_config_loaded()
|
|
294
|
+
else:
|
|
295
|
+
# Guard-stops are resumable (the operator may have raised the
|
|
296
|
+
# limits); terminal outcomes (completed/failed/script_exhausted)
|
|
297
|
+
# and crash-mid-run checkpoints ("running") pass through as-is.
|
|
298
|
+
if state.status.startswith("stopped_"):
|
|
299
|
+
state.status = "running"
|
|
300
|
+
# Resume seed-guard (final review): a checkpoint written before
|
|
301
|
+
# the fix_20 user-seed can hold a [system]-only message list,
|
|
302
|
+
# which Z.ai-class endpoints reject (HTTP 400 code 1214). Seed
|
|
303
|
+
# the same user turn a fresh run gets — from state.goal, the
|
|
304
|
+
# PERSISTED checkpoint goal, never manifest.goal
|
|
305
|
+
# (--allow-config-change can swap the manifest under a resume).
|
|
306
|
+
# Idempotent: post-fix states always carry the user turn.
|
|
307
|
+
if (len(state.messages) == 1
|
|
308
|
+
and isinstance(state.messages[0], dict)
|
|
309
|
+
and state.messages[0].get("role") == "system"):
|
|
310
|
+
state.messages.append(
|
|
311
|
+
{"role": "user", "content": f"Goal: {state.goal}"})
|
|
312
|
+
self.log.append("resume", run_id=state.run_id,
|
|
313
|
+
steps_done=state.steps_done)
|
|
314
|
+
self._log_config_loaded()
|
|
315
|
+
# Crash-resume double-execution guard (FIXLIST item 1): calls
|
|
316
|
+
# that already ran (or were approved) after the last completed
|
|
317
|
+
# boundary are skipped with a synthesized observation.
|
|
318
|
+
self._replay_skips = self._build_replay_ledger()
|
|
319
|
+
self._replay_active = bool(self._replay_skips)
|
|
320
|
+
try:
|
|
321
|
+
while state.status == "running":
|
|
322
|
+
# Guards FIRST.
|
|
323
|
+
if state.steps_done >= self.manifest.limits.max_steps:
|
|
324
|
+
state.status = "stopped_max_steps"
|
|
325
|
+
self.log.append("guard_trip", kind="max_steps",
|
|
326
|
+
limit=self.manifest.limits.max_steps,
|
|
327
|
+
value=state.steps_done)
|
|
328
|
+
break
|
|
329
|
+
max_cost = self.manifest.limits.max_cost_usd
|
|
330
|
+
if max_cost is not None and state.cost_usd >= max_cost:
|
|
331
|
+
state.status = "stopped_max_cost"
|
|
332
|
+
self.log.append("guard_trip", kind="max_cost_usd",
|
|
333
|
+
limit=max_cost, value=state.cost_usd)
|
|
334
|
+
break
|
|
335
|
+
max_ctx = self.manifest.limits.max_context_chars
|
|
336
|
+
if max_ctx is not None:
|
|
337
|
+
estimate = sum(
|
|
338
|
+
len(str(m.get("content", "")))
|
|
339
|
+
for m in state.messages if isinstance(m, dict))
|
|
340
|
+
if estimate >= max_ctx:
|
|
341
|
+
state.status = "stopped_context"
|
|
342
|
+
self.log.append("guard_trip", kind="context_chars",
|
|
343
|
+
limit=max_ctx, value=estimate)
|
|
344
|
+
break
|
|
345
|
+
try:
|
|
346
|
+
self.step(state)
|
|
347
|
+
except ProviderError as e:
|
|
348
|
+
if _is_context_error(str(e)):
|
|
349
|
+
state.status = "stopped_context"
|
|
350
|
+
state.failure = self.redactor.scrub(str(e))
|
|
351
|
+
else:
|
|
352
|
+
state.status = "failed"
|
|
353
|
+
state.failure = self.redactor.scrub(str(e))
|
|
354
|
+
break
|
|
355
|
+
except ScriptedModelExhausted:
|
|
356
|
+
state.status = "script_exhausted"
|
|
357
|
+
break
|
|
358
|
+
# The mid-loop checkpoint carries the LIVE script cursor so
|
|
359
|
+
# a crash-resume never replays completed turns (their calls
|
|
360
|
+
# already ran — replaying them re-executes).
|
|
361
|
+
if hasattr(self.provider, "cursor"):
|
|
362
|
+
state.script_cursor = self.provider.cursor
|
|
363
|
+
save_checkpoint(state, checkpoint_path(self.home), self.redactor)
|
|
364
|
+
self.log.append("checkpoint", n=state.steps_done,
|
|
365
|
+
status=state.status)
|
|
366
|
+
# The replayed region is closed once its step is durable.
|
|
367
|
+
self._replay_active = False
|
|
368
|
+
finally:
|
|
369
|
+
if hasattr(self.provider, "cursor"):
|
|
370
|
+
state.script_cursor = self.provider.cursor
|
|
371
|
+
# Persist the terminal state too, so `status`/`resume` see it.
|
|
372
|
+
save_checkpoint(state, checkpoint_path(self.home), self.redactor)
|
|
373
|
+
self.log.append("run_end", status=state.status,
|
|
374
|
+
steps_done=state.steps_done)
|
|
375
|
+
return state
|
|
376
|
+
|
|
377
|
+
def step(self, state: RunState) -> None:
|
|
378
|
+
# 1. Model turn.
|
|
379
|
+
reply = self.provider.complete(state.messages)
|
|
380
|
+
self.log.append("model_reply", content=reply.content,
|
|
381
|
+
input_tokens=reply.usage.input_tokens,
|
|
382
|
+
output_tokens=reply.usage.output_tokens,
|
|
383
|
+
estimated=reply.usage.estimated)
|
|
384
|
+
state.messages.append({"role": "assistant", "content": reply.content})
|
|
385
|
+
state.tokens_in += reply.usage.input_tokens
|
|
386
|
+
state.tokens_out += reply.usage.output_tokens
|
|
387
|
+
prov = self.manifest.provider
|
|
388
|
+
if prov.usd_per_1k_input is not None and prov.usd_per_1k_output is not None:
|
|
389
|
+
state.cost_usd += reply.usage.cost_usd(prov.usd_per_1k_input,
|
|
390
|
+
prov.usd_per_1k_output)
|
|
391
|
+
# 2. Parse; a clean reply with zero calls ends the run.
|
|
392
|
+
parsed = parse_tool_calls(reply.content,
|
|
393
|
+
self.manifest.limits.max_tool_calls_per_step)
|
|
394
|
+
if not parsed:
|
|
395
|
+
state.status = "completed"
|
|
396
|
+
return
|
|
397
|
+
# 3. Execute in order.
|
|
398
|
+
for item in parsed:
|
|
399
|
+
if item.call is None:
|
|
400
|
+
observation = f"ERROR malformed_call: {item.error}"
|
|
401
|
+
state.messages.append({"role": "user", "content": observation})
|
|
402
|
+
continue
|
|
403
|
+
call = item.call
|
|
404
|
+
result = self._act(state, call)
|
|
405
|
+
if result.error_code in ("sandbox_escape", "protected_target"):
|
|
406
|
+
self.log.append("sandbox_block", tool=call.name,
|
|
407
|
+
detail=result.error or "")
|
|
408
|
+
self.log.append("tool_result", tool=call.name, ok=result.ok,
|
|
409
|
+
error=result.error,
|
|
410
|
+
error_code=result.error_code, output=result.output)
|
|
411
|
+
state.messages.append(
|
|
412
|
+
{"role": "user",
|
|
413
|
+
"content": format_observation(call, result,
|
|
414
|
+
self.manifest.log_truncate_chars,
|
|
415
|
+
obs_token=self._obs_token)})
|
|
416
|
+
# 4. Bookkeeping.
|
|
417
|
+
state.steps_done += 1
|
|
418
|
+
self.log.append("step", n=state.steps_done)
|
|
419
|
+
|
|
420
|
+
def _act(self, state: RunState, call: ToolCall) -> ToolResult:
|
|
421
|
+
# Replay skip (FIXLIST item 1): this identical request already ran
|
|
422
|
+
# (or was approved) before the crash. Never re-execute, never
|
|
423
|
+
# re-prompt — the model gets the synthesized observation. v0.1.2
|
|
424
|
+
# (FIXLIST2 item 1): the live key is the RAW-args digest first, so
|
|
425
|
+
# args the log truncated or redacted still match their ledger entry.
|
|
426
|
+
if self._replay_active:
|
|
427
|
+
for key in _replay_keys(call.name, call.args):
|
|
428
|
+
if self._replay_skips.get(key):
|
|
429
|
+
return ToolResult(ok=True, output=SYNTHESIZED_MAY_HAVE_EXECUTED)
|
|
430
|
+
self.log.append("tool_call", tool=call.name, args=call.args,
|
|
431
|
+
args_sha256=_args_sha256(call.args))
|
|
432
|
+
decision = self.gate.decide(call)
|
|
433
|
+
if decision.action is Action.ALLOW:
|
|
434
|
+
return self.registry.execute(call, decision)
|
|
435
|
+
if decision.action is Action.DENY:
|
|
436
|
+
return ToolResult(ok=False, output="", error=decision.reason,
|
|
437
|
+
error_code="denied_by_policy")
|
|
438
|
+
self.log.append("approval_request", tool=call.name, args=call.args,
|
|
439
|
+
reason=decision.reason)
|
|
440
|
+
if self.approver is None:
|
|
441
|
+
self._stop_for_unattended_approval(state, call)
|
|
442
|
+
return ToolResult(ok=False, output="", error="no approver configured",
|
|
443
|
+
error_code="denied_by_operator")
|
|
444
|
+
approved = self.approver.approve(call, decision)
|
|
445
|
+
if not approved and getattr(self.approver, "unattended", False):
|
|
446
|
+
# A human cannot hear this prompt: pause cleanly instead of
|
|
447
|
+
# spinning to max_steps (FIXLIST item 10).
|
|
448
|
+
self._stop_for_unattended_approval(state, call)
|
|
449
|
+
final = self.gate.resolve_approval(call, approved)
|
|
450
|
+
if final.action is Action.ALLOW:
|
|
451
|
+
return self.registry.execute(call, final)
|
|
452
|
+
return ToolResult(ok=False, output="", error="denied by operator",
|
|
453
|
+
error_code="denied_by_operator")
|
|
454
|
+
|
|
455
|
+
def _stop_for_unattended_approval(self, state: RunState,
|
|
456
|
+
call: ToolCall) -> None:
|
|
457
|
+
state.status = "stopped_approval"
|
|
458
|
+
self.log.append("guard_trip", kind="approval_unattended",
|
|
459
|
+
tool=call.name)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _new_run_id() -> str:
|
|
463
|
+
return uuid.uuid4().hex
|