@tiens.nguyen/gonext-local-worker 1.0.191 → 1.0.193

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 +244 -102
  2. package/package.json +1 -1
@@ -476,6 +476,28 @@ def _clip(text: str, limit: int = 280) -> str:
476
476
  return f"{cut}…"
477
477
 
478
478
 
479
+ def _render_numbered_tool_list(tools) -> str:
480
+ """Render ' N. name(args) — description' lines from the REAL registered smolagents
481
+ tool objects — their actual name/inputs/docstring — instead of a hand-maintained
482
+ duplicate of the same information. This is the single source of truth for what's
483
+ ACTUALLY callable, so the list can never drift out of sync with the tool set (e.g.
484
+ when RAG or a workspace is enabled/disabled) and the numbering is always exactly
485
+ correct, unlike the old manually-incremented counter it replaces. Kept intentionally
486
+ terse — first docstring line only, bare arg names — since the system prompt's own
487
+ `tool.to_code_prompt()` rendering (see _COMPACT_CODE_SYSTEM_PROMPT) already shows the
488
+ full typed signature on every model call; this is just a quick-reference index."""
489
+ lines = []
490
+ for i, t in enumerate(tools, 1):
491
+ args = []
492
+ for pname, pinfo in (getattr(t, "inputs", None) or {}).items():
493
+ optional = isinstance(pinfo, dict) and pinfo.get("nullable")
494
+ args.append(f"{pname}=''" if optional and pinfo.get("type") == "string" else pname)
495
+ desc_full = (getattr(t, "description", "") or "").strip()
496
+ desc = desc_full.splitlines()[0] if desc_full else ""
497
+ lines.append(f" {i}. {t.name}({', '.join(args)}) — {desc}")
498
+ return ("\n".join(lines) + "\n") if lines else ""
499
+
500
+
479
501
  def _summarise_step(step_log):
480
502
  """Return a short human-readable description of an agent step."""
481
503
  tool_calls = getattr(step_log, "tool_calls", None) or []
@@ -598,6 +620,31 @@ def _is_trivial_chat(text: str) -> bool:
598
620
  return bool(_TRIVIAL_CHAT.match(t))
599
621
 
600
622
 
623
+ # A weak/confused coding model sometimes "thinks out loud" in plain prose instead of
624
+ # emitting the next tool call — e.g. a rambling, self-correcting scratchpad ("Wait,
625
+ # actually... Let's try... Actually, let's just...") that can run thousands of chars.
626
+ # The prose-final-answer heuristic below (long text with list-like structure) can't tell
627
+ # this apart from a genuine, complete answer on structure alone — both use headings/
628
+ # numbered lists. These two signals catch the scratchpad case specifically: it either
629
+ # self-labels as "Thought"/"Thinking Process" up front (a real answer doesn't open by
630
+ # naming itself as the model's own reasoning), or it's dense with self-correction
631
+ # language a finished answer wouldn't contain.
632
+ _SCRATCHPAD_START_RE = re.compile(r"^\s*thought\b\s*[:\n]", re.I)
633
+ _DELIBERATION_RE = re.compile(
634
+ r"\b(wait,|actually,|let'?s (try|just|use|list|see|go)|"
635
+ r"i (?:need to|should) (?:check|verify|confirm))\b",
636
+ re.I,
637
+ )
638
+
639
+
640
+ def _looks_like_scratchpad(stripped: str) -> bool:
641
+ """True if `stripped` reads like unresolved internal deliberation rather than a
642
+ finished, user-facing answer — see module comment above for the two signals used."""
643
+ if _SCRATCHPAD_START_RE.match(stripped):
644
+ return True
645
+ return len(_DELIBERATION_RE.findall(stripped)) >= 3
646
+
647
+
601
648
  # Compact replacement for smolagents' default CodeAgent system prompt. The stock template
602
649
  # is ~9.9k chars, dominated by ~6k chars of GENERIC few-shot examples (image captions,
603
650
  # Wikipedia, etc.) that are irrelevant to our HTTP/file tools and cost real prompt-eval
@@ -2267,25 +2314,12 @@ def run_agent_chat(cfg):
2267
2314
  # fabricate URLs/responses, and always terminate with final_answer().
2268
2315
  from datetime import datetime as _dt_now
2269
2316
  now_str = _dt_now.now().astimezone().strftime("%A, %d %B %Y, %H:%M %Z")
