@tiens.nguyen/gonext-local-worker 1.0.117 → 1.0.123

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.
@@ -1506,11 +1506,25 @@ async function runAgentChatJob(job) {
1506
1506
  codingBaseURL: payload?.codingBaseURL ?? "",
1507
1507
  codingModelId: payload?.codingModelId ?? "",
1508
1508
  tools: payload?.tools ?? ["http_request"],
1509
- maxSteps: payload?.maxSteps ?? 5,
1509
+ maxSteps: payload?.maxSteps ?? 8,
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
 
@@ -11,7 +11,7 @@ Reads on stdin:
11
11
  "codingBaseURL": str, # optional: dedicated coding/reasoning model for the
12
12
  "codingModelId": str, # CodeAgent's tool-use loop; empty = reuse agentModelId
13
13
  "tools": ["http_request"], # v1: only http_request
14
- "maxSteps": int # default 10
14
+ "maxSteps": int # multi-step ReAct budget; default 8
15
15
  }
16
16
 
17
17
  Emits NDJSON lines on stdout:
@@ -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,
@@ -360,6 +570,27 @@ def _strip_tool_tags(text: str) -> str:
360
570
  return "\n".join(out).strip()
361
571
 
362
572
 
573
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
574
+
575
+
576
+ def _strip_think(text: str) -> str:
577
+ """Remove Qwen3-style <think>…</think> reasoning traces from a model reply.
578
+
579
+ Reasoning models (e.g. Qwen3) emit their chain-of-thought inside <think> tags
580
+ before the actual answer/code. We keep thinking ON (it improves the output) but
581
+ strip the trace before smolagents parses the step, so the code/final_answer parser
582
+ only sees the real content — and the trace never leaks to the user. Handles a
583
+ dangling </think> too (some chat templates open <think> server-side)."""
584
+ if not text:
585
+ return text
586
+ text = _THINK_BLOCK.sub("", text)
587
+ low = text.lower()
588
+ if "</think>" in low and "<think>" not in low:
589
+ # Template opened the think tag for the model; drop everything up to the close.
590
+ text = text[low.find("</think>") + len("</think>"):]
591
+ return text.strip()
592
+
593
+
363
594
  _PDF_INSTALL_HINT = (
364
595
  "PDF engine not installed on the worker. On macOS:\n"
365
596
  " brew install pango libffi\n"
@@ -505,11 +736,12 @@ def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
505
736
 
506
737
 
507
738
  def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
508
- model_id: str) -> str:
739
+ model_id: str, instruction: str = "") -> str:
509
740
  """Use the model to clean/structure raw text into well-formed Markdown for the PDF.
510
741
 
511
- Falls back to the original text (lightly wrapped) if the model call fails, so a
512
- PDF is still produced.
742
+ When `instruction` is set (a follow-up edit like "fill the teams to the schedule"),
743
+ the model applies that change to `text` while reformatting. Falls back to the
744
+ original text (lightly wrapped) if the model call fails, so a PDF is still produced.
513
745
  """
514
746
  fallback = text or ""
515
747
  if title and not fallback.lstrip().startswith("#"):
@@ -518,20 +750,28 @@ def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
518
750
  from openai import OpenAI
519
751
  client = OpenAI(base_url=base_url, api_key=api_key or "local",
520
752
  max_retries=0, timeout=60)
753
+ system_content = (
754
+ "You format raw text/data into clean Markdown for a PDF document. "
755
+ "Add a single top-level '# Title', sensible headings, bullet lists, "
756
+ "and tables where appropriate. Do NOT invent facts, do NOT add "
757
+ "commentary, and preserve all numbers and wording. Output ONLY the "
758
+ "Markdown — no code fences, no explanations."
759
+ )
760
+ if instruction:
761
+ system_content += (
762
+ " Apply the user's requested change to the content before formatting; "
763
+ "keep everything else intact."
764
+ )
765
+ user_content = (
766
+ (f"Title: {title}\n\n" if title else "")
767
+ + (f"Requested change: {instruction}\n\n" if instruction else "")
768
+ + f"Content to format:\n{text}"
769
+ )
521
770
  resp = client.chat.completions.create(
522
771
  model=model_id,
523
772
  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
- )},
773
+ {"role": "system", "content": system_content},
774
+ {"role": "user", "content": user_content},
535
775
  ],
536
776
  max_tokens=1500,
537
777
  temperature=0.2,
