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

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
@@ -1925,19 +1955,35 @@ async function runAgentChatJob(job) {
1925
1955
  // re-validates). Terminal = its launch cwd; web = the temp scratch folder.
1926
1956
  activeWorkspace: jobActiveWorkspace,
1927
1957
  });
1928
- // 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
1929
- // cache or a remote Ollama box on slow GPU cold-loading a big model — can run
1930
- // for many minutes per step. Must stay BELOW the web client's hard WS deadline
1931
- // (1_860_000 ms) so our clean timeout error reaches the user instead of a generic
1932
- // WS timeout. On expiry the python child is SIGKILLed, which also shows up as a
1933
- // BrokenPipeError in the model server log (expected). Liveness during long model
1934
- // calls comes from the python heartbeat (a keepalive step chunk every 45s), not
1935
- // from this cap.
1936
- 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);
1937
1979
 
1938
1980
  let inThink = false;
1939
1981
  let finalText = "";
1940
1982
  let answerStreamed = false; // an answer_stream delta already delivered the visible text
1983
+ // Task #83: running total of prompt tokens sent to the agent CODE model. Updated on
1984
+ // each python "tokens" event, persisted to the job doc (Mongo) so the REPL can show
1985
+ // it live and it survives on the completed job.
1986
+ let latestCodeTokens = 0;
1941
1987
  // Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
1942
1988
  // prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
1943
1989
  // markdown, which turns `#`-prefixed code comments into <h1> headings and
@@ -1985,6 +2031,45 @@ async function runAgentChatJob(job) {
1985
2031
  }, 1500);
1986
2032
  cancelAc.signal.addEventListener("abort", () => { cancelled = true; }, { once: true });
1987
2033
 
2034
+ // Task #81: when the time budget is reached in the TERMINAL, ask the user whether to
2035
+ // keep waiting instead of just aborting. Reuses the #67 approval plumbing
2036
+ // (approval-request → poll approvalDecision) with a __TIMEOUT__:: sentinel the REPL
2037
+ // recognizes and renders as a Yes/No picker (auto-No at 60s). Returns true = keep
2038
+ // waiting (disable the cap), false = stop. Only wired for interactive jobs (web never
2039
+ // reaches here — it isn't passed as onTimeout).
2040
+ const onAgentTimeout = async () => {
2041
+ if (!isInteractiveTimeout || !jobId) return false;
2042
+ const approvalId = `timeout-${Date.now()}`;
2043
+ const mins = Math.round(timeoutMs / 60000);
2044
+ const command = `__TIMEOUT__::The agent has run for ${mins} min (the time budget). Keep waiting?`;
2045
+ try {
2046
+ const r = await workerFetch(`/api/worker/jobs/${jobId}/approval-request`, {
2047
+ method: "POST",
2048
+ body: JSON.stringify({ id: approvalId, command }),
2049
+ });
2050
+ if (!r.ok) return false;
2051
+ } catch {
2052
+ return false;
2053
+ }
2054
+ // Poll for the user's Yes/No up to ~65s (the REPL auto-answers No at 60s).
2055
+ const deadline = Date.now() + 65_000;
2056
+ while (Date.now() < deadline) {
2057
+ if (cancelAc.signal.aborted) return false;
2058
+ await new Promise((r) => setTimeout(r, 1500));
2059
+ try {
2060
+ const r = await workerFetch(`/api/worker/jobs/${jobId}`);
2061
+ if (r.ok) {
2062
+ const j = await r.json().catch(() => ({}));
2063
+ const dec = j?.approvalDecision;
2064
+ if (dec && dec.id === approvalId) return dec.allow === true;
2065
+ }
2066
+ } catch {
2067
+ /* transient poll error — keep trying */
2068
+ }
2069
+ }
2070
+ return false; // no answer in time → treat as No (stop)
2071
+ };
2072
+
1988
2073
  try {
1989
2074
  await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
1990
2075
  if (event.type === "log" && typeof event.text === "string") {
@@ -2025,6 +2110,19 @@ async function runAgentChatJob(job) {
2025
2110
  }
2026
2111
  enqueueText(event.text);
2027
2112
  answerStreamed = true;
2113
+ } else if (event.type === "tokens" && Number.isFinite(event.codeInput)) {
2114
+ // Prompt tokens sent to the agent CODE model (task #83). Persist the running
2115
+ // total to the job doc so it's in Mongo and the REPL can render it live. Steps
2116
+ // are seconds apart, so a fire-and-forget PATCH per event is cheap; it only
2117
+ // touches agentCodeTokens (never resultText/jobStatus), so it can't race the
2118
+ // chunk/completed writes.
2119
+ if (event.codeInput > latestCodeTokens) {
2120
+ latestCodeTokens = event.codeInput;
2121
+ workerFetch(`/api/worker/jobs/${jobId}`, {
2122
+ method: "PATCH",
2123
+ body: JSON.stringify({ agentCodeTokens: latestCodeTokens }),
2124
+ }).catch(() => {});
2125
+ }
2028
2126
  } else if (event.type === "final" && typeof event.text === "string") {
2029
2127
  closeStreamFence();
2030
2128
  if (inThink) {
@@ -2039,7 +2137,7 @@ async function runAgentChatJob(job) {
2039
2137
  enqueueText(event.text);
2040
2138
  }
2041
2139
  }
2042
- }, pdfEnv, cancelAc.signal);
2140
+ }, pdfEnv, cancelAc.signal, isInteractiveTimeout ? onAgentTimeout : undefined);
2043
2141
  } finally {
2044
2142
  clearInterval(cancelPoll);
2045
2143
  }
