@tiens.nguyen/gonext-local-worker 1.0.197 → 1.0.198
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_agent_chat.py +226 -22
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -610,6 +610,13 @@ _WS_ACTION_RE = re.compile(
|
|
|
610
610
|
r"\b(?:create|make|add|new|delete|remove|rename|move|copy|list|show)\b"
|
|
611
611
|
r"[^.?!]{0,60}?"
|
|
612
612
|
r"\b(?:folders?|director(?:y|ies)|dirs?|files?|subfolders?)\b"
|
|
613
|
+
# Run/verify actions on the project itself ("start and test the project", "run
|
|
614
|
+
# the app", "build it and run the tests") — second reported miss (task #40):
|
|
615
|
+
# none of these words were keywords anywhere, so "help me start and test the
|
|
616
|
+
# project" got a step-by-step npm tutorial instead of the agent running them.
|
|
617
|
+
r"|\b(?:run|start|launch|build|test|install|compile|verify|stop|kill|restart)\b"
|
|
618
|
+
r"[^.?!]{0,60}?"
|
|
619
|
+
r"\b(?:project|app|application|server|site|website|tests?|build|dependenc(?:y|ies)|packages?)\b"
|
|
613
620
|
r"|\bmkdir\b",
|
|
614
621
|
re.IGNORECASE,
|
|
615
622
|
)
|
|
@@ -825,6 +832,20 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
|
|
|
825
832
|
from openai import OpenAI
|
|
826
833
|
client = OpenAI(base_url=base_url, api_key=api_key or "local",
|
|
827
834
|
max_retries=0, timeout=20)
|
|
835
|
+
# The classifier's question must reflect the capabilities that ACTUALLY exist
|
|
836
|
+
# this run — with a workspace registered, acting on files/code/commands is
|
|
837
|
+
# agent work too, and pronoun phrasings ("test it") that the deterministic
|
|
838
|
+
# keyword gates can't safely match land here. Without this clause the prompt
|
|
839
|
+
# was HTTP-only (it predates the workspace tools), so NO was the classifier's
|
|
840
|
+
# CORRECT answer to the wrong question for every workspace action.
|
|
841
|
+
ws_clause = (
|
|
842
|
+
"\nThe user also has a code workspace registered, so answer YES as well "
|
|
843
|
+
"when the task asks to read, edit, create, delete, or list files or "
|
|
844
|
+
"folders, run tests/builds/commands, or start, stop, or restart the "
|
|
845
|
+
"project or its server — even when phrased with pronouns like 'test it' "
|
|
846
|
+
"or 'run it'."
|
|
847
|
+
) if _WS_ROOTS else ""
|
|
848
|
+
ws_q = ", files, code, or commands" if _WS_ROOTS else ""
|
|
828
849
|
resp = _chat_create(
|
|
829
850
|
client,
|
|
830
851
|
model=model_id,
|
|
@@ -833,12 +854,13 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
|
|
|
833
854
|
"You are a task classifier. Reply YES or NO only, no punctuation.\n"
|
|
834
855
|
"Answer YES if the task requires fetching data from an external network source "
|
|
835
856
|
"(URL, API, website, remote server), a web search / factual lookup, or the "
|
|
836
|
-
"current date or time
|
|
837
|
-
|
|
838
|
-
"
|
|
857
|
+
"current date or time."
|
|
858
|
+
+ ws_clause +
|
|
859
|
+
"\nAnswer NO only if it is pure conversation, opinion, or simple text the "
|
|
860
|
+
"assistant can answer directly without looking anything up or touching anything."
|
|
839
861
|
)},
|
|
840
862
|
{"role": "user", "content": (
|
|
841
|
-
f"Does this task require
|
|
863
|
+
f"Does this task require using tools (network{ws_q})?\n\n"
|
|
842
864
|
f"Task: {task_text}\n\nYES or NO:"
|
|
843
865
|
)},
|
|
844
866
|
],
|
|
@@ -2064,6 +2086,77 @@ _WS_RUN_ALLOWED = {
|
|
|
2064
2086
|
"rspec", "bundle", "php", "composer", "tsc", "jest", "vitest",
|
|
2065
2087
|
}
|
|
2066
2088
|
|
|
2089
|
+
# ---- background servers (behavior-detected, command-agnostic) ----
|
|
2090
|
+
# run_command classifies a command by OBSERVED BEHAVIOR, never by its text: if the
|
|
2091
|
+
# process keeps running AND starts LISTENing on a TCP port, it IS a server — npm start,
|
|
2092
|
+
# bun dev, a custom `make serve`, anything. (An earlier version pattern-matched command
|
|
2093
|
+
# names like "npm start"; rejected as unwinnable hardcode — same lesson as task #37.)
|
|
2094
|
+
# Detected servers are left running in their own process group (start_new_session),
|
|
2095
|
+
# recorded in ~/.gonext/servers.json, and stoppable via the stop_server tool.
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def _ws_pgroup_pids(pgid: int) -> list:
|
|
2099
|
+
"""Live pids in a process group (the server + everything it spawned)."""
|
|
2100
|
+
import subprocess
|
|
2101
|
+
try:
|
|
2102
|
+
out = subprocess.run(["pgrep", "-g", str(pgid)],
|
|
2103
|
+
stdout=subprocess.PIPE, text=True, timeout=5)
|
|
2104
|
+
return [int(x) for x in out.stdout.split()]
|
|
2105
|
+
except Exception: # noqa: BLE001
|
|
2106
|
+
return []
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def _ws_listening_ports(pgid: int) -> list:
|
|
2110
|
+
"""TCP ports the process group is LISTENing on — the behavioral definition of
|
|
2111
|
+
'this command is a server'. Empty when none (or lsof/pgrep unavailable)."""
|
|
2112
|
+
import subprocess
|
|
2113
|
+
pids = _ws_pgroup_pids(pgid)
|
|
2114
|
+
if not pids:
|
|
2115
|
+
return []
|
|
2116
|
+
try:
|
|
2117
|
+
out = subprocess.run(
|
|
2118
|
+
["lsof", "-a", "-p", ",".join(map(str, pids)),
|
|
2119
|
+
"-iTCP", "-sTCP:LISTEN", "-P", "-n"],
|
|
2120
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=5)
|
|
2121
|
+
ports = set()
|
|
2122
|
+
for line in out.stdout.splitlines()[1:]:
|
|
2123
|
+
m = re.search(r":(\d+)\s+\(LISTEN\)", line)
|
|
2124
|
+
if m:
|
|
2125
|
+
ports.add(int(m.group(1)))
|
|
2126
|
+
return sorted(ports)
|
|
2127
|
+
except Exception: # noqa: BLE001
|
|
2128
|
+
return []
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def _ws_servers_path() -> str:
|
|
2132
|
+
import os
|
|
2133
|
+
base = os.path.join(os.path.expanduser("~"), ".gonext")
|
|
2134
|
+
os.makedirs(base, exist_ok=True)
|
|
2135
|
+
return os.path.join(base, "servers.json")
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
def _ws_load_servers(prune: bool = True) -> list:
|
|
2139
|
+
"""Registry of background servers we started. `prune` drops entries whose
|
|
2140
|
+
process group is gone (crashed or stopped outside of us)."""
|
|
2141
|
+
import json as _json
|
|
2142
|
+
try:
|
|
2143
|
+
with open(_ws_servers_path(), encoding="utf-8") as fh:
|
|
2144
|
+
servers = _json.load(fh).get("servers", [])
|
|
2145
|
+
except Exception: # noqa: BLE001
|
|
2146
|
+
return []
|
|
2147
|
+
if prune:
|
|
2148
|
+
servers = [s for s in servers if _ws_pgroup_pids(int(s.get("pgid", 0)))]
|
|
2149
|
+
return servers
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
def _ws_save_servers(servers: list) -> None:
|
|
2153
|
+
import json as _json
|
|
2154
|
+
try:
|
|
2155
|
+
with open(_ws_servers_path(), "w", encoding="utf-8") as fh:
|
|
2156
|
+
_json.dump({"servers": servers}, fh, indent=2)
|
|
2157
|
+
except OSError as e:
|
|
2158
|
+
_log(f"servers.json write failed: {e}")
|
|
2159
|
+
|
|
2067
2160
|
|
|
2068
2161
|
def _ws_scrubbed_env() -> dict:
|
|
2069
2162
|
"""Minimal child env for run_command. The worker process env holds secrets
|
|
@@ -2072,7 +2165,11 @@ def _ws_scrubbed_env() -> dict:
|
|
|
2072
2165
|
import os
|
|
2073
2166
|
keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM",
|
|
2074
2167
|
"JAVA_HOME", "GOPATH", "CARGO_HOME", "NVM_DIR")
|
|
2075
|
-
|
|
2168
|
+
env = {k: os.environ[k] for k in keep if k in os.environ}
|
|
2169
|
+
# CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
|
|
2170
|
+
# exit instead of sitting in interactive watch mode until the timeout kills them.
|
|
2171
|
+
env["CI"] = "1"
|
|
2172
|
+
return env
|
|
2076
2173
|
|
|
2077
2174
|
|
|
2078
2175
|
def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) -> str:
|
|
@@ -4154,17 +4251,19 @@ def run_agent_chat(cfg):
|
|
|
4154
4251
|
|
|
4155
4252
|
@tool
|
|
4156
4253
|
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
|
|
4157
|
-
"""Run a
|
|
4254
|
+
"""Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING in the background and its URL is reported — tell the user the URL, and use stop_server to stop it when asked.
|
|
4158
4255
|
|
|
4159
4256
|
Args:
|
|
4160
|
-
command: the command line (
|
|
4257
|
+
command: the command line (allowlisted runners only; no shells or sudo).
|
|
4161
4258
|
workdir: directory to run in. Empty = first registered workspace.
|
|
4162
|
-
timeout_seconds:
|
|
4259
|
+
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
4163
4260
|
"""
|
|
4164
4261
|
try:
|
|
4165
4262
|
import os
|
|
4166
4263
|
import shlex
|
|
4264
|
+
import signal
|
|
4167
4265
|
import subprocess
|
|
4266
|
+
import time as _t
|
|
4168
4267
|
wd = _rag_read_allowed((workdir or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
|
|
4169
4268
|
w = _ws_for_path(wd)
|
|
4170
4269
|
if w is None or not w.get("allowRun"):
|
|
@@ -4184,23 +4283,128 @@ def run_agent_chat(cfg):
|
|
|
4184
4283
|
return msg
|
|
4185
4284
|
_emit({"type": "step", "text": f"Running → {command[:70]}"})
|
|
4186
4285
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
4187
|
-
#
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4286
|
+
# Own process group (start_new_session) + output to a log file. This is
|
|
4287
|
+
# what lets a server OUTLIVE this job process, and what lets a timeout
|
|
4288
|
+
# kill take the runner's whole child tree (npm's node child etc. — the
|
|
4289
|
+
# old subprocess.run timeout killed only the direct child, leaking it).
|
|
4290
|
+
logs_dir = os.path.join(os.path.expanduser("~"), ".gonext", "run-logs")
|
|
4291
|
+
os.makedirs(logs_dir, exist_ok=True)
|
|
4292
|
+
log_path = os.path.join(logs_dir, f"{_t.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.log")
|
|
4293
|
+
log_fh = open(log_path, "wb")
|
|
4294
|
+
# Scrubbed env: repo scripts must never see worker secrets.
|
|
4295
|
+
proc = subprocess.Popen(
|
|
4296
|
+
argv, cwd=wd, env=_ws_scrubbed_env(),
|
|
4297
|
+
stdout=log_fh, stderr=subprocess.STDOUT,
|
|
4298
|
+
start_new_session=True,
|
|
4191
4299
|
)
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4300
|
+
start = _t.time()
|
|
4301
|
+
|
|
4302
|
+
def _read_log() -> str:
|
|
4303
|
+
log_fh.flush()
|
|
4304
|
+
try:
|
|
4305
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
4306
|
+
return fh.read()
|
|
4307
|
+
except OSError:
|
|
4308
|
+
return ""
|
|
4309
|
+
|
|
4310
|
+
# Behavior classification loop — no command-name matching anywhere:
|
|
4311
|
+
# exited → one-shot result; still running AND listening on a TCP port →
|
|
4312
|
+
# it's a server, leave it running; neither by the deadline → kill group.
|
|
4313
|
+
while True:
|
|
4314
|
+
rc = proc.poll()
|
|
4315
|
+
if rc is not None:
|
|
4316
|
+
out = _ws_distill_output(_read_log())
|
|
4317
|
+
status = "PASSED (exit 0)" if rc == 0 else f"FAILED (exit {rc})"
|
|
4318
|
+
_emit({"type": "step", "text": f"Command {status.split()[0].lower()} → {command[:50]}"})
|
|
4319
|
+
result = f"{status}\n{out}" if out.strip() else status
|
|
4320
|
+
_last_obs["text"] = result
|
|
4321
|
+
return result
|
|
4322
|
+
if _t.time() - start >= 3:
|
|
4323
|
+
ports = _ws_listening_ports(proc.pid)
|
|
4324
|
+
if ports:
|
|
4325
|
+
servers = _ws_load_servers()
|
|
4326
|
+
servers.append({
|
|
4327
|
+
"pgid": proc.pid, "pid": proc.pid, "command": command,
|
|
4328
|
+
"workdir": wd, "ports": ports, "log": log_path,
|
|
4329
|
+
"started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
|
|
4330
|
+
})
|
|
4331
|
+
_ws_save_servers(servers)
|
|
4332
|
+
urls = ", ".join(f"http://localhost:{p}" for p in ports)
|
|
4333
|
+
_emit({"type": "step", "text": f"Server up → {urls} (left running)"})
|
|
4334
|
+
tail = _ws_distill_output(_read_log())
|
|
4335
|
+
result = (f"RUNNING: '{command}' is up and listening — {urls} "
|
|
4336
|
+
f"(pid {proc.pid}, log {log_path}). It stays running in "
|
|
4337
|
+
"the background; use stop_server to stop it. "
|
|
4338
|
+
f"Startup output:\n{tail}")
|
|
4339
|
+
_last_obs["text"] = result
|
|
4340
|
+
return result
|
|
4341
|
+
if _t.time() - start >= t:
|
|
4342
|
+
try:
|
|
4343
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
4344
|
+
except OSError:
|
|
4345
|
+
pass
|
|
4346
|
+
tail = _ws_distill_output(_read_log())
|
|
4347
|
+
msg = (f"Error: command neither exited nor started a server within "
|
|
4348
|
+
f"{t}s — killed. Output so far:\n{tail}")
|
|
4349
|
+
_last_obs["text"] = msg
|
|
4350
|
+
return msg
|
|
4351
|
+
_t.sleep(2)
|
|
4352
|
+
except Exception as ex: # noqa: BLE001
|
|
4353
|
+
msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
|
|
4200
4354
|
_last_obs["text"] = msg
|
|
4201
4355
|
return msg
|
|
4356
|
+
finally:
|
|
4357
|
+
try:
|
|
4358
|
+
log_fh.close()
|
|
4359
|
+
except Exception: # noqa: BLE001
|
|
4360
|
+
pass
|
|
4361
|
+
|
|
4362
|
+
@tool
|
|
4363
|
+
def stop_server(port_or_pid: str = "") -> str:
|
|
4364
|
+
"""Stop a background server previously started by run_command. Use when the user asks to stop/kill the server.
|
|
4365
|
+
|
|
4366
|
+
Args:
|
|
4367
|
+
port_or_pid: the server's port or pid (empty = the most recently started one).
|
|
4368
|
+
"""
|
|
4369
|
+
try:
|
|
4370
|
+
import os
|
|
4371
|
+
import signal
|
|
4372
|
+
import time as _t
|
|
4373
|
+
servers = _ws_load_servers()
|
|
4374
|
+
if not servers:
|
|
4375
|
+
msg = "No background servers are running."
|
|
4376
|
+
_last_obs["text"] = msg
|
|
4377
|
+
return msg
|
|
4378
|
+
key = (port_or_pid or "").strip()
|
|
4379
|
+
target = None
|
|
4380
|
+
if key:
|
|
4381
|
+
for s in servers:
|
|
4382
|
+
if key == str(s.get("pid")) or any(key == str(p) for p in s.get("ports", [])):
|
|
4383
|
+
target = s
|
|
4384
|
+
break
|
|
4385
|
+
if target is None:
|
|
4386
|
+
listing = "; ".join(
|
|
4387
|
+
f"pid {s['pid']} ports {s.get('ports')} ({s['command']})" for s in servers)
|
|
4388
|
+
msg = f"No running server matches '{key}'. Running: {listing}"
|
|
4389
|
+
_last_obs["text"] = msg
|
|
4390
|
+
return msg
|
|
4391
|
+
else:
|
|
4392
|
+
target = servers[-1]
|
|
4393
|
+
pgid = int(target["pgid"])
|
|
4394
|
+
try:
|
|
4395
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
4396
|
+
_t.sleep(1.5)
|
|
4397
|
+
if _ws_pgroup_pids(pgid):
|
|
4398
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
4399
|
+
except OSError:
|
|
4400
|
+
pass
|
|
4401
|
+
_ws_save_servers([s for s in servers if s is not target])
|
|
4402
|
+
_emit({"type": "step", "text": f"Server stopped → {target['command'][:50]}"})
|
|
4403
|
+
out = f"Stopped '{target['command']}' (pid {target['pid']}, ports {target.get('ports')})."
|
|
4404
|
+
_last_obs["text"] = out
|
|
4405
|
+
return out
|
|
4202
4406
|
except Exception as ex: # noqa: BLE001
|
|
4203
|
-
msg = f"Error:
|
|
4407
|
+
msg = f"Error: stop_server failed: {type(ex).__name__}: {ex}"
|
|
4204
4408
|
_last_obs["text"] = msg
|
|
4205
4409
|
return msg
|
|
4206
4410
|
|
|
@@ -4218,7 +4422,7 @@ def run_agent_chat(cfg):
|
|
|
4218
4422
|
rag_index, rag_add, rag_search]
|
|
4219
4423
|
if _WS_AVAILABLE:
|
|
4220
4424
|
agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
|
|
4221
|
-
create_file, create_folder, run_command]
|
|
4425
|
+
create_file, create_folder, run_command, stop_server]
|
|
4222
4426
|
# list_dir/read_text_file are needed for workspace browsing even when RAG
|
|
4223
4427
|
# (S3 indexing) isn't configured.
|
|
4224
4428
|
if not _RAG_AVAILABLE:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.198",
|
|
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",
|