@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.
- package/gonext-local-worker.mjs +15 -1
- package/gonext_agent_chat.py +636 -52
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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 ??
|
|
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
|
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
789
|
-
"
|
|
790
|
-
"
|
|
791
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
843
|
-
#
|
|
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
|
|
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
|
-
#
|
|
1067
|
-
#
|
|
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
|
|
1070
|
-
# corrupting exact tool output (dates/numbers)
|
|
1071
|
-
class
|
|
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
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
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
|
-
#
|
|
1098
|
-
# final_answer (or the
|
|
1099
|
-
#
|
|
1100
|
-
# they don't leak to the user.
|
|
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
|
-
|
|
1108
|
-
|
|
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.
|
|
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",
|