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

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.
@@ -867,7 +867,7 @@ async function performHttpMeasurement(params) {
867
867
  * line as they arrive. Resolves when the process exits cleanly; rejects on
868
868
  * non-zero exit or timeout. stderr is collected and appended to error messages.
869
869
  */
870
- function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal) {
870
+ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal, onTimeout) {
871
871
  return new Promise((resolve, reject) => {
872
872
  const spawnOpts = { stdio: ["pipe", "pipe", "pipe"] };
873
873
  if (extraEnv && Object.keys(extraEnv).length > 0) {
@@ -878,10 +878,36 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
878
878
  let lineBuffer = "";
879
879
  let timedOut = false;
880
880
  let aborted = false;
881
- const timer = setTimeout(() => {
881
+ let closed = false;
882
+ let timerLive = true;
883
+ // On the time budget: with no onTimeout handler (probe/legacy) this is a hard cap —
884
+ // kill immediately. With a handler (task #81, interactive terminal) ask the user
885
+ // whether to keep waiting; the child keeps running/streaming during the ~60s prompt.
886
+ // "Keep waiting" disables the cap for the rest of the run; No / no-answer kills it.
887
+ const fireTimeout = async () => {
888
+ if (!timerLive || closed || aborted) return;
889
+ if (typeof onTimeout !== "function") {
890
+ timedOut = true;
891
+ timerLive = false;
892
+ child.kill("SIGKILL");
893
+ return;
894
+ }
895
+ let extend = false;
896
+ try {
897
+ extend = await onTimeout();
898
+ } catch {
899
+ extend = false;
900
+ }
901
+ if (closed || aborted || !timerLive) return; // finished/cancelled during the prompt
902
+ if (extend) {
903
+ timerLive = false; // ignore the time budget for the rest of this run
904
+ return;
905
+ }
882
906
  timedOut = true;
907
+ timerLive = false;
883
908
  child.kill("SIGKILL");
884
- }, timeoutMs);
909
+ };
910
+ const timer = setTimeout(fireTimeout, timeoutMs);
885
911
  // User-requested cancel (gonext Ctrl+C): kill the Python agent so it stops burning
886
912
  // steps/tokens. On close we resolve cleanly (not reject) — the caller checks the
887
913
  // cancel flag and leaves the job in its already-"cancelled" state.
@@ -913,10 +939,14 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
913
939
  stderr += d;
914
940
  });
915
941
  child.on("error", (e) => {
942
+ closed = true;
943
+ timerLive = false;
916
944
  clearTimeout(timer);
917
945
  reject(e);
918
946
  });
919
947
  child.on("close", (code) => {
948
+ closed = true;
949
+ timerLive = false;
920
950
  clearTimeout(timer);
921
951
  if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
922
952
  // Drain any remaining buffered line
@@ -1879,6 +1909,9 @@ async function runAgentChatJob(job) {
1879
1909
  // REPL's "I can show a picker" signal (else python keeps hard-blocking, as on web).
1880
1910
  jobId,
1881
1911
  interactiveApproval: payload?.interactiveApproval === true,
1912
+ // Session opt-in (task #80): auto-extend past the step budget without re-prompting
1913
+ // (the REPL sets this once the user picks "yes, don't ask again").
1914
+ alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
1882
1915
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
1883
1916
  autoTest: payload?.autoTest === true,
1884
1917
  // Deploy target chosen via the terminal /server picker (task #69). Host/user only.
@@ -1922,15 +1955,27 @@ async function runAgentChatJob(job) {
1922
1955
  // re-validates). Terminal = its launch cwd; web = the temp scratch folder.
1923
1956
  activeWorkspace: jobActiveWorkspace,
1924
1957
  });
1925
- // 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
1926
- // cache or a remote Ollama box on slow GPU cold-loading a big model — can run
1927
- // for many minutes per step. Must stay BELOW the web client's hard WS deadline
1928
- // (1_860_000 ms) so our clean timeout error reaches the user instead of a generic
1929
- // WS timeout. On expiry the python child is SIGKILLed, which also shows up as a
1930
- // BrokenPipeError in the model server log (expected). Liveness during long model
1931
- // calls comes from the python heartbeat (a keepalive step chunk every 45s), not
1932
- // from this cap.
1933
- const timeoutMs = 1_800_000;
1958
+ // Agent run time budget (task #81). Default 60 min; user-configurable in web Settings
1959
+ // (agentTimeoutMinutes payload.agentTimeoutMs). Multi-step ReAct on a 14B/31B with a
1960
+ // cold prompt cache or a remote Ollama box slow-cold-loading a big model — can run
1961
+ // for many minutes per step, so a generous budget is normal. On expiry the TERMINAL
1962
+ // (interactive) asks the user whether to keep waiting (onAgentTimeout below); "yes"
1963
+ // ignores the budget for the rest of the run. WEB has no picker AND a hard WS deadline
1964
+ // (1_860_000 ms), so it's capped at 30 min and just aborts our clean timeout error
1965
+ // then reaches the user instead of a generic WS timeout. On a hard stop the python
1966
+ // child is SIGKILLed (shows as a BrokenPipeError in the model server log — expected).
1967
+ // Liveness during long model calls comes from the python heartbeat (a keepalive step
1968
+ // chunk every 45s), not from this cap.
1969
+ const DEFAULT_AGENT_TIMEOUT_MS = 3_600_000; // 1 hour
1970
+ const WEB_AGENT_TIMEOUT_CAP_MS = 1_800_000; // stay under the web WS deadline
1971
+ const isInteractiveTimeout = payload?.interactiveApproval === true;
1972
+ const requestedTimeoutMs =
1973
+ Number.isFinite(payload?.agentTimeoutMs) && payload.agentTimeoutMs > 0
1974
+ ? payload.agentTimeoutMs
1975
+ : DEFAULT_AGENT_TIMEOUT_MS;
1976
+ const timeoutMs = isInteractiveTimeout
1977
+ ? requestedTimeoutMs
1978
+ : Math.min(requestedTimeoutMs, WEB_AGENT_TIMEOUT_CAP_MS);
1934
1979
 
1935
1980
  let inThink = false;
1936
1981
  let finalText = "";
@@ -1982,6 +2027,45 @@ async function runAgentChatJob(job) {
1982
2027
  }, 1500);
1983
2028
  cancelAc.signal.addEventListener("abort", () => { cancelled = true; }, { once: true });
1984
2029
 
2030
+ // Task #81: when the time budget is reached in the TERMINAL, ask the user whether to
2031
+ // keep waiting instead of just aborting. Reuses the #67 approval plumbing
2032
+ // (approval-request → poll approvalDecision) with a __TIMEOUT__:: sentinel the REPL
2033
+ // recognizes and renders as a Yes/No picker (auto-No at 60s). Returns true = keep
2034
+ // waiting (disable the cap), false = stop. Only wired for interactive jobs (web never
2035
+ // reaches here — it isn't passed as onTimeout).
2036
+ const onAgentTimeout = async () => {
2037
+ if (!isInteractiveTimeout || !jobId) return false;
2038
+ const approvalId = `timeout-${Date.now()}`;
2039
+ const mins = Math.round(timeoutMs / 60000);
2040
+ const command = `__TIMEOUT__::The agent has run for ${mins} min (the time budget). Keep waiting?`;
2041
+ try {
2042
+ const r = await workerFetch(`/api/worker/jobs/${jobId}/approval-request`, {
2043
+ method: "POST",
2044
+ body: JSON.stringify({ id: approvalId, command }),
2045
+ });
2046
+ if (!r.ok) return false;
2047
+ } catch {
2048
+ return false;
2049
+ }
2050
+ // Poll for the user's Yes/No up to ~65s (the REPL auto-answers No at 60s).
2051
+ const deadline = Date.now() + 65_000;
2052
+ while (Date.now() < deadline) {
2053
+ if (cancelAc.signal.aborted) return false;
2054
+ await new Promise((r) => setTimeout(r, 1500));
2055
+ try {
2056
+ const r = await workerFetch(`/api/worker/jobs/${jobId}`);
2057
+ if (r.ok) {
2058
+ const j = await r.json().catch(() => ({}));
2059
+ const dec = j?.approvalDecision;
2060
+ if (dec && dec.id === approvalId) return dec.allow === true;
2061
+ }
2062
+ } catch {
2063
+ /* transient poll error — keep trying */
2064
+ }
2065
+ }
2066
+ return false; // no answer in time → treat as No (stop)
2067
+ };
2068
+
1985
2069
  try {
1986
2070
  await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
1987
2071
  if (event.type === "log" && typeof event.text === "string") {
@@ -2036,7 +2120,7 @@ async function runAgentChatJob(job) {
2036
2120
  enqueueText(event.text);
2037
2121
  }
2038
2122
  }
2039
- }, pdfEnv, cancelAc.signal);
2123
+ }, pdfEnv, cancelAc.signal, isInteractiveTimeout ? onAgentTimeout : undefined);
2040
2124
  } finally {
2041
2125
  clearInterval(cancelPoll);
2042
2126
  }