2270
- # The PDF reader and send_email are only advertised (and registered) when available
2271
- # here never offer the model a tool it cannot run. Entry numbers are dynamic so the
2272
- # list reads cleanly whether 6, 7, or 8 tools are present.
2273
- _tool_num = 6
2274
- _pdf_num = 0
2275
- if _PDF_READ_AVAILABLE:
2276
- _tool_num += 1
2277
- _pdf_num = _tool_num
2278
- _email_num = 0
2279
- if _EMAIL_AVAILABLE:
2280
- _tool_num += 1
2281
- _email_num = _tool_num
2282
- if _RAG_AVAILABLE:
2283
- _tool_num += 7 # download_file, unzip_file, list_dir, read_text_file, rag_index/add/search
2284
- if _WS_AVAILABLE:
2285
- _tool_num += 6 # grep_repo, read_file_lines, edit_lines, edit_file, create_file, run_command
2286
- if not _RAG_AVAILABLE:
2287
- _tool_num += 2 # list_dir + read_text_file registered for workspace browsing
2288
- _tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
2317
+ # The numbered tool list itself is generated LATER from the real registered tool
2318
+ # objects (see _render_numbered_tool_list, spliced into task_with_hint below via the
2319
+ # {{TOOL_LIST}} placeholder) it can never drift or miscount, unlike a hand-written
2320
+ # list with a manually-incremented number. The PDF reader / send_email / RAG /
2321
+ # workspace tools are only ever REGISTERED (further below) when available, so an
2322
+ # unavailable tool is never advertised.
2289
2323
  _rag_tool_block = (
2290
2324
  " FILES & KNOWLEDGE BASE — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
2291
2325
  " - download_file(url) — download a file (e.g. a .zip) to this machine.\n"
@@ -2339,18 +2373,10 @@ def run_agent_chat(cfg):
2339
2373
  "AFTER editing, re-read with grep_repo/read_file_lines (an S3 knowledge base may be "
2340
2374
  "stale — do not trust rag_search for freshly edited code).\n"
2341
2375
  ) if _WS_AVAILABLE else ""
2342
- _pdf_read_tool_line = (
2343
- f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
2344
- "at a URL. This does NOT create a PDF (use create_pdf for that).\n"
2345
- ) if _PDF_READ_AVAILABLE else ""
2346
2376
  _pdf_read_choose_line = (
2347
2377
  "- 'read'/'summarize'/'what does the PDF say' for an existing PDF URL -> "
2348
2378
  "extract_text_from_pdf(url).\n"
2349
2379
  ) if _PDF_READ_AVAILABLE else ""
