@tiens.nguyen/gonext-local-worker 1.0.92 → 1.0.94

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
 
@@ -23,6 +23,7 @@ import contextlib
23
23
  import json
24
24
  import re
25
25
  import sys
26
+ import traceback
26
27
  import urllib.request
27
28
  import urllib.error
28
29
 
@@ -230,6 +231,7 @@ _AGENT_KEYWORDS = re.compile(
230
231
  r"|download|scrape|crawl"
231
232
  r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
232
233
  r"|date|time|what\s+day|what\s+time"
234
+ 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
235
  r")\b",
234
236
  re.IGNORECASE,
235
237
  )
@@ -358,6 +360,152 @@ def _strip_tool_tags(text: str) -> str:
358
360
  return "\n".join(out).strip()
359
361
 
360
362
 
363
+ _PDF_INSTALL_HINT = (
364
+ "PDF engine not installed on the worker. Install it with:\n"
365
+ " python3 -m pip install xhtml2pdf markdown\n"
366
+ "then restart the worker. (See the Instructions page in the web app.)"
367
+ )
368
+
369
+
370
+ def _render_pdf_bytes(markdown_text: str, title: str = "") -> bytes:
371
+ """Render Markdown text to PDF bytes using pure-Python xhtml2pdf (no system deps).
372
+
373
+ Raises RuntimeError with an install hint if the engine isn't available, or on a
374
+ render failure. The model is expected to pass already-formatted Markdown.
375
+ """
376
+ try:
377
+ import markdown as _md
378
+ from xhtml2pdf import pisa as _pisa
379
+ except Exception: # noqa: BLE001 — missing optional dependency
380
+ raise RuntimeError(_PDF_INSTALL_HINT)
381
+
382
+ body_html = _md.markdown(
383
+ markdown_text or "",
384
+ extensions=["extra", "sane_lists", "tables", "nl2br"],
385
+ )
386
+ safe_title = (title or "Document").strip()
387
+ html = (
388
+ "<html><head><meta charset='utf-8'><style>"
389
+ "@page { size: A4; margin: 2cm; }"
390
+ "body { font-family: Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #18181b; }"
391
+ "h1 { font-size: 20pt; margin: 0 0 12pt; }"
392
+ "h2 { font-size: 15pt; margin: 16pt 0 8pt; }"
393
+ "h3 { font-size: 12pt; margin: 12pt 0 6pt; }"
394
+ "code, pre { font-family: Courier, monospace; background: #f4f4f5; }"
395
+ "pre { padding: 8pt; }"
396
+ "table { border-collapse: collapse; width: 100%; }"
397
+ "th, td { border: 1px solid #d4d4d8; padding: 4pt 6pt; text-align: left; }"
398
+ "</style></head><body>"
399
+ f"{body_html}"
400
+ "</body></html>"
401
+ )
402
+ from io import BytesIO as _BytesIO
403
+ out = _BytesIO()
404
+ result = _pisa.CreatePDF(src=html, dest=out, encoding="utf-8")
405
+ if getattr(result, "err", 1):
406
+ raise RuntimeError("PDF rendering failed (xhtml2pdf reported an error).")
407
+ return out.getvalue()
408
+
409
+
410
+ def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
411
+ pdf_bytes: bytes) -> str:
412
+ """Ask the API to presign an S3 upload, PUT the PDF bytes, return the download URL.
413
+
414
+ Keeps all AWS credentials on the API/Lambda — the worker only holds its worker key.
415
+ """
416
+ import urllib.request as _u
417
+ import urllib.error as _ue
418
+
419
+ base = (api_base or "").rstrip("/")
420
+ if not base or not worker_key:
421
+ raise RuntimeError("PDF upload is not available: worker API base/key missing.")
422
+
423
+ # 1) Presign.
424
+ req = _u.Request(
425
+ f"{base}/api/worker/pdf-upload-url",
426
+ data=json.dumps({"fileName": file_name}).encode("utf-8"),
427
+ headers={
428
+ "Content-Type": "application/json",
429
+ "X-Worker-Key": worker_key,
430
+ },
431
+ method="POST",
432
+ )
433
+ try:
434
+ with _u.urlopen(req, timeout=30) as resp:
435
+ ref = json.loads(resp.read().decode("utf-8"))
436
+ except _ue.HTTPError as e: # noqa: PERF203
437
+ detail = e.read().decode("utf-8", "replace")[:300]
438
+ raise RuntimeError(f"Could not get a PDF upload URL (HTTP {e.code}): {detail}")
439
+ except _ue.URLError as e:
440
+ raise RuntimeError(
441
+ f"Could not reach the API at {base} to presign the upload "
442
+ f"({getattr(e, 'reason', e)}). Is the worker API deployed/online?"
443
+ )
444
+ put_url = ref.get("putUrl")
445
+ get_url = ref.get("getUrl")
446
+ if not put_url or not get_url:
447
+ raise RuntimeError("PDF upload URL response was incomplete.")
448
+
449
+ # 2) Upload the bytes to the presigned PUT URL.
450
+ put_req = _u.Request(
451
+ put_url, data=pdf_bytes,
452
+ headers={"Content-Type": "application/pdf"},
453
+ method="PUT",
454
+ )
455
+ try:
456
+ with _u.urlopen(put_req, timeout=60) as up:
457
+ if up.status not in (200, 201, 204):
458
+ raise RuntimeError(f"S3 upload failed (HTTP {up.status}).")
459
+ except _ue.HTTPError as e: # noqa: PERF203
460
+ detail = e.read().decode("utf-8", "replace")[:200]
461
+ raise RuntimeError(f"S3 upload failed (HTTP {e.code}): {detail}")
462
+ except _ue.URLError as e:
463
+ raise RuntimeError(f"S3 upload could not connect ({getattr(e, 'reason', e)}).")
464
+ return get_url
465
+
466
+
467
+ def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
468
+ model_id: str) -> str:
469
+ """Use the model to clean/structure raw text into well-formed Markdown for the PDF.
470
+
471
+ Falls back to the original text (lightly wrapped) if the model call fails, so a
472
+ PDF is still produced.
473
+ """
474
+ fallback = text or ""
475
+ if title and not fallback.lstrip().startswith("#"):
476
+ fallback = f"# {title}\n\n{fallback}"
477
+ try:
478
+ from openai import OpenAI
479
+ client = OpenAI(base_url=base_url, api_key=api_key or "local",
480
+ max_retries=0, timeout=60)
481
+ resp = client.chat.completions.create(
482
+ model=model_id,
483
+ messages=[
484
+ {"role": "system", "content": (
485
+ "You format raw text/data into clean Markdown for a PDF document. "
486
+ "Add a single top-level '# Title', sensible headings, bullet lists, "
487
+ "and tables where appropriate. Do NOT invent facts, do NOT add "
488
+ "commentary, and preserve all numbers and wording. Output ONLY the "
489
+ "Markdown — no code fences, no explanations."
490
+ )},
491
+ {"role": "user", "content": (
492
+ (f"Title: {title}\n\n" if title else "")
493
+ + f"Content to format:\n{text}"
494
+ )},
495
+ ],
496
+ max_tokens=1500,
497
+ temperature=0.2,
498
+ )
499
+ out = (resp.choices[0].message.content or "").strip()
500
+ # Strip accidental ```markdown fences.
501
+ if out.startswith("```"):
502
+ out = re.sub(r"^```[a-zA-Z]*\n?|\n?```$", "", out).strip()
503
+ return out or fallback
504
+ except Exception as e: # noqa: BLE001
505
+ _log(f"pdf format error: {e} — using raw text")
506
+ return fallback
507
+
508
+
361
509
  def run_agent_chat(cfg):