package/gonext-repl.mjs CHANGED
@@ -778,8 +778,39 @@ 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
+ const TIMEOUT_PREFIX = "__TIMEOUT__::";
788
+ async function approvalPrompt(command) {
789
+ // Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
790
+ // keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
791
+ if (command.startsWith(TIMEOUT_PREFIX)) {
792
+ const label = command.slice(TIMEOUT_PREFIX.length);
793
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
794
+ process.stdout.write("\n" + yellow("⏳ " + label) + dim(" (no answer in 60s = stop)") + "\n");
795
+ const idx = await pickFromList(
796
+ ["Yes, keep waiting", "No, stop now"],
797
+ 0,
798
+ { timeoutMs: 60000, timeoutIndex: 1 }
799
+ );
800
+ return { allow: idx === 0, dontAsk: false };
801
+ }
802
+ const isMax = command.startsWith(MAXSTEP_PREFIX);
803
+ if (isMax) {
804
+ const label = command.slice(MAXSTEP_PREFIX.length);
805
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
806
+ process.stdout.write("\n" + yellow("⏭ " + label) + "\n");
807
+ const idx = await pickFromList(
808
+ ["Yes, keep going", "Yes, and don't ask again this session", "No, stop here"],
809
+ 0
810
+ );
811
+ return { allow: idx === 0 || idx === 1, dontAsk: idx === 1 };
812
+ }
813
+ const allow = await new Promise((resolve) => {
783
814
  if (!process.stdin.isTTY) {
784
815
  process.stdout.write(dim(`\n(approval needed for: ${command} — no interactive terminal, skipping)\n`));
785
816
  resolve(false);
@@ -799,6 +830,7 @@ function approvalPrompt(command) {
799
830
  orig(v);
800
831
  };
801
832
  });
833
+ return { allow, dontAsk: false };
802
834
  }
803
835
  // Generic arrow-key list picker (↑/↓ + Enter) — used by /server so you navigate instead
804
836
  // of typing a number. Same mechanism as the Yes/No approval picker above, generalized to
@@ -839,7 +871,7 @@ function onListKey(key) {
839
871
  finishList(-1);
840
872
  }
841
873
  }
