@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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +155 -15
  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"
@@ -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(task_text, agent_base_url, agent_api_key, agent_model_id)
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 use http_request correctly
382
- # 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")
383
467
  tool_hint = (
384
- "You have ONE built-in function:\n"
385
- " `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"
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
- "- Pass response DIRECTLY to final_answer do NOT split, parse, or index the string.\n"
401
- "- If the response starts with 'HTTP 2' it SUCCEEDEDcall final_answer immediately.\n"
402
- "- If http_request returns 'Error:' or HTTP 4xx/5xx, try a different approach.\n"
403
- "- 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"
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
- # Append a success tag to 2xx responses so the model knows to stop and call final_answer.
456
- 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"):
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.87",
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",