@tiens.nguyen/gonext-local-worker 1.0.116 → 1.0.121

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.
@@ -1507,10 +1507,24 @@ async function runAgentChatJob(job) {
1507
1507
  codingModelId: payload?.codingModelId ?? "",
1508
1508
  tools: payload?.tools ?? ["http_request"],
1509
1509
  maxSteps: payload?.maxSteps ?? 5,
1510
+ // Tool-invocation mode for the agent: "code" (default, python CodeAgent) or
1511
+ // "toolcall" (structured JSON tool calls). Passed through only; the API/web can
1512
+ // set payload.agentToolMode to A/B — default keeps current behavior.
1513
+ agentToolMode: payload?.agentToolMode ?? "code",
1510
1514
  // For the create_pdf tool: the worker key lets the Python tool ask the API
1511
1515
  // to presign an S3 upload, so no AWS creds ever live on the worker machine.
1512
1516
  apiBaseURL: apiBase,
1513
1517
  workerKey,
1518
+ // send_email tool: the user configures their own email API (e.g. a Lambda) in
1519
+ // web Settings. The tool is only registered when emailEnabled + URL + body
1520
+ // template are set, and always previews before sending (confirm required).
1521
+ emailEnabled: payload?.emailEnabled ?? false,
1522
+ emailApiUrl: payload?.emailApiUrl ?? "",
1523
+ emailApiMethod: payload?.emailApiMethod ?? "POST",
1524
+ emailApiHeaders: payload?.emailApiHeaders ?? "",
1525
+ emailBodyTemplate: payload?.emailBodyTemplate ?? "",
1526
+ emailFrom: payload?.emailFrom ?? "",
1527
+ emailAllowList: payload?.emailAllowList ?? "",
1514
1528
  });
1515
1529
  const timeoutMs = 300_000; // 5 min max for an agent run
1516
1530
 
@@ -65,6 +65,213 @@ def _http_request_impl(method, url, headers=None, body=None, timeout=25):
65
65
  return f"Error: {e}"
66
66
 
67
67
 