@@ -555,6 +795,51 @@ _PDF_INTENT = re.compile(
555
795
  re.IGNORECASE,
556
796
  )
557
797
 
798
+ # Follow-up phrasings that reference an *existing / target* PDF — e.g. "in the pdf
799
+ # file", "add the teams to the pdf", "put it in the pdf", "update the pdf", "as a pdf".
800
+ # These carry no "create … pdf" verb so _PDF_INTENT misses them, yet they still mean
801
+ # "(re)generate the PDF" — previously they fell through to the CodeAgent and crashed
802
+ # on create_pdf(text="…long literal…"). The document body comes from history (A2).
803
+ _PDF_FOLLOWUP = re.compile(
804
+ r"\b(in|into|on|to)\s+(the\s+|a\s+|this\s+|that\s+)?pdf\b"
805
+ r"|\b(add|include|put|fill|insert|append|update|regenerate|remake|redo)\b[\s\S]{0,40}\bpdf\b"
806
+ r"|\bpdf\b[\s\S]{0,40}\b(add|include|fill|update|insert|append)\b"
807
+ r"|\bas\s+(a\s+)?pdf\b"
808
+ r"|\bthe\s+pdf\s+file\b",
809
+ re.IGNORECASE,
810
+ )
811
+
812
+ # Interrogatives ABOUT an existing PDF ("what's in the pdf?") must NOT trigger
813
+ # generation — only imperative (re)build requests should.
814
+ _PDF_QUESTION = re.compile(
815
+ r"^\s*(what|why|how|where|who|which|when|does|do|is|are|can|could|should|would)\b[\s\S]*\?\s*$",
816
+ re.IGNORECASE,
817
+ )
818
+
819
+ # READING an existing PDF ("summarize this pdf", "extract text from the pdf") is the job
820
+ # of extract_text_from_pdf, NOT the create fast-path. These read verbs must veto _wants_pdf
821
+ # so a "summarize the pdf file" request doesn't get hijacked into (re)generating a PDF.
822
+ # Deliberately excludes add/put/fill/update (task #6 follow-ups still (re)generate).
823
+ _PDF_READ_INTENT = re.compile(
824
+ r"\b(read|summari[sz]e|extract|analy[sz]e|parse|review|scan)\b[\s\S]{0,40}\bpdf\b"
825
+ r"|\bpdf\b[\s\S]{0,40}\b(say|says|contain|contains|about|content)\b",
826
+ re.IGNORECASE,
827
+ )
828
+
829
+
830
+ def _wants_pdf(text: str) -> bool:
831
+ """True when the user is asking us to (re)generate a PDF — fresh request or a
832
+ follow-up referencing a target PDF — but NOT when merely asking about one, and NOT
833
+ when asking us to READ/summarize an existing PDF (that's extract_text_from_pdf)."""
834
+ t = (text or "").strip()
835
+ if not t:
836
+ return False
837
+ if _PDF_QUESTION.match(t):
838
+ return False
839
+ if _PDF_READ_INTENT.search(t):
840
+ return False
841
+ return bool(_PDF_INTENT.search(t) or _PDF_FOLLOWUP.search(t))
842
+
558
843
 
559
844
  def _extract_pdf_doc_text(user_text: str) -> str:
