@tiens.nguyen/gonext-local-worker 1.0.188 → 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 +50 -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
|
@@ -54,10 +54,15 @@ const isCodeCloseLine = (line) => line.trim() === "</code>";
|
|
|
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
56
|
// The router's internal play-by-play ("Routing your request…", "→ Agent mode (needs
|
|
57
|
-
// tools)", "→ Chat reply", "Choosing a tool…") is implementation
|
|
58
|
-
// user needs — also visible in the daemon's own terminal.
|
|
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.
|
|
59
62
|
const isRoutingLine = (line) =>
|
|
60
|
-
/^(Routing your request…|Choosing a tool…|→ Agent mode.*|→ Chat reply)$/.test(
|
|
63
|
+
/^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
|
|
64
|
+
line.trim()
|
|
65
|
+
);
|
|
61
66
|
|
|
62
67
|
// Playful words for the local "thinking…" ticker, reused verbatim from the worker's
|
|
63
68
|
// thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
|
|
@@ -271,7 +276,15 @@ async function runAgentTurn(history) {
|
|
|
271
276
|
let statusShown = false; // a transient status line is currently on screen
|
|
272
277
|
let warnedPending = false;
|
|
273
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
|
|
274
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;
|
|
275
288
|
|
|
276
289
|
// --- Live "thinking…" ticker ---------------------------------------------------
|
|
277
290
|
// Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
|
|
@@ -343,18 +356,25 @@ async function runAgentTurn(history) {
|
|
|
343
356
|
// keep spacing; everything else commits dim to scrollback.
|
|
344
357
|
let inCode = false;
|
|
345
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;
|
|
346
363
|
const consume = (chunk) => {
|
|
347
364
|
carry += stripThinkTags(chunk);
|
|
348
365
|
let nl;
|
|
349
366
|
while ((nl = carry.indexOf("\n")) >= 0) {
|
|
350
367
|
const line = carry.slice(0, nl);
|
|
351
368
|
carry = carry.slice(nl + 1);
|
|
369
|
+
const alreadyFlushed = carryFlushedLen;
|
|
370
|
+
carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
|
|
352
371
|
if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
|
|
353
372
|
if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
|
|
354
373
|
if (isRoutingLine(line)) {
|
|
355
374
|
// Replace the whole routing play-by-play with ONE friendly line, shown once
|
|
356
375
|
// per turn — the raw labels are implementation detail (visible in the daemon's
|
|
357
376
|
// own terminal if you need them).
|
|
377
|
+
if (line.trim() === "→ Chat reply") plainReplyFlow = true;
|
|
358
378
|
if (!routingAnnounced) {
|
|
359
379
|
routingAnnounced = true;
|
|
360
380
|
onContent();
|
|
@@ -386,8 +406,24 @@ async function runAgentTurn(history) {
|
|
|
386
406
|
}
|
|
387
407
|
continue;
|
|
388
408
|
}
|
|
389
|
-
onContent();
|
|
390
|
-
|
|
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;
|
|
391
427
|
lastWasBlank = false;
|
|
392
428
|
}
|
|
393
429
|
// A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
|
|
@@ -402,7 +438,7 @@ async function runAgentTurn(history) {
|
|
|
402
438
|
process.stdout.write(
|
|
403
439
|
dim("\n(stopped following — the job keeps running on the worker)\n")
|
|
404
440
|
);
|
|
405
|
-
return "";
|
|
441
|
+
return { text: "", shown: false };
|
|
406
442
|
}
|
|
407
443
|
const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
|
|
408
444
|
headers: { "X-Worker-Key": workerKey },
|
|
@@ -412,10 +448,11 @@ async function runAgentTurn(history) {
|
|
|
412
448
|
jobRunning = job.jobStatus === "running";
|
|
413
449
|
const text = typeof job.resultText === "string" ? job.resultText : "";
|
|
414
450
|
if (job.jobStatus === "completed") {
|
|
415
|
-
// Drop the transient status line and any partial reasoning tail
|
|
416
|
-
//
|
|
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.
|
|
417
454
|
clearStatus();
|
|
418
|
-
return answerFrom(text);
|
|
455
|
+
return { text: answerFrom(text), shown: answerShownLive };
|
|
419
456
|
}
|
|
420
457
|
if (text.length > shownChars) {
|
|
421
458
|
consume(text.slice(shownChars));
|
|
@@ -512,10 +549,12 @@ async function main() {
|
|
|
512
549
|
}
|
|
513
550
|
history.push({ role: "user", content: line });
|
|
514
551
|
try {
|
|
515
|
-
const answer = await runAgentTurn(history);
|
|
552
|
+
const { text: answer, shown } = await runAgentTurn(history);
|
|
516
553
|
if (answer) {
|
|
517
554
|
history.push({ role: "assistant", content: answer });
|
|
518
|
-
|
|
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`);
|
|
519
558
|
} else {
|
|
520
559
|
history.pop(); // aborted/failed turn — don't poison the next request's history
|
|
521
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",
|