sliceagent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/loop.py ADDED
@@ -0,0 +1,548 @@
1
+ """The agent loop — the moat. Stateless core over contracts.
2
+
3
+ One while(true) = one "thought" = one memory slice. The slice is the SEED (built ONCE); working memory
4
+ ACCUMULATES within the loop as native assistant/tool messages and is folded to the durable cache at the
5
+ turn boundary (the seal). Markov ACROSS loops (no transcript), continuous WITHIN — validated to hold
6
+ coding accuracy + multi-turn continuity while lifting cache% / cutting cost and dissolving per-step
7
+ eviction churn. On context overflow it drops the oldest accumulated exchange (never grows a transcript).
8
+
9
+ The core depends ONLY on: build_slice (the reconstruction seam), an LLMClient, a ToolHost, a
10
+ dispatch_event callable, and hooks. It never imports implementations and never touches slice internals
11
+ (tool results flow back via the slice_sink on events).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+
18
+ from .context_overflow import ContextOverflow
19
+ from .errors import with_retry
20
+ from .events import (
21
+ AssistantText,
22
+ Dispatcher,
23
+ SliceBuilt,
24
+ SliceTightened,
25
+ StepBegin,
26
+ StepEnd,
27
+ ToolResult,
28
+ ToolStarted,
29
+ TurnEnd,
30
+ TurnInterrupted,
31
+ )
32
+ from .guardrails import DEDUP_SAFE_TOOL_NAMES, canonical_tool_args
33
+ from .guidance import BUDGET_EXHAUSTED, STUCK
34
+ from .hooks import Hooks
35
+ from .registry import ToolText
36
+ from .scheduler import run_scheduled
37
+
38
+
39
+ def _as_text(out):
40
+ """Preserve a ToolText (str subclass carrying .ok); coerce anything non-str defensively. Used so the
41
+ scheduler step does not strip the registry's structured success flag back to a plain string."""
42
+ return out if isinstance(out, str) else str(out)
43
+
44
+
45
+ # Path-targeted file mutators — a read of a path written by one of these IN THE SAME BATCH must not be
46
+ # served from a cached earlier read (it would be stale). Focused on tools that carry a `path` arg.
47
+ _FILE_MUTATING_TOOLS = frozenset({"write_file", "edit_file", "str_replace", "append_to_file"})
48
+
49
+
50
+ def _dedup_key(name: str, args):
51
+ """Same-step exact-call dedup key: (name, canonical args). Reuses the guardrail's canonicalizer
52
+ (sorted JSON, `note` stripped) so the dedup identity matches loop-detection's. None ⇒ never dedup
53
+ (odd/unserializable args) — paging that case to the normal execute path, never failing the batch."""
54
+ try:
55
+ return name + "\x00" + canonical_tool_args(args or {})
56
+ except Exception: # noqa: BLE001
57
+ return None
58
+
59
+
60
+ def _tool_timeout() -> float | None:
61
+ """Opt-in per-tool wall-clock deadline (seconds) from AGENT_TOOL_TIMEOUT; None/0/invalid → off (the
62
+ default), preserving the original wait-for-every-tool behaviour. A last-resort net above each tool's
63
+ own subprocess/SIGALRM timeout, for a custom/MCP tool that blocks with no internal limit."""
64
+ import os
65
+ raw = os.environ.get("AGENT_TOOL_TIMEOUT", "").strip()
66
+ try:
67
+ v = float(raw)
68
+ return v if v > 0 else None
69
+ except ValueError:
70
+ return None
71
+
72
+
73
+ # Anti-spin floor: after this many guardrail BLOCKS in one turn the loop stops and hands control back
74
+ # to the user (TurnInterrupted "stuck") instead of letting a weak model keep generating variants
75
+ # against the guard. The proactive path is the ask_user tool; this is the harness backstop. Each block
76
+ # already represents a 3-4x repeat caught by the guardrail, so a few blocks = genuinely stuck.
77
+ STUCK_BLOCK_BUDGET = 3
78
+
79
+ # Shown when the working context overflows and can't be compacted further (the seed itself is too big).
80
+ # With one loop mode there's no tighten-ladder fallback, so we fail SOFT here instead of crashing.
81
+ OVERFLOW_MSG = ("The working context overflowed and could not be compacted further. Stopping this turn — "
82
+ "try a narrower request, or reduce the number of files in play, and continue.")
83
+
84
+ # #11: a 'length'/'content_filter' finish is NOT a clean turn — the reply was truncated/blocked, not
85
+ # completed, so we PARK (interrupted) instead of sealing it as done.
86
+ MAX_TOKENS_MSG = ("The response hit the output token limit and was cut off mid-answer — it is INCOMPLETE. "
87
+ "Continue, or ask a narrower question.")
88
+ FILTERED_MSG = "The response was stopped by the provider's content filter; the turn is incomplete."
89
+
90
+ # Breadcrumb inserted ONCE when overflow compaction drops the oldest exchange, so the loss is never
91
+ # silent: the model is told it happened and how to recover (the episode sink archived it losslessly).
92
+ OVERFLOW_COMPACTED = ("[context note: the oldest step(s) of this turn were compacted out to fit the window. "
93
+ "If you need details from an early step, re-derive them or call recall_history if it is "
94
+ "available — do not assume that work is undone.]")
95
+ _CRUMB_PREFIX = "[context note: the oldest" # stable prefix to detect the breadcrumb (with or without the checkpoint)
96
+
97
+
98
+ def _overflow_breadcrumb(consolidate) -> dict:
99
+ """F2 — REBUILD-FROM-CHECKPOINT: the overflow breadcrumb carries the DISTILLED state (the deterministic
100
+ checkpoint), not just a generic 'oldest steps compacted' note — so when overflow sheds the oldest raw
101
+ exchanges, the turn's intent/decisions/change-set survive in front of the model. Best-effort: a failing
102
+ or empty checkpoint degrades to the plain note."""
103
+ snap = ""
104
+ if consolidate is not None:
105
+ try:
106
+ snap = (consolidate() or "").strip()
107
+ except Exception: # noqa: BLE001 — a checkpoint hiccup must never break overflow handling
108
+ snap = ""
109
+ content = (OVERFLOW_COMPACTED + "\n\n# CHECKPOINT — state of play (the distilled state of the compacted "
110
+ "steps; recall_history for raw detail):\n" + snap) if snap else OVERFLOW_COMPACTED
111
+ return {"role": "user", "content": content}
112
+
113
+ # Micro-compaction: on overflow, the FIRST move is to clear the BODIES of
114
+ # OLD tool-result messages — the bulky, stale part — while keeping the assistant reasoning skeleton and the
115
+ # recent window. Strictly better than dropping whole exchanges (which loses the reasoning too), and it keeps
116
+ # every tool_call↔reply pairing intact so the message sequence stays valid. The full output is archived in
117
+ # the episode cache, so this is lossless-by-default (recall_history pages it back).
118
+ MICRO_KEEP_RECENT = 10
119
+ MICRO_MARKER = "[old tool result cleared to fit the window — recall_history can page it back]"
120
+
121
+
122
+ def _micro_compact(messages: list, *, floor: int, keep_recent: int = MICRO_KEEP_RECENT) -> bool:
123
+ """Clear the bodies of OLD tool-result messages between `floor` and the recent window (last
124
+ `keep_recent` messages). Returns True if it cleared at least one (the caller retries the LLM call
125
+ before resorting to dropping whole exchanges)."""
126
+ cleared = False
127
+ for i in range(floor, max(floor, len(messages) - keep_recent)):
128
+ m = messages[i]
129
+ if m.get("role") == "tool" and m.get("content") and m["content"] != MICRO_MARKER:
130
+ m["content"] = MICRO_MARKER
131
+ cleared = True
132
+ return cleared
133
+
134
+
135
+ def _final_answer(llm, msgs: list, tools, dispatch, guidance: str) -> dict:
136
+ """Closeout helper: a turn must NEVER end silently or with a bare stub. Offer ONLY ask_user (all other
137
+ tools stay banned) so the model can ASK instead of guessing when blocked/ambiguous; if it asks, surface
138
+ the question as the final message. Otherwise emit its summary — and if that is empty, a deterministic,
139
+ honest fallback. RETURNS the closeout completion's usage so the caller accounts it (it's a real model
140
+ call, and the budget must see its own closeout — no silent overspend)."""
141
+ ask = None
142
+ try:
143
+ for sc in (tools.schemas() if hasattr(tools, "schemas") else []):
144
+ if sc.get("function", {}).get("name") == "ask_user":
145
+ ask = sc
146
+ break
147
+ except Exception: # noqa: BLE001
148
+ ask = None
149
+ resp = None
150
+ try:
151
+ resp = llm.complete(msgs, [ask] if ask else [])
152
+ except Exception: # noqa: BLE001
153
+ resp = None
154
+ usage = getattr(resp, "usage", None) or {}
155
+ for tc in (getattr(resp, "tool_calls", None) or []): # the model chose to ASK → surface the question
156
+ if getattr(tc, "name", "") == "ask_user":
157
+ q = (getattr(tc, "args", None) or {}).get("question")
158
+ if q:
159
+ dispatch(AssistantText(str(q)))
160
+ return usage
161
+ content = (getattr(resp, "content", "") or "").strip()
162
+ if content: # a real (or short) summary — keep it
163
+ dispatch(AssistantText(content))
164
+ return usage
165
+ dispatch(AssistantText( # deterministic, never-empty, honest fallback
166
+ "I had to stop here (" + guidance.strip().rstrip(".") + "). I could not confirm the task is fully "
167
+ "complete — please review the changes so far, or re-run with more steps, and tell me if you'd like "
168
+ "me to continue."))
169
+ return usage
170
+
171
+
172
+ @dataclass
173
+ class TurnResult:
174
+ stop_reason: str
175
+ steps: int
176
+ usage: dict
177
+
178
+
179
+ def _normalize_stop(resp) -> str:
180
+ fr = (resp.finish_reason or "").lower()
181
+ if fr in ("length", "max_tokens"):
182
+ return "max_tokens"
183
+ if fr in ("content_filter", "filtered"):
184
+ return "filtered"
185
+ return "tool_use" if resp.tool_calls else "end_turn"
186
+
187
+
188
+ def _tool_call_id(tc, i: int) -> str:
189
+ """The ONE id-assigner: a real provider id, else a stable index fallback. run_tool_batch and
190
+ _assistant_message MUST agree on this or the `tool` messages orphan their `tool_calls`."""
191
+ return getattr(tc, "id", None) or f"call_{i}"
192
+
193
+
194
+ def _try_model_fallback(llm) -> bool:
195
+ """On exhausted-compaction overflow, swap to AGENT_MODEL_FALLBACK ONCE (a larger-context model) and
196
+ return True so the loop retries; False if no fallback is configured / already used / same model. Sticky
197
+ for the session — once you've overflowed the primary, the bigger model is the right place to stay."""
198
+ import os
199
+ fb = os.environ.get("AGENT_MODEL_FALLBACK", "").strip()
200
+ if not fb or getattr(llm, "_fellback", False) or fb == getattr(llm, "model", None):
201
+ return False
202
+ llm._fellback = True
203
+ try:
204
+ llm.model = fb
205
+ except Exception: # noqa: BLE001
206
+ return False
207
+ return True
208
+
209
+
210
+ def _hook_debug(where: str, e: Exception) -> None:
211
+ import os as _os
212
+ if _os.environ.get("SLICEAGENT_DEBUG_TRACE"):
213
+ import sys as _sys
214
+ import traceback as _tb
215
+ print(f"[hook error in {where}: {type(e).__name__}: {e}]", file=_sys.stderr)
216
+ _tb.print_exc(file=_sys.stderr)
217
+
218
+
219
+ def _safe_advisory(where: str, fn, default=None):
220
+ """Run an ADVISORY hook (budget/oracle/plugin: before/after_step, record_step_usage, prepare_messages,
221
+ transform_tool_result, should_continue_after_stop). A misbehaving plugin hook must DEGRADE the turn, not
222
+ end it — on any exception we log (opt-in) and return `default` (= 'no opinion'), so the loop continues."""
223
+ try:
224
+ return fn()
225
+ except Exception as e: # noqa: BLE001
226
+ _hook_debug(where, e)
227
+ return default
228
+
229
+
230
+ def _safe_authorize(hooks, name, args):
231
+ """authorize_tool is SECURITY, so it fails CLOSED: any exception in the permission hook DENIES the call,
232
+ never silently allows it. Returns the hook's ToolDecision, or a synthetic deny on error."""
233
+ try:
234
+ return hooks.authorize_tool(name, args or {}) # None args → {} so an argless call isn't an opaque fail-closed deny
235
+ except Exception as e: # noqa: BLE001
236
+ _hook_debug("authorize_tool", e)
237
+ from types import SimpleNamespace
238
+ return SimpleNamespace(allow=False, reason=f"permission hook errored ({type(e).__name__}) — denied")
239
+
240
+
241
+ def run_tool_batch(tool_calls, tools, dispatch: Dispatcher, hooks: Hooks):
242
+ """Authorize, schedule (safe-parallel by resource access), and report results in provider order.
243
+ Returns (blocked_count, results): `blocked` feeds the anti-spin floor; `results` carries each call's
244
+ {id,name,args,output,failing} in provider order so the loop can build native tool messages. The
245
+ dispatched ToolResult events drive slice_sink + the episode cache independently of the return."""
246
+ tasks = []
247
+ metas = []
248
+ blocked = 0
249
+ seen: dict = {} # dedup key -> index of the FIRST (executing) occurrence in this batch
250
+ dup_of: dict = {} # index -> primary index whose result it reuses (same-step exact-call dedup)
251
+ # Paths MUTATED somewhere in this same batch — a read of one of these must NOT be deduped against an
252
+ # earlier read of it (the cached sibling result would be pre-mutation/stale for a read-then-edit-then-
253
+ # read-back issued in one assistant turn). Best-effort exact-path match.
254
+ mutated_paths = set()
255
+ # A shell-exec in the batch can mutate ANY file (we can't know which), so a read in the same batch
256
+ # might see post-exec state — disable read-dedup entirely for the batch when one is present.
257
+ batch_has_shell = any(getattr(_tc, "name", None) in ("run_command", "execute_code", "terminal_send", "proc_start")
258
+ for _tc in tool_calls) # any of these can mutate a file the batch also reads
259
+ for _tc in tool_calls:
260
+ if _tc.name in _FILE_MUTATING_TOOLS and isinstance(_tc.args, dict):
261
+ _p = _tc.args.get("path")
262
+ if isinstance(_p, str) and _p:
263
+ mutated_paths.add(_p)
264
+ for tc in tool_calls:
265
+ idx = len(metas)
266
+ decision = _safe_authorize(hooks, tc.name, tc.args) # SECURITY: fails CLOSED on hook error
267
+ # #44: `note` is the universal slice-capture seam injected by with_note onto EVERY tool — it is
268
+ # NOT a handler parameter. Strip it before invoking the tool so strict MCP/plugin handlers don't
269
+ # reject an unexpected property; it still rides the dispatched ToolResult.args (metas, below),
270
+ # where slice_sink folds it into FINDINGS.
271
+ # tc.args may be a non-dict if the model emits a non-object JSON value (a list/str/number that
272
+ # json.loads accepted) — coerce to {} so `.items()` can't AttributeError and crash the whole batch
273
+ # (which parked the turn). The tool then reports its missing required arg as a normal per-call error.
274
+ raw_args = tc.args if isinstance(tc.args, dict) else {}
275
+ call_args = {k: v for k, v in raw_args.items() if k != "note"}
276
+ # SAME-STEP exact-call dedup (lossless): an identical (name, args)
277
+ # read-only call already issued in THIS batch reuses the first call's result instead of executing
278
+ # the tool a second time. Gated to read-only query tools (a re-run is byte-identical anyway) and
279
+ # only when allowed — a blocked/spinning call still gets its block message, and mutating/unknown
280
+ # tools are never deduped. A duplicate emits NO ToolStarted/ToolResult event (it is free: not
281
+ # re-folded into the slice/episode, not counted), but still gets a tool message below so the
282
+ # provider sees a reply for every tool_call_id.
283
+ _read_path = call_args.get("path") if isinstance(call_args, dict) else None
284
+ _safe_to_dedup = (tc.name in DEDUP_SAFE_TOOL_NAMES
285
+ and not batch_has_shell # a shell-exec may have mutated the read target
286
+ and not (isinstance(_read_path, str) and _read_path in mutated_paths)) # or a same-batch file edit
287
+ key = _dedup_key(tc.name, call_args) if (decision.allow and _safe_to_dedup) else None
288
+ # NOTE (R15 critic, by-design): a duplicate read carrying a DISTINCT `note` reuses the primary's
289
+ # result and its note is not separately folded (the model re-states it next turn). Deduping the
290
+ # re-read is the deliberate efficiency win that test_tool_dedup.note_only_difference_still_dedups pins.
291
+ if key is not None and key in seen:
292
+ dup_of[idx] = seen[key]
293
+ tasks.append(([], (lambda: ""))) # placeholder; its output is filled from the primary, unused
294
+ metas.append((_tool_call_id(tc, idx), tc.name, raw_args)) # dict-coerced (note preserved) so sinks never see a non-dict
295
+ continue
296
+ dispatch(ToolStarted(tc.name, raw_args))
297
+ if not decision.allow:
298
+ if getattr(decision, "counts_as_stuck", True):
299
+ blocked += 1 # only a HARD spin counts toward STUCK; a deduped read is skipped but free
300
+ tasks.append(([], (lambda d=decision: ToolText(f"Error: blocked by policy: {d.reason or 'denied'}", ok=False))))
301
+ else:
302
+ if key is not None:
303
+ seen[key] = idx
304
+ # preserve ToolText (a str subclass carrying .ok) — coercing with str() here would strip the
305
+ # success flag and force the failing check back onto the prose-match it is meant to replace.
306
+ tasks.append((tools.accesses(tc.name, call_args),
307
+ (lambda name=tc.name, ca=call_args: _as_text(tools.run(name, ca)))))
308
+ # real OpenAI tool_calls carry an id; synthesize a stable index-based one if absent (e.g. test
309
+ # fakes / a provider that omits it) so accumulate's assistant.tool_calls ↔ tool messages still match.
310
+ metas.append((_tool_call_id(tc, idx), tc.name, raw_args)) # dict-coerced (note preserved) so sinks never see a non-dict
311
+
312
+ outputs = run_scheduled(tasks, timeout=_tool_timeout())
313
+ final: list = [None] * len(metas) # post-transform output per call, in provider order
314
+ failed: list = [False] * len(metas)
315
+ for i, ((tcid, name, args), out) in enumerate(zip(metas, outputs)):
316
+ if i in dup_of:
317
+ continue # a duplicate is filled from its primary below (no transform, no dispatch)
318
+ transformed = _safe_advisory("transform_tool_result", lambda: hooks.transform_tool_result(name, args, out))
319
+ if transformed is not None:
320
+ out = transformed
321
+ # M: trust the STRUCTURED success flag the registry attached (ToolText.ok) — a tool that returned
322
+ # normally succeeded even if its output begins with "Error"/"Exit code" (a grep hit, a log line).
323
+ # Fall back to the old prose-match ONLY for plain strings with no flag (a plugin transform that
324
+ # returned a bare str, an MCP tool, or a test fake).
325
+ ok = getattr(out, "ok", None)
326
+ failing = (out.startswith("Error") or out.startswith("Exit code")) if ok is None else (not ok)
327
+ dispatch(ToolResult(name, args, out, failing))
328
+ final[i] = out
329
+ failed[i] = failing
330
+ for i, p in dup_of.items(): # reuse the primary's FINAL (post-transform) result, byte-identical
331
+ final[i] = final[p]
332
+ failed[i] = failed[p]
333
+ results = [{"id": tcid, "name": name, "args": args, "output": final[i], "failing": failed[i]}
334
+ for i, (tcid, name, args) in enumerate(metas)]
335
+ return blocked, results
336
+
337
+
338
+ def _assistant_message(resp) -> dict:
339
+ """Reconstruct the OpenAI assistant message (with native tool_calls) for the accumulated transcript.
340
+ ids are synthesized index-based when absent (matching run_tool_batch's scheme) so the assistant's
341
+ tool_calls and the following tool messages reference the SAME ids."""
342
+ msg: dict = {"role": "assistant", "content": resp.content or ""}
343
+ if resp.tool_calls:
344
+ msg["tool_calls"] = [
345
+ {"id": _tool_call_id(tc, i), "type": "function",
346
+ # #13: tc.args may be None (a tool call with no args) → emit "{}", not "null" (which some
347
+ # providers reject as an invalid arguments payload).
348
+ "function": {"name": tc.name, "arguments": json.dumps(tc.args or {}, ensure_ascii=False)}}
349
+ for i, tc in enumerate(resp.tool_calls)
350
+ ]
351
+ return msg
352
+
353
+
354
+ def _prepared(hooks, msgs: list) -> list:
355
+ """Pre-LLM-call hook seam (context injection, prompt-cache-safe): return the hook's rewrite, or
356
+ `msgs` unchanged when it returns None. Note `is not None` (an empty-list rewrite is honored)."""
357
+ prepared = _safe_advisory("prepare_messages", lambda: hooks.prepare_messages(msgs))
358
+ return prepared if prepared is not None else msgs
359
+
360
+
361
+ def run_turn(*, build_slice, llm, tools, dispatch: Dispatcher, hooks: Hooks | None = None,
362
+ max_steps: int = 40, signal=None, checkpoint=None, consolidate=None) -> TurnResult:
363
+ """One per-LOOP working-memory turn. The slice is the SEED, built ONCE; within the while(true) working
364
+ memory ACCUMULATES as native assistant/tool messages — NO per-step rebuild, NO eviction. The LLM ends
365
+ by not calling tools (Markov at the loop boundary; continuous within).
366
+
367
+ Because the seed is built once, mid-turn hook→model communication rides the MESSAGE channel, never a
368
+ slice mutation (which would never re-render): prepare_messages is applied per llm.complete, and a
369
+ continue-hook's `feedback` (e.g. the Oracle's test failure) is appended as the model's next input.
370
+
371
+ Every NON-clean exit — max_steps, stuck, token budget, hook block, overflow, abort, AND any
372
+ UNEXPECTED internal error (a non-retryable llm failure, a throwing build_slice) — routes through ONE
373
+ helper, _park: honest reason + exactly one TurnInterrupted (+ an ACCOUNTED closeout where another model
374
+ call is affordable). A budget/hook stop PARKS — never `end_turn` (the caller checkpoints end_turn⇒done).
375
+ Overflow compacts the oldest WHOLE exchange; a seed that alone overflows parks soft.
376
+
377
+ (Known deferred — M: failing-tool detection in run_tool_batch is still a prose match, not a structured
378
+ ToolHost.run ok-flag, so a legit tool output beginning with "Error"/"Exit code" can false-flag.)"""
379
+ hooks = hooks or Hooks()
380
+ hooks.reset_for_turn() # clear per-turn guards ONCE per user task (not per step)
381
+ total = {"prompt_tokens": 0, "completion_tokens": 0}
382
+ steps = 0
383
+ total_blocked = 0
384
+ said_anything = False # did the turn emit ANY assistant text? (never end truly silent)
385
+ messages: list = [] # defined BEFORE the seed build so _park's closure is safe even if it throws
386
+ seed_len = 0
387
+
388
+ def _account(usage: dict) -> None:
389
+ total["prompt_tokens"] += usage.get("prompt_tokens", 0)
390
+ total["completion_tokens"] += usage.get("completion_tokens", 0)
391
+
392
+ def _park(reason: str, msg: str | None, *, closeout: bool = True) -> TurnResult:
393
+ """The ONE non-clean exit: an optional ACCOUNTED closeout, then exactly one TurnInterrupted."""
394
+ if closeout and msg is not None and messages:
395
+ try:
396
+ cmsgs = _prepared(hooks, messages + [{"role": "user", "content": "# TURN IS ENDING — " + msg
397
+ + " Give your best answer/summary NOW (what you did, what you verified, what remains) from "
398
+ "what you already have; make NO edit/run tool call. If the request was ambiguous or you are "
399
+ "blocked, call ask_user with ONE concise question instead."}])
400
+ _account(_final_answer(llm, cmsgs, tools, dispatch, msg))
401
+ except Exception: # noqa: BLE001
402
+ pass
403
+ dispatch(TurnInterrupted(reason, message=msg))
404
+ return TurnResult(reason, steps, total)
405
+
406
+ # The ENTIRE turn (seed build + loop) is wrapped so EVERY non-clean exit routes through _park — even
407
+ # ones we did not anticipate: a non-retryable llm error past with_retry, or a throwing build_slice /
408
+ # retriever / probe. The session must NEVER die uncaught with no TurnInterrupted (Q + R).
409
+ try:
410
+ schemas = tools.schemas() if hasattr(tools, "schemas") else [] # stable per session → hoist once
411
+ messages = list(build_slice()) # SEED — built ONCE, stored RAW (prepare_messages applies per-call)
412
+ seed_len = len(messages) # never compact below the seed
413
+ seed_view = _prepared(hooks, messages) # dispatch what the model will SEE (== messages unless a hook injects)
414
+ # #14: guard an empty seed (a build_slice that returns []) — index it directly and we'd IndexError
415
+ # into the generic 'error' park, masking the real cause.
416
+ _rendered = seed_view[-1]["content"] if seed_view else ""
417
+ if isinstance(_rendered, list): # multimodal user content (image parts) → use the TEXT part for the
418
+ _rendered = next((p.get("text", "") for p in _rendered # rendered/inspection view
419
+ if isinstance(p, dict) and p.get("type") == "text"), "")
420
+ dispatch(SliceBuilt(_rendered, seed_view))
421
+
422
+ while True:
423
+ if signal is not None and signal.is_set():
424
+ return _park("aborted", None, closeout=False)
425
+ if steps >= max_steps:
426
+ return _park("max_steps", BUDGET_EXHAUSTED("max_steps"))
427
+
428
+ steps += 1
429
+ before = _safe_advisory("before_step", lambda: hooks.before_step(steps))
430
+ if before and before.get("block"):
431
+ # a hook signalled "block this step" — PARK gracefully (closeout would re-block), never crash.
432
+ return _park("blocked", before.get("reason") or "step blocked by a hook", closeout=False)
433
+ dispatch(StepBegin(steps))
434
+ if checkpoint is not None: # crash-recovery WAL: persist the in-flight turn BEFORE the LLM
435
+ _safe_advisory("checkpoint", lambda: checkpoint(messages, steps)) # call (best-effort)
436
+
437
+ # The step is interrupt-guarded: ctrl-C anywhere — the blocking llm.complete OR a slow tool in
438
+ # run_tool_batch (a hung run_command) — aborts the turn cleanly instead of crashing it.
439
+ try:
440
+ # overflow → compact the OLDEST WHOLE exchange (assistant + ALL its tool replies; a fixed
441
+ # 2-window would orphan tool messages on parallel calls → invalid sequence → provider 400).
442
+ # If the SEED itself overflows (nothing left to compact), fail SOFT — no tighten ladder.
443
+ overflow_tries = 0
444
+ while True:
445
+ try:
446
+ resp = with_retry(
447
+ lambda: llm.complete(_prepared(hooks, messages), schemas),
448
+ is_retryable=getattr(llm, "is_retryable", None), dispatch=dispatch,
449
+ )
450
+ break
451
+ except ContextOverflow:
452
+ # The breadcrumb (if present) is pinned at seed_len; derive its presence from the
453
+ # transcript so it is inserted exactly ONCE PER TURN even across multiple overflow
454
+ # steps (a per-step flag would stack duplicates). floor keeps it below the seed.
455
+ has_crumb = bool(messages[seed_len:]) and str(messages[seed_len].get("content", "")).startswith(_CRUMB_PREFIX)
456
+ floor = seed_len + (1 if has_crumb else 0)
457
+ # MICRO-COMPACTION FIRST: clear OLD tool-result BODIES — keeping the
458
+ # assistant reasoning, the recent window, and valid tool pairings — before resorting
459
+ # to dropping a whole exchange. Lossless-by-default (full content in the episode cache).
460
+ micro = _micro_compact(messages, floor=floor)
461
+ if not micro and (len(messages) <= floor or overflow_tries >= 4):
462
+ # micro-clear exhausted AND nothing left to drop (even the seed overflows).
463
+ # SECONDARY net: if a bigger-context model is configured (AGENT_MODEL_FALLBACK),
464
+ # swap to it ONCE and retry rather than parking — the moat's compaction stays the
465
+ # primary, cheaper path.
466
+ if _try_model_fallback(llm):
467
+ dispatch(AssistantText(f"[context overflow — switching to {llm.model} "
468
+ "for a larger window]"))
469
+ overflow_tries = 0
470
+ continue
471
+ return _park("overflow", OVERFLOW_MSG, closeout=False)
472
+ if not micro: # micro-clear exhausted → drop the oldest WHOLE exchange (assistant + replies)
473
+ end = floor + 1
474
+ while end < len(messages) and messages[end].get("role") == "tool":
475
+ end += 1
476
+ del messages[floor:end]
477
+ overflow_tries += 1
478
+ if not has_crumb: # breadcrumb ONCE PER TURN, carrying the distilled CHECKPOINT (F2)
479
+ messages.insert(seed_len, _overflow_breadcrumb(consolidate))
480
+ dispatch(SliceTightened(level=overflow_tries))
481
+
482
+ usage = resp.usage or {}
483
+ _account(usage)
484
+ # record_step_usage is the BUDGET path → fail CLOSED: on a hook exception, default to
485
+ # stop_turn=True (a crashing accountant must STOP, never silently overspend). No downside in
486
+ # normal use — the built-in BudgetHook is plain arithmetic and never raises, so the default
487
+ # only fires on a genuinely broken (e.g. 3rd-party) usage hook, where stopping is correct.
488
+ budget_stop = bool((_safe_advisory("record_step_usage",
489
+ lambda: hooks.record_step_usage(usage),
490
+ default={"stop_turn": True}) or {}).get("stop_turn"))
491
+ if resp.content:
492
+ dispatch(AssistantText(resp.content))
493
+ said_anything = True
494
+ stop = _normalize_stop(resp)
495
+
496
+ if budget_stop:
497
+ # F: a token-budget stop is a PARK, never end_turn/done. Append the final content (never
498
+ # a dangling tool_calls); no closeout — we're already at the ceiling.
499
+ if resp.content:
500
+ messages.append({"role": "assistant", "content": resp.content})
501
+ dispatch(StepEnd(steps, usage, "token_budget"))
502
+ return _park("token_budget", BUDGET_EXHAUSTED("token_budget"), closeout=False)
503
+
504
+ if stop != "tool_use":
505
+ # the LLM ended the while(true): append its FINAL text only — never a dangling tool_calls.
506
+ if resp.content:
507
+ messages.append({"role": "assistant", "content": resp.content})
508
+ dispatch(StepEnd(steps, usage, stop))
509
+ cont = _safe_advisory("should_continue_after_stop", lambda: hooks.should_continue_after_stop(stop))
510
+ if cont and cont.get("continue"):
511
+ messages.append({"role": "user", "content": cont.get("feedback") or "Continue."})
512
+ continue
513
+ if stop in ("max_tokens", "filtered"):
514
+ # #11: a truncated (length) or content-filtered response is INCOMPLETE — park it as
515
+ # interrupted instead of sealing a partial answer as a clean turn. Content (if any)
516
+ # was already emitted + appended above; no closeout (it would truncate again too).
517
+ return _park(stop, MAX_TOKENS_MSG if stop == "max_tokens" else FILTERED_MSG,
518
+ closeout=False)
519
+ if not said_anything: # never end the turn truly silent (e.g. empty end_turn after tools)
520
+ dispatch(AssistantText("Done — no summary to add."))
521
+ dispatch(TurnEnd(stop, steps, total)) # the ONE clean-exit event
522
+ return TurnResult(stop, steps, total)
523
+
524
+ # tool_use: accumulate the assistant turn (with tool_calls), run, accumulate the tool results
525
+ messages.append(_assistant_message(resp))
526
+ blocked, results = run_tool_batch(resp.tool_calls, tools, dispatch, hooks)
527
+ total_blocked += blocked
528
+ for r in results:
529
+ messages.append({"role": "tool", "tool_call_id": r["id"], "content": r["output"]})
530
+ dispatch(StepEnd(steps, usage, "tool_use"))
531
+ except KeyboardInterrupt:
532
+ return _park("aborted", None, closeout=False)
533
+
534
+ after = _safe_advisory("after_step", lambda: hooks.after_step(steps, usage, "tool_use"))
535
+ if after and after.get("stop_turn"):
536
+ return _park("token_budget", BUDGET_EXHAUSTED("token_budget"), closeout=False)
537
+ if total_blocked >= STUCK_BLOCK_BUDGET:
538
+ return _park("stuck", STUCK)
539
+ except KeyboardInterrupt:
540
+ # ctrl-C during SETUP (build_slice/schemas), before the step's own interrupt guard is in scope.
541
+ return _park("aborted", None, closeout=False)
542
+ except Exception as e: # noqa: BLE001 — Q + R: any unexpected error PARKS, never crashes the session.
543
+ import os as _os
544
+ if _os.environ.get("SLICEAGENT_DEBUG_TRACE"): # opt-in traceback so a parked 'error' is diagnosable
545
+ import sys as _sys
546
+ import traceback as _tb
547
+ _tb.print_exc(file=_sys.stderr)
548
+ return _park("error", f"an internal error ended the turn ({type(e).__name__})", closeout=False)