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

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/README.md CHANGED
@@ -1,3 +1,34 @@
1
1
  # @tiens.nguyen/gonext-local-worker
2
2
  Run:
3
3
  GONEXT_API_BASE=... GONEXT_WORKER_KEY=... npx -y --package @tiens.nguyen/gonext-local-worker gonext-local-worker
4
+
5
+ ## API Check / HTTP probe (Tools & Agents modes)
6
+
7
+ The worker can run Postman-style HTTP probes queued from the web app
8
+ ("Tools" = `tool_only`, "Agents" = `agentic`). The worker always performs the
9
+ actual HTTP request itself (Node `fetch`, so TLS works), measures the
10
+ status/latency/headers/body, and classifies it (2xx/3xx/4xx/5xx/timeout/
11
+ network_error).
12
+
13
+ - **Tools (`tool_only`)** — no extra setup. The selected local model writes a
14
+ one-line health summary of the measured result.
15
+ - **Agents (`agentic`)** — a [smolagents](https://github.com/huggingface/smolagents)
16
+ agent (running on the selected local model) produces the summary. Install it
17
+ in the worker's Python environment:
18
+
19
+ ```sh
20
+ pip install smolagents
21
+ ```
22
+
23
+ The agent talks to your local MLX OpenAI-compatible server (no cloud calls).
24
+ The agent only summarizes; the worker's measurement stays the source of truth,
25
+ so if smolagents or the model is unavailable the probe still returns the
26
+ measured result with a note.
27
+
28
+ ### Probe-related env
29
+
30
+ GONEXT_PROBE_PYTHON Python executable for the smolagents agent
31
+ (default: GONEXT_MLX_LM_PYTHON or python3)
32
+
33
+ The agent script lives next to the worker as `gonext_probe_agent.py` (reads a
34
+ JSON probe config on stdin, writes a JSON summary on stdout).
@@ -8,14 +8,17 @@
8
8
  * - `gonext-local-worker` — starts polling loop (claims jobs and runs models)
9
9
  */
10
10
  import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
11
- import { execFile as execFileCallback } from "node:child_process";
11
+ import { execFile as execFileCallback, spawn } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
13
  import { homedir, platform, tmpdir } from "node:os";
14
- import { extname, join } from "node:path";
14
+ import { dirname, extname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
15
16
  import { promisify } from "node:util";
16
17
  import dotenv from "dotenv";
17
18
  import OpenAI from "openai";
18
19
 
20
+ const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
21
+
19
22
  /** Avoid `node:child_process/promises` — not available on some Node builds / older runtimes. */
20
23
  const execFile = promisify(execFileCallback);
21
24
 
@@ -548,6 +551,298 @@ async function runChatJob(job) {
548
551
  }
549
552
  }
550
553
 
554
+ function categorizeHttpStatus(status) {
555
+ if (status >= 200 && status < 300) return "2xx";
556
+ if (status >= 300 && status < 400) return "3xx";
557
+ if (status >= 400 && status < 500) return "4xx";
558
+ if (status >= 500 && status < 600) return "5xx";
559
+ return "network_error";
560
+ }
561
+
562
+ function probeVerdict(status, expectedStatus) {
563
+ if (expectedStatus === undefined || expectedStatus === null) {
564
+ return status >= 200 && status < 300 ? "pass" : "fail";
565
+ }
566
+ const list = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus];
567
+ return list.includes(status) ? "pass" : "fail";
568
+ }
569
+
570
+ /** tool_only / agentic summary: ask the selected model to interpret the measured result. */
571
+ async function summarizeProbeWithModel(payload, result) {
572
+ const client = new OpenAI({
573
+ baseURL: payload.agentBaseURL,
574
+ apiKey: payload.agentApiKey || "local",
575
+ maxRetries: 0,
576
+ timeout: 60_000,
577
+ });
578
+ const lines = [
579
+ `HTTP probe result for ${result.request.method} ${result.request.url}`,
580
+ ];
581
+ if (result.response) {
582
+ lines.push(
583
+ `Status: ${result.response.status} ${result.response.statusText} (${result.category})`,
584
+ `Latency: ${result.response.latencyMs} ms`,
585
+ `Body (first chars): ${result.response.bodySnippet || "(empty)"}`
586
+ );
587
+ } else {
588
+ lines.push(`No response — ${result.category}: ${result.error ?? "unknown error"}`);
589
+ }
590
+ lines.push(
591
+ `Verdict: ${result.verdict}.`,
592
+ "In 1-2 sentences, say whether the endpoint is healthy and what this status means."
593
+ );
594
+ const completion = await client.chat.completions.create({
595
+ model: payload.agentModelId,
596
+ messages: [
597
+ {
598
+ role: "system",
599
+ content: "You are an API health assistant. Be concise and factual.",
600
+ },
601
+ { role: "user", content: lines.join("\n") },
602
+ ],
603
+ temperature: 0,
604
+ max_tokens: 200,
605
+ });
606
+ return completion.choices?.[0]?.message?.content?.trim() || "";
607
+ }
608
+
609
+ /** Deterministic HTTP measurement: real status/latency/headers/body, no model. */
610
+ async function performHttpMeasurement(params) {
611
+ let response = null;
612
+ let category;
613
+ let errorMessage;
614
+ const reqStart = Date.now();
615
+ const ac = new AbortController();
616
+ const timer = setTimeout(() => ac.abort(), params.timeoutMs);
617
+ try {
618
+ const res = await fetch(params.url, {
619
+ method: params.method,
620
+ headers: params.headers,
621
+ body: params.sendBody ? params.body : undefined,
622
+ signal: ac.signal,
623
+ redirect: "manual",
624
+ });
625
+ const latencyMs = Date.now() - reqStart;
626
+ let text = "";
627
+ try {
628
+ text = await res.text();
629
+ } catch {
630
+ text = "";
631
+ }
632
+ const SNIP = 2048;
633
+ const bodySnippet =
634
+ text.length > SNIP
635
+ ? `${text.slice(0, SNIP)} …[truncated ${text.length - SNIP} chars]`
636
+ : text;
637
+ const resHeaders = {};
638
+ res.headers.forEach((v, k) => {
639
+ resHeaders[k] = v;
640
+ });
641
+ category = categorizeHttpStatus(res.status);
642
+ response = {
643
+ status: res.status,
644
+ statusText: res.statusText || "",
645
+ ok: res.status >= 200 && res.status < 300,
646
+ latencyMs,
647
+ headers: resHeaders,
648
+ bodySnippet,
649
+ bodyBytes: Buffer.byteLength(text),
650
+ };
651
+ } catch (err) {
652
+ const aborted =
653
+ ac.signal.aborted ||
654
+ (err && typeof err === "object" && err.name === "AbortError");
655
+ category = aborted ? "timeout" : "network_error";
656
+ errorMessage = aborted
657
+ ? `Request timed out after ${params.timeoutMs} ms`
658
+ : err instanceof Error
659
+ ? err.message
660
+ : String(err);
661
+ } finally {
662
+ clearTimeout(timer);
663
+ }
664
+ return { response, category, errorMessage };
665
+ }
666
+
667
+ /** Run a child process, feed it stdin, resolve with stdout (rejects on non-zero/timeout). */
668
+ function runProcessWithStdin(cmd, cmdArgs, stdinStr, timeoutMs) {
669
+ return new Promise((resolve, reject) => {
670
+ const child = spawn(cmd, cmdArgs, { stdio: ["pipe", "pipe", "pipe"] });
671
+ let stdout = "";
672
+ let stderr = "";
673
+ let timedOut = false;
674
+ const timer = setTimeout(() => {
675
+ timedOut = true;
676
+ child.kill("SIGKILL");
677
+ }, timeoutMs);
678
+ child.stdout.on("data", (d) => {
679
+ stdout += d;
680
+ });
681
+ child.stderr.on("data", (d) => {
682
+ stderr += d;
683
+ });
684
+ child.on("error", (e) => {
685
+ clearTimeout(timer);
686
+ reject(e);
687
+ });
688
+ child.on("close", (code) => {
689
+ clearTimeout(timer);
690
+ if (timedOut) {
691
+ reject(new Error(`probe agent timed out after ${timeoutMs} ms`));
692
+ return;
693
+ }
694
+ if (code !== 0) {
695
+ reject(
696
+ new Error(
697
+ `probe agent exited ${code}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`
698
+ )
699
+ );
700
+ return;
701
+ }
702
+ resolve(stdout);
703
+ });
704
+ child.stdin.write(stdinStr);
705
+ child.stdin.end();
706
+ });
707
+ }
708
+
709
+ /**
710
+ * Agentic summary: drive a smolagents agent (on the selected local model) over
711
+ * the worker's measurement. The agent does not re-fetch — the worker's Node
712
+ * measurement stays the source of truth. Throws so callers can fall back.
713
+ */
714
+ async function summarizeProbeAgentic(payload, result) {
715
+ const python =
716
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "")
717
+ .trim() || "python3";
718
+ const scriptPath = join(WORKER_DIR, "gonext_probe_agent.py");
719
+ const input = JSON.stringify({
720
+ request: result.request,
721
+ measurement: result.response,
722
+ category: result.category,
723
+ error: result.error ?? null,
724
+ agentBaseURL: payload?.agentBaseURL ?? "",
725
+ agentApiKey: payload?.agentApiKey ?? "",
726
+ agentModelId: payload?.agentModelId ?? "",
727
+ });
728
+ const timeoutMs =
729
+ (Number.isFinite(payload?.timeoutMs) ? payload.timeoutMs : 15_000) + 120_000;
730
+ const stdout = await runProcessWithStdin(python, [scriptPath], input, timeoutMs);
731
+ const parsed = JSON.parse(stdout);
732
+ const summary =
733
+ typeof parsed?.agentSummary === "string" ? parsed.agentSummary.trim() : "";
734
+ if (!summary) {
735
+ throw new Error(parsed?.error || "agent produced no summary");
736
+ }
737
+ return summary;
738
+ }
739
+
740
+ async function runHttpProbeJob(job) {
741
+ const { jobId, payload } = job;
742
+ const start = Date.now();
743
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
744
+ method: "PATCH",
745
+ body: JSON.stringify({ jobStatus: "running" }),
746
+ });
747
+ await ensureWorkerOk(runRes, `mark running http_probe jobId=${jobId}`);
748
+ try {
749
+ const method = String(payload?.method || "GET").toUpperCase();
750
+ const url = String(payload?.url || "");
751
+ if (!url) throw new Error("http_probe payload missing url.");
752
+ const headers =
753
+ payload?.headers && typeof payload.headers === "object" ? payload.headers : {};
754
+ const sendBody =
755
+ !["GET", "HEAD"].includes(method) &&
756
+ typeof payload?.body === "string" &&
757
+ payload.body.length > 0;
758
+ const timeoutMs =
759
+ Number.isFinite(payload?.timeoutMs) && payload.timeoutMs > 0
760
+ ? payload.timeoutMs
761
+ : 15_000;
762
+ const requestBodyBytes = sendBody ? Buffer.byteLength(payload.body) : 0;
763
+ const measureParams = { method, url, headers, sendBody, body: payload?.body, timeoutMs };
764
+ const agentMode = payload?.agentMode === "agentic" ? "agentic" : "tool_only";
765
+
766
+ // The worker's Node fetch is the authoritative measurement for both modes.
767
+ const measurement = await performHttpMeasurement(measureParams);
768
+ const { response, category, errorMessage } = measurement;
769
+
770
+ const status = response?.status ?? 0;
771
+ const verdict =
772
+ category === "timeout" || category === "network_error"
773
+ ? "fail"
774
+ : probeVerdict(status, payload?.expectedStatus);
775
+
776
+ const result = {
777
+ request: { method, url, headers, bodyBytes: requestBodyBytes },
778
+ response,
779
+ category,
780
+ verdict,
781
+ agentMode,
782
+ agentModel: payload?.agentModel || payload?.agentModelId || "",
783
+ ...(errorMessage ? { error: errorMessage } : {}),
784
+ };
785
+
786
+ if (agentMode === "agentic") {
787
+ try {
788
+ result.agentSummary = await summarizeProbeAgentic(payload, result);
789
+ } catch (e) {
790
+ // smolagents/Python unavailable: fall back to a direct model summary.
791
+ try {
792
+ result.agentSummary = `${await summarizeProbeWithModel(payload, result)} (agentic fallback)`;
793
+ } catch {
794
+ result.agentSummary = `(Agentic summary unavailable: ${
795
+ e instanceof Error ? e.message : String(e)
796
+ })`;
797
+ }
798
+ }
799
+ } else {
800
+ try {
801
+ result.agentSummary = await summarizeProbeWithModel(payload, result);
802
+ } catch (e) {
803
+ result.agentSummary = `(Model summary unavailable: ${
804
+ e instanceof Error ? e.message : String(e)
805
+ })`;
806
+ }
807
+ }
808
+
809
+ const totalTimeSeconds = (Date.now() - start) / 1000;
810
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
811
+ method: "PATCH",
812
+ body: JSON.stringify({
813
+ jobStatus: "completed",
814
+ resultText: JSON.stringify(result),
815
+ tokenCount: 1,
816
+ totalTimeSeconds,
817
+ }),
818
+ });
819
+ await ensureWorkerOk(doneRes, `complete http_probe jobId=${jobId}`);
820
+ console.log(
821
+ `[gonext-worker] completed http_probe ${jobId} (${totalTimeSeconds.toFixed(
822
+ 1
823
+ )}s) ${method} ${url} -> ${category}/${verdict}`
824
+ );
825
+ } catch (e) {
826
+ const message = e instanceof Error ? e.message : String(e);
827
+ const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
828
+ method: "PATCH",
829
+ body: JSON.stringify({
830
+ jobStatus: "failed",
831
+ errorMessage: message,
832
+ totalTimeSeconds: (Date.now() - start) / 1000,
833
+ }),
834
+ });
835
+ if (!failRes.ok) {
836
+ const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
837
+ console.error(
838
+ `[gonext-worker] http_probe fail PATCH also failed ${failRes.status} jobId=${jobId}` +
839
+ (snippet ? ` response=${snippet}` : "")
840
+ );
841
+ }
842
+ console.error(`[gonext-worker] failed http_probe ${jobId}:`, message);
843
+ }
844
+ }
845
+
551
846
  function resolveImageExtension(mimeType, fileName) {
552
847
  const byMime = {
553
848
  "image/png": ".png",
@@ -1224,6 +1519,10 @@ async function pollOnce() {
1224
1519
  );
1225
1520
  continue;
1226
1521
  }
1522
+ if (job.jobType === "http_probe") {
1523
+ await runHttpProbeJob(job);
1524
+ return;
1525
+ }
1227
1526
  const isOcrByType = job.jobType === "ocr";
1228
1527
  const isOcrByPayload =
1229
1528
  job.payload &&
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gonext probe agent (agentic mode for the gonext local worker).
4
+
5
+ Reads a JSON probe config on stdin and writes a single JSON object on stdout:
6
+
7
+ in: {"request": {"method","url"},
8
+ "measurement": {"status","statusText","latencyMs","headers",
9
+ "bodySnippet","bodyBytes"} | null,
10
+ "category": str, "error": str | null,
11
+ "agentBaseURL","agentApiKey","agentModelId"}
12
+ out: {"agentSummary": str, "error": str | null}
13
+
14
+ The worker performs the authoritative HTTP measurement (reliable TLS via Node
15
+ fetch) and passes it here; this script drives a smolagents agent on the selected
16
+ local model to produce the natural-language health assessment. The agent's
17
+ `send_request` tool returns the already-measured result, so there is exactly one
18
+ network call (the worker's) and the measured status stays the source of truth.
19
+
20
+ smolagents/rich writes its console UI to stdout, so we redirect stdout to stderr
21
+ during the agent run and only emit our JSON result on stdout.
22
+
23
+ See docs/plans/smolagents-local-worker-http-probe.md.
24
+ """
25
+ import contextlib
26
+ import json
27
+ import sys
28
+
29
+
30
+ def describe(measurement, category, error):
31
+ if not measurement or error:
32
+ return f"The request did not get a response ({category}): {error or 'unknown error'}."
33
+ return (
34
+ f"HTTP {measurement.get('status')} {measurement.get('statusText', '')} "
35
+ f"({category}) in {measurement.get('latencyMs')} ms. "
36
+ f"Body preview: {str(measurement.get('bodySnippet', ''))[:400]}"
37
+ )
38
+
39
+
40
+ def run_agent_summary(cfg):
41
+ """Returns (summary, error). Never raises."""
42
+ try:
43
+ from smolagents import CodeAgent, OpenAIServerModel, tool
44
+ except Exception as e: # noqa: BLE001
45
+ return "", f"smolagents not installed ({e})"
46
+
47
+ request = cfg.get("request") or {}
48
+ method = (request.get("method") or "GET").upper()
49
+ url = request.get("url") or ""
50
+ measurement = cfg.get("measurement")
51
+ category = cfg.get("category") or ""
52
+ error = cfg.get("error")
53
+ measured_text = describe(measurement, category, error)
54
+
55
+ @tool
56
+ def send_request() -> str:
57
+ """Send the configured HTTP request to the target endpoint and return its
58
+ status code and a short body preview."""
59
+ return measured_text
60
+
61
+ try:
62
+ model = OpenAIServerModel(
63
+ model_id=cfg.get("agentModelId") or "",
64
+ api_base=cfg.get("agentBaseURL") or "",
65
+ api_key=cfg.get("agentApiKey") or "local",
66
+ )
67
+ agent = CodeAgent(tools=[send_request], model=model, max_steps=4)
68
+ task = (
69
+ f"Use send_request to call {method} {url}. Then report the HTTP status "
70
+ "code, classify it (2xx success, 3xx redirect, 4xx client error, 5xx "
71
+ "server error, or network failure), and give a one-sentence health "
72
+ "assessment of the endpoint."
73
+ )
74
+ # smolagents/rich logs to stdout; keep stdout clean for our JSON result.
75
+ with contextlib.redirect_stdout(sys.stderr):
76
+ summary = str(agent.run(task)).strip()
77
+ return summary, None
78
+ except Exception as e: # noqa: BLE001
79
+ return "", f"agent run failed ({e})"
80
+
81
+
82
+ def main():
83
+ try:
84
+ cfg = json.load(sys.stdin)
85
+ except Exception as e: # noqa: BLE001
86
+ json.dump({"agentSummary": "", "error": f"invalid input: {e}"}, sys.stdout)
87
+ return
88
+ summary, agent_error = run_agent_summary(cfg)
89
+ json.dump({"agentSummary": summary, "error": agent_error}, sys.stdout)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.47",
3
+ "version": "1.0.48",
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",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "gonext-local-worker.mjs",
25
+ "gonext_probe_agent.py",
25
26
  "README.md",
26
27
  "launchd/"
27
28
  ],