@tiens.nguyen/gonext-local-worker 1.0.145 → 1.0.148

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.
@@ -1538,6 +1538,19 @@ async function runAgentChatJob(job) {
1538
1538
 
1539
1539
  let inThink = false;
1540
1540
  let finalText = "";
1541
+ // Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
1542
+ // prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
1543
+ // markdown, which turns `#`-prefixed code comments into <h1> headings and
1544
+ // swallows the tag fragments. Wrap only the raw stream in a fenced code block so
1545
+ // it renders verbatim; `step` labels stay outside the fence as normal prose.
1546
+ // Tilde fences (~~~) avoid colliding with any ``` the model itself emits.
1547
+ let inStreamFence = false;
1548
+ const closeStreamFence = () => {
1549
+ if (inStreamFence) {
1550
+ inStreamFence = false;
1551
+ enqueueText("\n~~~\n\n");
1552
+ }
1553
+ };
1541
1554
 
1542
1555
  // The create_pdf tool uses WeasyPrint, whose Homebrew libs (pango/cairo/gobject)
1543
1556
  // live in /opt/homebrew/lib (Apple Silicon) or /usr/local/lib (Intel). macOS doesn't
@@ -1554,13 +1567,30 @@ async function runAgentChatJob(job) {
1554
1567
  await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
1555
1568
  if (event.type === "log" && typeof event.text === "string") {
1556
1569
  console.log(`[gonext-agent] ${event.text}`);
1570
+ } else if (event.type === "stream" && typeof event.text === "string") {
1571
+ // Live model token stream (the agent's Thought / reasoning as it generates).
1572
+ // Goes inside the same <think> block as the step summaries, but RAW — no
1573
+ // "\n\n" separator, so the tokens concatenate into flowing text. Wrapped in
1574
+ // a code fence (opened lazily on the first stream token) so the web viewer
1575
+ // renders it verbatim instead of as markdown.
1576
+ if (!inThink) {
1577
+ inThink = true;
1578
+ enqueueText("<think>");
1579
+ }
1580
+ if (!inStreamFence) {
1581
+ inStreamFence = true;
1582
+ enqueueText("~~~\n");
1583
+ }
1584
+ enqueueText(event.text);
1557
1585
  } else if (event.type === "step" && typeof event.text === "string") {
1586
+ closeStreamFence();
1558
1587
  if (!inThink) {
1559
1588
  inThink = true;
1560
1589
  enqueueText("<think>");
1561
1590
  }
1562
1591
  enqueueText(event.text + "\n\n");
1563
1592
  } else if (event.type === "final" && typeof event.text === "string") {
1593
+ closeStreamFence();
1564
1594
  if (inThink) {
1565
1595
  inThink = false;
1566
1596
  enqueueText("</think>");
@@ -1570,6 +1600,7 @@ async function runAgentChatJob(job) {
1570
1600
  }
1571
1601
  }, pdfEnv);
1572
1602
 
1603
+ closeStreamFence();
1573
1604
  if (inThink) {
1574
1605
  enqueueText("</think>");
1575
1606
  }
@@ -15,9 +15,11 @@ Reads on stdin:
15
15
  }
16
16
 
17
17
  Emits NDJSON lines on stdout:
18
- {"type": "log", "text": "..."} — worker logs to console, not shown in chat
19
- {"type": "step", "text": "..."} — shown in <think> area
20
- {"type": "final", "text": "..."} assistant answer
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
@@ -1892,33 +1894,34 @@ def run_agent_chat(cfg):
1892
1894
  _log(f"streamed generate failed ({e}) → falling back to non-streaming")
1893
1895
  return super().generate(*args, **kwargs)
1894
1896
 
1895
- def _streamed_generate(
1896
- self, messages, stop_sequences=None, response_format=None,
1897
- tools_to_call_from=None, **kwargs,
1898
- ):
1899
- from smolagents.models import ChatMessage, TokenUsage # local: version-safe
1900
- completion_kwargs = self._prepare_completion_kwargs(
1901
- messages=messages,
1902
- stop_sequences=stop_sequences,
1903
- response_format=response_format,
1904
- tools_to_call_from=tools_to_call_from,
1905
- model=self.model_id,
1906
- custom_role_conversions=self.custom_role_conversions,
1907
- convert_images_to_image_urls=True,
1908
- **kwargs,
1909
- )
1910
- completion_kwargs["stream"] = True
1911
- # Ask for a usage summary in the final chunk (OpenAI streaming convention;
1912
- # Ollama honors it). Harmless if the server ignores it — usage stays 0.
1913
- 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."""
1914
1902
  self._apply_rate_limit()
1915
1903
  t0 = time.monotonic()
1916
1904
  _log("streamed generate: request issued (stream=True), awaiting first token…")
1917
1905
  stream = self.client.chat.completions.create(**completion_kwargs)
1918
1906
  parts = []
1919
1907
  role = "assistant"
1920
- in_tok = out_tok = 0
1908
+ in_tok = out_tok = reasoning_len = 0
1921
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
+
1922
1925
  for chunk in stream:
1923
1926
  usage = getattr(chunk, "usage", None)
1924
1927
  if usage is not None:
@@ -1932,26 +1935,79 @@ def run_agent_chat(cfg):
1932
1935
  continue
1933
1936
  if getattr(delta, "role", None):
1934
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})
1935
1949
  piece = getattr(delta, "content", None)
1936
1950
  if piece:
1937
- if first_token_at is None:
1938
- # First streamed byte. The gap t0→here is pure prompt-eval time
1939
- # (server sent nothing before this); everything after is token
1940
- # generation with bytes flowing, so the proxy idle timer resets.
1941
- # If this line never appears in the log, the request died in
1942
- # prompt-eval BEFORE any token — streaming can't help there
1943
- # (shrink the model / warm the prompt cache instead).
1944
- first_token_at = time.monotonic()
1945
- _log(f"streamed generate: FIRST token after {first_token_at - t0:.1f}s "
1946
- "(prompt-eval done; tokens now flowing)")
1947
- _emit({"type": "step", "text": "Model is responding…"})
1951
+ _mark_first()
1948
1952
  parts.append(piece)
1953
+ _emit({"type": "stream", "text": piece})
1949
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
1950
2008
  if stop_sequences and not self.supports_stop_parameter:
1951
2009
  from smolagents.models import remove_content_after_stop_sequences
1952
2010
  content = remove_content_after_stop_sequences(content, stop_sequences)
1953
- _log(f"streamed generate assembled {len(content)} chars "
1954
- f"(in={in_tok} out={out_tok} tokens)")
1955
2011
  return ChatMessage(
1956
2012
  role=role,
1957
2013
  content=content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.145",
3
+ "version": "1.0.148",
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",