@tiens.nguyen/gonext-local-worker 1.0.48 → 1.0.49
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-local-worker.mjs +208 -0
- package/gonext_agent_chat.py +170 -0
- package/package.json +2 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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,170 @@
|
|
|
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
|
+
|
|
31
|
+
def _ssl_context():
|
|
32
|
+
import ssl
|
|
33
|
+
try:
|
|
34
|
+
import certifi
|
|
35
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
36
|
+
except ImportError:
|
|
37
|
+
return ssl.create_default_context()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _http_request_impl(method, url, headers=None, body=None, timeout=15):
|
|
41
|
+
req = urllib.request.Request(url, method=method.upper())
|
|
42
|
+
if headers:
|
|
43
|
+
for k, v in headers.items():
|
|
44
|
+
req.add_header(k, v)
|
|
45
|
+
data = body.encode() if isinstance(body, str) and body else (body or None)
|
|
46
|
+
try:
|
|
47
|
+
ctx = _ssl_context()
|
|
48
|
+
with urllib.request.urlopen(req, data=data, timeout=timeout, context=ctx) as resp:
|
|
49
|
+
status = resp.status
|
|
50
|
+
raw = resp.read(4096)
|
|
51
|
+
snippet = raw.decode("utf-8", errors="replace")[:2000]
|
|
52
|
+
return f"HTTP {status}\n{snippet}"
|
|
53
|
+
except urllib.error.HTTPError as e:
|
|
54
|
+
raw = e.read(512)
|
|
55
|
+
snippet = raw.decode("utf-8", errors="replace")
|
|
56
|
+
return f"HTTP {e.code} {e.reason}\n{snippet}"
|
|
57
|
+
except Exception as e: # noqa: BLE001
|
|
58
|
+
return f"Error: {e}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_agent_chat(cfg):
|
|
62
|
+
try:
|
|
63
|
+
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
64
|
+
except Exception as e: # noqa: BLE001
|
|
65
|
+
_emit({"type": "final", "text": f"[smolagents not installed: {e}]"})
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
messages = cfg.get("messages") or []
|
|
69
|
+
agent_base_url = cfg.get("agentBaseURL") or ""
|
|
70
|
+
agent_api_key = cfg.get("agentApiKey") or "local"
|
|
71
|
+
agent_model_id = cfg.get("agentModelId") or ""
|
|
72
|
+
max_steps = int(cfg.get("maxSteps") or 10)
|
|
73
|
+
|
|
74
|
+
# Build the task from the last user message; use prior turns as context.
|
|
75
|
+
task_text = ""
|
|
76
|
+
context_lines = []
|
|
77
|
+
for m in messages:
|
|
78
|
+
role = m.get("role", "")
|
|
79
|
+
content = m.get("content", "")
|
|
80
|
+
if role == "user":
|
|
81
|
+
task_text = content
|
|
82
|
+
elif role == "assistant":
|
|
83
|
+
context_lines.append(f"Assistant previously said: {content[:500]}")
|
|
84
|
+
|
|
85
|
+
if not task_text:
|
|
86
|
+
_emit({"type": "final", "text": "[No user message found in history]"})
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
if context_lines:
|
|
90
|
+
task_text = "\n".join(context_lines) + "\n\nNow answer: " + task_text
|
|
91
|
+
|
|
92
|
+
@tool
|
|
93
|
+
def http_request(method: str, url: str, headers: str = "", body: str = "") -> str:
|
|
94
|
+
"""Perform an HTTP request and return the status code and a body preview.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
98
|
+
url: Full URL to request
|
|
99
|
+
headers: Optional JSON object string of request headers
|
|
100
|
+
body: Optional request body string
|
|
101
|
+
"""
|
|
102
|
+
parsed_headers = {}
|
|
103
|
+
if headers:
|
|
104
|
+
try:
|
|
105
|
+
parsed_headers = json.loads(headers)
|
|
106
|
+
except Exception: # noqa: BLE001
|
|
107
|
+
pass
|
|
108
|
+
return _http_request_impl(method, url, parsed_headers, body or None)
|
|
109
|
+
|
|
110
|
+
def step_callback(step_log):
|
|
111
|
+
try:
|
|
112
|
+
parts = []
|
|
113
|
+
# tool_calls is a list of ToolCall(name, arguments, id)
|
|
114
|
+
tool_calls = getattr(step_log, "tool_calls", None) or []
|
|
115
|
+
for tc in tool_calls:
|
|
116
|
+
name = getattr(tc, "name", str(tc))
|
|
117
|
+
args = getattr(tc, "arguments", None)
|
|
118
|
+
arg_str = json.dumps(args) if args is not None else ""
|
|
119
|
+
parts.append(f"Tool: {name}({arg_str})")
|
|
120
|
+
observations = getattr(step_log, "observations", None)
|
|
121
|
+
if observations:
|
|
122
|
+
parts.append(f"→ {str(observations)[:400]}")
|
|
123
|
+
error = getattr(step_log, "error", None)
|
|
124
|
+
if error:
|
|
125
|
+
parts.append(f"Error: {error}")
|
|
126
|
+
if not parts:
|
|
127
|
+
parts.append(str(step_log)[:300])
|
|
128
|
+
text = " ".join(parts)
|
|
129
|
+
except Exception as e: # noqa: BLE001
|
|
130
|
+
text = f"Step: {e}"
|
|
131
|
+
|
|
132
|
+
_emit({"type": "step", "text": text})
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
model = OpenAIServerModel(
|
|
136
|
+
model_id=agent_model_id,
|
|
137
|
+
api_base=agent_base_url,
|
|
138
|
+
api_key=agent_api_key,
|
|
139
|
+
)
|
|
140
|
+
agent = CodeAgent(
|
|
141
|
+
tools=[http_request],
|
|
142
|
+
model=model,
|
|
143
|
+
max_steps=max_steps,
|
|
144
|
+
step_callbacks=[step_callback],
|
|
145
|
+
)
|
|
146
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
147
|
+
result = agent.run(task_text)
|
|
148
|
+
final_text = str(result).strip()
|
|
149
|
+
_emit({"type": "final", "text": final_text})
|
|
150
|
+
except Exception as e: # noqa: BLE001
|
|
151
|
+
_emit({"type": "final", "text": f"[Agent error: {e}]"})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _emit(obj):
|
|
155
|
+
"""Write one NDJSON line to stdout and flush immediately."""
|
|
156
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
157
|
+
sys.stdout.flush()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main():
|
|
161
|
+
try:
|
|
162
|
+
cfg = json.load(sys.stdin)
|
|
163
|
+
except Exception as e: # noqa: BLE001
|
|
164
|
+
_emit({"type": "final", "text": f"[Invalid input: {e}]"})
|
|
165
|
+
return
|
|
166
|
+
run_agent_chat(cfg)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
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.49",
|
|
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
|
],
|