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/__init__.py
ADDED
sliceagent/__main__.py
ADDED
sliceagent/access.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Resource-access model for safe tool parallelism.
|
|
2
|
+
|
|
3
|
+
Each tool declares what it touches; the scheduler runs non-conflicting tool calls
|
|
4
|
+
concurrently and serializes conflicting ones. Two accesses conflict iff one is
|
|
5
|
+
`AllAccess` (globally exclusive, e.g. a shell), OR one writes AND their paths overlap.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FileAccess:
|
|
14
|
+
operation: str # "read" | "write" | "readwrite" | "search"
|
|
15
|
+
path: str
|
|
16
|
+
recursive: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AllAccess:
|
|
21
|
+
"""An un-representable side effect (shell, network). Globally exclusive."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ReadAllAccess:
|
|
26
|
+
"""A workspace-WIDE READ with no side effect (a read-only explorer subagent: it may read/list/grep/
|
|
27
|
+
recall anywhere, but cannot write/run). Conflicts with any WRITER (AllAccess or a write FileAccess)
|
|
28
|
+
but NEVER with another ReadAllAccess or a plain read — so N explorers fan out concurrently while any
|
|
29
|
+
writing child still serializes. This is what turns the dormant one-at-a-time swarm into a real swarm."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Access = FileAccess | AllAccess | ReadAllAccess
|
|
33
|
+
Accesses = list[Access]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# convenience builders
|
|
37
|
+
def none() -> Accesses:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def all_() -> Accesses:
|
|
42
|
+
return [AllAccess()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_file(path: str) -> Accesses:
|
|
46
|
+
return [FileAccess("read", path)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_file(path: str) -> Accesses:
|
|
50
|
+
return [FileAccess("readwrite", path)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def search_tree(path: str) -> Accesses:
|
|
54
|
+
return [FileAccess("search", path, recursive=True)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _writes(op: str) -> bool:
|
|
58
|
+
return op in ("write", "readwrite")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _norm(path: str) -> str:
|
|
62
|
+
p = path.replace("\\", "/")
|
|
63
|
+
while "//" in p:
|
|
64
|
+
p = p.replace("//", "/")
|
|
65
|
+
p = p.lower()
|
|
66
|
+
return p[:-1] if len(p) > 1 and p.endswith("/") else p
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _overlap(left: FileAccess, right: FileAccess) -> bool:
|
|
70
|
+
lp, rp = _norm(left.path), _norm(right.path)
|
|
71
|
+
if lp == rp:
|
|
72
|
+
return True
|
|
73
|
+
lpre = lp if lp.endswith("/") else lp + "/"
|
|
74
|
+
rpre = rp if rp.endswith("/") else rp + "/"
|
|
75
|
+
return (left.recursive and rp.startswith(lpre)) or (right.recursive and lp.startswith(rpre))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _pair_conflict(left: Access, right: Access) -> bool:
|
|
79
|
+
if isinstance(left, AllAccess) or isinstance(right, AllAccess):
|
|
80
|
+
return True
|
|
81
|
+
ra_left, ra_right = isinstance(left, ReadAllAccess), isinstance(right, ReadAllAccess)
|
|
82
|
+
if ra_left or ra_right:
|
|
83
|
+
if ra_left and ra_right:
|
|
84
|
+
return False # two workspace-wide reads never conflict
|
|
85
|
+
other = right if ra_left else left # the other side is a FileAccess (AllAccess handled above)
|
|
86
|
+
return _writes(other.operation) # read-all conflicts ONLY with a write, never with a read
|
|
87
|
+
if not (_writes(left.operation) or _writes(right.operation)):
|
|
88
|
+
return False # read/read, read/search never conflict
|
|
89
|
+
return _overlap(left, right)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def conflict(left: Accesses, right: Accesses) -> bool:
|
|
93
|
+
return any(_pair_conflict(a, b) for a in left for b in right)
|
sliceagent/agents.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Named-agent registry — file-defined subagent KINDS.
|
|
2
|
+
|
|
3
|
+
sliceagent's subagents were two HARDCODED kinds (read-only explorer + writable). The kinds are now a
|
|
4
|
+
pluggable REGISTRY: each agent is a {name, description, tools-allowlist, reasoning, system-prompt}
|
|
5
|
+
definition, discovered from `<root>/agents/*.md` (markdown + frontmatter — sliceagent's own SKILL.md idiom),
|
|
6
|
+
and the model spawns one BY NAME via the generic `spawn_agent` tool. Built-ins (explorer, general) ship
|
|
7
|
+
in-tree; user files add or override by name.
|
|
8
|
+
|
|
9
|
+
Periphery, NOT the moat: a spawned agent still runs the bounded slice loop and returns only a summary.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import glob
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# An EXPLORER's read-only surface — the single source of truth (subagent.py imports this).
|
|
18
|
+
# `grep` (find by CONTENT) + `glob` (find by NAME) are the two discovery tools; both read-only.
|
|
19
|
+
READ_ONLY_TOOLS = ("read_file", "list_files", "grep", "glob", "skill", "recall_history", "code_review")
|
|
20
|
+
_READ_ONLY_SET = frozenset(READ_ONLY_TOOLS) # mutability is decided against this KNOWN-safe set (pessimistic)
|
|
21
|
+
|
|
22
|
+
# Tools NO subagent may use, regardless of its allowlist. A
|
|
23
|
+
# subagent must not stop to ask the END-USER — ambiguity is the parent's job; a child that blocks on input
|
|
24
|
+
# is a stall (and racy/meaningless when several run in parallel). It returns its summary instead.
|
|
25
|
+
SUBAGENT_EXCLUDED_TOOLS = frozenset({"ask_user"})
|
|
26
|
+
|
|
27
|
+
# Mutating tools — an agent whose allowlist includes ANY of these is "writable" (globally serialized vs
|
|
28
|
+
# other writers); an allowlist with none of them is read-only (parallelizes as a swarm).
|
|
29
|
+
WRITE_TOOLS = frozenset({
|
|
30
|
+
"edit_file", "append_to_file", "str_replace", "run_command", "execute_code",
|
|
31
|
+
"world_set", "world_clear", "require", "drop_requirement", "requirement_done", "update_plan",
|
|
32
|
+
"set_mission", "mission_done",
|
|
33
|
+
"terminal_open", "terminal_send", "terminal_read", "terminal_wait", "terminal_close",
|
|
34
|
+
"proc_start", "proc_poll", "proc_tail", "proc_wait", "proc_kill",
|
|
35
|
+
"spawn_subagent", "spawn_explore", "spawn_agent",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AgentSpec:
|
|
41
|
+
"""One subagent KIND. `tools=None` → inherit the parent's FULL tool surface (a 'general' agent)."""
|
|
42
|
+
name: str
|
|
43
|
+
description: str = ""
|
|
44
|
+
tools: tuple[str, ...] | None = None # allowlist of tool names the child may use (None = all)
|
|
45
|
+
reasoning: str | None = None # "fast" | "full" | None (inherit the parent's)
|
|
46
|
+
system_prompt: str = "" # extra system-prompt layer prepended for the child
|
|
47
|
+
summary_is_deliverable: bool = False # the child's SUMMARY is the product (a trailing failing check is
|
|
48
|
+
# intentional, not a crash) — like a verifier that ends on a FAIL.
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def read_only(self) -> bool:
|
|
52
|
+
"""A child is read-only iff EVERY tool in its allowlist is a KNOWN read-only tool. Pessimistic by
|
|
53
|
+
design: an unknown / plugin / MCP tool is NOT assumed safe (the old check only excluded the static
|
|
54
|
+
WRITE_TOOLS names, so a side-effecting plugin tool was mis-classified read-only and could be
|
|
55
|
+
scheduled as a parallel non-writer). None (full surface) is writable."""
|
|
56
|
+
return self.tools is not None and set(self.tools).issubset(_READ_ONLY_SET)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
BUILTIN_AGENTS: dict[str, AgentSpec] = {
|
|
60
|
+
"explorer": AgentSpec(
|
|
61
|
+
name="explorer",
|
|
62
|
+
description="Read-only investigation — find files, trace usages, understand code; returns a summary. "
|
|
63
|
+
"Fan out several in one turn for breadth.",
|
|
64
|
+
tools=READ_ONLY_TOOLS, reasoning="fast",
|
|
65
|
+
system_prompt="You are a read-only EXPLORER subagent: investigate the task by reading/grepping and "
|
|
66
|
+
"return a concise summary of what you found (files, locations, conclusions). You cannot "
|
|
67
|
+
"modify anything — do not attempt edits or commands.",
|
|
68
|
+
),
|
|
69
|
+
"general": AgentSpec(
|
|
70
|
+
name="general",
|
|
71
|
+
description="A full sub-agent for ONE self-contained sub-task (can read AND edit/run); returns a summary.",
|
|
72
|
+
tools=None, reasoning=None,
|
|
73
|
+
system_prompt="You are a SUBAGENT handling one self-contained sub-task in the shared workspace. Do the "
|
|
74
|
+
"work, then return a concise summary of what you changed and verified. Do NOT ask the "
|
|
75
|
+
"user; if the task is ambiguous, make the best reasonable choice and note it in the summary.",
|
|
76
|
+
),
|
|
77
|
+
# An independent ADVERSARIAL verifier. Runs in a FRESH
|
|
78
|
+
# slice and returns only a VERDICT + evidence — so it complements the parent's structural done-gates
|
|
79
|
+
# (OracleHook/SelfCheckHook) with a second, skeptical opinion WITHOUT any context crossing the seal.
|
|
80
|
+
# Read-only EXCEPT running checks: read/grep + run_command/execute_code (to build/test/probe), no edit
|
|
81
|
+
# tools (the allowlist is enforced at runtime in subagent.py). It is "writable" by classification (shell
|
|
82
|
+
# is in WRITE_TOOLS) so it serializes vs other writers — correct for a verifier that runs tests.
|
|
83
|
+
"verification": AgentSpec(
|
|
84
|
+
name="verification",
|
|
85
|
+
description="Independent adversarial VERIFIER — given a change/claim, TRY TO BREAK IT (reproduce, run "
|
|
86
|
+
"build/tests, probe edges) and return VERDICT: PASS/FAIL/PARTIAL with command evidence. "
|
|
87
|
+
"Read-only except running checks. Spawn after a non-trivial change, before reporting done.",
|
|
88
|
+
tools=READ_ONLY_TOOLS + ("run_command", "execute_code"),
|
|
89
|
+
reasoning="full",
|
|
90
|
+
summary_is_deliverable=True, # a FAIL verdict normally ends on a failing check — that's the product,
|
|
91
|
+
# not a crash; don't reclassify it as "did not finish cleanly".
|
|
92
|
+
system_prompt=(
|
|
93
|
+
"You are an independent VERIFICATION subagent. Your job is NOT to confirm the work is done — it is "
|
|
94
|
+
"to TRY TO BREAK IT. You are given a task/claim and the change that was made; verify it "
|
|
95
|
+
"INDEPENDENTLY and decide.\n"
|
|
96
|
+
"Avoid two failure modes: (1) verification AVOIDANCE — reading code and narrating what you WOULD "
|
|
97
|
+
"test, then writing PASS. Reading is NOT verification; RUN it. (2) being seduced by the first 80% "
|
|
98
|
+
"— a passing test suite or the happy path is not proof; your value is the last 20%.\n"
|
|
99
|
+
"DO NOT MODIFY THE PROJECT: no editing/creating/deleting project files, no installing deps, no git "
|
|
100
|
+
"writes. You MAY write EPHEMERAL probe scripts to a temp dir WHERE THE SANDBOX ALLOWS (e.g. $TMPDIR "
|
|
101
|
+
"or /tmp, via run_command/execute_code) and clean up after yourself.\n"
|
|
102
|
+
"Method: REPRODUCE the original issue/scenario; run the cheapest sufficient build/test; then RUN at "
|
|
103
|
+
"least ONE adversarial probe — a boundary/empty/large input, idempotency, the EXACT property the "
|
|
104
|
+
"task names, or a related path that could regress. The implementer is also an LLM, so its tests may "
|
|
105
|
+
"be happy-path — verify end-to-end yourself.\n"
|
|
106
|
+
"Before PASS: you must have RUN at least one adversarial probe and observed its real output. Before "
|
|
107
|
+
"FAIL: check the issue isn't already handled elsewhere or intentional.\n"
|
|
108
|
+
"Format every check as — Check: <what> / Command: <exact> / Output: <actual observed, not "
|
|
109
|
+
"paraphrased> / PASS or FAIL. A check with no command output is a SKIP, not a PASS. END your "
|
|
110
|
+
"summary with EXACTLY one line: 'VERDICT: PASS' or 'VERDICT: FAIL' or 'VERDICT: PARTIAL' (PARTIAL "
|
|
111
|
+
"only for environment limits — missing tool/deps/can't run — never for 'unsure'). Do NOT ask the user."
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_agent_md(path: str) -> AgentSpec | None:
|
|
118
|
+
"""Parse an agent file: optional `---` frontmatter (name/description/tools/reasoning) + body = system
|
|
119
|
+
prompt. Never raises — a malformed/unreadable file is skipped (returns None)."""
|
|
120
|
+
try:
|
|
121
|
+
with open(path, encoding="utf-8") as f:
|
|
122
|
+
text = f.read()
|
|
123
|
+
except OSError:
|
|
124
|
+
return None
|
|
125
|
+
meta: dict[str, str] = {}
|
|
126
|
+
body = text
|
|
127
|
+
if text.startswith("---"):
|
|
128
|
+
end = text.find("\n---", 3)
|
|
129
|
+
if end == -1:
|
|
130
|
+
# opening fence but no closing one (authoring typo). FAIL CLOSED: don't fall through to the
|
|
131
|
+
# no-frontmatter path, which would leave tools=None (= full writable surface) for a file that
|
|
132
|
+
# was trying to declare a restrictive tool list. Skip it, per the "malformed → skipped" contract.
|
|
133
|
+
return None
|
|
134
|
+
if end != -1:
|
|
135
|
+
for line in text[3:end].splitlines():
|
|
136
|
+
line = line.strip()
|
|
137
|
+
if ":" in line and not line.startswith("#"):
|
|
138
|
+
k, v = line.split(":", 1)
|
|
139
|
+
meta[k.strip().lower()] = v.strip()
|
|
140
|
+
body = text[end + 4:].lstrip("\n")
|
|
141
|
+
name = meta.get("name") or os.path.splitext(os.path.basename(path))[0]
|
|
142
|
+
if not name:
|
|
143
|
+
return None
|
|
144
|
+
tools_raw = meta.get("tools")
|
|
145
|
+
# #58: accept both the scalar list `tools: a, b` AND inline YAML `tools: [a, b]` — strip brackets/quotes
|
|
146
|
+
# before splitting so a bracketed value doesn't become tool names like "[a".
|
|
147
|
+
# A PRESENT-but-blank `tools:` means restrict to ZERO tools (read-only, matching `tools: []`); only an
|
|
148
|
+
# ABSENT key grants the full writable surface (None).
|
|
149
|
+
if "tools" in meta and not str(tools_raw or "").strip():
|
|
150
|
+
tools = ()
|
|
151
|
+
elif tools_raw:
|
|
152
|
+
tools = tuple(t for t in tools_raw.replace(",", " ").replace("[", " ").replace("]", " ")
|
|
153
|
+
.replace("'", " ").replace('"', " ").split() if t)
|
|
154
|
+
else:
|
|
155
|
+
tools = None
|
|
156
|
+
reasoning = (meta.get("reasoning") or "").lower() or None
|
|
157
|
+
return AgentSpec(name=name, description=meta.get("description", ""),
|
|
158
|
+
tools=tools, reasoning=reasoning, system_prompt=body.strip())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_agents(roots) -> dict[str, AgentSpec]:
|
|
162
|
+
"""Built-in agents overlaid with user-defined `<root>/agents/*.md` (later roots / user files win by
|
|
163
|
+
name). `roots` are dirs that MAY contain an `agents/` subdir."""
|
|
164
|
+
out = dict(BUILTIN_AGENTS)
|
|
165
|
+
for root in roots or []:
|
|
166
|
+
adir = os.path.join(root, "agents")
|
|
167
|
+
if not os.path.isdir(adir):
|
|
168
|
+
continue
|
|
169
|
+
for path in sorted(glob.glob(os.path.join(adir, "*.md"))):
|
|
170
|
+
spec = _parse_agent_md(path)
|
|
171
|
+
if spec:
|
|
172
|
+
out[spec.name] = spec
|
|
173
|
+
return out
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Async background-review fork (item 16) — OPT-IN, OFF by default, behind an env flag.
|
|
2
|
+
|
|
3
|
+
After a turn, fork a daemon thread that critiques the just-finished episode and writes lessons
|
|
4
|
+
to the memory store. sliceagent has NO transcript to replay, so
|
|
5
|
+
the fork instead re-runs the EXISTING consolidation code (consolidate.promote_episodes /
|
|
6
|
+
promote_procedures) over the durable episodic JSONL — same write surface as session-end
|
|
7
|
+
consolidate, just incremental and off the critical path.
|
|
8
|
+
|
|
9
|
+
WHY THIS CAN'T DESTABILIZE THE DEFAULT PATH (the hard requirement):
|
|
10
|
+
- OFF by default. Only runs when AGENT_BACKGROUND_REVIEW is truthy.
|
|
11
|
+
- The fork reads ONLY the durable episodic cache (already flushed to disk by hippocampus.py's EpisodeSink)
|
|
12
|
+
and writes ONLY to durable stores (memem remember / SKILL.md / FTS5 index). It NEVER
|
|
13
|
+
touches the Slice, the Session, the loop, the dispatcher, or the prompt cache.
|
|
14
|
+
- It is a daemon thread: it can't block process exit and an exception in it is swallowed.
|
|
15
|
+
- Re-entrancy guard: at most one review in flight; a new turn while one runs is skipped.
|
|
16
|
+
- The main turn has already returned before this is even spawned (cli wires it AFTER
|
|
17
|
+
run_turn), so latency is never on the user's critical path.
|
|
18
|
+
|
|
19
|
+
NO-TRANSCRIPT INVARIANT: upheld structurally — the worker's only inputs are durable records
|
|
20
|
+
and its only outputs are durable stores. Nothing it does can enter a slice tier directly; a
|
|
21
|
+
lesson it writes is recalled later through the SAME relevance-gated memory tier as any other.
|
|
22
|
+
|
|
23
|
+
PUBLIC SIGNATURES (pinned):
|
|
24
|
+
background_review_enabled() -> bool
|
|
25
|
+
class BackgroundReviewer:
|
|
26
|
+
def __init__(self, memory, *, scope: str, on_log=None) -> None
|
|
27
|
+
def review(self, session_id: str) -> None # spawns the daemon (no-op if disabled/busy)
|
|
28
|
+
def join(self, timeout: float | None = None) -> None # tests/shutdown: wait for the worker
|
|
29
|
+
make_background_reviewer(memory, *, scope: str, on_log=None) -> BackgroundReviewer | None
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import threading
|
|
35
|
+
|
|
36
|
+
_ENV_FLAG = "AGENT_BACKGROUND_REVIEW"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def background_review_enabled() -> bool:
|
|
40
|
+
"""True iff AGENT_BACKGROUND_REVIEW is set truthy. OFF by default — the whole feature is
|
|
41
|
+
inert unless explicitly opted in, so the synchronous default path is byte-for-byte unchanged."""
|
|
42
|
+
return str(os.environ.get(_ENV_FLAG, "")).strip().lower() in ("1", "true", "yes", "on")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BackgroundReviewer:
|
|
46
|
+
"""Forks a daemon thread per review that consolidates the latest episode incrementally.
|
|
47
|
+
|
|
48
|
+
Reuses consolidate.promote_episodes / promote_procedures verbatim (the moat's existing
|
|
49
|
+
write logic) — this module adds ONLY the fork + safety scaffolding, no new mining logic.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, memory, *, scope: str, on_log=None) -> None:
|
|
53
|
+
self.memory = memory
|
|
54
|
+
self.scope = scope
|
|
55
|
+
self._on_log = on_log
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self._busy = False
|
|
58
|
+
self._thread: threading.Thread | None = None
|
|
59
|
+
|
|
60
|
+
def review(self, session_id: str) -> None:
|
|
61
|
+
"""Spawn the review daemon for `session_id`. No-op when disabled, when memory isn't
|
|
62
|
+
durable, or when a prior review is still running (re-entrancy guard). Returns
|
|
63
|
+
immediately — the work happens off-thread."""
|
|
64
|
+
if not background_review_enabled():
|
|
65
|
+
return
|
|
66
|
+
if not getattr(self.memory, "is_durable", False):
|
|
67
|
+
return
|
|
68
|
+
with self._lock:
|
|
69
|
+
if self._busy:
|
|
70
|
+
return # at most one review in flight
|
|
71
|
+
self._busy = True
|
|
72
|
+
t = threading.Thread(
|
|
73
|
+
target=self._run, args=(session_id,),
|
|
74
|
+
name="sliceagent-bg-review", daemon=True)
|
|
75
|
+
# publish self._thread only AFTER a successful start: otherwise a start() failure both latches
|
|
76
|
+
# _busy=True forever (no more reviews) AND leaves join() calling .join() on a never-started thread.
|
|
77
|
+
try:
|
|
78
|
+
t.start()
|
|
79
|
+
except Exception: # noqa: BLE001 — a thread-spawn failure must not escape into the foreground caller
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._busy = False
|
|
82
|
+
self._log("background review: thread start failed")
|
|
83
|
+
return
|
|
84
|
+
self._thread = t
|
|
85
|
+
|
|
86
|
+
def join(self, timeout: float | None = None) -> None:
|
|
87
|
+
"""Wait for the in-flight review (for deterministic tests / clean shutdown)."""
|
|
88
|
+
t = self._thread
|
|
89
|
+
if t is not None:
|
|
90
|
+
t.join(timeout)
|
|
91
|
+
|
|
92
|
+
def _log(self, msg: str) -> None:
|
|
93
|
+
if self._on_log is not None:
|
|
94
|
+
try:
|
|
95
|
+
self._on_log(msg)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
def _run(self, session_id: str) -> None:
|
|
100
|
+
"""Worker body — durable-in, durable-out. Every failure is swallowed: a background
|
|
101
|
+
critique must NEVER affect the foreground session."""
|
|
102
|
+
try:
|
|
103
|
+
from .neocortex import promote_episodes, promote_procedures, render_skill
|
|
104
|
+
records = self.memory.read_episodes(session_id)
|
|
105
|
+
if not records:
|
|
106
|
+
return
|
|
107
|
+
# critique the LATEST turn in the context of the session: promote_* are pure and
|
|
108
|
+
# frequency-weight across the whole session, so passing all records gives the newest
|
|
109
|
+
# corrective/procedural signal its proper recurrence count. Dedup against what's
|
|
110
|
+
# already stored is memem's job (remember is idempotent-ish on identical content).
|
|
111
|
+
lessons = promote_episodes(records)
|
|
112
|
+
for lesson in lessons:
|
|
113
|
+
try:
|
|
114
|
+
self.memory.remember(lesson["content"], title=lesson["title"], scope=self.scope,
|
|
115
|
+
tags=lesson["tags"], paths=lesson.get("files")) # file-context parity
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
procs = promote_procedures(records)
|
|
119
|
+
if procs:
|
|
120
|
+
from .memory import _skills_dir, write_skill_file # SAME guarded writer as session-end
|
|
121
|
+
from .skill_provenance import AUTO, reset_authoring_origin, set_authoring_origin
|
|
122
|
+
skills_dir = _skills_dir()
|
|
123
|
+
token = set_authoring_origin(AUTO) # mark fork-authored skills curator-prunable
|
|
124
|
+
try:
|
|
125
|
+
for proc in procs:
|
|
126
|
+
# guarded writer: validate frontmatter + strict threat-scan + redact + ATOMIC replace
|
|
127
|
+
# (parity with memory.consolidate; closes the bypass + non-atomic-write bugs)
|
|
128
|
+
write_skill_file(proc["name"], render_skill(proc), skills_dir=skills_dir)
|
|
129
|
+
finally:
|
|
130
|
+
reset_authoring_origin(token)
|
|
131
|
+
self._log(f"background review: {len(lessons)} lesson(s), {len(procs)} skill(s)")
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
finally:
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._busy = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def make_background_reviewer(memory, *, scope: str, on_log=None) -> BackgroundReviewer | None:
|
|
140
|
+
"""Factory. Returns None when the feature is disabled OR memory isn't durable — so the
|
|
141
|
+
host can skip wiring entirely and the default path adds zero objects."""
|
|
142
|
+
if not background_review_enabled():
|
|
143
|
+
return None
|
|
144
|
+
if not getattr(memory, "is_durable", False):
|
|
145
|
+
return None
|
|
146
|
+
return BackgroundReviewer(memory, scope=scope, on_log=on_log)
|
sliceagent/binsniff.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Binary-file sniffing (build task: binsniff) — keep text-only tools from choking on binaries.
|
|
2
|
+
|
|
3
|
+
A read_file / str_replace / fuzzy edit only makes sense on TEXT. Feeding a .png, a .so, or a
|
|
4
|
+
mojibake blob through those paths corrupts the slice (garbage in OPEN FILES) and wastes tokens.
|
|
5
|
+
This module is the single cheap gate the file tools call BEFORE treating bytes as text.
|
|
6
|
+
|
|
7
|
+
Two independent signals, in cost order:
|
|
8
|
+
1. Extension — a pure string check, no I/O (`has_binary_extension`). Catches the common case
|
|
9
|
+
(.png/.so/.zip/...) without ever reading the file.
|
|
10
|
+
2. Content — a NUL byte, or >30% non-printable control chars (excluding \\n \\r \\t) in a
|
|
11
|
+
head sample, marks bytes that decoded to text but clearly are not text.
|
|
12
|
+
|
|
13
|
+
NO-TRANSCRIPT INVARIANT: pure functions over a path string + a head sample; no state, no growing
|
|
14
|
+
context, no I/O of their own. The caller supplies the sample (already bounded to a file head).
|
|
15
|
+
|
|
16
|
+
PUBLIC SIGNATURES (pinned):
|
|
17
|
+
BINARY_EXTENSIONS: frozenset[str]
|
|
18
|
+
has_binary_extension(path: str) -> bool
|
|
19
|
+
looks_binary(path: str, sample: str) -> bool
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Extensions whose contents can't be meaningfully treated as text.
|
|
23
|
+
BINARY_EXTENSIONS = frozenset({
|
|
24
|
+
# Images
|
|
25
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff", ".tif",
|
|
26
|
+
# Videos
|
|
27
|
+
".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv", ".m4v", ".mpeg", ".mpg",
|
|
28
|
+
# Audio
|
|
29
|
+
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".aiff", ".opus",
|
|
30
|
+
# Archives
|
|
31
|
+
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz", ".z", ".tgz", ".iso",
|
|
32
|
+
# Executables/binaries
|
|
33
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".obj", ".lib",
|
|
34
|
+
".app", ".msi", ".deb", ".rpm",
|
|
35
|
+
# Documents (exclude .pdf — text-based, agents may want to inspect)
|
|
36
|
+
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
37
|
+
".odt", ".ods", ".odp",
|
|
38
|
+
# Fonts
|
|
39
|
+
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
|
40
|
+
# Bytecode / VM artifacts
|
|
41
|
+
".pyc", ".pyo", ".class", ".jar", ".war", ".ear", ".node", ".wasm", ".rlib",
|
|
42
|
+
# Database files
|
|
43
|
+
".sqlite", ".sqlite3", ".db", ".mdb", ".idx",
|
|
44
|
+
# Design / 3D
|
|
45
|
+
".psd", ".ai", ".eps", ".sketch", ".fig", ".xd", ".blend", ".3ds", ".max",
|
|
46
|
+
# Flash
|
|
47
|
+
".swf", ".fla",
|
|
48
|
+
# Lock/profiling data
|
|
49
|
+
".lockb", ".dat", ".data",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
# How many leading chars of the sample to inspect, and the non-printable fraction that
|
|
53
|
+
# tips a decoded-but-not-text blob over into "binary".
|
|
54
|
+
_HEAD = 1000
|
|
55
|
+
_NON_PRINTABLE_RATIO = 0.30
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def has_binary_extension(path: str) -> bool:
|
|
59
|
+
"""True if `path` has a known binary extension. Pure string check, no I/O."""
|
|
60
|
+
dot = path.rfind(".")
|
|
61
|
+
if dot == -1:
|
|
62
|
+
return False
|
|
63
|
+
return path[dot:].lower() in BINARY_EXTENSIONS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def looks_binary(path: str, sample: str) -> bool:
|
|
67
|
+
"""True if `path` is binary by extension, or `sample` looks binary by content.
|
|
68
|
+
|
|
69
|
+
Content is binary when it contains a NUL byte, or when more than 30% of the first
|
|
70
|
+
1000 chars are non-printable control chars (ord < 32, excluding \\n \\r \\t).
|
|
71
|
+
|
|
72
|
+
`sample == ''` means we have no content signal -> non-binary unless the extension says so.
|
|
73
|
+
O(head sample) only; never reads the file itself.
|
|
74
|
+
"""
|
|
75
|
+
if has_binary_extension(path):
|
|
76
|
+
return True
|
|
77
|
+
if not sample:
|
|
78
|
+
return False
|
|
79
|
+
head = sample[:_HEAD]
|
|
80
|
+
if "\x00" in head: # a single NUL is a definitive binary marker
|
|
81
|
+
return True
|
|
82
|
+
non_printable = sum(1 for c in head if ord(c) < 32 and c not in "\n\r\t")
|
|
83
|
+
return non_printable / len(head) > _NON_PRINTABLE_RATIO
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__": # smoke: a .png ext and a NUL sample are binary; plain text is not
|
|
87
|
+
assert looks_binary("logo.png", "") and looks_binary("x.txt", "a\x00b")
|
|
88
|
+
assert not looks_binary("notes.txt", "plain readable text\nwith newlines\n")
|
|
89
|
+
print("binsniff smoke OK")
|