@melaya/runner 1.1.79 → 1.1.81

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
@@ -247,6 +282,7 @@ _DELTA_FLUSH_MS = 35
247
282
 
248
283
  # Sentinel returned by a turn's coroutine when the user pressed STOP.
249
284
  _CANCELLED = object()
285
+ _DEFERRED = object() # EXCOM: the turn ended on an approval request
250
286
 
251
287
 
252
288
  class _RedactingStdout:
@@ -532,7 +568,8 @@ def _apply_turn_update(req) -> None:
532
568
  if not tid or tid != _stream.get("turnId"):
533
569
  return
534
570
  changed = False
535
- if "hitl_mode" in req:
571
+ # An EXCOM turn keeps the server gate: a mode change never re-arms the card.
572
+ if "hitl_mode" in req and not _EXCOM_GATE.get("on"):
536
573
  _apply_hitl_mode(req.get("hitl_mode"))
537
574
  _log(f"autonomy changed mid-turn -> {os.environ.get('MEL_ASSISTANT_HITL_MODE')}")
538
575
  changed = True
@@ -615,6 +652,15 @@ def _autonomy_block() -> str:
615
652
  approval_required result and only THEN pauses. Refreshed every turn so a
616
653
  safe<->autonomous flip takes effect on the next message (the tool gate env is
617
654
  flipped in the same turn by _apply_hitl_mode)."""
655
+ if _EXCOM_GATE.get("on"):
656
+ # EXCOM executive: its autonomy is the server's gate, not this mode.
657
+ return (
658
+ "\n\n## AUTONOMY: decided by the executive gate\n"
659
+ "Every tool call is checked by the server against your autonomy, grants and rules "
660
+ "before it runs. A refused call returns the reason: do not retry it, adjust or propose "
661
+ "it as a task for the humans. A call that needs a human returns approval_pending: "
662
+ "stop there, the turn ends and resumes once a human decides.\n"
663
+ )
618
664
  mode = (os.environ.get("MEL_ASSISTANT_HITL_MODE", "safe") or "safe").lower()
619
665
  if mode == "autonomous":
620
666
  return (
@@ -689,13 +735,195 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
689
735
  """Write one structured event line to stdout (flushed) for the runner to relay."""
690
736
  try:
691
737
  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()
738
+ line = _EVENT_PREFIX + json.dumps(payload, ensure_ascii=False, default=str) + "\n"
739
+ with _EMIT_LOCK:
740
+ sys.stdout.write(line)
741
+ sys.stdout.flush()
742
+ if turn_id:
743
+ _stream["lastEmitAt"] = time.monotonic()
694
744
  except Exception:
695
745
  # Never let event emission crash the turn loop.
696
746
  pass
697
747
 
698
748
 
749
+ # ── EXCOM gate (server-authoritative tool gate) ─────────────────────────────
750
+ # An EXCOM executive whose model runs here gets exactly the gate it has on a
751
+ # cloud model: before EVERY tool call of its turn this host asks the server
752
+ # (stdout frame `excom_gate` {gateId, tool, args} -> runner -> server, which
753
+ # evaluates the turn's own constraint) and waits for the answer on stdin
754
+ # (`excom_gate_reply` {gateId, decision, message, requestId, cached, report}):
755
+ # allow -> run the tool, then report its metadata (`excom_gate_result`
756
+ # {gateId, ok, chars, uncertain}; plus `content` when the server
757
+ # asked for it: vault reads, for the internal-excerpt guard);
758
+ # `cached` -> answer with that text, nothing runs;
759
+ # deny -> the tool does not run; the model sees the server's reason;
760
+ # ask -> the approval card is staged server side; the model sees
761
+ # approval_pending and the turn ends (the job parks on it).
762
+ # Fail closed: no answer within MEL_EXCOM_GATE_TIMEOUT_S (default 30 s), a
763
+ # malformed answer, or a gate that cannot be installed => nothing runs. The
764
+ # runner's own Safe-mode card is not part of it: the server decides.
765
+ _EXCOM_GATE: dict = {
766
+ "on": False, "turnId": "", "installed": False, "deferred": None,
767
+ "lock": threading.Lock(), "waiters": {},
768
+ }
769
+ _EXCOM_GATE_CONTENT_MAX = 64_000
770
+
771
+
772
+ def _excom_gate_timeout() -> float:
773
+ try:
774
+ return max(2.0, min(120.0, float(os.environ.get("MEL_EXCOM_GATE_TIMEOUT_S", "30") or 30)))
775
+ except Exception:
776
+ return 30.0
777
+
778
+
779
+ def _excom_gate_deny(gate_id: str, message: str) -> dict:
780
+ return {"gateId": gate_id, "decision": "deny", "message": message}
781
+
782
+
783
+ def _excom_gate_ask(tool: str, args) -> dict:
784
+ """Blocking: ask the server for a decision on one tool call (worker thread)."""
785
+ gid = _uuid.uuid4().hex
786
+ ev = threading.Event()
787
+ slot = {"ev": ev, "reply": None}
788
+ with _EXCOM_GATE["lock"]:
789
+ _EXCOM_GATE["waiters"][gid] = slot
790
+ try:
791
+ _emit(str(_EXCOM_GATE.get("turnId") or ""), "excom_gate", gateId=gid, tool=str(tool or ""),
792
+ args=args if isinstance(args, dict) else {})
793
+ if not ev.wait(_excom_gate_timeout()):
794
+ return _excom_gate_deny(gid, "The executive gate did not answer in time, so nothing ran. Try again later or propose it as a task for the humans.")
795
+ reply = slot["reply"]
796
+ finally:
797
+ with _EXCOM_GATE["lock"]:
798
+ _EXCOM_GATE["waiters"].pop(gid, None)
799
+ if not isinstance(reply, dict) or reply.get("gateId") != gid or reply.get("decision") not in ("allow", "deny", "ask"):
800
+ return _excom_gate_deny(gid, "The executive gate answered something unreadable, so nothing ran.")
801
+ return reply
802
+
803
+
804
+ def _excom_gate_deliver(reply) -> bool:
805
+ """stdin-reader thread: hand a server answer to the waiting call."""
806
+ if not isinstance(reply, dict):
807
+ return False
808
+ gid = str(reply.get("gateId") or "")
809
+ with _EXCOM_GATE["lock"]:
810
+ slot = _EXCOM_GATE["waiters"].get(gid)
811
+ if not slot:
812
+ return False
813
+ slot["reply"] = reply
814
+ slot["ev"].set()
815
+ return True
816
+
817
+
818
+ def _excom_gate_release_all() -> None:
819
+ """Turn end / channel loss: every waiting call is denied (fail closed)."""
820
+ with _EXCOM_GATE["lock"]:
821
+ slots = list(_EXCOM_GATE["waiters"].items())
822
+ for gid, slot in slots:
823
+ slot["reply"] = _excom_gate_deny(gid, "The executive gate is gone, so nothing ran.")
824
+ slot["ev"].set()
825
+
826
+
827
+ def _excom_gate_report(gate_id: str, ok: bool, text: str, uncertain: bool, with_content: bool) -> None:
828
+ fields = {"gateId": gate_id, "ok": bool(ok), "chars": len(text or ""), "uncertain": bool(uncertain)}
829
+ if with_content and ok:
830
+ fields["content"] = (text or "")[:_EXCOM_GATE_CONTENT_MAX]
831
+ _emit(str(_EXCOM_GATE.get("turnId") or ""), "excom_gate_result", **fields)
832
+
833
+
834
+ def _excom_text_response(text: str):
835
+ from agentscope.tool import ToolResponse
836
+ from agentscope.message import TextBlock
837
+ return ToolResponse(content=[TextBlock(type="text", text=str(text))])
838
+
839
+
840
+ def _excom_response_text(resp) -> str:
841
+ try:
842
+ content = getattr(resp, "content", None) or []
843
+ parts = []
844
+ for b in content:
845
+ t = b.get("text") if isinstance(b, dict) else getattr(b, "text", None)
846
+ if isinstance(t, str):
847
+ parts.append(t)
848
+ return "\n".join(parts)
849
+ except Exception:
850
+ return ""
851
+
852
+
853
+ def _excom_looks_failed(text: str) -> bool:
854
+ head = (text or "").lstrip()[:80].lower()
855
+ return head.startswith(("error", "could not", "failed", "functionnotfounderror", "tool error"))
856
+
857
+
858
+ async def _excom_gate_middleware(kwargs, next_handler):
859
+ """agentscope Toolkit middleware: the gate around every tool call."""
860
+ if not _EXCOM_GATE.get("on"):
861
+ async for chunk in next_handler(**kwargs):
862
+ yield chunk
863
+ return
864
+ import asyncio
865
+ tc = kwargs.get("tool_call") or {}
866
+ name = str((tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")) or "")
867
+ args = (tc.get("input") if isinstance(tc, dict) else getattr(tc, "input", None)) or {}
868
+ if _EXCOM_GATE.get("deferred"):
869
+ yield _excom_text_response("Not run: this turn is waiting for a human approval and ends now.")
870
+ return
871
+ reply = await asyncio.to_thread(_excom_gate_ask, name, args)
872
+ decision = reply.get("decision")
873
+ if decision == "ask":
874
+ yield _excom_text_response(str(reply.get("message") or json.dumps({"status": "approval_pending", "requestId": reply.get("requestId"), "tool": name})))
875
+ # The turn ends after this round, like the cloud loop (the job parks).
876
+ _EXCOM_GATE["deferred"] = {"requestId": str(reply.get("requestId") or ""), "tool": name}
877
+ return
878
+ if decision != "allow":
879
+ yield _excom_text_response(str(reply.get("message") or "Outside this executive's autonomy. Nothing ran."))
880
+ return
881
+ if isinstance(reply.get("cached"), str):
882
+ yield _excom_text_response(reply["cached"])
883
+ return
884
+ gid = str(reply.get("gateId") or "")
885
+ is_write = reply.get("isWrite") is True
886
+ last = None
887
+ try:
888
+ async for chunk in next_handler(**kwargs):
889
+ last = chunk
890
+ yield chunk
891
+ except BaseException:
892
+ # Cancelled, timed out or raised: a write may have happened.
893
+ _excom_gate_report(gid, False, "", is_write, False)
894
+ raise
895
+ text = _excom_response_text(last)
896
+ failed = _excom_looks_failed(text)
897
+ _excom_gate_report(gid, not failed, text, failed and is_write and "timeout" in text.lower(), reply.get("report") == "content")
898
+
899
+
900
+ def _install_excom_gate(toolkit) -> bool:
901
+ try:
902
+ if toolkit is None or not hasattr(toolkit, "register_middleware"):
903
+ return False
904
+ toolkit.register_middleware(_excom_gate_middleware)
905
+ return True
906
+ except Exception as exc:
907
+ _log(f"excom gate middleware unavailable: {type(exc).__name__}")
908
+ return False
909
+
910
+
911
+ def _excom_gate_begin(turn_id: str) -> None:
912
+ _EXCOM_GATE["on"] = True
913
+ _EXCOM_GATE["turnId"] = turn_id
914
+ _EXCOM_GATE["deferred"] = None
915
+ # The server decides every call: the host's own Safe-mode card stays out
916
+ # (lazy_registry reads the mode at call time).
917
+ _apply_hitl_mode("autonomous")
918
+
919
+
920
+ def _excom_gate_end() -> None:
921
+ _EXCOM_GATE["on"] = False
922
+ _EXCOM_GATE["turnId"] = ""
923
+ _EXCOM_GATE["deferred"] = None
924
+ _excom_gate_release_all()
925
+
926
+
699
927
  # ── Output files (assistant protocol 4) ──────────────────────────────────────
700
928
  # A turn frame may carry `output_dir`: a fresh folder the runner made for THIS
701
929
  # turn. It becomes MELAYA_OUTPUT_DIR for the turn, so every file-producing tool
@@ -761,6 +989,24 @@ def _emit_turn_files(turn_id: str) -> int:
761
989
  return n
762
990
 
763
991
 
992
+ def _last_tool_input_size(content) -> "tuple[str, int]":
993
+ """(name, characters of its arguments so far) of the last tool call in a
994
+ streamed message, so a long silent argument write can be reported."""
995
+ blocks = content if isinstance(content, (list, tuple)) else [content]
996
+ for b in reversed(list(blocks)):
997
+ btype = b.get("type") if isinstance(b, dict) else getattr(b, "type", None)
998
+ if btype != "tool_use":
999
+ continue
1000
+ name = b.get("name") if isinstance(b, dict) else getattr(b, "name", "")
1001
+ args = b.get("input") if isinstance(b, dict) else getattr(b, "input", None)
1002
+ try:
1003
+ size = len(args) if isinstance(args, str) else len(json.dumps(args or {}, default=str))
1004
+ except Exception:
1005
+ size = 0
1006
+ return str(name or ""), size
1007
+ return "", 0
1008
+
1009
+
764
1010
  def _split_stream_blocks(content):
765
1011
  """Split a streamed message's content into (answer_text, thinking_text,