2350
- _email_tool_line = (
2351
- f" {_email_num}. send_email(to, subject, body) — send an email via the configured "
2352
- "email API. It PREVIEWS first and only sends after the user replies 'confirm'.\n"
2353
- ) if _EMAIL_AVAILABLE else ""
2354
2380
  _email_choose_line = (
2355
2381
  "- 'email …' / 'send an email to …' -> send_email(to, subject, body) "
2356
2382
  "(previews first, then sends on confirm).\n"
@@ -2362,20 +2388,10 @@ def run_agent_chat(cfg):
2362
2388
  "next (e.g. web_search to find a URL, THEN fetch_url to read it). When you have "
2363
2389
  "enough to answer, call final_answer(<your answer>) in a code block. Prefer "
2364
2390
  "FEWER steps, and never repeat a tool call that already succeeded.\n\n"
2365
- f"You have {_tool_count_word} tools:\n"
2366
- " 1. http_request(method, url, headers='', body='', username='', password='') "
2367
- "call a SPECIFIC known API/URL.\n"
2368
- " 2. web_search(query) look up facts about a person, place, thing, or topic "
2369
- "when you do NOT already have a real URL. Returns a summary + source.\n"
2370
- " 3. fetch_url(url) — READ the full text of a specific web page (e.g. a link the "
2371
- "user gave, or a source URL from web_search). Returns clean text, not raw HTML.\n"
2372
- " 4. calculate(expression) — do math: arithmetic, percentages, powers "
2373
- "(e.g. '15% of 80', '(3+4)*2'). ALWAYS use this instead of computing yourself.\n"
2374
- f" 5. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
2375
- "Use this ONLY when the task explicitly asks for the date or time.\n"
2376
- " 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
2377
- "download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
2378
- + _pdf_read_tool_line + _email_tool_line + _rag_tool_block + _ws_tool_block +
2391
+ "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
2392
+ f"(Current date/time: {now_str} pass a timezone to get_current_datetime() only "
2393
+ "if the task needs a DIFFERENT one.)\n"
2394
+ + _rag_tool_block + _ws_tool_block +
2379
2395
  "\n"
2380
2396
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
2381
2397
  "\n"
@@ -3134,9 +3150,11 @@ def run_agent_chat(cfg):
3134
3150
  msg.content = normalized
3135
3151
  else:
3136
3152
  stripped = content.strip()
3137
- looks_final = len(stripped) >= 400 or re.search(
3138
- r"(^|\n)#{1,4}\s|\n\d+\.\s|\n[-*]\s|\|.+\|", stripped
3139
- ) is not None
3153
+ looks_final = (
3154
+ len(stripped) >= 400 or re.search(
3155
+ r"(^|\n)#{1,4}\s|\n\d+\.\s|\n[-*]\s|\|.+\|", stripped
3156
+ ) is not None
3157
+ ) and not _looks_like_scratchpad(stripped)
3140
3158
  if stripped and looks_final:
3141
3159
  _log(
3142
3160
  f"prose-only turn ({len(stripped)} chars, no tool call) "
@@ -3145,6 +3163,19 @@ def run_agent_chat(cfg):
3145
3163
  msg.content = (
3146
3164
  stripped + "\n<code>\nfinal_answer(" + repr(stripped) + ")\n</code>"
3147
3165
  )
3166
+ elif stripped and _looks_like_scratchpad(stripped):
3167
+ # Looks like unresolved deliberation, not a finished answer — do
3168
+ # NOT wrap it as final_answer (that would dump this rambling
3169
+ # scratchpad into the user-facing reply AND permanently pollute
3170
+ # every future turn's conversation history with it, seen live:
3171
+ # a 6000+ char "Thinking Process:" dump derailed the next two
3172
+ # unrelated questions). Leave content as-is: smolagents' own
3173
+ # code-block parser will fail to find one and burn this step as
3174
+ # a normal parse-error retry, giving the model another attempt.
3175
+ _log(
3176
+ f"scratchpad-like prose ({len(stripped)} chars, no tool call) "
3177
+ "→ NOT auto-wrapping; letting the step fail normally"
3178
+ )
3148
3179
  except Exception as e: # noqa: BLE001
3149
3180
  _log(f"content-normalize error: {e}")
3150
3181
  return msg
@@ -3257,6 +3288,14 @@ def run_agent_chat(cfg):
3257
3288
  text = (_last_obs.get("text") or "").strip()
3258
3289
  if text:
3259
3290
  _log(f"max-steps fallback (last tool obs) → {text[:80]}")
3291
+ # Without this, `text` (a tool's RAW return value — e.g. a bare grep_repo
3292
+ # dump of file:line hits) becomes the entire chat reply with zero framing,
3293
+ # reading as a bizarre non-answer. A short honest note makes clear this is
3294
+ # a partial result from an interrupted run, not the actual answer/edit.
3295
+ text = (
3296
+ "I ran out of steps before finishing this. Here's the last thing I "
3297
+ "found — reply to have me continue from here:\n\n" + text
3298
+ )
3260
3299
  else:
3261
3300
  # No usable tool output (e.g. every step's code failed to parse).
3262
3301
  # Don't dead-end on a canned apology — answer from the conversation.
@@ -3313,12 +3352,18 @@ def run_agent_chat(cfg):
3313
3352
  path, cached = _rag_download(url, (dest_path or "").strip())
3314
3353
  if cached:
3315
3354
  _emit({"type": "step", "text": "Already downloaded — reusing cached file"})
3316
- return (f"Already downloaded earlier — reusing {path} "
3317
- f"({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}').")
3355
+ out = (f"Already downloaded earlier — reusing {path} "
3356
+ f"({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}').")
3357
+ _last_obs["text"] = out
3358
+ return out
3318
3359
  _emit({"type": "step", "text": f"Downloading → {url[:80]}"})
3319
- return f"Downloaded to {path} ({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}') if it is a zip."
3360
+ out = f"Downloaded to {path} ({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}') if it is a zip."
3361
+ _last_obs["text"] = out
3362
+ return out
3320
3363
  except Exception as e: # noqa: BLE001
3321
- return f"Error: download failed: {type(e).__name__}: {e}"
3364
+ msg = f"Error: download failed: {type(e).__name__}: {e}"
3365
+ _last_obs["text"] = msg
3366
+ return msg
3322
3367
 
3323
3368
  @tool
3324
3369
  def unzip_file(zip_path: str, dest_dir: str = "") -> str:
@@ -3334,12 +3379,18 @@ def run_agent_chat(cfg):
3334
3379
  more = f" …(+{len(names) - 15} more)" if len(names) > 15 else ""
3335
3380
  if cached:
3336
3381
  _emit({"type": "step", "text": "Already unzipped — reusing extracted files"})
3337
- return (f"Already unzipped earlier — {len(names)} files at {d}. "
3338
- f"Files: {preview}{more}. Use list_dir/read_text_file or rag_index on it.")
3382
+ out = (f"Already unzipped earlier — {len(names)} files at {d}. "
3383
+ f"Files: {preview}{more}. Use list_dir/read_text_file or rag_index on it.")
3384
+ _last_obs["text"] = out
3385
+ return out
3339
3386
  _emit({"type": "step", "text": "Unzipping…"})
3340
- return f"Unzipped {len(names)} files to {d}. Files: {preview}{more}. Next: rag_index(path='{d}', source_url=<the original url>)."
3387
+ out = f"Unzipped {len(names)} files to {d}. Files: {preview}{more}. Next: rag_index(path='{d}', source_url=<the original url>)."
3388
+ _last_obs["text"] = out
3389
+ return out
3341
3390
  except Exception as e: # noqa: BLE001
3342
- return f"Error: unzip failed: {type(e).__name__}: {e}"
3391
+ msg = f"Error: unzip failed: {type(e).__name__}: {e}"
3392
+ _last_obs["text"] = msg
3393
+ return msg
3343
3394
 
3344
3395
  @tool
3345
3396
  def list_dir(path: str) -> str:
@@ -3352,7 +3403,9 @@ def run_agent_chat(cfg):
3352
3403
  import os
3353
3404
  root = _rag_read_allowed((path or ".").strip())
3354
3405
  if os.path.isfile(root):
3355
- return f"{root} is a file ({os.path.getsize(root)} bytes)."
3406
+ out = f"{root} is a file ({os.path.getsize(root)} bytes)."
3407
+ _last_obs["text"] = out
3408
+ return out
3356
3409
  entries = []
3357
3410
  for dirpath, dirnames, filenames in os.walk(root):
3358
3411
  dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
@@ -3367,9 +3420,13 @@ def run_agent_chat(cfg):
3367
3420
  break
3368
3421
  entries.sort()
3369
3422
  more = " …(truncated)" if len(entries) >= 300 else ""
3370
- return f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
3423
+ out = f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
3424
+ _last_obs["text"] = out
3425
+ return out
3371
3426
  except Exception as e: # noqa: BLE001
3372
- return f"Error: list_dir failed: {type(e).__name__}: {e}"
3427
+ msg = f"Error: list_dir failed: {type(e).__name__}: {e}"
3428
+ _last_obs["text"] = msg
3429
+ return msg
3373
3430
 
3374
3431
  @tool
3375
3432
  def read_text_file(path: str, max_chars: int = 20000) -> str:
@@ -3383,14 +3440,22 @@ def run_agent_chat(cfg):
3383
3440
  import os
3384
3441
  rp = _rag_read_allowed((path or "").strip())
3385
3442
  if not os.path.isfile(rp):
3386
- return f"Error: not a file: {path}"
3443
+ msg = f"Error: not a file: {path}"
3444
+ _last_obs["text"] = msg
3445
+ return msg
3387
3446
  if os.path.getsize(rp) > 5 * 1024 * 1024:
3388
- return "Error: file too large to read (> 5 MB)."
3447
+ msg = "Error: file too large to read (> 5 MB)."
3448
+ _last_obs["text"] = msg
3449
+ return msg
3389
3450
  with open(rp, "r", encoding="utf-8", errors="replace") as fh:
3390
3451
  data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
3391
- return data or "(empty file)"
3452
+ out = data or "(empty file)"
3453
+ _last_obs["text"] = out
3454
+ return out
3392
3455
  except Exception as e: # noqa: BLE001
3393
- return f"Error: read_text_file failed: {type(e).__name__}: {e}"
3456
+ msg = f"Error: read_text_file failed: {type(e).__name__}: {e}"
3457
+ _last_obs["text"] = msg
3458
+ return msg
3394
3459
 
3395
3460
  @tool
3396
3461
  def rag_index(path: str, source_url: str) -> str:
@@ -3401,7 +3466,9 @@ def run_agent_chat(cfg):
3401
3466
  source_url: the original URL the user gave — used as the knowledge-base key.
3402
3467
  """
3403
3468
  if not _RAG_AVAILABLE:
3404
- return "Error: RAG is not configured (enable it + add AWS credentials in Settings)."
3469
+ msg = "Error: RAG is not configured (enable it + add AWS credentials in Settings)."
3470
+ _last_obs["text"] = msg
3471
+ return msg
3405
3472
  _emit({"type": "step", "text": f"RAG source → {source_url}"})
3406
3473
  _emit({"type": "step", "text": "Indexing files for RAG…"})
3407
3474
  try:
@@ -3417,7 +3484,9 @@ def run_agent_chat(cfg):
3417
3484
  files += 1
3418
3485
  records.extend(_rag_chunk_text(text, rel, ext))
3419
3486
  if not records:
3420
- return "No indexable text files found at that path."
3487
+ msg = "No indexable text files found at that path."
3488
+ _last_obs["text"] = msg
3489
+ return msg
3421
3490
  for i in range(0, len(records), 64):
3422
3491
  batch = records[i:i + 64]
3423
3492
  vecs = _embed(rag_embed_base, rag_embed_model, [r["text"] for r in batch])
@@ -3438,10 +3507,14 @@ def run_agent_chat(cfg):
3438
3507
  client.put_object(Bucket=bucket, Key=f"{base}/manifest.json",
3439
3508
  Body=json.dumps(manifest).encode("utf-8"),
3440
3509
  ContentType="application/json")
3441
- return (f"Indexed {files} files into {len(records)} chunks for {source_url}. "
3442
- f"Use rag_search(source_url='{source_url}', query=...) to retrieve, then answer the user.")
3510
+ out = (f"Indexed {files} files into {len(records)} chunks for {source_url}. "
3511
+ f"Use rag_search(source_url='{source_url}', query=...) to retrieve, then answer the user.")
3512
+ _last_obs["text"] = out
3513
+ return out
3443
3514
  except Exception as e: # noqa: BLE001
3444
- return f"Error: indexing failed: {type(e).__name__}: {e}"
3515
+ msg = f"Error: indexing failed: {type(e).__name__}: {e}"
3516
+ _last_obs["text"] = msg
3517
+ return msg
3445
3518
 
3446
3519
  @tool
3447
3520
  def rag_add(source_url: str, text: str) -> str:
@@ -3452,9 +3525,13 @@ def run_agent_chat(cfg):
3452
3525
  text: the new information to embed and store.
3453
3526
  """
3454
3527
  if not _RAG_AVAILABLE:
3455
- return "Error: RAG is not configured."
3528
+ msg = "Error: RAG is not configured."
3529
+ _last_obs["text"] = msg
3530
+ return msg
3456
3531
  if not (text or "").strip():
3457
- return "Error: no text to add."
3532
+ msg = "Error: no text to add."
3533
+ _last_obs["text"] = msg
3534
+ return msg
3458
3535
  _emit({"type": "step", "text": f"RAG source → {source_url}"})
3459
3536
  _emit({"type": "step", "text": "Adding info to RAG…"})
3460
3537
  try:
@@ -3468,9 +3545,13 @@ def run_agent_chat(cfg):
3468
3545
  shard = f"{base}/chunks-add-{int(_t.time())}.jsonl"
3469
3546
  body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
3470
3547
  client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
3471
- return f"Added {len(records)} chunk(s) to the knowledge base for {source_url}."
3548
+ out = f"Added {len(records)} chunk(s) to the knowledge base for {source_url}."
3549
+ _last_obs["text"] = out
3550
+ return out
3472
3551
  except Exception as e: # noqa: BLE001
3473
- return f"Error: rag_add failed: {type(e).__name__}: {e}"
3552
+ msg = f"Error: rag_add failed: {type(e).__name__}: {e}"
3553
+ _last_obs["text"] = msg
3554
+ return msg
3474
3555
 
3475
3556
  @tool
3476
3557
  def rag_search(source_url: str, query: str, k: int = 0) -> str:
@@ -3482,7 +3563,9 @@ def run_agent_chat(cfg):
3482
3563
  k: number of chunks to return (0 = configured default).
3483
3564
  """
3484
3565
  if not _RAG_AVAILABLE:
3485
- return "Error: RAG is not configured."
3566
+ msg = "Error: RAG is not configured."
3567
+ _last_obs["text"] = msg
3568
+ return msg
3486
3569
  _emit({"type": "step", "text": f"RAG source → {source_url}"})
3487
3570
  _emit({"type": "step", "text": f"Searching RAG → {query[:60]}"})
3488
3571
  try:
@@ -3490,7 +3573,9 @@ def run_agent_chat(cfg):
3490
3573
  base = _rag_prefix_for(source_url, prefix)
3491
3574
  chunks = _rag_load_chunks(client, bucket, base)
3492
3575
  if not chunks:
3493
- return f"No knowledge base found for {source_url}. Run rag_index first."
3576
+ msg = f"No knowledge base found for {source_url}. Run rag_index first."
3577
+ _last_obs["text"] = msg
3578
+ return msg
3494
3579
  qvec = _embed(rag_embed_base, rag_embed_model, [query])[0]
3495
3580
  scored = sorted(
3496
3581
  ((_cosine(qvec, c.get("embedding") or []), c) for c in chunks),
@@ -3499,9 +3584,13 @@ def run_agent_chat(cfg):
3499
3584
  topk = scored[: (k if k and k > 0 else rag_top_k)]
3500
3585
  parts = [f"[{c.get('file', '?')}] (score {score:.2f})\n{c.get('text', '')}"
3501
3586
  for score, c in topk]
3502
- return "\n\n---\n\n".join(parts) if parts else "No matches."
3587
+ out = "\n\n---\n\n".join(parts) if parts else "No matches."
3588
+ _last_obs["text"] = out
3589
+ return out
3503
3590
  except Exception as e: # noqa: BLE001
3504
- return f"Error: rag_search failed: {type(e).__name__}: {e}"
3591
+ msg = f"Error: rag_search failed: {type(e).__name__}: {e}"
3592
+ _last_obs["text"] = msg
3593
+ return msg
3505
3594
 
3506
3595
  @tool
3507
3596
  def grep_repo(pattern: str, path: str = "", glob: str = "") -> str:
@@ -3542,11 +3631,17 @@ def run_agent_chat(cfg):
3542
3631
  break
3543
3632
  _emit({"type": "step", "text": f"Searching code → {pattern[:60]} ({len(hits)} hits)"})
3544
3633
  if not hits:
3545
- return f"No matches for {pattern!r} under {root}."
3634
+ msg = f"No matches for {pattern!r} under {root}."
3635
+ _last_obs["text"] = msg
3636
+ return msg
3546
3637
  more = "\n…(capped at 100 hits)" if len(hits) >= 100 else ""
3547
- return f"Matches under {root}:\n" + "\n".join(hits) + more
3638
+ out = f"Matches under {root}:\n" + "\n".join(hits) + more
3639
+ _last_obs["text"] = out
3640
+ return out
3548
3641
  except Exception as e: # noqa: BLE001
3549
- return f"Error: grep_repo failed: {type(e).__name__}: {e}"
3642
+ msg = f"Error: grep_repo failed: {type(e).__name__}: {e}"
3643
+ _last_obs["text"] = msg
3644
+ return msg
3550
3645
 
3551
3646
  @tool
3552
3647
  def read_file_lines(path: str, start_line: int = 1, end_line: int = 0) -> str:
@@ -3565,9 +3660,13 @@ def run_agent_chat(cfg):
3565
3660
  e = int(end_line or 0) or len(lines)
3566
3661
  e = min(e, len(lines), s + 399)
3567
3662
  body = "".join(f"{i}: {lines[i - 1]}" for i in range(s, e + 1))
3568
- return f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}"
3663
+ out = f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}"
3664
+ _last_obs["text"] = out
3665
+ return out
3569
3666
  except Exception as ex: # noqa: BLE001
3570
- return f"Error: read_file_lines failed: {type(ex).__name__}: {ex}"
3667
+ msg = f"Error: read_file_lines failed: {type(ex).__name__}: {ex}"
3668
+ _last_obs["text"] = msg
3669
+ return msg
3571
3670
 
3572
3671
  @tool
3573
3672
  def edit_lines(path: str, start_line: int, end_line: int, new_content: str) -> str:
@@ -3583,14 +3682,18 @@ def run_agent_chat(cfg):
3583
3682
  rp = _ws_write_allowed((path or "").strip())
3584
3683
  import os
3585
3684
  if not os.path.isfile(rp):
3586
- return f"Error: not a file: {path}"
3685
+ msg = f"Error: not a file: {path}"
3686
+ _last_obs["text"] = msg
3687
+ return msg
3587
3688
  backup = _ws_snapshot(rp)
3588
3689
  with open(rp, "r", encoding="utf-8", errors="replace") as fh:
3589
3690
  lines = fh.readlines()
3590
3691
  s, e = int(start_line), int(end_line)
3591
3692
  if not (1 <= s <= e <= len(lines)):
3592
- return (f"Error: line range {s}-{e} is out of bounds "
3593
- f"(file has {len(lines)} lines). Re-check with read_file_lines.")
3693
+ msg = (f"Error: line range {s}-{e} is out of bounds "
3694
+ f"(file has {len(lines)} lines). Re-check with read_file_lines.")
3695
+ _last_obs["text"] = msg
3696
+ return msg
3594
3697
  new_lines = new_content.splitlines(keepends=True)
3595
3698
  if new_lines and not new_lines[-1].endswith("\n"):
3596
3699
  new_lines[-1] += "\n"
@@ -3601,10 +3704,14 @@ def run_agent_chat(cfg):
3601
3704
  _emit({"type": "step", "text": f"Edited {os.path.basename(rp)} lines {s}-{e}"})
3602
3705
  shown = "".join(f"{i}: {lines[i - 1]}" for i in
3603
3706
  range(max(1, s - 2), min(len(lines), s + len(new_lines) + 1) + 1))
3604
- return (f"Replaced lines {s}-{e} of {rp} with {len(new_lines)} line(s). "
3605
- f"Backup saved. Updated region:\n{shown}")
3707
+ out = (f"Replaced lines {s}-{e} of {rp} with {len(new_lines)} line(s). "
3708
+ f"Backup saved. Updated region:\n{shown}")
3709
+ _last_obs["text"] = out
3710
+ return out
3606
3711
  except Exception as ex: # noqa: BLE001
3607
- return f"Error: edit_lines failed: {type(ex).__name__}: {ex}"
3712
+ msg = f"Error: edit_lines failed: {type(ex).__name__}: {ex}"
3713
+ _last_obs["text"] = msg
3714
+ return msg
3608
3715
 
3609
3716
  @tool
3610
3717
  def edit_file(path: str, old_string: str, new_string: str) -> str:
@@ -3619,23 +3726,33 @@ def run_agent_chat(cfg):
3619
3726
  rp = _ws_write_allowed((path or "").strip())
3620
3727
  import os
3621
3728
  if not os.path.isfile(rp):
3622
- return f"Error: not a file: {path}"
3729
+ msg = f"Error: not a file: {path}"
3730
+ _last_obs["text"] = msg
3731
+ return msg
3623
3732
  with open(rp, "r", encoding="utf-8", errors="replace") as fh:
3624
3733
  text = fh.read()
3625
3734
  n = text.count(old_string)
3626
3735
  if n == 0:
3627
- return ("Error: old_string not found — it must match EXACTLY "
3628
- "(check whitespace with read_file_lines, or use edit_lines).")
3736
+ msg = ("Error: old_string not found — it must match EXACTLY "
3737
+ "(check whitespace with read_file_lines, or use edit_lines).")
3738
+ _last_obs["text"] = msg
3739
+ return msg
3629
3740
  if n > 1:
3630
- return f"Error: old_string occurs {n} times — add surrounding context to make it unique, or use edit_lines."
3741
+ msg = f"Error: old_string occurs {n} times — add surrounding context to make it unique, or use edit_lines."
3742
+ _last_obs["text"] = msg
3743
+ return msg
3631
3744
  backup = _ws_snapshot(rp)
3632
3745
  with open(rp, "w", encoding="utf-8") as fh:
3633
3746
  fh.write(text.replace(old_string, new_string, 1))
3634
3747
  _ws_record_change("edit", rp, backup)
3635
3748
  _emit({"type": "step", "text": f"Edited {os.path.basename(rp)}"})
3636
- return f"Replaced 1 occurrence in {rp}. Backup saved."
3749
+ out = f"Replaced 1 occurrence in {rp}. Backup saved."
3750
+ _last_obs["text"] = out
3751
+ return out
3637
3752
  except Exception as ex: # noqa: BLE001
3638
- return f"Error: edit_file failed: {type(ex).__name__}: {ex}"
3753
+ msg = f"Error: edit_file failed: {type(ex).__name__}: {ex}"
3754
+ _last_obs["text"] = msg
3755
+ return msg
3639
3756
 
3640
3757
  @tool
3641
3758
  def create_file(path: str, content: str) -> str:
@@ -3649,15 +3766,21 @@ def run_agent_chat(cfg):
3649
3766
  rp = _ws_write_allowed((path or "").strip())
3650
3767
  import os
3651
3768
  if os.path.exists(rp):
3652
- return f"Error: {path} already exists — use edit_lines/edit_file to change it."
3769
+ msg = f"Error: {path} already exists — use edit_lines/edit_file to change it."
3770
+ _last_obs["text"] = msg
3771
+ return msg
3653
3772
  os.makedirs(os.path.dirname(rp) or ".", exist_ok=True)
3654
3773
  with open(rp, "w", encoding="utf-8") as fh:
3655
3774
  fh.write(content)
3656
3775
  _ws_record_change("create", rp, None)
3657
3776
  _emit({"type": "step", "text": f"Created {os.path.basename(rp)}"})
3658
- return f"Created {rp} ({len(content)} chars)."
3777
+ out = f"Created {rp} ({len(content)} chars)."
3778
+ _last_obs["text"] = out
3779
+ return out
3659
3780
  except Exception as ex: # noqa: BLE001
3660
- return f"Error: create_file failed: {type(ex).__name__}: {ex}"
3781
+ msg = f"Error: create_file failed: {type(ex).__name__}: {ex}"
3782
+ _last_obs["text"] = msg
3783
+ return msg
3661
3784
 
3662
3785
  @tool
3663
3786
  def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
@@ -3675,14 +3798,20 @@ def run_agent_chat(cfg):
3675
3798
  wd = _rag_read_allowed((workdir or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
3676
3799
  w = _ws_for_path(wd)
3677
3800
  if w is None or not w.get("allowRun"):
3678
- return ("Error: running commands is not enabled for this workspace. "
3679
- "Re-register it with: gonext-local-worker workspace add <path> --allow-run")
3801
+ msg = ("Error: running commands is not enabled for this workspace. "
3802
+ "Re-register it with: gonext-local-worker workspace add <path> --allow-run")
3803
+ _last_obs["text"] = msg
3804
+ return msg
3680
3805
  argv = shlex.split(command or "")
3681
3806
  if not argv:
3682
- return "Error: empty command."
3807
+ msg = "Error: empty command."
3808
+ _last_obs["text"] = msg
3809
+ return msg
3683
3810
  if argv[0] not in _WS_RUN_ALLOWED:
3684
- return (f"Error: '{argv[0]}' is not an allowed runner. Allowed: "
3685
- + ", ".join(sorted(_WS_RUN_ALLOWED)))
3811
+ msg = (f"Error: '{argv[0]}' is not an allowed runner. Allowed: "
3812
+ + ", ".join(sorted(_WS_RUN_ALLOWED)))
3813
+ _last_obs["text"] = msg
3814
+ return msg
3686
3815
  _emit({"type": "step", "text": f"Running → {command[:70]}"})
3687
3816
  t = max(5, min(int(timeout_seconds or 180), 600))
3688
3817
  # Scrubbed env: repo test scripts must never see worker secrets.
@@ -3693,16 +3822,23 @@ def run_agent_chat(cfg):
3693
3822
  out = _ws_distill_output(proc.stdout or "")
3694
3823
  status = "PASSED (exit 0)" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
3695
3824
  _emit({"type": "step", "text": f"Command {status.split()[0].lower()} → {command[:50]}"})
3696
- return f"{status}\n{out}" if out.strip() else status
3825
+ result = f"{status}\n{out}" if out.strip() else status
3826
+ _last_obs["text"] = result
3827
+ return result
3697
3828
  except subprocess.TimeoutExpired:
3698
- return f"Error: command timed out after {timeout_seconds}s."
3829
+ msg = f"Error: command timed out after {timeout_seconds}s."
3830
+ _last_obs["text"] = msg
3831
+ return msg
3699
3832
  except Exception as ex: # noqa: BLE001
3700
- return f"Error: run_command failed: {type(ex).__name__}: {ex}"
3833
+ msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
3834
+ _last_obs["text"] = msg
3835
+ return msg
3701
3836
 
3702
3837
  agent_tools = [http_request, web_search, fetch_url, calculate,
3703
3838
  get_current_datetime, create_pdf]
3704
- # Only register the PDF reader / email / RAG tools when available (kept in sync
3705
- # with the gated hint entries above so the model never sees a tool it can't run).
3839
+ # Only register the PDF reader / email / RAG tools when available the model
3840
+ # never sees a tool it can't run, since the task-hint's tool list (filled in just
3841
+ # below) is generated FROM this exact list, so it's always in sync by construction.
3706
3842
  if _PDF_READ_AVAILABLE:
3707
3843
  agent_tools.append(extract_text_from_pdf)
3708
3844
  if _EMAIL_AVAILABLE:
@@ -3717,6 +3853,12 @@ def run_agent_chat(cfg):
3717
3853
  # (S3 indexing) isn't configured.
3718
3854
  if not _RAG_AVAILABLE:
3719
3855
  agent_tools += [list_dir, read_text_file]
3856
+ # Fill in the numbered tool list now that the REAL tool objects exist — see
3857
+ # _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
3858
+ task_with_hint = task_with_hint.replace(
3859
+ "{{TOOL_LIST}}",
3860
+ f"You have {len(agent_tools)} tools:\n" + _render_numbered_tool_list(agent_tools),
3861
+ )
3720
3862
  agent_kwargs = dict(
3721
3863
  tools=agent_tools,
3722
3864
  model=model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.191",
3
+ "version": "1.0.193",
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",