@tiens.nguyen/gonext-local-worker 1.0.48 → 1.0.50

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.
@@ -664,6 +664,73 @@ async function performHttpMeasurement(params) {
664
664
  return { response, category, errorMessage };
665
665
  }
666
666
 
667
+ /**
668
+ * Spawn a child process, feed it stdin, and call onLine for each NDJSON stdout
669
+ * line as they arrive. Resolves when the process exits cleanly; rejects on
670
+ * non-zero exit or timeout. stderr is collected and appended to error messages.
671
+ */
672
+ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine) {
673
+ return new Promise((resolve, reject) => {
674
+ const child = spawn(cmd, cmdArgs, { stdio: ["pipe", "pipe", "pipe"] });
675
+ let stderr = "";
676
+ let lineBuffer = "";
677
+ let timedOut = false;
678
+ const timer = setTimeout(() => {
679
+ timedOut = true;
680
+ child.kill("SIGKILL");
681
+ }, timeoutMs);
682
+ child.stdout.on("data", (d) => {
683
+ lineBuffer += d.toString("utf8");
684
+ const parts = lineBuffer.split("\n");
685
+ lineBuffer = parts.pop() ?? "";
686
+ for (const line of parts) {
687
+ const trimmed = line.trim();
688
+ if (trimmed) {
689
+ try {
690
+ onLine(JSON.parse(trimmed));
691
+ } catch {
692
+ /* ignore non-JSON */
693
+ }
694
+ }
695
+ }
696
+ });
697
+ child.stderr.on("data", (d) => {
698
+ stderr += d;
699
+ });
700
+ child.on("error", (e) => {
701
+ clearTimeout(timer);
702
+ reject(e);
703
+ });
704
+ child.on("close", (code) => {
705
+ clearTimeout(timer);
706
+ // Drain any remaining buffered line
707
+ const remaining = lineBuffer.trim();
708
+ if (remaining) {
709
+ try {
710
+ onLine(JSON.parse(remaining));
711
+ } catch {
712
+ /* ignore */
713
+ }
714
+ }
715
+ if (timedOut) {
716
+ reject(new Error(`agent chat timed out after ${timeoutMs} ms`));
717
+ return;
718
+ }
719
+ if (code !== 0) {
720
+ reject(
721
+ new Error(
722
+ `agent chat exited ${code}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`
723
+ )
724
+ );
725
+ return;
726
+ }
727
+ resolve();
728
+ });
729
+ child.stdin.write(stdinStr);
730
+ child.stdin.end();
731
+ });
732
+ }
733
+
667
734
  /** Run a child process, feed it stdin, resolve with stdout (rejects on non-zero/timeout). */