68
+ def _html_to_text(html_text, limit=4000):
69
+ """Strip HTML to readable plain text (zero-dep). Used by fetch_url so the weak
70
+ model receives prose instead of raw tags it cannot parse."""
71
+ import html as _html
72
+ text = html_text or ""
73
+ # Drop script/style/head/svg noise wholesale (incl. their content).
74
+ text = re.sub(r"(?is)<(script|style|head|noscript|svg|template)\b.*?</\1>", " ", text)
75
+ # Turn block-ending tags into newlines so document structure survives stripping.
76
+ text = re.sub(r"(?i)<(br|/p|/div|/li|/tr|/h[1-6]|/section|/article)\s*>", "\n", text)
77
+ # Remove all remaining tags.
78
+ text = re.sub(r"(?s)<[^>]+>", " ", text)
79
+ text = _html.unescape(text)
80
+ # Collapse runs of blank space but keep line breaks for readability.
81
+ lines = [re.sub(r"[ \t]{2,}", " ", ln).strip() for ln in text.splitlines()]
82
+ text = "\n".join(ln for ln in lines if ln)
83
+ if len(text) > limit:
84
+ text = text[:limit].rstrip() + "\n…[truncated]"
85
+ return text.strip()
86
+
87
+
88
+ def _fetch_page_impl(url, timeout=25, max_bytes=300000):
89
+ """GET a URL for reading. Returns (status:int|None, content_type:str, raw:bytes).
90
+
91
+ status None => transport error (raw holds the message). Reads more than
92
+ _http_request_impl (which caps at 2000 chars) so a page has enough text to read.
93
+ """
94
+ req = urllib.request.Request(url, method="GET", headers={
95
+ "User-Agent": "gonext-agent/1.0 (local API testing assistant)",
96
+ "Accept": "text/html,application/xhtml+xml,text/plain,*/*",
97
+ })
98
+ try:
99
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
100
+ ctype = (resp.headers.get("Content-Type") or "").lower()
101
+ return resp.status, ctype, resp.read(max_bytes)
102
+ except urllib.error.HTTPError as e:
103
+ return e.code, "", e.read(512)
104
+ except Exception as e: # noqa: BLE001
105
+ return None, "", str(e).encode()
106
+
107
+
108
+ def _calc_impl(expression):
109
+ """Safely evaluate an arithmetic expression via an AST whitelist (no eval()).
110
+
111
+ Accepts + - * / // % ** and () plus a small function/const allow-list. Normalizes
112
+ common natural phrasings ('15% of 80', '2^10', '×', '÷'). Returns a result string
113
+ or an 'Error: …' string — never raises.
114
+ """
115
+ import ast
116
+ import math
117
+ import operator
118
+ expr = (expression or "").strip()
119
+ if not expr:
120
+ return "Error: empty expression."
121
+ # Natural-language normalizations before parsing.
122
+ expr = re.sub(r"(\d+(?:\.\d+)?)\s*%\s*of\s+", r"(\1/100)*", expr, flags=re.IGNORECASE)
123
+ expr = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", expr)
124
+ expr = expr.replace("^", "**").replace("×", "*").replace("÷", "/").replace(",", "")
125
+
126
+ ops = {
127
+ ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
128
+ ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
129
+ ast.Mod: operator.mod, ast.Pow: operator.pow,
130
+ ast.USub: operator.neg, ast.UAdd: operator.pos,
131
+ }
132
+ funcs = {
133
+ "sqrt": math.sqrt, "pow": pow, "round": round, "abs": abs,
134
+ "floor": math.floor, "ceil": math.ceil, "log": math.log,
135
+ "log10": math.log10, "min": min, "max": max,
136
+ }
137
+ consts = {"pi": math.pi, "e": math.e, "tau": math.tau}
138
+
139
+ def _ev(node):
140
+ if isinstance(node, ast.Expression):
141
+ return _ev(node.body)
142
+ if isinstance(node, ast.Constant):
143
+ if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
144
+ return node.value
145
+ raise ValueError("only numbers allowed")
146
+ if isinstance(node, ast.BinOp) and type(node.op) in ops:
147
+ return ops[type(node.op)](_ev(node.left), _ev(node.right))
148
+ if isinstance(node, ast.UnaryOp) and type(node.op) in ops:
149
+ return ops[type(node.op)](_ev(node.operand))
150
+ if isinstance(node, ast.Name) and node.id in consts:
151
+ return consts[node.id]
152
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
153
+ and node.func.id in funcs and not node.keywords):
154
+ return funcs[node.func.id](*[_ev(a) for a in node.args])
155
+ raise ValueError("unsupported expression")
156
+
157
+ try:
158
+ value = _ev(ast.parse(expr, mode="eval"))
159
+ except ZeroDivisionError:
160
+ return "Error: division by zero."
161
+ except Exception as e: # noqa: BLE001
162
+ return f"Error: cannot evaluate '{expression}' ({e})."
163
+ if isinstance(value, float) and value.is_integer():
164
+ value = int(value)
165
+ return str(value)
166
+
167
+
168
+ def _pdf_reader_available():
169
+ """True if a PDF text-extraction library is importable on this worker."""
170
+ for lib in ("pypdf", "pdfminer.high_level", "PyPDF2"):
171
+ try:
172
+ __import__(lib)
173
+ return True
174
+ except Exception: # noqa: BLE001
175
+ continue
176
+ return False
177
+
178
+
179
+ # Probed once at import: gates whether extract_text_from_pdf is offered at all, so the
180
+ # weak model is never advertised a tool that cannot run on this machine.
181
+ _PDF_READ_AVAILABLE = _pdf_reader_available()
182
+
183
+
184
+ def _extract_pdf_text_impl(url, max_chars=6000, timeout=30, max_bytes=15_000_000):
185
+ """Download a PDF at `url` and return its extracted text (truncated), or an
186
+ 'Error:'/'not installed' string. Never raises. Tries pypdf then pdfminer then PyPDF2."""
187
+ import io
188
+ try:
189
+ req = urllib.request.Request(url, method="GET", headers={
190
+ "User-Agent": "gonext-agent/1.0 (local API testing assistant)",
191
+ "Accept": "application/pdf,*/*",
192
+ })
193
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
194
+ raw = resp.read(max_bytes)
195
+ except Exception as e: # noqa: BLE001
196
+ return f"Error: could not download {url}: {e}"
197
+ if not raw[:5].lstrip().startswith(b"%PDF"):
198
+ return (f"Error: {url} does not look like a PDF (no %PDF header). "
199
+ "Use fetch_url for web pages.")
200
+
201
+ text = None
202
+ # 1) pypdf (preferred; also handles the legacy PyPDF2 import name).
203
+ try:
204
+ try:
205
+ from pypdf import PdfReader
206
+ except ImportError:
207
+ from PyPDF2 import PdfReader # type: ignore
208
+ reader = PdfReader(io.BytesIO(raw))
209
+ parts = []
210
+ for page in reader.pages:
211
+ try:
212
+ parts.append(page.extract_text() or "")
213
+ except Exception: # noqa: BLE001 — skip unreadable pages, keep the rest
214
+ continue
215
+ text = "\n".join(p for p in parts if p).strip()
216
+ except ImportError:
217
+ text = None
218
+ except Exception as e: # noqa: BLE001
219
+ return f"Error: failed to read PDF ({e})."
220
+
221
+ # 2) pdfminer.six fallback.
222
+ if text is None:
223
+ try:
224
+ from pdfminer.high_level import extract_text as _pm_extract
225
+ text = (_pm_extract(io.BytesIO(raw)) or "").strip()
226
+ except ImportError:
227
+ return ("PDF reading is not installed on this worker. Ask the user to run "
228
+ "'pip install pypdf' and try again.")
229
+ except Exception as e: # noqa: BLE001
230
+ return f"Error: failed to read PDF ({e})."
231
+
232
+ if not text:
233
+ return f"{url}\n(The PDF has no extractable text — it may be scanned images.)"
234
+ if len(text) > max_chars:
235
+ text = text[:max_chars].rstrip() + "\n…[truncated]"
236
+ return f"{url}\n{text}"
237
+
238
+
239
+ # Affirmative confirmation tokens for send_email's two-step (preview → confirm) flow.
240
+ # A send only happens when one of these is in the CURRENT user message AND a prior
241
+ # assistant turn already showed a preview — so the weak model cannot self-confirm.
242
+ _EMAIL_CONFIRM = re.compile(
243
+ r"\b(confirm(?:ed)?|send it|send the (?:email|mail)|go ahead|approved?|"
244
+ r"yes\s*,?\s*send|do it|please send)\b",
245
+ re.IGNORECASE,
246
+ )
247
+
248
+
249
+ def _email_allowed(addr, allow):
250
+ """True if addr matches an allow-list entry — an exact address or a bare domain."""
251
+ a = (addr or "").lower()
252
+ dom = a.split("@")[-1]
253
+ for entry in allow:
254
+ e = entry.lstrip("@")
255
+ if a == entry or dom == e:
256
+ return True
257
+ return False
258
+
259
+
260
+ def _email_fill_template(template, mapping):
261
+ """Fill {{key}} placeholders in a JSON body template with JSON-escaped values, then
262
+ parse. Returns a dict/list on success, or an 'Error: …' string. JSON-escaping keeps
263
+ the body valid even when subject/body contain quotes or newlines."""
264
+ out = template or ""
265
+ for key, val in mapping.items():
266
+ esc = json.dumps("" if val is None else str(val))[1:-1]
267
+ out = out.replace("{{%s}}" % key, esc).replace("{{ %s }}" % key, esc)
268
+ try:
269
+ return json.loads(out)
270
+ except Exception as e: # noqa: BLE001
271
+ return (f"Error: the email body template is not valid JSON after filling in the "
272
+ f"values ({e}). Fix the template in Settings.")
273
+
274
+
68
275
  def _get_json(url, timeout=15):