842
- function pickFromList(items, initialSel = 0) {
874
+ function pickFromList(items, initialSel = 0, opts = {}) {
843
875
  return new Promise((resolve) => {
844
876
  if (!process.stdin.isTTY || items.length === 0) {
845
877
  resolve(-1);
@@ -850,6 +882,20 @@ function pickFromList(items, initialSel = 0) {
850
882
  listResolve = resolve;
851
883
  listPickerActive = true;
852
884
  drawList(false);
885
+ // Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
886
+ // user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
887
+ // listResolve so any real selection clears the timer.
888
+ if (opts && opts.timeoutMs > 0) {
889
+ const to = setTimeout(
890
+ () => finishList(opts.timeoutIndex ?? -1),
891
+ opts.timeoutMs
892
+ );
893
+ const orig = listResolve;
894
+ listResolve = (v) => {
895
+ clearTimeout(to);
896
+ orig(v);
897
+ };
898
+ }
853
899
  });
854
900
  }
855
901
 
@@ -860,6 +906,10 @@ let sessionCodingModel = "";
860
906
  // and fixes → re-tests until it passes before finishing. Default off; remembered per
861
907
  // folder in the session file; sent to /agent-ask as autoTest so python drives the loop.
862
908
  let sessionTestAuto = false;
909
+ // Step-budget opt-out (task #80): set once the user picks "yes, don't ask again" at a
910
+ // max-step prompt. Session-scoped (not persisted per folder); sent to /agent-ask each turn
911
+ // so the agent auto-extends past the step budget without prompting again.
912
+ let alwaysExtendOnMaxStep = false;
863
913
  // Deploy target chosen via /server (task #69): {name, host, user} or null. Session-scoped;
864
914
  // sent to /agent-ask as deployServer so the agent deploys to this host with KEY auth.
865
915
  let selectedServer = null;
@@ -965,6 +1015,9 @@ async function runAgentTurn(history) {
965
1015
  // The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
966
1016
  // asks instead of hard-blocking it (interactive command-approval gate).
967
1017
  interactiveApproval: true,
1018
+ // Step-budget opt-out (task #80): once the user chose "don't ask again", auto-extend
1019
+ // past the step budget without prompting.
1020
+ ...(alwaysExtendOnMaxStep ? { alwaysExtendOnMaxStep: true } : {}),
968
1021
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
969
1022
  ...(sessionTestAuto ? { autoTest: true } : {}),
970
1023
  // Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
@@ -1409,7 +1462,13 @@ async function runAgentTurn(history) {
1409
1462
  ) {
1410
1463
  lastApprovalId = job.pendingApproval.id;
1411
1464
  clearStatus();
1412
- const allow = await approvalPrompt(job.pendingApproval.command || "");
1465
+ const { allow, dontAsk } = await approvalPrompt(job.pendingApproval.command || "");
1466
+ // "Yes, don't ask again this session" (max-step prompt only): remember it so every
1467
+ // later turn sends alwaysExtendOnMaxStep and the agent auto-extends silently (#80).
1468
+ if (dontAsk) {
1469
+ alwaysExtendOnMaxStep = true;
1470
+ process.stdout.write(dim(" (won't ask again about the step budget this session)\n"));
1471
+ }
1413
1472
  try {
1414
1473
  await fetch(`${apiBase}/api/worker/jobs/${jobId}/approval`, {
1415
1474
  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)
@@ -5396,8 +5399,38 @@ def run_agent_chat(cfg):
5396
5399
  _log(f"compact system prompt unavailable ({_e}); using smolagents default")
5397
5400
  agent = _ToolAgent(**agent_kwargs)
5398
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"))
5399
5413
  with contextlib.redirect_stdout(sys.stderr):
5400
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)
5401
5434
  # Final formatting — NO extra summarizer model call. In multi-step mode the
5402
5435
  # agent's own final_answer() (or the max-steps fallback above) already holds the
5403
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.246",
3
+ "version": "1.0.249",
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",