@tiens.nguyen/gonext-local-worker 1.0.224 → 1.0.225

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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +184 -18
  2. package/package.json +1 -1
@@ -2369,6 +2369,23 @@ def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) ->
2369
2369
  + out[-cap_tail:])
2370
2370
 
2371
2371
 
2372
+ def _ws_cap_obs(text: str, cap: int = 3000) -> str:
2373
+ """Cap a READ tool's observation (read_file_lines / list_dir / read_text_file /
2374
+ grep_repo) before it's returned to the CodeAgent. Every observation stays in the
2375
+ agent's step memory and is re-sent to the model EVERY subsequent step, so an
2376
+ uncapped 20k-char file dump costs ~5-6k tokens PER remaining step (O(n²) growth —
2377
+ task #63). Keeps the head (line numbers / range start) + a small tail; the model is
2378
+ told to read smaller ranges / grep to locate instead of whole files."""
2379
+ if len(text) <= cap:
2380
+ return text
2381
+ head = int(cap * 0.7)
2382
+ tail = cap - head
2383
+ return (text[:head].rstrip()
2384
+ + f"\n…[{len(text) - cap} chars truncated to save context — read a smaller "
2385
+ "line range, or grep_repo to locate the exact spot]…\n"
2386
+ + text[-tail:].lstrip())
2387
+
2388
+
2372
2389
  def _rag_s3_client(region: str, akid: str, secret: str):
2373
2390
  import boto3 # noqa: PLC0415
