@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.
- package/dist/assistantHost.py +338 -5
- package/dist/connection.js +23 -0
- package/dist/excomGate.d.ts +32 -0
- package/dist/excomGate.js +100 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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,
|
package/dist/connection.js
CHANGED
|
@@ -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
|
+
}
|