@tiens.nguyen/gonext-local-worker 1.0.91 → 1.0.93
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-local-worker.mjs +4 -0
- package/gonext_agent_chat.py +220 -13
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1380,6 +1380,10 @@ async function runAgentChatJob(job) {
|
|
|
1380
1380
|
codingModelId: payload?.codingModelId ?? "",
|
|
1381
1381
|
tools: payload?.tools ?? ["http_request"],
|
|
1382
1382
|
maxSteps: payload?.maxSteps ?? 5,
|
|
1383
|
+
// For the create_pdf tool: the worker key lets the Python tool ask the API
|
|
1384
|
+
// to presign an S3 upload, so no AWS creds ever live on the worker machine.
|
|
1385
|
+
apiBaseURL: apiBase,
|
|
1386
|
+
workerKey,
|
|
1383
1387
|
});
|
|
1384
1388
|
const timeoutMs = 300_000; // 5 min max for an agent run
|
|
1385
1389
|
|
package/gonext_agent_chat.py
CHANGED
|
@@ -230,6 +230,7 @@ _AGENT_KEYWORDS = re.compile(
|
|
|
230
230
|
r"|download|scrape|crawl"
|
|
231
231
|
r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
|
|
232
232
|
r"|date|time|what\s+day|what\s+time"
|
|
233
|
+
r"|pdf|\.pdf|create\s+a\s+pdf|generate\s+a\s+pdf|make\s+a\s+pdf|export.*pdf|make\s+a\s+document"
|
|
233
234
|
r")\b",
|
|
234
235
|
re.IGNORECASE,
|
|
235
236
|
)
|
|
@@ -358,6 +359,144 @@ def _strip_tool_tags(text: str) -> str:
|
|
|
358
359
|
return "\n".join(out).strip()
|
|
359
360
|
|
|
360
361
|
|
|
362
|
+
_PDF_INSTALL_HINT = (
|
|
363
|
+
"PDF engine not installed on the worker. Install it with:\n"
|
|
364
|
+
" python3 -m pip install xhtml2pdf markdown\n"
|
|
365
|
+
"then restart the worker. (See the Instructions page in the web app.)"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _render_pdf_bytes(markdown_text: str, title: str = "") -> bytes:
|
|
370
|
+
"""Render Markdown text to PDF bytes using pure-Python xhtml2pdf (no system deps).
|
|
371
|
+
|
|
372
|
+
Raises RuntimeError with an install hint if the engine isn't available, or on a
|
|
373
|
+
render failure. The model is expected to pass already-formatted Markdown.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
import markdown as _md
|
|
377
|
+
from xhtml2pdf import pisa as _pisa
|
|
378
|
+
except Exception: # noqa: BLE001 — missing optional dependency
|
|
379
|
+
raise RuntimeError(_PDF_INSTALL_HINT)
|
|
380
|
+
|
|
381
|
+
body_html = _md.markdown(
|
|
382
|
+
markdown_text or "",
|
|
383
|
+
extensions=["extra", "sane_lists", "tables", "nl2br"],
|
|
384
|
+
)
|
|
385
|
+
safe_title = (title or "Document").strip()
|
|
386
|
+
html = (
|
|
387
|
+
"<html><head><meta charset='utf-8'><style>"
|
|
388
|
+
"@page { size: A4; margin: 2cm; }"
|
|
389
|
+
"body { font-family: Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #18181b; }"
|
|
390
|
+
"h1 { font-size: 20pt; margin: 0 0 12pt; }"
|
|
391
|
+
"h2 { font-size: 15pt; margin: 16pt 0 8pt; }"
|
|
392
|
+
"h3 { font-size: 12pt; margin: 12pt 0 6pt; }"
|
|
393
|
+
"code, pre { font-family: Courier, monospace; background: #f4f4f5; }"
|
|
394
|
+
"pre { padding: 8pt; }"
|
|
395
|
+
"table { border-collapse: collapse; width: 100%; }"
|
|
396
|
+
"th, td { border: 1px solid #d4d4d8; padding: 4pt 6pt; text-align: left; }"
|
|
397
|
+
"</style></head><body>"
|
|
398
|
+
f"{body_html}"
|
|
399
|
+
"</body></html>"
|
|
400
|
+
)
|
|
401
|
+
from io import BytesIO as _BytesIO
|
|
402
|
+
out = _BytesIO()
|
|
403
|
+
result = _pisa.CreatePDF(src=html, dest=out, encoding="utf-8")
|
|
404
|
+
if getattr(result, "err", 1):
|
|
405
|
+
raise RuntimeError("PDF rendering failed (xhtml2pdf reported an error).")
|
|
406
|
+
return out.getvalue()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
410
|
+
pdf_bytes: bytes) -> str:
|
|
411
|
+
"""Ask the API to presign an S3 upload, PUT the PDF bytes, return the download URL.
|
|
412
|
+
|
|
413
|
+
Keeps all AWS credentials on the API/Lambda — the worker only holds its worker key.
|
|
414
|
+
"""
|
|
415
|
+
import urllib.request as _u
|
|
416
|
+
import urllib.error as _ue
|
|
417
|
+
|
|
418
|
+
base = (api_base or "").rstrip("/")
|
|
419
|
+
if not base or not worker_key:
|
|
420
|
+
raise RuntimeError("PDF upload is not available: worker API base/key missing.")
|
|
421
|
+
|
|
422
|
+
# 1) Presign.
|
|
423
|
+
req = _u.Request(
|
|
424
|
+
f"{base}/api/worker/pdf-upload-url",
|
|
425
|
+
data=json.dumps({"fileName": file_name}).encode("utf-8"),
|
|
426
|
+
headers={
|
|
427
|
+
"Content-Type": "application/json",
|
|
428
|
+
"X-Worker-Key": worker_key,
|
|
429
|
+
},
|
|
430
|
+
method="POST",
|
|
431
|
+
)
|
|
432
|
+
try:
|
|
433
|
+
with _u.urlopen(req, timeout=30) as resp:
|
|
434
|
+
ref = json.loads(resp.read().decode("utf-8"))
|
|
435
|
+
except _ue.HTTPError as e: # noqa: PERF203
|
|
436
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
437
|
+
raise RuntimeError(f"Could not get a PDF upload URL (HTTP {e.code}): {detail}")
|
|
438
|
+
put_url = ref.get("putUrl")
|
|
439
|
+
get_url = ref.get("getUrl")
|
|
440
|
+
if not put_url or not get_url:
|
|
441
|
+
raise RuntimeError("PDF upload URL response was incomplete.")
|
|
442
|
+
|
|
443
|
+
# 2) Upload the bytes to the presigned PUT URL.
|
|
444
|
+
put_req = _u.Request(
|
|
445
|
+
put_url, data=pdf_bytes,
|
|
446
|
+
headers={"Content-Type": "application/pdf"},
|
|
447
|
+
method="PUT",
|
|
448
|
+
)
|
|
449
|
+
try:
|
|
450
|
+
with _u.urlopen(put_req, timeout=60) as up:
|
|
451
|
+
if up.status not in (200, 201, 204):
|
|
452
|
+
raise RuntimeError(f"S3 upload failed (HTTP {up.status}).")
|
|
453
|
+
except _ue.HTTPError as e: # noqa: PERF203
|
|
454
|
+
raise RuntimeError(f"S3 upload failed (HTTP {e.code}).")
|
|
455
|
+
return get_url
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
|
|
459
|
+
model_id: str) -> str:
|
|
460
|
+
"""Use the model to clean/structure raw text into well-formed Markdown for the PDF.
|
|
461
|
+
|
|
462
|
+
Falls back to the original text (lightly wrapped) if the model call fails, so a
|
|
463
|
+
PDF is still produced.
|
|
464
|
+
"""
|
|
465
|
+
fallback = text or ""
|
|
466
|
+
if title and not fallback.lstrip().startswith("#"):
|
|
467
|
+
fallback = f"# {title}\n\n{fallback}"
|
|
468
|
+
try:
|
|
469
|
+
from openai import OpenAI
|
|
470
|
+
client = OpenAI(base_url=base_url, api_key=api_key or "local",
|
|
471
|
+
max_retries=0, timeout=60)
|
|
472
|
+
resp = client.chat.completions.create(
|
|
473
|
+
model=model_id,
|
|
474
|
+
messages=[
|
|
475
|
+
{"role": "system", "content": (
|
|
476
|
+
"You format raw text/data into clean Markdown for a PDF document. "
|
|
477
|
+
"Add a single top-level '# Title', sensible headings, bullet lists, "
|
|
478
|
+
"and tables where appropriate. Do NOT invent facts, do NOT add "
|
|
479
|
+
"commentary, and preserve all numbers and wording. Output ONLY the "
|
|
480
|
+
"Markdown — no code fences, no explanations."
|
|
481
|
+
)},
|
|
482
|
+
{"role": "user", "content": (
|
|
483
|
+
(f"Title: {title}\n\n" if title else "")
|
|
484
|
+
+ f"Content to format:\n{text}"
|
|
485
|
+
)},
|
|
486
|
+
],
|
|
487
|
+
max_tokens=1500,
|
|
488
|
+
temperature=0.2,
|
|
489
|
+
)
|
|
490
|
+
out = (resp.choices[0].message.content or "").strip()
|
|
491
|
+
# Strip accidental ```markdown fences.
|
|
492
|
+
if out.startswith("```"):
|
|
493
|
+
out = re.sub(r"^```[a-zA-Z]*\n?|\n?```$", "", out).strip()
|
|
494
|
+
return out or fallback
|
|
495
|
+
except Exception as e: # noqa: BLE001
|
|
496
|
+
_log(f"pdf format error: {e} — using raw text")
|
|
497
|
+
return fallback
|
|
498
|
+
|
|
499
|
+
|
|
361
500
|
def run_agent_chat(cfg):
|
|
362
501
|
try:
|
|
363
502
|
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
@@ -369,6 +508,9 @@ def run_agent_chat(cfg):
|
|
|
369
508
|
agent_base_url = cfg.get("agentBaseURL") or ""
|
|
370
509
|
agent_api_key = cfg.get("agentApiKey") or "local"
|
|
371
510
|
agent_model_id = cfg.get("agentModelId") or ""
|
|
511
|
+
# For the create_pdf tool: API base + worker key to request a presigned S3 upload.
|
|
512
|
+
pdf_api_base = (cfg.get("apiBaseURL") or "").strip()
|
|
513
|
+
pdf_worker_key = (cfg.get("workerKey") or "").strip()
|
|
372
514
|
# Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
|
|
373
515
|
# Routing, plain replies and summarization stay on the chat model (better at
|
|
374
516
|
# natural language); the code model only drives http_request reasoning.
|
|
@@ -482,22 +624,25 @@ def run_agent_chat(cfg):
|
|
|
482
624
|
|
|
483
625
|
# Agent path — from here all step events go into <think>.
|
|
484
626
|
_log("router: agent (HTTP tool use needed)")
|
|
485
|
-
_emit({"type": "step", "text": "
|
|
627
|
+
_emit({"type": "step", "text": "Choosing a tool…"})
|
|
486
628
|
|
|
487
629
|
# Prepend explicit tool instructions so small models pick the right tool, never
|
|
488
630
|
# fabricate URLs/responses, and always terminate with final_answer().
|
|
489
631
|
from datetime import datetime as _dt_now
|
|
490
632
|
now_str = _dt_now.now().astimezone().strftime("%A, %d %B %Y, %H:%M %Z")
|
|
491
633
|
tool_hint = (
|
|
492
|
-
|
|
493
|
-
"
|
|
494
|
-
"
|
|
495
|
-
"You have
|
|
634
|
+
"YOU HAVE EXACTLY ONE TURN. Read the TASK above. In a single code block, call "
|
|
635
|
+
"the ONE tool that fits THAT task, then pass its result to final_answer(). "
|
|
636
|
+
"Do not plan multiple steps.\n\n"
|
|
637
|
+
"You have FOUR tools:\n"
|
|
496
638
|
" 1. http_request(method, url, headers='', body='', username='', password='') — "
|
|
497
639
|
"call a SPECIFIC known API/URL.\n"
|
|
498
|
-
" 2. web_search(query) — look up facts
|
|
499
|
-
"Returns a summary + source.\n"
|
|
500
|
-
" 3. get_current_datetime(timezone='') — current date/time (
|
|
640
|
+
" 2. web_search(query) — look up facts about a person, place, thing, or topic "
|
|
641
|
+
"when you do NOT already have a real URL. Returns a summary + source.\n"
|
|
642
|
+
f" 3. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
|
|
643
|
+
"Use this ONLY when the task explicitly asks for the date or time.\n"
|
|
644
|
+
" 4. create_pdf(text, title='') — make a PDF document from text/data and return a "
|
|
645
|
+
"download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
|
|
501
646
|
"\n"
|
|
502
647
|
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
503
648
|
"\n"
|
|
@@ -511,9 +656,11 @@ def run_agent_chat(cfg):
|
|
|
511
656
|
" response = http_request('GET', url, headers='{\"Authorization\": \"Bearer TOKEN\"}')\n"
|
|
512
657
|
" final_answer(response)\n"
|
|
513
658
|
"\n"
|
|
514
|
-
"CHOOSING A TOOL:\n"
|
|
515
|
-
"-
|
|
516
|
-
"- '
|
|
659
|
+
"CHOOSING A TOOL (match the TASK, not these examples):\n"
|
|
660
|
+
"- ONLY a date/time question (e.g. 'what is the date today') -> get_current_datetime().\n"
|
|
661
|
+
"- 'who is' / 'what is' / 'tell me about' / a person / place / topic / general "
|
|
662
|
+
"knowledge -> web_search(query).\n"
|
|
663
|
+
"- 'create/make/generate/export a PDF' of some text/data -> create_pdf(text, title).\n"
|
|
517
664
|
"- A specific known API/URL was given -> http_request().\n"
|
|
518
665
|
"\n"
|
|
519
666
|
"RULES:\n"
|
|
@@ -525,7 +672,15 @@ def run_agent_chat(cfg):
|
|
|
525
672
|
"- If a tool returns 'Error:' or HTTP 4xx/5xx, try a DIFFERENT approach, not the same URL.\n"
|
|
526
673
|
"- Do NOT put final_answer outside the code block.\n\n"
|
|
527
674
|
)
|
|
528
|
-
|
|
675
|
+
# Lead with the TASK so the weak model anchors on what's actually being asked —
|
|
676
|
+
# not on the tool reference below. (Previously the hint led with the date, and the
|
|
677
|
+
# 3B model treated every message as a date question.)
|
|
678
|
+
task_with_hint = (
|
|
679
|
+
"TASK (answer THIS, choose the tool that fits it):\n"
|
|
680
|
+
f"{task_text}\n\n"
|
|
681
|
+
"----- TOOL REFERENCE -----\n"
|
|
682
|
+
+ tool_hint
|
|
683
|
+
)
|
|
529
684
|
|
|
530
685
|
# Track URLs that have already failed so we don't retry dead endpoints across steps.
|
|
531
686
|
_failed_urls: set = set()
|
|
@@ -632,6 +787,58 @@ def run_agent_chat(cfg):
|
|
|
632
787
|
_last_obs["text"] = result
|
|
633
788
|
return result
|
|
634
789
|
|
|
790
|
+
@tool
|
|
791
|
+
def create_pdf(text: str, title: str = "") -> str:
|
|
792
|
+
"""Create a PDF document from text/data and return a download link.
|
|
793
|
+
|
|
794
|
+
Use this ONLY when the user explicitly asks to make/create/generate/export a PDF.
|
|
795
|
+
The text is first cleaned into well-formed Markdown, then rendered to a PDF on the
|
|
796
|
+
worker and uploaded to cloud storage; the returned message contains the download URL.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
text: The content to put in the PDF (raw text, notes, or data).
|
|
800
|
+
title: Optional document title shown at the top and used in the file name.
|
|
801
|
+
"""
|
|
802
|
+
doc_title = (title or "").strip() or "Document"
|
|
803
|
+
# 1) Format the raw text into clean Markdown (extra model call, intentional).
|
|
804
|
+
_emit({"type": "step", "text": "Formatting document…"})
|
|
805
|
+
markdown_text = _format_text_for_pdf(
|
|
806
|
+
text or "", doc_title, coding_base_url, agent_api_key, coding_model_id
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# 2) Render the Markdown to PDF bytes locally (pure-Python xhtml2pdf).
|
|
810
|
+
_emit({"type": "step", "text": "Rendering PDF…"})
|
|
811
|
+
try:
|
|
812
|
+
pdf_bytes = _render_pdf_bytes(markdown_text, doc_title)
|
|
813
|
+
except RuntimeError as e:
|
|
814
|
+
# Engine missing or render failure — surface the (self-explaining) message.
|
|
815
|
+
msg = str(e)
|
|
816
|
+
_log(f"create_pdf render error: {msg[:120]}")
|
|
817
|
+
_last_obs["text"] = msg
|
|
818
|
+
return msg
|
|
819
|
+
|
|
820
|
+
# 3) Upload via the API-presigned PUT (no AWS creds on the worker).
|
|
821
|
+
_emit({"type": "step", "text": "Uploading PDF…"})
|
|
822
|
+
file_name = f"{doc_title}.pdf"
|
|
823
|
+
try:
|
|
824
|
+
download_url = _pdf_upload_via_api(
|
|
825
|
+
pdf_api_base, pdf_worker_key, file_name, pdf_bytes
|
|
826
|
+
)
|
|
827
|
+
except RuntimeError as e:
|
|
828
|
+
msg = f"PDF was created but could not be uploaded: {e}"
|
|
829
|
+
_log(f"create_pdf upload error: {str(e)[:120]}")
|
|
830
|
+
_last_obs["text"] = msg
|
|
831
|
+
return msg
|
|
832
|
+
|
|
833
|
+
out = (
|
|
834
|
+
f"✅ Your PDF \"{doc_title}\" is ready.\n"
|
|
835
|
+
f"Download it here (link valid for a limited time):\n{download_url}"
|
|
836
|
+
)
|
|
837
|
+
_emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
|
|
838
|
+
_log(f"create_pdf ok title={doc_title!r} bytes={len(pdf_bytes)}")
|
|
839
|
+
_last_obs["text"] = out
|
|
840
|
+
return out
|
|
841
|
+
|
|
635
842
|
def step_callback(step_log):
|
|
636
843
|
step_num = getattr(step_log, "step_number", "?")
|
|
637
844
|
|
|
@@ -713,7 +920,7 @@ def run_agent_chat(cfg):
|
|
|
713
920
|
api_key=agent_api_key,
|
|
714
921
|
)
|
|
715
922
|
agent = _SingleShotAgent(
|
|
716
|
-
tools=[http_request, web_search, get_current_datetime],
|
|
923
|
+
tools=[http_request, web_search, get_current_datetime, create_pdf],
|
|
717
924
|
model=model,
|
|
718
925
|
max_steps=max_steps,
|
|
719
926
|
step_callbacks=[step_callback],
|
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.93",
|
|
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",
|