@melaya/runner 1.1.79 → 1.1.80

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.
@@ -229,7 +229,42 @@ def _render_summary_text(summary) -> str:
229
229
  # STOP flag: set by the stdin-reader thread, polled by the running turn.
230
230
  _stream = {"turnId": "", "lens": {}, "cancel": False, "usedBrowser": False,
231
231
  "deltaBuf": "", "deltaMid": "", "deltaAt": 0.0,
232
- "thinkLens": {}, "toolIds": set()}
232
+ "thinkLens": {}, "toolIds": set(),
233
+ # Progress watchdog (see _progress_watchdog): what the turn is doing
234
+ # right now and when the client last heard from it.
235
+ "activity": "", "lastEmitAt": 0.0, "turnStartAt": 0.0}
236
+
237
+ # One event line at a time: the progress watchdog writes from its own thread.
238
+ _EMIT_LOCK = threading.Lock()
239
+
240
+ # A turn that is silent this long gets a visible "Still working" status line
241
+ # (2026-11-05, conv 33d90517: a Luma → Google Sheet turn went quiet for 5+
242
+ # minutes and the user pressed STOP four times, never seeing what it was doing).
243
+ _PROGRESS_SILENCE_S = 20.0
244
+
245
+
246
+ def _fmt_elapsed(seconds: float) -> str:
247
+ s = int(max(0, seconds))
248
+ return f"{s // 60}m {s % 60:02d}s" if s >= 60 else f"{s}s"
249
+
250
+
251
+ def _progress_watchdog(turn_id: str, stop: "threading.Event") -> None:
252
+ """While a turn runs, say what it is doing whenever it has been silent for
253
+ _PROGRESS_SILENCE_S: one `think` line in the chat and one line in the runner
254
+ log. Never raises, never touches the turn itself."""
255
+ while not stop.wait(5.0):
256
+ try:
257
+ if _stream.get("turnId") != turn_id:
258
+ return
259
+ now = time.monotonic()
260
+ if now - float(_stream.get("lastEmitAt") or now) < _PROGRESS_SILENCE_S:
261
+ continue
262
+ activity = _stream.get("activity") or "waiting for the model"
263
+ line = f"Still working ({_fmt_elapsed(now - float(_stream.get('turnStartAt') or now))}): {activity}"
264
+ _log(f"progress turn={turn_id[:8]} {line}")
265
+ _emit(turn_id, "think", content=f"\n{line}\n")
266
+ except Exception:
267
+ pass
233
268
 
234
269
  # Coalesce per-token pre_print fragments before emitting a `delta`. agentscope
235
270
  # hands us one cumulative snapshot per model chunk; a streaming Anthropic/OpenAI
@@ -689,8 +724,12 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
689
724
  """Write one structured event line to stdout (flushed) for the runner to relay."""
690
725
  try:
691
726
  payload = {"turnId": turn_id, "kind": kind, **fields}
692
- sys.stdout.write(_EVENT_PREFIX + json.dumps(payload, ensure_ascii=False, default=str) + "\n")
693
- sys.stdout.flush()
727
+ line = _EVENT_PREFIX + json.dumps(payload, ensure_ascii=False, default=str) + "\n"
728
+ with _EMIT_LOCK:
729
+ sys.stdout.write(line)
730
+ sys.stdout.flush()
731
+ if turn_id:
732
+ _stream["lastEmitAt"] = time.monotonic()
694
733
  except Exception:
695
734
  # Never let event emission crash the turn loop.
696
735
  pass
@@ -761,6 +800,24 @@ def _emit_turn_files(turn_id: str) -> int:
761
800
  return n
762
801
 
763
802
 
803
+ def _last_tool_input_size(content) -> "tuple[str, int]":
804
+ """(name, characters of its arguments so far) of the last tool call in a
805
+ streamed message, so a long silent argument write can be reported."""
806
+ blocks = content if isinstance(content, (list, tuple)) else [content]
807
+ for b in reversed(list(blocks)):
808
+ btype = b.get("type") if isinstance(b, dict) else getattr(b, "type", None)
809
+ if btype != "tool_use":
810
+ continue
811
+ name = b.get("name") if isinstance(b, dict) else getattr(b, "name", "")
812
+ args = b.get("input") if isinstance(b, dict) else getattr(b, "input", None)
813
+ try:
814
+ size = len(args) if isinstance(args, str) else len(json.dumps(args or {}, default=str))
815
+ except Exception:
816
+ size = 0
817
+ return str(name or ""), size
818
+ return "", 0
819
+
820
+
764
821
  def _split_stream_blocks(content):