69
276
  """GET a URL and parse the JSON body. Returns dict/list, or None on failure.
70
277
 
@@ -231,6 +438,9 @@ _AGENT_KEYWORDS = re.compile(
231
438
  r"|download|scrape|crawl"
232
439
  r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
233
440
  r"|date|time|what\s+day|what\s+time"
441
+ r"|read\s+(?:this\s+|that\s+|the\s+)?(?:page|article|url|link)|open\s+(?:this\s+|that\s+|the\s+)?(?:url|link|page)|contents?\s+of"
442
+ r"|calculate|compute|convert|multiply|divide|percentage|percent|average|how\s+much\s+is"
443
+ r"|email|e-mail|send\s+(?:an?\s+)?(?:email|mail)|mail\s+to"
234
444
  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"
235
445
  r")\b",
236
446
  re.IGNORECASE,
@@ -505,11 +715,12 @@ def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
505
715
 
506
716
 
507
717
  def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
508
- model_id: str) -> str:
718
+ model_id: str, instruction: str = "") -> str:
509
719
  """Use the model to clean/structure raw text into well-formed Markdown for the PDF.
510
720
 
511
- Falls back to the original text (lightly wrapped) if the model call fails, so a
512
- PDF is still produced.
721
+ When `instruction` is set (a follow-up edit like "fill the teams to the schedule"),
722
+ the model applies that change to `text` while reformatting. Falls back to the
723
+ original text (lightly wrapped) if the model call fails, so a PDF is still produced.
513
724
  """
514
725
  fallback = text or ""
515
726
  if title and not fallback.lstrip().startswith("#"):
@@ -518,20 +729,28 @@ def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
518
729
  from openai import OpenAI
519
730
  client = OpenAI(base_url=base_url, api_key=api_key or "local",
520
731
  max_retries=0, timeout=60)
732
+ system_content = (
733
+ "You format raw text/data into clean Markdown for a PDF document. "
734
+ "Add a single top-level '# Title', sensible headings, bullet lists, "
735
+ "and tables where appropriate. Do NOT invent facts, do NOT add "
736
+ "commentary, and preserve all numbers and wording. Output ONLY the "
737
+ "Markdown — no code fences, no explanations."
738
+ )
739
+ if instruction:
740
+ system_content += (
741
+ " Apply the user's requested change to the content before formatting; "
742
+ "keep everything else intact."
743
+ )
744
+ user_content = (
745
+ (f"Title: {title}\n\n" if title else "")
746
+ + (f"Requested change: {instruction}\n\n" if instruction else "")
747
+ + f"Content to format:\n{text}"
748
+ )
521
749
  resp = client.chat.completions.create(
522
750
  model=model_id,
523
751
  messages=[
524
- {"role": "system", "content": (
525
- "You format raw text/data into clean Markdown for a PDF document. "
526
- "Add a single top-level '# Title', sensible headings, bullet lists, "
527
- "and tables where appropriate. Do NOT invent facts, do NOT add "
528
- "commentary, and preserve all numbers and wording. Output ONLY the "
529
- "Markdown — no code fences, no explanations."
530
- )},
531
- {"role": "user", "content": (
532
- (f"Title: {title}\n\n" if title else "")
533
- + f"Content to format:\n{text}"
534
- )},
752
+ {"role": "system", "content": system_content},
753
+ {"role": "user", "content": user_content},
535
754
  ],
536
755
  max_tokens=1500,
537
756
  temperature=0.2,
@@ -555,6 +774,51 @@ _PDF_INTENT = re.compile(
555
774
  re.IGNORECASE,
556
775
  )
557
776
 
777
+ # Follow-up phrasings that reference an *existing / target* PDF — e.g. "in the pdf
778
+ # file", "add the teams to the pdf", "put it in the pdf", "update the pdf", "as a pdf".
779
+ # These carry no "create … pdf" verb so _PDF_INTENT misses them, yet they still mean
780
+ # "(re)generate the PDF" — previously they fell through to the CodeAgent and crashed
781
+ # on create_pdf(text="…long literal…"). The document body comes from history (A2).
782
+ _PDF_FOLLOWUP = re.compile(
783
+ r"\b(in|into|on|to)\s+(the\s+|a\s+|this\s+|that\s+)?pdf\b"
784
+ r"|\b(add|include|put|fill|insert|append|update|regenerate|remake|redo)\b[\s\S]{0,40}\bpdf\b"
785
+ r"|\bpdf\b[\s\S]{0,40}\b(add|include|fill|update|insert|append)\b"
786
+ r"|\bas\s+(a\s+)?pdf\b"
787
+ r"|\bthe\s+pdf\s+file\b",
788
+ re.IGNORECASE,
789
+ )
790
+
791
+ # Interrogatives ABOUT an existing PDF ("what's in the pdf?") must NOT trigger
792
+ # generation — only imperative (re)build requests should.
793
+ _PDF_QUESTION = re.compile(
794
+ r"^\s*(what|why|how|where|who|which|when|does|do|is|are|can|could|should|would)\b[\s\S]*\?\s*$",
795
+ re.IGNORECASE,
796
+ )
797
+
798
+ # READING an existing PDF ("summarize this pdf", "extract text from the pdf") is the job
799
+ # of extract_text_from_pdf, NOT the create fast-path. These read verbs must veto _wants_pdf
800
+ # so a "summarize the pdf file" request doesn't get hijacked into (re)generating a PDF.
801
+ # Deliberately excludes add/put/fill/update (task #6 follow-ups still (re)generate).
802
+ _PDF_READ_INTENT = re.compile(
803
+ r"\b(read|summari[sz]e|extract|analy[sz]e|parse|review|scan)\b[\s\S]{0,40}\bpdf\b"
804
+ r"|\bpdf\b[\s\S]{0,40}\b(say|says|contain|contains|about|content)\b",
805
+ re.IGNORECASE,
806
+ )
807
+
808
+
809
+ def _wants_pdf(text: str) -> bool:
810
+ """True when the user is asking us to (re)generate a PDF — fresh request or a
811
+ follow-up referencing a target PDF — but NOT when merely asking about one, and NOT
812
+ when asking us to READ/summarize an existing PDF (that's extract_text_from_pdf)."""
813
+ t = (text or "").strip()
814
+ if not t:
815
+ return False
816
+ if _PDF_QUESTION.match(t):
817
+ return False
818
+ if _PDF_READ_INTENT.search(t):
819
+ return False
820
+ return bool(_PDF_INTENT.search(t) or _PDF_FOLLOWUP.search(t))
821
+
558
822
 
559
823
  def _extract_pdf_doc_text(user_text: str) -> str:
560
824
  """Pull the document body out of a 'create a PDF from this text "…"' message.
@@ -713,8 +977,41 @@ def run_agent_chat(cfg):
713
977
  _log(f" history[{j}]: {ln[:240]}")
714
978
  _log(f"current task (latest user message): {task_text.rsplit('Current task: ', 1)[-1][:240]!r}")
715
979
 
980
+ # ---- send_email config (per-user email API, e.g. a Lambda the user set up) ----
981
+ email_enabled = bool(cfg.get("emailEnabled"))
982
+ email_api_url = (cfg.get("emailApiUrl") or "").strip()
983
+ email_api_method = ((cfg.get("emailApiMethod") or "POST").strip().upper()) or "POST"
984
+ email_api_headers_raw = (cfg.get("emailApiHeaders") or "").strip()
985
+ email_body_template = (cfg.get("emailBodyTemplate") or "").strip()
986
+ email_from = (cfg.get("emailFrom") or "").strip()
987
+ email_allow = [a for a in re.split(r"[,\s]+", (cfg.get("emailAllowList") or "").strip().lower()) if a]
988
+ # Advertised + registered ONLY when fully configured AND enabled — an outward
989
+ # action must never be silently available (same gating as the PDF reader).
990
+ _EMAIL_AVAILABLE = bool(email_enabled and email_api_url and email_body_template)
991
+
992
+ def _prior_email_preview() -> bool:
993
+ """True if an earlier assistant turn showed a send_email preview awaiting confirm."""
994
+ for m in reversed(messages[:last_user_idx]):
995
+ if m.get("role") == "assistant":
996
+ c = m.get("content") or ""
997
+ if "📧" in c and "About to email" in c:
998
+ return True
999
+ return False
1000
+
1001
+ def _email_confirm_pending() -> bool:
1002
+ """A send is authorized only when the CURRENT message confirms AND a preview
1003
+ was already shown — the weak model cannot self-confirm."""
1004
+ return bool(_EMAIL_AVAILABLE and _EMAIL_CONFIRM.search(latest_user_text or "")
1005
+ and _prior_email_preview())
1006
+
716
1007
  # Route: ask the model if this task needs HTTP tool use.
717
1008
  needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
1009
+ # A pending email awaiting 'confirm' must reach the agent even though a bare
1010
+ # 'confirm' / 'send it' is not a network keyword — otherwise the send never fires.
1011
+ if not needs_agent and _email_confirm_pending():
1012
+ _log("router → YES (email confirm pending)")
1013
+ _emit({"type": "step", "text": "→ Agent mode (email confirm)"})
1014
+ needs_agent = True
718
1015
 
719
1016
  if not needs_agent:
720
1017
  _log("router: plain chat (no HTTP needed)")
@@ -734,14 +1031,39 @@ def run_agent_chat(cfg):
734
1031
  # the tool ever runs. When the user clearly wants a PDF, extract their real text and
735
1032
  # run format → render → upload directly. No model string-echoing, and 1 fewer call.
736
1033
  # (pdf_api_base / pdf_worker_key are read once near the top of run_agent_chat.)
737
- if _PDF_INTENT.search(latest_user_text or ""):
1034
+ if _wants_pdf(latest_user_text):
738
1035
  doc_text = _extract_pdf_doc_text(latest_user_text)
1036
+ # A2: a follow-up like "in the pdf file" / "add the teams to the pdf" carries
1037
+ # no document body — it refers to the last thing the assistant produced. Pull
1038
+ # the most recent substantial assistant turn as the base document and treat the
1039
+ # current user message as an edit instruction the formatter should apply.
1040
+ edit_instruction = ""
1041
+ if len(doc_text.strip()) < 40:
1042
+ prior_doc = ""
1043
+ for m in reversed(messages[:last_user_idx]):
1044
+ if m.get("role") != "assistant":
1045
+ continue
1046
+ cand = think_re.sub("", (m.get("content") or "")).strip()
1047
+ low = cand.lower()
1048
+ # Skip our own PDF-ready confirmations / bare links — not document bodies.
1049
+ if cand.startswith("✅") or low.startswith("http") or "amazonaws.com" in low:
1050
+ continue
1051
+ if len(cand) >= 40:
1052
+ prior_doc = cand
1053
+ break
1054
+ if prior_doc:
1055
+ edit_instruction = latest_user_text.strip()
1056
+ doc_text = prior_doc
739
1057
  doc_title = _derive_pdf_title(doc_text)
740
- _log(f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}")
1058
+ _log(
1059
+ f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}"
1060
+ + (f" edit={edit_instruction[:60]!r}" if edit_instruction else "")
1061
+ )
741
1062
 
742
1063
  _emit({"type": "step", "text": "Formatting document…"})
743
1064
  markdown_text = _format_text_for_pdf(
744
- doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id
1065
+ doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id,
1066
+ instruction=edit_instruction,
745
1067
  )
746
1068
 
747
1069
  _emit({"type": "step", "text": "Rendering PDF…"})
@@ -784,19 +1106,53 @@ def run_agent_chat(cfg):
784
1106
  # fabricate URLs/responses, and always terminate with final_answer().
785
1107
  from datetime import datetime as _dt_now
786
1108
  now_str = _dt_now.now().astimezone().strftime("%A, %d %B %Y, %H:%M %Z")
1109
+ # The PDF reader and send_email are only advertised (and registered) when available
1110
+ # here — never offer the model a tool it cannot run. Entry numbers are dynamic so the
1111
+ # list reads cleanly whether 6, 7, or 8 tools are present.
1112
+ _tool_num = 6
1113
+ _pdf_num = 0
1114
+ if _PDF_READ_AVAILABLE:
1115
+ _tool_num += 1
1116
+ _pdf_num = _tool_num
1117
+ _email_num = 0
1118
+ if _EMAIL_AVAILABLE:
1119
+ _tool_num += 1
1120
+ _email_num = _tool_num
1121
+ _tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
1122
+ _pdf_read_tool_line = (
1123
+ f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
1124
+ "at a URL. This does NOT create a PDF (use create_pdf for that).\n"
1125
+ ) if _PDF_READ_AVAILABLE else ""
1126
+ _pdf_read_choose_line = (
1127
+ "- 'read'/'summarize'/'what does the PDF say' for an existing PDF URL -> "
1128
+ "extract_text_from_pdf(url).\n"
1129
+ ) if _PDF_READ_AVAILABLE else ""
1130
+ _email_tool_line = (
1131
+ f" {_email_num}. send_email(to, subject, body) — send an email via the configured "
1132
+ "email API. It PREVIEWS first and only sends after the user replies 'confirm'.\n"
1133
+ ) if _EMAIL_AVAILABLE else ""
1134
+ _email_choose_line = (
1135
+ "- 'email …' / 'send an email to …' -> send_email(to, subject, body) "
1136
+ "(previews first, then sends on confirm).\n"
1137
+ ) if _EMAIL_AVAILABLE else ""
787
1138
  tool_hint = (
788
1139
  "YOU HAVE EXACTLY ONE TURN. Read the TASK above. In a single code block, call "
789
1140
  "the ONE tool that fits THAT task, then pass its result to final_answer(). "
790
1141
  "Do not plan multiple steps.\n\n"
791
- "You have FOUR tools:\n"
1142
+ f"You have {_tool_count_word} tools:\n"
792
1143
  " 1. http_request(method, url, headers='', body='', username='', password='') — "
793
1144
  "call a SPECIFIC known API/URL.\n"
794
1145
  " 2. web_search(query) — look up facts about a person, place, thing, or topic "
795
1146
  "when you do NOT already have a real URL. Returns a summary + source.\n"
796
- f" 3. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
1147
+ " 3. fetch_url(url) — READ the full text of a specific web page (e.g. a link the "
1148
+ "user gave, or a source URL from web_search). Returns clean text, not raw HTML.\n"
1149
+ " 4. calculate(expression) — do math: arithmetic, percentages, powers "
1150
+ "(e.g. '15% of 80', '(3+4)*2'). ALWAYS use this instead of computing yourself.\n"
1151
+ f" 5. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
797
1152
  "Use this ONLY when the task explicitly asks for the date or time.\n"
798
- " 4. create_pdf(text, title='') — make a PDF document from text/data and return a "
1153
+ " 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
799
1154
  "download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
1155
+ + _pdf_read_tool_line + _email_tool_line +
800
1156
  "\n"
801
1157
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
802
1158
  "\n"
@@ -814,7 +1170,10 @@ def run_agent_chat(cfg):
814
1170
  "- ONLY a date/time question (e.g. 'what is the date today') -> get_current_datetime().\n"
815
1171
  "- 'who is' / 'what is' / 'tell me about' / a person / place / topic / general "
816
1172
  "knowledge -> web_search(query).\n"
1173
+ "- 'read this page' / 'open this link' / 'what does <URL> say' -> fetch_url(url).\n"
1174
+ "- any arithmetic, percentage, or 'how much is' math -> calculate(expression).\n"
817
1175
  "- 'create/make/generate/export a PDF' of some text/data -> create_pdf(text, title).\n"
1176
+ + _pdf_read_choose_line + _email_choose_line +
818
1177
  "- A specific known API/URL was given -> http_request().\n"
819
1178
  "\n"
820
1179
  "RULES:\n"
@@ -941,6 +1300,135 @@ def run_agent_chat(cfg):
941
1300
  _last_obs["text"] = result
942
1301
  return result
943
1302
 
1303
+ @tool
1304
+ def fetch_url(url: str) -> str:
1305
+ """Fetch a specific web page and return its readable text (HTML stripped).
1306
+
1307
+ Use this to READ the actual contents of a specific URL — e.g. a page found by
1308
+ web_search, or a link the user gave — when a summary is not enough. Returns clean
1309
+ plain text, not raw HTML. For JSON APIs use http_request instead.
1310
+
1311
+ Args:
1312
+ url: The full URL of the page to read, e.g. 'https://example.com/article'.
1313
+ """
1314
+ _emit({"type": "step", "text": f"Reading page → {url[:80]}"})
1315
+ status, ctype, raw = _fetch_page_impl(url)
1316
+ if status is None:
1317
+ msg = f"Error: could not fetch {url}: {raw.decode('utf-8', 'replace')[:200]}"
1318
+ _log(f"fetch_url error {url} → {msg[:80]}")
1319
+ _last_obs["text"] = msg
1320
+ return msg
1321
+ if isinstance(status, int) and status >= 400:
1322
+ msg = f"Error: {url} returned HTTP {status}. Try a different URL or use web_search()."
1323
+ _log(f"fetch_url {url} → HTTP {status}")
1324
+ _last_obs["text"] = msg
1325
+ return msg
1326
+ # PDFs / binaries don't strip to useful text.
1327
+ if "application/pdf" in ctype or raw[:5].lstrip().startswith(b"%PDF"):
1328
+ msg = (f"{url} is a PDF, not an HTML page — fetch_url cannot read PDFs. "
1329
+ "Tell the user this needs a PDF-reading tool.")
1330
+ _log(f"fetch_url {url} → PDF, cannot read")
1331
+ _last_obs["text"] = msg
1332
+ return msg
1333
+ text = _html_to_text(raw.decode("utf-8", errors="replace")) or "(page had no readable text)"
1334
+ out = f"{url}\n{text}"
1335
+ _log(f"fetch_url {url} → {len(text)} chars")
1336
+ _last_obs["text"] = out
1337
+ return out
1338
+
1339
+ @tool
1340
+ def calculate(expression: str) -> str:
1341
+ """Evaluate a math expression and return the numeric result. Do NOT do the math
1342
+ yourself — always call this for arithmetic, percentages, powers, or conversions.
1343
+
1344
+ Supports + - * / // % ** and parentheses, plus sqrt/pow/round/abs/floor/ceil/log
1345
+ and the constants pi and e. Understands '15% of 80' and '2^10'.
1346
+
1347
+ Args:
1348
+ expression: The math to evaluate, e.g. '(3+4)*2', '2**10', '15% of 80'.
1349
+ """
1350
+ _emit({"type": "step", "text": f"Calculating → {expression[:80]}"})
1351
+ result = _calc_impl(expression)
1352
+ _log(f"calculate({expression!r}) → {result[:80]}")
1353
+ _last_obs["text"] = result
1354
+ return result
1355
+
1356
+ @tool
1357
+ def send_email(to: str, subject: str, body: str) -> str:
1358
+ """Send an email through the configured email API. Asks for confirmation FIRST.
1359
+
1360
+ The FIRST time you call this it only PREVIEWS the email and does NOT send — tell
1361
+ the user to reply 'confirm' to actually send it. Use ONLY when the user explicitly
1362
+ asks to email or send a message to someone.
1363
+
1364
+ Args:
1365
+ to: Recipient email address.
1366
+ subject: The email subject line.
1367
+ body: The email body text.
1368
+ """
1369
+ to_addr = (to or "").strip()
1370
+ if "@" not in to_addr or " " in to_addr:
1371
+ msg = f"Error: '{to_addr}' is not a valid recipient email address."
1372
+ _last_obs["text"] = msg
1373
+ return msg
1374
+ if email_allow and not _email_allowed(to_addr, email_allow):
1375
+ msg = (f"Error: {to_addr} is not in the allowed recipients list — refusing to "
1376
+ "send. Add it to the allow-list in Settings if this is intended.")
1377
+ _log(f"send_email blocked by allow-list: {to_addr}")
1378
+ _last_obs["text"] = msg
1379
+ return msg
1380
+ preview = (
1381
+ f"📧 About to email {to_addr}\n"
1382
+ f"Subject: {subject or '(no subject)'}\n\n"
1383
+ f"{body or '(no body)'}\n\n"
1384
+ "Reply 'confirm' to send this, or tell me what to change."
1385
+ )
1386
+ # Send ONLY when the current user message confirms AND a preview was already
1387
+ # shown — otherwise just preview (never send on the first call).
1388
+ if not _email_confirm_pending():
1389
+ _emit({"type": "step", "text": f"Email preview → {to_addr} (awaiting confirm)"})
1390
+ _log(f"send_email preview (awaiting confirm) → {to_addr}")
1391
+ _last_obs["text"] = preview
1392
+ return preview
1393
+ filled = _email_fill_template(email_body_template, {
1394
+ "to": to_addr, "subject": subject or "", "body": body or "", "from": email_from,
1395
+ })
1396
+ if isinstance(filled, str): # template error
1397
+ _last_obs["text"] = filled
1398
+ return filled
1399
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
1400
+ if email_api_headers_raw:
1401
+ try:
1402
+ headers.update(json.loads(email_api_headers_raw))
1403
+ except Exception: # noqa: BLE001
1404
+ pass
1405
+ _emit({"type": "step", "text": f"Sending email → {to_addr}"})
1406
+ result = _http_request_impl(email_api_method, email_api_url, headers, json.dumps(filled))
1407
+ first = result.split("\n", 1)[0] if result else "no response"
1408
+ if result.startswith("HTTP 2"):
1409
+ out = f"✅ Email sent to {to_addr} (Subject: {subject or '(no subject)'})."
1410
+ else:
1411
+ out = f"Email send failed ({first}). Check the email API settings."
1412
+ _log(f"send_email {to_addr} → {first}")
1413
+ _last_obs["text"] = out
1414
+ return out
1415
+
1416
+ @tool
1417
+ def extract_text_from_pdf(url: str) -> str:
1418
+ """Read an existing PDF at a URL and return its extracted text.
1419
+
1420
+ Use this to READ or SUMMARIZE a PDF the user links to. This does NOT create a
1421
+ PDF — use create_pdf for that. Returns the PDF's text (truncated), not a link.
1422
+
1423
+ Args:
1424
+ url: The full URL of the PDF file, e.g. 'https://example.com/report.pdf'.
1425
+ """
1426
+ _emit({"type": "step", "text": f"Reading PDF → {url[:80]}"})
1427
+ result = _extract_pdf_text_impl(url)
1428
+ _log(f"extract_text_from_pdf {url} → {result[:80]}")
1429
+ _last_obs["text"] = result
1430
+ return result
1431
+
944
1432
  @tool
945
1433
  def create_pdf(text: str, title: str = "") -> str:
946
1434
  """Create a PDF document from text/data and return a download link.
@@ -1063,19 +1551,49 @@ def run_agent_chat(cfg):
1063
1551
  _log(f"MODEL REQUEST log error: {e}")
1064
1552
  return ck
1065
1553
 
1554
+ # Tool-invocation mode: "code" (default) makes the model author a Python code
1555
+ # block (CodeAgent); "toolcall" makes it emit a structured JSON tool call
1556
+ # (ToolCallingAgent), which sidesteps the unterminated-string-literal failures weak
1557
+ # models hit when forced to echo long payloads as Python literals. Default is
1558
+ # unchanged ("code"); flip via cfg.agentToolMode to A/B without touching the
1559
+ # working paths (datetime / web_search / http_request).
1560
+ agent_tool_mode = (cfg.get("agentToolMode") or "code").strip().lower()
1561
+ _AgentBase = CodeAgent
1562
+ if agent_tool_mode == "toolcall":
1563
+ try:
1564
+ from smolagents import ToolCallingAgent
1565
+ _AgentBase = ToolCallingAgent
1566
+ _log("agent tool mode: toolcall (structured JSON tool calls)")
1567
+ except Exception as e: # noqa: BLE001
1568
+ _log(f"ToolCallingAgent unavailable ({e}); falling back to CodeAgent")
1569
+ else:
1570
+ _log("agent tool mode: code (python CodeAgent)")
1571
+
1066
1572
  # Single-shot agent: if the one model call doesn't end in final_answer(),
1067
1573
  # smolagents would normally make an EXTRA model call (provide_final_answer) to
1068
1574
  # synthesize one. We override that to return the last tool observation
1069
1575
  # deterministically — keeping the agent to EXACTLY ONE model call, and never
1070
1576
  # corrupting exact tool output (dates/numbers) the way a weak model would.
1071
- class _SingleShotAgent(CodeAgent):
1577
+ class _SingleShotAgent(_AgentBase):
1072
1578
  def provide_final_answer(self, task, *args, **kwargs):
1073
1579
  from smolagents.models import ChatMessage, MessageRole
1074
1580
  text = (_last_obs.get("text") or "").strip()
1075
- if not text:
1076
- text = ("I couldn't complete that in one step. Please rephrase, or give "
1077
- "a specific URL/API to call.")
1078
- _log(f"single-shot fallback (no model call) → {text[:80]}")
1581
+ if text:
1582
+ _log(f"single-shot fallback (last tool obs) {text[:80]}")
1583
+ else:
1584
+ # No usable tool output (e.g. the single code block failed to parse).
1585
+ # Don't dead-end on a canned apology — answer from the conversation.
1586
+ _log("single-shot fallback → plain reply over conversation")
1587
+ try:
1588
+ text = _plain_reply(
1589
+ messages, agent_base_url, agent_api_key, agent_model_id
1590
+ ).strip()
1591
+ except Exception as e: # noqa: BLE001
1592
+ _log(f"plain-reply fallback error: {e}")
1593
+ text = ""
1594
+ if not text:
1595
+ text = ("I couldn't complete that in one step. Please rephrase, or "
1596
+ "give a specific URL/API to call.")
1079
1597
  return ChatMessage(role=MessageRole.ASSISTANT, content=text)
1080
1598
 
1081
1599
  try:
@@ -1084,14 +1602,27 @@ def run_agent_chat(cfg):
1084
1602
  api_base=coding_base_url,
1085
1603
  api_key=agent_api_key,
1086
1604
  )
