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.
@@ -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