@tiens.nguyen/gonext-local-worker 1.0.243 → 1.0.246

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-repl.mjs CHANGED
@@ -1082,11 +1082,12 @@ async function runAgentTurn(history) {
1082
1082
  const max = Math.max(24, (process.stdout.columns || 80) - 14);
1083
1083
  const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
1084
1084
  clearStatus();
1085
- // Line 1: green ● + yellow phase/seconds. Line 2 (indented): the SPINNER + the playful
1086
- // word together, both in the cycling color the spinner belongs with the word, like
1087
- // the web (dot on the label line, spinner leading the flavor line).
1085
+ // Line 1: green ● + green phase/seconds (matches the web's green streaming label).
1086
+ // Line 2 (indented): the SPINNER + the playful word together, both in the cycling
1087
+ // color — the spinner belongs with the word (dot on the label line, spinner leading
1088
+ // the flavor line).
1088
1089
  process.stdout.write(
1089
- `${green(BULLET)} ${yellow(`${fit(primary)}… (${secs}s)`)}` +
1090
+ `${green(BULLET)} ${green(`${fit(primary)}… (${secs}s)`)}` +
1090
1091
  "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}`
1091
1092
  );
1092
1093
  statusShown = true;
@@ -3181,19 +3181,26 @@ def run_agent_chat(cfg):
3181
3181
  # flakes, prove the test ran, cap retries) so it converges instead of chasing green.
3182
3182
  _auto_test_block = (
3183
3183
  "\nAUTO-TEST MODE (ON): after you CHANGE code you MUST verify it before "
3184
- "final_answer — never finish on an untested change. Use the CHEAPEST check that "
3185
- "proves the change, climbing only as needed:\n"
3186
- " 1. grep_repo the edited file to confirm the change landed (and the old code is gone).\n"
3187
- " 2. build / typecheck (e.g. run_command('npm run build') / 'tsc --noEmit' / 'go build').\n"
3188
- " 3. run the project's tests (npm test / pytest / …) if they exist.\n"
3189
- " 4. if it's a running app, start it and curl/fetch_url it, then check the response "
3184
+ "final_answer — never finish on an untested change. Climb the CHEAPEST check that "
3185
+ "proves the change, only as far as needed:\n"
3186
+ " 1. grep_repo the edited file to confirm the change landed (old code gone).\n"
3187
+ " 2. STATIC (cheapest — nothing runs): typecheck + lint. run_command('tsc --noEmit' "
3188
+ "/ 'mypy' / 'go vet'; 'eslint' / 'ruff'). Fix errors here first.\n"
3189
+ " 3. build / compile if the project does (npm run build / go build).\n"
3190
+ " 4. run the project's tests (npm test / pytest / …) if they exist — prefer the "
3191
+ "SINGLE test/file covering your change for speed (e.g. pytest path::test_x).\n"
3192
+ " 5. if it's a running app, start it and curl/fetch_url it, then check the response "
3190
3193
  "contains what the change should produce.\n"
3191
- "DECIDE what 'passes' BEFORE you fix do NOT weaken the check to make it green. If a "
3192
- "check FAILS: read the error, make ONE fix, then RE-TEST the SAME way. If it passes only "
3193
- "after re-running with no change, it was flaky — stop, don't keep editing. Give up after "
3194
- "3 fix attempts on the same failure and final_answer honestly what still fails. Confirm a "
3195
- "test actually exercised the change (e.g. the asserted text was really present), not an "
3196
- "empty/no-op pass.\n"
3194
+ "FIXING A BUG write a MINIMAL test that reproduces it and run it FIRST: it must FAIL "
3195
+ "(red) BEFORE your fix and PASS (green) after. A test that passes before you change "
3196
+ "anything proves nothing. Keep it with the project's other tests.\n"
3197
+ "Before final_answer, run_command('git diff') (or 'git status' if not a git repo) to "
3198
+ "review EVERYTHING you changed only what you intended, no stray edits or debug prints.\n"
3199
+ "DECIDE what 'passes' BEFORE you fix — do NOT weaken the check to make it green. On a "
3200
+ "FAIL: read the error, make ONE fix, RE-TEST the SAME way. Passes only after a no-change "
3201
+ "re-run = flaky, stop editing. Give up after 3 fix attempts on the same failure and "
3202
+ "final_answer honestly what still fails. Confirm a test actually exercised the change "
3203
+ "(the asserted behavior really ran), not an empty/no-op pass.\n"
3197
3204
  if (_auto_test and _WS_AVAILABLE) else ""
3198
3205
  )
3199
3206
  # Deploy target (/server): when the user picked one, tell the agent to deploy THERE
@@ -3383,6 +3390,54 @@ def run_agent_chat(cfg):
3383
3390
  # still deterministically deliver (e.g. render a partial PDF) instead of dead-ending.
3384
3391
  _gathered: list = []
3385
3392
 
3393
+ # --- Loop / no-progress guards (task #79) -------------------------------------------
3394
+ # Weak coders thrash on a debug loop: rewrite a file to a previously-tried version,
3395
+ # re-hit a broken endpoint, and re-read unchanged files — burning the whole step
3396
+ # budget without new information. These caches let the tools detect and refuse that.
3397
+ _file_writes: dict = {} # realpath -> {content hashes already written this run}
3398
+ _url_5xx: dict = {} # localhost url -> how many 5xx it has returned
3399
+ _reads: dict = {} # realpath -> (mtime, identical-read count)
3400
+
3401
+ def _server_log_tail_for_url(url: str) -> str:
3402
+ """If `url` is a localhost server we started via run_command, return the tail of
3403
+ its run-log — the REAL error. A 5xx returns only an HTML error page; the actual
3404
+ stack trace lives in the server's stdout log (task #79)."""
3405
+ try:
3406
+ import os as _os
3407
+ from urllib.parse import urlparse
3408
+ p = urlparse(url)
3409
+ if p.hostname not in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
3410
+ return ""
3411
+ for s in _ws_load_servers():
3412
+ if p.port and p.port in (s.get("ports") or []):
3413
+ logp = s.get("log") or ""
3414
+ if logp and _os.path.isfile(logp):
3415
+ with open(logp, "r", encoding="utf-8", errors="replace") as fh:
3416
+ return _ws_distill_output(fh.read())[-1500:]
3417
+ return ""
3418
+ except Exception: # noqa: BLE001
3419
+ return ""
3420
+
3421
+ def _read_repeat_block(rp: str) -> str:
3422
+ """Refuse a 3rd+ identical read of an UNCHANGED file (a common step-waster)."""
3423
+ try:
3424
+ import os as _os
3425
+ mt = _os.path.getmtime(rp)
3426
+ except OSError:
3427
+ return ""
3428
+ prev = _reads.get(rp)
3429
+ if prev and prev[0] == mt:
3430
+ cnt = prev[1] + 1
3431
+ _reads[rp] = (mt, cnt)
3432
+ if cnt >= 3:
3433
+ import os as _os2
3434
+ return (f"You've already read {_os2.path.basename(rp)} {cnt} times and it "
3435
+ "hasn't changed. Stop re-reading it — act on what it says, or "
3436
+ "call final_answer.")
3437
+ return ""
3438
+ _reads[rp] = (mt, 1)
3439
+ return ""
3440
+
3386
3441
  def _dedup(tool_name: str, arg: str, next_hint: str):
3387
3442
  """Return (is_repeat, message). On a repeat, message steers the model forward."""
3388
3443
  key = _norm_query(arg) if tool_name == "web_search" else (arg or "").strip().lower()
@@ -3452,6 +3507,23 @@ def run_agent_chat(cfg):
3452
3507
  # Append a success tag to 2xx JSON responses so the model stops and calls final_answer.
3453
3508
  elif result and result.startswith("HTTP 2"):
3454
3509
  result = result + "\n[SUCCESS — call final_answer(response) now, do not parse or retry]"
3510
+ # A 5xx from a localhost dev server (task #79): the HTML error page hides the real
3511
+ # cause. Surface the SERVER'S OWN LOG (the actual stack trace) so the model fixes
3512
+ # the specific error instead of guessing/oscillating — and hard-stop after a few
3513
+ # repeats so it can't burn the whole budget re-hitting a broken endpoint.
3514
+ _m5 = re.match(r"HTTP\s+(5\d\d)", result or "")
3515
+ if _m5:
3516
+ _url_5xx[url] = _url_5xx.get(url, 0) + 1
3517
+ _tail = _server_log_tail_for_url(url)
3518
+ if _tail:
3519
+ result = (result + f"\n[SERVER ERROR LOG — the real cause of the "
3520
+ f"{_m5.group(1)} (fix THIS, don't guess):\n{_tail}\n]")
3521
+ if _url_5xx[url] >= 3:
3522
+ result = result + (
3523
+ f"\n[STOP: {url} has returned a 5xx {_url_5xx[url]} times. Do NOT request "
3524
+ "it again or rewrite the same file again. Fix the SPECIFIC error in the "
3525
+ "server log above, or call final_answer honestly stating what's failing.]"
3526
+ )
3455
3527
  _emit({"type": "step", "text": f"HTTP {method.upper()} {url} → {status_line}"})
3456
3528
  _log(f"http_request {method.upper()} {url} → {result[:80]}")
3457
3529
  _last_obs["text"] = result
@@ -4307,14 +4379,23 @@ def run_agent_chat(cfg):
4307
4379
  text = (_last_obs.get("text") or "").strip()
4308
4380
  if text:
4309
4381
  _log(f"max-steps fallback (last tool obs) → {text[:80]}")
4310
- # Without this, `text` (a tool's RAW return value e.g. a bare grep_repo
4311
- # dump of file:line hits) becomes the entire chat reply with zero framing,
4312
- # reading as a bizarre non-answer. A short honest note makes clear this is
4313
- # a partial result from an interrupted run, not the actual answer/edit.
4314
- text = (
4315
- "I ran out of steps before finishing this. Here's the last thing I "
4316
- "found reply to have me continue from here:\n\n" + text
4317
- )
4382
+ # Summarize WHAT WAS TRIED + the current blocker, not just the last tool
4383
+ # observation (task #79): a bare last obs ('Stopped npm run dev') reads as a
4384
+ # non-answer and hides the loop the run got stuck in. Build a compact bullet
4385
+ # list of the tool calls from this turn's step trace + the last result.
4386
+ _tried = []
4387
+ for _st in _turn_steps[-8:]:
4388
+ _did = str(_st.get("did") or "").strip().replace("\n", " ")
4389
+ _m = re.search(r"([a-z_]+\([^\n]{0,70})", _did)
4390
+ _label = (_m.group(1) if _m else _did)[:70].strip()
4391
+ if _label:
4392
+ _tried.append(f" • {_label}")
4393
+ _summary = "I ran out of steps before finishing this."
4394
+ if _tried:
4395
+ _summary += " Here's what I tried:\n" + "\n".join(_tried)
4396
+ _summary += "\n\nCurrent blocker (last result):\n" + text[:600]
4397
+ _summary += "\n\nReply 'continue' to have me keep going from here."
4398
+ text = _summary
4318
4399
  else:
4319
4400
  # No usable tool output (e.g. every step's code failed to parse).
4320
4401
  # Don't dead-end on a canned apology — answer from the conversation.
@@ -4425,6 +4506,10 @@ def run_agent_chat(cfg):
4425
4506
  out = f"{root} is a file ({os.path.getsize(root)} bytes)."
4426
4507
  _last_obs["text"] = out
4427
4508
  return out
4509
+ _rb = _read_repeat_block(root)
4510
+ if _rb:
4511
+ _last_obs["text"] = _rb
4512
+ return _rb
4428
4513
  entries = []
4429
4514
  for dirpath, dirnames, filenames in os.walk(root):
4430
4515
  dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
@@ -4467,6 +4552,10 @@ def run_agent_chat(cfg):
4467
4552
  msg = "Error: file too large to read (> 5 MB)."
4468
4553
  _last_obs["text"] = msg
4469
4554
  return msg
4555
+ _rb = _read_repeat_block(rp)
4556
+ if _rb:
4557
+ _last_obs["text"] = _rb
4558
+ return _rb
4470
4559
  with open(rp, "r", encoding="utf-8", errors="replace") as fh:
4471
4560
  data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
4472
4561
  out = _ws_cap_obs(data or "(empty file)")
@@ -4800,12 +4889,26 @@ def run_agent_chat(cfg):
4800
4889
  try:
4801
4890
  rp = _ws_write_allowed((path or "").strip())
4802
4891
  import os
4892
+ import hashlib as _hl
4803
4893
  existed = os.path.exists(rp)
4804
4894
  if existed and not overwrite:
4805
4895
  msg = (f"Error: {path} already exists — pass overwrite=True to replace the "
4806
4896
  "whole file, or use edit_lines/edit_file for a partial change.")
4807
4897
  _last_obs["text"] = msg
4808
4898
  return msg
4899
+ # Oscillation guard (task #79): if the model rewrites a file to a version it
4900
+ # ALREADY wrote this run (the NextResponse ↔ new Response flip-flop), that
4901
+ # exact content already failed — refuse instead of looping on a non-fix.
4902
+ _ch = _hl.sha1((content or "").encode("utf-8", "replace")).hexdigest()
4903
+ _seen = _file_writes.setdefault(rp, set())
4904
+ if existed and _ch in _seen:
4905
+ msg = (f"You already wrote this EXACT content to {os.path.basename(rp)} "
4906
+ "earlier this task and it did not fix the problem — writing it "
4907
+ "again won't help. Do NOT rewrite this file with the same content. "
4908
+ "Read the server error log, try a genuinely DIFFERENT fix, or call "
4909
+ "final_answer honestly stating what's still failing.")
4910
+ _last_obs["text"] = msg
4911
+ return msg
4809
4912
  # Snapshot BEFORE overwriting so revert restores the prior version. Record as
4810
4913
  # an "edit" (not "create") when the file already existed, so revert restores
4811
4914
  # the backup instead of deleting a file the user may have had before.
@@ -4820,6 +4923,7 @@ def run_agent_chat(cfg):
4820
4923
  ("Rewrote file — " if existed else "Added ") + f"{len(content_ls)} line(s)",
4821
4924
  added=[(i + 1, t) for i, t in enumerate(content_ls)],
4822
4925
  )})
