@tiens.nguyen/gonext-local-worker 1.0.87 → 1.0.89
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_agent_chat.py +155 -15
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -64,6 +64,80 @@ def _http_request_impl(method, url, headers=None, body=None, timeout=25):
|
|
|
64
64
|
return f"Error: {e}"
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _get_json(url, timeout=15):
|
|
68
|
+
"""GET a URL and parse the JSON body. Returns dict/list, or None on failure.
|
|
69
|
+
|
|
70
|
+
Used by web_search against free no-key APIs (DuckDuckGo, Wikipedia). Wikipedia
|
|
71
|
+
requires a descriptive User-Agent, so we send one.
|
|
72
|
+
"""
|
|
73
|
+
req = urllib.request.Request(url, method="GET", headers={
|
|
74
|
+
"User-Agent": "gonext-agent/1.0 (local API testing assistant)",
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
})
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
|
79
|
+
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
80
|
+
except Exception as e: # noqa: BLE001
|
|
81
|
+
_log(f"web_search fetch failed {url}: {e}")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _web_search_impl(query):
|
|
86
|
+
"""Look up factual info via free no-key JSON APIs (DuckDuckGo + Wikipedia).
|
|
87
|
+
|
|
88
|
+
Returns a short text summary with a source URL, or a 'no results' message.
|
|
89
|
+
Tries DuckDuckGo Instant Answer first, then falls back to a Wikipedia search +
|
|
90
|
+
REST summary. Never fabricates — callers should surface 'no results' honestly.
|
|
91
|
+
"""
|
|
92
|
+
from urllib.parse import quote
|
|
93
|
+
q = (query or "").strip()
|
|
94
|
+
if not q:
|
|
95
|
+
return "web_search: empty query."
|
|
96
|
+
|
|
97
|
+
# 1) DuckDuckGo Instant Answer API.
|
|
98
|
+
ddg = _get_json(
|
|
99
|
+
f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1"
|
|
100
|
+
)
|
|
101
|
+
if isinstance(ddg, dict):
|
|
102
|
+
abstract = (ddg.get("AbstractText") or "").strip()
|
|
103
|
+
if abstract:
|
|
104
|
+
src = (ddg.get("AbstractURL") or "").strip()
|
|
105
|
+
return f"{abstract[:1500]}\nSource: {src}" if src else abstract[:1500]
|
|
106
|
+
# No abstract — use the first related topic that has text.
|
|
107
|
+
for topic in ddg.get("RelatedTopics") or []:
|
|
108
|
+
if isinstance(topic, dict) and topic.get("Text"):
|
|
109
|
+
src = (topic.get("FirstURL") or "").strip()
|
|
110
|
+
text = topic["Text"][:1500]
|
|
111
|
+
return f"{text}\nSource: {src}" if src else text
|
|
112
|
+
|
|
113
|
+
# 2) Wikipedia: find the best-matching title, then fetch its summary extract.
|
|
114
|
+
search = _get_json(
|
|
115
|
+
"https://en.wikipedia.org/w/api.php?action=query&list=search"
|
|
116
|
+
f"&srsearch={quote(q)}&format=json&srlimit=1"
|
|
117
|
+
)
|
|
118
|
+
title = ""
|
|
119
|
+
try:
|
|
120
|
+
title = search["query"]["search"][0]["title"]
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
title = ""
|
|
123
|
+
if title:
|
|
124
|
+
slug = quote(title.replace(" ", "_"))
|
|
125
|
+
summary = _get_json("https://en.wikipedia.org/api/rest_v1/page/summary/" + slug)
|
|
126
|
+
if isinstance(summary, dict):
|
|
127
|
+
extract = (summary.get("extract") or "").strip()
|
|
128
|
+
if extract:
|
|
129
|
+
src = (
|
|
130
|
+
(summary.get("content_urls") or {}).get("desktop", {}).get("page", "")
|
|
131
|
+
or f"https://en.wikipedia.org/wiki/{slug}"
|
|
132
|
+
)
|
|
133
|
+
return f"{extract[:1500]}\nSource: {src}"
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
137
|
+
"do NOT invent an answer or a URL."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
67
141
|
def _detect_model_id(base_url, api_key=""):
|
|
68
142
|
"""Ask an OpenAI-compatible server which model it serves.
|
|
69
143
|
|
|
@@ -154,6 +228,8 @@ _AGENT_KEYWORDS = re.compile(
|
|
|
154
228
|
r"|external\s+source|external\s+api|external\s+service"
|
|
155
229
|
r"|web\s+service|rest\s+api|rest\s+call"
|
|
156
230
|
r"|download|scrape|crawl"
|
|
231
|
+
r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
|
|
232
|
+
r"|date|time|what\s+day|what\s+time"
|
|
157
233
|
r")\b",
|
|
158
234
|
re.IGNORECASE,
|
|
159
235
|
)
|
|
@@ -180,8 +256,10 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
|
|
|
180
256
|
{"role": "system", "content": (
|
|
181
257
|
"You are a task classifier. Reply YES or NO only, no punctuation.\n"
|
|
182
258
|
"Answer YES if the task requires fetching data from an external network source "
|
|
183
|
-
"(URL, API, website, web
|
|
184
|
-
"
|
|
259
|
+
"(URL, API, website, remote server), a web search / factual lookup, or the "
|
|
260
|
+
"current date or time.\n"
|
|
261
|
+
"Answer NO only if it is pure conversation, opinion, or simple text the "
|
|
262
|
+
"assistant can answer directly without looking anything up."
|
|
185
263
|
)},
|
|
186
264
|
{"role": "user", "content": (
|
|
187
265
|
f"Does this task require fetching data from an external network source?\n\n"
|
|
@@ -330,6 +408,10 @@ def run_agent_chat(cfg):
|
|
|
330
408
|
_emit({"type": "final", "text": "[No user message found in history]"})
|
|
331
409
|
return
|
|
332
410
|
task_text = (messages[last_user_idx].get("content") or "").strip()
|
|
411
|
+
# Routing must look at the CURRENT message alone — not the history-laden blob
|
|
412
|
+
# below. Otherwise the keyword router matches URLs/"api"/"GET" from prior turns
|
|
413
|
+
# and fires the agent on trivial replies like "thanks" or "good".
|
|
414
|
+
latest_user_text = task_text
|
|
333
415
|
|
|
334
416
|
# Walk prior turns newest-first, keeping condensed lines until the budget is
|
|
335
417
|
# spent, then restore chronological (oldest→newest) order.
|
|
@@ -365,7 +447,7 @@ def run_agent_chat(cfg):
|
|
|
365
447
|
_log(f"current task (latest user message): {task_text.rsplit('Current task: ', 1)[-1][:240]!r}")
|
|
366
448
|
|
|
367
449
|
# Route: ask the model if this task needs HTTP tool use.
|
|
368
|
-
needs_agent = _route(
|
|
450
|
+
needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
|
|
369
451
|
|
|
370
452
|
if not needs_agent:
|
|
371
453
|
_log("router: plain chat (no HTTP needed)")
|
|
@@ -378,13 +460,20 @@ def run_agent_chat(cfg):
|
|
|
378
460
|
_log("router: agent (HTTP tool use needed)")
|
|
379
461
|
_emit({"type": "step", "text": "Planning HTTP request…"})
|
|
380
462
|
|
|
381
|
-
# Prepend explicit tool instructions so small models
|
|
382
|
-
# and always terminate with final_answer()
|
|
463
|
+
# Prepend explicit tool instructions so small models pick the right tool, never
|
|
464
|
+
# fabricate URLs/responses, and always terminate with final_answer().
|
|
465
|
+
from datetime import datetime as _dt_now
|
|
466
|
+
now_str = _dt_now.now().astimezone().strftime("%A, %d %B %Y, %H:%M %Z")
|
|
383
467
|
tool_hint = (
|
|
384
|
-
"
|
|
385
|
-
"
|
|
468
|
+
f"Current date/time: {now_str}.\n\n"
|
|
469
|
+
"You have THREE tools:\n"
|
|
470
|
+
" 1. http_request(method, url, headers='', body='', username='', password='') — "
|
|
471
|
+
"call a SPECIFIC known API/URL.\n"
|
|
472
|
+
" 2. web_search(query) — look up facts when you do NOT already have a real URL. "
|
|
473
|
+
"Returns a summary + source.\n"
|
|
474
|
+
" 3. get_current_datetime(timezone='') — current date/time (no HTTP needed).\n"
|
|
386
475
|
"\n"
|
|
387
|
-
"RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
476
|
+
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
388
477
|
"\n"
|
|
389
478
|
"BASIC AUTH — ALWAYS use username= and password=, NEVER construct headers manually:\n"
|
|
390
479
|
" response = http_request('GET', 'https://api.example.com/data',\n"
|
|
@@ -396,11 +485,18 @@ def run_agent_chat(cfg):
|
|
|
396
485
|
" response = http_request('GET', url, headers='{\"Authorization\": \"Bearer TOKEN\"}')\n"
|
|
397
486
|
" final_answer(response)\n"
|
|
398
487
|
"\n"
|
|
488
|
+
"CHOOSING A TOOL:\n"
|
|
489
|
+
"- Date/time question -> get_current_datetime(); do NOT use http_request.\n"
|
|
490
|
+
"- 'find' / 'look up' / 'what is' / general knowledge -> web_search(query).\n"
|
|
491
|
+
"- A specific known API/URL was given -> http_request().\n"
|
|
492
|
+
"\n"
|
|
399
493
|
"RULES:\n"
|
|
400
|
-
"-
|
|
401
|
-
"
|
|
402
|
-
"-
|
|
403
|
-
"-
|
|
494
|
+
"- NEVER invent or guess a URL. If you have no real URL, use web_search() instead. "
|
|
495
|
+
"If nothing works, call final_answer explaining what you need — do NOT make up an answer.\n"
|
|
496
|
+
"- Only report what a tool ACTUALLY returned. Never fabricate a response, body, or status code.\n"
|
|
497
|
+
"- Pass an http_request response DIRECTLY to final_answer — do NOT split, parse, or index it.\n"
|
|
498
|
+
"- If a response starts with 'HTTP 2' it SUCCEEDED — call final_answer immediately.\n"
|
|
499
|
+
"- If a tool returns 'Error:' or HTTP 4xx/5xx, try a DIFFERENT approach, not the same URL.\n"
|
|
404
500
|
"- Do NOT put final_answer outside the code block.\n\n"
|
|
405
501
|
)
|
|
406
502
|
task_with_hint = tool_hint + "Task: " + task_text
|
|
@@ -452,13 +548,57 @@ def run_agent_chat(cfg):
|
|
|
452
548
|
"Try a DIFFERENT URL or use Python's datetime/math/etc. module instead."
|
|
453
549
|
)
|
|
454
550
|
status_line = result.split("\n")[0][:150] if result else "no response"
|
|
455
|
-
#
|
|
456
|
-
|
|
551
|
+
# Detect HTML pages so the model stops trying to json.loads() a web page.
|
|
552
|
+
body_part = result.split("\n", 1)[1].lstrip().lower() if "\n" in result else ""
|
|
553
|
+
is_html = body_part.startswith("<!doctype html") or body_part.startswith("<html")
|
|
554
|
+
if is_html:
|
|
555
|
+
result = result + (
|
|
556
|
+
"\n[NOTE: This is an HTML web page, not JSON. Do NOT json.loads() it. "
|
|
557
|
+
"Use web_search() for facts, or request a JSON API endpoint instead.]"
|
|
558
|
+
)
|
|
559
|
+
# Append a success tag to 2xx JSON responses so the model stops and calls final_answer.
|
|
560
|
+
elif result and result.startswith("HTTP 2"):
|
|
457
561
|
result = result + "\n[SUCCESS — call final_answer(response) now, do not parse or retry]"
|
|
458
562
|
_emit({"type": "step", "text": f"HTTP {method.upper()} {url} → {status_line}"})
|
|
459
563
|
_log(f"http_request {method.upper()} {url} → {result[:80]}")
|
|
460
564
|
return result
|
|
461
565
|
|
|
566
|
+
@tool
|
|
567
|
+
def get_current_datetime(timezone: str = "") -> str:
|
|
568
|
+
"""Return the current date and time. Use for any date/time question — no HTTP needed.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
timezone: Optional IANA timezone name (e.g. 'Asia/Bangkok', 'UTC'). Empty = server local time.
|
|
572
|
+
"""
|
|
573
|
+
from datetime import datetime as _dtl
|
|
574
|
+
try:
|
|
575
|
+
if timezone:
|
|
576
|
+
from zoneinfo import ZoneInfo
|
|
577
|
+
now = _dtl.now(ZoneInfo(timezone))
|
|
578
|
+
else:
|
|
579
|
+
now = _dtl.now().astimezone()
|
|
580
|
+
except Exception: # noqa: BLE001
|
|
581
|
+
now = _dtl.now().astimezone()
|
|
582
|
+
out = now.strftime("%A, %d %B %Y, %H:%M:%S %Z")
|
|
583
|
+
_emit({"type": "step", "text": f"Current date/time → {out}"})
|
|
584
|
+
_log(f"get_current_datetime({timezone!r}) → {out}")
|
|
585
|
+
return out
|
|
586
|
+
|
|
587
|
+
@tool
|
|
588
|
+
def web_search(query: str) -> str:
|
|
589
|
+
"""Search for factual or encyclopedic information using free no-key sources.
|
|
590
|
+
|
|
591
|
+
Use this INSTEAD of guessing a URL when the user asks to 'find' something or asks a
|
|
592
|
+
general-knowledge question. Returns a short summary and a source URL.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
query: What to look up, e.g. 'capital of France' or 'productivity day-to-day method'.
|
|
596
|
+
"""
|
|
597
|
+
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
598
|
+
result = _web_search_impl(query)
|
|
599
|
+
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
600
|
+
return result
|
|
601
|
+
|
|
462
602
|
def step_callback(step_log):
|
|
463
603
|
step_num = getattr(step_log, "step_number", "?")
|
|
464
604
|
|
|
@@ -525,7 +665,7 @@ def run_agent_chat(cfg):
|
|
|
525
665
|
api_key=agent_api_key,
|
|
526
666
|
)
|
|
527
667
|
agent = CodeAgent(
|
|
528
|
-
tools=[http_request],
|
|
668
|
+
tools=[http_request, web_search, get_current_datetime],
|
|
529
669
|
model=model,
|
|
530
670
|
max_steps=max_steps,
|
|
531
671
|
step_callbacks=[step_callback],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.89",
|
|
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",
|