560
845
  """Pull the document body out of a 'create a PDF from this text "…"' message.
@@ -636,11 +921,18 @@ def run_agent_chat(cfg):
636
921
  else:
637
922
  coding_base_url = agent_base_url
638
923
  coding_model_id = agent_model_id
639
- # Strict single-shot: exactly ONE agent model call per message. The single code
640
- # block must call a tool AND final_answer together no multi-step ReAct loop.
641
- # If the model fails to call final_answer, the max-steps fallback below returns
642
- # the last tool observation deterministically (no extra model call).
643
- max_steps = 1
924
+ # Multi-step ReAct loop (thinking agent): the agent may take several
925
+ # Thought tool Observation steps and then call final_answer() itself. This
926
+ # replaced the old strict single-shot (max_steps=1) once a stronger coding model
927
+ # (Qwen3-14B class) made real multi-step reasoning reliable. Default budget 8,
928
+ # overridable via cfg.maxSteps. The provide_final_answer override below is now only
929
+ # the exhaustion fallback (loop ends without final_answer).
930
+ try:
931
+ max_steps = int(cfg.get("maxSteps") or 8)
932
+ except (TypeError, ValueError):
933
+ max_steps = 8
934
+ if max_steps < 1:
935
+ max_steps = 8
644
936
 
645
937
  _log(
646
938
  f"start model={agent_model_id!r} base={agent_base_url!r} "
@@ -713,8 +1005,41 @@ def run_agent_chat(cfg):
713
1005
  _log(f" history[{j}]: {ln[:240]}")
714
1006
  _log(f"current task (latest user message): {task_text.rsplit('Current task: ', 1)[-1][:240]!r}")
715
1007
 
1008
+ # ---- send_email config (per-user email API, e.g. a Lambda the user set up) ----
1009
+ email_enabled = bool(cfg.get("emailEnabled"))
1010
+ email_api_url = (cfg.get("emailApiUrl") or "").strip()
1011
+ email_api_method = ((cfg.get("emailApiMethod") or "POST").strip().upper()) or "POST"
1012
+ email_api_headers_raw = (cfg.get("emailApiHeaders") or "").strip()
1013
+ email_body_template = (cfg.get("emailBodyTemplate") or "").strip()
1014
+ email_from = (cfg.get("emailFrom") or "").strip()
1015
+ email_allow = [a for a in re.split(r"[,\s]+", (cfg.get("emailAllowList") or "").strip().lower()) if a]
1016
+ # Advertised + registered ONLY when fully configured AND enabled — an outward
1017
+ # action must never be silently available (same gating as the PDF reader).
1018
+ _EMAIL_AVAILABLE = bool(email_enabled and email_api_url and email_body_template)
1019
+
1020
+ def _prior_email_preview() -> bool:
1021
+ """True if an earlier assistant turn showed a send_email preview awaiting confirm."""
1022
+ for m in reversed(messages[:last_user_idx]):
1023
+ if m.get("role") == "assistant":
1024
+ c = m.get("content") or ""
1025
+ if "📧" in c and "About to email" in c:
1026
+ return True
1027
+ return False
1028
+
1029
+ def _email_confirm_pending() -> bool:
1030
+ """A send is authorized only when the CURRENT message confirms AND a preview
1031
+ was already shown — the weak model cannot self-confirm."""
1032
+ return bool(_EMAIL_AVAILABLE and _EMAIL_CONFIRM.search(latest_user_text or "")
1033
+ and _prior_email_preview())
1034
+
716
1035
  # Route: ask the model if this task needs HTTP tool use.
717
1036
  needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
1037
+ # A pending email awaiting 'confirm' must reach the agent even though a bare
1038
+ # 'confirm' / 'send it' is not a network keyword — otherwise the send never fires.
1039
+ if not needs_agent and _email_confirm_pending():
1040
+ _log("router → YES (email confirm pending)")
1041
+ _emit({"type": "step", "text": "→ Agent mode (email confirm)"})
1042
+ needs_agent = True
718
1043
 
719
1044
  if not needs_agent:
720
1045
  _log("router: plain chat (no HTTP needed)")
@@ -734,14 +1059,39 @@ def run_agent_chat(cfg):
734
1059
  # the tool ever runs. When the user clearly wants a PDF, extract their real text and
735
1060
  # run format → render → upload directly. No model string-echoing, and 1 fewer call.
736
1061
  # (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 ""):
1062
+ if _wants_pdf(latest_user_text):
738
1063
  doc_text = _extract_pdf_doc_text(latest_user_text)
1064
+ # A2: a follow-up like "in the pdf file" / "add the teams to the pdf" carries
1065
+ # no document body — it refers to the last thing the assistant produced. Pull
1066
+ # the most recent substantial assistant turn as the base document and treat the
1067
+ # current user message as an edit instruction the formatter should apply.
1068
+ edit_instruction = ""
1069
+ if len(doc_text.strip()) < 40:
1070
+ prior_doc = ""
1071
+ for m in reversed(messages[:last_user_idx]):
1072
+ if m.get("role") != "assistant":
1073
+ continue
1074
+ cand = think_re.sub("", (m.get("content") or "")).strip()
1075
+ low = cand.lower()
1076
+ # Skip our own PDF-ready confirmations / bare links — not document bodies.
1077
+ if cand.startswith("✅") or low.startswith("http") or "amazonaws.com" in low:
1078
+ continue
1079
+ if len(cand) >= 40:
1080
+ prior_doc = cand
1081
+ break
1082
+ if prior_doc:
1083
+ edit_instruction = latest_user_text.strip()
1084
+ doc_text = prior_doc
739
1085
  doc_title = _derive_pdf_title(doc_text)
740
- _log(f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}")
1086
+ _log(
1087
+ f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}"
1088
+ + (f" edit={edit_instruction[:60]!r}" if edit_instruction else "")
1089
+ )
741
1090
 
742
1091
  _emit({"type": "step", "text": "Formatting document…"})
743
1092
  markdown_text = _format_text_for_pdf(
744
- doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id
1093
+ doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id,
1094
+ instruction=edit_instruction,
745
1095
  )
746
1096
 
747
1097
  _emit({"type": "step", "text": "Rendering PDF…"})
@@ -784,19 +1134,56 @@ def run_agent_chat(cfg):
784
1134
  # fabricate URLs/responses, and always terminate with final_answer().
785
1135
  from datetime import datetime as _dt_now
786
1136
  now_str = _dt_now.now().astimezone().strftime("%A, %d %B %Y, %H:%M %Z")
1137
+ # The PDF reader and send_email are only advertised (and registered) when available
1138
+ # here — never offer the model a tool it cannot run. Entry numbers are dynamic so the
1139
+ # list reads cleanly whether 6, 7, or 8 tools are present.
1140
+ _tool_num = 6
1141
+ _pdf_num = 0
1142
+ if _PDF_READ_AVAILABLE:
1143
+ _tool_num += 1
1144
+ _pdf_num = _tool_num
1145
+ _email_num = 0
1146
+ if _EMAIL_AVAILABLE:
1147
+ _tool_num += 1
1148
+ _email_num = _tool_num
1149
+ _tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
1150
+ _pdf_read_tool_line = (
1151
+ f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
1152
+ "at a URL. This does NOT create a PDF (use create_pdf for that).\n"
1153
+ ) if _PDF_READ_AVAILABLE else ""
1154
+ _pdf_read_choose_line = (
1155
+ "- 'read'/'summarize'/'what does the PDF say' for an existing PDF URL -> "
1156
+ "extract_text_from_pdf(url).\n"
1157
+ ) if _PDF_READ_AVAILABLE else ""
1158
+ _email_tool_line = (
1159
+ f" {_email_num}. send_email(to, subject, body) — send an email via the configured "
1160
+ "email API. It PREVIEWS first and only sends after the user replies 'confirm'.\n"
1161
+ ) if _EMAIL_AVAILABLE else ""
1162
+ _email_choose_line = (
1163
+ "- 'email …' / 'send an email to …' -> send_email(to, subject, body) "
1164
+ "(previews first, then sends on confirm).\n"
1165
+ ) if _EMAIL_AVAILABLE else ""
787
1166
  tool_hint = (
788
- "YOU HAVE EXACTLY ONE TURN. Read the TASK above. In a single code block, call "
789
- "the ONE tool that fits THAT task, then pass its result to final_answer(). "
790
- "Do not plan multiple steps.\n\n"
791
- "You have FOUR tools:\n"
1167
+ f"Solve the TASK step by step (up to {max_steps} steps). At EACH step write a "
1168
+ "brief Thought, then ONE code block that calls a SINGLE tool. You will SEE that "
1169
+ "tool's result (Observation) before the next step — use it to decide what to do "
1170
+ "next (e.g. web_search to find a URL, THEN fetch_url to read it). When you have "
1171
+ "enough to answer, call final_answer(<your answer>) in a code block. Prefer "
1172
+ "FEWER steps, and never repeat a tool call that already succeeded.\n\n"
1173
+ f"You have {_tool_count_word} tools:\n"
792
1174
  " 1. http_request(method, url, headers='', body='', username='', password='') — "
793
1175
  "call a SPECIFIC known API/URL.\n"
794
1176
  " 2. web_search(query) — look up facts about a person, place, thing, or topic "
795
1177
  "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}). "
1178
+ " 3. fetch_url(url) — READ the full text of a specific web page (e.g. a link the "
1179
+ "user gave, or a source URL from web_search). Returns clean text, not raw HTML.\n"
1180
+ " 4. calculate(expression) — do math: arithmetic, percentages, powers "
1181
+ "(e.g. '15% of 80', '(3+4)*2'). ALWAYS use this instead of computing yourself.\n"
1182
+ f" 5. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
797
1183
  "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 "
1184
+ " 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
799
1185
  "download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
1186
+ + _pdf_read_tool_line + _email_tool_line +
800
1187
  "\n"
801
1188
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
802
1189
  "\n"
@@ -814,7 +1201,10 @@ def run_agent_chat(cfg):
814
1201
  "- ONLY a date/time question (e.g. 'what is the date today') -> get_current_datetime().\n"
815
1202
  "- 'who is' / 'what is' / 'tell me about' / a person / place / topic / general "
816
1203
  "knowledge -> web_search(query).\n"
1204
+ "- 'read this page' / 'open this link' / 'what does <URL> say' -> fetch_url(url).\n"
1205
+ "- any arithmetic, percentage, or 'how much is' math -> calculate(expression).\n"
817
1206
  "- 'create/make/generate/export a PDF' of some text/data -> create_pdf(text, title).\n"
1207
+ + _pdf_read_choose_line + _email_choose_line +
818
1208
  "- A specific known API/URL was given -> http_request().\n"
819
1209
  "\n"
820
1210
  "RULES:\n"
@@ -839,8 +1229,8 @@ def run_agent_chat(cfg):
839
1229
  # Track URLs that have already failed so we don't retry dead endpoints across steps.
840
1230
  _failed_urls: set = set()
841
1231
 
842
- # Remember the last tool output so the single-shot fallback + the deterministic
843
- # final formatting can report exactly what a tool returned (no extra model call).
1232
+ # Remember the last tool output so the max-steps fallback can report exactly what a
1233
+ # tool returned (no extra model call) if the loop ends without final_answer.
844
1234
  _last_obs: dict = {"text": ""}
845
1235
 
846
1236
  @tool
@@ -941,6 +1331,135 @@ def run_agent_chat(cfg):
941
1331
  _last_obs["text"] = result
942
1332
  return result
943
1333
 
1334
+ @tool
1335
+ def fetch_url(url: str) -> str:
1336
+ """Fetch a specific web page and return its readable text (HTML stripped).
1337
+
1338
+ Use this to READ the actual contents of a specific URL — e.g. a page found by
1339
+ web_search, or a link the user gave — when a summary is not enough. Returns clean
1340
+ plain text, not raw HTML. For JSON APIs use http_request instead.
1341
+
1342
+ Args:
1343
+ url: The full URL of the page to read, e.g. 'https://example.com/article'.
1344
+ """
1345
+ _emit({"type": "step", "text": f"Reading page → {url[:80]}"})
1346
+ status, ctype, raw = _fetch_page_impl(url)
1347
+ if status is None:
1348
+ msg = f"Error: could not fetch {url}: {raw.decode('utf-8', 'replace')[:200]}"
1349
+ _log(f"fetch_url error {url} → {msg[:80]}")
1350
+ _last_obs["text"] = msg
1351
+ return msg
1352
+ if isinstance(status, int) and status >= 400:
1353
+ msg = f"Error: {url} returned HTTP {status}. Try a different URL or use web_search()."
1354
+ _log(f"fetch_url {url} → HTTP {status}")
1355
+ _last_obs["text"] = msg
1356
+ return msg
1357
+ # PDFs / binaries don't strip to useful text.
1358
+ if "application/pdf" in ctype or raw[:5].lstrip().startswith(b"%PDF"):
1359
+ msg = (f"{url} is a PDF, not an HTML page — fetch_url cannot read PDFs. "
1360
+ "Tell the user this needs a PDF-reading tool.")
1361
+ _log(f"fetch_url {url} → PDF, cannot read")
1362
+ _last_obs["text"] = msg
1363
+ return msg
1364
+ text = _html_to_text(raw.decode("utf-8", errors="replace")) or "(page had no readable text)"
1365
+ out = f"{url}\n{text}"
1366
+ _log(f"fetch_url {url} → {len(text)} chars")
1367
+ _last_obs["text"] = out
1368
+ return out
1369
+
1370
+ @tool
1371
+ def calculate(expression: str) -> str:
1372
+ """Evaluate a math expression and return the numeric result. Do NOT do the math
1373
+ yourself — always call this for arithmetic, percentages, powers, or conversions.
1374
+
1375
+ Supports + - * / // % ** and parentheses, plus sqrt/pow/round/abs/floor/ceil/log
1376
+ and the constants pi and e. Understands '15% of 80' and '2^10'.
1377
+
1378
+ Args:
1379
+ expression: The math to evaluate, e.g. '(3+4)*2', '2**10', '15% of 80'.
1380
+ """
1381
+ _emit({"type": "step", "text": f"Calculating → {expression[:80]}"})
1382
+ result = _calc_impl(expression)
1383
+ _log(f"calculate({expression!r}) → {result[:80]}")
1384
+ _last_obs["text"] = result
1385
+ return result
1386
+
1387
+ @tool
1388
+ def send_email(to: str, subject: str, body: str) -> str:
1389
+ """Send an email through the configured email API. Asks for confirmation FIRST.
1390
+
1391
+ The FIRST time you call this it only PREVIEWS the email and does NOT send — tell
1392
+ the user to reply 'confirm' to actually send it. Use ONLY when the user explicitly
1393
+ asks to email or send a message to someone.
1394
+
1395
+ Args:
1396
+ to: Recipient email address.
1397
+ subject: The email subject line.
1398
+ body: The email body text.
1399
+ """
1400
+ to_addr = (to or "").strip()
1401
+ if "@" not in to_addr or " " in to_addr:
1402
+ msg = f"Error: '{to_addr}' is not a valid recipient email address."
1403
+ _last_obs["text"] = msg
1404
+ return msg
1405
+ if email_allow and not _email_allowed(to_addr, email_allow):
1406
+ msg = (f"Error: {to_addr} is not in the allowed recipients list — refusing to "
1407
+ "send. Add it to the allow-list in Settings if this is intended.")
1408
+ _log(f"send_email blocked by allow-list: {to_addr}")
1409
+ _last_obs["text"] = msg
1410
+ return msg
1411
+ preview = (
1412
+ f"📧 About to email {to_addr}\n"
1413
+ f"Subject: {subject or '(no subject)'}\n\n"
1414
+ f"{body or '(no body)'}\n\n"
1415
+ "Reply 'confirm' to send this, or tell me what to change."
1416
+ )
1417
+ # Send ONLY when the current user message confirms AND a preview was already
1418
+ # shown — otherwise just preview (never send on the first call).
1419
+ if not _email_confirm_pending():
1420
+ _emit({"type": "step", "text": f"Email preview → {to_addr} (awaiting confirm)"})
1421
+ _log(f"send_email preview (awaiting confirm) → {to_addr}")
1422
+ _last_obs["text"] = preview
1423
+ return preview
1424
+ filled = _email_fill_template(email_body_template, {
1425
+ "to": to_addr, "subject": subject or "", "body": body or "", "from": email_from,
1426
+ })
1427
+ if isinstance(filled, str): # template error
1428
+ _last_obs["text"] = filled
1429
+ return filled
1430
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
1431
+ if email_api_headers_raw:
1432
+ try:
1433
+ headers.update(json.loads(email_api_headers_raw))
1434
+ except Exception: # noqa: BLE001
1435
+ pass
1436
+ _emit({"type": "step", "text": f"Sending email → {to_addr}"})
1437
+ result = _http_request_impl(email_api_method, email_api_url, headers, json.dumps(filled))
1438
+ first = result.split("\n", 1)[0] if result else "no response"
1439
+ if result.startswith("HTTP 2"):
1440
+ out = f"✅ Email sent to {to_addr} (Subject: {subject or '(no subject)'})."
1441
+ else:
1442
+ out = f"Email send failed ({first}). Check the email API settings."
1443
+ _log(f"send_email {to_addr} → {first}")
1444
+ _last_obs["text"] = out
1445
+ return out
1446
+
1447
+ @tool
1448
+ def extract_text_from_pdf(url: str) -> str:
1449
+ """Read an existing PDF at a URL and return its extracted text.
1450
+
1451
+ Use this to READ or SUMMARIZE a PDF the user links to. This does NOT create a
1452
+ PDF — use create_pdf for that. Returns the PDF's text (truncated), not a link.
1453
+
1454
+ Args:
1455
+ url: The full URL of the PDF file, e.g. 'https://example.com/report.pdf'.
1456
+ """
1457
+ _emit({"type": "step", "text": f"Reading PDF → {url[:80]}"})
1458
+ result = _extract_pdf_text_impl(url)
1459
+ _log(f"extract_text_from_pdf {url} → {result[:80]}")
1460
+ _last_obs["text"] = result
1461
+ return result
1462
+
944
1463
  @tool
945
1464
  def create_pdf(text: str, title: str = "") -> str:
946
1465
  """Create a PDF document from text/data and return a download link.
