@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.
@@ -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
@@ -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 ≥2 of
1448
- # the 5 steps free to actually FINISH — the live failure mode was burning ALL steps
1449
- # on slightly-different searches and never producing the asked-for PDF.
1450
- _research = {"used": 0, "max": 3}
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 _streamed_generate(
1856
- self, messages, stop_sequences=None, response_format=None,
1857
- tools_to_call_from=None, **kwargs,
1858
- ):
1859
- from smolagents.models import ChatMessage, TokenUsage # local: version-safe
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
- if first_token_at is None:
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-on-exhaustion guarantee: the user asked for a PDF and the loop burned
2085
- # all steps researching without ever calling create_pdf (seen live: 5 steps
2086
- # of search/fetch, zero deliverable). Render whatever WAS gathered into a
2087
- # (possibly partial) PDF instead of returning raw page text.
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"max-steps fallback forcing create_pdf from {len(_gathered)} "
2091
- "gathered observation(s)")
2188
+ _log(f"delivering PDF deterministically from {len(_gathered)} "
2189
+ "gathered result(s) (model didn't call create_pdf itself)")
2092
2190
  try:
2093
- compiled = "\n\n---\n\n".join(_gathered)[:12000]
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"forced create_pdf did not succeed: {str(out)[:120]}")
2207
+ _log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
2104
2208
  except Exception as e: # noqa: BLE001
2105
- _log(f"forced create_pdf error: {type(e).__name__}: {e}")
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.144",
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",