668
735
  function runProcessWithStdin(cmd, cmdArgs, stdinStr, timeoutMs) {
669
736
  return new Promise((resolve, reject) => {
@@ -1091,6 +1158,143 @@ async function runOcrJob(job) {
1091
1158
  }
1092
1159
  }
1093
1160
 
1161
+ async function runAgentChatJob(job) {
1162
+ const { jobId, payload } = job;
1163
+ const start = Date.now();
1164
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1165
+ method: "PATCH",
1166
+ body: JSON.stringify({ jobStatus: "running" }),
1167
+ });
1168
+ await ensureWorkerOk(runRes, `mark running agent_chat jobId=${jobId}`);
1169
+
1170
+ let buf = "";
1171
+ let flushTimer = null;
1172
+ let fullText = "";
1173
+ let flushTail = Promise.resolve();
1174
+ const CHUNK_DEBOUNCE_MS = 80;
1175
+ const CHUNK_MAX_BUF = 6144;
1176
+
1177
+ const flushChunks = async () => {
1178
+ const t = buf;
1179
+ buf = "";
1180
+ if (!t) return;
1181
+ const res = await workerFetch(CHUNK_PATH, {
1182
+ method: "POST",
1183
+ body: JSON.stringify({ jobId, text: t }),
1184
+ });
1185
+ if (!res.ok && res.status !== 204) {
1186
+ const snippet = (await res.text().catch(() => "")).trim().slice(0, 400);
1187
+ const benign409 =
1188
+ res.status === 409 && snippet.includes('"jobStatus":"completed"');
1189
+ if (!benign409) {
1190
+ console.error(
1191
+ `[gonext-worker] agent_chat job-chunk POST failed status=${res.status} jobId=${jobId}` +
1192
+ (snippet ? ` response=${snippet}` : "")
1193
+ );
1194
+ }
1195
+ }
1196
+ };
1197
+
1198
+ const enqueueText = (s) => {
1199
+ if (!s) return;
1200
+ fullText += s;
1201
+ buf += s;
1202
+ if (buf.length >= CHUNK_MAX_BUF) {
1203
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1204
+ flushTail = flushTail.then(() => flushChunks()).catch((err) => {
1205
+ console.error("[gonext-worker] agent_chat chunk flush error:", err);
1206
+ });
1207
+ return;
1208
+ }
1209
+ if (!flushTimer) {
1210
+ flushTimer = setTimeout(() => {
1211
+ flushTimer = null;
1212
+ flushTail = flushTail.then(() => flushChunks()).catch((err) => {
1213
+ console.error("[gonext-worker] agent_chat chunk flush error:", err);
1214
+ });
1215
+ }, CHUNK_DEBOUNCE_MS);
1216
+ }
1217
+ };
1218
+
1219
+ try {
1220
+ const python =
1221
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "")
1222
+ .trim() || "python3";
1223
+ const scriptPath = join(WORKER_DIR, "gonext_agent_chat.py");
1224
+ const input = JSON.stringify({
1225
+ messages: payload?.messages ?? [],
1226
+ agentBaseURL: payload?.agentBaseURL ?? "",
1227
+ agentApiKey: payload?.agentApiKey ?? "",
1228
+ agentModelId: payload?.agentModelId ?? "",
1229
+ tools: payload?.tools ?? ["http_request"],
1230
+ maxSteps: payload?.maxSteps ?? 10,
1231
+ });
1232
+ const timeoutMs = 300_000; // 5 min max for an agent run
1233
+
1234
+ let inThink = false;
1235
+ let finalText = "";
1236
+
1237
+ await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
1238
+ if (event.type === "step" && typeof event.text === "string") {
1239
+ if (!inThink) {
1240
+ inThink = true;
1241
+ enqueueText("<think>");
1242
+ }
1243
+ enqueueText(event.text + "\n");
1244
+ } else if (event.type === "final" && typeof event.text === "string") {
1245
+ if (inThink) {
1246
+ inThink = false;
1247
+ enqueueText("</think>");
1248
+ }
1249
+ finalText = event.text;
1250
+ enqueueText(event.text);
1251
+ }
1252
+ });
1253
+
1254
+ if (inThink) {
1255
+ enqueueText("</think>");
1256
+ }
1257
+
1258
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1259
+ await flushTail;
1260
+ await flushChunks();
1261
+
1262
+ const totalTimeSeconds = (Date.now() - start) / 1000;
1263
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1264
+ method: "PATCH",
1265
+ body: JSON.stringify({
1266
+ jobStatus: "completed",
1267
+ resultText: finalText || fullText,
1268
+ tokenCount: Math.max(1, Math.ceil((finalText || fullText).length / 4)),
1269
+ totalTimeSeconds,
1270
+ }),
1271
+ });
1272
+ await ensureWorkerOk(doneRes, `complete agent_chat PATCH jobId=${jobId}`);
1273
+ console.log(`[gonext-worker] completed agent_chat ${jobId} (${totalTimeSeconds.toFixed(1)}s)`);
1274
+ } catch (e) {
1275
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1276
+ await flushTail;
1277
+ await flushChunks().catch(() => {});
1278
+ const message = e instanceof Error ? e.message : String(e);
1279
+ const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1280
+ method: "PATCH",
1281
+ body: JSON.stringify({
1282
+ jobStatus: "failed",
1283
+ errorMessage: message,
1284
+ totalTimeSeconds: (Date.now() - start) / 1000,
1285
+ }),
1286
+ });
1287
+ if (!failRes.ok) {
1288
+ const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
1289
+ console.error(
1290
+ `[gonext-worker] agent_chat fail PATCH also failed ${failRes.status} jobId=${jobId}` +
1291
+ (snippet ? ` response=${snippet}` : "")
1292
+ );
1293
+ }
1294
+ console.error(`[gonext-worker] failed agent_chat ${jobId}:`, message);
1295
+ }
1296
+ }
1297
+
1094
1298
  function normalizeBaseUrl(raw) {
1095
1299
  return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
1096
1300
  }