@@ -962,7 +1481,7 @@ def run_agent_chat(cfg):
962
1481
 
963
1482
  # 2) Render the Markdown to PDF bytes locally (pure-Python xhtml2pdf).
964
1483
  # Catch EVERYTHING (xhtml2pdf can raise arbitrary errors on exotic glyphs /
965
- # emoji), so the tool always returns a string and never breaks the single shot.
1484
+ # emoji), so the tool always returns a string and never breaks the agent loop.
966
1485
  _emit({"type": "step", "text": "Rendering PDF…"})
967
1486
  try:
968
1487
  pdf_bytes = _render_pdf_bytes(markdown_text, doc_title)
@@ -1038,6 +1557,21 @@ def run_agent_chat(cfg):
1038
1557
  # and any step memory it accumulates. completion_kwargs["messages"] here is the
1039
1558
  # literal messages array sent to /v1/chat/completions.
1040
1559
  class _LoggingModel(OpenAIServerModel):
1560
+ def generate(self, *args, **kwargs):
1561
+ # Strip Qwen3 <think>…</think> reasoning traces from each step's reply
1562
+ # BEFORE smolagents parses it for the code block / final_answer. Thinking
1563
+ # stays on (better reasoning), but the trace never confuses the parser or
1564
+ # leaks to the user.
1565
+ msg = super().generate(*args, **kwargs)
1566
+ try:
1567
+ if isinstance(getattr(msg, "content", None), str) and "</think>" in msg.content.lower():
1568
+ cleaned = _strip_think(msg.content)
1569
+ _log(f"stripped <think> trace ({len(msg.content) - len(cleaned)} chars)")
1570
+ msg.content = cleaned
1571
+ except Exception as e: # noqa: BLE001
1572
+ _log(f"think-strip error: {e}")
1573
+ return msg
1574
+
1041
1575
  def _prepare_completion_kwargs(self, *args, **kwargs):