2374
2391
  return boto3.client(
@@ -2928,7 +2945,9 @@ def run_agent_chat(cfg):
2928
2945
  " - grep_repo(pattern, path='', glob='') — search code, returns file:line hits. "
2929
2946
  "Use this to find the EXACT lines to change.\n"
2930
2947
  " - read_file_lines(path, start_line, end_line) — read WITH line numbers "
2931
- "before editing.\n"
2948
+ "before editing. Read a SMALL range around the spot you need (large reads are "
2949
+ "truncated and slow every later step). Use grep_repo to LOCATE first, then read "
2950
+ "~40 lines around the hit — do NOT re-read the whole file or the same range twice.\n"
2932
2951
  " - edit_lines(path, start_line, end_line, new_content) — PRIMARY edit tool: "
2933
2952
  "replace a line range (backup saved automatically).\n"
2934
2953
  " - edit_file(path, old_string, new_string) — replace one exact unique string "
@@ -2944,18 +2963,15 @@ def run_agent_chat(cfg):
2944
2963
  f" ALLOWED runners (first word only): {', '.join(sorted(_WS_RUN_ALLOWED))}. "
2945
2964
  "Anything else is rejected — do NOT retry a blocked command a different way. "
2946
2965
  "For Python packages use `python3 -m pip install <pkg>` (bare `pip` is NOT allowed).\n"
2947
- " DEPLOY (ssh/scp/rsync are allowed): transfer files + run remote commands "
2948
- "directly, e.g. `rsync -az build/ user@host:/path` then `ssh user@host '…'`. Use "
2949
- "NON-INTERACTIVE KEY auth: add `-o BatchMode=yes` (and "
2950
- "`-o StrictHostKeyChecking=accept-new`) so it fails fast instead of hanging on a "
2951
- "prompt. NEVER put a password in a command, file, or your reply there is no "
2952
- "stdin here. If the server only accepts a PASSWORD (BatchMode fails with "
2953
- "'Permission denied (publickey,password)'), STOP and tell the user to set up key "
2954
- "auth once: `ssh-copy-id user@host` then retry. Do not write paramiko/expect "
2955
- "scripts with hardcoded credentials. REMOTE sudo usually FAILS over ssh (no TTY: "
2956
- "'a terminal is required') — deploy to a directory the remote user can write, "
2957
- "and if a root step is unavoidable (mv to /var/www, nginx config/reload), "
2958
- "final_answer the exact one-liner for the user to run on the server themselves.\n"
2966
+ " - deploy_web(local_dir, host, user, remote_path, domain='') DEPLOY a built "
2967
+ "static site in ONE call: build first (run_command('npm run build')), then "
2968
+ "deploy_web('my-app/build', host, user, remote_path, domain). It rsyncs over your "
2969
+ "SSH KEY, finishes nginx via passwordless sudo when possible, else writes deploy.sh "
2970
+ "+ returns the exact server commands; if key auth isn't set up it returns the "
2971
+ "one-time `ssh-copy-id` to run. PREFER THIS over hand-writing scp/ssh/rsync.\n"
2972
+ " Raw ssh/scp/rsync are also allowed for other remote work always KEY auth "
2973
+ "(`-o BatchMode=yes`), NEVER a password in a command/file/reply (no stdin here), "
2974
+ "no paramiko/expect with hardcoded creds; remote sudo fails without a TTY.\n"
2959
2975
  " NOTE: plain `import os`/`open()` is blocked — use ONLY these tools for files.\n"
2960
2976
  " WRITING FILE CONTENT: multi-line content MUST use \\n for newlines (or a "
2961
2977
  "triple-quoted string). NEVER put a raw line break inside a normal \"...\" — it "
@@ -3472,10 +3488,33 @@ def run_agent_chat(cfg):
3472
3488
  # _write_turn_checkpoint (inside step_callback below) — see _load_and_clear_turn_
3473
3489
  # checkpoint above for how a crashed/interrupted turn gets recovered on the next run.
3474
3490
  _turn_steps: list = []
3491
+ # Set right after the agent is built (below) so step_callback can reach the running
3492
+ # agent's memory to trim old observations (task #63, keeps the prompt from growing
3493
+ # O(n²) as big file reads pile up in the step trace).
3494
+ _agent_ref: dict = {"a": None}
3475
3495
 
3476
3496
  def step_callback(step_log):
3477
3497
  step_num = getattr(step_log, "step_number", "?")
3478
3498
 
3499
+ # (B) Trim OLD, LARGE tool observations out of the CodeAgent's step memory so
3500
+ # they aren't re-sent to the model on every subsequent step (the #63 O(n²) token
3501
+ # blow-up: an uncapped read_file_lines dump cost ~5-6k tokens PER remaining step).
3502
+ # Keep the last 2 steps' observations intact (the model just acted on them);
3503
+ # replace older long ones with a short placeholder — the model can re-read.
3504
+ try:
3505
+ _mem = getattr(_agent_ref.get("a"), "memory", None)
3506
+ _steps = getattr(_mem, "steps", None)
3507
+ if _steps and len(_steps) > 2:
3508
+ for _st in _steps[:-2]:
3509
+ _obs = getattr(_st, "observations", None)
3510
+ if isinstance(_obs, str) and len(_obs) > 900:
3511
+ _st.observations = (
3512
+ _obs[:400].rstrip()
3513
+ + "\n…[earlier observation trimmed to save context — "
3514
+ "re-read the file/dir with a small range if you still need it]")
3515
+ except Exception as _e: # noqa: BLE001
3516
+ _log(f"memory-trim skip: {_e}")
3517
+
3479
3518
  # Log what was sent to the model (last message in the conversation).
3480
3519
  model_input = getattr(step_log, "model_input_messages", None)
3481
3520
  if model_input:
@@ -4102,7 +4141,8 @@ def run_agent_chat(cfg):
4102
4141
  break
4103
4142
  entries.sort()
4104
4143
  more = " …(truncated)" if len(entries) >= 300 else ""
4105
- out = f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
4144
+ out = _ws_cap_obs(
4145
+ f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more)
4106
4146
  _last_obs["text"] = out
4107
4147
  return out
4108
4148
  except Exception as e: # noqa: BLE001
@@ -4131,7 +4171,7 @@ def run_agent_chat(cfg):
4131
4171
  return msg
4132
4172
  with open(rp, "r", encoding="utf-8", errors="replace") as fh:
4133
4173
  data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
4134
- out = data or "(empty file)"
4174
+ out = _ws_cap_obs(data or "(empty file)")
4135
4175
  _last_obs["text"] = out
4136
4176
  return out
4137
4177
  except Exception as e: # noqa: BLE001
@@ -4317,7 +4357,7 @@ def run_agent_chat(cfg):
4317
4357
  _last_obs["text"] = msg
4318
4358
  return msg
4319
4359
  more = "\n…(capped at 100 hits)" if len(hits) >= 100 else ""
4320
- out = f"Matches under {root}:\n" + "\n".join(hits) + more
4360
+ out = _ws_cap_obs(f"Matches under {root}:\n" + "\n".join(hits) + more)
4321
4361
  _last_obs["text"] = out
4322
4362
  return out
4323
4363
  except Exception as e: # noqa: BLE001
@@ -4342,7 +4382,7 @@ def run_agent_chat(cfg):
4342
4382
  e = int(end_line or 0) or len(lines)
4343
4383
  e = min(e, len(lines), s + 399)
4344
4384
  body = "".join(f"{i}: {lines[i - 1]}" for i in range(s, e + 1))
4345
- out = f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}"
4385
+ out = _ws_cap_obs(f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}")
4346
4386
  _last_obs["text"] = out
4347
4387
  return out
4348
4388
  except Exception as ex: # noqa: BLE001