4926
+ _seen.add(_ch) # remember this content so a later revert-to-it is caught
4823
4927
  out = f"{'Overwrote' if existed else 'Created'} {rp} ({len(content)} chars)."
4824
4928
  _last_obs["text"] = out
4825
4929
  return out
@@ -4938,6 +5042,23 @@ def run_agent_chat(cfg):
4938
5042
  _last_obs["text"] = msg
4939
5043
  return msg
4940
5044
  # else: destructive but non-interactive → allowed by default (unchanged)
5045
+ # Don't spawn a DUPLICATE of a server that's already running (task #79): the
5046
+ # model re-runs 'npm run dev' and Next/Vite then bind a SECOND port, which
5047
+ # confused the whole debug loop (3000 500 vs 3001 refused). If the identical
5048
+ # command+workdir is still listening, reuse it.
5049
+ for _srv in _ws_load_servers():
5050
+ if _srv.get("command") == command and _srv.get("workdir") == wd:
5051
+ _live = _ws_listening_ports(_srv.get("pid") or 0)
5052
+ if _live:
5053
+ _urls = ", ".join(f"http://localhost:{p}" for p in _live)
5054
+ msg = (f"'{command}' is ALREADY running — {_urls} (pid "
5055
+ f"{_srv.get('pid')}). Reusing it; do NOT start another. A "
5056
+ "dev server hot-reloads code changes automatically, so just "
5057
+ f"request {_urls} again. Use stop_server first only if you "
5058
+ "truly need a full restart.")
5059
+ _emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
5060
+ _last_obs["text"] = msg
5061
+ return msg
4941
5062
  _emit({"type": "step", "text": f"Running → {command[:70]}"})
4942
5063
  t = max(5, min(int(timeout_seconds or 180), 600))
4943
5064
  # Own process group (start_new_session) + output to a log file. This is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.243",
3
+ "version": "1.0.246",
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",