@@ -2075,6 +2173,7 @@ async function runAgentChatJob(job) {
2075
2173
  // the whole story.
2076
2174
  resultText: answerStreamed ? fullText : finalText || fullText,
2077
2175
  tokenCount: Math.max(1, Math.ceil((finalText || fullText).length / 4)),
2176
+ ...(latestCodeTokens > 0 ? { agentCodeTokens: latestCodeTokens } : {}),
2078
2177
  totalTimeSeconds,
2079
2178
  }),
2080
2179
  });
package/gonext-repl.mjs CHANGED
@@ -122,6 +122,12 @@ const SPINNERS = [
122
122
  // green, cyan, blue, indigo, pink, amber).
123
123
  const WAIT_COLORS_256 = [120, 87, 111, 147, 218, 221];
124
124
  const color256 = (n, s) => `\x1b[38;5;${n}m${s}\x1b[0m`;
125
+ // Compact token count: 950 → "950", 12300 → "12.3k", 1500000 → "1.5M" (task #83).
126
+ const fmtTokens = (n) => {
127
+ if (n < 1000) return String(n);
128
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0)}k`;
129
+ return `${(n / 1_000_000).toFixed(1)}M`;
130
+ };
125
131
  const BULLET = "●"; // a completed action
126
132
 
127
133
  // ---------- flags ----------
@@ -784,7 +790,21 @@ function onApprovalKey(key) {
784
790
  // Returns { allow, dontAsk } — dontAsk is only ever true for the max-step "don't ask
785
791
  // again this session" choice (the caller then stops sending the prompt).
786
792
  const MAXSTEP_PREFIX = "__MAXSTEP__::";
793
+ const TIMEOUT_PREFIX = "__TIMEOUT__::";
787
794
  async function approvalPrompt(command) {
795
+ // Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
796
+ // keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
797
+ if (command.startsWith(TIMEOUT_PREFIX)) {
798
+ const label = command.slice(TIMEOUT_PREFIX.length);
799
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
800
+ process.stdout.write("\n" + yellow("⏳ " + label) + dim(" (no answer in 60s = stop)") + "\n");
801
+ const idx = await pickFromList(
802
+ ["Yes, keep waiting", "No, stop now"],
803
+ 0,
804
+ { timeoutMs: 60000, timeoutIndex: 1 }
805
+ );
806
+ return { allow: idx === 0, dontAsk: false };
807
+ }
788
808
  const isMax = command.startsWith(MAXSTEP_PREFIX);
789
809
  if (isMax) {
790
810
  const label = command.slice(MAXSTEP_PREFIX.length);
@@ -857,7 +877,7 @@ function onListKey(key) {
857
877
  finishList(-1);
858
878
  }
859
879
  }
860
- function pickFromList(items, initialSel = 0) {
880
+ function pickFromList(items, initialSel = 0, opts = {}) {
861
881
  return new Promise((resolve) => {
862
882
  if (!process.stdin.isTTY || items.length === 0) {
863
883
  resolve(-1);
@@ -868,6 +888,20 @@ function pickFromList(items, initialSel = 0) {
868
888
  listResolve = resolve;
869
889
  listPickerActive = true;
870
890
  drawList(false);
891
+ // Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
892
+ // user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
893
+ // listResolve so any real selection clears the timer.
894
+ if (opts && opts.timeoutMs > 0) {
895
+ const to = setTimeout(
896
+ () => finishList(opts.timeoutIndex ?? -1),
897
+ opts.timeoutMs
898
+ );
899
+ const orig = listResolve;
900
+ listResolve = (v) => {
901
+ clearTimeout(to);
902
+ orig(v);
903
+ };
904
+ }
871
905
  });
872
906
  }
873
907
 
@@ -1037,6 +1071,9 @@ async function runAgentTurn(history) {
1037
1071
  // poll figure out how much of the FINAL answer is still unshown even when resultText
1038
1072
  // was replaced (not extended) on completion. See the completed-branch recovery below.
1039
1073
  let liveAnswer = "";
1074
+ // Running total of prompt tokens sent to the agent CODE model this turn (task #83),
1075
+ // read from the job doc each poll. Rendered on the thinking line after the playful word.
1076
+ let latestCodeTokens = 0;
1040
1077
  // First few KB of the raw stream as consumed — a cheap fingerprint to verify the
1041
1078
  // completed resultText really EXTENDS what we streamed (an old worker replaces it
1042
1079
  // with the shorter bare answer, which must not be sliced by shownChars).
@@ -1111,9 +1148,12 @@ async function runAgentTurn(history) {
1111
1148
  // Line 2 (indented): the SPINNER + the playful word together, both in the cycling
1112
1149
  // color — the spinner belongs with the word (dot on the label line, spinner leading
1113
1150
  // the flavor line).
1151
+ // Task #83: after the playful word, show the running agent-code-model token count
1152
+ // (dim), e.g. " ⠙ Caffeinating… · 12.3k tok". Only once we've seen some tokens.
1153
+ const tokLabel = latestCodeTokens > 0 ? dim(` · ${fmtTokens(latestCodeTokens)} tok`) : "";
1114
1154
  process.stdout.write(
1115
1155
  `${green(BULLET)} ${green(`${fit(primary)}… (${secs}s)`)}` +
1116
- "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}`
1156
+ "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`
1117
1157
  );
