@tiens.nguyen/gonext-local-worker 1.0.88 → 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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +150 -14
  2. package/package.json +1 -1
@@ -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 service, or any remote server).\n"
184
- "Answer NO if it can be solved entirely with Python stdlib, math, or is just conversation."
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"
@@ -382,13 +460,20 @@ def run_agent_chat(cfg):
382
460
  _log("router: agent (HTTP tool use needed)")
383
461
  _emit({"type": "step", "text": "Planning HTTP request…"})
384
462
 
385
- # Prepend explicit tool instructions so small models use http_request correctly
386
- # and always terminate with final_answer() rather than looping forever.
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")
387
467
  tool_hint = (
388
- "You have ONE built-in function:\n"
389
- " `http_request(method, url, headers='', body='', username='', password='')`\n"
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"
390
475
  "\n"
391
- "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"
392
477
  "\n"
393
478
  "BASIC AUTH — ALWAYS use username= and password=, NEVER construct headers manually:\n"
394
479
  " response = http_request('GET', 'https://api.example.com/data',\n"
@@ -400,11 +485,18 @@ def run_agent_chat(cfg):
400
485
  " response = http_request('GET', url, headers='{\"Authorization\": \"Bearer TOKEN\"}')\n"
401
486
  " final_answer(response)\n"
402
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"
403
493
  "RULES:\n"
404
- "- Pass response DIRECTLY to final_answer do NOT split, parse, or index the string.\n"
405
- "- If the response starts with 'HTTP 2' it SUCCEEDEDcall final_answer immediately.\n"
406
- "- If http_request returns 'Error:' or HTTP 4xx/5xx, try a different approach.\n"
407
- "- Python's datetime module is available for date/time tasks (no HTTP needed).\n"
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 needdo 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"
408
500
  "- Do NOT put final_answer outside the code block.\n\n"
409
501
  )
410
502
  task_with_hint = tool_hint + "Task: " + task_text
@@ -456,13 +548,57 @@ def run_agent_chat(cfg):
456
548
  "Try a DIFFERENT URL or use Python's datetime/math/etc. module instead."
457
549
  )
458
550
  status_line = result.split("\n")[0][:150] if result else "no response"
459
- # Append a success tag to 2xx responses so the model knows to stop and call final_answer.
460
- if result and result.startswith("HTTP 2"):
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"):
461
561
  result = result + "\n[SUCCESS — call final_answer(response) now, do not parse or retry]"
462
562
  _emit({"type": "step", "text": f"HTTP {method.upper()} {url} → {status_line}"})
463
563
  _log(f"http_request {method.upper()} {url} → {result[:80]}")
464
564
  return result
465
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
+
466
602
  def step_callback(step_log):
467
603
  step_num = getattr(step_log, "step_number", "?")
468
604
 
@@ -529,7 +665,7 @@ def run_agent_chat(cfg):
529
665
  api_key=agent_api_key,
530
666
  )
531
667
  agent = CodeAgent(
532
- tools=[http_request],
668
+ tools=[http_request, web_search, get_current_datetime],
533
669
  model=model,
534
670
  max_steps=max_steps,
535
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.88",
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",