@@ -4725,6 +4765,130 @@ def run_agent_chat(cfg):
4725
4765
  _last_obs["text"] = msg
4726
4766
  return msg
4727
4767
 
4768
+ @tool
4769
+ def deploy_web(local_dir: str, host: str, user: str, remote_path: str,
4770
+ domain: str = "") -> str:
4771
+ """Deploy a built static site to a remote server in ONE step — prefer this over hand-writing scp/ssh/rsync. rsyncs local_dir to user@host:remote_path over SSH KEY auth, and (if domain is given) tries to finish the nginx setup via PASSWORDLESS sudo; if that needs a password it writes a ready-to-run deploy.sh and returns the exact commands for the user. Uses the user's SSH key only — never a password. If key auth isn't set up it returns the one-time `ssh-copy-id` command to run and stops.
4772
+
4773
+ Args:
4774
+ local_dir: the built folder to upload (e.g. 'my-react-app/build') inside the workspace.
4775
+ host: server hostname (e.g. 'example.com').
4776
+ user: SSH username.
4777
+ remote_path: destination dir on the server (e.g. '/home/<user>/site' or '/var/www/site').
4778
+ domain: optional site domain — when set, also generate/apply the nginx server block.
4779
+ """
4780
+ try:
4781
+ import os
4782
+ import subprocess
4783
+ src = _rag_read_allowed((local_dir or "").strip())
4784
+ if not os.path.isdir(src):
4785
+ msg = (f"Error: local_dir '{local_dir}' is not a folder — build first "
4786
+ "(e.g. run_command('npm run build')) so the output dir exists.")
4787
+ _last_obs["text"] = msg
4788
+ return msg
4789
+ host = (host or "").strip()
4790
+ user = (user or "").strip()
4791
+ remote_path = (remote_path or "").strip()
4792
+ if not host or not user or not remote_path:
4793
+ msg = "Error: host, user and remote_path are all required."
4794
+ _last_obs["text"] = msg
4795
+ return msg
4796
+ target = f"{user}@{host}"
4797
+ sshopts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new",
4798
+ "-o", "ConnectTimeout=8"]
4799
+ env = _ws_scrubbed_env()
4800
+ _emit({"type": "step", "text": f"Deploying → {target}:{remote_path}"})
4801
+ # 1) KEY-auth probe. No password is ever used or requested.
4802
+ probe = subprocess.run(["ssh", *sshopts, target, "true"], env=env,
4803
+ capture_output=True, text=True, timeout=25)
4804
+ if probe.returncode != 0:
4805
+ msg = (
4806
+ f"Can't deploy yet: SSH key auth to {target} isn't set up "
4807
+ f"({(probe.stderr or '').strip()[:160]}). Run this ONCE in your "
4808
+ f"OWN terminal (it will ask for the password a final time):\n"
4809
+ f" ssh-copy-id {target}\n"
4810
+ "Then ask me to deploy again — I'll use the key (no password needed)."
4811
+ )
4812
+ _last_obs["text"] = msg
4813
+ return msg
4814
+ # 2) rsync the build over the key.
4815
+ rsync = subprocess.run(
4816
+ ["rsync", "-az", "--delete", "-e", "ssh " + " ".join(sshopts),
4817
+ src.rstrip("/") + "/", f"{target}:{remote_path.rstrip('/')}/"],
4818
+ env=env, capture_output=True, text=True, timeout=600)
4819
+ if rsync.returncode != 0:
4820
+ msg = (f"Error: rsync to {target}:{remote_path} failed "
4821
+ f"({(rsync.stderr or rsync.stdout or '').strip()[:200]}).")
4822
+ _last_obs["text"] = msg
4823
+ return msg
4824
+ uploaded = f"Uploaded {os.path.basename(src)} → {target}:{remote_path}."
4825
+ if not domain.strip():
4826
+ _last_obs["text"] = uploaded
4827
+ return uploaded + " No domain given, so nothing was served — pass domain= to configure nginx."
4828
+ dom = domain.strip()
4829
+ webroot = f"/var/www/{dom}"
4830
+ nginx_conf = (
4831
+ f"server {{\n listen 80;\n server_name {dom};\n"
4832
+ f" root {webroot};\n index index.html;\n"
4833
+ f" location / {{ try_files $uri $uri/ /index.html; }}\n}}\n"
4834
+ )
4835
+ # Remote setup script: move the upload into the webroot + wire up nginx.
4836
+ remote_sh = (
4837
+ "set -e\n"
4838
+ f"mkdir -p {webroot}\n"
4839
+ f"cp -r {remote_path.rstrip('/')}/* {webroot}/\n"
4840
+ f"cat > /etc/nginx/sites-available/{dom} <<'NGINX'\n{nginx_conf}NGINX\n"
4841
+ f"ln -sf /etc/nginx/sites-available/{dom} /etc/nginx/sites-enabled/{dom}\n"
4842
+ "nginx -t\n"
4843
+ "systemctl reload nginx\n"
4844
+ )
4845
+ # 3) Try to finish via PASSWORDLESS sudo (sudo -n succeeds only w/ NOPASSWD).
4846
+ fin = subprocess.run(["ssh", *sshopts, target, "sudo -n bash -s"],
4847
+ input=remote_sh, env=env, capture_output=True,
4848
+ text=True, timeout=120)
4849
+ if fin.returncode == 0:
4850
+ out = f"{uploaded} Configured nginx and reloaded — the site is live at http://{dom}/."
4851
+ _emit({"type": "step", "text": f"Site live → http://{dom}/"})
4852
+ _last_obs["text"] = out
4853
+ return out
4854
+ # 4) sudo needs a password (no TTY here) → write a runnable deploy.sh
4855
+ # (SAME sudo commands as the printed steps) and hand it over.
4856
+ sudo_sh = (
4857
+ "#!/usr/bin/env bash\nset -e\n"
4858
+ f"sudo mkdir -p {webroot}\n"
4859
+ f"sudo cp -r {remote_path.rstrip('/')}/* {webroot}/\n"
4860
+ f"sudo tee /etc/nginx/sites-available/{dom} >/dev/null <<'NGINX'\n"
4861
+ f"{nginx_conf}NGINX\n"
4862
+ f"sudo ln -sf /etc/nginx/sites-available/{dom} /etc/nginx/sites-enabled/{dom}\n"
4863
+ "sudo nginx -t && sudo systemctl reload nginx\n"
4864
+ )
4865
+ sh_local = os.path.join(_default_ws_root(), "deploy.sh")
4866
+ try:
4867
+ with open(sh_local, "w", encoding="utf-8") as fh:
4868
+ fh.write(sudo_sh)
4869
+ _ws_record_change("create", sh_local, None)
4870
+ where = sh_local
4871
+ except OSError:
4872
+ where = "(could not write deploy.sh)"
4873
+ out = (
4874
+ f"{uploaded} The nginx/webroot step needs sudo (no password prompt is "
4875
+ f"possible over SSH). I wrote a runnable script to {where} — copy it to "
4876
+ f"the server and run it (`scp deploy.sh {target}:` then `ssh {target} "
4877
+ f"'bash deploy.sh'`), OR run these on the server yourself:\n"
4878
+ + "\n".join(" " + ln for ln in sudo_sh.splitlines()[2:])
4879
+ + f"\nThen {dom} serves the site."
4880
+ )
4881
+ _last_obs["text"] = out
4882
+ return out
4883
+ except subprocess.TimeoutExpired:
4884
+ msg = f"Error: deploy to {host} timed out (server unreachable or slow)."
4885
+ _last_obs["text"] = msg
4886
+ return msg
4887
+ except Exception as ex: # noqa: BLE001
4888
+ msg = f"Error: deploy_web failed: {type(ex).__name__}: {ex}"
4889
+ _last_obs["text"] = msg
4890
+ return msg
4891
+
4728
4892
  agent_tools = [http_request, web_search, fetch_url, calculate,
4729
4893
  get_current_datetime, create_pdf]
