@tiens.nguyen/gonext-local-worker 1.0.187 → 1.0.189
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 +21 -1
- package/gonext-repl.mjs +90 -11
- package/gonext_agent_chat.py +57 -7
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1815,6 +1815,7 @@ async function runAgentChatJob(job) {
|
|
|
1815
1815
|
|
|
1816
1816
|
let inThink = false;
|
|
1817
1817
|
let finalText = "";
|
|
1818
|
+
let answerStreamed = false; // an answer_stream delta already delivered the visible text
|
|
1818
1819
|
// Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
|
|
1819
1820
|
// prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
|
|
1820
1821
|
// markdown, which turns `#`-prefixed code comments into <h1> headings and
|
|
@@ -1866,6 +1867,20 @@ async function runAgentChatJob(job) {
|
|
|
1866
1867
|
enqueueText("<think>");
|
|
1867
1868
|
}
|
|
1868
1869
|
enqueueText(event.text + "\n\n");
|
|
1870
|
+
} else if (event.type === "answer_stream" && typeof event.text === "string") {
|
|
1871
|
+
// Live delta of the FINAL user-facing answer (the plain-chat fast path streams
|
|
1872
|
+
// its reply as it generates, instead of blocking for the whole thing) — this is
|
|
1873
|
+
// the actual answer, NOT reasoning, so it must stay OUTSIDE <think> (wrapping it
|
|
1874
|
+
// would hide it in the web's collapsed Thinking panel instead of the live main
|
|
1875
|
+
// answer bubble). Close any open think/stream fence first in case a prior step
|
|
1876
|
+
// left one dangling.
|
|
1877
|
+
closeStreamFence();
|
|
1878
|
+
if (inThink) {
|
|
1879
|
+
inThink = false;
|
|
1880
|
+
enqueueText("</think>");
|
|
1881
|
+
}
|
|
1882
|
+
enqueueText(event.text);
|
|
1883
|
+
answerStreamed = true;
|
|
1869
1884
|
} else if (event.type === "final" && typeof event.text === "string") {
|
|
1870
1885
|
closeStreamFence();
|
|
1871
1886
|
if (inThink) {
|
|
@@ -1873,7 +1888,12 @@ async function runAgentChatJob(job) {
|
|
|
1873
1888
|
enqueueText("</think>");
|
|
1874
1889
|
}
|
|
1875
1890
|
finalText = event.text;
|
|
1876
|
-
|
|
1891
|
+
// If the answer was already delivered live via answer_stream deltas, its text is
|
|
1892
|
+
// ALREADY in resultText — appending it again here would duplicate the whole reply
|
|
1893
|
+
// (streamed once, then dumped again in full). Only append when nothing streamed it.
|
|
1894
|
+
if (!answerStreamed) {
|
|
1895
|
+
enqueueText(event.text);
|
|
1896
|
+
}
|
|
1877
1897
|
}
|
|
1878
1898
|
}, pdfEnv);
|
|
1879
1899
|
|
package/gonext-repl.mjs
CHANGED
|
@@ -53,6 +53,16 @@ const isCodeCloseLine = (line) => line.trim() === "</code>";
|
|
|
53
53
|
// "unzip_file(...) | → Unzipped 256 files…") duplicates what's already visible in the
|
|
54
54
|
// gonext-local-worker daemon's own terminal — swallow it here to keep the REPL terse.
|
|
55
55
|
const isToolStepSummary = (line) => / \| → /.test(line);
|
|
56
|
+
// The router's internal play-by-play ("Routing your request…", "→ Agent mode (needs
|
|
57
|
+
// tools)", "→ Chat reply", "Choosing a tool…", "Composing a reply…") is implementation
|
|
58
|
+
// detail, not something a user needs — also visible in the daemon's own terminal.
|
|
59
|
+
// Swallowed; see routingAnnounced. "Composing a reply…" specifically must be included so
|
|
60
|
+
// it doesn't print (undimmed, since plainReplyFlow is already true by then) right before
|
|
61
|
+
// the real streamed answer, where it would visually blend into the answer text.
|
|
62
|
+
const isRoutingLine = (line) =>
|
|
63
|
+
/^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
|
|
64
|
+
line.trim()
|
|
65
|
+
);
|
|
56
66
|
|
|
57
67
|
// Playful words for the local "thinking…" ticker, reused verbatim from the worker's
|
|
58
68
|
// thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
|
|
@@ -106,7 +116,17 @@ if (!workerKey || !apiBase) {
|
|
|
106
116
|
// awaits (workspace prompt fetch, payload probe) are queued instead of lost — vital
|
|
107
117
|
// for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
|
|
108
118
|
// loop consume from this queue (never rl.question, to avoid double-capture).
|
|
109
|
-
|
|
119
|
+
//
|
|
120
|
+
// terminal: false — we draw our OWN prompt (cyan ">> ") and colorize output ourselves;
|
|
121
|
+
// readline's default terminal:true (auto-enabled whenever output is a TTY) makes it ALSO
|
|
122
|
+
// track cursor position and redraw the input line on every keystroke using ITS OWN
|
|
123
|
+
// (empty, since we never call rl.setPrompt/rl.prompt) prompt — that tracking goes stale
|
|
124
|
+
// the moment we write anything readline doesn't know about (which is everything we print
|
|
125
|
+
// between prompts), and its next redraw emits a stray leading "[" (a malformed/partial
|
|
126
|
+
// cursor-position escape) right before typed input, e.g. "[>> hi". With terminal:false,
|
|
127
|
+
// readline stops managing the line/cursor itself; the OS's own canonical-mode terminal
|
|
128
|
+
// still echoes keystrokes normally, and 'line' events fire exactly the same.
|
|
129
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
110
130
|
const _lineQueue = [];
|
|
111
131
|
let _lineWaiter = null;
|
|
112
132
|
let _stdinClosed = false;
|
|
@@ -256,6 +276,15 @@ async function runAgentTurn(history) {
|
|
|
256
276
|
let statusShown = false; // a transient status line is currently on screen
|
|
257
277
|
let warnedPending = false;
|
|
258
278
|
let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
|
|
279
|
+
let answerShownLive = false; // plain-reply content already printed — don't re-print it
|
|
280
|
+
let routingAnnounced = false; // one friendly line replaces the whole routing play-by-play
|
|
281
|
+
// True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
|
|
282
|
+
// branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
|
|
283
|
+
// path streams its answer via answer_stream with NO heartbeat/tool-summary/code lines
|
|
284
|
+
// ever interleaved — so only THERE is it safe to partial-flush trailing content live
|
|
285
|
+
// (see carryFlushedLen below); the agent path's code/tool-call lines must stay buffered
|
|
286
|
+
// until a full line is known, or we'd risk showing part of something we meant to hide.
|
|
287
|
+
let plainReplyFlow = false;
|
|
259
288
|
|
|
260
289
|
// --- Live "thinking…" ticker ---------------------------------------------------
|
|
261
290
|
// Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
|
|
@@ -326,14 +355,35 @@ async function runAgentTurn(history) {
|
|
|
326
355
|
// them prints in blue (not dim, so it stands out from "Thought:" prose); blank lines
|
|
327
356
|
// keep spacing; everything else commits dim to scrollback.
|
|
328
357
|
let inCode = false;
|
|
358
|
+
let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
|
|
359
|
+
// Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
|
|
360
|
+
// a long plain-reply answer stream character-by-character instead of waiting for a "\n"
|
|
361
|
+
// that might not arrive until the very end (most short replies have none at all).
|
|
362
|
+
let carryFlushedLen = 0;
|
|
329
363
|
const consume = (chunk) => {
|
|
330
364
|
carry += stripThinkTags(chunk);
|
|
331
365
|
let nl;
|
|
332
366
|
while ((nl = carry.indexOf("\n")) >= 0) {
|
|
333
367
|
const line = carry.slice(0, nl);
|
|
334
368
|
carry = carry.slice(nl + 1);
|
|
369
|
+
const alreadyFlushed = carryFlushedLen;
|
|
370
|
+
carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
|
|
335
371
|
if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
|
|
336
372
|
if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
|
|
373
|
+
if (isRoutingLine(line)) {
|
|
374
|
+
// Replace the whole routing play-by-play with ONE friendly line, shown once
|
|
375
|
+
// per turn — the raw labels are implementation detail (visible in the daemon's
|
|
376
|
+
// own terminal if you need them).
|
|
377
|
+
if (line.trim() === "→ Chat reply") plainReplyFlow = true;
|
|
378
|
+
if (!routingAnnounced) {
|
|
379
|
+
routingAnnounced = true;
|
|
380
|
+
onContent();
|
|
381
|
+
process.stdout.write(dim(`${pickWord()}…`) + "\n");
|
|
382
|
+
lastWasBlank = false;
|
|
383
|
+
}
|
|
384
|
+
lastContentAt = Date.now();
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
337
387
|
if (!inCode && isCodeOpenLine(line)) {
|
|
338
388
|
stampIfThinking(); // hold/refresh the stamp; the code body commits it below
|
|
339
389
|
inCode = true;
|
|
@@ -344,14 +394,37 @@ async function runAgentTurn(history) {
|
|
|
344
394
|
if (line.trim() === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
|
|
345
395
|
onContent();
|
|
346
396
|
process.stdout.write(blue(line) + "\n");
|
|
397
|
+
lastWasBlank = false;
|
|
347
398
|
continue;
|
|
348
399
|
}
|
|
349
400
|
if (line.trim() === "") {
|
|
350
|
-
|
|
401
|
+
// Several sources (swallowed routing lines, swallowed step summaries) each leave
|
|
402
|
+
// their own blank-line companion behind — collapse consecutive blanks into one.
|
|
403
|
+
if (!thinking && !statusShown && !lastWasBlank) {
|
|
404
|
+
process.stdout.write("\n");
|
|
405
|
+
lastWasBlank = true;
|
|
406
|
+
}
|
|
351
407
|
continue;
|
|
352
408
|
}
|
|
353
|
-
onContent();
|
|
354
|
-
|
|
409
|
+
if (alreadyFlushed === 0) onContent();
|
|
410
|
+
// Plain-reply content IS the answer (not background narration) — print it in the
|
|
411
|
+
// terminal's default color, not dim, and remember it's already visible so the
|
|
412
|
+
// caller doesn't print the same text again once the turn completes.
|
|
413
|
+
const paint = plainReplyFlow ? (s) => s : dim;
|
|
414
|
+
process.stdout.write(paint(line.slice(alreadyFlushed)) + "\n");
|
|
415
|
+
if (plainReplyFlow) answerShownLive = true;
|
|
416
|
+
lastWasBlank = false;
|
|
417
|
+
}
|
|
418
|
+
// Plain-reply answers often have NO internal newline at all (a short one-sentence
|
|
419
|
+
// reply) and can take many seconds to generate — without this, nothing would print
|
|
420
|
+
// until the whole thing finally lands, right back to the "wait then dump" problem
|
|
421
|
+
// this was meant to fix. Only safe in the plain-reply flow (see plainReplyFlow above).
|
|
422
|
+
if (plainReplyFlow && !inCode && carry.length > carryFlushedLen) {
|
|
423
|
+
if (carryFlushedLen === 0) onContent();
|
|
424
|
+
process.stdout.write(carry.slice(carryFlushedLen)); // default color — this IS the answer
|
|
425
|
+
carryFlushedLen = carry.length;
|
|
426
|
+
answerShownLive = true;
|
|
427
|
+
lastWasBlank = false;
|
|
355
428
|
}
|
|
356
429
|
// A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
|
|
357
430
|
// a newline), so tokens are actively flowing — keep the ticker asleep.
|
|
@@ -365,7 +438,7 @@ async function runAgentTurn(history) {
|
|
|
365
438
|
process.stdout.write(
|
|
366
439
|
dim("\n(stopped following — the job keeps running on the worker)\n")
|
|
367
440
|
);
|
|
368
|
-
return "";
|
|
441
|
+
return { text: "", shown: false };
|
|
369
442
|
}
|
|
370
443
|
const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
|
|
371
444
|
headers: { "X-Worker-Key": workerKey },
|
|
@@ -375,10 +448,11 @@ async function runAgentTurn(history) {
|
|
|
375
448
|
jobRunning = job.jobStatus === "running";
|
|
376
449
|
const text = typeof job.resultText === "string" ? job.resultText : "";
|
|
377
450
|
if (job.jobStatus === "completed") {
|
|
378
|
-
// Drop the transient status line and any partial reasoning tail
|
|
379
|
-
//
|
|
451
|
+
// Drop the transient status line and any partial reasoning tail. If this was a
|
|
452
|
+
// plain reply, its text is ALREADY fully visible (streamed live above) — the
|
|
453
|
+
// caller must not print it again; `shown` tells it so.
|
|
380
454
|
clearStatus();
|
|
381
|
-
return answerFrom(text);
|
|
455
|
+
return { text: answerFrom(text), shown: answerShownLive };
|
|
382
456
|
}
|
|
383
457
|
if (text.length > shownChars) {
|
|
384
458
|
consume(text.slice(shownChars));
|
|
@@ -430,7 +504,10 @@ async function main() {
|
|
|
430
504
|
console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
|
|
431
505
|
|
|
432
506
|
const history = [];
|
|
433
|
-
|
|
507
|
+
// With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
|
|
508
|
+
// scanning (rl.on("SIGINT", …) would never fire) — it now delivers a real OS SIGINT to
|
|
509
|
+
// the process instead, which we catch directly here. Same behavior as before.
|
|
510
|
+
process.on("SIGINT", () => {
|
|
434
511
|
if (following) {
|
|
435
512
|
followAborted = true;
|
|
436
513
|
process.stdout.write(red("\n^C "));
|
|
@@ -472,10 +549,12 @@ async function main() {
|
|
|
472
549
|
}
|
|
473
550
|
history.push({ role: "user", content: line });
|
|
474
551
|
try {
|
|
475
|
-
const answer = await runAgentTurn(history);
|
|
552
|
+
const { text: answer, shown } = await runAgentTurn(history);
|
|
476
553
|
if (answer) {
|
|
477
554
|
history.push({ role: "assistant", content: answer });
|
|
478
|
-
|
|
555
|
+
// A plain-reply answer already streamed live in the loop above — printing it
|
|
556
|
+
// again here would just duplicate it; a blank line is enough for spacing.
|
|
557
|
+
console.log(shown ? "" : `\n\n${answer}\n`);
|
|
479
558
|
} else {
|
|
480
559
|
history.pop(); // aborted/failed turn — don't poison the next request's history
|
|
481
560
|
console.log(red("\n(no answer — run aborted or failed)\n"));
|
package/gonext_agent_chat.py
CHANGED
|
@@ -690,6 +690,43 @@ def _chat_create(client, **kwargs):
|
|
|
690
690
|
raise
|
|
691
691
|
|
|
692
692
|
|
|
693
|
+
def _chat_create_stream(client, on_delta, **kwargs) -> str:
|
|
694
|
+
"""Like _chat_create but STREAMS the completion: on_delta(text) is called for each
|
|
695
|
+
content piece AS IT ARRIVES (so the caller can emit it live), and the full assembled
|
|
696
|
+
text is returned once the stream ends. Applies the same mlx_lm model-not-found retry
|
|
697
|
+
as _chat_create — that failure always happens at/near the very first network
|
|
698
|
+
round-trip (before any token streams), so a clean retry from scratch never risks
|
|
699
|
+
re-emitting a partial answer twice."""
|
|
700
|
+
kwargs = dict(kwargs)
|
|
701
|
+
kwargs["stream"] = True
|
|
702
|
+
|
|
703
|
+
def _run(kw):
|
|
704
|
+
stream = client.chat.completions.create(**kw)
|
|
705
|
+
parts = []
|
|
706
|
+
for chunk in stream:
|
|
707
|
+
choices = getattr(chunk, "choices", None)
|
|
708
|
+
delta = getattr(choices[0], "delta", None) if choices else None
|
|
709
|
+
piece = getattr(delta, "content", None) if delta else None
|
|
710
|
+
if piece:
|
|
711
|
+
parts.append(piece)
|
|
712
|
+
on_delta(piece)
|
|
713
|
+
return "".join(parts)
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
return _run(kwargs)
|
|
717
|
+
except Exception as e: # noqa: BLE001
|
|
718
|
+
if kwargs.get("model") != "default_model" and _is_model_not_found_err(e):
|
|
719
|
+
_log(f"model {kwargs.get('model')!r} not found on server; retrying as "
|
|
720
|
+
"'default_model' (mlx_lm preloaded model)")
|
|
721
|
+
try:
|
|
722
|
+
kwargs2 = dict(kwargs)
|
|
723
|
+
kwargs2["model"] = "default_model"
|
|
724
|
+
return _run(kwargs2)
|
|
725
|
+
except Exception: # noqa: BLE001
|
|
726
|
+
raise e # surface the clearer original error, not the retry's
|
|
727
|
+
raise
|
|
728
|
+
|
|
729
|
+
|
|
693
730
|
def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
|
|
694
731
|
"""Decide if the task needs the HTTP agent (True) or a plain chat reply (False).
|
|
695
732
|
|
|
@@ -787,9 +824,12 @@ def _summarize_result(task_text: str, agent_output: str,
|
|
|
787
824
|
def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
788
825
|
fallback_base_url: str = "", fallback_model_id: str = "") -> str:
|
|
789
826
|
"""Plain chat completion using the full conversation history — a SMALL prompt (no tool
|
|
790
|
-
preamble), so it returns fast.
|
|
791
|
-
|
|
792
|
-
|
|
827
|
+
preamble), so it returns fast. STREAMS the answer live via {"type":"answer_stream"}
|
|
828
|
+
deltas as the model generates it (instead of blocking until the whole reply is ready
|
|
829
|
+
and delivering it in one burst) — the worker renders these as the live-updating MAIN
|
|
830
|
+
answer, not the collapsible Thinking panel. If the primary model is unreachable (e.g.
|
|
831
|
+
the local MLX server is down) and a distinct fallback model is given (the coding
|
|
832
|
+
model), retry there so a greeting still gets answered instead of erroring."""
|
|
793
833
|
_THINK_RE_LOCAL = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
|
794
834
|
chat_messages = [{"role": "system", "content": "You are a helpful assistant."}]
|
|
795
835
|
for m in messages:
|
|
@@ -812,20 +852,30 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
|
812
852
|
|
|
813
853
|
last_err = None
|
|
814
854
|
for i, (b, mid) in enumerate(targets):
|
|
855
|
+
emitted_here = False
|
|
856
|
+
|
|
857
|
+
def _track(piece):
|
|
858
|
+
nonlocal emitted_here
|
|
859
|
+
emitted_here = True
|
|
860
|
+
_emit({"type": "answer_stream", "text": piece})
|
|
861
|
+
|
|
815
862
|
try:
|
|
816
863
|
from openai import OpenAI
|
|
817
864
|
client = OpenAI(base_url=b, api_key=api_key or "local",
|
|
818
865
|
max_retries=0, timeout=60)
|
|
819
|
-
|
|
820
|
-
client,
|
|
866
|
+
return _chat_create_stream(
|
|
867
|
+
client, _track,
|
|
821
868
|
model=mid,
|
|
822
869
|
messages=chat_messages,
|
|
823
870
|
temperature=0.7,
|
|
824
871
|
max_tokens=512,
|
|
825
|
-
)
|
|
826
|
-
return (resp.choices[0].message.content or "").strip()
|
|
872
|
+
).strip()
|
|
827
873
|
except Exception as e: # noqa: BLE001
|
|
828
874
|
last_err = e
|
|
875
|
+
if emitted_here:
|
|
876
|
+
# Part of THIS attempt's answer already streamed to the user — don't
|
|
877
|
+
# silently splice a DIFFERENT model's output after a partial one.
|
|
878
|
+
break
|
|
829
879
|
if i + 1 < len(targets):
|
|
830
880
|
nb, nmid = targets[i + 1] # the model we're about to try next
|
|
831
881
|
_log(f"plain reply: {mid} @ {b} failed ({e}); "
|
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.189",
|
|
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",
|