@tiens.nguyen/gonext-local-worker 1.0.190 → 1.0.192

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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +124 -60
  2. package/package.json +1 -1
@@ -476,6 +476,28 @@ def _clip(text: str, limit: int = 280) -> str:
476
476
  return f"{cut}…"
477
477
 
478
478
 
479
+ def _render_numbered_tool_list(tools) -> str:
480
+ """Render ' N. name(args) — description' lines from the REAL registered smolagents
481
+ tool objects — their actual name/inputs/docstring — instead of a hand-maintained
482
+ duplicate of the same information. This is the single source of truth for what's
483
+ ACTUALLY callable, so the list can never drift out of sync with the tool set (e.g.
484
+ when RAG or a workspace is enabled/disabled) and the numbering is always exactly
485
+ correct, unlike the old manually-incremented counter it replaces. Kept intentionally
486
+ terse — first docstring line only, bare arg names — since the system prompt's own
487
+ `tool.to_code_prompt()` rendering (see _COMPACT_CODE_SYSTEM_PROMPT) already shows the
488
+ full typed signature on every model call; this is just a quick-reference index."""
489
+ lines = []
490
+ for i, t in enumerate(tools, 1):
491
+ args = []
492
+ for pname, pinfo in (getattr(t, "inputs", None) or {}).items():
493
+ optional = isinstance(pinfo, dict) and pinfo.get("nullable")
494
+ args.append(f"{pname}=''" if optional and pinfo.get("type") == "string" else pname)
495
+ desc_full = (getattr(t, "description", "") or "").strip()
496
+ desc = desc_full.splitlines()[0] if desc_full else ""
497
+ lines.append(f" {i}. {t.name}({', '.join(args)}) — {desc}")
498
+ return ("\n".join(lines) + "\n") if lines else ""
499
+
500
+
479
501
  def _summarise_step(step_log):
480
502
  """Return a short human-readable description of an agent step."""
481
503
  tool_calls = getattr(step_log, "tool_calls", None) or []
@@ -598,6 +620,31 @@ def _is_trivial_chat(text: str) -> bool:
598
620
  return bool(_TRIVIAL_CHAT.match(t))
599
621
 
600
622
 
623
+ # A weak/confused coding model sometimes "thinks out loud" in plain prose instead of
624
+ # emitting the next tool call — e.g. a rambling, self-correcting scratchpad ("Wait,
625
+ # actually... Let's try... Actually, let's just...") that can run thousands of chars.
626
+ # The prose-final-answer heuristic below (long text with list-like structure) can't tell
627
+ # this apart from a genuine, complete answer on structure alone — both use headings/
628
+ # numbered lists. These two signals catch the scratchpad case specifically: it either
629
+ # self-labels as "Thought"/"Thinking Process" up front (a real answer doesn't open by
630
+ # naming itself as the model's own reasoning), or it's dense with self-correction
631
+ # language a finished answer wouldn't contain.
632
+ _SCRATCHPAD_START_RE = re.compile(r"^\s*thought\b\s*[:\n]", re.I)
633
+ _DELIBERATION_RE = re.compile(
634
+ r"\b(wait,|actually,|let'?s (try|just|use|list|see|go)|"
635
+ r"i (?:need to|should) (?:check|verify|confirm))\b",
636
+ re.I,
637
+ )
638
+
639
+
640
+ def _looks_like_scratchpad(stripped: str) -> bool:
641
+ """True if `stripped` reads like unresolved internal deliberation rather than a
642
+ finished, user-facing answer — see module comment above for the two signals used."""
643
+ if _SCRATCHPAD_START_RE.match(stripped):
644
+ return True
645
+ return len(_DELIBERATION_RE.findall(stripped)) >= 3
646
+
647
+
601
648
  # Compact replacement for smolagents' default CodeAgent system prompt. The stock template
602
649
  # is ~9.9k chars, dominated by ~6k chars of GENERIC few-shot examples (image captions,
603
650
  # Wikipedia, etc.) that are irrelevant to our HTTP/file tools and cost real prompt-eval
@@ -829,7 +876,13 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
829
876
  and delivering it in one burst) — the worker renders these as the live-updating MAIN
