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
hexcli/llm.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.llm — model transport: streaming, non-streaming, the mock backend,
|
|
3
|
+
and the token estimator. Lifted out of agent.py.
|
|
4
|
+
|
|
5
|
+
call_llm is the single entry point the loop uses; agent.py re-binds it (and
|
|
6
|
+
everything else here), so patching sa.call_llm still intercepts every model
|
|
7
|
+
call in the loop, in loop_v2, and in compaction.
|
|
8
|
+
|
|
9
|
+
Two things deliberately resolve through the agent hub at call time rather
|
|
10
|
+
than locally:
|
|
11
|
+
* _CURRENT_SESSION_ID — run_autopilot owns it, and npurun uses it to detect
|
|
12
|
+
continuation turns and skip a dialog reset. Reading a module-local copy
|
|
13
|
+
here would silently break that (and the tests that patch it).
|
|
14
|
+
* the cancellation primitives, via agent's re-bound names, so the eval
|
|
15
|
+
runner's silencers apply.
|
|
16
|
+
|
|
17
|
+
_MOCK_RESPONSE_QUEUE is mutated in place (never rebound), so agent's alias
|
|
18
|
+
stays live for the suites that assert on queue depth.
|
|
19
|
+
|
|
20
|
+
Split stage 7 (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim
|
|
21
|
+
apart from the two hub lookups.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import queue
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from hexcli import memory, ui
|
|
35
|
+
from hexcli.http_client import http_json_request
|
|
36
|
+
from hexcli.ui import C
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _agent():
|
|
40
|
+
from hexcli import agent
|
|
41
|
+
return agent
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# The server holds one inference slot and answers 429 + Retry-After while it
|
|
45
|
+
# is busy — including the end-of-turn prewarm, which rebuilds a long KV cache
|
|
46
|
+
# for ~20 s. The streaming paths open their own connection (see
|
|
47
|
+
# _ollama_stream_chat), so they must wait that out here the way
|
|
48
|
+
# http_client._http_request does for the keep-alive pool; before this a query
|
|
49
|
+
# typed during the prewarm failed outright with "HTTP Error 429".
|
|
50
|
+
_BUSY_WAIT_MAX_S = 25.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _urlopen_wait_busy(req: Any, timeout_s: int) -> Any:
|
|
54
|
+
import urllib.request
|
|
55
|
+
deadline = time.monotonic() + _BUSY_WAIT_MAX_S
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
return urllib.request.urlopen(req, timeout=timeout_s)
|
|
59
|
+
except urllib.error.HTTPError as exc:
|
|
60
|
+
if exc.code != 429 or time.monotonic() >= deadline:
|
|
61
|
+
raise
|
|
62
|
+
try:
|
|
63
|
+
delay = float(exc.headers.get("Retry-After") or 1.0)
|
|
64
|
+
except ValueError:
|
|
65
|
+
delay = 1.0
|
|
66
|
+
time.sleep(min(max(delay, 0.2), 3.0))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str) -> Any:
|
|
70
|
+
"""Fall back to the agent module for the handful of names this layer
|
|
71
|
+
borrows (Spinner, CancelMonitor, _agent().UserCancelled, DEBUG, ...), so the eval
|
|
72
|
+
runner's patches on hexcli.agent are honoured at call time."""
|
|
73
|
+
if name.startswith("__"):
|
|
74
|
+
raise AttributeError(name)
|
|
75
|
+
return getattr(_agent(), name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_MOCK_RESPONSE_QUEUE: list[str] = []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_mock_responses(responses: list[str]) -> None:
|
|
82
|
+
"""Load scripted LLM responses. Each call to call_llm pops the next entry.
|
|
83
|
+
|
|
84
|
+
Fixture entries are raw strings — identical to what a real LLM would return
|
|
85
|
+
(JSON action objects, finish messages, plain text, etc.).
|
|
86
|
+
"""
|
|
87
|
+
_MOCK_RESPONSE_QUEUE[:] = responses
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pop_mock_response() -> tuple[str, int]:
|
|
91
|
+
"""Return (response_text, eval_count); falls back to a finish action."""
|
|
92
|
+
if _MOCK_RESPONSE_QUEUE:
|
|
93
|
+
return (_MOCK_RESPONSE_QUEUE.pop(0), 0)
|
|
94
|
+
return ('{"action":"finish","message":"Mock queue exhausted."}', 0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _TokenEstimator:
|
|
98
|
+
"""Data-driven replacement for the blanket chars/4 token estimate.
|
|
99
|
+
|
|
100
|
+
Every live completion returns an exact token count (the fork emits one
|
|
101
|
+
Genie chunk per generated token), and the text length is known locally —
|
|
102
|
+
so the real chars-per-token ratio of THIS model on THIS workload is
|
|
103
|
+
observable for free. The estimate feeds the context budget, where assuming
|
|
104
|
+
4 chars/token while code-heavy turns actually run ~3.3 means firing
|
|
105
|
+
compaction PAST the ~2,600-token degradation cliff — the v1.7 calibration
|
|
106
|
+
bug one layer down.
|
|
107
|
+
|
|
108
|
+
EMA over completions, clamped so one garbage usage report cannot poison
|
|
109
|
+
the budget. Starts at 4.0, which is byte-for-byte the old behaviour until
|
|
110
|
+
real observations arrive. A lower ratio means a HIGHER token estimate and
|
|
111
|
+
therefore earlier compaction — the safe direction.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self.ratio = 4.0
|
|
116
|
+
self.observations = 0
|
|
117
|
+
|
|
118
|
+
def observe(self, chars: int, tokens: int) -> None:
|
|
119
|
+
if tokens < 20 or chars < 40:
|
|
120
|
+
return # too small to carry signal
|
|
121
|
+
sample = chars / tokens
|
|
122
|
+
if not 1.5 <= sample <= 8.0:
|
|
123
|
+
return # implausible; likely a broken usage report
|
|
124
|
+
self.ratio = min(4.5, max(2.5, 0.9 * self.ratio + 0.1 * sample))
|
|
125
|
+
self.observations += 1
|
|
126
|
+
|
|
127
|
+
def estimate(self, text_len: int) -> int:
|
|
128
|
+
return int(text_len / self.ratio)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_TOKEN_ESTIMATOR = _TokenEstimator()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _clear_progress_row() -> None:
|
|
135
|
+
"""Wipe the "label... N tokens" row. Only on a terminal: a pipe or a
|
|
136
|
+
log never got the row, and the erase would land in the capture."""
|
|
137
|
+
try:
|
|
138
|
+
if not sys.stderr.isatty():
|
|
139
|
+
return
|
|
140
|
+
except Exception: # noqa: BLE001
|
|
141
|
+
return
|
|
142
|
+
sys.stderr.write("\r\033[K")
|
|
143
|
+
sys.stderr.flush()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def estimate_tokens(text: str) -> int:
|
|
147
|
+
"""Estimated token count of `text` for budget decisions."""
|
|
148
|
+
return _TOKEN_ESTIMATOR.estimate(len(text))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ollama_stream_chat(
|
|
152
|
+
config: dict[str, Any],
|
|
153
|
+
messages: list[dict[str, str]],
|
|
154
|
+
token_key: str,
|
|
155
|
+
label: str = "thinking",
|
|
156
|
+
json_format: bool = False,
|
|
157
|
+
) -> tuple[str, int]:
|
|
158
|
+
"""Stream from Ollama /api/chat. Returns (content, eval_count)."""
|
|
159
|
+
host = config["ollama"]["host"].rstrip("/")
|
|
160
|
+
url = f"{host}/api/chat"
|
|
161
|
+
payload: dict[str, Any] = {
|
|
162
|
+
"model": config["model"],
|
|
163
|
+
"messages": messages,
|
|
164
|
+
"stream": True,
|
|
165
|
+
"options": {
|
|
166
|
+
"temperature": config["temperature"],
|
|
167
|
+
"num_predict": int(config.get(token_key, 2048)),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
if json_format:
|
|
171
|
+
payload["format"] = "json"
|
|
172
|
+
body = json.dumps(payload).encode("utf-8")
|
|
173
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
174
|
+
req.add_header("Content-Type", "application/json")
|
|
175
|
+
|
|
176
|
+
line_q: queue.Queue[bytes | None] = queue.Queue()
|
|
177
|
+
err_box: dict[str, BaseException] = {}
|
|
178
|
+
|
|
179
|
+
def read_lines(resp: Any) -> None:
|
|
180
|
+
try:
|
|
181
|
+
for raw in resp:
|
|
182
|
+
line_q.put(raw)
|
|
183
|
+
except BaseException as exc: # noqa: BLE001
|
|
184
|
+
err_box["value"] = exc
|
|
185
|
+
finally:
|
|
186
|
+
line_q.put(None)
|
|
187
|
+
|
|
188
|
+
parts: list[str] = []
|
|
189
|
+
eval_count = 0
|
|
190
|
+
tok = 0
|
|
191
|
+
|
|
192
|
+
# A dedicated connection per call, not the shared keep-alive pool used by
|
|
193
|
+
# the non-streaming helpers below: the response body here is read by a
|
|
194
|
+
# background thread and can be abandoned mid-stream (cancel, or the
|
|
195
|
+
# "done" line arriving before the socket reaches EOF), which would leave
|
|
196
|
+
# a shared connection in an indeterminate state for the next reuse.
|
|
197
|
+
try:
|
|
198
|
+
with _urlopen_wait_busy(req, int(config["timeout_seconds"])) as resp:
|
|
199
|
+
reader = threading.Thread(target=read_lines, args=(resp,), daemon=True)
|
|
200
|
+
with _agent().CancelMonitor() as monitor:
|
|
201
|
+
reader.start()
|
|
202
|
+
while True:
|
|
203
|
+
if monitor.cancelled.is_set():
|
|
204
|
+
raise _agent().UserCancelled()
|
|
205
|
+
try:
|
|
206
|
+
raw = line_q.get(timeout=0.05)
|
|
207
|
+
except queue.Empty:
|
|
208
|
+
continue
|
|
209
|
+
if raw is None:
|
|
210
|
+
break
|
|
211
|
+
line = raw.strip()
|
|
212
|
+
if not line:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
data = json.loads(line)
|
|
216
|
+
except json.JSONDecodeError:
|
|
217
|
+
continue
|
|
218
|
+
chunk = (data.get("message") or {}).get("content", "")
|
|
219
|
+
if chunk:
|
|
220
|
+
parts.append(chunk)
|
|
221
|
+
tok += 1
|
|
222
|
+
if ui._live_area() is None and sys.stderr.isatty(): # the bar shows progress; a pipe gets none
|
|
223
|
+
sys.stderr.write(
|
|
224
|
+
f"\r{C.DIM} {label}... {tok} tokens{C.RESET}"
|
|
225
|
+
)
|
|
226
|
+
sys.stderr.flush()
|
|
227
|
+
if data.get("done"):
|
|
228
|
+
eval_count = data.get("eval_count", tok)
|
|
229
|
+
|
|
230
|
+
if "value" in err_box:
|
|
231
|
+
exc = err_box["value"]
|
|
232
|
+
if isinstance(exc, (ConnectionResetError, ConnectionAbortedError)):
|
|
233
|
+
sys.stderr.write(f"\r{C.YELLOW} Stream dropped; retrying.{C.RESET} \n")
|
|
234
|
+
sys.stderr.flush()
|
|
235
|
+
return ollama_chat_non_stream(config, messages, token_key, json_format=json_format), 0
|
|
236
|
+
raise exc
|
|
237
|
+
|
|
238
|
+
return "".join(parts), eval_count
|
|
239
|
+
finally:
|
|
240
|
+
_clear_progress_row()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def ollama_chat_non_stream(
|
|
244
|
+
config: dict[str, Any],
|
|
245
|
+
messages: list[dict[str, str]],
|
|
246
|
+
token_key: str,
|
|
247
|
+
json_format: bool = False,
|
|
248
|
+
) -> str:
|
|
249
|
+
host = config["ollama"]["host"].rstrip("/")
|
|
250
|
+
payload: dict[str, Any] = {
|
|
251
|
+
"model": config["model"],
|
|
252
|
+
"messages": messages,
|
|
253
|
+
"stream": False,
|
|
254
|
+
"options": {
|
|
255
|
+
"temperature": config["temperature"],
|
|
256
|
+
"num_predict": int(config.get(token_key, 2048)),
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
if json_format:
|
|
260
|
+
payload["format"] = "json"
|
|
261
|
+
resp = http_json_request(f"{host}/api/chat", payload, {}, int(config["timeout_seconds"]))
|
|
262
|
+
return str((resp.get("message") or {}).get("content", "")).strip()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def openai_chat(
|
|
266
|
+
config: dict[str, Any],
|
|
267
|
+
messages: list[dict[str, str]],
|
|
268
|
+
token_key: str,
|
|
269
|
+
json_format: bool = False,
|
|
270
|
+
) -> str:
|
|
271
|
+
base_url = config["openai_compatible"]["base_url"].rstrip("/")
|
|
272
|
+
api_key = config["openai_compatible"].get("api_key", "local")
|
|
273
|
+
payload: dict[str, Any] = {
|
|
274
|
+
"model": config["model"],
|
|
275
|
+
"temperature": config["temperature"],
|
|
276
|
+
"max_tokens": int(config.get(token_key, 2048)),
|
|
277
|
+
"messages": messages,
|
|
278
|
+
"stop": ["<|im_end|>", "<|im_start|>"],
|
|
279
|
+
}
|
|
280
|
+
if json_format:
|
|
281
|
+
payload["response_format"] = {"type": "json_object"}
|
|
282
|
+
if _agent()._CURRENT_SESSION_ID is not None:
|
|
283
|
+
payload["session_id"] = _agent()._CURRENT_SESSION_ID
|
|
284
|
+
resp = http_json_request(
|
|
285
|
+
f"{base_url}/chat/completions", payload,
|
|
286
|
+
{"Authorization": f"Bearer {api_key}"}, int(config["timeout_seconds"])
|
|
287
|
+
)
|
|
288
|
+
choices = resp.get("choices") or []
|
|
289
|
+
if not choices:
|
|
290
|
+
# A 200 with no choices is the server's request watchdog ending a
|
|
291
|
+
# stalled request (fork 0.2.3). Hand back an empty reply: the loop
|
|
292
|
+
# retries one of those, which is what it does when the streamed
|
|
293
|
+
# path produces nothing, instead of ending the turn with an error.
|
|
294
|
+
return ""
|
|
295
|
+
return str((choices[0].get("message") or {}).get("content", "")).strip()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _openai_stream_chat(
|
|
299
|
+
config: dict[str, Any],
|
|
300
|
+
messages: list[dict[str, str]],
|
|
301
|
+
token_key: str,
|
|
302
|
+
label: str = "thinking",
|
|
303
|
+
json_format: bool = False,
|
|
304
|
+
) -> tuple[str, int]:
|
|
305
|
+
"""Stream from an OpenAI-compatible SSE endpoint. Returns (content, token_count).
|
|
306
|
+
|
|
307
|
+
SSE format (per chunk): data: {"choices":[{"delta":{"content":"..."},...}]}
|
|
308
|
+
Terminator: data: [DONE]
|
|
309
|
+
"""
|
|
310
|
+
base_url = config["openai_compatible"]["base_url"].rstrip("/")
|
|
311
|
+
api_key = config["openai_compatible"].get("api_key", "local")
|
|
312
|
+
url = f"{base_url}/chat/completions"
|
|
313
|
+
|
|
314
|
+
payload: dict[str, Any] = {
|
|
315
|
+
"model": config["model"],
|
|
316
|
+
"temperature": config["temperature"],
|
|
317
|
+
"max_tokens": int(config.get(token_key, 2048)),
|
|
318
|
+
"messages": messages,
|
|
319
|
+
"stream": True,
|
|
320
|
+
"stop": ["<|im_end|>", "<|im_start|>"],
|
|
321
|
+
}
|
|
322
|
+
if json_format:
|
|
323
|
+
payload["response_format"] = {"type": "json_object"}
|
|
324
|
+
if _agent()._CURRENT_SESSION_ID is not None:
|
|
325
|
+
payload["session_id"] = _agent()._CURRENT_SESSION_ID
|
|
326
|
+
body = json.dumps(payload).encode("utf-8")
|
|
327
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
328
|
+
req.add_header("Content-Type", "application/json")
|
|
329
|
+
req.add_header("Authorization", f"Bearer {api_key}")
|
|
330
|
+
|
|
331
|
+
line_q: queue.Queue[bytes | None] = queue.Queue()
|
|
332
|
+
err_box: dict[str, BaseException] = {}
|
|
333
|
+
|
|
334
|
+
def read_lines(resp: Any) -> None:
|
|
335
|
+
try:
|
|
336
|
+
for raw in resp:
|
|
337
|
+
line_q.put(raw)
|
|
338
|
+
except BaseException as exc: # noqa: BLE001
|
|
339
|
+
err_box["value"] = exc
|
|
340
|
+
finally:
|
|
341
|
+
line_q.put(None)
|
|
342
|
+
|
|
343
|
+
parts: list[str] = []
|
|
344
|
+
tok = 0
|
|
345
|
+
renderer = _make_live_renderer(config, label)
|
|
346
|
+
|
|
347
|
+
# Dedicated per-call connection — see _ollama_stream_chat for why the
|
|
348
|
+
# shared keep-alive pool isn't used here.
|
|
349
|
+
try:
|
|
350
|
+
with _urlopen_wait_busy(req, int(config["timeout_seconds"])) as resp:
|
|
351
|
+
reader = threading.Thread(target=read_lines, args=(resp,), daemon=True)
|
|
352
|
+
with _agent().CancelMonitor() as monitor:
|
|
353
|
+
reader.start()
|
|
354
|
+
while True:
|
|
355
|
+
if monitor.cancelled.is_set():
|
|
356
|
+
raise _agent().UserCancelled()
|
|
357
|
+
try:
|
|
358
|
+
raw = line_q.get(timeout=0.05)
|
|
359
|
+
except queue.Empty:
|
|
360
|
+
continue
|
|
361
|
+
if raw is None:
|
|
362
|
+
break
|
|
363
|
+
line = raw.strip()
|
|
364
|
+
if not line:
|
|
365
|
+
continue
|
|
366
|
+
# SSE lines start with "data: "
|
|
367
|
+
text = line.decode("utf-8", errors="replace")
|
|
368
|
+
if text == "data: [DONE]":
|
|
369
|
+
break
|
|
370
|
+
if not text.startswith("data: "):
|
|
371
|
+
continue
|
|
372
|
+
try:
|
|
373
|
+
data = json.loads(text[6:])
|
|
374
|
+
except json.JSONDecodeError:
|
|
375
|
+
continue
|
|
376
|
+
choices = data.get("choices") or []
|
|
377
|
+
if not choices:
|
|
378
|
+
continue
|
|
379
|
+
delta = (choices[0].get("delta") or {}).get("content", "")
|
|
380
|
+
if delta:
|
|
381
|
+
parts.append(delta)
|
|
382
|
+
tok += 1
|
|
383
|
+
if renderer is not None:
|
|
384
|
+
renderer.feed(delta)
|
|
385
|
+
elif ui._live_area() is None and sys.stderr.isatty():
|
|
386
|
+
sys.stderr.write(
|
|
387
|
+
f"\r{C.DIM} {label}... {tok} tokens{C.RESET}"
|
|
388
|
+
)
|
|
389
|
+
sys.stderr.flush()
|
|
390
|
+
|
|
391
|
+
if "value" in err_box:
|
|
392
|
+
exc = err_box["value"]
|
|
393
|
+
if isinstance(exc, (ConnectionResetError, ConnectionAbortedError)):
|
|
394
|
+
sys.stderr.write(f"\r{C.YELLOW} Stream dropped; retrying.{C.RESET} \n")
|
|
395
|
+
sys.stderr.flush()
|
|
396
|
+
return openai_chat(config, messages, token_key, json_format=json_format), 0
|
|
397
|
+
raise exc
|
|
398
|
+
|
|
399
|
+
if renderer is not None:
|
|
400
|
+
renderer.finish()
|
|
401
|
+
return "".join(parts), tok
|
|
402
|
+
finally:
|
|
403
|
+
if renderer is not None:
|
|
404
|
+
_end_live_render(renderer)
|
|
405
|
+
else:
|
|
406
|
+
_clear_progress_row()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _status_activity(label: str) -> Any:
|
|
410
|
+
"""The streaming paths never had a spinner: their progress was the
|
|
411
|
+
text itself, and a spinner would fight it for the same row. With the
|
|
412
|
+
status bar up the spinner lives in the status line instead, so it can
|
|
413
|
+
run alongside the stream and show the step while nothing has arrived
|
|
414
|
+
yet. Without the bar this is a no-op, exactly as before."""
|
|
415
|
+
if ui._live_area() is None:
|
|
416
|
+
return contextlib.nullcontext()
|
|
417
|
+
return _agent().Spinner(label)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _make_live_renderer(config: dict[str, Any], label: str) -> Any:
|
|
421
|
+
"""Renderer that prints the answer as it arrives, or None when live
|
|
422
|
+
rendering is off / inappropriate (evals, delegate sub-loops, compaction).
|
|
423
|
+
|
|
424
|
+
v1.7 showed only a token counter, so a 20-90s answer looked like a hang
|
|
425
|
+
(review finding W6). The renderer streams the finish message's TEXT and
|
|
426
|
+
announces tool intent early, without ever showing raw JSON.
|
|
427
|
+
"""
|
|
428
|
+
if not config.get("live_streaming", True):
|
|
429
|
+
return None
|
|
430
|
+
if _agent()._in_delegate or label in ("compacting", "summarising"):
|
|
431
|
+
return None
|
|
432
|
+
if not sys.stderr.isatty():
|
|
433
|
+
return None # eval/CI capture: keep logs clean
|
|
434
|
+
|
|
435
|
+
from .markdown_stream import MarkdownStream
|
|
436
|
+
from .stream_render import StreamRenderer
|
|
437
|
+
|
|
438
|
+
state = {"started": False}
|
|
439
|
+
markdown = MarkdownStream() # headings, bullets, bold, code spans and fences, live
|
|
440
|
+
|
|
441
|
+
def emit(text: str) -> None:
|
|
442
|
+
if not state["started"]:
|
|
443
|
+
_clear_progress_row()
|
|
444
|
+
state["started"] = True
|
|
445
|
+
# The answer is now arriving, not being thought about: relabel the
|
|
446
|
+
# status line (the spinner started by _status_activity keeps ticking).
|
|
447
|
+
live = ui.LIVE_AREA
|
|
448
|
+
if live is not None and live.enabled:
|
|
449
|
+
live.set_activity("responding")
|
|
450
|
+
# One blank line between whatever came before (the question, a
|
|
451
|
+
# tool card) and the answer. A pipe gets the answer alone.
|
|
452
|
+
if sys.stdout.isatty():
|
|
453
|
+
sys.stdout.write("\n")
|
|
454
|
+
sys.stdout.write(markdown.feed(text))
|
|
455
|
+
sys.stdout.flush()
|
|
456
|
+
|
|
457
|
+
def on_tool(name: str) -> None:
|
|
458
|
+
live = ui.LIVE_AREA
|
|
459
|
+
if live is not None and live.enabled:
|
|
460
|
+
live.set_activity(f"▸ {name}") # in the status line, not on the transcript
|
|
461
|
+
return
|
|
462
|
+
sys.stderr.write(f"\r{C.DIM} ▸ {name}{C.RESET}" + " " * 20)
|
|
463
|
+
sys.stderr.flush()
|
|
464
|
+
|
|
465
|
+
r = StreamRenderer(emit, on_tool)
|
|
466
|
+
r._live_started = state # type: ignore[attr-defined]
|
|
467
|
+
r._markdown = markdown # type: ignore[attr-defined]
|
|
468
|
+
return r
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _end_live_render(renderer: Any) -> None:
|
|
472
|
+
global _LAST_STREAMED_TEXT
|
|
473
|
+
started = getattr(renderer, "_live_started", {}).get("started", False)
|
|
474
|
+
_LAST_STREAMED_TEXT = getattr(renderer, "text_emitted", "") if started else ""
|
|
475
|
+
if started:
|
|
476
|
+
markdown = getattr(renderer, "_markdown", None)
|
|
477
|
+
tail = markdown.finish() if markdown is not None else ""
|
|
478
|
+
sys.stdout.write(tail + "\n")
|
|
479
|
+
sys.stdout.flush()
|
|
480
|
+
else:
|
|
481
|
+
_clear_progress_row()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# What the most recent model call streamed to the screen. Reset at the start
|
|
485
|
+
# of every call_llm, so after a turn it holds the FINAL call's text (or "" if
|
|
486
|
+
# that call did not stream: mock backend, non-tty, delegate, a dropped-stream
|
|
487
|
+
# retry). The REPL uses it to skip the "Result" box when the answer is
|
|
488
|
+
# already on screen word for word.
|
|
489
|
+
_LAST_STREAMED_TEXT = ""
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def last_streamed_matches(message: str) -> bool:
|
|
493
|
+
"""True when `message` is what the final model call already streamed,
|
|
494
|
+
ignoring whitespace differences (the parser trims the message; the
|
|
495
|
+
margin layer reflows it)."""
|
|
496
|
+
streamed = " ".join(_LAST_STREAMED_TEXT.split())
|
|
497
|
+
return bool(streamed) and streamed == " ".join(message.split())
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def ollama_generate_with_system(config: dict[str, Any], system: str, prompt: str) -> str:
|
|
501
|
+
host = config["ollama"]["host"].rstrip("/")
|
|
502
|
+
payload: dict[str, Any] = {
|
|
503
|
+
"model": config["model"],
|
|
504
|
+
"system": system,
|
|
505
|
+
"prompt": prompt.strip(),
|
|
506
|
+
"stream": False,
|
|
507
|
+
"options": {
|
|
508
|
+
"temperature": config["temperature"],
|
|
509
|
+
"num_predict": int(config.get("max_output_tokens", 512)),
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
resp = http_json_request(f"{host}/api/generate", payload, {}, int(config["timeout_seconds"]))
|
|
513
|
+
return str(resp.get("response", "")).strip()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def openai_generate_with_system(config: dict[str, Any], system: str, prompt: str) -> str:
|
|
517
|
+
return openai_chat(
|
|
518
|
+
config,
|
|
519
|
+
[{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
|
520
|
+
"max_output_tokens",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def llm_generate(config: dict[str, Any], system: str, prompt: str) -> str:
|
|
525
|
+
if config.get("backend") == "mock":
|
|
526
|
+
return _pop_mock_response()[0]
|
|
527
|
+
if config["backend"] == "ollama":
|
|
528
|
+
return ollama_generate_with_system(config, system, prompt)
|
|
529
|
+
if config["backend"] == "openai":
|
|
530
|
+
return openai_generate_with_system(config, system, prompt)
|
|
531
|
+
raise RuntimeError(f"Unsupported backend: {config['backend']}")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def call_llm(
|
|
535
|
+
config: dict[str, Any],
|
|
536
|
+
messages: list[dict[str, str]],
|
|
537
|
+
token_key: str,
|
|
538
|
+
*,
|
|
539
|
+
label: str = "thinking",
|
|
540
|
+
json_format: bool = False,
|
|
541
|
+
) -> tuple[str, int]:
|
|
542
|
+
"""Unified LLM call with correct cancellation.
|
|
543
|
+
|
|
544
|
+
Streaming path (_ollama_stream_chat) manages its own CancelMonitor; calling
|
|
545
|
+
it through run_cancellable would create two competing monitors on the same
|
|
546
|
+
console input buffer. Non-streaming path uses run_cancellable + Spinner.
|
|
547
|
+
Resets _LAST_STREAMED_TEXT so it only ever describes this call.
|
|
548
|
+
Acquires memory._NPU_INFERENCE_LOCK so the dreaming daemon defers while any
|
|
549
|
+
inference is in progress.
|
|
550
|
+
"""
|
|
551
|
+
global _LAST_STREAMED_TEXT
|
|
552
|
+
_LAST_STREAMED_TEXT = ""
|
|
553
|
+
with memory._NPU_INFERENCE_LOCK:
|
|
554
|
+
if config.get("backend") == "mock":
|
|
555
|
+
# Mock fixtures carry no real token counts — never feed the
|
|
556
|
+
# estimator from them.
|
|
557
|
+
return _pop_mock_response()
|
|
558
|
+
|
|
559
|
+
if config["backend"] == "ollama" and config.get("use_streaming", True):
|
|
560
|
+
with _status_activity(label):
|
|
561
|
+
text, count = _ollama_stream_chat(
|
|
562
|
+
config, messages, token_key, label=label, json_format=json_format)
|
|
563
|
+
_TOKEN_ESTIMATOR.observe(len(text), count)
|
|
564
|
+
return text, count
|
|
565
|
+
|
|
566
|
+
if config["backend"] == "openai" and config.get("use_streaming", True):
|
|
567
|
+
with _status_activity(label):
|
|
568
|
+
text, count = _openai_stream_chat(
|
|
569
|
+
config, messages, token_key, label=label, json_format=json_format)
|
|
570
|
+
_TOKEN_ESTIMATOR.observe(len(text), count)
|
|
571
|
+
return text, count
|
|
572
|
+
|
|
573
|
+
result_box: dict[str, Any] = {}
|
|
574
|
+
error_box: dict[str, BaseException] = {}
|
|
575
|
+
|
|
576
|
+
def _work() -> None:
|
|
577
|
+
try:
|
|
578
|
+
if config["backend"] == "ollama":
|
|
579
|
+
content = ollama_chat_non_stream(config, messages, token_key, json_format=json_format)
|
|
580
|
+
elif config["backend"] == "openai":
|
|
581
|
+
content = openai_chat(config, messages, token_key, json_format=json_format)
|
|
582
|
+
else:
|
|
583
|
+
raise RuntimeError(f"Unsupported backend: {config['backend']}")
|
|
584
|
+
result_box["value"] = (content, 0)
|
|
585
|
+
except BaseException as exc: # noqa: BLE001
|
|
586
|
+
error_box["value"] = exc
|
|
587
|
+
|
|
588
|
+
thread = threading.Thread(target=_work, daemon=True)
|
|
589
|
+
with _agent().CancelMonitor() as monitor, _agent().Spinner(label):
|
|
590
|
+
thread.start()
|
|
591
|
+
while thread.is_alive():
|
|
592
|
+
if monitor.cancelled.is_set():
|
|
593
|
+
raise _agent().UserCancelled()
|
|
594
|
+
thread.join(0.05)
|
|
595
|
+
|
|
596
|
+
if "value" in error_box:
|
|
597
|
+
raise error_box["value"]
|
|
598
|
+
return result_box.get("value", ("", 0))
|
|
599
|
+
|