362
510
  try:
363
511
  from smolagents import CodeAgent, OpenAIServerModel, tool
@@ -369,6 +517,9 @@ def run_agent_chat(cfg):
369
517
  agent_base_url = cfg.get("agentBaseURL") or ""
370
518
  agent_api_key = cfg.get("agentApiKey") or "local"
371
519
  agent_model_id = cfg.get("agentModelId") or ""
520
+ # For the create_pdf tool: API base + worker key to request a presigned S3 upload.
521
+ pdf_api_base = (cfg.get("apiBaseURL") or "").strip()
522
+ pdf_worker_key = (cfg.get("workerKey") or "").strip()
372
523
  # Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
373
524
  # Routing, plain replies and summarization stay on the chat model (better at
374
525
  # natural language); the code model only drives http_request reasoning.
@@ -492,13 +643,15 @@ def run_agent_chat(cfg):
492
643
  "YOU HAVE EXACTLY ONE TURN. Read the TASK above. In a single code block, call "
493
644
  "the ONE tool that fits THAT task, then pass its result to final_answer(). "
494
645
  "Do not plan multiple steps.\n\n"
495
- "You have THREE tools:\n"
646
+ "You have FOUR tools:\n"
496
647
  " 1. http_request(method, url, headers='', body='', username='', password='') — "
497
648
  "call a SPECIFIC known API/URL.\n"
498
649
  " 2. web_search(query) — look up facts about a person, place, thing, or topic "
499
650
  "when you do NOT already have a real URL. Returns a summary + source.\n"
500
651
  f" 3. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
501
652
  "Use this ONLY when the task explicitly asks for the date or time.\n"
