@tiens.nguyen/gonext-local-worker 1.0.144 → 1.0.146
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 +9 -0
- package/gonext_agent_chat.py +157 -53
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1554,6 +1554,15 @@ async function runAgentChatJob(job) {
|
|
|
1554
1554
|
await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
|
|
1555
1555
|
if (event.type === "log" && typeof event.text === "string") {
|
|
1556
1556
|
console.log(`[gonext-agent] ${event.text}`);
|
|
1557
|
+
} else if (event.type === "stream" && typeof event.text === "string") {
|
|
1558
|
+
// Live model token stream (the agent's Thought / reasoning as it generates).
|
|
1559
|
+
// Goes inside the same <think> block as the step summaries, but RAW — no
|
|
1560
|
+
// "\n\n" separator, so the tokens concatenate into flowing text.
|
|
1561
|
+
if (!inThink) {
|
|
1562
|
+
inThink = true;
|
|
1563
|
+
enqueueText("<think>");
|
|
1564
|
+
}
|
|
1565
|
+
enqueueText(event.text);
|
|
1557
1566
|
} else if (event.type === "step" && typeof event.text === "string") {
|
|
1558
1567
|
if (!inThink) {
|
|
1559
1568
|
inThink = true;
|
package/gonext_agent_chat.py
CHANGED
|
@@ -15,9 +15,11 @@ Reads on stdin:
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
Emits NDJSON lines on stdout:
|
|
18
|
-
{"type": "log",
|
|
19
|
-
{"type": "step",
|
|
20
|
-
{"type": "
|
|
18
|
+
{"type": "log", "text": "..."} — worker logs to console, not shown in chat
|
|
19
|
+
{"type": "step", "text": "..."} — a summary line shown in the <think> area
|
|
20
|
+
{"type": "stream", "text": "..."} — RAW model token(s) streamed live into <think>
|
|
21
|
+
(the agent's Thought/reasoning as it generates)
|
|
22
|
+
{"type": "final", "text": "..."} — assistant answer
|
|
21
23
|
"""
|
|
22
24
|
import contextlib
|
|
23
25
|
import json
|
|
@@ -626,6 +628,44 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str) ->
|
|
|
626
628
|
return f"[Error: {e}]"
|
|
627
629
|
|
|
628
630
|
|
|
631
|
+
def _synthesize_document(gathered: list, task: str, base_url: str, api_key: str,
|
|
632
|
+
model_id: str) -> str:
|
|
633
|
+
"""Turn raw gathered research observations into ONE clean, organized document body
|
|
634
|
+
for create_pdf. This is a pure REFORMAT (organize/deduplicate facts already found —
|
|
635
|
+
no new research), so the fast local chat model handles it well. Returns "" on any
|
|
636
|
+
failure so the caller can fall back to the raw concatenation."""
|
|
637
|
+
notes = "\n\n---\n\n".join(gathered)[:12000]
|
|
638
|
+
sys_prompt = (
|
|
639
|
+
"You are a document editor. You are given a user's request and raw research "
|
|
640
|
+
"notes gathered to answer it. Produce a single, clean, well-organized document "
|
|
641
|
+
"that fulfills the request using ONLY facts present in the notes.\n"
|
|
642
|
+
"- Use a clear title, headings, and bullet lists or a markdown table where it "
|
|
643
|
+
"fits (e.g. schedules → a table of Teams | Date | Time).\n"
|
|
644
|
+
"- Deduplicate and order the information sensibly (e.g. by date).\n"
|
|
645
|
+
"- Do NOT invent facts not in the notes, and do NOT mention the research process, "
|
|
646
|
+
"steps, tools, or that anything is partial. Output ONLY the document body."
|
|
647
|
+
)
|
|
648
|
+
user_prompt = f"USER REQUEST:\n{task}\n\nRESEARCH NOTES:\n{notes}"
|
|
649
|
+
try:
|
|
650
|
+
from openai import OpenAI
|
|
651
|
+
client = OpenAI(base_url=base_url, api_key=api_key or "local",
|
|
652
|
+
max_retries=0, timeout=120)
|
|
653
|
+
resp = client.chat.completions.create(
|
|
654
|
+
model=model_id,
|
|
655
|
+
messages=[{"role": "system", "content": sys_prompt},
|
|
656
|
+
{"role": "user", "content": user_prompt}],
|
|
657
|
+
temperature=0.3,
|
|
658
|
+
max_tokens=2048,
|
|
659
|
+
)
|
|
660
|
+
out = (resp.choices[0].message.content or "").strip()
|
|
661
|
+
# A reasoning model might wrap output in <think>…</think>; strip it.
|
|
662
|
+
out = _THINK_BLOCK.sub("", out).strip()
|
|
663
|
+
return out
|
|
664
|
+
except Exception as e: # noqa: BLE001
|
|
665
|
+
_log(f"document synthesis failed ({e}) → will use raw gathered notes")
|
|
666
|
+
return ""
|
|
667
|
+
|
|
668
|
+
|
|
629
669
|
def _strip_tool_tags(text: str) -> str:
|
|
630
670
|
"""Remove the internal hint tags we append to tool output (e.g. '[SUCCESS …]',
|
|
631
671
|
'[NOTE: …]', 'Note: This URL failed …') so they never leak into the user reply."""
|
|
@@ -1444,10 +1484,12 @@ def run_agent_chat(cfg):
|
|
|
1444
1484
|
return " ".join(sorted(tokens))
|
|
1445
1485
|
|
|
1446
1486
|
# Hard research budget: at most N retrieval calls (web_search + fetch_url) per run.
|
|
1447
|
-
# Past it, retrieval tools refuse and steer to create_pdf/final_answer. Keeps
|
|
1448
|
-
#
|
|
1449
|
-
#
|
|
1450
|
-
|
|
1487
|
+
# Past it, retrieval tools refuse and steer to create_pdf/final_answer. Keeps steps
|
|
1488
|
+
# free to actually FINISH — the live failure mode was burning ALL steps on
|
|
1489
|
+
# slightly-different searches and never producing the asked-for PDF. PDF tasks get a
|
|
1490
|
+
# TIGHTER budget (2) so the model is steered to create_pdf a step sooner and has more
|
|
1491
|
+
# steps left to actually emit that call itself (avoids leaning on the fallback).
|
|
1492
|
+
_research = {"used": 0, "max": 2 if agent_pdf_requested else 3}
|
|
1451
1493
|
|
|
1452
1494
|
def _research_spend(tool_name: str) -> str:
|
|
1453
1495
|
"""Return '' if under budget (and spend 1), else a refusal steering to finish."""
|
|
@@ -1852,33 +1894,34 @@ def run_agent_chat(cfg):
|
|
|
1852
1894
|
_log(f"streamed generate failed ({e}) → falling back to non-streaming")
|
|
1853
1895
|
return super().generate(*args, **kwargs)
|
|
1854
1896
|
|
|
1855
|
-
def
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
completion_kwargs = self._prepare_completion_kwargs(
|
|
1861
|
-
messages=messages,
|
|
1862
|
-
stop_sequences=stop_sequences,
|
|
1863
|
-
response_format=response_format,
|
|
1864
|
-
tools_to_call_from=tools_to_call_from,
|
|
1865
|
-
model=self.model_id,
|
|
1866
|
-
custom_role_conversions=self.custom_role_conversions,
|
|
1867
|
-
convert_images_to_image_urls=True,
|
|
1868
|
-
**kwargs,
|
|
1869
|
-
)
|
|
1870
|
-
completion_kwargs["stream"] = True
|
|
1871
|
-
# Ask for a usage summary in the final chunk (OpenAI streaming convention;
|
|
1872
|
-
# Ollama honors it). Harmless if the server ignores it — usage stays 0.
|
|
1873
|
-
completion_kwargs["stream_options"] = {"include_usage": True}
|
|
1897
|
+
def _run_stream(self, completion_kwargs):
|
|
1898
|
+
"""Issue ONE streaming completion, stream tokens live to the UI, and return
|
|
1899
|
+
(content, role, in_tok, out_tok, reasoning_len). `content` is ONLY the visible
|
|
1900
|
+
message text (for smolagents' parser); reasoning-channel tokens are streamed to
|
|
1901
|
+
the Thinking panel but never accumulated into content."""
|
|
1874
1902
|
self._apply_rate_limit()
|
|
1875
1903
|
t0 = time.monotonic()
|
|
1876
1904
|
_log("streamed generate: request issued (stream=True), awaiting first token…")
|
|
1877
1905
|
stream = self.client.chat.completions.create(**completion_kwargs)
|
|
1878
1906
|
parts = []
|
|
1879
1907
|
role = "assistant"
|
|
1880
|
-
in_tok = out_tok = 0
|
|
1908
|
+
in_tok = out_tok = reasoning_len = 0
|
|
1881
1909
|
first_token_at = None
|
|
1910
|
+
|
|
1911
|
+
def _mark_first():
|
|
1912
|
+
# First streamed byte (content OR reasoning). The gap t0→here is pure
|
|
1913
|
+
# prompt-eval time (server sent nothing before this); everything after is
|
|
1914
|
+
# token generation with bytes flowing, so the proxy idle timer resets. If
|
|
1915
|
+
# this never logs, the request died in prompt-eval BEFORE any token —
|
|
1916
|
+
# streaming can't help there (shrink the model / warm the prompt cache).
|
|
1917
|
+
nonlocal first_token_at
|
|
1918
|
+
if first_token_at is None:
|
|
1919
|
+
first_token_at = time.monotonic()
|
|
1920
|
+
_log(f"streamed generate: FIRST token after {first_token_at - t0:.1f}s "
|
|
1921
|
+
"(prompt-eval done; tokens now flowing)")
|
|
1922
|
+
# Separate this step's live thinking from the previous step summary.
|
|
1923
|
+
_emit({"type": "stream", "text": "\n"})
|
|
1924
|
+
|
|
1882
1925
|
for chunk in stream:
|
|
1883
1926
|
usage = getattr(chunk, "usage", None)
|
|
1884
1927
|
if usage is not None:
|
|
@@ -1892,26 +1935,79 @@ def run_agent_chat(cfg):
|
|
|
1892
1935
|
continue
|
|
1893
1936
|
if getattr(delta, "role", None):
|
|
1894
1937
|
role = delta.role
|
|
1938
|
+
# Live "thinking": stream BOTH the visible content AND any reasoning-channel
|
|
1939
|
+
# tokens (gemma4/deepseek-class models emit their chain-of-thought there,
|
|
1940
|
+
# leaving `content` empty) to the web Thinking panel as they arrive. Only
|
|
1941
|
+
# `content` is accumulated into the message smolagents parses — reasoning is
|
|
1942
|
+
# display-only and must NEVER reach the <code> parser.
|
|
1943
|
+
rpiece = (getattr(delta, "reasoning", None)
|
|
1944
|
+
or getattr(delta, "reasoning_content", None))
|
|
1945
|
+
if rpiece:
|
|
1946
|
+
_mark_first()
|
|
1947
|
+
reasoning_len += len(rpiece)
|
|
1948
|
+
_emit({"type": "stream", "text": rpiece})
|
|
1895
1949
|
piece = getattr(delta, "content", None)
|
|
1896
1950
|
if piece:
|
|
1897
|
-
|
|
1898
|
-
# First streamed byte. The gap t0→here is pure prompt-eval time
|
|
1899
|
-
# (server sent nothing before this); everything after is token
|
|
1900
|
-
# generation with bytes flowing, so the proxy idle timer resets.
|
|
1901
|
-
# If this line never appears in the log, the request died in
|
|
1902
|
-
# prompt-eval BEFORE any token — streaming can't help there
|
|
1903
|
-
# (shrink the model / warm the prompt cache instead).
|
|
1904
|
-
first_token_at = time.monotonic()
|
|
1905
|
-
_log(f"streamed generate: FIRST token after {first_token_at - t0:.1f}s "
|
|
1906
|
-
"(prompt-eval done; tokens now flowing)")
|
|
1907
|
-
_emit({"type": "step", "text": "Model is responding…"})
|
|
1951
|
+
_mark_first()
|
|
1908
1952
|
parts.append(piece)
|
|
1953
|
+
_emit({"type": "stream", "text": piece})
|
|
1909
1954
|
content = "".join(parts)
|
|
1955
|
+
_log(f"streamed generate assembled {len(content)} chars "
|
|
1956
|
+
f"(in={in_tok} out={out_tok} tokens, reasoning={reasoning_len} chars)")
|
|
1957
|
+
return content, role, in_tok, out_tok, reasoning_len
|
|
1958
|
+
|
|
1959
|
+
# Injected when a turn produced ONLY reasoning and no message content — a hard,
|
|
1960
|
+
# model-agnostic steer to emit the actionable code block instead of more analysis.
|
|
1961
|
+
_CODE_NOW_DIRECTIVE = (
|
|
1962
|
+
"You wrote analysis but produced NO code block, so nothing ran. Output ONLY a "
|
|
1963
|
+
"single code block NOW that calls exactly one tool and nothing else, e.g.:\n"
|
|
1964
|
+
"<code>\nweb_search(query=\"...\")\n</code>\n"
|
|
1965
|
+
"No explanation, no <thought>, no <think> — just the <code>…</code> block. "
|
|
1966
|
+
"If you already have enough information, call final_answer(...) or create_pdf(...)."
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
def _streamed_generate(
|
|
1970
|
+
self, messages, stop_sequences=None, response_format=None,
|
|
1971
|
+
tools_to_call_from=None, **kwargs,
|
|
1972
|
+
):
|
|
1973
|
+
from smolagents.models import ChatMessage, TokenUsage # local: version-safe
|
|
1974
|
+
completion_kwargs = self._prepare_completion_kwargs(
|
|
1975
|
+
messages=messages,
|
|
1976
|
+
stop_sequences=stop_sequences,
|
|
1977
|
+
response_format=response_format,
|
|
1978
|
+
tools_to_call_from=tools_to_call_from,
|
|
1979
|
+
model=self.model_id,
|
|
1980
|
+
custom_role_conversions=self.custom_role_conversions,
|
|
1981
|
+
convert_images_to_image_urls=True,
|
|
1982
|
+
**kwargs,
|
|
1983
|
+
)
|
|
1984
|
+
completion_kwargs["stream"] = True
|
|
1985
|
+
# Ask for a usage summary in the final chunk (OpenAI streaming convention;
|
|
1986
|
+
# Ollama honors it). Harmless if the server ignores it — usage stays 0.
|
|
1987
|
+
completion_kwargs["stream_options"] = {"include_usage": True}
|
|
1988
|
+
content, role, in_tok, out_tok, reasoning_len = self._run_stream(completion_kwargs)
|
|
1989
|
+
# Empty-turn retry: reasoning models (gemma4 on Ollama) sometimes spend the
|
|
1990
|
+
# WHOLE turn in the reasoning channel and emit no content → smolagents parses an
|
|
1991
|
+
# empty tool call and WASTES the step (seen live: 2 of 5 steps burned this way).
|
|
1992
|
+
# Retry ONCE with a hard "emit the code block now" directive appended to the
|
|
1993
|
+
# already-prepared API messages (plain dicts — no smolagents format guessing).
|
|
1994
|
+
if not content.strip():
|
|
1995
|
+
_log(f"empty-content turn (reasoning={reasoning_len} chars, no message) "
|
|
1996
|
+
"→ retrying once with a 'code now' directive")
|
|
1997
|
+
_emit({"type": "step",
|
|
1998
|
+
"text": "Model produced only analysis — nudging it to write the action…"})
|
|
1999
|
+
retry_kwargs = dict(completion_kwargs)
|
|
2000
|
+
retry_kwargs["messages"] = list(completion_kwargs.get("messages") or []) + [
|
|
2001
|
+
{"role": "user", "content": self._CODE_NOW_DIRECTIVE}
|
|
2002
|
+
]
|
|
2003
|
+
r_content, r_role, r_in, r_out, _ = self._run_stream(retry_kwargs)
|
|
2004
|
+
if r_content.strip():
|
|
2005
|
+
content, role = r_content, r_role
|
|
2006
|
+
# Count both calls' output so token accounting isn't understated.
|
|
2007
|
+
in_tok, out_tok = (r_in or in_tok), out_tok + r_out
|
|
1910
2008
|
if stop_sequences and not self.supports_stop_parameter:
|
|
1911
2009
|
from smolagents.models import remove_content_after_stop_sequences
|
|
1912
2010
|
content = remove_content_after_stop_sequences(content, stop_sequences)
|
|
1913
|
-
_log(f"streamed generate assembled {len(content)} chars "
|
|
1914
|
-
f"(in={in_tok} out={out_tok} tokens)")
|
|
1915
2011
|
return ChatMessage(
|
|
1916
2012
|
role=role,
|
|
1917
2013
|
content=content,
|
|
@@ -2081,28 +2177,36 @@ def run_agent_chat(cfg):
|
|
|
2081
2177
|
class _ToolAgent(_AgentBase):
|
|
2082
2178
|
def provide_final_answer(self, task, *args, **kwargs):
|
|
2083
2179
|
from smolagents.models import ChatMessage, MessageRole
|
|
2084
|
-
# PDF
|
|
2085
|
-
#
|
|
2086
|
-
#
|
|
2087
|
-
#
|
|
2180
|
+
# Deterministic PDF delivery: the user asked for a PDF and the model did the
|
|
2181
|
+
# research but never emitted the create_pdf call itself (weak/slow coders often
|
|
2182
|
+
# keep researching and never converge). Rendering the gathered facts here is the
|
|
2183
|
+
# INTENDED deliverable, not an error — so deliver it cleanly, with NO apologetic
|
|
2184
|
+
# "may be partial / hit my step limit" note (that made a normal, successful PDF
|
|
2185
|
+
# look like a failure to the user).
|
|
2088
2186
|
if (agent_pdf_requested and _gathered
|
|
2089
2187
|
and not (_last_obs.get("text") or "").startswith("✅")):
|
|
2090
|
-
_log(f"
|
|
2091
|
-
"gathered
|
|
2188
|
+
_log(f"delivering PDF deterministically from {len(_gathered)} "
|
|
2189
|
+
"gathered result(s) (model didn't call create_pdf itself)")
|
|
2092
2190
|
try:
|
|
2093
|
-
|
|
2191
|
+
# Reformat the raw observations into a clean document (headings/tables)
|
|
2192
|
+
# via the fast local chat model; fall back to the raw concatenation if
|
|
2193
|
+
# synthesis fails or returns nothing.
|
|
2194
|
+
compiled = _synthesize_document(
|
|
2195
|
+
_gathered, latest_user_text,
|
|
2196
|
+
agent_base_url, agent_api_key, agent_model_id,
|
|
2197
|
+
)
|
|
2198
|
+
if compiled:
|
|
2199
|
+
_log(f"synthesized document ({len(compiled)} chars) for PDF")
|
|
2200
|
+
else:
|
|
2201
|
+
compiled = "\n\n---\n\n".join(_gathered)[:12000]
|
|
2094
2202
|
out = create_pdf(
|
|
2095
2203
|
text=compiled, title=_derive_pdf_title(latest_user_text)
|
|
2096
2204
|
)
|
|
2097
2205
|
if isinstance(out, str) and out.startswith("✅"):
|
|
2098
|
-
out += (
|
|
2099
|
-
"\n\n(Note: I hit my research step limit, so this PDF contains "
|
|
2100
|
-
"what I could gather — it may be partial.)"
|
|
2101
|
-
)
|
|
2102
2206
|
return ChatMessage(role=MessageRole.ASSISTANT, content=out)
|
|
2103
|
-
_log(f"
|
|
2207
|
+
_log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
|
|
2104
2208
|
except Exception as e: # noqa: BLE001
|
|
2105
|
-
_log(f"
|
|
2209
|
+
_log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
|
|
2106
2210
|
text = (_last_obs.get("text") or "").strip()
|
|
2107
2211
|
if text:
|
|
2108
2212
|
_log(f"max-steps fallback (last tool obs) → {text[:80]}")
|
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.146",
|
|
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",
|