1042
1576
  ck = super()._prepare_completion_kwargs(*args, **kwargs)
1043
1577
  try:
@@ -1063,19 +1597,50 @@ def run_agent_chat(cfg):
1063
1597
  _log(f"MODEL REQUEST log error: {e}")
1064
1598
  return ck
1065
1599
 
1066
- # Single-shot agent: if the one model call doesn't end in final_answer(),
1067
- # smolagents would normally make an EXTRA model call (provide_final_answer) to
1600
+ # Tool-invocation mode: "code" (default) makes the model author a Python code
1601
+ # block (CodeAgent); "toolcall" makes it emit a structured JSON tool call
1602
+ # (ToolCallingAgent), which sidesteps the unterminated-string-literal failures weak
1603
+ # models hit when forced to echo long payloads as Python literals. Default is
1604
+ # unchanged ("code"); flip via cfg.agentToolMode to A/B without touching the
1605
+ # working paths (datetime / web_search / http_request).
1606
+ agent_tool_mode = (cfg.get("agentToolMode") or "code").strip().lower()
1607
+ _AgentBase = CodeAgent
1608
+ if agent_tool_mode == "toolcall":
1609
+ try:
1610
+ from smolagents import ToolCallingAgent
1611
+ _AgentBase = ToolCallingAgent
1612
+ _log("agent tool mode: toolcall (structured JSON tool calls)")
1613
+ except Exception as e: # noqa: BLE001
1614
+ _log(f"ToolCallingAgent unavailable ({e}); falling back to CodeAgent")
1615
+ else:
1616
+ _log("agent tool mode: code (python CodeAgent)")
1617
+
1618
+ # Exhaustion fallback: normally the model calls final_answer() itself within the
1619
+ # multi-step loop and that answer is used. Only if it burns through all max_steps
1620
+ # WITHOUT calling final_answer does smolagents call provide_final_answer to
1068
1621
  # synthesize one. We override that to return the last tool observation
