@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.
- package/gonext-local-worker.mjs +14 -0
- package/gonext_agent_chat.py +569 -31
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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
|
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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(
|
|
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
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
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.
|
|
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",
|