@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.
- package/gonext_agent_chat.py +124 -60
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
return
|
|
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
|
|
2251
|
-
#
|
|
2252
|
-
#
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
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
|
-
|
|
2346
|
-
"
|
|
2347
|
-
"
|
|
2348
|
-
|
|
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 =
|
|
3118
|
-
|
|
3119
|
-
|
|
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
|
|
3685
|
-
#
|
|
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
|
-
|
|
3764
|
-
|
|
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.
|
|
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",
|