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/pfc.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""PFC — the Active Memory Slice's own carried state (the working-memory brain region).
|
|
2
|
+
|
|
3
|
+
NORTH STAR — the slice is a CACHE, not a log. Every model call is a pure function
|
|
4
|
+
f(selector, store): the durable stores (disk, code graph, episode cache) are the only
|
|
5
|
+
authority; the slice is a small typed SELECTOR over them, reconstructed ONCE PER TURN as the
|
|
6
|
+
SEED (see seed.py). Within the turn, working memory ACCUMULATES as native assistant/tool
|
|
7
|
+
messages — no per-step rebuild, no within-turn eviction; the bound is the TURN-BOUNDARY seal
|
|
8
|
+
(the next turn starts from a fresh seed + recall). The single invariant "cache not log" IMPLIES
|
|
9
|
+
the moat (a cache keeps no history), task-agnosticism (a cache doesn't know what it caches), and
|
|
10
|
+
LLM-agnosticism (the cache contract sits below the model).
|
|
11
|
+
|
|
12
|
+
IN BRAIN TERMS (a naming aid — see pagetable.py for the fuller legend): the Slice's own carried
|
|
13
|
+
state (findings, conversation ring, plan, mission — see seal() below) is PREFRONTAL CORTEX /
|
|
14
|
+
working memory: bounded, actively maintained, free, lost on reset. This module owns exactly
|
|
15
|
+
that region: the `Slice` dataclass, its lifecycle (reset/seal), and the functions that MUTATE
|
|
16
|
+
it in place (touch_file, add_skill, record_user, consolidate_checkpoint, slice_sink). The
|
|
17
|
+
reconstruction seam that READS durable stores to build a turn's SEED lives in seed.py; the
|
|
18
|
+
stable SYSTEM prompt text lives in prompt.py.
|
|
19
|
+
|
|
20
|
+
PROVENANCE (Invariant 1): a finding is tagged by where it came from, and model prose is never an
|
|
21
|
+
established FACT. Each finding carries a `source` (observed > tool-note > claim): a tool result is
|
|
22
|
+
"observed"; the `note` arg on a non-failing call is "tool-note"; the model's free reasoning and any
|
|
23
|
+
"done"-style claim are "claim". ALL are folded forward so a reasoning model reuses them instead of
|
|
24
|
+
re-deriving (the costly Markov trap) — but rendered as the model's OWN notes to VERIFY against OPEN
|
|
25
|
+
FILES, never as "do not re-derive". Pure intent/narration ("Let me…") is dropped. This keeps the
|
|
26
|
+
"already done" ratchet dead (a claim is not a fact) WITHOUT starving carry-forward — dropping prose
|
|
27
|
+
entirely ~2x'd steps on normal tasks.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
from .events import AssistantText, Event, ToolResult
|
|
35
|
+
from .regions import (
|
|
36
|
+
CONVO_MSG_CHARS,
|
|
37
|
+
MAX_CONVERSATION,
|
|
38
|
+
MAX_FINDINGS,
|
|
39
|
+
MAX_MISSION_CHARS,
|
|
40
|
+
MAX_PLAN_CHARS,
|
|
41
|
+
MAX_PLAN_ITEMS,
|
|
42
|
+
MAX_REQUIREMENTS,
|
|
43
|
+
MAX_REQ_CHARS,
|
|
44
|
+
record_action,
|
|
45
|
+
record_note,
|
|
46
|
+
)
|
|
47
|
+
from .swap import READ_BUDGET, READ_BUDGET_MAX, _DEFAULT_SWAP
|
|
48
|
+
from .text_utils import one_line
|
|
49
|
+
|
|
50
|
+
# literal paths the model touches via execute_code helpers — so code-as-action reads/edits
|
|
51
|
+
# still populate the OPEN FILES working set (they run in the sandbox, bypassing the ToolHost)
|
|
52
|
+
_CODE_PATH_RE = re.compile(
|
|
53
|
+
r"\b(?:read_file|write_file|append_file|str_replace)\(\s*['\"]([^'\"]+)['\"]"
|
|
54
|
+
)
|
|
55
|
+
# the subset that MUTATES a file (vs read_file) — so code-as-action edits join the protected change set
|
|
56
|
+
_CODE_EDIT_PATH_RE = re.compile(
|
|
57
|
+
r"\b(?:write_file|append_file|str_replace)\(\s*['\"]([^'\"]+)['\"]"
|
|
58
|
+
)
|
|
59
|
+
# code_ops (the anti-loop tally's view THROUGH execute_code) lives in regions.py with action_sig/
|
|
60
|
+
# record_action. paths_in_code/edited_paths_in_code stay here (slice_sink is their only caller).
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def paths_in_code(code: str) -> list[str]:
|
|
64
|
+
return _CODE_PATH_RE.findall(code or "")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def edited_paths_in_code(code: str) -> list[str]:
|
|
68
|
+
return _CODE_EDIT_PATH_RE.findall(code or "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Slice:
|
|
73
|
+
goal: str = ""
|
|
74
|
+
# DURABLE TASK SPEC — the original defining request for this topic (the first user message), kept
|
|
75
|
+
# WHOLE and resident for the life of the topic. Standing requirements live here (an exact function
|
|
76
|
+
# name/signature, output format, "use British spelling", "don't use lib X", an invariant) — they are
|
|
77
|
+
# STANDING REQUIREMENTS — the live contract that must hold when the task is DONE: a model-CURATED set
|
|
78
|
+
# of constraints (an exact name/signature, an output format, a stated rule, an added requirement), NOT
|
|
79
|
+
# the frozen first message. The model maintains it in-band via require / requirement_done /
|
|
80
|
+
# drop_requirement (folded by slice_sink, the world_set seam — zero extra LLM call). CARRIED across
|
|
81
|
+
# turns (the seal never touches it; continue_topic moves `goal`, not this), wiped only by reset/new_topic.
|
|
82
|
+
# EMPTY by default → a greeting/question has NO contract and the region self-suppresses, so a trivial
|
|
83
|
+
# first message can never become a binding spec. bound-is-relevance: only what must hold at the end.
|
|
84
|
+
requirements: list[dict] = field(default_factory=list) # [{"text": str, "done": bool}], insertion order
|
|
85
|
+
# PLAN (TodoWrite) — the model's ORDERED execution steps with live status. Distinct from requirements
|
|
86
|
+
# (acceptance criteria): this is the step sequence + progress. Replace-all via the update_plan tool
|
|
87
|
+
# (folded by slice_sink). Carried by seal() (continuity), wiped by reset(). Bounded (MAX_PLAN_ITEMS).
|
|
88
|
+
plan: list[dict] = field(default_factory=list) # [{"step": str, "status": pending|in_progress|done}]
|
|
89
|
+
# MISSION — the NORTH-STAR objective / "why" framing the agent sets to stay oriented
|
|
90
|
+
# over a long multi-step task, ABOVE the literal `goal`. ONE string (inherently bounded); set via
|
|
91
|
+
# set_mission, cleared via mission_done. Self-suppresses when empty (no bloat). Carried by seal()
|
|
92
|
+
# across the task's turns; wiped by reset() (a brand-new task) — same lifecycle as requirements/plan.
|
|
93
|
+
mission: str = ""
|
|
94
|
+
action_log: dict[str, dict] = field(default_factory=dict)
|
|
95
|
+
active_files: list[str] = field(default_factory=list)
|
|
96
|
+
last_error: str = ""
|
|
97
|
+
active_skills: list[dict] = field(default_factory=list) # [{name, body}] loaded SKILLs
|
|
98
|
+
edit_anchor: dict[str, str] = field(default_factory=dict) # path -> last edit-target text (huge-file focus)
|
|
99
|
+
findings: list[str] = field(default_factory=list) # distilled conclusions carried across turns
|
|
100
|
+
# I1 PROVENANCE — source tag per finding (text -> "observed" | "tool-note" | "claim"). Parallel
|
|
101
|
+
# to `findings` (kept a plain list[str] so it stays JSON-serializable for taskstate/memory and
|
|
102
|
+
# readable by discovery_query). Bounded with the findings ring; pruned to live keys only.
|
|
103
|
+
finding_source: dict = field(default_factory=dict)
|
|
104
|
+
# AGENT WORLD MODEL — a durable, agent-MAINTAINED key→value scratchpad for NON-code task state the
|
|
105
|
+
# model must carry across many steps: an explored maze map, a text-adventure's rooms+inventory, a
|
|
106
|
+
# system inventory (processes/ports/services), a running plan. Written via the world_set tool (folded
|
|
107
|
+
# in by slice_sink, the same note→findings seam); READ from the rendered WORLD MODEL region, built into
|
|
108
|
+
# each turn's SEED (no world_get needed) — within the SAME turn a just-set value lives only in the
|
|
109
|
+
# model's own world_set call above until the next seed re-renders it. Unbounded (bound = the seal); SURVIVES
|
|
110
|
+
# the seal (distilled task state); cleared only by reset (a new task). This generalizes the slice
|
|
111
|
+
# beyond source files — where its multi-step memory wins on non-code tasks (maze/zork) actually lives.
|
|
112
|
+
world: dict = field(default_factory=dict)
|
|
113
|
+
edited_files: set = field(default_factory=set) # the change set — protected from eviction
|
|
114
|
+
since_edit: int = 0 # tool calls since the last successful edit — drives the EDIT convergence check
|
|
115
|
+
turn_actions: int = 0 # tool calls THIS user turn — finding-INDEPENDENT (unlike since_edit, which resets
|
|
116
|
+
# on every new finding); drives the explore-nudge so a read-heavy Q&A that records notes still converges
|
|
117
|
+
reviewed: list[str] = field(default_factory=list) # history lookbacks done — the recall_history ratchet
|
|
118
|
+
# I3 — OPEN USER REPORT. The user's most-recent FAILURE REPORT ("it can't play", "cd: no such
|
|
119
|
+
# file"), captured verbatim as a BLOCKER the model must verify against the real artifact before
|
|
120
|
+
# claiming done. A snapshot agent loses the dialectic — the user pushing back on a "done" claim —
|
|
121
|
+
# so the report is a durable tier. ONE string (inherently bounded); survives continue_topic (a new
|
|
122
|
+
# directive does NOT mean the user retracted the report); cleared only by a real topic reset or a
|
|
123
|
+
# NEWER report. NOT a transcript: a single most-recent line, capped.
|
|
124
|
+
open_report: str = ""
|
|
125
|
+
# CONTINUITY (short-range): a bounded ring of the last few user<->assistant exchanges so the slice
|
|
126
|
+
# carries the immediate conversational thread (a snapshot agent otherwise loses "what we just said").
|
|
127
|
+
# Older turns are NOT here — they live in the durable episodic cache, paged in ON DEMAND via
|
|
128
|
+
# recall_history (the decompression path). `turns` counts user turns this topic (for the "+N older"
|
|
129
|
+
# pointer). Bounded => growth stays decoupled from conversation length (the moat).
|
|
130
|
+
conversation: list[dict] = field(default_factory=list) # [{user, assistant}], last MAX_CONVERSATION
|
|
131
|
+
turns: int = 0
|
|
132
|
+
# GHOST INDEX: bounded recovery POINTERS (references, not content) to things recently paged OUT of
|
|
133
|
+
# the slice — an evicted read, a dropped skill. Turns "omission is unrecoverable" into a one-call
|
|
134
|
+
# fetch. [{kind, ref}], bounded by MAX_GHOSTS; an item leaves the moment it's back in the slice.
|
|
135
|
+
ghosts: list[dict] = field(default_factory=list)
|
|
136
|
+
# CO-RESIDENCY: read-only files that are DEPENDENCIES (contracts/callers) of the change set,
|
|
137
|
+
# recomputed each turn from the code graph (make_build_slice). Protected from eviction so the
|
|
138
|
+
# files an edit must stay consistent with don't page out from under it. Bounded (DEP_CEILING);
|
|
139
|
+
# a plain set of relpaths (serializable, like edited_files). Empty without a dep graph.
|
|
140
|
+
protected_deps: set = field(default_factory=set)
|
|
141
|
+
# CHANGE-SET CLOSURE state (symbol-aware): pre_defs snapshots each file's def-names BEFORE it is
|
|
142
|
+
# edited, so prefetch can compute what an edit REMOVED (pre - current) and flag dependents whose
|
|
143
|
+
# CURRENT tokens still reference a removed name — a precise dangling-call-site signal. INTERNAL
|
|
144
|
+
# host state (def-name sets from the durable code graph), never rendered into the slice and never a
|
|
145
|
+
# conversation transcript; scoped to the files touched this session (one small set per such file).
|
|
146
|
+
pre_defs: dict = field(default_factory=dict)
|
|
147
|
+
stale_deps: set = field(default_factory=set)
|
|
148
|
+
# KERNEL-INTERNAL self-tuning state — NOT rendered into the slice the model sees, NOT serialized
|
|
149
|
+
# (taskstate ignores it), transient. Pure mechanism, like Linux vmstat + mm/workingset refault
|
|
150
|
+
# tracking. `io` = per-session page hit/miss/refault/evict counters (makes the moat MEASURED, not
|
|
151
|
+
# asserted; the only legit input to any future budget auto-sizing). `hot` = files the kernel granted
|
|
152
|
+
# ITSELF a brief reclaim-protection on a refault (path -> TTL steps), bounded by HOT_CEILING — the
|
|
153
|
+
# automatic self-tuning loop (no model involvement), the validated automatic-beats-active-asker path.
|
|
154
|
+
io: dict = field(default_factory=lambda: {"hit": 0, "miss": 0, "refault": 0, "evict": 0})
|
|
155
|
+
hot: dict = field(default_factory=dict)
|
|
156
|
+
# ADAPTIVE working-set budget (the "bounded = Markov current-state, not a fixed ceiling" reframe). The
|
|
157
|
+
# resident exploratory-read budget is no longer the constant READ_BUDGET: it starts at that FLOOR and the
|
|
158
|
+
# kernel GROWS it on refault thrash (SwapManager._grow) up to read_ceiling. Transient session state (like
|
|
159
|
+
# io/hot) — NOT serialized, reset per task. Growth is task/refault-driven + window-bounded, never history-
|
|
160
|
+
# proportional, so the moat holds. read_ceiling is the per-slice disaster ceiling (genuine breadth goes to
|
|
161
|
+
# the swarm, not to inflating this one slice).
|
|
162
|
+
read_budget: int = READ_BUDGET
|
|
163
|
+
read_ceiling: int = READ_BUDGET_MAX
|
|
164
|
+
# EXPLORER mode: a read-only delegated explorer's whole job IS thorough read-only investigation, so
|
|
165
|
+
# the read-only convergence nudge ("you've explored N times, ANSWER now") must NOT fire on it — that
|
|
166
|
+
# nudge is for the TOP-LEVEL agent over-exploring instead of answering the user. max_steps bounds the
|
|
167
|
+
# explorer. Transient, set by run_subagent for read-only children. (Sibling of the EXPLORER_READ_BUDGET fix.)
|
|
168
|
+
explore_mode: bool = False
|
|
169
|
+
|
|
170
|
+
def reset(self, goal: str) -> None:
|
|
171
|
+
self.goal = goal
|
|
172
|
+
self.requirements = [] # a brand-new task starts with an EMPTY contract (model curates it in-band)
|
|
173
|
+
self.plan = [] # a brand-new task starts with an empty plan (kept by seal() within a task)
|
|
174
|
+
self.mission = "" # north-star objective — wiped on a brand-new task (kept by seal())
|
|
175
|
+
self.action_log = {}
|
|
176
|
+
self.active_files = []
|
|
177
|
+
self.last_error = ""
|
|
178
|
+
self.active_skills = []
|
|
179
|
+
self.edit_anchor = {}
|
|
180
|
+
self.findings = []
|
|
181
|
+
self.finding_source = {}
|
|
182
|
+
self.world = {} # agent world model → wiped on a brand-new task (kept by seal())
|
|
183
|
+
self.edited_files = set()
|
|
184
|
+
self.since_edit = 0
|
|
185
|
+
self.turn_actions = 0
|
|
186
|
+
self.reviewed = []
|
|
187
|
+
self.open_report = ""
|
|
188
|
+
self.conversation = []
|
|
189
|
+
self.turns = 0
|
|
190
|
+
self.ghosts = []
|
|
191
|
+
self.protected_deps = set()
|
|
192
|
+
self.pre_defs = {}
|
|
193
|
+
self.stale_deps = set()
|
|
194
|
+
self.io = {"hit": 0, "miss": 0, "refault": 0, "evict": 0}
|
|
195
|
+
self.hot = {}
|
|
196
|
+
self.read_budget = READ_BUDGET # back to the lean floor each task; grows on refault within the task
|
|
197
|
+
self.read_ceiling = READ_BUDGET_MAX
|
|
198
|
+
self.explore_mode = False
|
|
199
|
+
|
|
200
|
+
def seal(self) -> None:
|
|
201
|
+
"""SEAL the loop at a TURN boundary — "bounded = seal the within-loop info" (the moat is the seal
|
|
202
|
+
BETWEEN loops, never a cut WITHIN one). The finished loop's COMPLETE info was archived to the
|
|
203
|
+
durable cache on TurnEnd; here the NEXT loop starts FRESH so per-turn cost stays flat across a
|
|
204
|
+
long multi-turn session instead of growing like a transcript.
|
|
205
|
+
|
|
206
|
+
CARRY (distilled, durable continuity): findings + their sources, the in-progress edited change-set
|
|
207
|
+
(+ its edit anchors), conversation ring, goal, the OPEN USER REPORT blocker, deliberate pins, and
|
|
208
|
+
the demoted anti-loop tally — all kept by simply not touching them.
|
|
209
|
+
SEAL (archived + recall-on-demand via recall_history): the RAW within-loop trajectory — recent
|
|
210
|
+
steps, the intra-turn step cache, exploratory (non-edited) reads, and the prior loop's transient
|
|
211
|
+
kernel state. None of it is lost: it's in the durable cache, one recall away if the next loop needs
|
|
212
|
+
it. Distinct from reset() (a brand-new task wipes everything); seal() preserves the distilled carry."""
|
|
213
|
+
self.ghosts = [] # recovery pointers for the prior loop's evictions → moot now
|
|
214
|
+
self.hot = {} # prior loop's kernel soft-pins → reset
|
|
215
|
+
self.turn_actions = 0 # fresh action epoch for the new loop
|
|
216
|
+
self.read_budget = READ_BUDGET # back to the lean floor; re-grows on refault within the new loop
|
|
217
|
+
# BOUND THE CARRY AT THE SEAL (not within the loop): the next loop starts from the most-recent
|
|
218
|
+
# MAX_FINDINGS distilled facts; older ones are in the durable episodic cache (archived at TurnEnd,
|
|
219
|
+
# recallable). This is the loop-boundary bound the moat prescribes — without it findings carried
|
|
220
|
+
# unbounded across a long session (transcript-style growth). pre_defs is mostly transient pre-edit
|
|
221
|
+
# state (re-derived by prefetch next loop) — but prefetch only snapshots NON-edited files, so the
|
|
222
|
+
# pre-edit baseline for an EDITED file would be lost here and the change-set-closure (stale_deps)
|
|
223
|
+
# could never detect a removed symbol's dangling callers next turn. So KEEP pre_defs for the carried
|
|
224
|
+
# change-set (bounded by it), drop the exploratory rest. (world is intentionally durable: kept).
|
|
225
|
+
if len(self.findings) > MAX_FINDINGS:
|
|
226
|
+
self.findings = self.findings[-MAX_FINDINGS:]
|
|
227
|
+
live = set(self.findings)
|
|
228
|
+
self.finding_source = {k: v for k, v in self.finding_source.items() if k in live}
|
|
229
|
+
self.pre_defs = {p: d for p, d in self.pre_defs.items() if p in self.edited_files}
|
|
230
|
+
# CARRY the in-progress change-set resident; SEAL exploratory reads (re-readable / recallable).
|
|
231
|
+
self.active_files = [p for p in self.active_files if p in self.edited_files]
|
|
232
|
+
# Keep edited_files ⊆ active_files coherent: drop any phantom edit that is no longer resident, so a
|
|
233
|
+
# restored/desynced state can't feed ghost files into prefetch's change-set closure next turn.
|
|
234
|
+
self.edited_files = type(self.edited_files)(p for p in self.edited_files if p in self.active_files)
|
|
235
|
+
self.edit_anchor = {p: a for p, a in self.edit_anchor.items() if p in self.edited_files}
|
|
236
|
+
self.protected_deps = set() # re-derived from the carried change-set by prefetch on next build
|
|
237
|
+
self.stale_deps = set()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def touch_file(s: Slice, path: str, edited: bool = False) -> None:
|
|
241
|
+
"""Shim → SwapManager.load (swap.py owns the file load→evict→ghost lifecycle). Signature unchanged."""
|
|
242
|
+
_DEFAULT_SWAP.load(s, path, edited=edited)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def add_skill(s: Slice, name: str, body: str) -> None:
|
|
246
|
+
"""Shim → SwapManager.load_skill (swap.py owns skill load/evict + ghosts). Signature unchanged."""
|
|
247
|
+
_DEFAULT_SWAP.load_skill(s, name, body)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _active(state):
|
|
251
|
+
"""Resolve the current Slice from a Slice or a Session (host-side topic manager)."""
|
|
252
|
+
return state.active() if hasattr(state, "active") else state
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def record_user(s: Slice, message: str) -> None:
|
|
256
|
+
"""Append the user's message to the short-range CONVERSATION ring and count the turn. The host
|
|
257
|
+
calls this once per user message; slice_sink fills the assistant side as the turn produces text.
|
|
258
|
+
Bounded ring — older exchanges live in the durable cache, paged in on demand (not kept here)."""
|
|
259
|
+
s.turns += 1
|
|
260
|
+
s.turn_actions = 0 # new user turn → reset the per-turn exploration budget (drives the explore-nudge)
|
|
261
|
+
s.conversation.append({"user": one_line(message, CONVO_MSG_CHARS), "assistant": ""})
|
|
262
|
+
s.conversation = s.conversation[-MAX_CONVERSATION:]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def consolidate_checkpoint(s: "Slice", *, compact: bool = True) -> str:
|
|
266
|
+
"""F1 — the CHECKPOINT: a deterministic, BOUNDED re-projection of the carried task state into ONE dense
|
|
267
|
+
'state of play' snapshot (intent · decisions · change-set · open/next, plus a findings digest in full
|
|
268
|
+
mode). Pure (no LLM) — built from the durable tiers seal() already carries, so it adds LEGIBILITY +
|
|
269
|
+
a single resume/rebuild artifact, never new state. `compact=True` is the steady-state slice tier (no
|
|
270
|
+
findings re-list — those have their own tier); `compact=False` is the FULL artifact for the overflow
|
|
271
|
+
REBUILD (where the detailed tiers are gone, so the snapshot must stand alone). Self-suppresses when
|
|
272
|
+
there is nothing to report (a fresh greeting → no bytes)."""
|
|
273
|
+
from .finding_types import RULED_OUT, classify_finding # typed decisions read sharper in the snapshot
|
|
274
|
+
lines: list[str] = []
|
|
275
|
+
goal = (s.goal or "").strip()
|
|
276
|
+
if goal:
|
|
277
|
+
lines.append(f"intent: {one_line(goal, 240)}")
|
|
278
|
+
open_reqs = [r.get("text", "") for r in s.requirements if isinstance(r, dict) and not r.get("done")]
|
|
279
|
+
if open_reqs:
|
|
280
|
+
lines.append("requirements: " + " · ".join(one_line(t, 80) for t in open_reqs[:5]))
|
|
281
|
+
decisions = [f for f in s.findings if classify_finding(f) in ("decision", RULED_OUT)]
|
|
282
|
+
if decisions:
|
|
283
|
+
lines.append("decisions:")
|
|
284
|
+
lines += [f" - {one_line(d, 160)}" for d in decisions[-4:]]
|
|
285
|
+
if s.edited_files:
|
|
286
|
+
ch = sorted(s.edited_files)
|
|
287
|
+
lines.append("change-set: " + ", ".join(ch[:8]) + (f" (+{len(ch) - 8})" if len(ch) > 8 else ""))
|
|
288
|
+
if not compact: # FULL artifact: include the non-decision findings digest
|
|
289
|
+
facts = [f for f in s.findings if f not in decisions]
|
|
290
|
+
if facts:
|
|
291
|
+
lines.append("findings:")
|
|
292
|
+
lines += [f" - {one_line(f, 160)}" for f in facts[-8:]]
|
|
293
|
+
if s.open_report:
|
|
294
|
+
lines.append(f"open: {one_line(s.open_report, 200)}")
|
|
295
|
+
return "\n".join(lines)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _history_mark(args: dict) -> str:
|
|
299
|
+
"""A short label for a recall_history lookback, so the slice records WHAT was already reviewed."""
|
|
300
|
+
if not isinstance(args, dict):
|
|
301
|
+
return ""
|
|
302
|
+
full = "·full" if args.get("full") else ""
|
|
303
|
+
if args.get("turns"):
|
|
304
|
+
nums = []
|
|
305
|
+
for t in (args["turns"] if isinstance(args["turns"], (list, tuple)) else []):
|
|
306
|
+
try:
|
|
307
|
+
nums.append(int(t))
|
|
308
|
+
except (TypeError, ValueError):
|
|
309
|
+
pass # a malformed turn id is just a bad display label — never abort the slice fold
|
|
310
|
+
return f"turns={sorted(nums)}" + full
|
|
311
|
+
if args.get("last"):
|
|
312
|
+
try:
|
|
313
|
+
return f"last={int(args['last'])}" + full
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
return "index"
|
|
316
|
+
return "index"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def slice_sink(state):
|
|
320
|
+
"""Event sink that folds tool results back into the tiers (keeps the loop decoupled). `state`
|
|
321
|
+
is a Slice or a Session — events fold into the CURRENT active slice (so a topic switch redirects
|
|
322
|
+
subsequent folding)."""
|
|
323
|
+
def sink(event: Event) -> None:
|
|
324
|
+
s = _active(state)
|
|
325
|
+
# I1 PROVENANCE (root-cause revision) — fold the model's reasoning forward as an UNVERIFIED
|
|
326
|
+
# CLAIM, never as an established fact. Dropping assistant text ENTIRELY (the first I1 cut)
|
|
327
|
+
# starved the anti-re-derivation tier and ~2x'd steps on normal tasks; the defect was narration
|
|
328
|
+
# becoming FACT, not carry-forward itself. record_note drops pure narration (_NARRATION_RE),
|
|
329
|
+
# downgrades "done"-style claims, and bounds+dedups the ring — so this restores reasoning-reuse
|
|
330
|
+
# WITHOUT reviving the "already done" ratchet: a claim renders as "verify against OPEN FILES",
|
|
331
|
+
# and OPEN FILES stays the only ground truth. (The episode cache keeps assistant text losslessly.)
|
|
332
|
+
if isinstance(event, AssistantText):
|
|
333
|
+
record_note(s, event.content, source="claim")
|
|
334
|
+
if s.conversation and (event.content or "").strip():
|
|
335
|
+
# fill the assistant side of the in-progress exchange — the LAST AssistantText of the
|
|
336
|
+
# turn wins, so this ends up holding the final reply shown to the user (continuity).
|
|
337
|
+
full = event.content
|
|
338
|
+
s.conversation[-1]["assistant"] = one_line(full, CONVO_MSG_CHARS)
|
|
339
|
+
# RECALL BRIDGE (core cross-turn continuity): flag when the reply was CUT to the gist, so
|
|
340
|
+
# the NEXT turn's RECENT CONVERSATION advertises the recall_history call to page the FULL
|
|
341
|
+
# reply back. Without this the model reads an 800-char gist as the complete reply and
|
|
342
|
+
# confabulates anything past it ("explain item 2" of a long report it can no longer see)
|
|
343
|
+
# instead of recalling — the failure the whole cache-not-log design exists to prevent.
|
|
344
|
+
s.conversation[-1]["truncated"] = len(one_line(full, CONVO_MSG_CHARS + 1)) > CONVO_MSG_CHARS
|
|
345
|
+
return
|
|
346
|
+
if isinstance(event, ToolResult):
|
|
347
|
+
# the model's distilled conclusion rides on the tool call (the note arg) — fold it into
|
|
348
|
+
# the FINDINGS tier so a reasoning model reuses it instead of re-deriving next turn. A note
|
|
349
|
+
# on a NON-FAILING call is backed by a real tool result (source "tool-note"); a note on a
|
|
350
|
+
# FAILING call has no observation behind it → "claim" (rendered as unverified). record_note
|
|
351
|
+
# further downgrades any "done"-style note to "claim" unless an observation backs it.
|
|
352
|
+
new_finding = record_note(s, event.args.get("note", ""),
|
|
353
|
+
source="tool-note" if not event.failing else "claim")
|
|
354
|
+
# WORLD MODEL — fold world_set/world_clear into the durable scratchpad (the note→findings seam,
|
|
355
|
+
# but structured key→value). The tool handler only confirms; the STATE lives here so it renders
|
|
356
|
+
# into each turn's seed, survives the seal, and clears on reset.
|
|
357
|
+
if event.name == "world_set" and not event.failing:
|
|
358
|
+
_k = str(event.args.get("key", "")).strip()
|
|
359
|
+
if _k:
|
|
360
|
+
s.world[_k] = str(event.args.get("value", ""))
|
|
361
|
+
elif event.name == "world_clear" and not event.failing:
|
|
362
|
+
_k = str(event.args.get("key", "")).strip()
|
|
363
|
+
if _k:
|
|
364
|
+
s.world.pop(_k, None)
|
|
365
|
+
else:
|
|
366
|
+
s.world.clear()
|
|
367
|
+
# STANDING REQUIREMENTS — fold require/requirement_done/drop_requirement into the carried
|
|
368
|
+
# contract (same seam; the handler only confirms). Text-matched + idempotent so a re-emit is a
|
|
369
|
+
# no-op (byte-stable prefix); append-only + status-flip-in-place so a change never reorders
|
|
370
|
+
# existing lines; no match on done/drop = no-op (never silently corrupt the contract).
|
|
371
|
+
elif event.name in ("require", "requirement_done", "drop_requirement") and not event.failing:
|
|
372
|
+
_t = " ".join(str(event.args.get("text", "")).split())[:MAX_REQ_CHARS]
|
|
373
|
+
if _t:
|
|
374
|
+
_hit = next((r for r in s.requirements if isinstance(r, dict) and r.get("text", "").lower() == _t.lower()), None)
|
|
375
|
+
if event.name == "require":
|
|
376
|
+
if _hit is None:
|
|
377
|
+
s.requirements.append({"text": _t, "done": False})
|
|
378
|
+
del s.requirements[:-MAX_REQUIREMENTS] # bound: keep the most recent N
|
|
379
|
+
elif event.name == "requirement_done":
|
|
380
|
+
if _hit:
|
|
381
|
+
_hit["done"] = True
|
|
382
|
+
elif _hit: # drop_requirement
|
|
383
|
+
s.requirements.remove(_hit)
|
|
384
|
+
# PLAN (TodoWrite) — fold update_plan: the model sends the FULL ordered list each call, so this
|
|
385
|
+
# REPLACES s.plan (validated + bounded). Distinct from requirements (criteria); this is the step
|
|
386
|
+
# sequence + live progress. Replace-all keeps it simple and always consistent with the model's view.
|
|
387
|
+
elif event.name == "update_plan" and not event.failing:
|
|
388
|
+
_new = []
|
|
389
|
+
for _it in (event.args.get("steps") or [])[:MAX_PLAN_ITEMS]:
|
|
390
|
+
if not isinstance(_it, dict):
|
|
391
|
+
continue
|
|
392
|
+
_step = " ".join(str(_it.get("step", "")).split())[:MAX_PLAN_CHARS]
|
|
393
|
+
_st = str(_it.get("status", "pending")).strip().lower()
|
|
394
|
+
if _st not in ("pending", "in_progress", "done"):
|
|
395
|
+
_st = "pending"
|
|
396
|
+
if _step:
|
|
397
|
+
_new.append({"step": _step, "status": _st})
|
|
398
|
+
s.plan = _new
|
|
399
|
+
# MISSION (north star) — fold set_mission/mission_done (the handler only confirms; STATE here).
|
|
400
|
+
elif event.name == "set_mission" and not event.failing:
|
|
401
|
+
s.mission = " ".join(str(event.args.get("text", "")).split())[:MAX_MISSION_CHARS]
|
|
402
|
+
elif event.name == "mission_done" and not event.failing:
|
|
403
|
+
s.mission = ""
|
|
404
|
+
# FAN-IN: a subagent/explorer reports its result as the tool OUTPUT (not the note arg). Fold that
|
|
405
|
+
# distilled summary into the carried FINDINGS tier (observed) so it survives the turn-boundary seal —
|
|
406
|
+
# the parent reconciles summaries, never the children's transcripts (the swarm's no-bloat guarantee).
|
|
407
|
+
if event.name in ("spawn_subagent", "spawn_explore", "spawn_agent") and not event.failing and event.output:
|
|
408
|
+
new_finding = record_note(s, event.output, source="observed") or new_finding
|
|
409
|
+
did_edit = False
|
|
410
|
+
if event.name == "skill" and not event.failing:
|
|
411
|
+
# a loaded skill's body must enter the ACTIVE SKILL tier or it vanishes
|
|
412
|
+
# next turn (no transcript). The skill tool returns the body as its output.
|
|
413
|
+
add_skill(s, event.args.get("name", ""), event.output)
|
|
414
|
+
if event.name == "recall_history" and not event.failing:
|
|
415
|
+
# RATCHET (via SwapManager): record the lookback so render_reviewed shows it next rebuild.
|
|
416
|
+
_DEFAULT_SWAP.note_review(s, _history_mark(event.args))
|
|
417
|
+
# list_files/grep/glob "path" is a DIRECTORY scope, not a working-set FILE — don't pin it (else
|
|
418
|
+
# build_artifacts read_text(dir) → IsADirectoryError → a bogus OPEN FILES entry every turn).
|
|
419
|
+
if event.args.get("path") and event.name not in ("list_files", "grep", "glob"):
|
|
420
|
+
did_edit = event.name in ("edit_file", "append_to_file", "str_replace") and not event.failing
|
|
421
|
+
# WS1 — gate membership on SUCCESS. A read/edit that FAILED (e.g. _resolve raised
|
|
422
|
+
# "path escapes workspace") must NOT be pinned into the working set, or OPEN FILES
|
|
423
|
+
# re-renders the unreachable/missing path every rebuild and poisons the slice
|
|
424
|
+
# (the read-blindness loop). Successful reads and edits still join the set.
|
|
425
|
+
if not event.failing:
|
|
426
|
+
touch_file(s, event.args["path"], edited=did_edit)
|
|
427
|
+
# remember the agent's edit target so a HUGE file's region follows it (and a
|
|
428
|
+
# failed str_replace re-aims the region to where the agent meant to edit)
|
|
429
|
+
if event.name == "str_replace" and event.args.get("old_string"):
|
|
430
|
+
anchor = next((ln.strip() for ln in event.args["old_string"].splitlines() if ln.strip()), "")
|
|
431
|
+
if anchor:
|
|
432
|
+
s.edit_anchor[event.args["path"]] = anchor[:80]
|
|
433
|
+
elif event.name == "execute_code":
|
|
434
|
+
code = event.args.get("code", "")
|
|
435
|
+
mutated = set(edited_paths_in_code(code))
|
|
436
|
+
did_edit = bool(mutated) and not event.failing
|
|
437
|
+
# WS1 parity with the file-tool branch above: gate working-set/change-set membership on
|
|
438
|
+
# SUCCESS. A FAILED execute_code (e.g. NameError before write_file ran) must NOT pin the
|
|
439
|
+
# files it MENTIONS — that poisons edited_files with phantom writes (false "done") and
|
|
440
|
+
# active_files with phantom reads (read-blindness), and survives the seal.
|
|
441
|
+
if not event.failing:
|
|
442
|
+
for p in paths_in_code(code):
|
|
443
|
+
touch_file(s, p, edited=(p in mutated)) # code-as-action edits join the change set
|
|
444
|
+
record_action(s, event.name, event.args, event.output, failing=event.failing)
|
|
445
|
+
# convergence tracking: a real edit OR a genuinely-new finding resets the spin counter —
|
|
446
|
+
# actively LEARNING (recording new facts) is progress, not spinning (review #5). Only a call
|
|
447
|
+
# that neither edits nor learns advances the convergence/no-progress counter.
|
|
448
|
+
s.since_edit = 0 if (did_edit or new_finding) else s.since_edit + 1
|
|
449
|
+
return sink
|
sliceagent/plugins.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Plugins — packaging that feeds the existing seams (③.4).
|
|
2
|
+
|
|
3
|
+
A plugin is a directory with a `plugin.toml` manifest and
|
|
4
|
+
an `__init__.py` exposing `register(ctx)`. Through `ctx` it contributes to the SAME seams
|
|
5
|
+
everything else uses — the tool registry, the skill manager, MCP servers, and hooks — so a
|
|
6
|
+
plugin gets NO privileged surface and reuses all the sandbox/policy/scheduler machinery.
|
|
7
|
+
|
|
8
|
+
A broken plugin logs and is skipped; it never crashes the host.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import os
|
|
14
|
+
try:
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError: # Python < 3.11
|
|
17
|
+
import tomli as tomllib
|
|
18
|
+
|
|
19
|
+
from .access import AllAccess
|
|
20
|
+
from .registry import ToolEntry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_dirs() -> list[str]:
|
|
24
|
+
return [
|
|
25
|
+
os.path.join(os.getcwd(), ".sliceagent", "plugins"),
|
|
26
|
+
os.path.join(os.path.expanduser("~"), ".sliceagent", "plugins"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PluginContext:
|
|
31
|
+
"""The single facade a plugin's `register(ctx)` uses. Everything it contributes flows into
|
|
32
|
+
existing seams; aggregated MCP servers + hooks are returned to the host to connect/compose."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, name: str, registry, skills, *, root: str, config):
|
|
35
|
+
self.name = name
|
|
36
|
+
self.registry = registry # shared ToolRegistry
|
|
37
|
+
self.skills = skills # SkillManager
|
|
38
|
+
self.root = root # workspace root
|
|
39
|
+
self.config = config # Config
|
|
40
|
+
self.mcp_servers: dict = {} # collected; host connects them
|
|
41
|
+
self.hooks: list = [] # collected Hooks instances; host composes them
|
|
42
|
+
self.counts = {"tools": 0, "skills": 0, "mcp": 0, "hooks": 0}
|
|
43
|
+
|
|
44
|
+
def register_tool(self, name: str, description: str, handler, *, parameters: dict | None = None,
|
|
45
|
+
accesses=None, check=None, override: bool = False) -> None:
|
|
46
|
+
schema = {"type": "function", "function": {
|
|
47
|
+
"name": name, "description": description,
|
|
48
|
+
"parameters": parameters or {"type": "object", "properties": {}}}}
|
|
49
|
+
self.registry.register(ToolEntry(
|
|
50
|
+
name=name, schema=schema, handler=handler,
|
|
51
|
+
accesses=accesses or (lambda _a: [AllAccess()]), check=check,
|
|
52
|
+
source=f"plugin:{self.name}"), override=override)
|
|
53
|
+
self.counts["tools"] += 1
|
|
54
|
+
|
|
55
|
+
def register_skill(self, name: str, body: str, description: str = "") -> None:
|
|
56
|
+
self.skills.add(name, body, description=description)
|
|
57
|
+
self.counts["skills"] += 1
|
|
58
|
+
|
|
59
|
+
def register_mcp_server(self, name: str, config: dict) -> None:
|
|
60
|
+
self.mcp_servers[name] = config
|
|
61
|
+
self.counts["mcp"] += 1
|
|
62
|
+
|
|
63
|
+
def register_hook(self, hook) -> None: # a Hooks instance
|
|
64
|
+
self.hooks.append(hook)
|
|
65
|
+
self.counts["hooks"] += 1
|
|
66
|
+
|
|
67
|
+
def log(self, msg: str) -> None:
|
|
68
|
+
print(f" · plugin:{self.name}: {msg}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_one(pdir: str, registry, skills, *, root, config, on_log) -> PluginContext:
|
|
72
|
+
try:
|
|
73
|
+
with open(os.path.join(pdir, "plugin.toml"), "rb") as f:
|
|
74
|
+
meta = tomllib.load(f)
|
|
75
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
76
|
+
meta = {}
|
|
77
|
+
name = (meta.get("name") or os.path.basename(pdir)).strip()
|
|
78
|
+
ctx = PluginContext(name, registry, skills, root=root, config=config)
|
|
79
|
+
try:
|
|
80
|
+
spec = importlib.util.spec_from_file_location(
|
|
81
|
+
f"sliceagent_plugin_{name}", os.path.join(pdir, "__init__.py"))
|
|
82
|
+
mod = importlib.util.module_from_spec(spec)
|
|
83
|
+
spec.loader.exec_module(mod)
|
|
84
|
+
register = getattr(mod, "register", None)
|
|
85
|
+
if register is None:
|
|
86
|
+
on_log(f"plugin:{name} has no register(ctx), skipped")
|
|
87
|
+
return ctx
|
|
88
|
+
register(ctx)
|
|
89
|
+
c = ctx.counts
|
|
90
|
+
on_log(f"plugin:{name} loaded (tools={c['tools']} skills={c['skills']} "
|
|
91
|
+
f"mcp={c['mcp']} hooks={c['hooks']})")
|
|
92
|
+
except Exception as e: # a broken plugin must never crash the host
|
|
93
|
+
on_log(f"plugin:{name} failed: {e}")
|
|
94
|
+
return ctx
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_plugins(registry, skills, dirs: list[str] | None = None, *, root: str, config,
|
|
98
|
+
on_log=lambda m: None) -> tuple[dict, list]:
|
|
99
|
+
"""Discover + load plugins from (provided dirs + defaults). Each contributes to the shared
|
|
100
|
+
registry/skills; returns aggregated (mcp_servers, hooks) for the host to connect/compose."""
|
|
101
|
+
search = list(dict.fromkeys((dirs or []) + _default_dirs()))
|
|
102
|
+
mcp_servers: dict = {}
|
|
103
|
+
hooks: list = []
|
|
104
|
+
def _ls(r):
|
|
105
|
+
try:
|
|
106
|
+
return sorted(os.listdir(r))
|
|
107
|
+
except OSError: # an unreadable plugin search dir must not crash the whole host
|
|
108
|
+
return []
|
|
109
|
+
found = [os.path.join(r, e) for r in search if os.path.isdir(r) for e in _ls(r)
|
|
110
|
+
if os.path.isfile(os.path.join(r, e, "plugin.toml"))
|
|
111
|
+
and os.path.isfile(os.path.join(r, e, "__init__.py"))]
|
|
112
|
+
# SECURITY (#6/#7): a plugin's __init__.py executes with FULL host privileges, and a plugin hook can
|
|
113
|
+
# allow/deny any tool and rewrite messages/results. Loading is therefore OPT-IN: require an explicit
|
|
114
|
+
# AGENT_ALLOW_PLUGINS=1 before running ANY plugin code — otherwise a plugin dropped in ~/.sliceagent/
|
|
115
|
+
# plugins (a default search dir) would auto-execute silently. Default off = safe.
|
|
116
|
+
allowed = (os.environ.get("AGENT_ALLOW_PLUGINS") or "").strip().lower() in ("1", "true", "yes", "on")
|
|
117
|
+
if not allowed:
|
|
118
|
+
if found:
|
|
119
|
+
on_log(f"{len(found)} plugin(s) present but NOT loaded — set AGENT_ALLOW_PLUGINS=1 to run them "
|
|
120
|
+
"(plugin code executes with HOST privileges and can register auth hooks)")
|
|
121
|
+
return mcp_servers, hooks
|
|
122
|
+
for pdir in found:
|
|
123
|
+
on_log(f"⚠ loading TRUSTED plugin {os.path.basename(pdir)} — runs with host privileges")
|
|
124
|
+
ctx = _load_one(pdir, registry, skills, root=root, config=config, on_log=on_log)
|
|
125
|
+
mcp_servers.update(ctx.mcp_servers)
|
|
126
|
+
hooks.extend(ctx.hooks)
|
|
127
|
+
return mcp_servers, hooks
|