@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.
@@ -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
 
@@ -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": "Planning HTTP request…"})
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
- f"Current date/time: {now_str}.\n\n"
493
- "YOU HAVE EXACTLY ONE TURN. In a single code block, call the ONE right tool "
494
- "and then pass its result to final_answer(). Do not plan multiple steps.\n\n"
495
- "You have THREE tools:\n"
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 when you do NOT already have a real URL. "
499
- "Returns a summary + source.\n"
500
- " 3. get_current_datetime(timezone='') — current date/time (no HTTP needed).\n"
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
- "- Date/time question -> get_current_datetime(); do NOT use http_request.\n"
516
- "- 'find' / 'look up' / 'what is' / general knowledge -> web_search(query).\n"
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
- task_with_hint = tool_hint + "Task: " + task_text
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.91",
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",