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/swap.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""SwapManager — single owner of the working-set PAGE lifecycle (file/dep/skill/ghost/reviewed); every page enters/
|
|
2
|
+
leaves the slice THROUGH here. The memory plane of a DEMAND-PAGED SNAPSHOT MACHINE: the slice is a CACHE, not a log,
|
|
3
|
+
so eviction is always safe (a re-fault re-reads from the durable store). DUCK-TYPED: imports nothing from pfc.py
|
|
4
|
+
(reverse import circular — pfc.py imports the bounds below); only prefetch() reaches
|
|
5
|
+
self.retriever. SELF-TUNING (automatic, no model): a re-read of a file still in the recency ring (a REFAULT) proves
|
|
6
|
+
the budget was momentarily too tight, so the kernel grants ITSELF a brief reclaim-protection (Linux mm/workingset
|
|
7
|
+
refault detection, scaled down) AND widens its OWN read budget one notch (s.read_budget, bounded by s.read_ceiling)
|
|
8
|
+
— so the working set grows to TASK need, not to a fixed ceiling. hit/miss/refault/evict are counted (s.io) so the
|
|
9
|
+
moat is MEASURED, not asserted."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
READ_BUDGET = 4 # FLOOR for the exploratory-read residue — the lean DEFAULT, NOT a hard cap. The kernel GROWS the
|
|
13
|
+
# live budget (s.read_budget) on refault thrash up to READ_BUDGET_MAX. "Bounded" = no PASSIVE/
|
|
14
|
+
# history-proportional growth (Markov current-state), never a fixed size ceiling. SINGLE owner
|
|
15
|
+
# of this and every other bound below (pfc.py/seed.py import directly, no re-export chain).
|
|
16
|
+
READ_BUDGET_MAX = 16 # per-slice DISASTER CEILING for refault-driven growth. A single COHERENT task rarely needs more
|
|
17
|
+
# resident reads than this; genuine BREADTH ("review the repo") is delegated to the subagent SWARM
|
|
18
|
+
# (each child a fresh lean slice), NOT served by inflating one slice toward the context window
|
|
19
|
+
# (that invites context-rot / lost-in-the-middle — see auto-memory: kernel-architecture).
|
|
20
|
+
# bound ≠ size: the change set + dependency closure are RELEVANCE sets, kept resident IN FULL (evict no
|
|
21
|
+
# longer truncates them — see evict/prefetch). These two constants are now PHYSICAL fan-in backstops
|
|
22
|
+
# (guard against a pathological 100s-of-callers symbol), generous and well above any coherent change —
|
|
23
|
+
# NOT relevance caps. swap_tools.py reads them for the occupancy display; the real overflow handler is
|
|
24
|
+
# the physical-context tighten ladder, not these.
|
|
25
|
+
EDIT_CEILING = 32 # physical backstop on the change set (a coherent change is far smaller); not a relevance cap
|
|
26
|
+
DEP_CEILING = 64 # per-symbol caller fan-out backstop — keep ALL direct callers that break on the change
|
|
27
|
+
# (caller-first ranked in code_index.deps); generous physical guard, not a relevance cap.
|
|
28
|
+
MAX_GHOSTS = 6 # GHOST INDEX ring — pointers to recently paged-out files/skills (also the refault recency window)
|
|
29
|
+
MAX_ACTIVE_SKILLS = 2 # keep only the most-recently-loaded skills active
|
|
30
|
+
MAX_SKILL_CHARS = 12000 # a loaded skill body before it enters the slice; bounded by COUNT (MAX_ACTIVE_SKILLS=2),
|
|
31
|
+
# so this is generous — a half-read skill (helpers/examples/patterns in the tail lost) misleads more than it costs
|
|
32
|
+
MAX_REVIEWED = 8 # bounded ring of history lookbacks done (the recall_history ratchet)
|
|
33
|
+
HOT_TTL = 3 # steps a REFAULT-promoted file stays kernel-protected (self-tuning; not the model)
|
|
34
|
+
HOT_CEILING = 4 # bound the kernel-granted soft-pin set — never an accumulating tier (decoupled from
|
|
35
|
+
# DEP_CEILING so raising the dep ceiling doesn't widen the refault soft-pin set)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SwapManager:
|
|
39
|
+
"""Owns the working set: file load/evict, dep prefetch, skill load/evict, reviewed ratchet, ghosts."""
|
|
40
|
+
def __init__(self, retriever=None):
|
|
41
|
+
self.retriever = retriever
|
|
42
|
+
|
|
43
|
+
def load(self, s, path: str, edited: bool = False) -> None: # add/refresh a file, then evict to bounds
|
|
44
|
+
if not path:
|
|
45
|
+
return
|
|
46
|
+
io = getattr(s, "io", None)
|
|
47
|
+
if path in s.active_files: # already resident — a cache HIT
|
|
48
|
+
if io is not None:
|
|
49
|
+
io["hit"] += 1
|
|
50
|
+
elif self._is_ghost(s, "file", path): # re-read of a recently-evicted page — a REFAULT
|
|
51
|
+
if io is not None:
|
|
52
|
+
io["refault"] += 1
|
|
53
|
+
self._promote(s, path) # budget was too tight → kernel grants a brief soft-pin
|
|
54
|
+
self._grow(s) # …and widens its OWN read budget one notch (bidirectional ladder)
|
|
55
|
+
elif io is not None: # first sight — a cold MISS
|
|
56
|
+
io["miss"] += 1
|
|
57
|
+
s.active_files = [p for p in s.active_files if p != path]
|
|
58
|
+
s.active_files.append(path)
|
|
59
|
+
self._ghost_drop(s, "file", path) # it's back in the working set → no longer a ghost
|
|
60
|
+
if edited:
|
|
61
|
+
s.edited_files.add(path)
|
|
62
|
+
self.evict(s)
|
|
63
|
+
|
|
64
|
+
def evict(self, s) -> None:
|
|
65
|
+
"""Keep the change set (edited) + HOT + the WHOLE dependency closure (relevance, not a count) +
|
|
66
|
+
most-recent read_budget exploratory reads; page the rest OUT — non-lossy, since every evicted
|
|
67
|
+
file leaves a GHOST recovery pointer and re-reads on demand (a REFAULT, which also widens the
|
|
68
|
+
budget). Edited/hot/deps never evict for a plain read (re-observation reach must cover them).
|
|
69
|
+
NOTE: whether OPEN FILES should evict at all within a loop is an OPEN design decision (see the
|
|
70
|
+
bound-is-relevance discussion); this is the re-faultable middle ground pending that call."""
|
|
71
|
+
edited_set = {p for p in s.active_files if p in s.edited_files}
|
|
72
|
+
hot_set = {p for p in s.active_files if p in getattr(s, "hot", {})} - edited_set
|
|
73
|
+
protect = edited_set | hot_set
|
|
74
|
+
deps_set = {p for p in s.active_files if p in s.protected_deps and p not in protect}
|
|
75
|
+
read_budget = getattr(s, "read_budget", READ_BUDGET) # LIVE adaptive budget (grows on refault); floor = READ_BUDGET
|
|
76
|
+
reads = [p for p in s.active_files
|
|
77
|
+
if p not in s.edited_files and p not in deps_set and p not in protect][-read_budget:]
|
|
78
|
+
keep = protect | deps_set | set(reads)
|
|
79
|
+
io = getattr(s, "io", None)
|
|
80
|
+
for p in s.active_files:
|
|
81
|
+
if p not in keep:
|
|
82
|
+
s.edit_anchor.pop(p, None)
|
|
83
|
+
s.edited_files.discard(p)
|
|
84
|
+
if io is not None:
|
|
85
|
+
io["evict"] += 1
|
|
86
|
+
self._ghost_add(s, "file", p) # paged out of OPEN FILES → leave a recovery pointer
|
|
87
|
+
s.active_files = [p for p in s.active_files if p in keep]
|
|
88
|
+
|
|
89
|
+
def prefetch(self, s) -> None:
|
|
90
|
+
"""Refresh change-set protected DEPS from the code graph (recomputed once per turn at seed build).
|
|
91
|
+
Also AGE the kernel-granted soft-pins one notch (runs once per build = once per TURN) so refault
|
|
92
|
+
protection is temporary, never an accumulating tier. No graph → deps no-op; the hot decay still runs."""
|
|
93
|
+
if getattr(s, "hot", None):
|
|
94
|
+
s.hot = {p: t - 1 for p, t in s.hot.items() if t - 1 > 0}
|
|
95
|
+
r = self.retriever
|
|
96
|
+
# Snapshot pre-edit def-names of files READ but not yet edited, so we can later see what an edit
|
|
97
|
+
# REMOVED/MOVED (pre-edit defs - current defs). Cheap: reads the already-computed code graph
|
|
98
|
+
# (SENSORY CORTEX — a derived view, not a persisted store).
|
|
99
|
+
if hasattr(r, "def_names") and hasattr(s, "pre_defs"):
|
|
100
|
+
for p in s.active_files:
|
|
101
|
+
if p not in s.edited_files and p not in s.pre_defs:
|
|
102
|
+
s.pre_defs[p] = r.def_names(p)
|
|
103
|
+
if hasattr(r, "deps") and s.edited_files:
|
|
104
|
+
edited = list(s.edited_files) # the WHOLE change set (relevance, not a count) — materialize ONCE
|
|
105
|
+
deps: set = set()
|
|
106
|
+
for e in edited:
|
|
107
|
+
deps.update(r.deps(e, limit=DEP_CEILING)) # all direct callers that break on the change
|
|
108
|
+
s.protected_deps = deps - s.edited_files
|
|
109
|
+
# CHANGE-SET CLOSURE (symbol-aware): names the edits removed/moved, then the dependents whose
|
|
110
|
+
# CURRENT tokens still reference one — precise dangling call-sites. SILENT on feature-adds
|
|
111
|
+
# (nothing removed) so it never inflates non-refactor tasks; render_closure further filters to
|
|
112
|
+
# the UNOPENED ones (self-extinguishing once the model opens the site to fix/confirm).
|
|
113
|
+
if hasattr(r, "def_names") and hasattr(r, "ref_tokens") and hasattr(s, "stale_deps"):
|
|
114
|
+
removed: set = set()
|
|
115
|
+
for e in edited:
|
|
116
|
+
removed |= (s.pre_defs.get(e, set()) - r.def_names(e))
|
|
117
|
+
s.stale_deps = ({d for d in s.protected_deps if r.ref_tokens(d) & removed}
|
|
118
|
+
if removed else set())
|
|
119
|
+
elif not s.edited_files:
|
|
120
|
+
s.protected_deps = set()
|
|
121
|
+
if hasattr(s, "stale_deps"):
|
|
122
|
+
s.stale_deps = set()
|
|
123
|
+
|
|
124
|
+
def load_skill(self, s, name: str, body: str) -> None: # fold a SKILL into the active tier; evict overflow
|
|
125
|
+
if not name or not body:
|
|
126
|
+
return
|
|
127
|
+
s.active_skills = [sk for sk in s.active_skills if sk["name"] != name]
|
|
128
|
+
s.active_skills.append({"name": name, "body": body[:MAX_SKILL_CHARS]})
|
|
129
|
+
self._ghost_drop(s, "skill", name) # freshly loaded → not a ghost
|
|
130
|
+
if len(s.active_skills) > MAX_ACTIVE_SKILLS:
|
|
131
|
+
self.evict_skill(s)
|
|
132
|
+
|
|
133
|
+
def evict_skill(self, s) -> None: # drop skills beyond MAX_ACTIVE_SKILLS (oldest first), each → a ghost
|
|
134
|
+
for sk in s.active_skills[:-MAX_ACTIVE_SKILLS]:
|
|
135
|
+
self._ghost_add(s, "skill", sk["name"]) # evicted skill → recovery pointer
|
|
136
|
+
s.active_skills = s.active_skills[-MAX_ACTIVE_SKILLS:]
|
|
137
|
+
|
|
138
|
+
def note_review(self, s, mark: str) -> None: # RATCHET: record a history lookback (bounded) so it isn't re-done
|
|
139
|
+
if mark and mark not in s.reviewed:
|
|
140
|
+
s.reviewed.append(mark)
|
|
141
|
+
del s.reviewed[:-MAX_REVIEWED]
|
|
142
|
+
|
|
143
|
+
def _is_ghost(self, s, kind: str, ref: str) -> bool: # is this ref currently in the (recency-bounded) ghost ring?
|
|
144
|
+
return any(g["kind"] == kind and g["ref"] == ref for g in s.ghosts)
|
|
145
|
+
|
|
146
|
+
def _promote(self, s, path: str) -> None:
|
|
147
|
+
"""Kernel grants ITSELF a brief reclaim-protection for a thrashing (refaulted) page — refault-driven,
|
|
148
|
+
NO model involvement (the validated automatic-beats-active-asker path). Bounded by HOT_CEILING; decays
|
|
149
|
+
in prefetch (HOT_TTL steps), so it can never become an accumulating tier."""
|
|
150
|
+
hot = getattr(s, "hot", None)
|
|
151
|
+
if hot is None:
|
|
152
|
+
return
|
|
153
|
+
hot[path] = HOT_TTL
|
|
154
|
+
while len(hot) > HOT_CEILING: # drop the oldest soft-pin (insertion-ordered) past the ceiling
|
|
155
|
+
hot.pop(next(iter(hot)))
|
|
156
|
+
|
|
157
|
+
def _grow(self, s) -> None:
|
|
158
|
+
"""A REFAULT proves the resident read budget was momentarily too tight for THIS task (thrash).
|
|
159
|
+
The kernel widens its OWN live budget one notch — the BIDIRECTIONAL counterpart to the overflow
|
|
160
|
+
tighten ladder (which shrinks it). Bounded by the per-slice disaster ceiling (s.read_ceiling);
|
|
161
|
+
kernel-driven from MEASURED thrash (s.io refault), NO model involvement. Monotone within a
|
|
162
|
+
session (a task that needed N reads keeps needing ~N); never history-proportional, so the moat
|
|
163
|
+
holds — growth is TASK/refault-driven and window-bounded, and the kernel can always say no at
|
|
164
|
+
the ceiling. Same signal + same place as _promote: extend self-tuning from a per-file soft-pin
|
|
165
|
+
to the budget itself (Linux mm/workingset, scaled up)."""
|
|
166
|
+
cur = getattr(s, "read_budget", READ_BUDGET)
|
|
167
|
+
ceiling = getattr(s, "read_ceiling", READ_BUDGET_MAX)
|
|
168
|
+
if cur < ceiling:
|
|
169
|
+
s.read_budget = cur + 1
|
|
170
|
+
|
|
171
|
+
def _ghost_add(self, s, kind: str, ref: str) -> None: # paged-out item → bounded recovery POINTER (~0 tokens)
|
|
172
|
+
if not ref:
|
|
173
|
+
return
|
|
174
|
+
s.ghosts = [g for g in s.ghosts if not (g["kind"] == kind and g["ref"] == ref)]
|
|
175
|
+
s.ghosts.append({"kind": kind, "ref": ref})
|
|
176
|
+
s.ghosts = s.ghosts[-MAX_GHOSTS:]
|
|
177
|
+
|
|
178
|
+
def _ghost_drop(self, s, kind: str, ref: str) -> None: # back IN the slice → no longer a ghost
|
|
179
|
+
s.ghosts = [g for g in s.ghosts if not (g["kind"] == kind and g["ref"] == ref)]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_DEFAULT_SWAP = SwapManager() # retriever-free ops; touch_file/add_skill in pfc.py delegate here
|
sliceagent/taskstate.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Pure Slice <-> TaskState mappers (MEMORY-SPEC step 2).
|
|
2
|
+
|
|
3
|
+
No I/O, no memem — keeps pfc.py (the moat) byte-identical. TaskState stores REFS (paths +
|
|
4
|
+
anchors), never file contents; resume re-reads files live (ground truth = disk). Transient tiers
|
|
5
|
+
(recent, action_log, active_skills) are intentionally NOT serialized — they're per-turn residue
|
|
6
|
+
re-derived from ground truth. NOTE on resume: active_skills is dropped (skill bodies live on disk
|
|
7
|
+
and re-load via the `skill` tool); since_edit is zeroed (a fresh action epoch — otherwise the
|
|
8
|
+
restored counter could fire render_convergence's STOP nudge on turn 1). The `conversation` ring +
|
|
9
|
+
`turns` counter are also intentionally NOT carried CROSS-session (they survive within a session via
|
|
10
|
+
seal()): the durable distilled state (goal, findings, world, requirements, edited set) is what a
|
|
11
|
+
resume rebuilds on, and short-range chat is re-grounded from the goal — carrying it would persist
|
|
12
|
+
verbatim user text into the vault for marginal continuity gain.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .interfaces import TaskState
|
|
17
|
+
from .pfc import Slice
|
|
18
|
+
from .text_utils import one_line
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def slice_to_task_state(s: Slice, task_id: str, *, session_id: str = "", title: str = "",
|
|
22
|
+
status: str = "active", tags: str = "", resolution: str = "",
|
|
23
|
+
links: list[str] | None = None) -> TaskState:
|
|
24
|
+
return TaskState(
|
|
25
|
+
task_id=task_id, session_id=session_id,
|
|
26
|
+
title=title or one_line(s.goal, 60), status=status, goal=s.goal,
|
|
27
|
+
findings=list(s.findings),
|
|
28
|
+
finding_source={k: v for k, v in s.finding_source.items() if k in set(s.findings)}, # provenance, bounded to live findings
|
|
29
|
+
requirements=[dict(r) for r in s.requirements], # carry the standing contract across resume
|
|
30
|
+
plan=[dict(p) for p in s.plan], # carry the PLAN (TodoWrite) across resume
|
|
31
|
+
mission=s.mission, # carry the MISSION north-star across resume
|
|
32
|
+
open_report=getattr(s, "open_report", ""), # carry the OPEN USER REPORT blocker (was silently lost)
|
|
33
|
+
world=dict(s.world), # carry the agent WORLD MODEL (was silently lost)
|
|
34
|
+
active_files=list(s.active_files),
|
|
35
|
+
edited_files=sorted(s.edited_files), # byte-stable across checkpoints
|
|
36
|
+
edit_anchor={p: a for p, a in s.edit_anchor.items() if p in s.active_files}, # consistency
|
|
37
|
+
last_error=s.last_error, since_edit=s.since_edit, # serialized faithfully
|
|
38
|
+
links=list(links or []), tags=tags, resolution=resolution,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def task_state_to_slice(ts: TaskState, s: Slice | None = None) -> Slice:
|
|
43
|
+
s = s or Slice()
|
|
44
|
+
s.reset(ts.goal) # zeroes transient tiers (recent/action_log/active_skills/...)
|
|
45
|
+
s.findings = list(ts.findings)
|
|
46
|
+
s.finding_source = dict(getattr(ts, "finding_source", {})) # restore provenance (getattr: back-compat with old checkpoints)
|
|
47
|
+
s.requirements = [dict(r) for r in getattr(ts, "requirements", [])] # restore the standing contract
|
|
48
|
+
s.plan = [dict(p) for p in getattr(ts, "plan", [])] # restore the PLAN (TodoWrite)
|
|
49
|
+
s.mission = getattr(ts, "mission", "") # restore the MISSION north-star
|
|
50
|
+
s.open_report = getattr(ts, "open_report", "") # restore the OPEN USER REPORT blocker
|
|
51
|
+
s.world = dict(getattr(ts, "world", {})) # restore the WORLD MODEL
|
|
52
|
+
s.active_files = list(ts.active_files)
|
|
53
|
+
s.edited_files = set(ts.edited_files)
|
|
54
|
+
s.edit_anchor = dict(ts.edit_anchor)
|
|
55
|
+
s.last_error = ts.last_error
|
|
56
|
+
s.since_edit = 0 # resume = fresh action epoch (don't fire render_convergence)
|
|
57
|
+
return s
|
sliceagent/telemetry.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Reconstruction-quality telemetry — measures whether the slice KEEPS what the model needs, so the
|
|
2
|
+
slice's central bet becomes a NUMBER instead of an anecdote.
|
|
3
|
+
|
|
4
|
+
A pure OBSERVER sink: it consumes the loop's events and accumulates counters. It emits no events and
|
|
5
|
+
mutates no slice — completely off the moat. Deterministic, bounded, zero LLM. Wire it into a
|
|
6
|
+
dispatcher alongside slice_sink (the eval harness does this) and read `.summary()` after the run.
|
|
7
|
+
|
|
8
|
+
Signals:
|
|
9
|
+
- re_reads : a read_file on a path ALREADY read within the last `window` steps. Within a turn the prior
|
|
10
|
+
read is still in the accumulated transcript, so a re-read signals the model isn't using its resident
|
|
11
|
+
context; across turns it's the reconstruction-MISS signal (the seed/seal didn't carry what it needed).
|
|
12
|
+
- recalls : recall_history calls — recovery from the cold cache (the model knew it forgot).
|
|
13
|
+
- reads : total successful read_file calls (the denominator for a re-read RATE).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .events import Event, StepEnd, ToolResult
|
|
18
|
+
|
|
19
|
+
RE_READ_WINDOW = 6 # a path re-read within this many steps counts as a likely reconstruction miss
|
|
20
|
+
_MAX_TRACKED = 256 # bound the per-path last-seen map (never a transcript)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Telemetry:
|
|
24
|
+
"""Callable event sink that counts reconstruction-cost signals. Read `.summary()` after a run."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, window: int = RE_READ_WINDOW):
|
|
27
|
+
self.window = window
|
|
28
|
+
self.step = 0
|
|
29
|
+
self.reads = 0
|
|
30
|
+
self.re_reads = 0
|
|
31
|
+
self.recalls = 0
|
|
32
|
+
self._last_read: dict[str, int] = {} # path -> step it was last read (bounded)
|
|
33
|
+
|
|
34
|
+
def __call__(self, e: Event) -> None:
|
|
35
|
+
if isinstance(e, StepEnd):
|
|
36
|
+
self.step += 1
|
|
37
|
+
elif isinstance(e, ToolResult) and not e.failing:
|
|
38
|
+
if e.name == "read_file":
|
|
39
|
+
self.reads += 1
|
|
40
|
+
path = (e.args or {}).get("path")
|
|
41
|
+
if path:
|
|
42
|
+
last = self._last_read.get(path)
|
|
43
|
+
if last is not None and (self.step - last) <= self.window:
|
|
44
|
+
self.re_reads += 1 # read again soon after — slice didn't carry it
|
|
45
|
+
self._last_read[path] = self.step
|
|
46
|
+
if len(self._last_read) > _MAX_TRACKED: # bound: drop the oldest entry
|
|
47
|
+
del self._last_read[min(self._last_read, key=self._last_read.get)]
|
|
48
|
+
elif e.name == "recall_history":
|
|
49
|
+
self.recalls += 1
|
|
50
|
+
|
|
51
|
+
def summary(self) -> dict:
|
|
52
|
+
rate = round(self.re_reads / self.reads, 3) if self.reads else 0.0
|
|
53
|
+
return {"reads": self.reads, "re_reads": self.re_reads,
|
|
54
|
+
"re_read_rate": rate, "recalls": self.recalls}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def make_telemetry_sink() -> Telemetry:
|
|
58
|
+
"""A Telemetry instance IS the sink (it's callable) AND carries the counters to read afterward."""
|
|
59
|
+
return Telemetry()
|
sliceagent/terminal.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""terminal — persistent interactive PTY sessions (the other half of the live-process gap).
|
|
2
|
+
|
|
3
|
+
``Sandbox.run`` is one-shot and has no stdin, so a whole class of work is impossible: driving a
|
|
4
|
+
REPL or text game through successive prompts, navigating a TUI, sending keys into vim, or just
|
|
5
|
+
holding shell + env state (``cd``, ``export``, venv activation) across many tool calls. A
|
|
6
|
+
``PtySession`` allocates a real pseudo-terminal (stdlib ``pty``), launches a shell (or any program)
|
|
7
|
+
attached to it, and keeps it alive in a registry keyed by name so the agent can ``send`` keys,
|
|
8
|
+
``read`` the live output, ``wait`` for an expected pattern (the "expect" primitive that makes
|
|
9
|
+
interaction reliable), then ``close``.
|
|
10
|
+
|
|
11
|
+
stdlib only — ``pty`` + ``select`` + ``fcntl`` (``pexpect`` isn't a dependency). The master fd is
|
|
12
|
+
non-blocking; reads drain whatever the program has emitted (the output STREAM — not a rendered
|
|
13
|
+
screen-buffer, so full-curses TUIs show raw escape codes, but REPLs / games / line tools read
|
|
14
|
+
cleanly). Children run in their own process group so ``close`` takes down the whole tree.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import codecs
|
|
19
|
+
import fcntl
|
|
20
|
+
import os
|
|
21
|
+
import pty
|
|
22
|
+
import re
|
|
23
|
+
import select
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from .sandbox import _scrub_env
|
|
29
|
+
|
|
30
|
+
_READ_CHUNK = 65536
|
|
31
|
+
_BUF_CAP = 200_000 # keep the tail of a chatty session bounded
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Session:
|
|
35
|
+
__slots__ = ("name", "cmd", "master", "popen", "buf", "_decoder")
|
|
36
|
+
|
|
37
|
+
def __init__(self, name, cmd, master, popen):
|
|
38
|
+
self.name = name
|
|
39
|
+
self.cmd = cmd
|
|
40
|
+
self.master = master
|
|
41
|
+
self.popen = popen
|
|
42
|
+
self.buf = "" # output accumulated since the last read/wait returned it
|
|
43
|
+
# STATEFUL utf-8 decoder: a multibyte char split across two os.read() boundaries must not decode to
|
|
44
|
+
# U+FFFD on both halves — the decoder holds the partial sequence until its continuation bytes arrive.
|
|
45
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionManager:
|
|
49
|
+
"""Registry of live interactive PTY sessions. Single-threaded (the agent loop is)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, *, scrub_secrets: bool = True):
|
|
52
|
+
self.scrub_secrets = scrub_secrets
|
|
53
|
+
self._s: dict[str, _Session] = {}
|
|
54
|
+
|
|
55
|
+
# ── lifecycle ──────────────────────────────────────────────────────────
|
|
56
|
+
def open(self, name: str, *, cwd: str, command: str | None = None) -> str:
|
|
57
|
+
if name in self._s:
|
|
58
|
+
raise ValueError(f"session {name!r} is already open (close it first, or use another name)")
|
|
59
|
+
master, slave = pty.openpty()
|
|
60
|
+
env = _scrub_env() if self.scrub_secrets else dict(os.environ)
|
|
61
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
62
|
+
env.setdefault("TERM", "xterm")
|
|
63
|
+
try:
|
|
64
|
+
try:
|
|
65
|
+
popen = self._spawn_pty(command, cwd, env, slave)
|
|
66
|
+
finally:
|
|
67
|
+
os.close(slave) # parent keeps only the master end
|
|
68
|
+
except BaseException:
|
|
69
|
+
os.close(master) # #19: Popen failed — don't leak the master fd too
|
|
70
|
+
raise
|
|
71
|
+
try: # a fcntl failure AFTER spawn must tear down both the master fd AND the orphaned child (#19)
|
|
72
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
73
|
+
fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
74
|
+
except BaseException:
|
|
75
|
+
try:
|
|
76
|
+
os.killpg(os.getpgid(popen.pid), signal.SIGKILL)
|
|
77
|
+
except Exception: # noqa: BLE001
|
|
78
|
+
pass
|
|
79
|
+
os.close(master)
|
|
80
|
+
raise
|
|
81
|
+
self._s[name] = _Session(name, command or "(shell)", master, popen)
|
|
82
|
+
return name
|
|
83
|
+
|
|
84
|
+
def _spawn_pty(self, command, cwd, env, slave):
|
|
85
|
+
"""Launch the PTY-attached process. OVERRIDABLE SEAM: a container variant relaunches the same
|
|
86
|
+
command through `docker exec -it` so the session lives INSIDE the task container (host path here)."""
|
|
87
|
+
if command: # run the given program directly on the PTY
|
|
88
|
+
return subprocess.Popen(command, shell=True, cwd=cwd, env=env,
|
|
89
|
+
stdin=slave, stdout=slave, stderr=slave,
|
|
90
|
+
start_new_session=True, close_fds=True)
|
|
91
|
+
shell = os.environ.get("SHELL") or "/bin/bash" # interactive shell (holds cd/env across turns)
|
|
92
|
+
return subprocess.Popen([shell], cwd=cwd, env=env,
|
|
93
|
+
stdin=slave, stdout=slave, stderr=slave,
|
|
94
|
+
start_new_session=True, close_fds=True)
|
|
95
|
+
|
|
96
|
+
def send(self, name: str, keys: str, *, enter: bool = True, timeout: float = 30.0) -> str:
|
|
97
|
+
sess = self._get(name)
|
|
98
|
+
data = (keys + ("\n" if enter else "")).encode("utf-8", errors="replace")
|
|
99
|
+
# The master fd is O_NONBLOCK: one os.write may write only PART of a large payload (the rest would be
|
|
100
|
+
# silently dropped), and a FULL buffer raises BlockingIOError (EAGAIN) — back-pressure, not a dead
|
|
101
|
+
# session. Loop until every byte is written, waiting for writability on EAGAIN — but under an OVERALL
|
|
102
|
+
# deadline, so a child that has stopped reading stdin can't wedge the (possibly inline) loop thread
|
|
103
|
+
# forever. Reserve the "may have exited" error for a genuine OSError.
|
|
104
|
+
view = memoryview(data)
|
|
105
|
+
deadline = time.time() + timeout
|
|
106
|
+
try:
|
|
107
|
+
while view:
|
|
108
|
+
try:
|
|
109
|
+
view = view[os.write(sess.master, view):]
|
|
110
|
+
except BlockingIOError:
|
|
111
|
+
remaining = deadline - time.time()
|
|
112
|
+
if remaining <= 0:
|
|
113
|
+
raise ValueError(f"session {name!r} not writable within {timeout:g}s; the program "
|
|
114
|
+
"isn't reading its input (stdin) or its output buffer is full") from None
|
|
115
|
+
select.select([], [sess.master], [], min(remaining, 5)) # wait for the PTY to drain, then retry
|
|
116
|
+
except OSError as e:
|
|
117
|
+
raise ValueError(f"session {name!r} is not writable ({e}); it may have exited") from None
|
|
118
|
+
# peek (NON-consuming) so the response is still there for a following read/wait — otherwise
|
|
119
|
+
# send would eat the very output the model is about to wait for.
|
|
120
|
+
return self.peek(name, timeout=0.4)
|
|
121
|
+
|
|
122
|
+
def read(self, name: str, *, timeout: float = 1.0) -> str:
|
|
123
|
+
sess = self._get(name)
|
|
124
|
+
self._drain(sess, hard=timeout)
|
|
125
|
+
out, sess.buf = sess.buf, ""
|
|
126
|
+
if not out:
|
|
127
|
+
return f"(no output; {self._status(sess)})"
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
def peek(self, name: str, *, timeout: float = 0.6) -> str:
|
|
131
|
+
"""Drain output and return it WITHOUT consuming — so a later read/wait still sees it.
|
|
132
|
+
Used right after open() so the program's first prompt isn't eaten by the banner read."""
|
|
133
|
+
sess = self._get(name)
|
|
134
|
+
self._drain(sess, hard=timeout)
|
|
135
|
+
return sess.buf or f"(no output yet; {self._status(sess)})"
|
|
136
|
+
|
|
137
|
+
def wait(self, name: str, pattern: str, *, timeout: float = 10.0) -> str:
|
|
138
|
+
"""Drain until `pattern` (regex) appears or timeout — the reliable interaction primitive."""
|
|
139
|
+
sess = self._get(name)
|
|
140
|
+
# #20: bound the (model-supplied) pattern — cap its length to limit catastrophic-backtracking
|
|
141
|
+
# surface, and fail clearly on a bad regex instead of crashing the tool. (Python's re has no
|
|
142
|
+
# match timeout; the haystack is this subprocess's own output, so length-capping is the mitigation.)
|
|
143
|
+
if len(pattern) > 500:
|
|
144
|
+
raise ValueError("wait pattern too long (max 500 chars)")
|
|
145
|
+
try:
|
|
146
|
+
rx = re.compile(pattern)
|
|
147
|
+
except re.error as e:
|
|
148
|
+
raise ValueError(f"invalid wait pattern: {e}")
|
|
149
|
+
end = time.time() + timeout
|
|
150
|
+
# inspect already-buffered output at least once BEFORE the timeout loop — so timeout<=0 still polls
|
|
151
|
+
# (matches a buffered hit instead of false-negativing it and then clearing the buffer below).
|
|
152
|
+
self._drain(sess, hard=min(0.4, timeout) if timeout > 0 else 0.0)
|
|
153
|
+
m = rx.search(sess.buf)
|
|
154
|
+
while m is None and time.time() < end:
|
|
155
|
+
self._drain(sess, hard=0.4)
|
|
156
|
+
m = rx.search(sess.buf)
|
|
157
|
+
if m:
|
|
158
|
+
break
|
|
159
|
+
if sess.popen.poll() is not None: # process died — one last drain, then stop
|
|
160
|
+
self._drain(sess, hard=0.2)
|
|
161
|
+
m = rx.search(sess.buf)
|
|
162
|
+
break
|
|
163
|
+
if m: # expect semantics: consume up to the match, KEEP the rest
|
|
164
|
+
cut = m.end()
|
|
165
|
+
out, sess.buf = sess.buf[:cut], sess.buf[cut:]
|
|
166
|
+
return f"[{name}: matched; {self._status(sess)}]\n{out}"
|
|
167
|
+
out, sess.buf = sess.buf, "" # timeout: return + clear everything seen
|
|
168
|
+
return f"[{name}: NO match for {pattern!r} before timeout; {self._status(sess)}]\n{out or '(no output)'}"
|
|
169
|
+
|
|
170
|
+
def close(self, name: str) -> str:
|
|
171
|
+
sess = self._get(name)
|
|
172
|
+
if sess.popen.poll() is None:
|
|
173
|
+
try:
|
|
174
|
+
os.killpg(os.getpgid(sess.popen.pid), signal.SIGTERM)
|
|
175
|
+
except OSError:
|
|
176
|
+
try:
|
|
177
|
+
sess.popen.terminate()
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
try:
|
|
181
|
+
sess.popen.wait(timeout=3)
|
|
182
|
+
except subprocess.TimeoutExpired:
|
|
183
|
+
try:
|
|
184
|
+
os.killpg(os.getpgid(sess.popen.pid), signal.SIGKILL)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
try:
|
|
188
|
+
sess.popen.wait(timeout=2) # reap the SIGKILLed child so it isn't left a zombie at fd close
|
|
189
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
190
|
+
pass
|
|
191
|
+
try:
|
|
192
|
+
os.close(sess.master)
|
|
193
|
+
except OSError:
|
|
194
|
+
pass
|
|
195
|
+
self._s.pop(name, None)
|
|
196
|
+
return f"closed {name}"
|
|
197
|
+
|
|
198
|
+
def list(self) -> str:
|
|
199
|
+
if not self._s:
|
|
200
|
+
return "(no terminal sessions)"
|
|
201
|
+
return "\n".join(f"{n}: {self._status(s)} — {s.cmd}" for n, s in self._s.items())
|
|
202
|
+
|
|
203
|
+
def cleanup(self) -> None:
|
|
204
|
+
for n in list(self._s):
|
|
205
|
+
try:
|
|
206
|
+
self.close(n)
|
|
207
|
+
except Exception: # noqa: BLE001 — best-effort teardown
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# ── internals ──────────────────────────────────────────────────────────
|
|
211
|
+
def _get(self, name: str) -> _Session:
|
|
212
|
+
s = self._s.get(name)
|
|
213
|
+
if s is None:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"unknown session {name!r}. Open: {', '.join(self._s) or '(none)'}")
|
|
216
|
+
return s
|
|
217
|
+
|
|
218
|
+
def _drain(self, sess: _Session, *, hard: float) -> None:
|
|
219
|
+
"""Read everything currently available, stopping on a short idle gap or `hard` cap."""
|
|
220
|
+
end = time.time() + hard
|
|
221
|
+
while time.time() < end:
|
|
222
|
+
r, _, _ = select.select([sess.master], [], [], min(0.15, hard))
|
|
223
|
+
if not r:
|
|
224
|
+
break # no more output right now
|
|
225
|
+
try:
|
|
226
|
+
chunk = os.read(sess.master, _READ_CHUNK)
|
|
227
|
+
except (BlockingIOError, InterruptedError):
|
|
228
|
+
continue
|
|
229
|
+
except OSError:
|
|
230
|
+
break # master closed / child gone
|
|
231
|
+
if not chunk:
|
|
232
|
+
break # EOF
|
|
233
|
+
sess.buf += sess._decoder.decode(chunk) # incremental: holds a partial multibyte tail across reads
|
|
234
|
+
if len(sess.buf) > _BUF_CAP:
|
|
235
|
+
sess.buf = "…[earlier output elided]…\n" + sess.buf[-_BUF_CAP:]
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _status(sess: _Session) -> str:
|
|
239
|
+
rc = sess.popen.poll()
|
|
240
|
+
return "alive" if rc is None else f"exited {rc}"
|
sliceagent/text_utils.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tiny shared text helpers — ONE home for the whitespace/timestamp normalizers that were copy-pasted
|
|
2
|
+
across regions.py, skills.py, pagetable.py and hippocampus.py. Leaf module (no intra-package imports) so
|
|
3
|
+
any module can use it without an import cycle. Behavior is byte-identical to the expressions it replaces.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
_WS = re.compile(r"\s+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def now_iso() -> str:
|
|
14
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_ws(s) -> str:
|
|
18
|
+
"""Collapse all runs of whitespace to a single space and strip. `None`/non-str -> ''."""
|
|
19
|
+
return _WS.sub(" ", str(s or "")).strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def one_line(s, n: int = 80) -> str:
|
|
23
|
+
"""`normalize_ws` truncated to `n` chars — a one-line, bounded rendering of arbitrary text."""
|
|
24
|
+
return normalize_ws(s)[:n]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_ts(ts) -> str:
|
|
28
|
+
"""ISO timestamp -> compact 'MM-DD HH:MM' (e.g. '06-16 12:30'); '' for empty/None."""
|
|
29
|
+
return (ts or "")[5:16].replace("T", " ")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Pure social/greeting messages that never imply work. The host answers these on a CHEAP path — no slice,
|
|
33
|
+
# no tool schemas, a tiny prompt — so a "hi" or "thanks" doesn't pay the full per-turn token cost.
|
|
34
|
+
_CHITCHAT = frozenset({
|
|
35
|
+
"hi", "hii", "hiya", "hello", "hey", "heya", "hey there", "hi there", "hello there", "yo", "sup",
|
|
36
|
+
"howdy", "good morning", "good afternoon", "good evening", "morning", "evening", "gm",
|
|
37
|
+
"how are you", "how are you?", "how's it going", "hows it going", "what's up", "whats up", "how's things",
|
|
38
|
+
"thanks", "thank you", "thanks!", "thank you!", "thx", "ty", "tysm", "cheers", "much appreciated",
|
|
39
|
+
"appreciate it", "thanks a lot", "thank you so much",
|
|
40
|
+
"ok", "okay", "k", "kk", "cool", "nice", "great", "awesome", "perfect", "got it", "sounds good",
|
|
41
|
+
"great thanks", "ok thanks", "okay thanks", "nice thanks", "perfect thanks",
|
|
42
|
+
"bye", "goodbye", "see you", "see ya", "later", "good night", "gn", "night",
|
|
43
|
+
"lol", "haha", "nice one", "well done", "good job", "good bot", "gg",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
# Minimal system prompt for the chitchat fast-path — keep it tiny; the whole point is to spend few tokens.
|
|
47
|
+
CHITCHAT_PROMPT = ("You are sliceagent, a coding agent. The user sent a brief greeting or social message. "
|
|
48
|
+
"Reply in ONE short, warm line. Do not call tools, summarize, or start any work.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_chitchat(text) -> bool:
|
|
52
|
+
"""True ONLY for a pure greeting/social message (high precision — never fires on a real request). The
|
|
53
|
+
whole message must match a known social phrase after trimming punctuation; a length cap guards against
|
|
54
|
+
anything substantive ('hi, can you fix X' is not chitchat)."""
|
|
55
|
+
t = str(text or "").strip().lower().rstrip("!.?,~ ")
|
|
56
|
+
return 0 < len(t) <= 40 and t in _CHITCHAT
|