@@ -1523,6 +1727,10 @@ async function pollOnce() {
1523
1727
  await runHttpProbeJob(job);
1524
1728
  return;
1525
1729
  }
1730
+ if (job.jobType === "agent_chat") {
1731
+ await runAgentChatJob(job);
1732
+ return;
1733
+ }
1526
1734
  const isOcrByType = job.jobType === "ocr";
1527
1735
  const isOcrByPayload =
1528
1736
  job.payload &&
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gonext_agent_chat.py — streaming agent chat for the gonext local worker.
4
+
5
+ Reads on stdin:
6
+ {
7
+ "messages": [{"role": "system"|"user"|"assistant", "content": str}, ...],
8
+ "agentBaseURL": str,
9
+ "agentApiKey": str,
10
+ "agentModelId": str,
11
+ "tools": ["http_request"], # v1: only http_request
12
+ "maxSteps": int # default 10
13
+ }
14
+
15
+ Emits NDJSON lines on stdout per step/final:
16
+ {"type": "step", "text": "<tool call summary>"}
17
+ {"type": "final", "text": "<agent final answer>"}
18
+
19
+ All smolagents/rich console output goes to stderr so stdout stays clean.
20
+
21
+ TLS: uses certifi CA bundle when available (macOS may lack system certs for
22
+ Python urllib), with fallback to the default bundle.
23
+ """
24
+ import contextlib
25
+ import json
26
+ import sys
27
+ import urllib.request
28
+ import urllib.error
29
+
30
+ # Capture stdout before anything can redirect it. _emit() must always write
31
+ # to the real fd-1 so the Node worker's readline loop sees NDJSON even while
32
+ # contextlib.redirect_stdout(sys.stderr) is active inside agent.run().
33
+ _REAL_STDOUT = sys.stdout
34
+
35
+
36
+ def _ssl_context():
37
+ import ssl
38
+ try:
39
+ import certifi
40
+ return ssl.create_default_context(cafile=certifi.where())
41
+ except ImportError:
42
+ return ssl.create_default_context()
43
+
44
+
45
+ def _http_request_impl(method, url, headers=None, body=None, timeout=15):
46
+ req = urllib.request.Request(url, method=method.upper())
47
+ if headers:
48
+ for k, v in headers.items():
49
+ req.add_header(k, v)
50
+ data = body.encode() if isinstance(body, str) and body else (body or None)
51
+ try:
52
+ ctx = _ssl_context()
53
+ with urllib.request.urlopen(req, data=data, timeout=timeout, context=ctx) as resp:
54
+ status = resp.status
55
+ raw = resp.read(4096)
56
+ snippet = raw.decode("utf-8", errors="replace")[:2000]
57
+ return f"HTTP {status}\n{snippet}"
58
+ except urllib.error.HTTPError as e:
59
+ raw = e.read(512)
60
+ snippet = raw.decode("utf-8", errors="replace")
61
+ return f"HTTP {e.code} {e.reason}\n{snippet}"
62
+ except Exception as e: # noqa: BLE001
63
+ return f"Error: {e}"
64
+
65
+
66
+ def run_agent_chat(cfg):
67
+ try:
68
+ from smolagents import CodeAgent, OpenAIServerModel, tool
69
+ except Exception as e: # noqa: BLE001
70
+ _emit({"type": "final", "text": f"[smolagents not installed: {e}]"})
71
+ return
72
+
73
+ messages = cfg.get("messages") or []
74
+ agent_base_url = cfg.get("agentBaseURL") or ""
75
+ agent_api_key = cfg.get("agentApiKey") or "local"
76
+ agent_model_id = cfg.get("agentModelId") or ""
77
+ max_steps = int(cfg.get("maxSteps") or 10)
78
+
79
+ # Build the task from the last user message; use prior turns as context.
80
+ task_text = ""
81
+ context_lines = []
82
+ for m in messages:
83
+ role = m.get("role", "")
84
+ content = m.get("content", "")
85
+ if role == "user":
86
+ task_text = content
87
+ elif role == "assistant":
88
+ context_lines.append(f"Assistant previously said: {content[:500]}")
89
+
90
+ if not task_text:
91
+ _emit({"type": "final", "text": "[No user message found in history]"})
92
+ return
93
+
94
+ if context_lines:
95
+ task_text = "\n".join(context_lines) + "\n\nNow answer: " + task_text
96
+
97
+ @tool
98
+ def http_request(method: str, url: str, headers: str = "", body: str = "") -> str:
99
+ """Perform an HTTP request and return the status code and a body preview.
100
+
101
+ Args:
102
+ method: HTTP method (GET, POST, PUT, DELETE, etc.)
103
+ url: Full URL to request
104
+ headers: Optional JSON object string of request headers
105
+ body: Optional request body string
106
+ """
107
+ parsed_headers = {}
108
+ if headers:
109
+ try:
110
+ parsed_headers = json.loads(headers)
111
+ except Exception: # noqa: BLE001
112
+ pass
113
+ return _http_request_impl(method, url, parsed_headers, body or None)
114
+
115
+ def step_callback(step_log):
116
+ try:
117
+ parts = []
118
+ # tool_calls is a list of ToolCall(name, arguments, id)
119
+ tool_calls = getattr(step_log, "tool_calls", None) or []
120
+ for tc in tool_calls:
121
+ name = getattr(tc, "name", str(tc))
122
+ args = getattr(tc, "arguments", None)
123
+ arg_str = json.dumps(args) if args is not None else ""
124
+ parts.append(f"Tool: {name}({arg_str})")
125
+ observations = getattr(step_log, "observations", None)
126
+ if observations:
127
+ parts.append(f"→ {str(observations)[:400]}")
128
+ error = getattr(step_log, "error", None)
129
+ if error:
130
+ parts.append(f"Error: {error}")
131
+ if not parts:
132
+ parts.append(str(step_log)[:300])
133
+ text = " ".join(parts)
134
+ except Exception as e: # noqa: BLE001
135
+ text = f"Step: {e}"
136
+
137
+ _emit({"type": "step", "text": text})
138
+
139
+ try:
140
+ model = OpenAIServerModel(
141
+ model_id=agent_model_id,
142
+ api_base=agent_base_url,
143
+ api_key=agent_api_key,
144
+ )
145
+ agent = CodeAgent(
146
+ tools=[http_request],
147
+ model=model,
148
+ max_steps=max_steps,
149
+ step_callbacks=[step_callback],
150
+ )
151
+ with contextlib.redirect_stdout(sys.stderr):
152
+ result = agent.run(task_text)
153
+ final_text = str(result).strip()
154
+ _emit({"type": "final", "text": final_text})
155
+ except Exception as e: # noqa: BLE001
156
+ _emit({"type": "final", "text": f"[Agent error: {e}]"})
157
+
158
+
159
+ def _emit(obj):
160
+ """Write one NDJSON line to the real stdout and flush immediately.
161
+ Uses _REAL_STDOUT (captured at import time) so redirect_stdout blocks
162
+ inside agent.run() don't accidentally route events to stderr."""
163
+ _REAL_STDOUT.write(json.dumps(obj) + "\n")
164
+ _REAL_STDOUT.flush()
165
+
166
+
167
+ def main():
168
+ try:
169
+ cfg = json.load(sys.stdin)
170
+ except Exception as e: # noqa: BLE001
171
+ _emit({"type": "final", "text": f"[Invalid input: {e}]"})
172
+ return
173
+ run_agent_chat(cfg)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.48",
3
+ "version": "1.0.50",
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",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "gonext-local-worker.mjs",
25
25
  "gonext_probe_agent.py",
26
+ "gonext_agent_chat.py",
26
27
  "README.md",
27
28
  "launchd/"
28
29
  ],