@tiens.nguyen/gonext-local-worker 1.0.255 β†’ 1.0.257

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
@@ -803,7 +803,22 @@ function onApprovalKey(key) {
803
803
  // again this session" choice (the caller then stops sending the prompt).
804
804
  const MAXSTEP_PREFIX = "__MAXSTEP__::";
805
805
  const TIMEOUT_PREFIX = "__TIMEOUT__::";
806
+ const COMPACT_PREFIX = "__COMPACT__::";
806
807
  async function approvalPrompt(command) {
808
+ // Context-compaction prompt (task #87 rec#3): the running context got large. Ask whether
809
+ // to aggressively compact older steps. No answer in 30s = No (keep full detail β€” the safe
810
+ // default; the always-on auto-trim still bounds things).
811
+ if (command.startsWith(COMPACT_PREFIX)) {
812
+ const label = command.slice(COMPACT_PREFIX.length);
813
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
814
+ process.stdout.write("\n" + yellow("πŸ—œ " + label) + dim(" (no answer in 30s = keep full)") + "\n");
815
+ const idx = await pickFromList(
816
+ ["Yes, compact older steps", "No, keep full detail"],
817
+ 0,
818
+ { timeoutMs: 30000, timeoutIndex: 1 }
819
+ );
820
+ return { allow: idx === 0, dontAsk: false };
821
+ }
807
822
  // Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
808
823
  // keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
809
824
  if (command.startsWith(TIMEOUT_PREFIX)) {
@@ -2342,6 +2342,8 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason):
2342
2342
  return False
2343
2343
  if command.startswith("__MAXSTEP__::"):
2344
2344
  _emit({"type": "step", "text": "Awaiting your choice: continue past the step budget?"})
2345
+ elif command.startswith("__COMPACT__::"):
2346
+ _emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
2345
2347
  else:
2346
2348
  _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
2347
2349
  deadline = _t.time() + 180 # 3 min; absent user β†’ deny (never auto-run something risky)
@@ -2485,6 +2487,32 @@ def _ws_save_servers(servers: list) -> None:
2485
2487
  _log(f"servers.json write failed: {e}")
2486
2488
 
2487
2489
 
2490
+ def _ws_cap_servers(max_live: int = 3) -> list:
2491
+ """Server lifecycle (task #90 Phase 2): prune dead registry entries and STOP the
2492
+ oldest background servers beyond max_live, so dev servers leaked from earlier
2493
+ turns can't accumulate and squat every default port (the root cause of the
2494
+ 'port 3000/3001/3005 busy' punt seen live). Only ever touches processes in OUR
2495
+ registry (started by run_command β€” never arbitrary user processes). Persists the
2496
+ pruned registry and returns the live list, oldest first."""
2497
+ import os
2498
+ import signal
2499
+ servers = _ws_load_servers() # prune=True drops already-dead entries
2500
+ if len(servers) > max_live:
2501
+ for s in servers[:-max_live]: # registry is append-ordered β†’ oldest first
2502
+ try:
2503
+ pgid = int(s.get("pgid", 0))
2504
+ if pgid <= 0:
2505
+ continue
2506
+ os.killpg(pgid, signal.SIGTERM)
2507
+ _log(f"server lifecycle: stopped old background server pid={s.get('pid')} "
2508
+ f"ports={s.get('ports')} ('{str(s.get('command'))[:40]}')")
2509
+ except (OSError, ValueError) as e: # already gone / no permission
2510
+ _log(f"server lifecycle: stop skipped pid={s.get('pid')}: {e}")
2511
+ servers = servers[-max_live:]
2512
+ _ws_save_servers(servers)
2513
+ return servers
2514
+
2515
+
2488
2516
  def _ws_scrubbed_env() -> dict:
2489
2517
  """Minimal child env for run_command. The worker process env holds secrets
2490
2518
  (GONEXT_WORKER_KEY etc. via worker.env) β€” a repo's test script must NEVER see
@@ -3121,13 +3149,53 @@ def run_agent_chat(cfg):
3121
3149
  # the on-disk folder overview change between turns (the agent creates/edits files),
3122
3150
  # so they must NOT sit inside the static tool_hint β€” they'd bust the prefix cache
3123
3151
  # mid-reference every turn. They join the variable tail of task_with_hint instead.
3152
+ # Task #90 Phase 2 β€” environment visibility: prune/cap the background-server
3153
+ # registry once per turn (stops the oldest beyond 3 β€” kills the leak class), then
3154
+ # SHOW the survivors to the model so a 'port busy' is instantly explicable and
3155
+ # reuse/stop_server is actionable. Variable (server state changes) β†’ tail.
3156
+ _ws_servers_line = ""
3157
+ if _WS_AVAILABLE:
3158
+ try:
3159
+ import os as _os_srv
3160
+ _live_srv = _ws_cap_servers()
3161
+ if _live_srv:
3162
+ _descs = ", ".join(
3163
+ ":" + ",".join(str(p) for p in (s.get("ports") or ["?"]))
3164
+ + f" ('{str(s.get('command'))[:30]}' in "
3165
+ + f"{_os_srv.path.basename(str(s.get('workdir') or '')) or '?'})"
3166
+ for s in _live_srv)
3167
+ _ws_servers_line = (
3168
+ f" Background servers YOU already started (still running): {_descs}. "
3169
+ "REUSE one if it fits the task, or stop_server(<port>) β€” their ports "
3170
+ "are TAKEN, so never start a duplicate on one of them.\n")
3171
+ except Exception as _e: # noqa: BLE001
3172
+ _log(f"server context skip: {_e}")
3124
3173
  _ws_context_block = (
3125
3174
  _ws_current_line
3126
3175
  + f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
3127
- f" Current folder contents:\n"
3176
+ + _ws_servers_line
3177
+ + f" Current folder contents:\n"
3128
3178
  + "\n".join(f" {line}" for line in _ws_overview.splitlines()) + "\n"
3129
3179
  ) if _WS_AVAILABLE else ""
3130
3180
  _ws_tool_block = (
3181
+ # Task #90 (root-cause rev): senior-dev autonomy as GENERAL principles β€” outcome
3182
+ # over exit code, own the environment, evidence before finishing, switch approach.
3183
+ # Static (cached, per #86); deliberately not tied to any specific failure case.
3184
+ "WORK LIKE A SENIOR DEVELOPER β€” you OWN the task until it DEMONSTRABLY works:\n"
3185
+ " * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
3186
+ "state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
3187
+ "(server responds, artifact exists, test green, file contains the change) before "
3188
+ "moving on or finishing.\n"
3189
+ " * On failure: read the error, fix the ROOT CAUSE (edit the code/config, "
3190
+ "install the missing dependency, adjust the environment), re-run to confirm. If "
3191
+ "the SAME fix fails twice, SWITCH APPROACH β€” never repeat it, never just give up.\n"
3192
+ " * The environment is YOURS to manage: a busy port, missing package, or stale "
3193
+ "process you started earlier is yours to RESOLVE with the tools (e.g. start on a "
3194
+ "free port via 'env VAR=value cmd', stop_server your own old server) β€” not to "
3195
+ "report back as the user's problem.\n"
3196
+ " * final_answer needs EVIDENCE: say what you verified and how. If a thing "
3197
+ "truly cannot be done with these tools, state plainly WHAT failed and WHY β€” but "
3198
+ "NEVER end with 'you can do it yourself' when a tool could have done it.\n"
3131
3199
  " - list_dir(path) / read_text_file(path) β€” browse and read files.\n"
3132
3200
  " - grep_repo(pattern, path='', glob='') β€” search code, returns file:line hits. "
3133
3201
  "Use this to find the EXACT lines to change.\n"
@@ -3349,7 +3417,13 @@ def run_agent_chat(cfg):
3349
3417
  "if the task needs a DIFFERENT one.)\n"
3350
3418
  + _deploy_block
3351
3419
  + _ws_context_block +
3352
- "\nTASK (answer THIS, choose the tool that fits it):\n"
3420
+ # Task #90: ~25-token echo of the autonomy contract at the highest-attention spot
3421
+ # (recency β€” right next to the task), reinforcing the full directive that lives in
3422
+ # the cached reference above. Workspace/coding mode only.
3423
+ + ("Remember: FINISH it yourself β€” fix blockers, VERIFY the outcome, then "
3424
+ "final_answer with evidence; never hand the task back.\n"
3425
+ if _WS_AVAILABLE else "")
3426
+ + "\nTASK (answer THIS, choose the tool that fits it):\n"
3353
3427
  f"{task_text}\n"
3354
3428
  )
3355
3429
 
@@ -3873,6 +3947,81 @@ def run_agent_chat(cfg):
3873
3947
  # agent's memory to trim old observations (task #63, keeps the prompt from growing
3874
3948
  # O(nΒ²) as big file reads pile up in the step trace).
3875
3949
  _agent_ref: dict = {"a": None}
3950
+ # Task #87 rec#3: when the context grows large in an INTERACTIVE terminal turn, ask
3951
+ # the user Yes/No before aggressively compacting it (rather than deciding silently).
3952
+ # decision: None = not yet asked this turn; "always" = user said yes, keep compacting
3953
+ # each step without re-asking; "never" = user said no, don't ask again this turn (the
3954
+ # normal auto-trim above still runs). Threshold in prompt tokens for the last request.
3955
+ _compact_state: dict = {"decision": None}
3956
+ _COMPACT_ASK_TOKENS = 14000
3957
+
3958
+ # Task #90 Phase 3 β€” completion gate (code-enforced, complements the prompt). Fires at
3959
+ # most ONCE per turn: refuse the first final_answer that either hands the task back to
3960
+ # the user, or lands right after a state-changing command that was never verified β€” with
3961
+ # a verify-or-explain nudge fed back to the model (smolagents final_answer_checks: a
3962
+ # raised message becomes the check-failure the model sees and retries on). After one
3963
+ # nudge it ACCEPTS unconditionally β€” no loop; an honest "here's what failed" is valid.
3964
+ _completion_gate: dict = {"nudged": False}
3965
+ # Punt phrases: handing the CORE task back ("…start it yourself", "you'll need to run").
3966
+ # Deliberately narrow so a helpful tip ("you can also run npm test") does NOT trigger it.
3967
+ _PUNT_RE = re.compile(
3968
+ r"\b(?:do|run|start|complete|finish|build) it yourself\b"
3969
+ r"|\byou can navigate to\b"
3970
+ r"|\bstart (?:it|the (?:app|server|project|dev server)) yourself\b"
3971
+ r"|\byou'?ll (?:have to|need to) (?:run|start|do|build)\b",
3972
+ re.I)
3973
+
3974
+ def _final_answer_gate(final_answer, memory, agent=None):
3975
+ """smolagents final_answer_check. True = accept; raising = reject with the message
3976
+ the model then sees. Only nudges when there's real evidence of an unfinished job."""
3977
+ if _completion_gate["nudged"]:
3978
+ return True # already nudged once this turn β€” accept, never loop
3979
+ ans = str(final_answer if final_answer is not None else "")
3980
+ punt = bool(_PUNT_RE.search(ans))
3981
+ # The most-recent tool result: a command that finished but whose EFFECT was never
3982
+ # verified (Phase-1 'EXITED 0 (…VERIFY…)' marker), with no server/HTTP confirmation.
3983
+ last_unverified = False
3984
+ try:
3985
+ for _st in reversed(getattr(memory, "steps", []) or []):
3986
+ _obs = getattr(_st, "observations", None)
3987
+ if isinstance(_obs, str) and _obs.strip():
3988
+ last_unverified = ("EXITED 0 (" in _obs) and not any(
3989
+ k in _obs for k in
3990
+ ("RUNNING:", "listening", "HTTP 2", "http://localhost"))
3991
+ break
3992
+ except Exception: # noqa: BLE001
3993
+ pass
3994
+ if punt or last_unverified:
3995
+ _completion_gate["nudged"] = True
3996
+ raise ValueError(
3997
+ "NOT DONE YET β€” you ran commands or changed files but have NOT shown the "
3998
+ "goal actually works. Do ONE of these, then call final_answer:\n"
3999
+ " 1) VERIFY it β€” curl/open the server, run the test, or re-read the file to "
4000
+ "confirm the change took β€” and answer WITH that evidence; OR\n"
4001
+ " 2) if it genuinely cannot be done with these tools, state plainly WHAT "
4002
+ "failed and WHY.\n"
4003
+ "Do NOT hand an unfinished task back to the user.")
4004
+ return True
4005
+
4006
+ def _aggressive_compact(steps):
4007
+ """User-approved deep compaction: keep only the LAST step in full; for every
4008
+ older step shrink the observation to a stub and the model_output to just its
4009
+ Thought head. More aggressive than the always-on auto-trim (keep-last-2). Returns
4010
+ chars saved. Idempotent β€” already-short fields fall under the caps and are skipped."""
4011
+ saved = 0
4012
+ for _st in steps[:-1]:
4013
+ _obs = getattr(_st, "observations", None)
4014
+ if isinstance(_obs, str) and len(_obs) > 200:
4015
+ _b = len(_obs)
4016
+ _st.observations = _obs[:150].rstrip() + "\n…[compacted β€” re-read if needed]"
4017
+ saved += _b - len(_st.observations)
4018
+ _mo = getattr(_st, "model_output", None)
4019
+ if isinstance(_mo, str) and len(_mo) > 220:
4020
+ _b = len(_mo)
4021
+ _head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
4022
+ _st.model_output = (_head[:200].rstrip() or _mo[:150].rstrip()) + "\n…[compacted]"
4023
+ saved += _b - len(_st.model_output)
4024
+ return saved
3876
4025
 
3877
4026
  def step_callback(step_log):
3878
4027
  step_num = getattr(step_log, "step_number", "?")
@@ -3890,22 +4039,82 @@ def run_agent_chat(cfg):
3890
4039
  except Exception: # noqa: BLE001
3891
4040
  pass
3892
4041
 
3893
- # (B) Trim OLD, LARGE tool observations out of the CodeAgent's step memory so
3894
- # they aren't re-sent to the model on every subsequent step (the #63 O(nΒ²) token
3895
- # blow-up: an uncapped read_file_lines dump cost ~5-6k tokens PER remaining step).
3896
- # Keep the last 2 steps' observations intact (the model just acted on them);
3897
- # replace older long ones with a short placeholder β€” the model can re-read.
4042
+ # (B) Trim OLD, LARGE step content out of the CodeAgent's step memory so it isn't
4043
+ # re-sent to the model on every subsequent step (the #63 O(nΒ²) token blow-up: an
4044
+ # uncapped dump cost ~5-6k tokens PER remaining step). Keep the last 2 steps intact
4045
+ # (the model just acted on them); shrink older long content. write_memory_to_messages
4046
+ # rebuilds the prompt from memory.steps on EVERY request (no cache), so mutating the
4047
+ # step objects here takes effect on the next request; old steps are only rendered
4048
+ # into history, never re-parsed for execution, so trimming them is safe.
4049
+ # - observations = the tool's OUTPUT (a file/dir dump). Trimmed since #63.
4050
+ # - model_output = the model's own Thought + <code> block (task #87). For an OLD
4051
+ # step the code ALREADY ran and its result is in the observation, so re-sending
4052
+ # the code verbatim every step is pure waste β€” keep the Thought, drop the code.
4053
+ # Both trims are IDEMPOTENT (a re-trimmed field falls under the size threshold, so a
4054
+ # later step_callback skips it β€” the counter below only logs NEW trims).
3898
4055
  try:
3899
4056
  _mem = getattr(_agent_ref.get("a"), "memory", None)
3900
4057
  _steps = getattr(_mem, "steps", None)
3901
4058
  if _steps and len(_steps) > 2:
4059
+ _trim_n, _trim_saved = 0, 0
3902
4060
  for _st in _steps[:-2]:
3903
4061
  _obs = getattr(_st, "observations", None)
3904
4062
  if isinstance(_obs, str) and len(_obs) > 900:
4063
+ _before = len(_obs)
3905
4064
  _st.observations = (
3906
4065
  _obs[:400].rstrip()
3907
4066
  + "\n…[earlier observation trimmed to save context β€” "
3908
4067
  "re-read the file/dir with a small range if you still need it]")
4068
+ _trim_n += 1
4069
+ _trim_saved += _before - len(_st.observations)
4070
+ _mo = getattr(_st, "model_output", None)
4071
+ if isinstance(_mo, str) and len(_mo) > 700:
4072
+ _before = len(_mo)
4073
+ # Keep the Thought (everything before the code block); the code is
4074
+ # redundant with the observation that already came back.
4075
+ _head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
4076
+ _kept = (_head[:500].rstrip() if _head else _mo[:300].rstrip())
4077
+ _new = _kept + "\n…[code trimmed to save context β€” its result is in the Observation]"
4078
+ if len(_new) < _before:
4079
+ _st.model_output = _new
4080
+ _trim_n += 1
4081
+ _trim_saved += _before - len(_new)
4082
+ if _trim_n:
4083
+ _log(f"context trim: shrank {_trim_n} old step field(s), "
4084
+ f"saved ~{_trim_saved} chars (#63/#87)")
4085
+
4086
+ # (C) Task #87 rec#3 β€” INTERACTIVE compaction consent. When the context is
4087
+ # genuinely large and we're in a terminal that can prompt, ask the user
4088
+ # Yes/No before deep-compacting (the auto-trim above is always-on; THIS is
4089
+ # the extra, opt-in aggressive pass). Only in the terminal (interactive) β€”
4090
+ # web has no picker, so it just keeps the auto-trim.
4091
+ if (_interactive_approval and _job_id and pdf_api_base and pdf_worker_key
4092
+ and _compact_state["decision"] != "never"):
4093
+ # Size of the last request in prompt tokens (exact if the server
4094
+ # reported it, else ~chars/4 of the current step memory).
4095
+ _tu = getattr(step_log, "token_usage", None)
4096
+ _in_tok = int(getattr(_tu, "input_tokens", 0) or 0) if _tu else 0
4097
+ if _in_tok <= 0:
4098
+ _chars = sum(len(getattr(_s, "model_output", "") or "")
4099
+ + len(getattr(_s, "observations", "") or "")
4100
+ for _s in _steps)
4101
+ _in_tok = _chars // 4 + 7000 # + fixed system/reference baseline
4102
+ if _in_tok >= _COMPACT_ASK_TOKENS and len(_steps) > 2:
4103
+ if _compact_state["decision"] != "always":
4104
+ _kt = _in_tok // 1000
4105
+ _allow = _ws_request_approval(
4106
+ pdf_api_base, pdf_worker_key, _job_id,
4107
+ f"__COMPACT__::The context is getting large (~{_kt}k tokens "
4108
+ "per request). Compact older steps to keep it fast?",
4109
+ "compact-context")
4110
+ _compact_state["decision"] = "always" if _allow else "never"
4111
+ _emit({"type": "step", "text": (
4112
+ "Compacting context…" if _allow
4113
+ else "Keeping full context (won't ask again this turn).")})
4114
+ if _compact_state["decision"] == "always":
4115
+ _cs = _aggressive_compact(_steps)
4116
+ if _cs:
4117
+ _log(f"context compact (user-approved): saved ~{_cs} chars")
3909
4118
  except Exception as _e: # noqa: BLE001
3910
4119
  _log(f"memory-trim skip: {_e}")
3911
4120
 
@@ -5177,8 +5386,23 @@ def run_agent_chat(cfg):
5177
5386
  rc = proc.poll()
5178
5387
  if rc is not None:
5179
5388
  out = _ws_distill_output(_read_log())
5180
- status = "PASSED (exit 0)" if rc == 0 else f"FAILED (exit {rc})"
5181
- _emit({"type": "step", "text": f"Command {status.split()[0].lower()} β†’ {command[:50]}"})
5389
+ # Task #90 Phase 1 β€” truthful perception: never claim success the
5390
+ # tool didn't observe. exit 0 is a FACT about the process, not
5391
+ # proof of the GOAL (e.g. `npm start` exits 0 when its port is
5392
+ # busy and the server never started β€” the old "PASSED (exit 0)"
5393
+ # label told the model it succeeded, 3Γ— in one live run). The
5394
+ # RUNNING/LAUNCHED paths below stay β€” those ARE observed outcomes.
5395
+ # General (no command-name or error-string matching): the label
5396
+ # states the fact and hands verification to the model, which the
5397
+ # senior-dev directive tells it to do.
5398
+ status = (
5399
+ "EXITED 0 (the command finished β€” exit code alone does NOT "
5400
+ "prove the intended effect happened; check the output below "
5401
+ "and VERIFY before relying on it)"
5402
+ if rc == 0 else f"FAILED (exit {rc})"
5403
+ )
5404
+ _emit({"type": "step", "text":
5405
+ f"Command {'finished' if rc == 0 else 'failed'} β†’ {command[:50]}"})
5182
5406
  result = f"{status}\n{out}" if out.strip() else status
5183
5407
  _last_obs["text"] = result
5184
5408
  return result
@@ -5450,6 +5674,11 @@ def run_agent_chat(cfg):
5450
5674
  max_steps=max_steps,
5451
5675
  step_callbacks=[step_callback],
5452
5676
  )
5677
+ # Task #90 Phase 3: the completion gate only makes sense in coding/workspace mode
5678
+ # (state-changing commands + file edits); retrieval turns never trip it anyway, but
5679
+ # gating keeps plain-chat/research answers untouched.
5680
+ if _WS_AVAILABLE:
5681
+ agent_kwargs["final_answer_checks"] = [_final_answer_gate]
5453
5682
  if _AgentBase is CodeAgent:
5454
5683
  # Only the CodeAgent runs a Python executor; ToolCallingAgent takes neither.
5455
5684
  # 660s, NOT 60 (task #74): the wall clock must exceed the LARGEST tool-internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.255",
3
+ "version": "1.0.257",
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",