Skip to content

Commit 1ddaa4f

Browse files
AVADSA25Mikarina13claude
authored
refactor(llm): codec_llm raise_on_error mode + migrate fail-loud sites (A-12 tranche 2c) (#70)
PR-3E-2c. Adds the raise-on-failure contract that tranche 2 deferred, then migrates the 4 sites that MUST fail loud. New: codec_llm.LLMError + codec_llm.call(raise_on_error=True). When True, call() raises LLMError on EVERY non-success path — non-200 (after retries), request exception (after retries), and a 200 with empty/unparseable content. Default stays False (never-raise -> ""), so the existing streaming/best-effort callers (codec.py, qwen_call, compaction, dictate) are untouched — pinned by a regression guard test. Migrated: - codec_textassist.call_qwen -> call(raise_on_error=True). Fixes a real bug: on LLM failure the never-raise path would pbcopy "" + Cmd-V, pasting EMPTY over the user's selection and showing "Text replaced!". Now the caller's except shows the Error overlay (also on empty-200). FINAL-ANSWER strip kept at the call site; <think> strip now handled by codec_llm. - scripts/regen_skill_descriptions._llm -> call(raise_on_error=True). Fail-loud preserved (LLMError propagates like the old raise_for_status; empty-200 now raises instead of writing an empty description). - codec_agent_plan._qwen_chat + codec_agent_runner._qwen_chat -> call( raise_on_error=True) behind a thin adapter that maps LLMError onto their PUBLIC QwenUnavailableError, so the daemon's `except QwenUnavailableError` retry/abort/resume logic is unchanged. Added a parallel _qwen_base() resolver (call-time config). These also gain <think> strip + enable_thinking=False -> more robust JSON parsing downstream. Tests: tests/test_llm_raise_mode.py (14 — raise-mode success/non-200/exception/ empty-200, default-still-never-raises regression guard, agent adapters map to QwenUnavailableError + pass content through, source invariants). 109 agent tests (test_agent_plan/runner/chat_plan_persistence) still green. Full suite 1423 passing, 23 known-baseline failures, zero new. Zero net-new ruff (per-file delta vs origin/main = 0). No skills/ touched -> no manifest regen. Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b7bb472 commit 1ddaa4f

10 files changed

Lines changed: 345 additions & 72 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ docs/ API.md, MCP_HTTP_SETUP.md, CONTEXT_REPORT.md, desig
5656

5757
Other engine modules (`codec_overlays`, `codec_metrics`, `codec_logging`, `codec_gdocs`, `codec_google_auth`, `codec_cdp`, `codec_llm_proxy`, `codec_retry`, `codec_alerts`, `codec_search`, `codec_textassist`, `codec_watcher`, `codec_watchdog`) are internal helpers — read them when you need them, but they're not part of the navigation surface for an agent making structural changes. (Keyboard handling — wake word, F13 toggle, F18 voice, double-tap — lives **inline in `codec.py`** in the `codec` PM2 process; the old standalone `codec_keyboard.py` was deleted as a dead duplicate per A-8.)
5858

59-
**Canonical LLM + vision helpers (PR-3E, A-11/A-12).** `codec_vision.py` is the SINGLE source for screen-vision (`describe_sync` / `describe_async`, Gemini-flash → local-Qwen-VL fallback, config read live from `codec_config`) — used by `codec.py`, `codec_voice`, `codec_session`. `codec_llm.py` is the canonical chat/completions caller (`call()` + `strip_think`/`extract_content` — headers, Bearer auth, `enable_thinking`, `<think>` strip, `choices/reasoning` parse, retry+backoff, never-raises). NOTE: `codec_llm_proxy.py` is a priority *queue* (semaphore), NOT an HTTP caller — don't confuse the two. A-12 is migrating the ~45 inline `chat/completions` sites onto `codec_llm` in phased tranches. Done: `codec_llm.call()` (non-stream) + `stream()` (sync SSE generator, yields raw deltas, never-raises); migrated sites = codec.py voice-reply, `codec_session.qwen_call` + `qwen_stream`, `codec_compaction`, `codec_dictate`. Pending tranches: 2c raise-mode (`codec_llm.call(raise_on_error=True)` for agent_plan/runner + textassist + the regen script — they MUST fail loud, never-raise would silently paste empty / write empty), an async `astream()` for voice `_stream_qwen` + agents (queue stays at the call site — `codec_llm` never owns the semaphore), dashboard (4 non-stream + the `[SKILL:…]` stream tag-machine, which keeps its own parser and consumes only `stream()`'s raw tokens), bridges, and a skills tranche.
59+
**Canonical LLM + vision helpers (PR-3E, A-11/A-12).** `codec_vision.py` is the SINGLE source for screen-vision (`describe_sync` / `describe_async`, Gemini-flash → local-Qwen-VL fallback, config read live from `codec_config`) — used by `codec.py`, `codec_voice`, `codec_session`. `codec_llm.py` is the canonical chat/completions caller (`call()` + `strip_think`/`extract_content` — headers, Bearer auth, `enable_thinking`, `<think>` strip, `choices/reasoning` parse, retry+backoff, never-raises). NOTE: `codec_llm_proxy.py` is a priority *queue* (semaphore), NOT an HTTP caller — don't confuse the two. A-12 is migrating the ~45 inline `chat/completions` sites onto `codec_llm` in phased tranches. Done: `codec_llm.call()` (non-stream; + `raise_on_error=True` raising `codec_llm.LLMError` for fail-loud callers) + `stream()` (sync SSE generator, yields raw deltas, never-raises); migrated sites = codec.py voice-reply, `codec_session.qwen_call` + `qwen_stream`, `codec_compaction`, `codec_dictate`, `codec_textassist`, the regen script, and `codec_agent_plan`/`codec_agent_runner` `_qwen_chat` (adapter maps `LLMError` → their public `QwenUnavailableError`). Pending tranches: an async `astream()` for voice `_stream_qwen` + agents `Agent.run` (queue stays at the call site — `codec_llm` never owns the semaphore), dashboard (4 non-stream + the `[SKILL:…]` stream tag-machine, which keeps its own parser and consumes only `stream()`'s raw tokens), bridges, and a skills tranche.
6060