1087
- agent = _SingleShotAgent(
1088
- tools=[http_request, web_search, get_current_datetime, create_pdf],
1605
+ agent_tools = [http_request, web_search, fetch_url, calculate,
1606
+ get_current_datetime, create_pdf]
1607
+ # Only register the PDF reader / email tools when available (kept in sync with
1608
+ # the gated hint entries above so the model never sees a tool it can't run).
1609
+ if _PDF_READ_AVAILABLE:
1610
+ agent_tools.append(extract_text_from_pdf)
1611
+ if _EMAIL_AVAILABLE:
1612
+ agent_tools.append(send_email)
1613
+ agent_kwargs = dict(
1614
+ tools=agent_tools,
1089
1615
  model=model,
1090
1616
  max_steps=max_steps,
1091
1617
  step_callbacks=[step_callback],
1092
- executor_kwargs={"timeout_seconds": 60},
1093
- additional_authorized_imports=["json", "base64", "urllib", "urllib.request", "urllib.error"],
1094
1618
  )
1619
+ if _AgentBase is CodeAgent:
1620
+ # Only the CodeAgent runs a Python executor; ToolCallingAgent takes neither.
1621
+ agent_kwargs["executor_kwargs"] = {"timeout_seconds": 60}
1622
+ agent_kwargs["additional_authorized_imports"] = [
1623
+ "json", "base64", "urllib", "urllib.request", "urllib.error"
1624
+ ]
1625
+ agent = _SingleShotAgent(**agent_kwargs)
1095
1626
  with contextlib.redirect_stdout(sys.stderr):
1096
1627
  result = agent.run(task_with_hint)
1097
1628
  # Deterministic final formatting — NO summarizer model call. The agent's
@@ -1104,8 +1635,15 @@ def run_agent_chat(cfg):
1104
1635
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
1105
1636
  _emit({"type": "final", "text": final_text})
1106
1637
  except Exception as e: # noqa: BLE001
1107
- _log(f"agent error: {e}")
1108
- _emit({"type": "final", "text": f"[Agent error: {e}]"})
1638
+ # An unexpected failure inside the agent loop should still return a useful
1639
+ # answer from the conversation rather than surfacing a raw error to the user.
1640
+ _log(f"agent error: {e} — degrading to plain reply")
1641
+ try:
1642
+ fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
1643
+ except Exception as e2: # noqa: BLE001
1644
+ _log(f"plain-reply degrade error: {e2}")
1645
+ fallback = ""
1646
+ _emit({"type": "final", "text": fallback or f"[Agent error: {e}]"})
1109
1647
 
1110
1648
 
1111
1649
  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.116",
3
+ "version": "1.0.121",
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",