@tiens.nguyen/gonext-local-worker 1.0.145 → 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.
@@ -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;
@@ -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.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",