@tiens.nguyen/gonext-local-worker 1.0.244 → 1.0.247

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.
@@ -1879,6 +1879,9 @@ async function runAgentChatJob(job) {
1879
1879
  // REPL's "I can show a picker" signal (else python keeps hard-blocking, as on web).
1880
1880
  jobId,
1881
1881
  interactiveApproval: payload?.interactiveApproval === true,
1882
+ // Session opt-in (task #80): auto-extend past the step budget without re-prompting
1883
+ // (the REPL sets this once the user picks "yes, don't ask again").
1884
+ alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
1882
1885
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
1883
1886
  autoTest: payload?.autoTest === true,
1884
1887
  // Deploy target chosen via the terminal /server picker (task #69). Host/user only.
package/gonext-repl.mjs CHANGED
@@ -778,8 +778,25 @@ function onApprovalKey(key) {
778
778
  // Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
779
779
  // (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
780
780
  // under the python side's 180s wait); Enter is instant since Yes is preselected.
781
- function approvalPrompt(command) {
782
- return new Promise((resolve) => {
781
+ // The command carries a "__MAXSTEP__::<message>" sentinel when it's the step-budget
782
+ // continue prompt (task #80) rather than a risky-command approval. That case shows a
783
+ // 3-option picker (Yes / Yes-don't-ask-again / No); a normal command shows Yes/No.
784
+ // Returns { allow, dontAsk } — dontAsk is only ever true for the max-step "don't ask
785
+ // again this session" choice (the caller then stops sending the prompt).
786
+ const MAXSTEP_PREFIX = "__MAXSTEP__::";
787
+ async function approvalPrompt(command) {
788
+ const isMax = command.startsWith(MAXSTEP_PREFIX);
789
+ if (isMax) {
790
+ const label = command.slice(MAXSTEP_PREFIX.length);
791
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
792
+ process.stdout.write("\n" + yellow("⏭ " + label) + "\n");
793
+ const idx = await pickFromList(
794
+ ["Yes, keep going", "Yes, and don't ask again this session", "No, stop here"],
795
+ 0
796
+ );
797
+ return { allow: idx === 0 || idx === 1, dontAsk: idx === 1 };
798
+ }
799
+ const allow = await new Promise((resolve) => {
783
800
  if (!process.stdin.isTTY) {
784
801
  process.stdout.write(dim(`\n(approval needed for: ${command} — no interactive terminal, skipping)\n`));
785
802
  resolve(false);
@@ -799,6 +816,7 @@ function approvalPrompt(command) {
799
816
  orig(v);
800
817
  };
801
818
  });
819
+ return { allow, dontAsk: false };
802
820
  }
803
821
  // Generic arrow-key list picker (↑/↓ + Enter) — used by /server so you navigate instead
804
822
  // of typing a number. Same mechanism as the Yes/No approval picker above, generalized to
@@ -860,6 +878,10 @@ let sessionCodingModel = "";
860
878
  // and fixes → re-tests until it passes before finishing. Default off; remembered per
861
879
  // folder in the session file; sent to /agent-ask as autoTest so python drives the loop.
862
880
  let sessionTestAuto = false;
881
+ // Step-budget opt-out (task #80): set once the user picks "yes, don't ask again" at a
882
+ // max-step prompt. Session-scoped (not persisted per folder); sent to /agent-ask each turn
883
+ // so the agent auto-extends past the step budget without prompting again.
884
+ let alwaysExtendOnMaxStep = false;
863
885
  // Deploy target chosen via /server (task #69): {name, host, user} or null. Session-scoped;
864
886
  // sent to /agent-ask as deployServer so the agent deploys to this host with KEY auth.
865
887
  let selectedServer = null;
@@ -965,6 +987,9 @@ async function runAgentTurn(history) {
965
987
  // The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
966
988
  // asks instead of hard-blocking it (interactive command-approval gate).
967
989
  interactiveApproval: true,
990
+ // Step-budget opt-out (task #80): once the user chose "don't ask again", auto-extend
991
+ // past the step budget without prompting.
992
+ ...(alwaysExtendOnMaxStep ? { alwaysExtendOnMaxStep: true } : {}),
968
993
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
969
994
  ...(sessionTestAuto ? { autoTest: true } : {}),
970
995
  // Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
@@ -1409,7 +1434,13 @@ async function runAgentTurn(history) {
1409
1434
  ) {
1410
1435
  lastApprovalId = job.pendingApproval.id;
1411
1436
  clearStatus();
1412
- const allow = await approvalPrompt(job.pendingApproval.command || "");
1437
+ const { allow, dontAsk } = await approvalPrompt(job.pendingApproval.command || "");
1438
+ // "Yes, don't ask again this session" (max-step prompt only): remember it so every
1439
+ // later turn sends alwaysExtendOnMaxStep and the agent auto-extends silently (#80).
1440
+ if (dontAsk) {
1441
+ alwaysExtendOnMaxStep = true;
1442
+ process.stdout.write(dim(" (won't ask again about the step budget this session)\n"));
1443
+ }
1413
1444
  try {
1414
1445
  await fetch(`${apiBase}/api/worker/jobs/${jobId}/approval`, {
1415
1446
  method: "POST",
@@ -2340,7 +2340,10 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason):
2340
2340
  except Exception as e: # noqa: BLE001
2341
2341
  _log(f"approval-request failed: {e} — treating as deny")
2342
2342
  return False
2343
- _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
2343
+ if command.startswith("__MAXSTEP__::"):
2344
+ _emit({"type": "step", "text": "Awaiting your choice: continue past the step budget?"})
2345
+ else:
2346
+ _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
2344
2347
  deadline = _t.time() + 180 # 3 min; absent user → deny (never auto-run something risky)
2345
2348
  while _t.time() < deadline:
2346
2349
  _t.sleep(1.2)
@@ -3181,19 +3184,26 @@ def run_agent_chat(cfg):
3181
3184
  # flakes, prove the test ran, cap retries) so it converges instead of chasing green.
3182
3185
  _auto_test_block = (
3183
3186
  "\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 "
3187
+ "final_answer — never finish on an untested change. Climb the CHEAPEST check that "
3188
+ "proves the change, only as far as needed:\n"
3189
+ " 1. grep_repo the edited file to confirm the change landed (old code gone).\n"
3190
+ " 2. STATIC (cheapest — nothing runs): typecheck + lint. run_command('tsc --noEmit' "
3191
+ "/ 'mypy' / 'go vet'; 'eslint' / 'ruff'). Fix errors here first.\n"
3192
+ " 3. build / compile if the project does (npm run build / go build).\n"
3193
+ " 4. run the project's tests (npm test / pytest / …) if they exist — prefer the "
3194
+ "SINGLE test/file covering your change for speed (e.g. pytest path::test_x).\n"
3195
+ " 5. if it's a running app, start it and curl/fetch_url it, then check the response "
3190
3196
  "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"
3197
+ "FIXING A BUG write a MINIMAL test that reproduces it and run it FIRST: it must FAIL "
3198
+ "(red) BEFORE your fix and PASS (green) after. A test that passes before you change "
3199
+ "anything proves nothing. Keep it with the project's other tests.\n"
3200
+ "Before final_answer, run_command('git diff') (or 'git status' if not a git repo) to "
3201
+ "review EVERYTHING you changed only what you intended, no stray edits or debug prints.\n"
3202
+ "DECIDE what 'passes' BEFORE you fix — do NOT weaken the check to make it green. On a "
3203
+ "FAIL: read the error, make ONE fix, RE-TEST the SAME way. Passes only after a no-change "
3204
+ "re-run = flaky, stop editing. Give up after 3 fix attempts on the same failure and "
3205
+ "final_answer honestly what still fails. Confirm a test actually exercised the change "
3206
+ "(the asserted behavior really ran), not an empty/no-op pass.\n"
3197
3207
  if (_auto_test and _WS_AVAILABLE) else ""
3198
3208
  )
3199
3209
  # Deploy target (/server): when the user picked one, tell the agent to deploy THERE
@@ -3383,6 +3393,54 @@ def run_agent_chat(cfg):
3383
3393
  # still deterministically deliver (e.g. render a partial PDF) instead of dead-ending.
3384
3394
  _gathered: list = []
3385
3395
 
3396
+ # --- Loop / no-progress guards (task #79) -------------------------------------------
3397
+ # Weak coders thrash on a debug loop: rewrite a file to a previously-tried version,
3398
+ # re-hit a broken endpoint, and re-read unchanged files — burning the whole step
3399
+ # budget without new information. These caches let the tools detect and refuse that.
3400
+ _file_writes: dict = {} # realpath -> {content hashes already written this run}
3401
+ _url_5xx: dict = {} # localhost url -> how many 5xx it has returned
3402
+ _reads: dict = {} # realpath -> (mtime, identical-read count)
3403
+
3404
+ def _server_log_tail_for_url(url: str) -> str:
3405
+ """If `url` is a localhost server we started via run_command, return the tail of
3406
+ its run-log — the REAL error. A 5xx returns only an HTML error page; the actual
3407
+ stack trace lives in the server's stdout log (task #79)."""
3408
+ try:
3409
+ import os as _os
3410
+ from urllib.parse import urlparse
3411
+ p = urlparse(url)
3412
+ if p.hostname not in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
3413
+ return ""
3414
+ for s in _ws_load_servers():
3415
+ if p.port and p.port in (s.get("ports") or []):
3416
+ logp = s.get("log") or ""
3417
+ if logp and _os.path.isfile(logp):
3418
+ with open(logp, "r", encoding="utf-8", errors="replace") as fh:
3419
+ return _ws_distill_output(fh.read())[-1500:]
3420
+ return ""
3421
+ except Exception: # noqa: BLE001
3422
+ return ""
3423
+
3424
+ def _read_repeat_block(rp: str) -> str:
3425
+ """Refuse a 3rd+ identical read of an UNCHANGED file (a common step-waster)."""
3426
+ try:
3427
+ import os as _os
3428
+ mt = _os.path.getmtime(rp)
3429
+ except OSError:
3430
+ return ""
3431
+ prev = _reads.get(rp)
3432
+ if prev and prev[0] == mt:
3433
+ cnt = prev[1] + 1
3434
+ _reads[rp] = (mt, cnt)
3435
+ if cnt >= 3:
3436
+ import os as _os2
3437
+ return (f"You've already read {_os2.path.basename(rp)} {cnt} times and it "
3438
+ "hasn't changed. Stop re-reading it — act on what it says, or "
3439
+ "call final_answer.")
3440
+ return ""
3441
+ _reads[rp] = (mt, 1)
3442
+ return ""
3443
+
3386
3444
  def _dedup(tool_name: str, arg: str, next_hint: str):
3387
3445
  """Return (is_repeat, message). On a repeat, message steers the model forward."""
3388
3446
  key = _norm_query(arg) if tool_name == "web_search" else (arg or "").strip().lower()
@@ -3452,6 +3510,23 @@ def run_agent_chat(cfg):
3452
3510
  # Append a success tag to 2xx JSON responses so the model stops and calls final_answer.
3453
3511
  elif result and result.startswith("HTTP 2"):
3454
3512
  result = result + "\n[SUCCESS — call final_answer(response) now, do not parse or retry]"
3513
+ # A 5xx from a localhost dev server (task #79): the HTML error page hides the real
3514
+ # cause. Surface the SERVER'S OWN LOG (the actual stack trace) so the model fixes
3515
+ # the specific error instead of guessing/oscillating — and hard-stop after a few
3516
+ # repeats so it can't burn the whole budget re-hitting a broken endpoint.
3517
+ _m5 = re.match(r"HTTP\s+(5\d\d)", result or "")
3518
+ if _m5:
3519
+ _url_5xx[url] = _url_5xx.get(url, 0) + 1
3520
+ _tail = _server_log_tail_for_url(url)
3521
+ if _tail:
3522
+ result = (result + f"\n[SERVER ERROR LOG — the real cause of the "
3523
+ f"{_m5.group(1)} (fix THIS, don't guess):\n{_tail}\n]")
3524
+ if _url_5xx[url] >= 3:
3525
+ result = result + (
3526
+ f"\n[STOP: {url} has returned a 5xx {_url_5xx[url]} times. Do NOT request "
3527
+ "it again or rewrite the same file again. Fix the SPECIFIC error in the "
3528
+ "server log above, or call final_answer honestly stating what's failing.]"
3529
+ )
3455
3530
  _emit({"type": "step", "text": f"HTTP {method.upper()} {url} → {status_line}"})
3456
3531
  _log(f"http_request {method.upper()} {url} → {result[:80]}")
3457
3532
  _last_obs["text"] = result
@@ -4307,14 +4382,23 @@ def run_agent_chat(cfg):
4307
4382
  text = (_last_obs.get("text") or "").strip()
4308
4383
  if text:
4309
4384
  _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
- )
4385
+ # Summarize WHAT WAS TRIED + the current blocker, not just the last tool
4386
+ # observation (task #79): a bare last obs ('Stopped npm run dev') reads as a
4387
+ # non-answer and hides the loop the run got stuck in. Build a compact bullet
4388
+ # list of the tool calls from this turn's step trace + the last result.
4389
+ _tried = []
4390
+ for _st in _turn_steps[-8:]:
4391
+ _did = str(_st.get("did") or "").strip().replace("\n", " ")
4392
+ _m = re.search(r"([a-z_]+\([^\n]{0,70})", _did)
4393
+ _label = (_m.group(1) if _m else _did)[:70].strip()
4394
+ if _label:
4395
+ _tried.append(f" • {_label}")
4396
+ _summary = "I ran out of steps before finishing this."
4397
+ if _tried:
4398
+ _summary += " Here's what I tried:\n" + "\n".join(_tried)
4399
+ _summary += "\n\nCurrent blocker (last result):\n" + text[:600]
4400
+ _summary += "\n\nReply 'continue' to have me keep going from here."
4401
+ text = _summary
4318
4402
  else:
4319
4403
  # No usable tool output (e.g. every step's code failed to parse).
4320
4404
  # Don't dead-end on a canned apology — answer from the conversation.
@@ -4425,6 +4509,10 @@ def run_agent_chat(cfg):
4425
4509
  out = f"{root} is a file ({os.path.getsize(root)} bytes)."
4426
4510
  _last_obs["text"] = out
4427
4511
  return out
4512
+ _rb = _read_repeat_block(root)
4513
+ if _rb:
4514
+ _last_obs["text"] = _rb
4515
+ return _rb
4428
4516
  entries = []
4429
4517
  for dirpath, dirnames, filenames in os.walk(root):
4430
4518
  dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
@@ -4467,6 +4555,10 @@ def run_agent_chat(cfg):
4467
4555
  msg = "Error: file too large to read (> 5 MB)."
4468
4556
  _last_obs["text"] = msg
4469
4557
  return msg
4558
+ _rb = _read_repeat_block(rp)
4559
+ if _rb:
4560
+ _last_obs["text"] = _rb
4561
+ return _rb
4470
4562
  with open(rp, "r", encoding="utf-8", errors="replace") as fh:
4471
4563
  data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
4472
4564
  out = _ws_cap_obs(data or "(empty file)")
@@ -4800,12 +4892,26 @@ def run_agent_chat(cfg):
4800
4892
  try:
4801
4893
  rp = _ws_write_allowed((path or "").strip())
4802
4894
  import os
4895
+ import hashlib as _hl
4803
4896
  existed = os.path.exists(rp)
4804
4897
  if existed and not overwrite:
4805
4898
  msg = (f"Error: {path} already exists — pass overwrite=True to replace the "
4806
4899
  "whole file, or use edit_lines/edit_file for a partial change.")
4807
4900
  _last_obs["text"] = msg
4808
4901
  return msg
4902
+ # Oscillation guard (task #79): if the model rewrites a file to a version it
4903
+ # ALREADY wrote this run (the NextResponse ↔ new Response flip-flop), that
4904
+ # exact content already failed — refuse instead of looping on a non-fix.
4905
+ _ch = _hl.sha1((content or "").encode("utf-8", "replace")).hexdigest()
4906
+ _seen = _file_writes.setdefault(rp, set())
4907
+ if existed and _ch in _seen:
4908
+ msg = (f"You already wrote this EXACT content to {os.path.basename(rp)} "
4909
+ "earlier this task and it did not fix the problem — writing it "
4910
+ "again won't help. Do NOT rewrite this file with the same content. "
4911
+ "Read the server error log, try a genuinely DIFFERENT fix, or call "
4912
+ "final_answer honestly stating what's still failing.")
4913
+ _last_obs["text"] = msg
4914
+ return msg
4809
4915
  # Snapshot BEFORE overwriting so revert restores the prior version. Record as
4810
4916
  # an "edit" (not "create") when the file already existed, so revert restores
4811
4917
  # the backup instead of deleting a file the user may have had before.
@@ -4820,6 +4926,7 @@ def run_agent_chat(cfg):
4820
4926
  ("Rewrote file — " if existed else "Added ") + f"{len(content_ls)} line(s)",
4821
4927
  added=[(i + 1, t) for i, t in enumerate(content_ls)],
4822
4928
  )})
4929
+ _seen.add(_ch) # remember this content so a later revert-to-it is caught
4823
4930
  out = f"{'Overwrote' if existed else 'Created'} {rp} ({len(content)} chars)."
4824
4931
  _last_obs["text"] = out
4825
4932
  return out
@@ -4938,6 +5045,23 @@ def run_agent_chat(cfg):
4938
5045
  _last_obs["text"] = msg
4939
5046
  return msg
4940
5047
  # else: destructive but non-interactive → allowed by default (unchanged)
5048
+ # Don't spawn a DUPLICATE of a server that's already running (task #79): the
5049
+ # model re-runs 'npm run dev' and Next/Vite then bind a SECOND port, which
5050
+ # confused the whole debug loop (3000 500 vs 3001 refused). If the identical
5051
+ # command+workdir is still listening, reuse it.
5052
+ for _srv in _ws_load_servers():
5053
+ if _srv.get("command") == command and _srv.get("workdir") == wd:
5054
+ _live = _ws_listening_ports(_srv.get("pid") or 0)
5055
+ if _live:
5056
+ _urls = ", ".join(f"http://localhost:{p}" for p in _live)
5057
+ msg = (f"'{command}' is ALREADY running — {_urls} (pid "
5058
+ f"{_srv.get('pid')}). Reusing it; do NOT start another. A "
5059
+ "dev server hot-reloads code changes automatically, so just "
5060
+ f"request {_urls} again. Use stop_server first only if you "
5061
+ "truly need a full restart.")
5062
+ _emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
5063
+ _last_obs["text"] = msg
5064
+ return msg
4941
5065
  _emit({"type": "step", "text": f"Running → {command[:70]}"})
4942
5066
  t = max(5, min(int(timeout_seconds or 180), 600))
4943
5067
  # Own process group (start_new_session) + output to a log file. This is
@@ -5275,8 +5399,38 @@ def run_agent_chat(cfg):
5275
5399
  _log(f"compact system prompt unavailable ({_e}); using smolagents default")
5276
5400
  agent = _ToolAgent(**agent_kwargs)
5277
5401
  _agent_ref["a"] = agent # let step_callback trim this agent's step memory (#63)
5402
+ # At the step budget the run ends WITHOUT final_answer (_maxsteps_partial hit). Offer
5403
+ # to keep going instead of dead-ending (task #80): a terminal user says Yes → extend
5404
+ # the budget to a large ceiling and CONTINUE with the existing memory (reset=False,
5405
+ # so the model keeps all prior observations). smolagents captures max_steps as a
5406
+ # LOCAL at run() time, so we can't bump it mid-loop — we re-enter run() with a raised
5407
+ # agent.max_steps instead. The real bound is the 30-min worker job cap, so 1000 just
5408
+ # means "run to completion". 'Yes, don't ask again' is handled REPL-side: it flips a
5409
+ # session flag so the NEXT turn arrives with alwaysExtendOnMaxStep and we auto-extend
5410
+ # without prompting.
5411
+ _MAXSTEP_CEILING = 1000
5412
+ _always_extend = bool(cfg.get("alwaysExtendOnMaxStep"))
5278
5413
  with contextlib.redirect_stdout(sys.stderr):
5279
5414
  result = agent.run(task_with_hint)
5415
+ while _maxsteps_partial.get("hit"):
5416
+ if _always_extend:
5417
+ _log(f"max-steps reached → auto-extending to {_MAXSTEP_CEILING} "
5418
+ "(don't-ask-again session flag)")
5419
+ _do_extend = True
5420
+ elif _interactive_approval and _job_id and pdf_api_base and pdf_worker_key:
5421
+ _do_extend = _ws_request_approval(
5422
+ pdf_api_base, pdf_worker_key, _job_id,
5423
+ f"__MAXSTEP__::Reached the step budget ({agent.max_steps}). "
5424
+ "Keep going?", "max-steps")
5425
+ else:
5426
+ _do_extend = False # web / no interactive channel → current wrap-up
5427
+ if not _do_extend:
5428
+ break
5429
+ _maxsteps_partial["hit"] = False
5430
+ agent.max_steps = _MAXSTEP_CEILING
5431
+ _emit({"type": "step", "text": "Continuing — step budget extended…"})
5432
+ _log(f"continuing run with raised budget {agent.max_steps}")
5433
+ result = agent.run(task_with_hint, reset=False)
5280
5434
  # Final formatting — NO extra summarizer model call. In multi-step mode the
5281
5435
  # agent's own final_answer() (or the max-steps fallback above) already holds the
5282
5436
  # synthesized answer; we just strip the internal hint tags we appended to tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.244",
3
+ "version": "1.0.247",
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",