830
877
  answer, not the collapsible Thinking panel. If the primary model is unreachable (e.g.
831
878
  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."""
879
+ model), retry there so a greeting still gets answered instead of erroring.
880
+
881
+ RAISES (does not return a sentinel string) if every target fails or returns no
882
+ content — callers that want a soft degrade should catch this locally and substitute
883
+ their own clean message; letting it propagate uncaught turns into a genuine job
884
+ failure (red error, never persisted to conversation history) instead of a fabricated
885
+ "answer" that would poison every future turn's prompt with raw exception text."""
833
886
  _THINK_RE_LOCAL = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
834
887
  chat_messages = [{"role": "system", "content": "You are a helpful assistant."}]
835
888
  for m in messages:
@@ -863,24 +916,38 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
863
916
  from openai import OpenAI
864
917
  client = OpenAI(base_url=b, api_key=api_key or "local",
865
918
  max_retries=0, timeout=60)
866
- return _chat_create_stream(
919
+ text = _chat_create_stream(
867
920
  client, _track,
868
921
  model=mid,
869
922
  messages=chat_messages,
870
923
  temperature=0.7,
871
924
  max_tokens=512,
872
925
  ).strip()
926
+ if text:
927
+ return text
928
+ # The call SUCCEEDED (no exception) but the model produced NO content at all —
929
+ # seen with a reasoning model given a confusing/degenerate prompt (e.g. a prior
930
+ # turn's history polluted with raw error text). Treat this the same as a
931
+ # failure: fall back to the next target, or surface a clean message, rather
932
+ # than silently returning "" (which the caller can't tell apart from a real,
933
+ # deliberately empty answer).
934
+ last_err = RuntimeError(f"{mid} returned an empty response")
873
935
  except Exception as e: # noqa: BLE001
874
936
  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}]"
937
+ if emitted_here:
938
+ # Part of THIS attempt's answer already streamed to the user — don't
939
+ # silently splice a DIFFERENT model's output after a partial one.
940
+ break
941
+ if i + 1 < len(targets):
942
+ nb, nmid = targets[i + 1] # the model we're about to try next
943
+ _log(f"plain reply: {mid} @ {b} failed ({last_err}); "
944
+ f"falling back to {nmid} @ {nb}")
945
+ # Every target failed or returned nothing — raise rather than return a sentinel
946
+ # string. A returned string always looks like a legitimate answer to callers (and
947
+ # gets persisted to conversation history); raising lets it become a genuine job
948
+ # failure instead (see docstring). Kept short/clean (no embedded newlines/HTML — a
949
+ # raw nginx 502 page can be a whole document) for whichever caller logs/surfaces it.
950
+ raise RuntimeError(f"couldn't get a response ({_clip(str(last_err), 160)})")
884
951
 
885
952
 