766
1012
  [(tool_id, tool_name)]).
@@ -1039,6 +1285,11 @@ def _build_agent():
1039
1285
  except Exception:
1040
1286
  toolkit = build_toolkit(names=[])
1041
1287
 
1288
+ # EXCOM gate: the server decides every tool of an executive's turn. The
1289
+ # middleware is a no-op on every other turn; without it an EXCOM turn is
1290
+ # refused (fail closed, see the turn loop in main()).
1291
+ _EXCOM_GATE["installed"] = _install_excom_gate(toolkit)
1292
+
1042
1293
  phone_rule = (
1043
1294
  "- If the user asks you to DO something on their phone (open an app, browse, "
1044
1295
  "tap, type, comment, post), use the phone_* tools. ALWAYS call "
@@ -1430,6 +1681,16 @@ def _register_stream_hooks(agent) -> None:
1430
1681
  return
1431
1682
  mid = str(getattr(msg, "id", "") or id(msg))
1432
1683
  text, thinking, tool_uses = _split_stream_blocks(getattr(msg, "content", msg))
1684
+ # What the progress watchdog reports if the turn goes quiet.
1685
+ if tool_uses:
1686
+ _tname_now, _tchars = _last_tool_input_size(getattr(msg, "content", msg))
1687
+ if _tname_now:
1688
+ _stream["activity"] = (f"writing the {_tname_now} call ({_tchars // 1024} KB so far)"
1689
+ if _tchars >= 1024 else f"writing the {_tname_now} call")
1690
+ elif text:
1691
+ _stream["activity"] = "writing the answer"
1692
+ elif thinking:
1693
+ _stream["activity"] = "thinking"
1433
1694
  # Reasoning (Codex reasoning summaries, Claude thinking) streams to the
1434
1695
  # thinking strip as `think`, never into the answer bubble.
1435
1696
  if thinking:
@@ -1531,8 +1792,29 @@ def _register_stream_hooks(agent) -> None:
1531
1792
  except Exception:
1532
1793
  return False
1533
1794
 
1795
+ def _pre_acting_hook(_self, kw, *_rest) -> None:
1796
+ # Progress only: which tool is running now (no event of its own).
1797
+ try:
1798
+ if getattr(_self, "name", "") != "Assistant" or not _stream.get("turnId") or not isinstance(kw, dict):
1799
+ return
1800
+ tc = kw.get("tool_call") or {}
1801
+ name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
1802
+ if name:
1803
+ _stream["activity"] = f"running {name}"
1804
+ except Exception:
1805
+ pass
1806
+
1807
+ def _progress_after_tool(_self, kw, *_rest) -> None:
1808
+ try:
1809
+ if getattr(_self, "name", "") == "Assistant" and _stream.get("turnId"):
1810
+ _stream["activity"] = "waiting for the model"
1811
+ except Exception:
1812
+ pass
1813
+
1534
1814
  ok_a = _reg("pre_print", "mel_assistant_delta", _pre_print_hook)
1535
1815
  ok_b = _reg("post_acting", "mel_assistant_tool", _post_acting_hook)
1816
+ _reg("pre_acting", "mel_assistant_progress", _pre_acting_hook)
1817
+ _reg("post_acting", "mel_assistant_progress", _progress_after_tool)
1536
1818
  _HOOKS_REGISTERED = ok_a or ok_b
1537
1819
  _log(f"stream hooks registered (delta={ok_a} tool={ok_b})")
1538
1820
 
@@ -1575,6 +1857,11 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1575
1857
  _stream["deltaMid"] = ""
1576
1858
  _stream["cancel"] = False # fresh turn — clear any stale STOP
1577
1859
  _stream["usedBrowser"] = False
1860
+ _stream["activity"] = "waiting for the model"
1861
+ _stream["turnStartAt"] = _stream["lastEmitAt"] = time.monotonic()
1862
+ _progress_stop = threading.Event()
1863
+ threading.Thread(target=_progress_watchdog, args=(turn_id, _progress_stop),
1864
+ name="mel-progress", daemon=True).start()
1578
1865
 
1579
1866
  # Reset the per-turn sub-agent delegation budget so the breadth cap is per
1580
1867
  # turn, not per session. Best-effort: never let a stale/absent bundle break a
@@ -1611,6 +1898,15 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1611
1898
  except BaseException: # CancelledError + any teardown error
1612
1899
  pass
1613
1900
  return _CANCELLED
1901
+ # EXCOM: a call went to a human approval; the turn ends after
1902
+ # this round (the job parks), like the cloud loop.
1903
+ if _EXCOM_GATE.get("on") and _EXCOM_GATE.get("deferred"):
1904
+ task.cancel()
1905
+ try:
1906
+ await task
1907
+ except BaseException:
1908
+ pass
1909
+ return _DEFERRED
1614
1910
  if wallclock_s and time.time() - started_at > wallclock_s:
1615
1911
  task.cancel()
1616
1912
  try:
@@ -1622,7 +1918,10 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1622
1918
  return task.result()
1623
1919
 
1624
1920
  try:
1625
- result = asyncio.run(_go())
1921
+ try:
1922
+ result = asyncio.run(_go())
1923
+ finally:
1924
+ _progress_stop.set()
1626
1925
  except Exception as exc:
1627
1926
  _log("turn error:\n" + traceback.format_exc())
1628
1927
  _stream["turnId"] = ""
@@ -1646,6 +1945,16 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1646
1945
  _emit(turn_id, "done")
1647
1946
  return
1648
1947
 
1948
+ if result is _DEFERRED:
1949
+ _flush_delta(turn_id)
1950
+ _stream["turnId"] = ""
1951
+ pend = _EXCOM_GATE.get("deferred") or {}
1952
+ _log("turn ends on an EXCOM approval request")
1953
+ _emit(turn_id, "text", content=f"Waiting for a human approval of {pend.get('tool') or 'an action'}. The work continues once it is decided.")
1954
+ _emit_turn_files(turn_id)
1955
+ _emit(turn_id, "done")
1956
+ return
1957
+
1649
1958
  if result is _WALLCLOCK:
1650
1959
  _stream["turnId"] = ""
1651
1960
  _log("browser turn wall-clock budget exceeded")
@@ -1693,6 +2002,11 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1693
2002
  if not tid or tid == _stream.get("turnId"):
1694
2003
  _stream["cancel"] = True
1695
2004
  continue # consumed — do not queue as a turn
2005
+ # EXCOM gate answer: wakes the tool call waiting on it, WHILE
2006
+ # the turn runs. Never queued as a turn.
2007
+ if isinstance(req, dict) and req.get("kind") == "excom_gate_reply":
2008
+ _excom_gate_deliver(req)
2009
+ continue
1696
2010
  # Live mid-turn update (autonomy mode / connectors): applied HERE,
1697
2011
  # while the turn runs, like STOP. Never queued as a turn.
1698
2012
  if isinstance(req, dict) and req.get("kind") == "turn_update":
@@ -1705,6 +2019,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1705
2019
  except Exception:
1706
2020
  pass
1707
2021
  finally:
2022
+ _excom_gate_release_all() # no answer can come any more: deny (fail closed)
1708
2023
  q.put("") # sentinel: stdin closed
1709
2024
 
1710
2025
 
@@ -1862,6 +2177,20 @@ def main() -> int:
1862
2177
  # it here (before _run_turn) is enough — no agent rebuild needed.
1863
2178
  if "hitl_mode" in req:
1864
2179
  _apply_hitl_mode(req.get("hitl_mode"))
2180
+ # EXCOM gate: the server decides every tool of this turn. A host that
2181
+ # cannot install the gate refuses the turn (fail closed).
2182
+ excom_gate = bool(req.get("excom_gate"))
2183
+ if excom_gate and not _EXCOM_GATE.get("installed"):
2184
+ _log("excom gate requested but unavailable: turn refused")
2185
+ _emit(turn_id, "error", code="runner_constraint_unsupported", message=(
2186
+ "This runner cannot enforce the executive's autonomy gate, so nothing ran. "
2187
+ "Update the runner, or pick a cloud model for this executive."))
2188
+ _emit(turn_id, "done")
2189
+ continue
2190
+ if excom_gate:
2191
+ _excom_gate_begin(turn_id)
2192
+ else:
2193
+ _EXCOM_GATE["on"] = False
1865
2194
  # Product usage attribution is per turn. This must be applied before the
1866
2195
  # AgentScope spans are created; _emit_usage force-flushes them before the
1867
2196
  # next turn can replace the value.
@@ -1878,6 +2207,8 @@ def main() -> int:
1878
2207
  _apply_static_context(agent, base_sys_prompt, req.get("static_context"))
1879
2208
  if not message and not images:
1880
2209
  _purge_connector_env()
2210
+ if excom_gate:
2211
+ _excom_gate_end()
1881
2212
  _emit(turn_id, "done")
1882
2213
  continue
1883
2214
  # P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
@@ -1893,6 +2224,8 @@ def main() -> int:
1893
2224
  try:
1894
2225
  _run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
1895
2226
  finally:
2227
+ if excom_gate:
2228
+ _excom_gate_end()
1896
2229
  _clear_turn_output_dir()
1897
2230
  _purge_browser_turn_grant()
1898
2231
  # Connector credentials live for the turn only; the selection (ids,
@@ -57,6 +57,7 @@ import { reportHostFile, reportOutputsSince } from "./hostFiles.js";
57
57
  // Computer control: computer:command / computer:stop relayed to the desktop app
58
58
  // executor (or the CLI computer host); capabilities reported on connect.
59
59
  import { attachComputerRelay } from "./computerRelay.js";
60
+ import { EXCOM_GATE_VERSION, relayExcomGate, relayExcomGateResult } from "./excomGate.js";
60
61
  // Model rediscovery while connected (audit P2.3): every 15 min, and skipped
61
62
  // when the provider fingerprint (credential/cache file mtimes + local server
62
63
  // ports) is unchanged since the last scan, unless that scan is older than
@@ -613,6 +614,11 @@ export async function connect(opts) {
613
614
  package: "@melaya/runner",
614
615
  version: RUNNER_VERSION,
615
616
  assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
617
+ // EXCOM gate (excomGate.ts): this runner's host asks the server before
618
+ // every tool of an EXCOM executive's turn and fails closed without an
619
+ // answer. The server allows Prepare, Run and vault writes on a runner
620
+ // model only when this is advertised.
621
+ excomGate: EXCOM_GATE_VERSION,
616
622
  // Browser control capability report (plan Section 4).
617
623
  browserControl: {
618
624
  version: BROWSER_CONTROL_VERSION,
@@ -1848,6 +1854,21 @@ export async function connect(opts) {
1848
1854
  }
1849
1855
  continue;
1850
1856
  }
1857
+ // EXCOM gate frames (excomGate.ts): answered here, never forwarded
1858
+ // as assistant events. A host frame from a spare (no session) is denied.
1859
+ if (parsed?.kind === "excom_gate") {
1860
+ const write = (l) => { proc.stdin?.write(l); };
1861
+ if (session.forward)
1862
+ void relayExcomGate(parsed, { sessionId: session.sid, socket, write });
1863
+ else
1864
+ write(JSON.stringify({ kind: "excom_gate_reply", gateId: String(parsed.gateId || ""), decision: "deny", message: "No executive session on this host, so nothing ran." }) + "\n");
1865
+ continue;
1866
+ }
1867
+ if (parsed?.kind === "excom_gate_result") {
1868
+ if (session.forward)
1869
+ relayExcomGateResult(parsed, { sessionId: session.sid, socket });
1870
+ continue;
1871
+ }
1851
1872
  if (parsed?.kind === "done" && typeof parsed.turnId === "string")
1852
1873
  session.turnFiles?.delete(parsed.turnId);
1853
1874
  if (typeof parsed?.memoryWatermark === "number")
@@ -2574,6 +2595,8 @@ export async function connect(opts) {
2574
2595
  // v4: the turn's output dir (absent: the host keeps its boot-time dir
2575
2596
  // and reports no files).
2576
2597
  ...(turnOutDir ? { output_dir: turnOutDir } : {}),
2598
+ // EXCOM gate: the host asks before every tool and fails closed.
2599
+ ...(Number(payload.excomGate) >= 1 ? { excom_gate: 1 } : {}),
2577
2600
  }) + "\n");