1118
1158
  statusShown = true;
1119
1159
  };
@@ -1379,6 +1419,10 @@ async function runAgentTurn(history) {
1379
1419
  continue;
1380
1420
  }
1381
1421
  jobRunning = job.jobStatus === "running";
1422
+ // Live agent-code-model token total (task #83) — monotonic, shown on the thinking line.
1423
+ if (Number.isFinite(job.agentCodeTokens) && job.agentCodeTokens > latestCodeTokens) {
1424
+ latestCodeTokens = job.agentCodeTokens;
1425
+ }
1382
1426
  const text = typeof job.resultText === "string" ? job.resultText : "";
1383
1427
  if (job.jobStatus === "completed") {
1384
1428
  // The job can flip to "completed" between polls with authoritative resultText that
@@ -3936,6 +3936,10 @@ def run_agent_chat(cfg):
3936
3936
  # literal messages array sent to /v1/chat/completions.
3937
3937
  class _LoggingModel(OpenAIServerModel):
3938
3938
  def __init__(self, *a, stream_agent=False, **kw):
3939
+ # Running total of PROMPT (input) tokens sent to the CODE model across the
3940
+ # turn (task #83). Only this wrapper counts — the chat/brain helper calls
3941
+ # (_chat_completion) don't route through here, so this is "code model only".
3942
+ self._code_tokens_total = 0
3939
3943
  # stream_agent: request the completion with stream=True and reassemble the
3940
3944
  # deltas into one message. Enabled for Ollama, where a long single-shot
3941
3945
  # (non-stream) generation sends nothing until the very end — a reverse proxy
@@ -4129,6 +4133,29 @@ def run_agent_chat(cfg):
4129
4133
  token_usage=TokenUsage(input_tokens=in_tok, output_tokens=out_tok),
4130
4134
  )
4131
4135
 
4136
+ def _estimate_prompt_tokens(self, args, kwargs):
4137
+ """Fallback token estimate (~4 chars/token) from the outgoing messages, used
4138
+ only when the model server doesn't report prompt usage (task #83)."""
4139
+ msgs = kwargs.get("messages")
4140
+ if msgs is None and args:
4141
+ msgs = args[0]
4142
+ if not msgs:
4143
+ return 0
4144
+ chars = 0
4145
+ try:
4146
+ for m in msgs:
4147
+ c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
4148
+ if isinstance(c, list): # multimodal content parts
4149
+ for part in c:
4150
+ t = part.get("text") if isinstance(part, dict) else None
4151
+ if isinstance(t, str):
4152
+ chars += len(t)
4153
+ elif isinstance(c, str):
4154
+ chars += len(c)
4155
+ except Exception: # noqa: BLE001
4156
+ return 0
4157
+ return max(1, chars // 4)
4158
+
4132
4159
  def generate(self, *args, **kwargs):
4133
4160
  # Two safeguards on each step's reply BEFORE smolagents parses it for the
4134
4161
  # code block / final_answer:
@@ -4199,6 +4226,23 @@ def run_agent_chat(cfg):
4199
4226
  raise last_err
4200
4227
  finally:
4201
4228
  hb_stop.set()
4229
+ # Task #83: tally PROMPT tokens sent to the code model this step, then emit
4230
+ # the running total so the worker can persist it (Mongo) and the REPL can
4231
+ # show it live. Best-effort — never let accounting break the run.
4232
+ try:
4233
+ tu = getattr(msg, "token_usage", None)
4234
+ step_in = int(getattr(tu, "input_tokens", 0) or 0) if tu else 0
4235
+ if step_in <= 0:
4236
+ step_in = self._estimate_prompt_tokens(args, kwargs)
4237
+ if step_in > 0:
4238
+ self._code_tokens_total += step_in
4239
+ _emit({
4240
+ "type": "tokens",
4241
+ "codeInput": self._code_tokens_total,
4242
+ "stepInput": step_in,
4243
+ })
4244
+ except Exception: # noqa: BLE001
4245
+ pass
4202
4246
  try:
4203
4247
  content = getattr(msg, "content", None)
4204
4248
  if content is None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.247",
3
+ "version": "1.0.250",
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",