765
822
  """Split a streamed message's content into (answer_text, thinking_text,
766
823
  [(tool_id, tool_name)]).
@@ -1430,6 +1487,16 @@ def _register_stream_hooks(agent) -> None:
1430
1487
  return
1431
1488
  mid = str(getattr(msg, "id", "") or id(msg))
1432
1489
  text, thinking, tool_uses = _split_stream_blocks(getattr(msg, "content", msg))
1490
+ # What the progress watchdog reports if the turn goes quiet.
1491
+ if tool_uses:
1492
+ _tname_now, _tchars = _last_tool_input_size(getattr(msg, "content", msg))
1493
+ if _tname_now:
1494
+ _stream["activity"] = (f"writing the {_tname_now} call ({_tchars // 1024} KB so far)"
1495
+ if _tchars >= 1024 else f"writing the {_tname_now} call")
1496
+ elif text:
1497
+ _stream["activity"] = "writing the answer"
1498
+ elif thinking:
1499
+ _stream["activity"] = "thinking"
1433
1500
  # Reasoning (Codex reasoning summaries, Claude thinking) streams to the
1434
1501
  # thinking strip as `think`, never into the answer bubble.
1435
1502
  if thinking:
@@ -1531,8 +1598,29 @@ def _register_stream_hooks(agent) -> None:
1531
1598
  except Exception:
1532
1599
  return False
1533
1600
 
1601
+ def _pre_acting_hook(_self, kw, *_rest) -> None:
1602
+ # Progress only: which tool is running now (no event of its own).
1603
+ try:
1604
+ if getattr(_self, "name", "") != "Assistant" or not _stream.get("turnId") or not isinstance(kw, dict):
1605
+ return
1606
+ tc = kw.get("tool_call") or {}
1607
+ name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
1608
+ if name:
1609
+ _stream["activity"] = f"running {name}"
1610
+ except Exception:
1611
+ pass
1612
+
1613
+ def _progress_after_tool(_self, kw, *_rest) -> None:
1614
+ try:
1615
+ if getattr(_self, "name", "") == "Assistant" and _stream.get("turnId"):
1616
+ _stream["activity"] = "waiting for the model"
1617
+ except Exception:
1618
+ pass
1619
+
1534
1620
  ok_a = _reg("pre_print", "mel_assistant_delta", _pre_print_hook)
1535
1621
  ok_b = _reg("post_acting", "mel_assistant_tool", _post_acting_hook)
1622
+ _reg("pre_acting", "mel_assistant_progress", _pre_acting_hook)
1623
+ _reg("post_acting", "mel_assistant_progress", _progress_after_tool)
1536
1624
  _HOOKS_REGISTERED = ok_a or ok_b
1537
1625
  _log(f"stream hooks registered (delta={ok_a} tool={ok_b})")
1538
1626
 
@@ -1575,6 +1663,11 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1575
1663
  _stream["deltaMid"] = ""
1576
1664
  _stream["cancel"] = False # fresh turn — clear any stale STOP
1577
1665
  _stream["usedBrowser"] = False
1666
+ _stream["activity"] = "waiting for the model"
1667
+ _stream["turnStartAt"] = _stream["lastEmitAt"] = time.monotonic()
1668
+ _progress_stop = threading.Event()
1669
+ threading.Thread(target=_progress_watchdog, args=(turn_id, _progress_stop),
1670
+ name="mel-progress", daemon=True).start()
1578
1671
 
1579
1672
  # Reset the per-turn sub-agent delegation budget so the breadth cap is per
1580
1673
  # turn, not per session. Best-effort: never let a stale/absent bundle break a
@@ -1622,7 +1715,10 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1622
1715
  return task.result()
1623
1716
 
1624
1717
  try:
1625
- result = asyncio.run(_go())
1718
+ try:
1719
+ result = asyncio.run(_go())
1720
+ finally:
1721
+ _progress_stop.set()
1626
1722
  except Exception as exc:
1627
1723
  _log("turn error:\n" + traceback.format_exc())
1628
1724
  _stream["turnId"] = ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.79",
3
+ "version": "1.1.80",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",