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

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.
@@ -626,6 +626,44 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str) ->
626
626
  return f"[Error: {e}]"
627
627
 
628
628
 
629
+ def _synthesize_document(gathered: list, task: str, base_url: str, api_key: str,
630
+ model_id: str) -> str:
631
+ """Turn raw gathered research observations into ONE clean, organized document body
632
+ for create_pdf. This is a pure REFORMAT (organize/deduplicate facts already found —
633
+ no new research), so the fast local chat model handles it well. Returns "" on any
634
+ failure so the caller can fall back to the raw concatenation."""
635
+ notes = "\n\n---\n\n".join(gathered)[:12000]
636
+ sys_prompt = (
637
+ "You are a document editor. You are given a user's request and raw research "
638
+ "notes gathered to answer it. Produce a single, clean, well-organized document "
639
+ "that fulfills the request using ONLY facts present in the notes.\n"
640
+ "- Use a clear title, headings, and bullet lists or a markdown table where it "
641
+ "fits (e.g. schedules → a table of Teams | Date | Time).\n"
642
+ "- Deduplicate and order the information sensibly (e.g. by date).\n"
643
+ "- Do NOT invent facts not in the notes, and do NOT mention the research process, "
644
+ "steps, tools, or that anything is partial. Output ONLY the document body."
645
+ )
646
+ user_prompt = f"USER REQUEST:\n{task}\n\nRESEARCH NOTES:\n{notes}"
647
+ try:
648
+ from openai import OpenAI
649
+ client = OpenAI(base_url=base_url, api_key=api_key or "local",
650
+ max_retries=0, timeout=120)
651
+ resp = client.chat.completions.create(
652
+ model=model_id,
653
+ messages=[{"role": "system", "content": sys_prompt},
654
+ {"role": "user", "content": user_prompt}],
655
+ temperature=0.3,
656
+ max_tokens=2048,
657
+ )
658
+ out = (resp.choices[0].message.content or "").strip()
659
+ # A reasoning model might wrap output in <think>…</think>; strip it.
660
+ out = _THINK_BLOCK.sub("", out).strip()
661
+ return out
662
+ except Exception as e: # noqa: BLE001
663
+ _log(f"document synthesis failed ({e}) → will use raw gathered notes")
664
+ return ""
665
+
666
+
629
667
  def _strip_tool_tags(text: str) -> str:
630
668
  """Remove the internal hint tags we append to tool output (e.g. '[SUCCESS …]',
631
669
  '[NOTE: …]', 'Note: This URL failed …') so they never leak into the user reply."""
@@ -1444,10 +1482,12 @@ def run_agent_chat(cfg):
1444
1482
  return " ".join(sorted(tokens))
1445
1483
 
1446
1484
  # 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}
1485
+ # Past it, retrieval tools refuse and steer to create_pdf/final_answer. Keeps steps
1486
+ # free to actually FINISH — the live failure mode was burning ALL steps on
1487
+ # slightly-different searches and never producing the asked-for PDF. PDF tasks get a
1488
+ # TIGHTER budget (2) so the model is steered to create_pdf a step sooner and has more
1489
+ # steps left to actually emit that call itself (avoids leaning on the fallback).
1490
+ _research = {"used": 0, "max": 2 if agent_pdf_requested else 3}
1451
1491
 
1452
1492
  def _research_spend(tool_name: str) -> str:
1453
1493
  """Return '' if under budget (and spend 1), else a refusal steering to finish."""
@@ -1872,10 +1912,13 @@ def run_agent_chat(cfg):
1872
1912
  # Ollama honors it). Harmless if the server ignores it — usage stays 0.
1873
1913
  completion_kwargs["stream_options"] = {"include_usage": True}
1874
1914
  self._apply_rate_limit()
1915
+ t0 = time.monotonic()
1916
+ _log("streamed generate: request issued (stream=True), awaiting first token…")
1875
1917
  stream = self.client.chat.completions.create(**completion_kwargs)
1876
1918
  parts = []
1877
1919
  role = "assistant"
1878
1920
  in_tok = out_tok = 0
1921
+ first_token_at = None
1879
1922
  for chunk in stream:
1880
1923
  usage = getattr(chunk, "usage", None)
1881
1924
  if usage is not None:
@@ -1891,6 +1934,17 @@ def run_agent_chat(cfg):
1891
1934
  role = delta.role
1892
1935
  piece = getattr(delta, "content", None)
1893
1936
  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…"})
1894
1948
  parts.append(piece)
1895
1949
  content = "".join(parts)
1896
1950
  if stop_sequences and not self.supports_stop_parameter:
@@ -2067,28 +2121,36 @@ def run_agent_chat(cfg):
2067
2121
  class _ToolAgent(_AgentBase):
2068
2122
  def provide_final_answer(self, task, *args, **kwargs):
2069
2123
  from smolagents.models import ChatMessage, MessageRole
2070
- # PDF-on-exhaustion guarantee: the user asked for a PDF and the loop burned
2071
- # all steps researching without ever calling create_pdf (seen live: 5 steps
2072
- # of search/fetch, zero deliverable). Render whatever WAS gathered into a
2073
- # (possibly partial) PDF instead of returning raw page text.
2124
+ # Deterministic PDF delivery: the user asked for a PDF and the model did the
2125
+ # research but never emitted the create_pdf call itself (weak/slow coders often
2126
+ # keep researching and never converge). Rendering the gathered facts here is the
2127
+ # INTENDED deliverable, not an error so deliver it cleanly, with NO apologetic
2128
+ # "may be partial / hit my step limit" note (that made a normal, successful PDF
2129
+ # look like a failure to the user).
2074
2130
  if (agent_pdf_requested and _gathered
2075
2131
  and not (_last_obs.get("text") or "").startswith("✅")):
2076
- _log(f"max-steps fallback forcing create_pdf from {len(_gathered)} "
2077
- "gathered observation(s)")
2132
+ _log(f"delivering PDF deterministically from {len(_gathered)} "
2133
+ "gathered result(s) (model didn't call create_pdf itself)")
2078
2134
  try:
2079
- compiled = "\n\n---\n\n".join(_gathered)[:12000]
2135
+ # Reformat the raw observations into a clean document (headings/tables)
2136
+ # via the fast local chat model; fall back to the raw concatenation if
2137
+ # synthesis fails or returns nothing.
2138
+ compiled = _synthesize_document(
2139
+ _gathered, latest_user_text,
2140
+ agent_base_url, agent_api_key, agent_model_id,
2141
+ )
2142
+ if compiled:
2143
+ _log(f"synthesized document ({len(compiled)} chars) for PDF")
2144
+ else:
2145
+ compiled = "\n\n---\n\n".join(_gathered)[:12000]
2080
2146
  out = create_pdf(
2081
2147
  text=compiled, title=_derive_pdf_title(latest_user_text)
2082
2148
  )
2083
2149
  if isinstance(out, str) and out.startswith("✅"):
2084
- out += (
2085
- "\n\n(Note: I hit my research step limit, so this PDF contains "
2086
- "what I could gather — it may be partial.)"
2087
- )
2088
2150
  return ChatMessage(role=MessageRole.ASSISTANT, content=out)
2089
- _log(f"forced create_pdf did not succeed: {str(out)[:120]}")
2151
+ _log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
2090
2152
  except Exception as e: # noqa: BLE001
2091
- _log(f"forced create_pdf error: {type(e).__name__}: {e}")
2153
+ _log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
2092
2154
  text = (_last_obs.get("text") or "").strip()
2093
2155
  if text:
2094
2156
  _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.143",
3
+ "version": "1.0.145",
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",