4730
4894
  # Only register the PDF reader / email / RAG tools when available — the model
@@ -4739,7 +4903,8 @@ def run_agent_chat(cfg):
4739
4903
  rag_index, rag_add, rag_search]
4740
4904
  if _WS_AVAILABLE:
4741
4905
  agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
4742
- create_file, create_folder, run_command, stop_server]
4906
+ create_file, create_folder, run_command, stop_server,
4907
+ deploy_web]
4743
4908
  # list_dir/read_text_file are needed for workspace browsing even when RAG
4744
4909
  # (S3 indexing) isn't configured.
4745
4910
  if not _RAG_AVAILABLE:
@@ -4778,6 +4943,7 @@ def run_agent_chat(cfg):
4778
4943
  except Exception as _e: # noqa: BLE001
4779
4944
  _log(f"compact system prompt unavailable ({_e}); using smolagents default")
4780
4945
  agent = _ToolAgent(**agent_kwargs)
4946
+ _agent_ref["a"] = agent # let step_callback trim this agent's step memory (#63)
4781
4947
  with contextlib.redirect_stdout(sys.stderr):
4782
4948
  result = agent.run(task_with_hint)
4783
4949
  # Final formatting — NO extra summarizer model call. In multi-step mode the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.224",
3
+ "version": "1.0.225",
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",