1069
- # deterministically keeping the agent to EXACTLY ONE model call, and never
1070
- # corrupting exact tool output (dates/numbers) the way a weak model would.
1071
- class _SingleShotAgent(CodeAgent):
1622
+ # deterministically (or a plain reply) rather than dead-ending and without a weak
1623
+ # model corrupting exact tool output (dates/numbers).
1624
+ class _ToolAgent(_AgentBase):
1072
1625
  def provide_final_answer(self, task, *args, **kwargs):
1073
1626
  from smolagents.models import ChatMessage, MessageRole
1074
1627
  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]}")
1628
+ if text:
1629
+ _log(f"max-steps fallback (last tool obs) {text[:80]}")
1630
+ else:
1631
+ # No usable tool output (e.g. every step's code failed to parse).
1632
+ # Don't dead-end on a canned apology — answer from the conversation.
1633
+ _log("max-steps fallback → plain reply over conversation")
1634
+ try:
1635
+ text = _plain_reply(
1636
+ messages, agent_base_url, agent_api_key, agent_model_id
1637
+ ).strip()
1638
+ except Exception as e: # noqa: BLE001
1639
+ _log(f"plain-reply fallback error: {e}")
1640
+ text = ""
1641
+ if not text:
1642
+ text = ("I couldn't complete that within the step budget. Please "
1643
+ "rephrase, or give a specific URL/API to call.")
1079
1644
  return ChatMessage(role=MessageRole.ASSISTANT, content=text)