6161
## 3. Agent + Crew runtime
6262

codec_agent_plan.py

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ def _qwen_model() -> str:
249249
return "mlx-community/Qwen3.6-35B-A3B-4bit"
250250

251251

252+
def _qwen_base() -> str:
253+
"""Base URL (no /chat/completions) for codec_llm.call — call-time resolved."""
254+
try:
255+
from codec_config import QWEN_BASE_URL
256+
return QWEN_BASE_URL
257+
except Exception:
258+
return "http://localhost:8083/v1"
259+
260+
252261
QWEN_URL = _qwen_url() # back-compat — module-level constant for tests
253262
QWEN_MODEL = _qwen_model() # back-compat
254263
QWEN_TIMEOUT = 60 # seconds
@@ -268,33 +277,27 @@ def _qwen_chat(user_prompt: str, system_prompt: str = "",
268277
assistant's content string. Raises QwenUnavailableError on
269278
network failure or non-2xx response.
270279
271-
URL + model resolved at call time via _qwen_url() / _qwen_model()
280+
URL + model resolved at call time via _qwen_base() / _qwen_model()
272281
so they pick up ~/.codec/config.json:llm_base_url + :llm_model
273282
rather than the deploy-time hardcoded values."""
274-
import requests # lazy import — avoid forcing requests on test machines without it
275-
276-
payload = {
277-
"model": _qwen_model(),
278-
"messages": [
279-
{"role": "system", "content": system_prompt or ""},
280-
{"role": "user", "content": user_prompt},
281-
],
282-
"max_tokens": max_tokens,
283-
"temperature": 0.2,
284-
}
283+
# A-12 (PR-3E-2c): canonical codec_llm.call(raise_on_error=True) replaces the
284+
# inline POST + per-failure raises. The adapter maps codec_llm.LLMError onto
285+
# the public QwenUnavailableError, so callers' `except QwenUnavailableError`
286+
# is unchanged. (Now also strips <think> + enable_thinking=False — the
287+
# downstream JSON parse is more robust for it.)
288+
import codec_llm
285289
try:
286-
r = requests.post(_qwen_url(), json=payload, timeout=QWEN_TIMEOUT)
287-
except requests.exceptions.ConnectionError as e:
288-
raise QwenUnavailableError(f"qwen3.6 unreachable: {e}")
289-
except requests.exceptions.Timeout:
290-
raise QwenUnavailableError("qwen3.6 request timed out")
291-
if r.status_code != 200:
292-
raise QwenUnavailableError(f"qwen3.6 returned {r.status_code}: {r.text[:200]}")
293-
try:
294-
data = r.json()
295-
return data["choices"][0]["message"]["content"]
296-
except (KeyError, json.JSONDecodeError) as e:
297-
raise QwenUnavailableError(f"qwen3.6 returned malformed response: {e}")
290+
return codec_llm.call(
291+
[
292+
{"role": "system", "content": system_prompt or ""},
293+
{"role": "user", "content": user_prompt},
294+
],
295+
base_url=_qwen_base(), model=_qwen_model(),
296+
max_tokens=max_tokens, temperature=0.2,
297+
timeout=QWEN_TIMEOUT, raise_on_error=True,
298+
)
299+
except codec_llm.LLMError as e:
300+
raise QwenUnavailableError(f"qwen3.6 unavailable: {e}") from e
298301

299302

300303
# ── Plan drafting ─────────────────────────────────────────────────────────────

codec_agent_runner.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ def _qwen_model() -> str:
239239
return "mlx-community/Qwen3.6-35B-A3B-4bit"
240240

241241

242+
def _qwen_base() -> str:
243+
"""Base URL (no /chat/completions) for codec_llm.call — call-time resolved."""
244+
try:
245+
from codec_config import QWEN_BASE_URL
246+
return QWEN_BASE_URL
247+
except Exception:
248+
return "http://localhost:8083/v1"
249+
250+
242251
QWEN_URL = _qwen_url()
243252
QWEN_MODEL = _qwen_model()
244253
QWEN_TIMEOUT = 60
@@ -306,28 +315,23 @@ def _qwen_chat(user_prompt: str, system_prompt: str = "",
306315
307316
URL + model resolved at call time so config.json changes are picked
308317
up without a process restart."""
309-
import requests
310-
payload = {
311-
"model": _qwen_model(),
312-
"messages": [
313-
{"role": "system", "content": system_prompt or ""},
314-
{"role": "user", "content": user_prompt},
315-
],
316-
"max_tokens": max_tokens,
317-
"temperature": 0.2,
318-
}
319-
try:
320-
r = requests.post(_qwen_url(), json=payload, timeout=QWEN_TIMEOUT)
321-
except requests.exceptions.ConnectionError as e:
322-
raise QwenUnavailableError(f"qwen3.6 unreachable: {e}")
323-
except requests.exceptions.Timeout:
324-
raise QwenUnavailableError("qwen3.6 request timed out")
325-
if r.status_code != 200:
326-
raise QwenUnavailableError(f"qwen3.6 returned {r.status_code}")
318+
# A-12 (PR-3E-2c): canonical codec_llm.call(raise_on_error=True). Adapter
319+
# maps codec_llm.LLMError -> the public QwenUnavailableError so the daemon's
320+
# retry/abort logic (except QwenUnavailableError) is unchanged. Kept parallel
321+
# with codec_agent_plan._qwen_chat.
322+
import codec_llm
327323
try:
328-
return r.json()["choices"][0]["message"]["content"]
329-
except (KeyError, json.JSONDecodeError) as e:
330-
raise QwenUnavailableError(f"qwen3.6 returned malformed response: {e}")
324+
return codec_llm.call(
325+
[
326+
{"role": "system", "content": system_prompt or ""},
327+
{"role": "user", "content": user_prompt},
328+
],
329+
base_url=_qwen_base(), model=_qwen_model(),
330+
max_tokens=max_tokens, temperature=0.2,
331+
timeout=QWEN_TIMEOUT, raise_on_error=True,
332+
)
333+
except codec_llm.LLMError as e:
334+
raise QwenUnavailableError(f"qwen3.6 unavailable: {e}") from e
331335

332336

333337
def _qwen_next_action(plan_dict: Dict[str, Any], checkpoint: Dict[str, Any],

codec_llm.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@
2727

2828
log = logging.getLogger("codec.llm")
2929

30+
31+
class LLMError(Exception):
32+
"""Raised by ``call(raise_on_error=True)`` on any non-success outcome —
33+
non-200 (after retries), a request exception (after retries), or a 200 with
34+
empty/unparseable content. The default ``raise_on_error=False`` keeps the
35+
never-raise → "" contract that the streaming/best-effort callers rely on.
36+
Fail-loud callers (agent_plan/runner, textassist, the regen script) opt in
37+
and map this onto their own error handling."""
38+
39+
3040
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
3141

3242

@@ -98,13 +108,21 @@ def call(
98108
retries: int = 1,
99109
enable_thinking: bool = False,
100110
extra_kwargs: Optional[Dict[str, Any]] = None,
111+
raise_on_error: bool = False,
101112
) -> str:
102113
"""POST `messages` to `<base_url>/chat/completions` and return the parsed,
103-
`<think>`-stripped assistant text (or "" on failure).
114+
`<think>`-stripped assistant text.
104115
105116
`retries` includes the first attempt (retries=3 → up to 3 tries with
106117
exponential 2**n backoff between them, matching codec_session.qwen_call).
107-
Never raises — network/parse errors are logged and yield "".
118+
119+
Error contract:
120+
- `raise_on_error=False` (default): never raises — network/parse errors and
121+
empty/unparseable 200s are logged and yield "".
122+
- `raise_on_error=True`: raises `LLMError` on EVERY non-success outcome
123+
(non-200 after retries, request exception after retries, or a 200 with
124+
empty/unparseable content). For fail-loud callers that must not silently
125+
proceed on an empty answer.
108126
"""
109127
import requests
110128
headers, payload = _build_request(
@@ -115,20 +133,29 @@ def call(
115133

116134
attempts = max(1, retries)
117135
url = base_url.rstrip("/") + "/chat/completions"
136+
last_error: Optional[Exception] = None
118137
for attempt in range(attempts):
119138
try:
120139
r = requests.post(url, json=payload, headers=headers, timeout=timeout)
121140
if r.status_code == 200:
122141
resp = extract_content(r.json())
123142
if resp:
124143
return resp
125-
# 200 but empty/odd shape — don't retry, nothing more to get.
144+
# 200 but empty/odd shape — nothing more to get; don't retry.
145+
if raise_on_error:
146+
raise LLMError("LLM returned empty or unparseable content")
126147
return ""
148+
last_error = LLMError(f"LLM call returned {r.status_code}: {r.text[:200]}")
127149
log.warning("LLM call %s returned %s: %s", url, r.status_code, r.text[:200])
150+
except LLMError:
151+
raise # empty-200 in raise mode — propagate, don't swallow as a retry
128152
except Exception as e:
153+
last_error = e
129154
log.warning("LLM call attempt %d/%d failed: %s", attempt + 1, attempts, e)
130155
if attempt < attempts - 1:
131156
time.sleep(2 ** attempt)
157+
if raise_on_error:
158+
raise LLMError(f"LLM call failed after {attempts} attempt(s): {last_error}")
132159
return ""
133160

134161

codec_textassist.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,19 @@ def call_qwen(text, mode):
2424
"translate": "You are a translator. Translate the following text into English. No matter what language the input is — Ukrainian, Spanish, French, Russian, Chinese, Arabic, anything — always translate to English. Output ONLY the translated English text, nothing else.",
2525
"prompt": "You are a prompt engineer. Rewrite the following text to be a clear, optimized prompt for an AI language model. Make it specific, structured, and effective. Remove ambiguity, add context where helpful, and ensure the intent is crystal clear. Output ONLY the optimized prompt, nothing else."
2626
}
27-
payload = {"model": model, "messages": [
28-
{"role": "system", "content": prompts.get(mode, prompts["proofread"])},
29-
{"role": "user", "content": text}
30-
], "max_tokens": 4000, "temperature": 0.3, "stream": False,
31-
"chat_template_kwargs": {"enable_thinking": False}}
32-
payload.update(kwargs)
33-
r = requests.post(f"{base}/chat/completions", json=payload, timeout=60)
34-
result = r.json()["choices"][0]["message"]["content"].strip()
35-
result = re.sub(r'<think>[\s\S]*?</think>', '', result).strip()
27+
# A-12 (PR-3E-2c): canonical codec_llm.call(raise_on_error=True). Fail-loud
28+
# is required here — the caller's except shows an Error overlay; never-raise
29+
# would paste an empty result over the user's selection. codec_llm strips
30+
# <think>; the `### FINAL ANSWER:` marker is textassist-specific so it stays.
31+
import codec_llm
32+
result = codec_llm.call(
33+
[
34+
{"role": "system", "content": prompts.get(mode, prompts["proofread"])},
35+
{"role": "user", "content": text},
36+
],
37+
base_url=base, model=model, max_tokens=4000, temperature=0.3,
38+
extra_kwargs=kwargs, timeout=60, raise_on_error=True,
39+
)
3640
return re.sub(r'###\s*FINAL ANSWER:\s*', '', result).strip()
3741

3842
def overlay(text, color, duration):

0 commit comments

Comments
 (0)