hexcli 2.8.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.
- hexcli/__init__.py +6 -0
- hexcli/agent.py +1931 -0
- hexcli/assets/hexcli.ico +0 -0
- hexcli/assets/hexcli.png +0 -0
- hexcli/cancel.py +76 -0
- hexcli/chatlog.py +232 -0
- hexcli/commands.py +73 -0
- hexcli/compaction.py +309 -0
- hexcli/config.py +217 -0
- hexcli/diffview.py +106 -0
- hexcli/distribution.py +237 -0
- hexcli/doctor.py +265 -0
- hexcli/escalate.py +192 -0
- hexcli/http_client.py +156 -0
- hexcli/launcher.py +481 -0
- hexcli/lineedit.py +1110 -0
- hexcli/llm.py +599 -0
- hexcli/local_escalation.py +191 -0
- hexcli/lockfile.py +71 -0
- hexcli/loop_v2.py +393 -0
- hexcli/markdown_stream.py +241 -0
- hexcli/memory.py +416 -0
- hexcli/network.py +154 -0
- hexcli/parsing.py +215 -0
- hexcli/paths.py +127 -0
- hexcli/prompts.py +321 -0
- hexcli/protocol_v2.py +505 -0
- hexcli/repl.py +807 -0
- hexcli/safety.py +127 -0
- hexcli/sessions.py +226 -0
- hexcli/setup_wizard.py +144 -0
- hexcli/shell_session.py +186 -0
- hexcli/statusbar.py +894 -0
- hexcli/stream_render.py +250 -0
- hexcli/telemetry.py +131 -0
- hexcli/tools.py +775 -0
- hexcli/ui.py +1106 -0
- hexcli-2.8.0.dist-info/METADATA +394 -0
- hexcli-2.8.0.dist-info/RECORD +42 -0
- hexcli-2.8.0.dist-info/WHEEL +4 -0
- hexcli-2.8.0.dist-info/entry_points.txt +3 -0
- hexcli-2.8.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.local_escalation — consult a bigger LOCAL model at hard moments.
|
|
3
|
+
|
|
4
|
+
docs/V2_PLAN.md §4 escalation ladder, de-risked: instead of the (unavailable)
|
|
5
|
+
Qwen3-4B-Thinking-2507 self-compile, the precompiled qualcomm Qwen3-8B bundle
|
|
6
|
+
serves as the "senior engineer" — a hybrid-thinking model whose ~8-9 tok/s
|
|
7
|
+
decode is fine for rare consultations even though it would be too slow as the
|
|
8
|
+
main loop. Fully offline; the cloud path (hexcli.escalate) remains a separate,
|
|
9
|
+
opt-in, last resort.
|
|
10
|
+
|
|
11
|
+
The measured failure modes this targets (2026-07-30 instrument data):
|
|
12
|
+
* loop-detector trips (model repeats a failing call and cannot adapt)
|
|
13
|
+
* verification-gate nudges being ignored (finishes without checking work)
|
|
14
|
+
* "prose instead of action" — the model narrates or gives up on an edit
|
|
15
|
+
request without ever mutating a file (uc1-t5/t6, 0/3)
|
|
16
|
+
|
|
17
|
+
Design: one consult per turn max; every failure path degrades to the previous
|
|
18
|
+
behaviour (never crash the loop); the escalation server is spawned lazily on
|
|
19
|
+
first use and reused for the session.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import atexit
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
DETACHED_PROCESS = 0x00000008
|
|
35
|
+
|
|
36
|
+
_ESCALATION_SYSTEM = (
|
|
37
|
+
"You are a senior software engineer advising a junior terminal agent that "
|
|
38
|
+
"has gotten stuck. Think through the situation carefully, then give ONE "
|
|
39
|
+
"concrete, specific next action: the exact command to run, the exact "
|
|
40
|
+
"old/new text for a file edit, or the exact question to ask. Be brief and "
|
|
41
|
+
"actionable — the junior agent will execute your advice literally."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Verbs that signal the user asked for a file mutation; used by the
|
|
45
|
+
# prose-instead-of-action trigger.
|
|
46
|
+
_EDIT_INTENT_RE = re.compile(
|
|
47
|
+
r"\b(add|fix|edit|update|change|modify|refactor|rename|insert|remove|"
|
|
48
|
+
r"delete|guard|implement|patch|rewrite|append)\b", re.IGNORECASE)
|
|
49
|
+
_MUTATING_TOOLS = frozenset({"edit_file", "write_file", "append_file", "edit", "write"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def looks_like_edit_request(query: str) -> bool:
|
|
53
|
+
return bool(_EDIT_INTENT_RE.search(query or ""))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def turn_mutated(tools_used: list[str]) -> bool:
|
|
57
|
+
return any(t in _MUTATING_TOOLS for t in tools_used)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LocalEscalator:
|
|
61
|
+
"""Lazily-started second npurun server hosting the escalation model."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
64
|
+
self.model = str(config.get("escalation_local_model", "") or "")
|
|
65
|
+
self.bind = str(config.get("escalation_local_bind", "127.0.0.1:11436"))
|
|
66
|
+
self.max_tokens = int(config.get("escalation_max_output_tokens", 900))
|
|
67
|
+
self.timeout_s = int(config.get("escalation_timeout_seconds", 240))
|
|
68
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
69
|
+
self._failed = False
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def enabled(self) -> bool:
|
|
73
|
+
return bool(self.model) and not self._failed
|
|
74
|
+
|
|
75
|
+
# -- server lifecycle ---------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _npurun_exe(self) -> Path:
|
|
78
|
+
return Path.home() / ".cargo" / "bin" / "npurun.exe"
|
|
79
|
+
|
|
80
|
+
def _env(self) -> dict[str, str]:
|
|
81
|
+
env = os.environ.copy()
|
|
82
|
+
sdk = Path(env.get("QNN_SDK_ROOT", r"C:\Qualcomm\AIStack\QAIRT_2.47.0"))
|
|
83
|
+
env["QNN_SDK_ROOT"] = str(sdk)
|
|
84
|
+
env["ADSP_LIBRARY_PATH"] = str(sdk / "lib" / "hexagon-v73" / "unsigned")
|
|
85
|
+
env["PATH"] = (
|
|
86
|
+
f"{sdk / 'bin' / 'aarch64-windows-msvc'};"
|
|
87
|
+
f"{sdk / 'lib' / 'aarch64-windows-msvc'};"
|
|
88
|
+
f"{self._npurun_exe().parent};{env.get('PATH', '')}"
|
|
89
|
+
)
|
|
90
|
+
return env
|
|
91
|
+
|
|
92
|
+
def _healthy(self) -> bool:
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(f"http://{self.bind}/healthz", timeout=3) as r:
|
|
95
|
+
return r.status == 200
|
|
96
|
+
except Exception:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def ensure_server(self, wait_s: int = 90) -> bool:
|
|
100
|
+
"""Start the escalation server if it isn't already up. Slow on first
|
|
101
|
+
use (bundle load ~10s + spawn); a no-op afterwards."""
|
|
102
|
+
if self._healthy():
|
|
103
|
+
return True
|
|
104
|
+
exe = self._npurun_exe()
|
|
105
|
+
if not exe.exists():
|
|
106
|
+
self._failed = True
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
self._proc = subprocess.Popen(
|
|
110
|
+
[str(exe), "serve", "--model", self.model, "--bind", self.bind],
|
|
111
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
112
|
+
env=self._env(), creationflags=DETACHED_PROCESS,
|
|
113
|
+
)
|
|
114
|
+
# DETACHED_PROCESS means this multi-GB server outlives the CLI
|
|
115
|
+
# unless someone reaps it — and nothing called stop(). Register
|
|
116
|
+
# now, at the moment of spawn, so no exit path can miss it.
|
|
117
|
+
atexit.register(self.stop)
|
|
118
|
+
except Exception:
|
|
119
|
+
self._failed = True
|
|
120
|
+
return False
|
|
121
|
+
deadline = time.time() + wait_s
|
|
122
|
+
while time.time() < deadline:
|
|
123
|
+
if self._healthy():
|
|
124
|
+
return True
|
|
125
|
+
time.sleep(2)
|
|
126
|
+
self._failed = True # don't retry every turn against a broken spawn
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def stop(self) -> None:
|
|
130
|
+
"""Reap the escalation server. Idempotent; safe from atexit."""
|
|
131
|
+
proc, self._proc = self._proc, None
|
|
132
|
+
if proc is None:
|
|
133
|
+
return
|
|
134
|
+
try:
|
|
135
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
136
|
+
capture_output=True, timeout=10)
|
|
137
|
+
except Exception:
|
|
138
|
+
try:
|
|
139
|
+
proc.kill()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# -- consultation -------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def consult(self, situation: str) -> str | None:
|
|
146
|
+
"""Ask the escalation model for advice. Returns None on ANY failure —
|
|
147
|
+
callers must degrade gracefully."""
|
|
148
|
+
if not self.enabled or not self.ensure_server():
|
|
149
|
+
return None
|
|
150
|
+
body = json.dumps({
|
|
151
|
+
"model": self.model,
|
|
152
|
+
"messages": [
|
|
153
|
+
{"role": "system", "content": _ESCALATION_SYSTEM},
|
|
154
|
+
{"role": "user", "content": situation},
|
|
155
|
+
],
|
|
156
|
+
"max_tokens": self.max_tokens,
|
|
157
|
+
"temperature": 0.6,
|
|
158
|
+
"stream": False,
|
|
159
|
+
"stop": ["<|im_end|>", "<|im_start|>"],
|
|
160
|
+
}).encode()
|
|
161
|
+
req = urllib.request.Request(
|
|
162
|
+
f"http://{self.bind}/v1/chat/completions",
|
|
163
|
+
data=body, headers={"Content-Type": "application/json"},
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
|
|
167
|
+
data = json.loads(resp.read())
|
|
168
|
+
text = data["choices"][0]["message"]["content"] or ""
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
# The hybrid 8B thinks in <think> blocks; only the conclusion goes
|
|
172
|
+
# back to the 4B loop.
|
|
173
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
174
|
+
return text or None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_situation(
|
|
178
|
+
query: str,
|
|
179
|
+
recent_events: list[str],
|
|
180
|
+
problem: str,
|
|
181
|
+
max_chars: int = 4000,
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Compact consultation prompt: the task, what happened, what went wrong."""
|
|
184
|
+
events = "\n".join(f"- {e[:400]}" for e in recent_events[-8:])
|
|
185
|
+
text = (
|
|
186
|
+
f"TASK the agent was given:\n{query}\n\n"
|
|
187
|
+
f"RECENT ACTIONS AND RESULTS:\n{events}\n\n"
|
|
188
|
+
f"PROBLEM:\n{problem}\n\n"
|
|
189
|
+
"What exactly should the agent do next?"
|
|
190
|
+
)
|
|
191
|
+
return text[:max_chars]
|
hexcli/lockfile.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.lockfile — Advisory PID-file process lock for the shellai entry point.
|
|
3
|
+
|
|
4
|
+
Prevents two shellai instances from sharing the same npurun backend at the
|
|
5
|
+
same time (the backend is a singleton: one KV-cache context, one CDSP
|
|
6
|
+
session). All operations are non-fatal: if the filesystem is read-only or
|
|
7
|
+
ctypes is unavailable, the lock is silently skipped and the agent still
|
|
8
|
+
starts.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import atexit
|
|
13
|
+
import ctypes
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_LOCK_PATH: Path | None = None
|
|
18
|
+
_SYNCHRONIZE = 0x00100000 # Windows PROCESS_SYNCHRONIZE access right
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pid_alive(pid: int) -> bool:
|
|
22
|
+
"""Return True if the process with this PID is currently running (Windows)."""
|
|
23
|
+
try:
|
|
24
|
+
h = ctypes.windll.kernel32.OpenProcess(_SYNCHRONIZE, False, pid)
|
|
25
|
+
if h:
|
|
26
|
+
ctypes.windll.kernel32.CloseHandle(h)
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
except Exception:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def acquire(lock_dir: Path) -> str | None:
|
|
34
|
+
"""Write a PID lock file in lock_dir.
|
|
35
|
+
|
|
36
|
+
Returns a warning string if another live shellai process is already
|
|
37
|
+
running; returns None if the lock was acquired cleanly (or if the
|
|
38
|
+
check could not be performed).
|
|
39
|
+
"""
|
|
40
|
+
global _LOCK_PATH
|
|
41
|
+
lock_path = lock_dir / "shellai.lock"
|
|
42
|
+
_LOCK_PATH = lock_path
|
|
43
|
+
warning: str | None = None
|
|
44
|
+
|
|
45
|
+
if lock_path.exists():
|
|
46
|
+
try:
|
|
47
|
+
existing_pid = int(lock_path.read_text(encoding="utf-8").strip())
|
|
48
|
+
if existing_pid != os.getpid() and _pid_alive(existing_pid):
|
|
49
|
+
warning = f" ⚠ Another Hex CLI is running in this directory, PID {existing_pid}."
|
|
50
|
+
except Exception:
|
|
51
|
+
pass # stale or unreadable lock — overwrite silently
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
lock_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
56
|
+
atexit.register(_release)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass # read-only filesystem or permission error — advisory only
|
|
59
|
+
|
|
60
|
+
return warning
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _release() -> None:
|
|
64
|
+
"""Remove the lock file if it still contains our PID."""
|
|
65
|
+
if _LOCK_PATH is None or not _LOCK_PATH.exists():
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
if int(_LOCK_PATH.read_text(encoding="utf-8").strip()) == os.getpid():
|
|
69
|
+
_LOCK_PATH.unlink()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
hexcli/loop_v2.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.loop_v2 — the v2 agent loop (docs/V2_PLAN.md §5-§6).
|
|
3
|
+
|
|
4
|
+
Selected via config {"protocol": "v2"}; hexcli.agent.run_autopilot delegates
|
|
5
|
+
here so the v1 loop stays untouched while v2 is A/B-tested on the eval
|
|
6
|
+
instrument. Differences from v1, by design:
|
|
7
|
+
|
|
8
|
+
* Byte-stable system core (protocol_v2.SYSTEM_PROMPT_V2) + one per-session
|
|
9
|
+
context line — no per-turn workspace snapshot, no date/cwd churn, no
|
|
10
|
+
keyword-conditional tool schemas. Append-only message layout.
|
|
11
|
+
* Native-format actions parsed by protocol_v2; multi-line payloads never
|
|
12
|
+
touch JSON. A plain-text reply IS the final answer.
|
|
13
|
+
* Unconditional retry-with-precise-error-feedback on malformed output
|
|
14
|
+
(v1 retried only when the raw text happened to contain a tool name).
|
|
15
|
+
* Persistent PowerShell session: cd/env/variables survive across steps
|
|
16
|
+
(and across turns in the REPL).
|
|
17
|
+
* Fuzzy error-loop detection on (tool, args, payload) — near-identical
|
|
18
|
+
failing calls trip it, not just byte-identical (tool, output) pairs.
|
|
19
|
+
|
|
20
|
+
Shares with v1: call_llm transport (mock backend included), safety
|
|
21
|
+
classification + audit log + destructive confirm, sensitive-path blocks,
|
|
22
|
+
undo snapshots, memory indexing, telemetry, and the AutopilotProbe seam —
|
|
23
|
+
so the eval instrument drives both protocols unchanged.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import atexit
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from . import memory, safety, telemetry, ui
|
|
37
|
+
from .protocol_v2 import (
|
|
38
|
+
SYSTEM_PROMPT_V2,
|
|
39
|
+
apply_search_replace,
|
|
40
|
+
build_session_context,
|
|
41
|
+
parse_response,
|
|
42
|
+
render_tool_result,
|
|
43
|
+
)
|
|
44
|
+
from .shell_session import ShellSession
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def trim_middle(text: str, limit: int) -> str:
|
|
48
|
+
"""Head+tail truncation: command output usually carries its verdict at the
|
|
49
|
+
END (exit summaries, stack traces), so the tail must survive — v1's
|
|
50
|
+
head-only trim hid exactly the informative part."""
|
|
51
|
+
if len(text) <= limit:
|
|
52
|
+
return text
|
|
53
|
+
head = int(limit * 0.6)
|
|
54
|
+
tail = limit - head
|
|
55
|
+
omitted = len(text) - head - tail
|
|
56
|
+
return (text[:head] + f"\n[... {omitted} chars omitted ...]\n" + text[-tail:])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_MAX_FORMAT_RETRIES = 2 # per step, mirrors v1's retry budget
|
|
60
|
+
_LOOP_WINDOW = 3 # near-identical calls before the loop detector trips
|
|
61
|
+
_READ_DEFAULT_LIMIT = 400 # lines per read page
|
|
62
|
+
|
|
63
|
+
# Persistent shells for REPL sessions, keyed by session id. One-shot and eval
|
|
64
|
+
# runs (session=None) get an ephemeral shell that lives for the turn only.
|
|
65
|
+
_SESSION_SHELLS: dict[str, ShellSession] = {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@atexit.register
|
|
69
|
+
def _close_all_shells() -> None:
|
|
70
|
+
for sh in list(_SESSION_SHELLS.values()):
|
|
71
|
+
try:
|
|
72
|
+
sh.close()
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
_SESSION_SHELLS.clear()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_shell(session: dict[str, Any] | None, cwd: str,
|
|
79
|
+
shell_exe: str = "") -> tuple[ShellSession, bool]:
|
|
80
|
+
# Honour the user's shell_exe (and v1's pwsh-over-powershell preference);
|
|
81
|
+
# v2 previously hardcoded powershell.exe and silently ignored the setting.
|
|
82
|
+
exe = shell_exe or "powershell.exe"
|
|
83
|
+
if session and session.get("id"):
|
|
84
|
+
sid = str(session["id"])
|
|
85
|
+
sh = _SESSION_SHELLS.get(sid)
|
|
86
|
+
if sh is None:
|
|
87
|
+
sh = ShellSession(cwd=cwd, shell_exe=exe)
|
|
88
|
+
_SESSION_SHELLS[sid] = sh
|
|
89
|
+
return sh, False
|
|
90
|
+
return ShellSession(cwd=cwd, shell_exe=exe), True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def close_session_shell(session_id: str) -> None:
|
|
94
|
+
sh = _SESSION_SHELLS.pop(session_id, None)
|
|
95
|
+
if sh is not None:
|
|
96
|
+
sh.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# v2 tool dispatch
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _tool_read(agent: Any, args: dict[str, Any], output_limit: int) -> str:
|
|
104
|
+
path_text = str(args.get("path") or "").strip()
|
|
105
|
+
if not path_text:
|
|
106
|
+
return "Error: read requires 'path'."
|
|
107
|
+
path = agent.resolve_path(path_text)
|
|
108
|
+
agent._check_sensitive_path(path, "read")
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return f"Error: {path} does not exist."
|
|
111
|
+
if path.is_dir():
|
|
112
|
+
# The 4B model habitually tries read(".") to list a directory; a raw
|
|
113
|
+
# PermissionError here reads as "access denied" and derails the task.
|
|
114
|
+
return (f"Error: {path} is a directory, not a file. To list its "
|
|
115
|
+
f"contents, use shell with: Get-ChildItem \"{path}\"")
|
|
116
|
+
lines = path.read_text(encoding="utf-8", errors="replace").split("\n")
|
|
117
|
+
total = len(lines)
|
|
118
|
+
offset = max(1, int(args.get("offset") or 1))
|
|
119
|
+
limit = max(1, int(args.get("limit") or _READ_DEFAULT_LIMIT))
|
|
120
|
+
page = lines[offset - 1:offset - 1 + limit]
|
|
121
|
+
body = "\n".join(page)
|
|
122
|
+
header = ""
|
|
123
|
+
if offset > 1 or offset - 1 + limit < total:
|
|
124
|
+
end = min(offset - 1 + len(page), total)
|
|
125
|
+
header = (f"[lines {offset}-{end} of {total}; use offset/limit to read more]\n")
|
|
126
|
+
ui.tool_event("read", f"{path} ({total} lines)")
|
|
127
|
+
return header + agent.trim_text(body, output_limit)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _tool_write(agent: Any, args: dict[str, Any], payload: str | None) -> str:
|
|
131
|
+
path_text = str(args.get("path") or "").strip()
|
|
132
|
+
if not path_text:
|
|
133
|
+
return "Error: write requires 'path'."
|
|
134
|
+
if payload is None:
|
|
135
|
+
return "Error: write requires its file content in a fenced block after </action>."
|
|
136
|
+
return agent.write_file_tool(path_text, payload)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _tool_edit(agent: Any, args: dict[str, Any], payload: list[tuple[str, str]] | None) -> str:
|
|
140
|
+
path_text = str(args.get("path") or "").strip()
|
|
141
|
+
if not path_text:
|
|
142
|
+
return "Error: edit requires 'path'."
|
|
143
|
+
if not payload:
|
|
144
|
+
return "Error: edit requires at least one SEARCH/REPLACE block after </action>."
|
|
145
|
+
path = agent.resolve_path(path_text)
|
|
146
|
+
# This block reimplements the edit (payload blocks instead of old/new
|
|
147
|
+
# strings) rather than delegating, so it must gate itself. The write-scope
|
|
148
|
+
# half was once missing here while v1 had it, letting protocol v2 edit
|
|
149
|
+
# anywhere on disk — hence the single guard_mutation entry point.
|
|
150
|
+
agent.guard_mutation(path, "edit", agent._ACTIVE_CONFIG)
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return f"Error: {path} does not exist. Use write to create new files."
|
|
153
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
154
|
+
new_content, err = apply_search_replace(content, payload)
|
|
155
|
+
if err:
|
|
156
|
+
return f"Error: {err}"
|
|
157
|
+
tmp = path.parent / (path.name + ".tmp")
|
|
158
|
+
tmp.write_text(new_content, encoding="utf-8")
|
|
159
|
+
tmp.replace(path)
|
|
160
|
+
ui.tool_event("edit", f"{path} ({len(payload)} block(s))")
|
|
161
|
+
return f"Edited {path}: {len(payload)} block(s) applied."
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _tool_shell(agent: Any, config: dict[str, Any], sh: ShellSession, args: dict[str, Any]) -> str:
|
|
165
|
+
cmd = str(args.get("command") or "").strip()
|
|
166
|
+
if not cmd:
|
|
167
|
+
return "Error: shell requires 'command'."
|
|
168
|
+
classification = safety.classify_command(cmd)
|
|
169
|
+
if classification == "destructive" and config.get("autopilot_confirm_destructive", True):
|
|
170
|
+
if not ui.confirm_destructive_command(cmd):
|
|
171
|
+
safety.append_audit_log(agent._CURRENT_SESSION_ID, classification, cmd, "blocked")
|
|
172
|
+
return "Blocked by user."
|
|
173
|
+
if classification == "sensitive" and config.get("autopilot_confirm_sensitive", True):
|
|
174
|
+
if not ui.confirm_sensitive_command(cmd):
|
|
175
|
+
safety.append_audit_log(agent._CURRENT_SESSION_ID, classification, cmd, "blocked")
|
|
176
|
+
return ("Blocked: this command accesses sensitive data (credentials, keys, "
|
|
177
|
+
"or security files) and was not confirmed. Explain to the user what "
|
|
178
|
+
"you wanted and why, instead of retrying.")
|
|
179
|
+
ui.command_echo(cmd)
|
|
180
|
+
result = sh.run(cmd, timeout_s=int(config.get("timeout_seconds", 300)))
|
|
181
|
+
safety.append_audit_log(agent._CURRENT_SESSION_ID, classification, cmd, result.get("exit_code"))
|
|
182
|
+
code = result.get("exit_code")
|
|
183
|
+
prefix = f"Exit code: {code}\n" if code is not None else ""
|
|
184
|
+
return prefix + (result.get("output") or "").strip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dispatch(agent: Any, config: dict[str, Any], sh: ShellSession, parsed: Any,
|
|
188
|
+
shell_exe: str, output_limit: int) -> str:
|
|
189
|
+
tool, args, payload = parsed.tool, parsed.args, parsed.payload
|
|
190
|
+
try:
|
|
191
|
+
if tool == "shell":
|
|
192
|
+
return _tool_shell(agent, config, sh, args)
|
|
193
|
+
if tool == "read":
|
|
194
|
+
return _tool_read(agent, args, output_limit)
|
|
195
|
+
if tool == "write":
|
|
196
|
+
return _tool_write(agent, args, payload)
|
|
197
|
+
if tool == "edit":
|
|
198
|
+
return _tool_edit(agent, args, payload)
|
|
199
|
+
if tool == "grep":
|
|
200
|
+
action = {"action": "tool", "tool": "search_files",
|
|
201
|
+
"args": {"pattern": args.get("pattern", ""), "path": args.get("path", ".")}}
|
|
202
|
+
return agent.execute_tool_call(config, action, shell_exe)
|
|
203
|
+
if tool in ("recall", "fetch_url"):
|
|
204
|
+
v1_name = "search_memory" if tool == "recall" else tool
|
|
205
|
+
action = {"action": "tool", "tool": v1_name, "args": dict(args)}
|
|
206
|
+
return agent.execute_tool_call(config, action, shell_exe)
|
|
207
|
+
except agent.UserCancelled:
|
|
208
|
+
raise
|
|
209
|
+
except Exception as exc: # noqa: BLE001 — tool failures feed back to the model
|
|
210
|
+
return f"Error: {exc}"
|
|
211
|
+
return f"Error: unknown tool {tool!r}."
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _call_signature(parsed: Any) -> str:
|
|
215
|
+
payload_repr = ""
|
|
216
|
+
if parsed.payload is not None:
|
|
217
|
+
payload_repr = hashlib.sha1(repr(parsed.payload).encode()).hexdigest()[:12]
|
|
218
|
+
return f"{parsed.tool}|{json.dumps(parsed.args, sort_keys=True)}|{payload_repr}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# The loop
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def run(
|
|
226
|
+
config: dict[str, Any],
|
|
227
|
+
history: list[dict[str, str]],
|
|
228
|
+
query: str,
|
|
229
|
+
shell_exe: str,
|
|
230
|
+
session: dict[str, Any] | None = None,
|
|
231
|
+
turn: telemetry.TurnRecorder | None = None,
|
|
232
|
+
probe: Any = None,
|
|
233
|
+
) -> str:
|
|
234
|
+
import hexcli.agent as agent # late import; agent imports us lazily too
|
|
235
|
+
|
|
236
|
+
agent.set_active_config(config)
|
|
237
|
+
cwd = str(Path.cwd())
|
|
238
|
+
max_steps = int(config.get("max_agent_steps", 15))
|
|
239
|
+
output_limit = int(config.get("tool_output_limit", 12000))
|
|
240
|
+
|
|
241
|
+
system_prompt = (
|
|
242
|
+
SYSTEM_PROMPT_V2
|
|
243
|
+
+ "\n\n"
|
|
244
|
+
+ build_session_context(cwd, datetime.now().strftime("%Y-%m-%d"))
|
|
245
|
+
)
|
|
246
|
+
messages: list[dict[str, str]] = [
|
|
247
|
+
{"role": "system", "content": system_prompt},
|
|
248
|
+
*history,
|
|
249
|
+
{"role": "user", "content": query.strip()},
|
|
250
|
+
]
|
|
251
|
+
agent._probe(probe, "on_start", system_prompt, [dict(m) for m in messages])
|
|
252
|
+
|
|
253
|
+
sh, ephemeral_shell = _get_shell(session, cwd, shell_exe)
|
|
254
|
+
tools_used: list[str] = []
|
|
255
|
+
touched_paths: list[str] = []
|
|
256
|
+
turn_snapshots: dict[str, str | None] = {}
|
|
257
|
+
recent_sigs: list[tuple[str, bool]] = [] # (signature, was_error)
|
|
258
|
+
last_tool_output = ""
|
|
259
|
+
# Verification-gated finish (docs/V2_PLAN.md §5.3): after a successful file
|
|
260
|
+
# mutation, the model must observe SOMETHING (run/read/check) before its
|
|
261
|
+
# final answer is accepted. One nudge max — the gate guides, never traps.
|
|
262
|
+
unverified_mutation = False
|
|
263
|
+
verify_nudge_used = False
|
|
264
|
+
|
|
265
|
+
def _finish(kind: str, message: str, outcome: str) -> str:
|
|
266
|
+
memory.maybe_index_turn(config, query, tools_used, touched_paths, outcome=outcome)
|
|
267
|
+
if session:
|
|
268
|
+
agent._record_undo_snapshots(session, turn_snapshots)
|
|
269
|
+
if ephemeral_shell:
|
|
270
|
+
sh.close()
|
|
271
|
+
agent._probe(probe, "on_end", kind, message)
|
|
272
|
+
return message
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
for step in range(max_steps):
|
|
276
|
+
step_label = "thinking" if step == 0 else f"step {step + 1}/{max_steps}"
|
|
277
|
+
if sys.stderr.isatty():
|
|
278
|
+
agent.cprint(f"\n {step_label}...", agent.C.DIM, file=sys.stderr)
|
|
279
|
+
|
|
280
|
+
parsed = None
|
|
281
|
+
raw = ""
|
|
282
|
+
for attempt in range(_MAX_FORMAT_RETRIES + 1):
|
|
283
|
+
llm_start = time.monotonic()
|
|
284
|
+
raw, eval_count = agent.call_llm(
|
|
285
|
+
config, messages, "autopilot_max_output_tokens",
|
|
286
|
+
label=step_label, json_format=False,
|
|
287
|
+
)
|
|
288
|
+
llm_latency = time.monotonic() - llm_start
|
|
289
|
+
if turn:
|
|
290
|
+
turn.record_llm(llm_latency, eval_count)
|
|
291
|
+
agent._probe(probe, "on_llm", step, attempt, raw, llm_latency)
|
|
292
|
+
parsed = parse_response(raw)
|
|
293
|
+
if parsed.kind != "malformed":
|
|
294
|
+
break
|
|
295
|
+
if attempt < _MAX_FORMAT_RETRIES:
|
|
296
|
+
messages.append({"role": "assistant", "content": agent.strip_thinking(raw)})
|
|
297
|
+
messages.append({"role": "user", "content": f"Format error: {parsed.error}"})
|
|
298
|
+
assert parsed is not None
|
|
299
|
+
|
|
300
|
+
if parsed.kind == "malformed":
|
|
301
|
+
# Retries exhausted — report truthfully instead of pretending.
|
|
302
|
+
return _finish(
|
|
303
|
+
"malformed",
|
|
304
|
+
f"I could not produce a valid action ({parsed.error}). "
|
|
305
|
+
f"Last tool output, if any, follows:\n{last_tool_output}".strip(),
|
|
306
|
+
"malformed",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if parsed.kind == "final":
|
|
310
|
+
if (unverified_mutation and not verify_nudge_used
|
|
311
|
+
and config.get("require_verification", True)):
|
|
312
|
+
verify_nudge_used = True
|
|
313
|
+
messages.append({"role": "assistant", "content": agent.strip_thinking(raw)})
|
|
314
|
+
changed = touched_paths[-1] if touched_paths else "the file"
|
|
315
|
+
messages.append({
|
|
316
|
+
"role": "user",
|
|
317
|
+
"content": (
|
|
318
|
+
f"You modified {changed} but never verified the result. "
|
|
319
|
+
f"Use the read tool on {changed} (or run it via shell if "
|
|
320
|
+
"it is code) to confirm the change, then report what you "
|
|
321
|
+
"actually observed."
|
|
322
|
+
),
|
|
323
|
+
})
|
|
324
|
+
continue
|
|
325
|
+
return _finish("finish", parsed.final_text, "completed")
|
|
326
|
+
|
|
327
|
+
# Tool action.
|
|
328
|
+
tool = parsed.tool
|
|
329
|
+
tools_used.append(tool)
|
|
330
|
+
tool_path = parsed.args.get("path") if isinstance(parsed.args, dict) else None
|
|
331
|
+
if tool_path:
|
|
332
|
+
touched_paths.append(str(tool_path))
|
|
333
|
+
if tool in ("write", "edit") and tool_path:
|
|
334
|
+
try:
|
|
335
|
+
snap_key = str(agent.resolve_path(str(tool_path)))
|
|
336
|
+
if snap_key not in turn_snapshots:
|
|
337
|
+
p = Path(snap_key)
|
|
338
|
+
turn_snapshots[snap_key] = (
|
|
339
|
+
p.read_text(encoding="utf-8") if p.exists() else None
|
|
340
|
+
)
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
ui.tool_header(tool)
|
|
345
|
+
tool_start = time.monotonic()
|
|
346
|
+
tool_output = _dispatch(agent, config, sh, parsed, shell_exe, output_limit)
|
|
347
|
+
tool_latency = time.monotonic() - tool_start
|
|
348
|
+
tool_status = "error" if tool_output.startswith("Error:") else "ok"
|
|
349
|
+
if turn:
|
|
350
|
+
turn.record_tool(tool, parsed.args, tool_latency, tool_status)
|
|
351
|
+
agent._probe(probe, "on_tool", step, tool, dict(parsed.args or {}),
|
|
352
|
+
tool_output, tool_latency, tool_status)
|
|
353
|
+
last_tool_output = tool_output
|
|
354
|
+
|
|
355
|
+
if tool in ("write", "edit") and tool_status == "ok":
|
|
356
|
+
unverified_mutation = True
|
|
357
|
+
elif tool in ("shell", "read") and tool_status == "ok":
|
|
358
|
+
# Any successful observation after the mutation counts as
|
|
359
|
+
# verification — the model saw real post-change state.
|
|
360
|
+
unverified_mutation = False
|
|
361
|
+
|
|
362
|
+
# Fuzzy loop detection: N consecutive near-identical calls, at
|
|
363
|
+
# least one of which errored, means the agent is spinning.
|
|
364
|
+
sig = _call_signature(parsed)
|
|
365
|
+
recent_sigs.append((sig, tool_status == "error"))
|
|
366
|
+
if len(recent_sigs) > _LOOP_WINDOW:
|
|
367
|
+
recent_sigs.pop(0)
|
|
368
|
+
if (len(recent_sigs) == _LOOP_WINDOW
|
|
369
|
+
and len({s for s, _ in recent_sigs}) == 1
|
|
370
|
+
and any(err for _, err in recent_sigs)):
|
|
371
|
+
agent.cprint(
|
|
372
|
+
f"\n ⚠ Agent repeated the same failing call {_LOOP_WINDOW}x. Stopping.",
|
|
373
|
+
agent.C.BYELLOW,
|
|
374
|
+
)
|
|
375
|
+
return _finish(
|
|
376
|
+
"loop_stop",
|
|
377
|
+
f"I kept repeating the same failing action and stopped. Last error:\n{tool_output}",
|
|
378
|
+
"error_loop",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
messages.append({"role": "assistant", "content": agent.strip_thinking(raw)})
|
|
382
|
+
messages.append({
|
|
383
|
+
"role": "user",
|
|
384
|
+
"content": render_tool_result(tool, trim_middle(tool_output, output_limit)),
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
return _finish("step_limit", last_tool_output or "Hit the step limit without finishing.", "step_limit")
|
|
388
|
+
except (agent.UserCancelled, KeyboardInterrupt):
|
|
389
|
+
if session:
|
|
390
|
+
agent._record_undo_snapshots(session, turn_snapshots)
|
|
391
|
+
if ephemeral_shell:
|
|
392
|
+
sh.close()
|
|
393
|
+
raise
|