653
+ " 4. create_pdf(text, title='') — make a PDF document from text/data and return a "
654
+ "download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
502
655
  "\n"
503
656
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
504
657
  "\n"
@@ -516,6 +669,7 @@ def run_agent_chat(cfg):
516
669
  "- ONLY a date/time question (e.g. 'what is the date today') -> get_current_datetime().\n"
517
670
  "- 'who is' / 'what is' / 'tell me about' / a person / place / topic / general "
518
671
  "knowledge -> web_search(query).\n"
672
+ "- 'create/make/generate/export a PDF' of some text/data -> create_pdf(text, title).\n"
519
673
  "- A specific known API/URL was given -> http_request().\n"
520
674
  "\n"
521
675
  "RULES:\n"
@@ -642,6 +796,69 @@ def run_agent_chat(cfg):
642
796
  _last_obs["text"] = result
643
797
  return result
644
798
 
799
+ @tool
800
+ def create_pdf(text: str, title: str = "") -> str:
801
+ """Create a PDF document from text/data and return a download link.
802
+
803
+ Use this ONLY when the user explicitly asks to make/create/generate/export a PDF.
804
+ The text is first cleaned into well-formed Markdown, then rendered to a PDF on the
805
+ worker and uploaded to cloud storage; the returned message contains the download URL.
806
+
807
+ Args:
808
+ text: The content to put in the PDF (raw text, notes, or data).
809
+ title: Optional document title shown at the top and used in the file name.
810
+ """
811
+ doc_title = (title or "").strip() or "Document"
812
+ # 1) Format the raw text into clean Markdown (extra model call, intentional).
813
+ _emit({"type": "step", "text": "Formatting document…"})
814
+ markdown_text = _format_text_for_pdf(
815
+ text or "", doc_title, coding_base_url, agent_api_key, coding_model_id
816
+ )
817
+
818
+ # 2) Render the Markdown to PDF bytes locally (pure-Python xhtml2pdf).
819
+ # Catch EVERYTHING (xhtml2pdf can raise arbitrary errors on exotic glyphs /
820
+ # emoji), so the tool always returns a string and never breaks the single shot.
821
+ _emit({"type": "step", "text": "Rendering PDF…"})
822
+ try:
823
+ pdf_bytes = _render_pdf_bytes(markdown_text, doc_title)
824
+ except RuntimeError as e:
825
+ # Engine missing or a clean render failure — surface the message as-is.
826
+ msg = str(e)
827
+ _log(f"create_pdf render error: {msg[:200]}")
828
+ _last_obs["text"] = msg
829
+ return msg
830
+ except Exception as e: # noqa: BLE001 — unexpected render crash
831
+ _log(f"create_pdf render crash: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
832
+ msg = (
833
+ "Sorry — I couldn't render that text into a PDF "
834
+ f"({type(e).__name__}: {str(e)[:160]}). "
835
+ "Try simpler text without unusual symbols/emoji."
836
+ )
837
+ _last_obs["text"] = msg
838
+ return msg
839
+
840
+ # 3) Upload via the API-presigned PUT (no AWS creds on the worker).
841
+ _emit({"type": "step", "text": "Uploading PDF…"})
842
+ file_name = f"{doc_title}.pdf"
843
+ try:
844
+ download_url = _pdf_upload_via_api(
845
+ pdf_api_base, pdf_worker_key, file_name, pdf_bytes
846
+ )
847
+ except Exception as e: # noqa: BLE001 — network / API errors must not crash the tool
848
+ _log(f"create_pdf upload error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
849
+ msg = f"PDF was created but could not be uploaded: {str(e)[:200]}"
850
+ _last_obs["text"] = msg
851
+ return msg
852
+
853
+ out = (
854
+ f"✅ Your PDF \"{doc_title}\" is ready.\n"
855
+ f"Download it here (link valid for a limited time):\n{download_url}"
856
+ )
857
+ _emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
858
+ _log(f"create_pdf ok title={doc_title!r} bytes={len(pdf_bytes)}")
859
+ _last_obs["text"] = out
860
+ return out
861
+
645
862
  def step_callback(step_log):
646
863
  step_num = getattr(step_log, "step_number", "?")
647
864
 
@@ -723,7 +940,7 @@ def run_agent_chat(cfg):
723
940
  api_key=agent_api_key,
724
941
  )
725
942
  agent = _SingleShotAgent(
726
- tools=[http_request, web_search, get_current_datetime],
943
+ tools=[http_request, web_search, get_current_datetime, create_pdf],
727
944
  model=model,
728
945
  max_steps=max_steps,
729
946
  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.92",
3
+ "version": "1.0.94",
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",