@tiens.nguyen/gonext-local-worker 1.0.237 → 1.0.239
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 +169 -37
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -131,6 +131,12 @@ def _html_to_text(html_text, limit=3000):
|
|
|
131
131
|
# Main menu / Donate / Create account…" before any article text, which both starves
|
|
132
132
|
# the model of real content and bloats the per-step context (OOM risk on local MLX).
|
|
133
133
|
text = re.sub(r"(?is)<(nav|header|footer|aside|form|button|menu)\b.*?</\1>", " ", text)
|
|
134
|
+
# Preserve TABLE structure before stripping tags: a cell boundary becomes " | "
|
|
135
|
+
# and a row becomes a newline, so a schedule/fixtures table survives as readable
|
|
136
|
+
# pipe-delimited rows instead of collapsing into a wall of words — a "make a table"
|
|
137
|
+
# task (task #75) is unanswerable if the source table is flattened on the way in.
|
|
138
|
+
text = re.sub(r"(?i)</(td|th)>", " | ", text)
|
|
139
|
+
text = re.sub(r"(?i)</tr>", "\n", text)
|
|
134
140
|
# Turn block-ending tags into newlines so document structure survives stripping.
|
|
135
141
|
text = re.sub(r"(?i)<(br|/p|/div|/li|/tr|/h[1-6]|/section|/article)\s*>", "\n", text)
|
|
136
142
|
# Remove all remaining tags.
|
|
@@ -358,60 +364,90 @@ def _get_json(url, timeout=15):
|
|
|
358
364
|
return None
|
|
359
365
|
|
|
360
366
|
|
|
361
|
-
def _web_search_impl(query):
|
|
367
|
+
def _web_search_impl(query, max_results=5):
|
|
362
368
|
"""Look up factual info via free no-key JSON APIs (DuckDuckGo + Wikipedia).
|
|
363
369
|
|
|
364
|
-
Returns a short
|
|
365
|
-
|
|
366
|
-
|
|
370
|
+
Returns a short SUMMARY (for a direct-fact question) followed by a numbered list
|
|
371
|
+
of the top matching PAGES (title — snippet — URL), so the model can fetch_url() a
|
|
372
|
+
real page when a one-line summary can't carry the data it needs (a schedule, a
|
|
373
|
+
table, a fixtures list). Returning only a single Wikipedia intro used to trap weak
|
|
374
|
+
models in a re-search loop — they never saw a candidate URL to open (task #75).
|
|
375
|
+
Never fabricates — returns an honest 'no results' when nothing is found.
|
|
367
376
|
"""
|
|
377
|
+
import html as _html
|
|
368
378
|
from urllib.parse import quote
|
|
369
379
|
q = (query or "").strip()
|
|
370
380
|
if not q:
|
|
371
381
|
return "web_search: empty query."
|
|
372
382
|
|
|
373
|
-
|
|
383
|
+
def _strip(s):
|
|
384
|
+
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
|
|
385
|
+
|
|
386
|
+
summary = ""
|
|
387
|
+
summary_src = ""
|
|
388
|
+
results = [] # (title, snippet, url)
|
|
389
|
+
seen = set()
|
|
390
|
+
|
|
391
|
+
def _add(title, snippet, url):
|
|
392
|
+
url = (url or "").strip()
|
|
393
|
+
if not url or url in seen or len(results) >= max_results:
|
|
394
|
+
return
|
|
395
|
+
seen.add(url)
|
|
396
|
+
results.append((_strip(title) or url, _strip(snippet), url))
|
|
397
|
+
|
|
398
|
+
# 1) DuckDuckGo Instant Answer — a direct abstract + related topics (as pages).
|
|
374
399
|
ddg = _get_json(
|
|
375
400
|
f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1"
|
|
376
401
|
)
|
|
377
402
|
if isinstance(ddg, dict):
|
|
378
403
|
abstract = (ddg.get("AbstractText") or "").strip()
|
|
379
404
|
if abstract:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
# No abstract — use the first related topic that has text.
|
|
405
|
+
summary = abstract[:1200]
|
|
406
|
+
summary_src = (ddg.get("AbstractURL") or "").strip()
|
|
383
407
|
for topic in ddg.get("RelatedTopics") or []:
|
|
384
|
-
if isinstance(topic, dict) and topic.get("Text"):
|
|
385
|
-
|
|
386
|
-
text = topic["Text"][:1500]
|
|
387
|
-
return f"{text}\nSource: {src}" if src else text
|
|
408
|
+
if isinstance(topic, dict) and topic.get("Text") and topic.get("FirstURL"):
|
|
409
|
+
_add(topic["Text"][:80], topic["Text"], topic["FirstURL"])
|
|
388
410
|
|
|
389
|
-
# 2) Wikipedia
|
|
411
|
+
# 2) Wikipedia full-text search — SEVERAL candidate pages with snippets, so the
|
|
412
|
+
# model can pick the specific one (e.g. a "…knockout stage" fixtures page).
|
|
390
413
|
search = _get_json(
|
|
391
414
|
"https://en.wikipedia.org/w/api.php?action=query&list=search"
|
|
392
|
-
f"&srsearch={quote(q)}&format=json
|
|
415
|
+
f"&srsearch={quote(q)}&srlimit={max_results}&format=json"
|
|
393
416
|
)
|
|
394
|
-
title = ""
|
|
395
417
|
try:
|
|
396
|
-
|
|
418
|
+
hits = search["query"]["search"]
|
|
397
419
|
except Exception: # noqa: BLE001
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
slug = quote(title.replace(" ", "_"))
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
420
|
+
hits = []
|
|
421
|
+
for hit in hits:
|
|
422
|
+
slug = quote(hit.get("title", "").replace(" ", "_"))
|
|
423
|
+
_add(hit.get("title", ""), hit.get("snippet", ""),
|
|
424
|
+
f"https://en.wikipedia.org/wiki/{slug}")
|
|
425
|
+
# If no direct abstract, use the best-matching page's summary as the answer.
|
|
426
|
+
if not summary and hits:
|
|
427
|
+
slug = quote(hits[0]["title"].replace(" ", "_"))
|
|
428
|
+
s = _get_json("https://en.wikipedia.org/api/rest_v1/page/summary/" + slug)
|
|
429
|
+
if isinstance(s, dict):
|
|
430
|
+
summary = (s.get("extract") or "").strip()[:1200]
|
|
431
|
+
summary_src = f"https://en.wikipedia.org/wiki/{slug}"
|
|
432
|
+
|
|
433
|
+
if not summary and not results:
|
|
434
|
+
return (
|
|
435
|
+
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
436
|
+
"do NOT invent an answer or a URL."
|
|
437
|
+
)
|
|
410
438
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
439
|
+
parts = []
|
|
440
|
+
if summary:
|
|
441
|
+
parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
|
|
442
|
+
if results:
|
|
443
|
+
lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
|
|
444
|
+
for i, (title, snippet, url) in enumerate(results, 1):
|
|
445
|
+
line = f"{i}. {title}"
|
|
446
|
+
if snippet:
|
|
447
|
+
line += f" — {snippet[:160]}"
|
|
448
|
+
lines.append(line + f"\n {url}")
|
|
449
|
+
parts.append("\n".join(lines))
|
|
450
|
+
return "\n\n".join(parts)
|
|
415
451
|
|
|
416
452
|
|
|
417
453
|
class _AgentConfigError(RuntimeError):
|
|
@@ -1392,8 +1428,14 @@ def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
|
|
|
1392
1428
|
fallback = f"# {title}\n\n{fallback}"
|
|
1393
1429
|
try:
|
|
1394
1430
|
from openai import OpenAI
|
|
1431
|
+
# timeout=20, NOT 60 (task #74): this formatting pass runs INSIDE a create_pdf
|
|
1432
|
+
# tool call, which itself runs under the agent's python-executor wall clock. A
|
|
1433
|
+
# 60s wait here could eat the whole step budget — the PDF then rendered+uploaded
|
|
1434
|
+
# fine but the model was told the step timed out, so it re-created the PDF from
|
|
1435
|
+
# scratch (3 duplicate PDFs, ~10 wasted minutes). Formatting is optional polish:
|
|
1436
|
+
# give it 20s, then fall back to the raw text and leave room for render+upload.
|
|
1395
1437
|
client = OpenAI(base_url=base_url, api_key=api_key or "local",
|
|
1396
|
-
max_retries=0, timeout=
|
|
1438
|
+
max_retries=0, timeout=20)
|
|
1397
1439
|
system_content = (
|
|
1398
1440
|
"You format raw text/data into clean Markdown for a PDF document. "
|
|
1399
1441
|
"Add a single top-level '# Title', sensible headings, bullet lists, "
|
|
@@ -2961,8 +3003,15 @@ def run_agent_chat(cfg):
|
|
|
2961
3003
|
)
|
|
2962
3004
|
|
|
2963
3005
|
_emit({"type": "step", "text": "Formatting document…"})
|
|
3006
|
+
# CHAT model, not the coder (task #74 review): formatting raw text into
|
|
3007
|
+
# Markdown is a language task, and the chat model (local MLX) answers in
|
|
3008
|
+
# seconds where the remote coder can take minutes to first token — inside
|
|
3009
|
+
# the 20s format timeout that difference is "formatted" vs "always falls
|
|
3010
|
+
# back to raw text". Same split as routing/plain replies/summarization.
|
|
2964
3011
|
markdown_text = _format_text_for_pdf(
|
|
2965
|
-
doc_text, doc_title,
|
|
3012
|
+
doc_text, doc_title,
|
|
3013
|
+
agent_base_url or coding_base_url, agent_api_key,
|
|
3014
|
+
agent_model_id or coding_model_id,
|
|
2966
3015
|
instruction=edit_instruction,
|
|
2967
3016
|
)
|
|
2968
3017
|
|
|
@@ -3223,6 +3272,8 @@ def run_agent_chat(cfg):
|
|
|
3223
3272
|
f"- RESEARCH BUDGET — you may make at most {research_budget} web_search/fetch_url calls "
|
|
3224
3273
|
"TOTAL per task (rewording a query still counts). Reserve your remaining steps for "
|
|
3225
3274
|
"create_pdf / final_answer.\n"
|
|
3275
|
+
"- If an earlier Observation already says '✅ Your PDF … is ready', the PDF EXISTS — "
|
|
3276
|
+
"do NOT call create_pdf again. Pass that download link to final_answer.\n"
|
|
3226
3277
|
"- Do NOT put final_answer outside the code block.\n\n"
|
|
3227
3278
|
)
|
|
3228
3279
|
# Recover an interrupted prior turn's step trace, if one survives on disk (crash/
|
|
@@ -3495,7 +3546,12 @@ def run_agent_chat(cfg):
|
|
|
3495
3546
|
_log(f"fetch_url {url} → PDF, cannot read")
|
|
3496
3547
|
_last_obs["text"] = msg
|
|
3497
3548
|
return msg
|
|
3498
|
-
|
|
3549
|
+
# 8000, not the 3000 default: a research page's real content (fixture tables,
|
|
3550
|
+
# data) sits well past the lead, so the old cap returned only the intro/nav and
|
|
3551
|
+
# starved the model (task #75). Older large observations are trimmed by the #63
|
|
3552
|
+
# step_callback, so the extra size only costs context for the latest 2 steps.
|
|
3553
|
+
text = (_html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
|
|
3554
|
+
or "(page had no readable text)")
|
|
3499
3555
|
out = f"{url}\n{text}"
|
|
3500
3556
|
_log(f"fetch_url {url} → {len(text)} chars")
|
|
3501
3557
|
_last_obs["text"] = out
|
|
@@ -3595,6 +3651,22 @@ def run_agent_chat(cfg):
|
|
|
3595
3651
|
_last_obs["text"] = result
|
|
3596
3652
|
return result
|
|
3597
3653
|
|
|
3654
|
+
# Task #74 retry guard. When a create_pdf call outlives the executor's wall clock,
|
|
3655
|
+
# the tool still finishes (render + S3 upload succeed, "PDF ready" is emitted) but
|
|
3656
|
+
# the model is told the step FAILED — so it calls create_pdf again for the same
|
|
3657
|
+
# document. Remember the last success here; `timeout_pending` is flipped by
|
|
3658
|
+
# step_callback whenever a step's result was an executor-timeout error, meaning
|
|
3659
|
+
# whatever succeeded during that step was never reported to the model.
|
|
3660
|
+
_pdf_state: dict = {"last": None, "timeout_pending": False}
|
|
3661
|
+
|
|
3662
|
+
def _pdf_sig(title: str, text: str) -> str:
|
|
3663
|
+
return (title.strip().lower() + "||"
|
|
3664
|
+
+ re.sub(r"\s+", " ", (text or "").strip().lower())[:1500])
|
|
3665
|
+
|
|
3666
|
+
def _pdf_titles_similar(a: str, b: str) -> bool:
|
|
3667
|
+
a, b = a.strip().lower(), b.strip().lower()
|
|
3668
|
+
return bool(a and b) and (a == b or a in b or b in a)
|
|
3669
|
+
|
|
3598
3670
|
@tool
|
|
3599
3671
|
def create_pdf(text: str, title: str = "") -> str:
|
|
3600
3672
|
"""Create a PDF document from text/data and return a download link.
|
|
@@ -3608,10 +3680,46 @@ def run_agent_chat(cfg):
|
|
|
3608
3680
|
title: Optional document title shown at the top and used in the file name.
|
|
3609
3681
|
"""
|
|
3610
3682
|
doc_title = (title or "").strip() or "Document"
|
|
3683
|
+
# Retry guard (task #74): identical content is ALWAYS a retry; a merely similar
|
|
3684
|
+
# title counts only when the previous success was swallowed by an executor
|
|
3685
|
+
# timeout (the model rewords/shortens on retry, so the text rarely matches).
|
|
3686
|
+
prev = _pdf_state.get("last")
|
|
3687
|
+
if prev and (
|
|
3688
|
+
prev["sig"] == _pdf_sig(doc_title, text)
|
|
3689
|
+
or (_pdf_state.get("timeout_pending")
|
|
3690
|
+
and _pdf_titles_similar(prev["title"], doc_title))
|
|
3691
|
+
):
|
|
3692
|
+
_log(f"create_pdf retry detected (prev title={prev['title']!r}) "
|
|
3693
|
+
"→ returning the existing PDF instead of re-rendering")
|
|
3694
|
+
_emit({"type": "step",
|
|
3695
|
+
"text": f"PDF already created → {prev['title']}.pdf (reusing link)"})
|
|
3696
|
+
_last_obs["text"] = prev["msg"]
|
|
3697
|
+
return prev["msg"]
|
|
3698
|
+
|
|
3699
|
+
# Refuse a placeholder skeleton instead of shipping a confident-but-empty PDF
|
|
3700
|
+
# (task #75): a table of 'TBD vs TBD' rows means the agent never found the real
|
|
3701
|
+
# data. 3+ placeholder cells is the fingerprint of that failure and virtually
|
|
3702
|
+
# never appears in a genuine user document. Steer the model to answer honestly
|
|
3703
|
+
# rather than render a green "✅ ready" over an empty table.
|
|
3704
|
+
_placeholders = len(re.findall(
|
|
3705
|
+
r"\b(?:TBD|TBA|TBC|N/?A)\b|\?\?\?|—\s*(?:vs\.?)?\s*—", text or "", re.I))
|
|
3706
|
+
if _placeholders >= 3:
|
|
3707
|
+
msg = (
|
|
3708
|
+
f"This document is mostly placeholders ({_placeholders} TBD/TBA cells) — "
|
|
3709
|
+
"the real data was never found, so the PDF would be empty. Do NOT retry "
|
|
3710
|
+
"create_pdf. Call final_answer to tell the user honestly that you could "
|
|
3711
|
+
f"not find the actual {doc_title} data to fill the table."
|
|
3712
|
+
)
|
|
3713
|
+
_log(f"create_pdf refused: {_placeholders} placeholder cells")
|
|
3714
|
+
_last_obs["text"] = msg
|
|
3715
|
+
return msg
|
|
3611
3716
|
# 1) Format the raw text into clean Markdown (extra model call, intentional).
|
|
3717
|
+
# CHAT model, not the coder — see the fast-path call site for why (task #74).
|
|
3612
3718
|
_emit({"type": "step", "text": "Formatting document…"})
|
|
3613
3719
|
markdown_text = _format_text_for_pdf(
|
|
3614
|
-
text or "", doc_title,
|
|
3720
|
+
text or "", doc_title,
|
|
3721
|
+
agent_base_url or coding_base_url, agent_api_key,
|
|
3722
|
+
agent_model_id or coding_model_id,
|
|
3615
3723
|
)
|
|
3616
3724
|
|
|
3617
3725
|
# 2) Render the Markdown to PDF bytes locally (pure-Python xhtml2pdf).
|
|
@@ -3655,6 +3763,9 @@ def run_agent_chat(cfg):
|
|
|
3655
3763
|
)
|
|
3656
3764
|
_emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
|
|
3657
3765
|
_log(f"create_pdf ok title={doc_title!r} bytes={len(pdf_bytes)}")
|
|
3766
|
+
_pdf_state["last"] = {
|
|
3767
|
+
"sig": _pdf_sig(doc_title, text), "title": doc_title, "msg": out,
|
|
3768
|
+
}
|
|
3658
3769
|
_last_obs["text"] = out
|
|
3659
3770
|
return out
|
|
3660
3771
|
|
|
@@ -3670,6 +3781,19 @@ def run_agent_chat(cfg):
|
|
|
3670
3781
|
def step_callback(step_log):
|
|
3671
3782
|
step_num = getattr(step_log, "step_number", "?")
|
|
3672
3783
|
|
|
3784
|
+
# Task #74: an executor-timeout error means whatever the step's tool call
|
|
3785
|
+
# achieved was NEVER reported to the model (a create_pdf may have finished —
|
|
3786
|
+
# or may still be finishing — in the background). Flag it so a repeat
|
|
3787
|
+
# create_pdf with a similar title is treated as the retry it is; any step
|
|
3788
|
+
# that ends with a real (non-timeout) result clears the flag.
|
|
3789
|
+
try:
|
|
3790
|
+
_err = getattr(step_log, "error", None)
|
|
3791
|
+
_pdf_state["timeout_pending"] = bool(
|
|
3792
|
+
_err and "maximum execution time" in str(_err)
|
|
3793
|
+
)
|
|
3794
|
+
except Exception: # noqa: BLE001
|
|
3795
|
+
pass
|
|
3796
|
+
|
|
3673
3797
|
# (B) Trim OLD, LARGE tool observations out of the CodeAgent's step memory so
|
|
3674
3798
|
# they aren't re-sent to the model on every subsequent step (the #63 O(n²) token
|
|
3675
3799
|
# blow-up: an uncapped read_file_lines dump cost ~5-6k tokens PER remaining step).
|
|
@@ -5122,7 +5246,15 @@ def run_agent_chat(cfg):
|
|
|
5122
5246
|
)
|
|
5123
5247
|
if _AgentBase is CodeAgent:
|
|
5124
5248
|
# Only the CodeAgent runs a Python executor; ToolCallingAgent takes neither.
|
|
5125
|
-
|
|
5249
|
+
# 660s, NOT 60 (task #74): the wall clock must exceed the LARGEST tool-internal
|
|
5250
|
+
# timeout (run_command caps at 600s; create_pdf's format+render+upload can pass
|
|
5251
|
+
# 60s), otherwise a tool that eventually SUCCEEDS gets reported to the model as
|
|
5252
|
+
# 'Code execution exceeded the maximum execution time' — the model then retries
|
|
5253
|
+
# work that already finished (the duplicate-PDF bug). Every tool enforces its
|
|
5254
|
+
# own tighter timeout and returns a clean message; runaway pure-python is still
|
|
5255
|
+
# caught by smolagents' op-count caps (MAX_OPERATIONS/MAX_WHILE_ITERATIONS),
|
|
5256
|
+
# so this wall clock is a last-resort backstop, not the primary guard.
|
|
5257
|
+
agent_kwargs["executor_kwargs"] = {"timeout_seconds": 660}
|
|
5126
5258
|
agent_kwargs["additional_authorized_imports"] = [
|
|
5127
5259
|
"json", "base64", "urllib", "urllib.request", "urllib.error"
|
|
5128
5260
|
]
|
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.239",
|
|
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",
|