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/cli.py
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
"""sliceagent CLI — a thin event-sink host over the stateless slice core.
|
|
2
|
+
|
|
3
|
+
The loop only dispatches events; this host wires the sinks (slice-updater, durable
|
|
4
|
+
log, terminal output) and the policy hooks (permission gate, optional Oracle/budget).
|
|
5
|
+
Other surfaces (TUI, SDK, channels) are just different sinks over the same core.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .events import (
|
|
14
|
+
ApiRetry,
|
|
15
|
+
AssistantText,
|
|
16
|
+
Event,
|
|
17
|
+
LessonSaved,
|
|
18
|
+
SliceBuilt,
|
|
19
|
+
ToolResult,
|
|
20
|
+
TurnEnd,
|
|
21
|
+
TurnInterrupted,
|
|
22
|
+
make_dispatcher,
|
|
23
|
+
)
|
|
24
|
+
from .hooks import BudgetHook, CompositeHooks, GuardrailHook, OracleHook, PermissionHook
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_env(path: str = ".env") -> None:
|
|
28
|
+
try:
|
|
29
|
+
with open(path, encoding="utf-8") as f:
|
|
30
|
+
for line in f:
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if line and not line.startswith("#") and "=" in line:
|
|
33
|
+
k, v = line.split("=", 1)
|
|
34
|
+
v = v.strip()
|
|
35
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
|
|
36
|
+
v = v[1:-1] # drop surrounding quotes (common .env convention) so the key isn't literal-quoted
|
|
37
|
+
os.environ.setdefault(k.strip(), v)
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
LOG_MAX_BYTES = 5 * 1024 * 1024 # rotate the debug log past this (keep one prior)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def log_sink(root: str = ".", path: str | None = None):
|
|
46
|
+
from .recovery import root_key, state_dir
|
|
47
|
+
from .safety import redact_text # strip secrets before they hit the on-disk debug log (off the moat)
|
|
48
|
+
# the debug log lives in the sliceagent STATE dir (~/.sliceagent/logs/<workspace-key>/), NOT scratch/ in the
|
|
49
|
+
# user's workspace — a coding agent must not litter the repo it's working on. `path` overrides (tests).
|
|
50
|
+
path = path or os.path.join(state_dir("logs", root_key(root)), "durable-log.jsonl")
|
|
51
|
+
|
|
52
|
+
def _scrub_args(args: dict) -> dict: # redact string values (edit_file content, inline tokens)
|
|
53
|
+
return {k: (redact_text(v) if isinstance(v, str) else v) for k, v in (args or {}).items()}
|
|
54
|
+
|
|
55
|
+
def sink(e: Event) -> None:
|
|
56
|
+
rec = None
|
|
57
|
+
# REDACT each string field BEFORE serializing (redacting the JSON line itself can corrupt
|
|
58
|
+
# quotes/escapes). A .env read or a token in a command must not land in the log in plaintext
|
|
59
|
+
# — reuses the same safety.redact_text the episodic-persist path uses, so the stores agree.
|
|
60
|
+
if isinstance(e, AssistantText):
|
|
61
|
+
rec = {"role": "assistant", "content": redact_text(e.content)}
|
|
62
|
+
elif isinstance(e, ToolResult):
|
|
63
|
+
rec = {"role": "tool", "name": e.name, "args": _scrub_args(e.args), "full": redact_text(e.output)}
|
|
64
|
+
elif isinstance(e, LessonSaved):
|
|
65
|
+
rec = {"role": "lesson", "title": redact_text(e.title), "content": redact_text(e.content)}
|
|
66
|
+
if rec is not None:
|
|
67
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
68
|
+
try: # rotate so the debug log can't grow unbounded
|
|
69
|
+
if os.path.getsize(path) > LOG_MAX_BYTES:
|
|
70
|
+
os.replace(path, path + ".1")
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
74
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
75
|
+
return sink
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plain_arg(args: dict) -> str:
|
|
79
|
+
"""The one informative arg for a plain-mode tool line (path/command/pattern/…), whitespace-collapsed."""
|
|
80
|
+
if not isinstance(args, dict):
|
|
81
|
+
return ""
|
|
82
|
+
for k in ("path", "command", "pattern", "name", "ref", "goal", "task"):
|
|
83
|
+
v = args.get(k)
|
|
84
|
+
if isinstance(v, str) and v.strip():
|
|
85
|
+
return " ".join(v.split())[:60]
|
|
86
|
+
v = next((x for x in args.values() if isinstance(x, str) and x.strip()), "")
|
|
87
|
+
return " ".join(v.split())[:60]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cli_sink(show_slice: bool = False):
|
|
91
|
+
"""Plain stdout sink (AGENT_TUI=off / no tui extra / pipes / CI): no color, no spinner — one readable
|
|
92
|
+
line per tool action (✓/✗ + name + primary arg, output for commands/failures) and a delimited answer."""
|
|
93
|
+
_quiet = {"read_file", "list_files"} # header says enough; their content is noise in a log
|
|
94
|
+
|
|
95
|
+
def sink(e: Event) -> None:
|
|
96
|
+
if isinstance(e, SliceBuilt) and show_slice:
|
|
97
|
+
print("\n ┌─ slice ─────────────")
|
|
98
|
+
print("\n".join(" │ " + ln for ln in e.rendered.splitlines()))
|
|
99
|
+
print(" └─────────────")
|
|
100
|
+
elif isinstance(e, ToolResult):
|
|
101
|
+
mark = "✓" if not e.failing else "✗"
|
|
102
|
+
head = f" {mark} {e.name} {_plain_arg(e.args)}".rstrip()
|
|
103
|
+
out = " ".join((e.output or "").split())[:140]
|
|
104
|
+
if out and (e.failing or e.name not in _quiet):
|
|
105
|
+
head += f" — {out}"
|
|
106
|
+
print(head)
|
|
107
|
+
elif isinstance(e, AssistantText):
|
|
108
|
+
if (e.content or "").strip():
|
|
109
|
+
print(f"\n{e.content}\n")
|
|
110
|
+
elif isinstance(e, ApiRetry):
|
|
111
|
+
print(f" …retry #{e.attempt} ({e.error})")
|
|
112
|
+
elif isinstance(e, TurnInterrupted):
|
|
113
|
+
print(f"\n[interrupted: {e.reason}]")
|
|
114
|
+
elif isinstance(e, LessonSaved):
|
|
115
|
+
print(f" 💡 learned: {e.title}")
|
|
116
|
+
elif isinstance(e, TurnEnd):
|
|
117
|
+
u = e.usage or {} # usage is non-None from loop.py, but guard like every other sink
|
|
118
|
+
print(f" [done: {e.stop_reason} · {e.steps} steps · "
|
|
119
|
+
f"{u.get('prompt_tokens', 0) + u.get('completion_tokens', 0)} tokens]")
|
|
120
|
+
return sink
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reasoning_note(llm) -> str:
|
|
124
|
+
"""One-line hint after a /model or /reasoning switch, so it's never a silent no-op. Checked in order of
|
|
125
|
+
how badly it fails: (1) the model's name implies a DIFFERENT provider than the current endpoint —
|
|
126
|
+
/model only switches the model STRING, never the endpoint (that's `config --use`), so 'gpt-5.5' while
|
|
127
|
+
still connected to DeepSeek used to 'succeed' silently and fail opaquely on the very next real turn (a
|
|
128
|
+
404 or a 400, always caught by the generic handler as an unhelpful 'internal error'); (2) the requested
|
|
129
|
+
reasoning effort has no route on this (model, endpoint) pair; (3) high/max needs /v1/responses."""
|
|
130
|
+
from .model_catalog import capability, likely_endpoint_mismatch
|
|
131
|
+
base = getattr(llm, "_base_url", "")
|
|
132
|
+
home = likely_endpoint_mismatch(llm.model, base)
|
|
133
|
+
if home:
|
|
134
|
+
label = {"openai": "an OpenAI", "deepseek": "a DeepSeek",
|
|
135
|
+
"moonshot": "a Moonshot", "anthropic": "an Anthropic"}.get(home, f"a {home}")
|
|
136
|
+
return (f"note: {llm.model} looks like {label} model, but you're connected to {base or '(default)'}"
|
|
137
|
+
f" — it will likely fail on your next message. Run `sliceagent init` to add {label} provider, "
|
|
138
|
+
f"then `config --use <id>` to switch (or `/model` with no args to see what's configured).")
|
|
139
|
+
eff = (getattr(llm, "reasoning", "full") or "full").lower()
|
|
140
|
+
if eff == "full":
|
|
141
|
+
return ""
|
|
142
|
+
if not capability(llm.model, base).supports_reasoning_effort:
|
|
143
|
+
return f"note: {llm.model} has no reasoning-effort knob — it runs at the provider default."
|
|
144
|
+
return "high/max run WITH tools via /v1/responses." if eff in ("high", "max") else ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ws_name(path: str) -> str:
|
|
148
|
+
"""Short display name for the workspace shown in the status bar — the folder's basename, or '~' when the
|
|
149
|
+
workspace is the home dir (so a home-launched session doesn't read as the username)."""
|
|
150
|
+
p = os.path.realpath(path or ".")
|
|
151
|
+
return "~" if p == os.path.realpath(os.path.expanduser("~")) else (os.path.basename(p) or p)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main() -> None:
|
|
155
|
+
# subcommands (onboarding / discovery) are handled BEFORE any key gate, so `sliceagent init` runs on a
|
|
156
|
+
# machine with nothing configured yet. A bare `sliceagent` (or one with non-subcommand args) falls through.
|
|
157
|
+
_argv = sys.argv[1:]
|
|
158
|
+
if _argv and _argv[0] in ("init", "config", "help", "--help", "-h", "version", "--version", "-V"):
|
|
159
|
+
from .onboarding import dispatch as _dispatch
|
|
160
|
+
sys.exit(_dispatch(_argv))
|
|
161
|
+
|
|
162
|
+
_load_env()
|
|
163
|
+
# config-persisted key/endpoint (written by `sliceagent init`) populate the env BEFORE the gate, so a
|
|
164
|
+
# configured user never has to export anything; ENV still wins for one-off overrides.
|
|
165
|
+
from .config import load_config
|
|
166
|
+
cfg = load_config()
|
|
167
|
+
for _env, _val in (("LLM_API_KEY", cfg.api_key), ("LLM_BASE_URL", cfg.base_url)):
|
|
168
|
+
if not os.environ.get(_env) and _val:
|
|
169
|
+
os.environ[_env] = _val
|
|
170
|
+
if not (os.environ.get("LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
171
|
+
or os.environ.get("MOONSHOT_API_KEY")):
|
|
172
|
+
print("No API key found. Run `sliceagent init` for guided setup, or set LLM_API_KEY (e.g. in a .env file).")
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
# validate enum env vars (warn + use default; never crash) — a typo'd AGENT_POLICY is now visible.
|
|
175
|
+
from .envspec import validate_env
|
|
176
|
+
for _w in validate_env():
|
|
177
|
+
print(f" · config warning: {_w}")
|
|
178
|
+
|
|
179
|
+
from .code_index import make_code_index
|
|
180
|
+
from .hippocampus import make_episode_sink
|
|
181
|
+
from .llm import OpenAILLM
|
|
182
|
+
from .mcp_client import connect_mcp_servers
|
|
183
|
+
from .loop import run_turn
|
|
184
|
+
from .memory import make_memory
|
|
185
|
+
from .oracle import CommandOracle
|
|
186
|
+
from .plugins import load_plugins
|
|
187
|
+
from .policy import CONFIRMS, legacy_warning, make_policy, policy_label, resolve_policy_mode
|
|
188
|
+
from .sandbox import make_sandbox
|
|
189
|
+
from .session import Session, make_topic_tools, route
|
|
190
|
+
from .skills import make_skill_manager, make_skill_tool
|
|
191
|
+
from .pfc import consolidate_checkpoint, record_user, slice_sink
|
|
192
|
+
from .seed import make_build_slice
|
|
193
|
+
from .text_utils import one_line
|
|
194
|
+
from .subagent import SubagentHost
|
|
195
|
+
from .tools import LocalToolHost
|
|
196
|
+
|
|
197
|
+
# cfg already loaded above (for the config→env key population + the gate)
|
|
198
|
+
root = os.getcwd()
|
|
199
|
+
from .config import load_prefs, save_prefs
|
|
200
|
+
_prefs = load_prefs()
|
|
201
|
+
# mode resolution: explicit env wins, then the saved /mode choice, then config (default teenager).
|
|
202
|
+
_raw_policy = os.environ.get("AGENT_POLICY") or _prefs.get("policy") or cfg.policy
|
|
203
|
+
canonical = resolve_policy_mode(_raw_policy) or "teenager"
|
|
204
|
+
_pol_warn = legacy_warning(_raw_policy) # loud note if a legacy name (e.g. guard) was used — no silent downgrade
|
|
205
|
+
if _pol_warn:
|
|
206
|
+
print(f" · {_pol_warn}")
|
|
207
|
+
mine_mode = cfg.mine # deterministic | llm | off
|
|
208
|
+
sub_depth = cfg.subagent_depth # 0 disables delegation
|
|
209
|
+
# SUBAGENT/base policy never prompts (no human in a spawned turn) → a confirm-mode runs as let-it-go for
|
|
210
|
+
# them (still blocks catastrophic). The MAIN agent's confirming policy is built at the hook below.
|
|
211
|
+
policy = make_policy("letitgo" if CONFIRMS.get(canonical) else canonical)
|
|
212
|
+
# model + reasoning resolution: explicit env wins, then the saved /model choice (prefs), then config.
|
|
213
|
+
_model = os.environ.get("AGENT_MODEL") or _prefs.get("model") or cfg.model
|
|
214
|
+
if not _model: # no built-in default — the user picks the model (parallels the API-key gate above)
|
|
215
|
+
print("No model configured. Run `sliceagent init` to pick a provider + model, "
|
|
216
|
+
"or set AGENT_MODEL to your model name.")
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
llm = OpenAILLM(model=_model)
|
|
219
|
+
if _prefs.get("reasoning") and not (os.environ.get("AGENT_REASONING") or os.environ.get("AGENT_THINKING")):
|
|
220
|
+
llm.reasoning = str(_prefs["reasoning"]).lower() # apply the saved /reasoning choice
|
|
221
|
+
retriever = make_code_index(root) # ripgrep CodeIndex (RELATED CODE tier); NullRetriever if no rg
|
|
222
|
+
memory = make_memory() # memem if available + a vault is configured, else NullMemory
|
|
223
|
+
|
|
224
|
+
sandbox = make_sandbox(cfg.sandbox_backend, image=cfg.sandbox_image, network=cfg.sandbox_network)
|
|
225
|
+
base_tools = LocalToolHost(root, sandbox=sandbox) # file ops confined to launch dir; shell via sandbox
|
|
226
|
+
for _r in os.environ.get("AGENT_ROOT", "").split(os.pathsep): # I2: extra dirs the user puts in reach
|
|
227
|
+
if _r.strip():
|
|
228
|
+
base_tools.add_root(_r.strip())
|
|
229
|
+
skills = make_skill_manager(cfg.skills_roots) # SKILL.md packs (config dirs or defaults)
|
|
230
|
+
# plugins: feed the SAME registry/skills, and contribute MCP servers + hooks (loaded first
|
|
231
|
+
# so plugin skills enter the catalog and plugin MCP servers get connected below)
|
|
232
|
+
plugin_mcp, plugin_hooks = load_plugins(
|
|
233
|
+
base_tools.registry, skills, cfg.plugin_dirs, root=root, config=cfg,
|
|
234
|
+
on_log=lambda m: print(f" · {m}"))
|
|
235
|
+
skill_tool = make_skill_tool(skills)
|
|
236
|
+
if skill_tool is not None: # register the `skill` tool into the shared registry
|
|
237
|
+
base_tools.registry.register(skill_tool)
|
|
238
|
+
from .code_grep import make_glob_tool, make_grep_tool # ripgrep discovery: grep (contents) + glob (names)
|
|
239
|
+
base_tools.registry.register(make_grep_tool(base_tools))
|
|
240
|
+
base_tools.registry.register(make_glob_tool(base_tools))
|
|
241
|
+
|
|
242
|
+
# WORKSPACE SWITCH — the agent CAN re-root when the user explicitly asks (the same re-root /cwd performs).
|
|
243
|
+
# Shared by the change_workspace tool AND /cwd. User-authorized either way; teenager mode still confirms
|
|
244
|
+
# the tool call, so switching is one tap instead of typing the whole path.
|
|
245
|
+
def _reroot(path: str) -> str:
|
|
246
|
+
nonlocal root, retriever
|
|
247
|
+
raw = os.path.expanduser((path or "").strip())
|
|
248
|
+
# a RELATIVE path (the agent often passes 'hunter') resolves against the CURRENT workspace, not the
|
|
249
|
+
# process cwd — so "switch to hunter" means <workspace>/hunter, as the user intends.
|
|
250
|
+
newp = os.path.realpath(raw if os.path.isabs(raw) else os.path.join(base_tools.root(), raw))
|
|
251
|
+
if not os.path.isdir(newp):
|
|
252
|
+
return f"not a directory: {path}"
|
|
253
|
+
base_tools._root = newp
|
|
254
|
+
base_tools._focus = None
|
|
255
|
+
base_tools._extra_roots.clear()
|
|
256
|
+
base_tools._subdir_hints = None # reset the convention-hint tracker (SENSORY CORTEX —
|
|
257
|
+
# derived from the filesystem, not persisted)
|
|
258
|
+
root = newp # crash-recovery WAL + @mentions track the new root
|
|
259
|
+
try:
|
|
260
|
+
_stats["workspace"] = _ws_name(newp) # status bar reflects the new workspace immediately
|
|
261
|
+
except NameError: # _stats not built yet (reroot before the REPL loop) — ignore
|
|
262
|
+
pass
|
|
263
|
+
try:
|
|
264
|
+
retriever = make_code_index(newp) # re-index for the RELATED CODE tier
|
|
265
|
+
except Exception: # noqa: BLE001 — re-index is best-effort; the re-root still stands
|
|
266
|
+
pass
|
|
267
|
+
return f"workspace switched → {newp} (repo map, file tools & commands now rooted here)"
|
|
268
|
+
|
|
269
|
+
from .registry import ToolEntry
|
|
270
|
+
_CW_SCHEMA = {"type": "function", "function": {
|
|
271
|
+
"name": "change_workspace",
|
|
272
|
+
"description": ("Switch your workspace ROOT to another directory — call this when the user explicitly "
|
|
273
|
+
"asks to switch workspace / open a project / work in a different folder (e.g. 'switch "
|
|
274
|
+
"to hunter', 'work in ~/proj'). Re-roots your file tools, run_command cwd, repo map and "
|
|
275
|
+
"git to that dir. Do NOT call it for a mere mention of a path."),
|
|
276
|
+
"parameters": {"type": "object", "properties": {
|
|
277
|
+
"path": {"type": "string", "description": "Directory to switch to (absolute or ~)."}},
|
|
278
|
+
"required": ["path"]}}}
|
|
279
|
+
base_tools.registry.register(ToolEntry(
|
|
280
|
+
name="change_workspace", schema=_CW_SCHEMA,
|
|
281
|
+
handler=lambda args: _reroot(str((args or {}).get("path") or "")),
|
|
282
|
+
accesses=lambda args: [], source="builtin"))
|
|
283
|
+
# web tools (fetch_url + web_search, DuckDuckGo, no key) — network egress, so gated by AGENT_WEB
|
|
284
|
+
# (default ON). SSRF-guarded + results fenced UNTRUSTED + large pages paged (see web.py).
|
|
285
|
+
if os.environ.get("AGENT_WEB", "1").strip().lower() not in ("0", "off", "false", "no"):
|
|
286
|
+
from .web import make_web_tools
|
|
287
|
+
for _wt in make_web_tools(base_tools):
|
|
288
|
+
base_tools.registry.register(_wt)
|
|
289
|
+
# foreground SKILL writer — the agent-callable tool /learn drives to turn a transcript into a
|
|
290
|
+
# reusable USER-provenance skill (guarded write: validate + threat-scan + redact + atomic).
|
|
291
|
+
from .memory import make_write_skill_tool
|
|
292
|
+
base_tools.registry.register(make_write_skill_tool())
|
|
293
|
+
# MCP: connect config + plugin-declared servers; tools register into the SAME registry
|
|
294
|
+
mcp_servers, mcp_runtime = connect_mcp_servers(
|
|
295
|
+
base_tools.registry, {**cfg.mcp_servers, **plugin_mcp}, on_log=lambda m: print(f" · {m}"),
|
|
296
|
+
page_out=base_tools._page_out) # big MCP results → blob + head/tail view, not inlined whole
|
|
297
|
+
mcp_tool_count = sum(1 for e in base_tools.registry._tools.values() if e.source == "mcp")
|
|
298
|
+
plugin_tool_count = sum(1 for e in base_tools.registry._tools.values()
|
|
299
|
+
if e.source.startswith("plugin:"))
|
|
300
|
+
# subagent activity → ONE dynamic line, not a line per child tool call. Late-bound: the
|
|
301
|
+
# renderer is set once the rich sink exists (below); plain/headless leaves it None (the spawn tool's
|
|
302
|
+
# result line carries the child's summary, so nothing is lost).
|
|
303
|
+
_sub_render: dict = {"fn": None}
|
|
304
|
+
def _notify_subagent(text):
|
|
305
|
+
fn = _sub_render["fn"]
|
|
306
|
+
if fn is not None:
|
|
307
|
+
fn(text)
|
|
308
|
+
tools = base_tools
|
|
309
|
+
if sub_depth > 0: # wrap so the model can delegate sub-tasks (summary-only return)
|
|
310
|
+
from .agents import load_agents
|
|
311
|
+
# named-agent registry: built-ins (explorer, general) + user-defined <root>/agents/*.md
|
|
312
|
+
agent_roots = list(cfg.skills_roots or []) + [root, os.path.join(root, ".sliceagent")]
|
|
313
|
+
tools = SubagentHost(base_tools, llm=llm, retriever=retriever, memory=memory,
|
|
314
|
+
policy=policy, max_depth=sub_depth, notify=_notify_subagent,
|
|
315
|
+
agents=load_agents(agent_roots))
|
|
316
|
+
session = Session(memory) # host-side topic manager (one bounded Slice per topic)
|
|
317
|
+
llm.set_cache_key(session.session_id) # session-stable prompt-cache routing (cheapest cache lever)
|
|
318
|
+
for t in make_topic_tools(session): # model can route topics via new_topic / switch_topic
|
|
319
|
+
base_tools.registry.register(t)
|
|
320
|
+
# recall_history: the model's bounded valve into the cold cache (paged-out turns of this session).
|
|
321
|
+
if getattr(memory, "is_durable", False):
|
|
322
|
+
from .hippocampus import make_history_tool
|
|
323
|
+
base_tools.registry.register(make_history_tool(memory, session.session_id))
|
|
324
|
+
|
|
325
|
+
# write side of the memory loop is CACHE-ONLY: distillation runs at session end in
|
|
326
|
+
# memory.consolidate (reads the episodic cache, never the slice). `mine_mode` (off|deterministic|llm)
|
|
327
|
+
# gates that consolidation — see the session-end call below. No per-turn slice-coupled miner.
|
|
328
|
+
# OPT-IN async background-review fork (item 16; OFF unless AGENT_BACKGROUND_REVIEW set).
|
|
329
|
+
# Reads the durable episodic cache off-thread and consolidates incrementally — never
|
|
330
|
+
# touches the slice/loop/prompt. None when disabled, so the default path is unchanged.
|
|
331
|
+
from .background_review import make_background_reviewer
|
|
332
|
+
reviewer = make_background_reviewer(memory, scope=os.path.basename(root) or "default",
|
|
333
|
+
on_log=lambda m: print(f" · {m}"))
|
|
334
|
+
# episodic cache: lossless turn log (None for NullMemory → eval path untouched)
|
|
335
|
+
episodic = make_episode_sink(memory, session_id=session.session_id,
|
|
336
|
+
task_id_fn=lambda: session.active_id or "t-none",
|
|
337
|
+
title_fn=lambda: one_line(session.active().goal, 80) if session.active_id else "",
|
|
338
|
+
# task-outcome signal for consolidation: how many STANDING REQUIREMENTS
|
|
339
|
+
# were still open at turn end (0 = none declared OR all met). promote_procedures
|
|
340
|
+
# won't mine a "successful workflow" skill from a task that left some unmet.
|
|
341
|
+
outcome_fn=lambda: {"requirements_open": sum(
|
|
342
|
+
1 for r in session.active().requirements
|
|
343
|
+
if isinstance(r, dict) and not r.get("done"))} if session.active_id else {})
|
|
344
|
+
|
|
345
|
+
# optional rich TUI (the `tui` extra). Output via Rich, input via prompt_toolkit — temporally
|
|
346
|
+
# separate from the synchronous run_turn, so no patch_stdout/threading. Off when piped (eval).
|
|
347
|
+
_tui = None
|
|
348
|
+
_stats = {"model": llm.model, "policy": policy_label(canonical), "topic": "",
|
|
349
|
+
"workspace": _ws_name(root), "tokens": 0}
|
|
350
|
+
try:
|
|
351
|
+
from . import tui as _tuimod
|
|
352
|
+
if _tuimod.tui_enabled():
|
|
353
|
+
_tui = _tuimod
|
|
354
|
+
except Exception:
|
|
355
|
+
_tui = None
|
|
356
|
+
_console = _tui.make_console() if _tui else None # themed: no black-bg highlight on inline `code`/paths
|
|
357
|
+
|
|
358
|
+
# DEFAULT UI = the inline rich+prompt_toolkit REPL: it stays in the NORMAL terminal buffer, so native
|
|
359
|
+
# copy / paste / scrollback work on ANY terminal (incl. macOS Terminal.app), with a pinned composer
|
|
360
|
+
# (patch_stdout, which provides a pinned static region above a live-updating composer) and
|
|
361
|
+
# streaming replies. AGENT_TUI=off → plain stdout (handled in tui_enabled). AGENT_TUI=live → the
|
|
362
|
+
# always-pinned live composer: the bordered box stays at the bottom EVEN WHILE the agent streams (output
|
|
363
|
+
# prints above it). Opt-in/experimental; the default REPL (box between turns) is the proven path, and
|
|
364
|
+
# live falls back to it if it can't start.
|
|
365
|
+
tui_env = os.environ.get("AGENT_TUI", "").strip().lower()
|
|
366
|
+
use_live = (_tui is not None and tui_env == "live")
|
|
367
|
+
|
|
368
|
+
# wire the ask_user capability to a real prompt when interactive — NOT in live mode, where a worker-
|
|
369
|
+
# thread console.input() would contend with the pinned prompt_toolkit app for stdin and HANG.
|
|
370
|
+
if not use_live and (_tui or sys.stdin.isatty()):
|
|
371
|
+
# wire the ask_user capability to a real prompt when interactive (TUI rich prompt, or plain
|
|
372
|
+
# input); headless/eval — AND live mode, where a worker-thread console.input() would contend with
|
|
373
|
+
# the pinned prompt_toolkit app for stdin and HANG — keep the non-interactive default.
|
|
374
|
+
def _ask_user(question, options):
|
|
375
|
+
if _tui:
|
|
376
|
+
return _tui.ask_user(_console, question, options)
|
|
377
|
+
print(f"\n ❓ {question}")
|
|
378
|
+
for i, o in enumerate(options or [], 1):
|
|
379
|
+
print(f" {i}. {o}")
|
|
380
|
+
try:
|
|
381
|
+
a = input(" your answer ▸ ").strip()
|
|
382
|
+
except (EOFError, KeyboardInterrupt):
|
|
383
|
+
return "(no answer)"
|
|
384
|
+
if options and a.isdigit() and 1 <= int(a) <= len(options):
|
|
385
|
+
return options[int(a) - 1]
|
|
386
|
+
return a or "(no answer)"
|
|
387
|
+
base_tools.on_ask_user = _ask_user
|
|
388
|
+
|
|
389
|
+
# sinks: update the active slice from tool results, cache the turn, persist, print (no per-turn
|
|
390
|
+
# miner — distillation is cache-only at session end via memory.consolidate).
|
|
391
|
+
sinks = [slice_sink(session)]
|
|
392
|
+
if episodic is not None:
|
|
393
|
+
sinks.append(episodic)
|
|
394
|
+
sinks.append(log_sink(root))
|
|
395
|
+
# optional: the moat-MEASURING cost sink (AGENT_METRICS=1). Accumulates the per-turn FRESH-input
|
|
396
|
+
# curve (should stay flat as the conversation grows) + cache-hit rate + reliability counters; the
|
|
397
|
+
# summary prints at session end. Pure observer — eval/default path untouched.
|
|
398
|
+
metrics = None
|
|
399
|
+
if os.environ.get("AGENT_METRICS"):
|
|
400
|
+
from .metrics import make_metrics_sink
|
|
401
|
+
metrics = make_metrics_sink()
|
|
402
|
+
sinks.append(metrics)
|
|
403
|
+
# EXACTLY ONE renderer is wired: the rich+prompt_toolkit sink (TUI), OR the plain stdout sink
|
|
404
|
+
# (headless/eval). Never two.
|
|
405
|
+
if _tui:
|
|
406
|
+
_rich = _tui.make_rich_sink(_console, _stats)
|
|
407
|
+
sinks.append(_rich)
|
|
408
|
+
llm.set_delta_sink(_rich.on_delta) # STREAM completions live into the rich TUI spinner
|
|
409
|
+
# child agent activity → one dynamic spinner line. NOT in live mode: a rich console Status would
|
|
410
|
+
# fight the pinned prompt_toolkit Application for the screen (garbled output) — let the spawn tool's
|
|
411
|
+
# result line carry the child summary instead, as the plain/headless path does.
|
|
412
|
+
_sub_render["fn"] = None if use_live else _rich.subagent_notify
|
|
413
|
+
else:
|
|
414
|
+
sinks.append(cli_sink(cfg.show_slice))
|
|
415
|
+
# optional: feed the live web monitor (AGENT_MONITOR=1) — eval path untouched. Writes per-step
|
|
416
|
+
# snapshots to the shared monitor dir; view them in the STANDING server (python -m sliceagent.monitor),
|
|
417
|
+
# which stays up across sessions and goes idle when none is running.
|
|
418
|
+
monitor_sink = None
|
|
419
|
+
if os.environ.get("AGENT_MONITOR"):
|
|
420
|
+
from .monitor import _monitor_dir, make_file_monitor_sink
|
|
421
|
+
monitor_sink = make_file_monitor_sink(
|
|
422
|
+
session.session_id,
|
|
423
|
+
context_fn=lambda: {"goal": session.active().goal if session.active_id else "",
|
|
424
|
+
"topic": session.active_id or ""})
|
|
425
|
+
sinks.append(monitor_sink)
|
|
426
|
+
print(f" · slice monitor: writing to {_monitor_dir()} — view at the persistent server "
|
|
427
|
+
"(run: python -m sliceagent.monitor)")
|
|
428
|
+
dispatch = make_dispatcher(*sinks)
|
|
429
|
+
|
|
430
|
+
# policy hooks (the seam is always wired; default 'guard' blocks catastrophic commands)
|
|
431
|
+
def _ask(name, args, reason): # interactive resolver for AGENT_POLICY=ask
|
|
432
|
+
detail = args.get("command") or args.get("path") or args.get("code", "")
|
|
433
|
+
if use_live: # the pinned prompt_toolkit app owns stdin → a Rich confirm would hang; deny (safe default)
|
|
434
|
+
return "no"
|
|
435
|
+
if _tui: # synchronous mid-run (no pt app live) → a Rich confirm is safe
|
|
436
|
+
return _tui.confirm(_console, name, str(detail), reason)
|
|
437
|
+
if not sys.stdin.isatty():
|
|
438
|
+
return "no"
|
|
439
|
+
ans = input(f" ⚠ allow {name} {str(detail)[:60]!r}? ({reason}) [y]es/[n]o/[a]lways: ").strip().lower()
|
|
440
|
+
return {"y": "yes", "yes": "yes", "a": "always", "always": "always"}.get(ans, "no")
|
|
441
|
+
|
|
442
|
+
def _handle_slash(line): # TUI navigation palette — wired to existing session ops
|
|
443
|
+
parts = line.split(maxsplit=1)
|
|
444
|
+
cmd, arg = parts[0], (parts[1].strip() if len(parts) > 1 else "")
|
|
445
|
+
if cmd == "/help":
|
|
446
|
+
_console.print("commands: /model · /mode · /cwd · /learn · /plan · /cost · /threads · /plugins · /mcp · "
|
|
447
|
+
"/help · /exit\n (type / for the menu · /cwd <path> switches workspace · "
|
|
448
|
+
"Esc = undo last turn · say \"review my changes\" for code_review · @path pins a file)")
|
|
449
|
+
elif cmd == "/plan":
|
|
450
|
+
s = session.active() if session.active_id else None
|
|
451
|
+
plan = getattr(s, "plan", None) if s else None
|
|
452
|
+
mission = getattr(s, "mission", "") if s else ""
|
|
453
|
+
if mission:
|
|
454
|
+
_console.print(f" 🎯 mission: {mission}", markup=False)
|
|
455
|
+
if not plan:
|
|
456
|
+
_console.print(" (no active plan — the agent sets one with update_plan on multi-step tasks)")
|
|
457
|
+
else:
|
|
458
|
+
mark = {"done": "✓", "in_progress": "▶", "pending": "○"}
|
|
459
|
+
for it in plan:
|
|
460
|
+
_console.print(f" {mark.get(it.get('status'), '○')} {it.get('step', '')}", markup=False)
|
|
461
|
+
elif cmd == "/cost":
|
|
462
|
+
from .tui import _saved_dollars
|
|
463
|
+
saved = _saved_dollars(_stats)
|
|
464
|
+
spent = _stats.get("cost", 0.0)
|
|
465
|
+
head = (f" 💰 saved ${saved:.4f} vs full-history (@ {_stats.get('model','?')}, cache-aware)"
|
|
466
|
+
f" · spent ${spent:.4f}" if saved is not None
|
|
467
|
+
else f" 💰 {_stats.get('saved_cached_tok', 0):,} tokens saved vs full-history (model price unknown)")
|
|
468
|
+
_console.print(head)
|
|
469
|
+
if metrics is None:
|
|
470
|
+
_console.print(" (per-turn curve off — start with AGENT_METRICS=1 to track it)")
|
|
471
|
+
else:
|
|
472
|
+
s = metrics.summary()
|
|
473
|
+
_console.print(f" per_turn_fresh={s['per_turn_fresh']} avg={s['avg_turn_fresh']} "
|
|
474
|
+
f"cache_hit={s['cache_hit_rate']} tools={s['tool_calls']} "
|
|
475
|
+
f"out={s['output']} retries={s['retries']} overflows={s['overflows']}")
|
|
476
|
+
elif cmd == "/threads":
|
|
477
|
+
ts = session.open_threads(include_active=True)
|
|
478
|
+
# markup=False: task_id/title are DATA — `[{t.task_id}]` renders as a Rich tag → MarkupError crash.
|
|
479
|
+
_console.print((" (no topics yet)" if not ts else
|
|
480
|
+
"\n".join(f" [{t.task_id}] {t.title} ({t.status})" for t in ts)), markup=False)
|
|
481
|
+
elif cmd in ("/switch", "/resume"):
|
|
482
|
+
if not arg:
|
|
483
|
+
_console.print(f" usage: {cmd} <task_id>")
|
|
484
|
+
else:
|
|
485
|
+
try:
|
|
486
|
+
session.switch_topic(arg)
|
|
487
|
+
_stats["topic"] = one_line(session.active().goal, 40)
|
|
488
|
+
_console.print(f" switched to {arg}", markup=False)
|
|
489
|
+
except Exception:
|
|
490
|
+
_console.print(f" no such topic: {arg}", markup=False)
|
|
491
|
+
elif cmd == "/undo":
|
|
492
|
+
_console.print(" " + base_tools.undo_last()) # revert the last file edit
|
|
493
|
+
elif cmd == "/plugins":
|
|
494
|
+
tools = sorted(e.name for e in base_tools.registry._tools.values()
|
|
495
|
+
if getattr(e, "source", "") == "plugin")
|
|
496
|
+
_console.print(f" plugin dirs: {', '.join(cfg.plugin_dirs) or '(none configured)'}")
|
|
497
|
+
_console.print(f" plugin tools ({len(tools)}): {', '.join(tools) or '(none loaded)'}")
|
|
498
|
+
elif cmd == "/mcp":
|
|
499
|
+
configured = list(cfg.mcp_servers.keys())
|
|
500
|
+
mtools = sorted(e.name for e in base_tools.registry._tools.values()
|
|
501
|
+
if getattr(e, "source", "") == "mcp")
|
|
502
|
+
if not configured and not mtools:
|
|
503
|
+
_console.print(" no MCP servers configured — add [mcp_servers.<name>] to ~/.sliceagent/config.toml")
|
|
504
|
+
else:
|
|
505
|
+
_console.print(f" configured servers: {', '.join(configured) or '(none)'}")
|
|
506
|
+
_console.print(f" connected tools ({len(mtools)}): {', '.join(mtools) or '(none — check startup logs)'}")
|
|
507
|
+
elif cmd == "/mode":
|
|
508
|
+
_menu_ok = sys.stdin.isatty() and not use_live
|
|
509
|
+
chosen = None
|
|
510
|
+
if not arg and _menu_ok: # no arg + interactive → open the picker menu
|
|
511
|
+
from .tui import run_selector
|
|
512
|
+
order = ["babysitter", "teenager", "letitgo"]
|
|
513
|
+
rows = [("baby-sitter", "confirm every edit + command"),
|
|
514
|
+
("teenager", "auto edits, confirm commands"),
|
|
515
|
+
("let-it-go", "auto-run — still blocks catastrophic moves")]
|
|
516
|
+
cur = next((i for i, k in enumerate(order) if policy_label(k) == _stats.get("policy")), -1)
|
|
517
|
+
pick = run_selector("Permission mode", rows, current=cur)
|
|
518
|
+
chosen = order[pick] if pick is not None else None
|
|
519
|
+
elif arg: # typed: /mode teenager
|
|
520
|
+
chosen = resolve_policy_mode(arg)
|
|
521
|
+
if chosen not in ("babysitter", "teenager", "letitgo"):
|
|
522
|
+
_console.print(" unknown mode — use: baby-sitter | teenager | let-it-go"); return True
|
|
523
|
+
else: # no arg, non-interactive → just show current
|
|
524
|
+
_console.print(f" mode: [bold]{_stats.get('policy', '?')}[/] options: baby-sitter · teenager · let-it-go")
|
|
525
|
+
_console.print(" baby-sitter = confirm every edit + command · teenager = auto edits, confirm "
|
|
526
|
+
"commands · let-it-go = auto (blocks catastrophic)")
|
|
527
|
+
if chosen:
|
|
528
|
+
eff = chosen if (sys.stdin.isatty() and not use_live) or not CONFIRMS.get(chosen) else "letitgo"
|
|
529
|
+
perm_hook.policy = make_policy(eff)
|
|
530
|
+
perm_hook.on_ask = _ask if CONFIRMS.get(eff) else None
|
|
531
|
+
_stats["policy"] = policy_label(eff)
|
|
532
|
+
save_prefs({"policy": eff}) # remember the choice for next launch
|
|
533
|
+
_console.print(f" → [bold]{policy_label(eff)}[/]"
|
|
534
|
+
+ ("" if eff == chosen else " (no interactive prompt here → running as let-it-go)"))
|
|
535
|
+
elif cmd == "/model":
|
|
536
|
+
if not arg and sys.stdin.isatty() and not use_live: # open the two-tier model→reasoning menu
|
|
537
|
+
from .tui import select_model_reasoning
|
|
538
|
+
choice = select_model_reasoning(llm, cfg)
|
|
539
|
+
if choice:
|
|
540
|
+
llm.switch(model=choice[0], reasoning=choice[1])
|
|
541
|
+
_stats["model"] = llm.model
|
|
542
|
+
save_prefs({"model": llm.model, "reasoning": llm.reasoning})
|
|
543
|
+
note = _reasoning_note(llm)
|
|
544
|
+
_console.print(f" ✓ model → [bold]{llm.model}[/] · reasoning [bold]{llm.reasoning}[/] (saved)"
|
|
545
|
+
+ (f"\n {note}" if note else ""))
|
|
546
|
+
elif not arg:
|
|
547
|
+
_console.print(f" model: [bold]{llm.model}[/] · reasoning: [bold]{llm.reasoning}[/]"
|
|
548
|
+
f" · net: {getattr(llm, 'proxy_used', 'direct')}")
|
|
549
|
+
known = ("gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "deepseek-chat",
|
|
550
|
+
"kimi-k2-0905-preview", "claude-sonnet-4-6")
|
|
551
|
+
_console.print(" switch: /model <name> [fast|full|high|max]")
|
|
552
|
+
_console.print(" known: " + ", ".join(known))
|
|
553
|
+
provs = cfg.providers()
|
|
554
|
+
if provs:
|
|
555
|
+
_console.print(" providers (use `config --use <id>` to change endpoint): "
|
|
556
|
+
+ ", ".join(f"{k}={v.get('model', '?')}" for k, v in provs.items()))
|
|
557
|
+
else:
|
|
558
|
+
name, *rest = arg.split()
|
|
559
|
+
eff = rest[0].lower() if rest else None
|
|
560
|
+
if eff and eff not in ("fast", "full", "high", "max"):
|
|
561
|
+
_console.print(" effort must be one of: fast | full | high | max"); return True
|
|
562
|
+
llm.switch(model=name, reasoning=eff)
|
|
563
|
+
_stats["model"] = llm.model
|
|
564
|
+
save_prefs({"model": llm.model, "reasoning": llm.reasoning})
|
|
565
|
+
note = _reasoning_note(llm)
|
|
566
|
+
_console.print(f" ✓ model → [bold]{llm.model}[/]"
|
|
567
|
+
+ (f" · reasoning [bold]{llm.reasoning}[/]" if eff else "")
|
|
568
|
+
+ " (saved)" + (f"\n {note}" if note else ""))
|
|
569
|
+
elif cmd == "/reasoning":
|
|
570
|
+
if arg.lower() not in ("fast", "full", "high", "max"):
|
|
571
|
+
_console.print(" usage: /reasoning <fast|full|high|max>"
|
|
572
|
+
" (full = provider default; high/max use /v1/responses for gpt-5)")
|
|
573
|
+
else:
|
|
574
|
+
llm.switch(reasoning=arg)
|
|
575
|
+
save_prefs({"reasoning": llm.reasoning})
|
|
576
|
+
note = _reasoning_note(llm)
|
|
577
|
+
_console.print(f" ✓ reasoning → [bold]{llm.reasoning}[/] (saved)" + (f"\n {note}" if note else ""))
|
|
578
|
+
elif cmd == "/cwd":
|
|
579
|
+
# Manual workspace switch (the agent also does this via change_workspace when you ask). Shares
|
|
580
|
+
# _reroot: re-roots file tools + run_command cwd + repo map + git; re-indexes; crash-recovery
|
|
581
|
+
# follows the new root. (A running subagent host keeps the old code index until relaunch.)
|
|
582
|
+
if not arg:
|
|
583
|
+
_console.print(f" workspace: {base_tools.root()}", markup=False)
|
|
584
|
+
else:
|
|
585
|
+
_console.print(" ✓ " + _reroot(arg))
|
|
586
|
+
else:
|
|
587
|
+
_console.print(f" unknown command {cmd} (/help)", markup=False)
|
|
588
|
+
return True
|
|
589
|
+
|
|
590
|
+
_IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
|
591
|
+
|
|
592
|
+
def _expand_mentions(text):
|
|
593
|
+
"""@path mentions: pin each EXISTING workspace file referenced as @path into
|
|
594
|
+
the slice's OPEN FILES; an @image is ATTACHED as a vision content part for the next turn when the model
|
|
595
|
+
supports vision (else skipped with a hint). Best-effort; leaves the text intact."""
|
|
596
|
+
if "@" not in text or session.active_id is None:
|
|
597
|
+
return
|
|
598
|
+
import re as _re
|
|
599
|
+
from .model_catalog import capability
|
|
600
|
+
from .pfc import touch_file
|
|
601
|
+
vision = capability(llm.model, getattr(llm, "_base_url", "")).supports_vision
|
|
602
|
+
pinned, images, skipped = [], [], []
|
|
603
|
+
for m in _re.findall(r"@([\w./\-]+)", text):
|
|
604
|
+
rel = m.lstrip("/")
|
|
605
|
+
if ".." in rel: # never let @../ reach outside the workspace (defense-in-depth)
|
|
606
|
+
continue
|
|
607
|
+
if not (rel and os.path.isfile(os.path.join(root, rel))):
|
|
608
|
+
continue
|
|
609
|
+
if rel.lower().endswith(_IMG_EXT):
|
|
610
|
+
if vision:
|
|
611
|
+
res = base_tools.attach_image(rel) # only claim "attached" if it actually worked
|
|
612
|
+
(skipped if isinstance(res, str) and res.startswith("Error") else images).append(rel)
|
|
613
|
+
else:
|
|
614
|
+
skipped.append(rel)
|
|
615
|
+
else:
|
|
616
|
+
touch_file(session.active(), rel); pinned.append(rel)
|
|
617
|
+
if _tui and _console is not None:
|
|
618
|
+
if pinned: # paths like app/jobs/[id]/page.tsx contain [ ] → markup=False or Rich crashes
|
|
619
|
+
_console.print(f" 📎 pinned: {', '.join(pinned)}", markup=False)
|
|
620
|
+
if images:
|
|
621
|
+
_console.print(f" 🖼 attached image: {', '.join(images)}", markup=False)
|
|
622
|
+
if skipped:
|
|
623
|
+
_console.print(f" 🖼 skipped (needs a vision-capable AGENT_MODEL): {', '.join(skipped)}", markup=False)
|
|
624
|
+
|
|
625
|
+
# AGENT_AUTO_APPROVE: comma-separated fnmatch globs over the command, pre-approved so safe read-only
|
|
626
|
+
# commands never prompt (e.g. AGENT_AUTO_APPROVE="git status*,git diff*,ls *,cat *").
|
|
627
|
+
_auto = [r.strip() for r in (os.environ.get("AGENT_AUTO_APPROVE") or "").split(",") if r.strip()]
|
|
628
|
+
# MAIN agent's policy: the canonical mode, but a confirm-mode needs a human — with no interactive prompt
|
|
629
|
+
# (headless/piped, or the live composer that owns stdin) fall back to let-it-go (auto, still catastrophic-gated).
|
|
630
|
+
_can_confirm = sys.stdin.isatty() and not use_live
|
|
631
|
+
_eff_mode = canonical if (_can_confirm or not CONFIRMS.get(canonical)) else "letitgo"
|
|
632
|
+
_stats["policy"] = policy_label(_eff_mode)
|
|
633
|
+
if CONFIRMS.get(canonical) and _eff_mode != canonical: # confirm-mode with no way to prompt → say so
|
|
634
|
+
print(f" · non-interactive shell: '{policy_label(canonical)}' can't ask for confirmation here → "
|
|
635
|
+
f"running as '{policy_label(_eff_mode)}' (auto-run, still blocks catastrophic commands).")
|
|
636
|
+
perm_hook = PermissionHook(make_policy(_eff_mode),
|
|
637
|
+
on_ask=_ask if CONFIRMS.get(_eff_mode) else None, auto_approve=_auto)
|
|
638
|
+
hook_list = [perm_hook]
|
|
639
|
+
hook_list.append(GuardrailHook()) # cross-step loop guard (per-turn counters, reset each task)
|
|
640
|
+
if cfg.verify_cmd:
|
|
641
|
+
oracle = CommandOracle(cfg.verify_cmd)
|
|
642
|
+
hook_list.append(OracleHook(oracle, lambda out: setattr(session.active(), "last_error", f"Verification failed:\n{out[:600]}")))
|
|
643
|
+
if cfg.max_tokens:
|
|
644
|
+
hook_list.append(BudgetHook(cfg.max_tokens))
|
|
645
|
+
hook_list.extend(plugin_hooks) # plugins compose into the same hook chain
|
|
646
|
+
hooks = CompositeHooks(*hook_list)
|
|
647
|
+
|
|
648
|
+
info = (f"model={llm.model} · net={getattr(llm, 'proxy_used', 'direct')} · "
|
|
649
|
+
f"policy={policy_label(_eff_mode)} · sandbox={cfg.sandbox_backend} · "
|
|
650
|
+
f"code={type(retriever).__name__} · memory={type(memory).__name__} · "
|
|
651
|
+
f"episodic={'on' if episodic is not None else 'off'} · "
|
|
652
|
+
f"mine={mine_mode} · subagents={'on' if sub_depth > 0 else 'off'} · "
|
|
653
|
+
f"skills={len(skills.names())} · mcp_tools={mcp_tool_count} · plugin_tools={plugin_tool_count}")
|
|
654
|
+
# ── choose UI: the always-pinned live composer (AGENT_TUI=live), else the rich+prompt_toolkit REPL ──
|
|
655
|
+
_input = _tui.TuiInput(_stats, root=root) if _tui else None
|
|
656
|
+
|
|
657
|
+
from .text_utils import is_chitchat
|
|
658
|
+
|
|
659
|
+
def _chitchat_reply(text, _dispatch):
|
|
660
|
+
"""A pure greeting/social message → cheap reply: MINIMAL prompt, NO tools, NO slice. Skips the full
|
|
661
|
+
per-turn token cost (the 12k system prompt + tool schemas + tiers) for a 'hi'/'thanks'. (item D)"""
|
|
662
|
+
from .text_utils import CHITCHAT_PROMPT
|
|
663
|
+
msgs = [{"role": "system", "content": CHITCHAT_PROMPT}, {"role": "user", "content": text}]
|
|
664
|
+
try:
|
|
665
|
+
am = llm.complete(msgs, []) # NO tools
|
|
666
|
+
_dispatch(AssistantText((am.content or "").strip() or "Hi! What would you like to work on?"))
|
|
667
|
+
except Exception: # noqa: BLE001 — a chitchat reply must never crash the session
|
|
668
|
+
_dispatch(AssistantText("Hi! What would you like to work on?"))
|
|
669
|
+
|
|
670
|
+
def _run_one_turn(text, sink, signal):
|
|
671
|
+
"""One turn for the LIVE composer: route (lexical) → build slice → run_turn with a per-turn dispatch
|
|
672
|
+
that feeds the LiveSink. Runs in run_live's worker thread, so the pinned box stays responsive."""
|
|
673
|
+
if is_chitchat(text): # 'hi'/'thanks' → cheap reply, no slice/tools (item D)
|
|
674
|
+
_chitchat_reply(text, make_dispatcher(sink))
|
|
675
|
+
return
|
|
676
|
+
if session.active_id is None:
|
|
677
|
+
session.new_topic(text)
|
|
678
|
+
else:
|
|
679
|
+
action, tid = route(llm, text, session)
|
|
680
|
+
if action == "new":
|
|
681
|
+
session.new_topic(text)
|
|
682
|
+
elif action == "resume":
|
|
683
|
+
session.switch_topic(tid); session.continue_topic(text, resume=True)
|
|
684
|
+
else:
|
|
685
|
+
session.continue_topic(text)
|
|
686
|
+
_stats["topic"] = one_line(session.active().goal, 40) if session.active_id else ""
|
|
687
|
+
record_user(session.active(), text)
|
|
688
|
+
_expand_mentions(text) # @path → pin the file into OPEN FILES
|
|
689
|
+
build = make_build_slice(session, tools, retriever, memory, text, session.session_id)
|
|
690
|
+
# live mode must wire the SAME host sinks as the REPL path (episodic cache, durable log, metrics) —
|
|
691
|
+
# not just slice+renderer — else the cache→memory loop and /cost produce NOTHING in live mode.
|
|
692
|
+
_live_sinks = [slice_sink(session)]
|
|
693
|
+
if episodic is not None:
|
|
694
|
+
_live_sinks.append(episodic)
|
|
695
|
+
_live_sinks.append(log_sink(root))
|
|
696
|
+
if metrics is not None:
|
|
697
|
+
_live_sinks.append(metrics)
|
|
698
|
+
if monitor_sink is not None: # live mode must feed the web monitor too (was silently omitted)
|
|
699
|
+
_live_sinks.append(monitor_sink)
|
|
700
|
+
_live_sinks.append(sink)
|
|
701
|
+
live_dispatch = make_dispatcher(*_live_sinks)
|
|
702
|
+
llm.set_delta_sink(sink.on_delta)
|
|
703
|
+
import time as _t
|
|
704
|
+
_t0 = _t.monotonic()
|
|
705
|
+
from . import recovery as _rec
|
|
706
|
+
result = run_turn(build_slice=build, llm=llm, tools=tools, dispatch=live_dispatch,
|
|
707
|
+
hooks=hooks, signal=signal, max_steps=cfg.max_steps,
|
|
708
|
+
consolidate=lambda: consolidate_checkpoint(session.active(), compact=False),
|
|
709
|
+
checkpoint=lambda m, s, _g=text: _rec.record(root, goal=_g, messages=m, step=s))
|
|
710
|
+
_rec.clear(root) # clean/parked exit → drop the WAL
|
|
711
|
+
_stats["last_turn_s"] = _t.monotonic() - _t0
|
|
712
|
+
if getattr(memory, "is_durable", False):
|
|
713
|
+
from .taskstate import slice_to_task_state
|
|
714
|
+
memory.checkpoint_task(slice_to_task_state(
|
|
715
|
+
session.active(), session.active_id, session_id=session.session_id,
|
|
716
|
+
status="done" if result.stop_reason == "end_turn" else "parked"))
|
|
717
|
+
if reviewer is not None:
|
|
718
|
+
reviewer.review(session.session_id)
|
|
719
|
+
|
|
720
|
+
def _try_live() -> bool:
|
|
721
|
+
"""Run the always-pinned live composer; return True if it ran (REPL below is then skipped), False to
|
|
722
|
+
fall back to the REPL on any startup failure — input is never left broken."""
|
|
723
|
+
try:
|
|
724
|
+
_tui.run_live(console=_console, stats=_stats, banner_info=info, root=root,
|
|
725
|
+
run_one_turn=_run_one_turn, handle_slash=_handle_slash)
|
|
726
|
+
return True
|
|
727
|
+
except Exception as _e: # noqa: BLE001
|
|
728
|
+
print(f"\n live UI failed ({type(_e).__name__}: {_e}); using the inline REPL instead.")
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
# crash recovery: a leftover WAL means the last turn in this workspace never reached a clean/parked
|
|
732
|
+
# exit (a hard crash). Surface what was in flight, then clear it (auto-loop-resume is a future step).
|
|
733
|
+
from . import recovery
|
|
734
|
+
_pend = recovery.pending(root)
|
|
735
|
+
if _pend:
|
|
736
|
+
_rg = one_line(_pend.get("goal", ""), 60)
|
|
737
|
+
_rla = one_line(recovery.last_assistant(_pend), 160)
|
|
738
|
+
_note = (f" ⚠ recovered an interrupted turn (step {_pend.get('step', '?')}): {_rg}"
|
|
739
|
+
+ (f"\n last said: {_rla}" if _rla else "")
|
|
740
|
+
+ "\n its progress wasn't saved cleanly — re-send the request to continue.")
|
|
741
|
+
# markup=False: _note holds RECOVERED agent/user text (paths like app/jobs/[id]/page.tsx, code, or a
|
|
742
|
+
# stray `[/learn]`) — parsing it as Rich markup crashes startup with a MarkupError. Style, don't parse.
|
|
743
|
+
(_console.print(_note, style="yellow", markup=False) if _console is not None else print(_note))
|
|
744
|
+
recovery.clear(root)
|
|
745
|
+
|
|
746
|
+
if use_live and _try_live():
|
|
747
|
+
pass # the live composer ran the whole session (until ctrl-d/exit)
|
|
748
|
+
else:
|
|
749
|
+
if _tui:
|
|
750
|
+
_tui.banner(_console, info)
|
|
751
|
+
else:
|
|
752
|
+
print("sliceagent · slice core (run_turn) · " + info)
|
|
753
|
+
print('type a task, or "exit" to quit\n')
|
|
754
|
+
from .sensory_cortex import project_root as _project_root
|
|
755
|
+
if _project_root(root) is None: # launched outside a project → tell the user how to pick one
|
|
756
|
+
_hint = " · no project here — type /cwd <path> to set your workspace (or just chat / ask me to find one)"
|
|
757
|
+
(_console.print(f"[grey50]{_hint}[/]") if _console is not None else print(_hint))
|
|
758
|
+
while True:
|
|
759
|
+
if _input is not None:
|
|
760
|
+
line = _input.prompt()
|
|
761
|
+
if line is None: # ctrl-d / EOF
|
|
762
|
+
break
|
|
763
|
+
line = line.strip()
|
|
764
|
+
else:
|
|
765
|
+
try:
|
|
766
|
+
line = input("You: ").strip()
|
|
767
|
+
except (EOFError, KeyboardInterrupt):
|
|
768
|
+
print()
|
|
769
|
+
break
|
|
770
|
+
if line in ("exit", "quit", "/exit"):
|
|
771
|
+
break
|
|
772
|
+
if not line:
|
|
773
|
+
continue
|
|
774
|
+
if line == "/learn" or line.startswith("/learn "): # transcript → reusable skill (runs as a turn)
|
|
775
|
+
from .neocortex import build_learn_prompt
|
|
776
|
+
line = build_learn_prompt(line[len("/learn"):].strip())
|
|
777
|
+
elif _tui and line.startswith("/"): # navigation palette (no turn)
|
|
778
|
+
_handle_slash(line)
|
|
779
|
+
continue
|
|
780
|
+
# INVARIANT: echo the user's line BEFORE any blocking work (esp. route_topic's LLM round-trip),
|
|
781
|
+
# so the message paints the instant Enter is pressed — not ~0.5-2s later.
|
|
782
|
+
if _tui: # anchor the user turn with spacing (fixes cramped layout)
|
|
783
|
+
_tui.user_echo(_console, line)
|
|
784
|
+
if is_chitchat(line): # 'hi'/'thanks' → cheap reply, skip routing/slice/run_turn (item D)
|
|
785
|
+
_chitchat_reply(line, dispatch)
|
|
786
|
+
continue
|
|
787
|
+
if session.active_id is None: # first message bootstraps the first topic
|
|
788
|
+
session.new_topic(line)
|
|
789
|
+
else: # route: continue / new / resume (no junk topic)
|
|
790
|
+
# route() is lexical by default (instant, zero round-trips); AGENT_ROUTER=llm restores the
|
|
791
|
+
# classifier (a provider round-trip). Cover it with a 'routing…' spinner so the llm mode has
|
|
792
|
+
# no silent freeze before run_turn's own 'thinking…' spinner (which only starts at SliceBuilt).
|
|
793
|
+
if _tui:
|
|
794
|
+
with _console.status("[grey50]routing…[/]", spinner="dots"):
|
|
795
|
+
action, tid = route(llm, line, session)
|
|
796
|
+
else:
|
|
797
|
+
action, tid = route(llm, line, session)
|
|
798
|
+
if action == "new":
|
|
799
|
+
session.new_topic(line)
|
|
800
|
+
elif action == "resume":
|
|
801
|
+
session.switch_topic(tid)
|
|
802
|
+
session.continue_topic(line, resume=True)
|
|
803
|
+
else:
|
|
804
|
+
session.continue_topic(line)
|
|
805
|
+
if not _tui: # TUI shows the topic in the status bar, not as noise
|
|
806
|
+
print(f" · topic: {action}{(' ' + tid) if tid else ''}")
|
|
807
|
+
_stats["topic"] = one_line(session.active().goal, 40) if session.active_id else ""
|
|
808
|
+
record_user(session.active(), line) # short-range continuity: the RECENT CONVERSATION tier
|
|
809
|
+
_expand_mentions(line) # @path → pin the file into OPEN FILES
|
|
810
|
+
build = make_build_slice(session, tools, retriever, memory, line, session.session_id)
|
|
811
|
+
if os.environ.get("AGENT_TIMING"): # per-turn latency breakdown (build vs model) → find the hang
|
|
812
|
+
import time as _tt
|
|
813
|
+
_b = build
|
|
814
|
+
def build(_b=_b):
|
|
815
|
+
_s = _tt.monotonic()
|
|
816
|
+
r = _b()
|
|
817
|
+
print(f" ⏱ slice build {(_tt.monotonic() - _s) * 1000:.0f} ms (spinner appears here; "
|
|
818
|
+
"the rest of the wait is the model's first token)", flush=True)
|
|
819
|
+
return r
|
|
820
|
+
# ctrl-c OR esc during the turn (incl. while the LLM is thinking) raises KeyboardInterrupt, which
|
|
821
|
+
# run_turn catches → aborts the turn cleanly and returns here to the prompt (then ctrl-d quits).
|
|
822
|
+
# Esc is translated to a real SIGINT by a narrow background sentinel (a no-op on non-tty/eval —
|
|
823
|
+
# start() gates itself exactly like _arrow_select does), so it reaches the SAME KeyboardInterrupt
|
|
824
|
+
# path ctrl-c already uses instead of a separate abort mechanism.
|
|
825
|
+
_esc = _tui.make_esc_sentinel() if _tui else None
|
|
826
|
+
if _esc is not None:
|
|
827
|
+
_esc.start()
|
|
828
|
+
import time as _time
|
|
829
|
+
_t0 = _time.monotonic()
|
|
830
|
+
try:
|
|
831
|
+
result = run_turn(build_slice=build, llm=llm, tools=tools, dispatch=dispatch, hooks=hooks,
|
|
832
|
+
max_steps=cfg.max_steps,
|
|
833
|
+
consolidate=lambda: consolidate_checkpoint(session.active(), compact=False),
|
|
834
|
+
checkpoint=lambda m, s, _g=line: recovery.record(root, goal=_g, messages=m, step=s))
|
|
835
|
+
finally:
|
|
836
|
+
if _esc is not None:
|
|
837
|
+
_esc.stop()
|
|
838
|
+
recovery.clear(root) # clean/parked exit → drop the WAL
|
|
839
|
+
_stats["last_turn_s"] = _time.monotonic() - _t0 # shown as ⏲ in the status bar
|
|
840
|
+
if getattr(memory, "is_durable", False): # durable checkpoint (no-op under NullMemory)
|
|
841
|
+
from .taskstate import slice_to_task_state
|
|
842
|
+
memory.checkpoint_task(slice_to_task_state(
|
|
843
|
+
session.active(), session.active_id, session_id=session.session_id,
|
|
844
|
+
status="done" if result.stop_reason == "end_turn" else "parked"))
|
|
845
|
+
if reviewer is not None: # OPT-IN: critique the turn off-thread
|
|
846
|
+
reviewer.review(session.session_id)
|
|
847
|
+
|
|
848
|
+
# session end: tear down background procs / PTY sessions, MCP servers, and consolidate memory. Each
|
|
849
|
+
# step is GUARDED (a failure warns, never crashes the exit) and the MCP shutdown is BOUNDED by a
|
|
850
|
+
# timeout, so a stuck server or index write can never freeze the process on the way out.
|
|
851
|
+
def _safe(label, fn):
|
|
852
|
+
try:
|
|
853
|
+
return fn()
|
|
854
|
+
except Exception as _e: # noqa: BLE001
|
|
855
|
+
print(f" · warning: {label} failed ({type(_e).__name__}: {_e})")
|
|
856
|
+
return None
|
|
857
|
+
|
|
858
|
+
def _bounded(label, fn, secs=8.0):
|
|
859
|
+
import threading as _th
|
|
860
|
+
t = _th.Thread(target=lambda: _safe(label, fn), daemon=True)
|
|
861
|
+
t.start(); t.join(secs)
|
|
862
|
+
if t.is_alive():
|
|
863
|
+
print(f" · warning: {label} timed out after {secs:.0f}s — exiting anyway")
|
|
864
|
+
|
|
865
|
+
_safe("tool cleanup", base_tools.cleanup)
|
|
866
|
+
if reviewer is not None: # let an in-flight background review finish (bounded) before consolidating
|
|
867
|
+
_safe("bg-review join", lambda: reviewer.join(timeout=10))
|
|
868
|
+
if mcp_runtime is not None: # #61/#62: a stuck MCP server must not freeze exit → bounded shutdown
|
|
869
|
+
_bounded("MCP shutdown", mcp_runtime.shutdown)
|
|
870
|
+
# consolidate the episodic cache into long-term memory (the cache→memory loop). mine_mode gates it:
|
|
871
|
+
# off → skip; deterministic → recorded skills; llm → render_skill_llm generalizes (scan-first).
|
|
872
|
+
if getattr(memory, "is_durable", False) and mine_mode not in ("0", "off", "none"):
|
|
873
|
+
st = _safe("memory consolidation",
|
|
874
|
+
lambda: memory.consolidate(session.session_id, llm=llm, mode=mine_mode)) or {}
|
|
875
|
+
if st.get("lessons") or st.get("skills"): # report the TRUTH, not a blind 'success'
|
|
876
|
+
print(f" · consolidated: {st.get('lessons', 0)} lesson(s), {st.get('skills', 0)} skill(s)"
|
|
877
|
+
+ (f", {st['skills_rejected']} rejected" if st.get("skills_rejected") else "")
|
|
878
|
+
+ (f", {st['errors']} error(s)" if st.get("errors") else ""))
|
|
879
|
+
elif st.get("skills_rejected") or st.get("errors"):
|
|
880
|
+
print(f" · consolidation: {st.get('skills_rejected', 0)} rejected, {st.get('errors', 0)} error(s)")
|
|
881
|
+
_safe("memory close", getattr(memory, "close", lambda: None)) # #33: close the FTS5 index (WAL checkpoint)
|
|
882
|
+
if metrics is not None: # the moat number: per-turn fresh-input curve
|
|
883
|
+
s = metrics.summary()
|
|
884
|
+
print(f" · metrics: per_turn_fresh={s['per_turn_fresh']} avg={s['avg_turn_fresh']} "
|
|
885
|
+
f"cache_hit={s['cache_hit_rate']} tools={s['tool_calls']}({s['tool_failures']} fail) "
|
|
886
|
+
f"retries={s['retries']} overflows={s['overflows']} errors={s['errors']}")
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
if __name__ == "__main__":
|
|
890
|
+
main()
|