2578
2601
  }
2579
2602
  catch (e) {
@@ -0,0 +1,32 @@
1
+ /** Advertised as `excomGate` in runner:hello (server EXCOM_GATE_VERSION). */
2
+ export declare const EXCOM_GATE_VERSION = 1;
3
+ /** Below the host's own wait (MEL_EXCOM_GATE_TIMEOUT_S, 30 s), so the host gets an explicit deny. */
4
+ export declare const EXCOM_GATE_ACK_MS = 25000;
5
+ export interface GateSocketLike {
6
+ connected?: boolean;
7
+ emit(event: string, payload: unknown, ack?: (reply: unknown) => void): unknown;
8
+ }
9
+ export interface GateRelayCtx {
10
+ sessionId: string;
11
+ socket: GateSocketLike;
12
+ /** Writes one line to the host's stdin. */
13
+ write: (line: string) => void;
14
+ timeoutMs?: number;
15
+ }
16
+ export type GateDecision = "allow" | "deny" | "ask";
17
+ export interface GateReplyFrame {
18
+ kind: "excom_gate_reply";
19
+ gateId: string;
20
+ decision: GateDecision;
21
+ message?: string;
22
+ requestId?: string;
23
+ cached?: string;
24
+ report?: "meta" | "content";
25
+ isWrite?: boolean;
26
+ }
27
+ /** The reply the host gets: only known fields, any doubt is a deny. */
28
+ export declare function sanitizeGateReply(gateId: string, raw: unknown): GateReplyFrame;
29
+ /** One `excom_gate` frame from the host: ask the server, answer the host once. */
30
+ export declare function relayExcomGate(frame: Record<string, unknown>, ctx: GateRelayCtx): Promise<GateReplyFrame>;
31
+ /** One `excom_gate_result` frame from the host: metadata to the server (best-effort). */
32
+ export declare function relayExcomGateResult(frame: Record<string, unknown>, ctx: Pick<GateRelayCtx, "sessionId" | "socket">): boolean;
@@ -0,0 +1,100 @@
1
+ // excomGate.ts — the runner half of the EXCOM gate (server:
2
+ // services/excom/excomRunnerGate.ts, host: assistantHost.py "EXCOM gate").
3
+ //
4
+ // An EXCOM executive's turn runs on this computer only when the server can
5
+ // decide every tool call. The host writes `excom_gate` {gateId, tool, args} on
6
+ // stdout and blocks that call; this relay asks the server
7
+ // (`runner:excom_gate`, answered on the ack) and writes the answer back on the
8
+ // host's stdin (`excom_gate_reply`). After an allowed call the host writes
9
+ // `excom_gate_result` (metadata, plus the text of a vault read), relayed as
10
+ // `runner:excom_gate_result`.
11
+ //
12
+ // Fail closed: a malformed frame, a disconnected socket, an error, no answer
13
+ // within EXCOM_GATE_ACK_MS or a malformed answer all become `deny`. Gate
14
+ // frames are never forwarded as assistant events and argument values are
15
+ // never logged.
16
+ /** Advertised as `excomGate` in runner:hello (server EXCOM_GATE_VERSION). */
17
+ export const EXCOM_GATE_VERSION = 1;
18
+ /** Below the host's own wait (MEL_EXCOM_GATE_TIMEOUT_S, 30 s), so the host gets an explicit deny. */
19
+ export const EXCOM_GATE_ACK_MS = 25_000;
20
+ const CONTENT_MAX = 64_000;
21
+ const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
22
+ /** The reply the host gets: only known fields, any doubt is a deny. */
23
+ export function sanitizeGateReply(gateId, raw) {
24
+ const r = (raw && typeof raw === "object" ? raw : {});
25
+ const decision = r.decision;
26
+ if (r.gateId !== gateId || (decision !== "allow" && decision !== "deny" && decision !== "ask")) {
27
+ return { kind: "excom_gate_reply", gateId, decision: "deny", message: "The executive gate answered something unreadable, so nothing ran." };
28
+ }
29
+ const out = { kind: "excom_gate_reply", gateId, decision };
30
+ if (typeof r.message === "string")
31
+ out.message = r.message.slice(0, 4_000);
32
+ if (typeof r.requestId === "string")
33
+ out.requestId = r.requestId.slice(0, 120);
34
+ if (decision === "allow" && typeof r.cached === "string")
35
+ out.cached = r.cached;
36
+ if (r.report === "content" || r.report === "meta")
37
+ out.report = r.report;
38
+ if (typeof r.isWrite === "boolean")
39
+ out.isWrite = r.isWrite;
40
+ return out;
41
+ }
42
+ const denyFrame = (gateId, message) => ({ kind: "excom_gate_reply", gateId, decision: "deny", message });
43
+ /** One `excom_gate` frame from the host: ask the server, answer the host once. */
44
+ export function relayExcomGate(frame, ctx) {
45
+ const gateId = str(frame?.gateId, 80);
46
+ const turnId = str(frame?.turnId, 200);
47
+ const tool = str(frame?.tool, 200);
48
+ const args = frame?.args && typeof frame.args === "object" && !Array.isArray(frame.args) ? frame.args : {};
49
+ return new Promise((resolve) => {
50
+ let done = false;
51
+ let timer;
52
+ const answer = (reply) => {
53
+ if (done)
54
+ return;
55
+ done = true;
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ try {
59
+ ctx.write(JSON.stringify(reply) + "\n");
60
+ }
61
+ catch { /* the host is gone: its own wait denies */ }
62
+ resolve(reply);
63
+ };
64
+ if (!gateId || !tool || !turnId) {
65
+ answer(denyFrame(gateId, "The executive gate refused a malformed call. Nothing ran."));
66
+ return;
67
+ }
68
+ if (ctx.socket.connected === false) {
69
+ answer(denyFrame(gateId, "The executive gate is not reachable (the runner is offline), so nothing ran."));
70
+ return;
71
+ }
72
+ timer = setTimeout(() => answer(denyFrame(gateId, "The executive gate did not answer in time, so nothing ran.")), ctx.timeoutMs ?? EXCOM_GATE_ACK_MS);
73
+ try {
74
+ ctx.socket.emit("runner:excom_gate", { sessionId: ctx.sessionId, turnId, gateId, tool, args }, (reply) => answer(sanitizeGateReply(gateId, reply)));
75
+ }
76
+ catch {
77
+ answer(denyFrame(gateId, "The executive gate failed on this call, so nothing ran."));
78
+ }
79
+ });
80
+ }
81
+ /** One `excom_gate_result` frame from the host: metadata to the server (best-effort). */
82
+ export function relayExcomGateResult(frame, ctx) {
83
+ const gateId = str(frame?.gateId, 80);
84
+ const turnId = str(frame?.turnId, 200);
85
+ if (!gateId || !turnId || ctx.socket.connected === false)
86
+ return false;
87
+ const payload = {
88
+ sessionId: ctx.sessionId, turnId, gateId, ok: frame.ok === true,
89
+ chars: Math.max(0, Math.trunc(Number(frame.chars) || 0)), uncertain: frame.uncertain === true,
90
+ };
91
+ if (typeof frame.content === "string")
92
+ payload.content = frame.content.slice(0, CONTENT_MAX);
93
+ try {
94
+ ctx.socket.emit("runner:excom_gate_result", payload);
95
+ return true;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.79",
3
+ "version": "1.1.81",
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",