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/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