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/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
+