886
953
  def _synthesize_document(gathered: list, task: str, base_url: str, api_key: str,
@@ -2247,25 +2314,12 @@ def run_agent_chat(cfg):
2247
2314
  # fabricate URLs/responses, and always terminate with final_answer().
2248
2315
  from datetime import datetime as _dt_now
2249
2316
  now_str = _dt_now.now().astimezone().strftime("%A, %d %B %Y, %H:%M %Z")
2250
- # The PDF reader and send_email are only advertised (and registered) when available
2251
- # here never offer the model a tool it cannot run. Entry numbers are dynamic so the
2252
- # list reads cleanly whether 6, 7, or 8 tools are present.
2253
- _tool_num = 6
2254
- _pdf_num = 0
2255
- if _PDF_READ_AVAILABLE:
2256
- _tool_num += 1
2257
- _pdf_num = _tool_num
2258
- _email_num = 0
2259
- if _EMAIL_AVAILABLE:
2260
- _tool_num += 1
2261
- _email_num = _tool_num
2262
- if _RAG_AVAILABLE:
2263
- _tool_num += 7 # download_file, unzip_file, list_dir, read_text_file, rag_index/add/search
2264
- if _WS_AVAILABLE:
2265
- _tool_num += 6 # grep_repo, read_file_lines, edit_lines, edit_file, create_file, run_command
2266
- if not _RAG_AVAILABLE:
2267
- _tool_num += 2 # list_dir + read_text_file registered for workspace browsing
2268
- _tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
2317
+ # The numbered tool list itself is generated LATER from the real registered tool
2318
+ # objects (see _render_numbered_tool_list, spliced into task_with_hint below via the
2319
+ # {{TOOL_LIST}} placeholder) it can never drift or miscount, unlike a hand-written
2320
+ # list with a manually-incremented number. The PDF reader / send_email / RAG /
2321
+ # workspace tools are only ever REGISTERED (further below) when available, so an
2322
+ # unavailable tool is never advertised.
2269
2323
  _rag_tool_block = (
2270
2324
  " FILES & KNOWLEDGE BASE — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
2271
2325
  " - download_file(url) — download a file (e.g. a .zip) to this machine.\n"
@@ -2319,18 +2373,10 @@ def run_agent_chat(cfg):
2319
2373
  "AFTER editing, re-read with grep_repo/read_file_lines (an S3 knowledge base may be "
2320
2374
  "stale — do not trust rag_search for freshly edited code).\n"
2321
2375
  ) if _WS_AVAILABLE else ""
2322
- _pdf_read_tool_line = (
2323
- f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
2324
- "at a URL. This does NOT create a PDF (use create_pdf for that).\n"
2325
- ) if _PDF_READ_AVAILABLE else ""
2326
2376
  _pdf_read_choose_line = (
2327
2377
  "- 'read'/'summarize'/'what does the PDF say' for an existing PDF URL -> "
2328
2378
  "extract_text_from_pdf(url).\n"
2329
2379
  ) if _PDF_READ_AVAILABLE else ""
2330
- _email_tool_line = (
2331
- f" {_email_num}. send_email(to, subject, body) — send an email via the configured "
2332
- "email API. It PREVIEWS first and only sends after the user replies 'confirm'.\n"
2333
- ) if _EMAIL_AVAILABLE else ""
2334
2380
  _email_choose_line = (
2335
2381
  "- 'email …' / 'send an email to …' -> send_email(to, subject, body) "
2336
2382
  "(previews first, then sends on confirm).\n"
@@ -2342,20 +2388,10 @@ def run_agent_chat(cfg):
2342
2388
  "next (e.g. web_search to find a URL, THEN fetch_url to read it). When you have "
2343
2389
  "enough to answer, call final_answer(<your answer>) in a code block. Prefer "
2344
2390
  "FEWER steps, and never repeat a tool call that already succeeded.\n\n"
2345
- f"You have {_tool_count_word} tools:\n"
2346
- " 1. http_request(method, url, headers='', body='', username='', password='') "
2347
- "call a SPECIFIC known API/URL.\n"
2348
- " 2. web_search(query) look up facts about a person, place, thing, or topic "
2349
- "when you do NOT already have a real URL. Returns a summary + source.\n"
2350
- " 3. fetch_url(url) — READ the full text of a specific web page (e.g. a link the "
2351
- "user gave, or a source URL from web_search). Returns clean text, not raw HTML.\n"
2352
- " 4. calculate(expression) — do math: arithmetic, percentages, powers "
2353
- "(e.g. '15% of 80', '(3+4)*2'). ALWAYS use this instead of computing yourself.\n"
2354
- f" 5. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
2355
- "Use this ONLY when the task explicitly asks for the date or time.\n"
2356
- " 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
2357
- "download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
2358
- + _pdf_read_tool_line + _email_tool_line + _rag_tool_block + _ws_tool_block +
2391
+ "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
2392
+ f"(Current date/time: {now_str} pass a timezone to get_current_datetime() only "
2393
+ "if the task needs a DIFFERENT one.)\n"
2394
+ + _rag_tool_block + _ws_tool_block +
2359
2395
  "\n"
2360
2396
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
2361
2397
  "\n"
@@ -3114,9 +3150,11 @@ def run_agent_chat(cfg):
3114
3150
  msg.content = normalized
3115
3151
  else:
3116
3152
  stripped = content.strip()
3117
- looks_final = len(stripped) >= 400 or re.search(
3118
- r"(^|\n)#{1,4}\s|\n\d+\.\s|\n[-*]\s|\|.+\|", stripped
3119
- ) is not None
3153
+ looks_final = (
3154
+ len(stripped) >= 400 or re.search(
3155
+ r"(^|\n)#{1,4}\s|\n\d+\.\s|\n[-*]\s|\|.+\|", stripped
3156
+ ) is not None
3157
+ ) and not _looks_like_scratchpad(stripped)
3120
3158
  if stripped and looks_final:
3121
3159
  _log(
3122
3160
  f"prose-only turn ({len(stripped)} chars, no tool call) "
@@ -3125,6 +3163,19 @@ def run_agent_chat(cfg):
3125
3163
  msg.content = (
3126
3164
  stripped + "\n<code>\nfinal_answer(" + repr(stripped) + ")\n</code>"
3127
3165
  )
3166
+ elif stripped and _looks_like_scratchpad(stripped):
3167
+ # Looks like unresolved deliberation, not a finished answer — do
3168
+ # NOT wrap it as final_answer (that would dump this rambling
3169
+ # scratchpad into the user-facing reply AND permanently pollute
3170
+ # every future turn's conversation history with it, seen live:
3171
+ # a 6000+ char "Thinking Process:" dump derailed the next two
3172
+ # unrelated questions). Leave content as-is: smolagents' own
3173
+ # code-block parser will fail to find one and burn this step as
3174
+ # a normal parse-error retry, giving the model another attempt.
3175
+ _log(
3176
+ f"scratchpad-like prose ({len(stripped)} chars, no tool call) "
3177
+ "→ NOT auto-wrapping; letting the step fail normally"
3178
+ )
3128
3179
  except Exception as e: # noqa: BLE001
3129
3180
  _log(f"content-normalize error: {e}")
3130
3181
  return msg
@@ -3681,8 +3732,9 @@ def run_agent_chat(cfg):
3681
3732
 
3682
3733
  agent_tools = [http_request, web_search, fetch_url, calculate,
3683
3734
  get_current_datetime, create_pdf]
3684
- # Only register the PDF reader / email / RAG tools when available (kept in sync
3685
- # with the gated hint entries above so the model never sees a tool it can't run).
3735
+ # Only register the PDF reader / email / RAG tools when available the model
3736
+ # never sees a tool it can't run, since the task-hint's tool list (filled in just
3737
+ # below) is generated FROM this exact list, so it's always in sync by construction.
3686
3738
  if _PDF_READ_AVAILABLE:
3687
3739
  agent_tools.append(extract_text_from_pdf)
3688
3740
  if _EMAIL_AVAILABLE:
@@ -3697,6 +3749,12 @@ def run_agent_chat(cfg):
3697
3749
  # (S3 indexing) isn't configured.
3698
3750
  if not _RAG_AVAILABLE:
3699
3751
  agent_tools += [list_dir, read_text_file]
3752
+ # Fill in the numbered tool list now that the REAL tool objects exist — see
3753
+ # _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
3754
+ task_with_hint = task_with_hint.replace(
3755
+ "{{TOOL_LIST}}",
3756
+ f"You have {len(agent_tools)} tools:\n" + _render_numbered_tool_list(agent_tools),
3757
+ )
3700
3758
  agent_kwargs = dict(
3701
3759
  tools=agent_tools,
3702
3760
  model=model,
@@ -3754,14 +3812,20 @@ def run_agent_chat(cfg):
3754
3812
  _emit({"type": "final", "text": f"⚠️ {cfg_err}"})
3755
3813
  return
3756
3814
  # 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.
3815
+ # answer from the conversation rather than surfacing a raw error to the user
3816
+ # but only when the fallback ACTUALLY has something useful to say.
3758
3817
  _log(f"agent error: {e} — degrading to plain reply")
3759
3818
  try:
3760
3819
  fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
3761
3820
  except Exception as e2: # noqa: BLE001
3762
3821
  _log(f"plain-reply degrade error: {e2}")
3763
- fallback = ""
3764
- _emit({"type": "final", "text": fallback or f"[Agent error: {e}]"})
3822
+ # Nothing useful to say — re-raise the ORIGINAL (more relevant) agent
3823
+ # failure so this becomes a genuine job failure: a red error the REPL/web
3824
+ # already handle correctly, NOT persisted to conversation history. Emitting
3825
+ # a fabricated "final" answer here instead would poison every future turn's
3826
+ # prompt with raw exception text (this is exactly what caused task #35).
3827
+ raise e
3828
+ _emit({"type": "final", "text": fallback})
3765
3829
 
3766
3830
 
3767
3831
  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.190",
3
+ "version": "1.0.192",
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",