@tiens.nguyen/gonext-local-worker 1.0.309 → 1.0.311

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.
@@ -1803,8 +1803,12 @@ async function runAgentChatJob(job) {
1803
1803
  let cancelled = false;
1804
1804
  // Remember this job's Ollama coding model and keep it resident via a background
1805
1805
  // interval. Only warms now if the model is new/changed — repeat questions rely on
1806
- // the interval, so we don't re-warm (or re-log) on every question.
1807
- trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
1806
+ // the interval, so we don't re-warm (or re-log) on every question. The warm-up uses
1807
+ // Ollama's native /api/generate, so SKIP it for an OpenAI-compatible coder (task #108):
1808
+ // that endpoint doesn't exist there and we shouldn't hit a paid API with keep-warm pings.
1809
+ if (payload?.codingKind !== "openai") {
1810
+ trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
1811
+ }
1808
1812
  const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1809
1813
  method: "PATCH",
1810
1814
  body: JSON.stringify({ jobStatus: "running" }),
@@ -1898,6 +1902,25 @@ async function runAgentChatJob(job) {
1898
1902
  jobActiveWorkspace = webWork;
1899
1903
  }
1900
1904
 
1905
+ // Task #108: coding backend kind ("ollama" | "openai" | "" = Auto/sniff). For an
1906
+ // OpenAI-compatible coder, fetch the API key from a dedicated worker-authed endpoint
1907
+ // (NOT the job payload/Mongo doc — secret hygiene) and inject it into python stdin only.
1908
+ const codingKind = payload?.codingKind === "ollama" || payload?.codingKind === "openai"
1909
+ ? payload.codingKind
1910
+ : "";
1911
+ let codingApiKey = "";
1912
+ if (codingKind === "openai") {
1913
+ try {
1914
+ const kr = await workerFetch("/api/worker/coding-key");
1915
+ if (kr.ok) {
1916
+ const kj = await kr.json().catch(() => ({}));
1917
+ if (typeof kj?.apiKey === "string") codingApiKey = kj.apiKey;
1918
+ }
1919
+ } catch {
1920
+ /* best-effort — python falls back to a keyless call, which the endpoint may 401 */
1921
+ }
1922
+ }
1923
+
1901
1924
  const input = JSON.stringify({
1902
1925
  messages: payload?.messages ?? [],
1903
1926
  agentBaseURL: payload?.agentBaseURL ?? "",
@@ -1905,6 +1928,8 @@ async function runAgentChatJob(job) {
1905
1928
  agentModelId: payload?.agentModelId ?? "",
1906
1929
  codingBaseURL: payload?.codingBaseURL ?? "",
1907
1930
  codingModelId: payload?.codingModelId ?? "",
1931
+ codingKind,
1932
+ codingApiKey,
1908
1933
  // Task #107: when true, python emits a {type:code_response} event per code-model
1909
1934
  // call so the worker persists the full raw text to Mongo.
1910
1935
  saveFullResponse: payload?.saveFullResponse === true,
package/gonext-repl.mjs CHANGED
@@ -1113,6 +1113,63 @@ function pickFromList(items, initialSel = 0, opts = {}) {
1113
1113
  });
1114
1114
  }
1115
1115
 
1116
+ // Cancel whichever approval picker is currently showing (Yes/No or the N-row list),
1117
+ // resolving it as a DENY. Used when the job ends server-side while we're still waiting
1118
+ // for the user, so the picker promise never leaks and no late choice is sent (#110).
1119
+ function cancelActivePicker() {
1120
+ if (approvalActive) finishApproval(false);
1121
+ if (listPickerActive) finishList(-1);
1122
+ }
1123
+
1124
+ // Show an approval picker, but RACE it against the job going terminal. The old code
1125
+ // awaited the picker INLINE inside the poll loop, so while the user thought, the REPL
1126
+ // stopped polling and couldn't notice that python had already given up (its own approval
1127
+ // deadline) and completed the job — the user's late "Yes" then hit a finished job and was
1128
+ // silently lost (#110). Here a background watcher polls the job; if it goes
1129
+ // completed/failed/cancelled first, we dismiss the picker and report `ended:true` so the
1130
+ // caller skips the POST and lets the normal terminal-status handling run.
1131
+ async function approvalPromptRacingJob(command) {
1132
+ let watching = true;
1133
+ const watcher = (async () => {
1134
+ while (watching) {
1135
+ await sleep(1000);
1136
+ if (!watching) return null;
1137
+ try {
1138
+ const r = await fetch(`${apiBase}/api/worker/jobs/${jobIdForApprovalRace}`, {
1139
+ headers: { "X-Worker-Key": workerKey },
1140
+ });
1141
+ if (!r.ok) continue;
1142
+ const j = await r.json().catch(() => ({}));
1143
+ if (
1144
+ j?.jobStatus === "completed" ||
1145
+ j?.jobStatus === "failed" ||
1146
+ j?.jobStatus === "cancelled"
1147
+ ) {
1148
+ return "ended";
1149
+ }
1150
+ } catch {
1151
+ /* transient poll error — keep watching */
1152
+ }
1153
+ }
1154
+ return null;
1155
+ })();
1156
+ const picker = approvalPrompt(command).then((r) => ({ kind: "answer", ...r }));
1157
+ const raced = await Promise.race([
1158
+ picker,
1159
+ watcher.then((v) => (v === "ended" ? { kind: "ended" } : new Promise(() => {}))),
1160
+ ]);
1161
+ watching = false;
1162
+ if (raced.kind === "ended") {
1163
+ cancelActivePicker(); // unblock the picker promise so it doesn't leak
1164
+ await picker.catch(() => {}); // let it settle after the cancel
1165
+ return { ended: true, allow: false, dontAsk: false };
1166
+ }
1167
+ // The user answered first — stop the watcher and return their choice.
1168
+ return { ended: false, allow: raced.allow, dontAsk: raced.dontAsk };
1169
+ }
1170
+ // The jobId the approval race should watch — set by runAgentTurn before it can prompt.
1171
+ let jobIdForApprovalRace = "";
1172
+
1116
1173
  // Per-session coding-model override chosen via /model (empty = use the account default).
1117
1174
  // Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
1118
1175
  let sessionCodingModel = "";
@@ -1326,6 +1383,7 @@ async function runAgentTurn(history) {
1326
1383
  following = true;
1327
1384
  followAborted = false;
1328
1385
  currentJobId = jobId;
1386
+ jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
1329
1387
  cancelRequested = false;
1330
1388
  const startedAt = Date.now();
1331
1389
  let shownChars = 0;
@@ -1894,7 +1952,19 @@ async function runAgentTurn(history) {
1894
1952
  ) {
1895
1953
  lastApprovalId = job.pendingApproval.id;
1896
1954
  clearStatus();
1897
- const { allow, dontAsk } = await approvalPrompt(job.pendingApproval.command || "");
1955
+ // Race the picker against the job ending server-side (#110): if python gives up
1956
+ // (its own approval deadline) and completes the job while we're waiting, dismiss
1957
+ // the picker and DON'T post a now-useless choice — fall through to the normal
1958
+ // completed/failed/cancelled handling below on the next poll.
1959
+ const { ended, allow, dontAsk } = await approvalPromptRacingJob(
1960
+ job.pendingApproval.command || ""
1961
+ );
1962
+ if (ended) {
1963
+ process.stdout.write(
1964
+ dim("\n (the run already ended before you chose — reply 'continue' to resume)\n")
1965
+ );
1966
+ continue; // re-poll; the terminal-status branches below take it from here
1967
+ }
1898
1968
  // "Yes, don't ask again this session" (max-step prompt only): remember it so every
1899
1969
  // later turn sends alwaysExtendOnMaxStep and the agent auto-extends silently (#80).
1900
1970
  if (dontAsk) {
@@ -2835,10 +2835,16 @@ def _ws_command_risk(command, argv, denyset):
2835
2835
  return (None, None)
2836
2836
 
2837
2837
 
2838
- def _ws_request_approval(api_base, worker_key, job_id, command, reason):
2838
+ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline_s=180):
2839
2839
  """Pause and ask the user (via the terminal REPL's Yes/No picker) to allow a risky
2840
2840
  command. Registers a pending approval on the job through the API, then polls the job
2841
2841
  for the user's decision. Returns True (allow) or False (deny / timeout / cancelled).
2842
+
2843
+ deadline_s bounds the wait. 180s is right for a RISKY command (absent user → deny,
2844
+ never auto-run). But the __MAXSTEP__ "keep going?" prompt is NOT security-sensitive and
2845
+ the coder can be slow, so it passes a much larger deadline (#110): the REPL now
2846
+ dismisses the picker the instant this job ends, and the worker's own job-cap kills a
2847
+ truly-abandoned run, so a long wait here is safe and lets a real "Yes" actually land.
2842
2848
  Mirrors _pdf_upload_via_api's worker-key auth — no new creds on the worker."""
2843
2849
  import urllib.request as _u
2844
2850
  import uuid as _uuid
@@ -2866,7 +2872,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason):
2866
2872
  _emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
2867
2873
  else:
2868
2874
  _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
2869
- deadline = _t.time() + 180 # 3 min; absent user → deny (never auto-run something risky)
2875
+ deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
2870
2876
  while _t.time() < deadline:
2871
2877
  _t.sleep(1.2)
2872
2878
  try:
@@ -3117,6 +3123,14 @@ def run_agent_chat(cfg):
3117
3123
  # also fixes an older API layer that appended /v1 after the native /api path).
3118
3124
  raw_coding_base = _normalize_openai_base(cfg.get("codingBaseURL") or "")
3119
3125
  raw_coding_model = (cfg.get("codingModelId") or "").strip()
3126
+ # Task #108: explicit coding backend kind + API key for an OpenAI-compatible endpoint.
3127
+ # coding_kind: "ollama" | "openai" | "" (Auto → sniff by URL). coding_api_key: the
3128
+ # Bearer key for an OpenAI-compatible coder (empty otherwise). The key is used for the
3129
+ # code-model calls (and coding-model auto-detect), NOT the chat model's agent_api_key.
3130
+ coding_kind = (cfg.get("codingKind") or "").strip().lower()
3131
+ if coding_kind not in ("ollama", "openai"):
3132
+ coding_kind = ""
3133
+ coding_api_key = (cfg.get("codingApiKey") or "").strip()
3120
3134
  # Task #107: when on, emit each code-model response so the worker persists it to Mongo.
3121
3135
  save_full_response = bool(cfg.get("saveFullResponse"))
3122
3136
  if raw_coding_base:
@@ -3136,7 +3150,8 @@ def run_agent_chat(cfg):
3136
3150
  # A DIFFERENT dedicated coding server. If no model name was given, ask the
3137
3151
  # server which model it serves (mlx_lm.server otherwise tries to download a
3138
3152
  # mismatched name from HF and 404s).
3139
- detected = raw_coding_model or _detect_model_id(raw_coding_base, agent_api_key)
3153
+ detected = raw_coding_model or _detect_model_id(
3154
+ raw_coding_base, coding_api_key or agent_api_key)
3140
3155
  if detected:
3141
3156
  coding_base_url = raw_coding_base
3142
3157
  coding_model_id = detected
@@ -3150,6 +3165,15 @@ def run_agent_chat(cfg):
3150
3165
  else:
3151
3166
  coding_base_url = agent_base_url
3152
3167
  coding_model_id = agent_model_id
3168
+ # Task #108: an OpenAI-compatible coder REQUIRES an explicit model name (its
3169
+ # /v1/chat/completions needs a `model` id, and we won't silently reuse the MLX chat
3170
+ # model's name against a cloud endpoint). Fail with a clear, actionable message.
3171
+ if coding_kind == "openai" and not (raw_coding_model or "").strip():
3172
+ raise _AgentConfigError(
3173
+ "Your agent coding model is set to OpenAI-compatible but has no model name. "
3174
+ "Enter the exact model id your endpoint expects (e.g. gpt-4o-mini) in "
3175
+ "Settings → Agent → Agent coding model name."
3176
+ )
3153
3177
  # Multi-step ReAct loop (thinking agent): the agent may take several
3154
3178
  # Thought → tool → Observation steps and then call final_answer() itself. This
3155
3179
  # replaced the old strict single-shot (max_steps=1) once a stronger coding model
@@ -5528,17 +5552,34 @@ def run_agent_chat(cfg):
5528
5552
  # the live test — minutes per step on a slow GPU — so disable it explicitly.
5529
5553
  # Sniffed per-server because MLX might reject the unknown param.
5530
5554
  extra_model_kwargs = {}
5531
- coding_is_ollama = _is_ollama_server(coding_base_url)
5555
+ # Task #108: the explicit backend kind (from web Settings) OVERRIDES the URL-sniff.
5556
+ # Auto ("") → sniff by URL (today's behavior; correctly False for MLX). Only Ollama
5557
+ # gets reasoning_effort='none' (MLX/OpenAI reject or don't need it).
5558
+ if coding_kind == "ollama":
5559
+ coding_is_ollama = True
5560
+ elif coding_kind == "openai":
5561
+ coding_is_ollama = False
5562
+ else:
5563
+ coding_is_ollama = _is_ollama_server(coding_base_url)
5532
5564
  if coding_is_ollama:
5533
5565
  _log("coding server is Ollama → reasoning_effort='none' (disable thinking) + streaming")
5534
5566
  extra_model_kwargs["reasoning_effort"] = "none"
5567
+ # Stream for Ollama AND for OpenAI-compatible remotes (task #108/#104): a remote
5568
+ # endpoint behind a proxy hits the same idle read-timeout streaming avoids, and
5569
+ # stream_options.include_usage keeps output-token counting working. Only a LOCAL
5570
+ # MLX coder (Auto, non-ollama) stays non-streaming.
5571
+ _stream_coder = coding_is_ollama or coding_kind == "openai"
5572
+ _log(f"coding backend: kind={coding_kind or 'auto'} ollama={coding_is_ollama} "
5573
+ f"stream={_stream_coder} key={'yes' if coding_api_key else 'no'}")
5535
5574
  model = _LoggingModel(
5536
5575
  model_id=coding_model_id,
5537
5576
  api_base=coding_base_url,
5538
- api_key=agent_api_key,
5539
- # Stream for Ollama so a long generation keeps the connection warm and can't
5540
- # trip a reverse-proxy idle read-timeout. smolagents still gets the full text.
5541
- stream_agent=coding_is_ollama,
5577
+ # Task #108: use the OpenAI-compatible coding key when set, else the chat key.
5578
+ api_key=coding_api_key or agent_api_key,
5579
+ # Stream for Ollama / OpenAI-compatible so a long generation keeps the connection
5580
+ # warm and can't trip a reverse-proxy idle read-timeout (smolagents still gets the
5581
+ # full text). Only a local MLX coder stays non-streaming.
5582
+ stream_agent=_stream_coder,
5542
5583
  # Task #107: persist each code-model response to Mongo when the user opted in.
5543
5584
  save_full_response=save_full_response,
5544
5585
  # Cap each HTTP attempt at 600s so a genuinely slow single call (a
@@ -6543,10 +6584,14 @@ def run_agent_chat(cfg):
6543
6584
  "(don't-ask-again session flag)")
6544
6585
  _do_extend = True
6545
6586
  elif _interactive_approval and _job_id and pdf_api_base and pdf_worker_key:
6587
+ # Long deadline (#110): the "keep going?" prompt isn't risky, and the
6588
+ # slow coder + a thinking user shouldn't get auto-denied at 180s. The
6589
+ # REPL dismisses the picker if this job ends, and the worker job-cap
6590
+ # bounds a walked-away user, so waiting here is safe.
6546
6591
  _do_extend = _ws_request_approval(
6547
6592
  pdf_api_base, pdf_worker_key, _job_id,
6548
6593
  f"__MAXSTEP__::Reached the step budget ({agent.max_steps}). "
6549
- "Keep going?", "max-steps")
6594
+ "Keep going?", "max-steps", deadline_s=1500)
6550
6595
  else:
6551
6596
  _do_extend = False # web / no interactive channel → current wrap-up
6552
6597
  if not _do_extend:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.309",
3
+ "version": "1.0.311",
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",