@tiens.nguyen/gonext-local-worker 1.0.189 → 1.0.191

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.
package/gonext-repl.mjs CHANGED
@@ -306,6 +306,13 @@ async function runAgentTurn(history) {
306
306
  const tick = () => {
307
307
  if (!following || followAborted || !jobRunning) return;
308
308
  if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
309
+ // A plain-reply answer streams onto a line with NO trailing newline until it's fully
310
+ // done — if the model pauses mid-sentence (normal token-generation jitter) and we're
311
+ // already partway through that open line, CLEAR_LINE can only erase the terminal's
312
+ // CURRENT visual row, not the whole logical line once it's wrapped — overwriting the
313
+ // ticker there corrupts the visible answer text mid-word. Stay silent once any of
314
+ // THIS line has already been shown; the next completed line (fresh, empty) is safe.
315
+ if (plainReplyFlow && carryFlushedLen > 0) return;
309
316
  // Count from when the stream actually went quiet, so the seconds reflect the true
310
317
  // wait for the first token (1s, 2s, 3s…) rather than from the first tick.
311
318
  if (!thinking) { thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); }
@@ -829,7 +829,13 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
829
829
  and delivering it in one burst) — the worker renders these as the live-updating MAIN
830
830
  answer, not the collapsible Thinking panel. If the primary model is unreachable (e.g.
831
831
  the local MLX server is down) and a distinct fallback model is given (the coding
832
- model), retry there so a greeting still gets answered instead of erroring."""
832
+ model), retry there so a greeting still gets answered instead of erroring.
833
+
834
+ RAISES (does not return a sentinel string) if every target fails or returns no
835
+ content — callers that want a soft degrade should catch this locally and substitute
836
+ their own clean message; letting it propagate uncaught turns into a genuine job
837
+ failure (red error, never persisted to conversation history) instead of a fabricated
838
+ "answer" that would poison every future turn's prompt with raw exception text."""
833
839
  _THINK_RE_LOCAL = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
834
840
  chat_messages = [{"role": "system", "content": "You are a helpful assistant."}]
835
841
  for m in messages:
@@ -863,24 +869,38 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
863
869
  from openai import OpenAI
864
870
  client = OpenAI(base_url=b, api_key=api_key or "local",
865
871
  max_retries=0, timeout=60)
866
- return _chat_create_stream(
872
+ text = _chat_create_stream(
867
873
  client, _track,
868
874
  model=mid,
869
875
  messages=chat_messages,
870
876
  temperature=0.7,
871
877
  max_tokens=512,
872
878
  ).strip()
879
+ if text:
880
+ return text
881
+ # The call SUCCEEDED (no exception) but the model produced NO content at all —
882
+ # seen with a reasoning model given a confusing/degenerate prompt (e.g. a prior
883
+ # turn's history polluted with raw error text). Treat this the same as a
884
+ # failure: fall back to the next target, or surface a clean message, rather
885
+ # than silently returning "" (which the caller can't tell apart from a real,
886
+ # deliberately empty answer).
887
+ last_err = RuntimeError(f"{mid} returned an empty response")
873
888
  except Exception as e: # noqa: BLE001
874
889
  last_err = e
875
- if emitted_here:
876
- # Part of THIS attempt's answer already streamed to the user — don't
877
- # silently splice a DIFFERENT model's output after a partial one.
878
- break
879
- if i + 1 < len(targets):
880
- nb, nmid = targets[i + 1] # the model we're about to try next
881
- _log(f"plain reply: {mid} @ {b} failed ({e}); "
882
- f"falling back to {nmid} @ {nb}")
883
- return f"[Error: {last_err}]"
890
+ if emitted_here:
891
+ # Part of THIS attempt's answer already streamed to the user — don't
892
+ # silently splice a DIFFERENT model's output after a partial one.
893
+ break
894
+ if i + 1 < len(targets):
895
+ nb, nmid = targets[i + 1] # the model we're about to try next
896
+ _log(f"plain reply: {mid} @ {b} failed ({last_err}); "
897
+ f"falling back to {nmid} @ {nb}")
898
+ # Every target failed or returned nothing — raise rather than return a sentinel
899
+ # string. A returned string always looks like a legitimate answer to callers (and
900
+ # gets persisted to conversation history); raising lets it become a genuine job
901
+ # failure instead (see docstring). Kept short/clean (no embedded newlines/HTML — a
902
+ # raw nginx 502 page can be a whole document) for whichever caller logs/surfaces it.
903
+ raise RuntimeError(f"couldn't get a response ({_clip(str(last_err), 160)})")
884
904
 
885
905
 
886
906
  def _synthesize_document(gathered: list, task: str, base_url: str, api_key: str,
@@ -3754,14 +3774,20 @@ def run_agent_chat(cfg):
3754
3774
  _emit({"type": "final", "text": f"⚠️ {cfg_err}"})
3755
3775
  return
3756
3776
  # An unexpected failure inside the agent loop should still return a useful
3757
- # answer from the conversation rather than surfacing a raw error to the user.
3777
+ # answer from the conversation rather than surfacing a raw error to the user
3778
+ # but only when the fallback ACTUALLY has something useful to say.
3758
3779
  _log(f"agent error: {e} — degrading to plain reply")
3759
3780
  try:
3760
3781
  fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
3761
3782
  except Exception as e2: # noqa: BLE001
3762
3783
  _log(f"plain-reply degrade error: {e2}")
3763
- fallback = ""
3764
- _emit({"type": "final", "text": fallback or f"[Agent error: {e}]"})
3784
+ # Nothing useful to say — re-raise the ORIGINAL (more relevant) agent
3785
+ # failure so this becomes a genuine job failure: a red error the REPL/web
3786
+ # already handle correctly, NOT persisted to conversation history. Emitting
3787
+ # a fabricated "final" answer here instead would poison every future turn's
3788
+ # prompt with raw exception text (this is exactly what caused task #35).
3789
+ raise e
3790
+ _emit({"type": "final", "text": fallback})
3765
3791
 
3766
3792
 
3767
3793
  def _log(text: str):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.189",
3
+ "version": "1.0.191",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",