1080
1645
 
1081
1646
  try:
@@ -1084,28 +1649,47 @@ def run_agent_chat(cfg):
1084
1649
  api_base=coding_base_url,
1085
1650
  api_key=agent_api_key,
1086
1651
  )
1087
- agent = _SingleShotAgent(
1088
- tools=[http_request, web_search, get_current_datetime, create_pdf],
1652
+ agent_tools = [http_request, web_search, fetch_url, calculate,
1653
+ get_current_datetime, create_pdf]
1654
+ # Only register the PDF reader / email tools when available (kept in sync with
1655
+ # the gated hint entries above so the model never sees a tool it can't run).
1656
+ if _PDF_READ_AVAILABLE:
1657
+ agent_tools.append(extract_text_from_pdf)
1658
+ if _EMAIL_AVAILABLE:
1659
+ agent_tools.append(send_email)
1660
+ agent_kwargs = dict(
1661
+ tools=agent_tools,
1089
1662
  model=model,
1090
1663
  max_steps=max_steps,
1091
1664
  step_callbacks=[step_callback],
1092
- executor_kwargs={"timeout_seconds": 60},
1093
- additional_authorized_imports=["json", "base64", "urllib", "urllib.request", "urllib.error"],
1094
1665
  )
1666
+ if _AgentBase is CodeAgent:
1667
+ # Only the CodeAgent runs a Python executor; ToolCallingAgent takes neither.
1668
+ agent_kwargs["executor_kwargs"] = {"timeout_seconds": 60}
1669
+ agent_kwargs["additional_authorized_imports"] = [
1670
+ "json", "base64", "urllib", "urllib.request", "urllib.error"
1671
+ ]
1672
+ agent = _ToolAgent(**agent_kwargs)
1095
1673
  with contextlib.redirect_stdout(sys.stderr):
1096
1674
  result = agent.run(task_with_hint)
1097
- # Deterministic final formatting — NO summarizer model call. The agent's
1098
- # final_answer (or the single-shot fallback above) already holds exact tool
1099
- # output; we just strip the internal hint tags we appended to tool results so
1100
- # they don't leak to the user. This permanently fixes the date-corruption a
1101
- # weak summarizer model used to introduce.
1675
+ # Final formatting — NO extra summarizer model call. In multi-step mode the
1676
+ # agent's own final_answer() (or the max-steps fallback above) already holds the
1677
+ # synthesized answer; we just strip the internal hint tags we appended to tool
1678
+ # results so they don't leak to the user.
1102
1679
  _emit({"type": "step", "text": "Composing answer…"})
1103
1680
  final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
1104
1681
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
1105
1682
  _emit({"type": "final", "text": final_text})
1106
1683
  except Exception as e: # noqa: BLE001
1107
- _log(f"agent error: {e}")
1108
- _emit({"type": "final", "text": f"[Agent error: {e}]"})
1684
+ # An unexpected failure inside the agent loop should still return a useful
1685
+ # answer from the conversation rather than surfacing a raw error to the user.
1686
+ _log(f"agent error: {e} — degrading to plain reply")
1687
+ try:
1688
+ fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
1689
+ except Exception as e2: # noqa: BLE001
1690
+ _log(f"plain-reply degrade error: {e2}")
1691
+ fallback = ""
1692
+ _emit({"type": "final", "text": fallback or f"[Agent error: {e}]"})
1109
1693
 
1110
1694
 
1111
1695
  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.117",
3
+ "version": "1.0.123",
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",