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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|