@melaya/runner 1.1.60 → 1.1.61
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 +258 -37
- package/dist/connection.js +91 -11
- package/dist/sharedVendor.js +33 -1
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -172,10 +172,22 @@ def _config_hash() -> str:
|
|
|
172
172
|
fields.append(pinned_tools)
|
|
173
173
|
if os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing":
|
|
174
174
|
fields.append("marketing-scoped-tools-v1")
|
|
175
|
+
# Per-turn connectors (assistant protocol 3): the connectors field above is
|
|
176
|
+
# empty (MEL_ASSISTANT_CONNECTORS is not set at boot) and this marker comes
|
|
177
|
+
# LAST, byte-for-byte like runnerAssistantRelay.ts assistantConfigHash. A
|
|
178
|
+
# connector change is then never a config change: no reboot.
|
|
179
|
+
if _turn_connectors_mode():
|
|
180
|
+
fields.append("turn-connectors-v1")
|
|
175
181
|
canon = "|".join(fields)
|
|
176
182
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
|
|
177
183
|
|
|
178
184
|
|
|
185
|
+
def _turn_connectors_mode() -> bool:
|
|
186
|
+
"""Assistant protocol 3: the chat's connectors arrive on every turn frame
|
|
187
|
+
(and mid-turn updates), not in the boot env. Set by the runner spawn env."""
|
|
188
|
+
return (os.environ.get("MEL_ASSISTANT_TURN_CONNECTORS", "") or "") == "1"
|
|
189
|
+
|
|
190
|
+
|
|
179
191
|
def _browser_capable() -> bool:
|
|
180
192
|
"""Browser CAPABILITY presence for this host (plan 0.4): set by the runner
|
|
181
193
|
spawn env when the paired runner advertises browser control. Capability
|
|
@@ -368,6 +380,189 @@ def _purge_browser_turn_grant() -> None:
|
|
|
368
380
|
os.environ.pop(k, None)
|
|
369
381
|
|
|
370
382
|
|
|
383
|
+
# ── Per-turn connectors (assistant protocol 3) ───────────────────────────────
|
|
384
|
+
# The chat's connector selection is a PER-TURN parameter, like hitl_mode: the
|
|
385
|
+
# turn frame carries {"connectors": [registry categories], "connector_env":
|
|
386
|
+
# {service: {ENV: value}}}. Before the turn, the selection becomes the lazy
|
|
387
|
+
# registry's searchable connector scope (set_lazy_connectors: deselected
|
|
388
|
+
# services' tools leave the pool AND the toolkit) and the env is installed for
|
|
389
|
+
# the turn. The env is PURGED at turn end (like the browser grant): between
|
|
390
|
+
# turns the warm host keeps only the selection (service ids, no secret). A
|
|
391
|
+
# mid-turn update (runner:assistant_turn_update, or a connector turned on by
|
|
392
|
+
# melaya_agent_request_connector_enable) goes through the same functions, under
|
|
393
|
+
# a lock, so the NEXT tool call of the running turn sees it.
|
|
394
|
+
#
|
|
395
|
+
# Never written here: the host's own identity / transport env. A connector env
|
|
396
|
+
# key that collides with a boot env value is restored to that value on purge.
|
|
397
|
+
_RESERVED_ENV = frozenset({
|
|
398
|
+
"MELAYA_API_KEY", "MELAYA_API_BASE", "MELAYA_API_URL", "_MEL_USER_ID", "PATH",
|
|
399
|
+
"PYTHONPATH", "PYTHONHOME", "PYTHONIOENCODING", "PYTHONUTF8", "SSL_CERT_FILE",
|
|
400
|
+
"REQUESTS_CA_BUNDLE", "LAYA_LOCAL_URL", "LMSTUDIO_BASE_URL", "OLLAMA_BASE_URL",
|
|
401
|
+
})
|
|
402
|
+
_ALLOWED_MEL_ENV = frozenset({"MEL_LUMA_BROWSER_URL", "MEL_LUMA_BROWSER_TOKEN"})
|
|
403
|
+
_LIVE = {
|
|
404
|
+
"lock": threading.RLock(),
|
|
405
|
+
"toolkit": None, # the lazy toolkit (set_lazy_connectors target)
|
|
406
|
+
"connectors": [], # current selection (registry categories)
|
|
407
|
+
"envKeys": set(), # connector env keys installed for this turn
|
|
408
|
+
"envOrig": {}, # key -> boot value (None = absent) to restore
|
|
409
|
+
"agent": None, "basePrompt": "", "staticContext": None, # mid-turn prompt refresh
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _connector_env_key_ok(k: str) -> bool:
|
|
414
|
+
if not k or not isinstance(k, str) or k in _RESERVED_ENV:
|
|
415
|
+
return False
|
|
416
|
+
if k.startswith("MEL_") or k.startswith("MELAYA_"):
|
|
417
|
+
return k in _ALLOWED_MEL_ENV
|
|
418
|
+
return True
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _install_connector_env(env_by_service, replace: bool) -> None:
|
|
422
|
+
"""Install connector env for the turn. replace=True drops keys no longer
|
|
423
|
+
provided (a deselected service); False only adds (one service turned on)."""
|
|
424
|
+
new: dict = {}
|
|
425
|
+
if isinstance(env_by_service, dict):
|
|
426
|
+
for _svc, kv in env_by_service.items():
|
|
427
|
+
if isinstance(kv, dict):
|
|
428
|
+
for k, v in kv.items():
|
|
429
|
+
if isinstance(v, str) and _connector_env_key_ok(str(k)):
|
|
430
|
+
new[str(k)] = v
|
|
431
|
+
with _LIVE["lock"]:
|
|
432
|
+
keys: set = _LIVE["envKeys"]
|
|
433
|
+
orig: dict = _LIVE["envOrig"]
|
|
434
|
+
if replace:
|
|
435
|
+
for k in list(keys - set(new)):
|
|
436
|
+
_restore_env_key(k)
|
|
437
|
+
keys.discard(k)
|
|
438
|
+
for k, v in new.items():
|
|
439
|
+
if k not in orig:
|
|
440
|
+
orig[k] = os.environ.get(k)
|
|
441
|
+
os.environ[k] = v
|
|
442
|
+
keys.add(k)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _restore_env_key(k: str) -> None:
|
|
446
|
+
orig = _LIVE["envOrig"].pop(k, None)
|
|
447
|
+
if orig is None:
|
|
448
|
+
os.environ.pop(k, None)
|
|
449
|
+
else:
|
|
450
|
+
os.environ[k] = orig
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _purge_connector_env() -> None:
|
|
454
|
+
"""Turn end: remove every connector credential from the warm host env."""
|
|
455
|
+
with _LIVE["lock"]:
|
|
456
|
+
for k in list(_LIVE["envKeys"]):
|
|
457
|
+
_restore_env_key(k)
|
|
458
|
+
_LIVE["envKeys"] = set()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _apply_turn_connectors(services, env_by_service, add_only: bool = False) -> bool:
|
|
462
|
+
"""Make `services` the connector scope (add_only: add to it) and install
|
|
463
|
+
their env. Arms the write-approval gate while any connector is selected
|
|
464
|
+
(lazy_registry decides at call time in this mode). Returns True on success."""
|
|
465
|
+
svcs = [str(x).strip().lower() for x in (services or []) if isinstance(x, str) and str(x).strip()]
|
|
466
|
+
with _LIVE["lock"]:
|
|
467
|
+
toolkit = _LIVE.get("toolkit")
|
|
468
|
+
try:
|
|
469
|
+
from shared.orchestration.lazy_registry import set_lazy_connectors
|
|
470
|
+
res = set_lazy_connectors(toolkit, svcs, add_only=add_only) if toolkit is not None else {"ok": False}
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
_log(f"connector scope update failed: {type(exc).__name__}")
|
|
473
|
+
res = {"ok": False}
|
|
474
|
+
current = sorted(set(_LIVE["connectors"]) | set(svcs)) if add_only else sorted(set(svcs))
|
|
475
|
+
_LIVE["connectors"] = current
|
|
476
|
+
_install_connector_env(env_by_service, replace=not add_only)
|
|
477
|
+
if current:
|
|
478
|
+
os.environ["MEL_ASSISTANT_CONNECTOR_HITL"] = "1"
|
|
479
|
+
else:
|
|
480
|
+
os.environ.pop("MEL_ASSISTANT_CONNECTOR_HITL", None)
|
|
481
|
+
if res.get("added") or res.get("removed"):
|
|
482
|
+
# Service ids only, never env.
|
|
483
|
+
_log(f"connectors updated (+{','.join(res.get('added') or []) or '-'} -{','.join(res.get('removed') or []) or '-'})")
|
|
484
|
+
return bool(res.get("ok"))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _apply_live_connector_activation(services, env_by_service) -> bool:
|
|
488
|
+
"""Applier registered with shared.runtime.assistant_live: a connector turned
|
|
489
|
+
on mid-turn (melaya_agent_request_connector_enable). Adds to the scope."""
|
|
490
|
+
if not _turn_connectors_mode() or not _stream.get("turnId"):
|
|
491
|
+
return False
|
|
492
|
+
ok = _apply_turn_connectors(services, env_by_service, add_only=True)
|
|
493
|
+
_refresh_live_prompt()
|
|
494
|
+
return ok
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _refresh_live_prompt() -> None:
|
|
498
|
+
"""Re-render the running turn's system prompt (autonomy + connectors) after a
|
|
499
|
+
mid-turn change; agentscope rebuilds the system message from it each step."""
|
|
500
|
+
agent = _LIVE.get("agent")
|
|
501
|
+
if agent is not None:
|
|
502
|
+
_apply_static_context(agent, _LIVE.get("basePrompt") or "", _LIVE.get("staticContext"))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _apply_turn_update(req) -> None:
|
|
506
|
+
"""runner:assistant_turn_update, on the stdin-reader thread WHILE the turn
|
|
507
|
+
runs: the user changed the autonomy mode and/or the connectors mid-reply.
|
|
508
|
+
Applies only to the turn in flight; the next tool call reads it. A pending
|
|
509
|
+
approval already polling is not affected (the gate decides at call start)."""
|
|
510
|
+
tid = str(req.get("turnId") or "")
|
|
511
|
+
if not tid or tid != _stream.get("turnId"):
|
|
512
|
+
return
|
|
513
|
+
changed = False
|
|
514
|
+
if "hitl_mode" in req:
|
|
515
|
+
_apply_hitl_mode(req.get("hitl_mode"))
|
|
516
|
+
_log(f"autonomy changed mid-turn -> {os.environ.get('MEL_ASSISTANT_HITL_MODE')}")
|
|
517
|
+
changed = True
|
|
518
|
+
if _turn_connectors_mode() and isinstance(req.get("connectors"), list):
|
|
519
|
+
_apply_turn_connectors(req.get("connectors"), req.get("connector_env") or {})
|
|
520
|
+
changed = True
|
|
521
|
+
if changed:
|
|
522
|
+
_refresh_live_prompt()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _connector_rule(connector_services, pinned_tool_names) -> str:
|
|
526
|
+
"""Prompt rules for the selected connectors (or the pinned tool set)."""
|
|
527
|
+
if pinned_tool_names:
|
|
528
|
+
return (
|
|
529
|
+
"- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
|
|
530
|
+
"Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
|
|
531
|
+
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
532
|
+
"call the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
533
|
+
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
534
|
+
)
|
|
535
|
+
if not connector_services:
|
|
536
|
+
return ""
|
|
537
|
+
return (
|
|
538
|
+
"- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
|
|
539
|
+
"These are the ONLY external systems available RIGHT NOW. This overrides the "
|
|
540
|
+
"conversation history: if earlier in this chat you used a DIFFERENT connector "
|
|
541
|
+
"(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
|
|
542
|
+
"NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
|
|
543
|
+
"that system (e.g. do not look for another ERP's model/method names here).\n"
|
|
544
|
+
"- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
|
|
545
|
+
"BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
|
|
546
|
+
"- never another system's internal API names - then activate_tool(name=...) ONCE, then "
|
|
547
|
+
"call it. The results ARE the available tools: pick the closest match and USE it; do "
|
|
548
|
+
"NOT keep re-searching for a tool from a different system. If two searches for the same "
|
|
549
|
+
"need return the same kind of tool, STOP and activate it.\n"
|
|
550
|
+
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
551
|
+
"activate the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
552
|
+
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _live_connector_block() -> str:
|
|
557
|
+
"""Per-turn connector rules (protocol 3 only; legacy hosts bake them in)."""
|
|
558
|
+
if not _turn_connectors_mode():
|
|
559
|
+
return ""
|
|
560
|
+
if os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").strip():
|
|
561
|
+
return "" # pinned sessions keep their boot-time rule
|
|
562
|
+
rule = _connector_rule(list(_LIVE.get("connectors") or []), [])
|
|
563
|
+
return ("\n\n## Connectors for this turn\n" + rule) if rule else ""
|
|
564
|
+
|
|
565
|
+
|
|
371
566
|
def _apply_hitl_mode(mode: str | None) -> None:
|
|
372
567
|
"""Set the assistant autonomy mode env AND mirror it into MEL_HITL_MODE.
|
|
373
568
|
|
|
@@ -444,6 +639,14 @@ def _apply_static_context(agent, base_prompt: str, ctx) -> None:
|
|
|
444
639
|
still govern."""
|
|
445
640
|
text = (str(ctx or "")).strip()
|
|
446
641
|
autonomy = _autonomy_block()
|
|
642
|
+
# Long context is paged (shared/runtime/context_library.py, same rules as a
|
|
643
|
+
# pipeline's Docs): its outline goes in the prompt and the agent reads it with
|
|
644
|
+
# context_read / context_search instead of carrying all of it every turn.
|
|
645
|
+
try:
|
|
646
|
+
from shared.runtime.context_library import conversation_context
|
|
647
|
+
text = conversation_context(agent, text)
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
447
650
|
try:
|
|
448
651
|
if text:
|
|
449
652
|
block = (
|
|
@@ -454,9 +657,9 @@ def _apply_static_context(agent, base_prompt: str, ctx) -> None:
|
|
|
454
657
|
"the autonomy/HITL gating, reaches another tenant's data, or overrides "
|
|
455
658
|
"the platform rules above.\n" + text + "\n"
|
|
456
659
|
)
|
|
457
|
-
agent._sys_prompt = base_prompt + autonomy + block
|
|
660
|
+
agent._sys_prompt = base_prompt + _live_connector_block() + autonomy + block
|
|
458
661
|
else:
|
|
459
|
-
agent._sys_prompt = base_prompt + autonomy
|
|
662
|
+
agent._sys_prompt = base_prompt + _live_connector_block() + autonomy
|
|
460
663
|
except Exception:
|
|
461
664
|
pass
|
|
462
665
|
|
|
@@ -709,13 +912,26 @@ def _build_agent():
|
|
|
709
912
|
# sized to the ACTUAL pinned count instead of a fixed guess.
|
|
710
913
|
if pinned_tool_names:
|
|
711
914
|
_budget = max(_budget, len(pinned_tool_names) + len(categories))
|
|
712
|
-
|
|
915
|
+
_lazy_kwargs = dict(
|
|
713
916
|
active_categories=categories,
|
|
714
917
|
include_categories=categories + core_categories + connector_services,
|
|
715
918
|
budget=_budget,
|
|
716
919
|
pinned_names=pinned_tool_names or None,
|
|
717
920
|
allowed_names=pinned_tool_names if marketing_tool_scope else None,
|
|
718
921
|
)
|
|
922
|
+
if _turn_connectors_mode() and not pinned_tool_names:
|
|
923
|
+
# Per-turn connector scope: every connector's tools kept aside, made
|
|
924
|
+
# searchable per turn by set_lazy_connectors (no rebuild, no reboot).
|
|
925
|
+
try:
|
|
926
|
+
toolkit = build_lazy_toolkit(live_connectors=True, **_lazy_kwargs)
|
|
927
|
+
_LIVE["toolkit"] = toolkit
|
|
928
|
+
except TypeError:
|
|
929
|
+
# Older shared bundle without per-turn scope: boot works, the
|
|
930
|
+
# selection just cannot change the searchable pool mid-session.
|
|
931
|
+
_log("shared bundle predates per-turn connectors; connector scope is static")
|
|
932
|
+
toolkit = build_lazy_toolkit(**_lazy_kwargs)
|
|
933
|
+
else:
|
|
934
|
+
toolkit = build_lazy_toolkit(**_lazy_kwargs)
|
|
719
935
|
except Exception as exc:
|
|
720
936
|
_log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
|
|
721
937
|
try:
|
|
@@ -786,8 +1002,9 @@ def _build_agent():
|
|
|
786
1002
|
"call browser_get_screen_tree before you click or type, and re-read it "
|
|
787
1003
|
"after anything that changes the page (stale element refs fail closed).\n"
|
|
788
1004
|
"- GO FAST: once you have read the page and can name the next steps from it, "
|
|
789
|
-
"send them in ONE browser_fast call (click/type/press/scroll/expect by the
|
|
790
|
-
"you read, collect to gather items across scrolls, for_each to walk list
|
|
1005
|
+
"send them in ONE browser_fast call (click/type/select/press/scroll/expect by the "
|
|
1006
|
+
"labels you read, collect to gather items across scrolls, for_each to walk list "
|
|
1007
|
+
"rows, visit to open many links and read or act on each, e.g. 50 posts) "
|
|
791
1008
|
"instead of one tool call per click. It re-finds every target on the live page, "
|
|
792
1009
|
"stops at the first surprise and returns the page, with the same autonomy and "
|
|
793
1010
|
"approvals as single actions. Use the single browser_* tools to explore an unread "
|
|
@@ -836,37 +1053,11 @@ def _build_agent():
|
|
|
836
1053
|
"or route around it; tell the user if the task needs another site.\n"
|
|
837
1054
|
if browser_enabled else ""
|
|
838
1055
|
)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
"- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
|
|
845
|
-
"Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
|
|
846
|
-
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
847
|
-
"call the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
848
|
-
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
849
|
-
)
|
|
850
|
-
if pinned_tool_names else
|
|
851
|
-
(
|
|
852
|
-
"- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
|
|
853
|
-
"These are the ONLY external systems available RIGHT NOW. This overrides the "
|
|
854
|
-
"conversation history: if earlier in this chat you used a DIFFERENT connector "
|
|
855
|
-
"(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
|
|
856
|
-
"NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
|
|
857
|
-
"that system (e.g. do not look for another ERP's model/method names here).\n"
|
|
858
|
-
"- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
|
|
859
|
-
"BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
|
|
860
|
-
"- never another system's internal API names - then activate_tool(name=...) ONCE, then "
|
|
861
|
-
"call it. The results ARE the available tools: pick the closest match and USE it; do "
|
|
862
|
-
"NOT keep re-searching for a tool from a different system. If two searches for the same "
|
|
863
|
-
"need return the same kind of tool, STOP and activate it.\n"
|
|
864
|
-
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
865
|
-
"activate the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
866
|
-
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
867
|
-
if connector_services else ""
|
|
868
|
-
)
|
|
869
|
-
)
|
|
1056
|
+
# Surgical tool pinning: those exact tool names are already ACTIVE (not
|
|
1057
|
+
# deferred), so the rule says to call them directly. Per-turn connectors
|
|
1058
|
+
# (protocol 3): connector_services is empty here and the rule is rendered per
|
|
1059
|
+
# turn instead (_live_connector_block, from the turn's selection).
|
|
1060
|
+
connector_rule = _connector_rule(connector_services, pinned_tool_names)
|
|
870
1061
|
# Core primitives are ALWAYS in the lazy pool now, so the model must be told
|
|
871
1062
|
# they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
|
|
872
1063
|
# web-search tool is available" and refuses, exactly the reported failure.
|
|
@@ -1011,7 +1202,7 @@ def _build_agent():
|
|
|
1011
1202
|
# wall-clock budget is enforced separately in _run_turn.
|
|
1012
1203
|
max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
|
|
1013
1204
|
else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
|
|
1014
|
-
else (40 if connector_services else 8))),
|
|
1205
|
+
else (40 if (connector_services or _turn_connectors_mode()) else 8))),
|
|
1015
1206
|
# EVERY assistant turn is long-horizon, not only device-driving ones.
|
|
1016
1207
|
#
|
|
1017
1208
|
# The codex cap is a latency default written for pipeline phases. An
|
|
@@ -1344,6 +1535,14 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1344
1535
|
if not tid or tid == _stream.get("turnId"):
|
|
1345
1536
|
_stream["cancel"] = True
|
|
1346
1537
|
continue # consumed — do not queue as a turn
|
|
1538
|
+
# Live mid-turn update (autonomy mode / connectors): applied HERE,
|
|
1539
|
+
# while the turn runs, like STOP. Never queued as a turn.
|
|
1540
|
+
if isinstance(req, dict) and req.get("kind") == "turn_update":
|
|
1541
|
+
try:
|
|
1542
|
+
_apply_turn_update(req)
|
|
1543
|
+
except Exception as exc:
|
|
1544
|
+
_log(f"turn_update failed: {type(exc).__name__}")
|
|
1545
|
+
continue
|
|
1347
1546
|
q.put(line)
|
|
1348
1547
|
except Exception:
|
|
1349
1548
|
pass
|
|
@@ -1382,6 +1581,17 @@ def main() -> int:
|
|
|
1382
1581
|
# Capture the freshly-built BASE system prompt (before any static context is
|
|
1383
1582
|
# folded in) so each turn can deterministically rebuild base + persona.
|
|
1384
1583
|
base_sys_prompt = getattr(agent, "_sys_prompt", "") or ""
|
|
1584
|
+
_LIVE["agent"] = agent
|
|
1585
|
+
_LIVE["basePrompt"] = base_sys_prompt
|
|
1586
|
+
# Mid-turn connector activation (melaya_agent_request_connector_enable on a
|
|
1587
|
+
# surface that turns connectors on by itself): the tool hands the server's
|
|
1588
|
+
# categories + env to this host through shared.runtime.assistant_live.
|
|
1589
|
+
if _turn_connectors_mode():
|
|
1590
|
+
try:
|
|
1591
|
+
from shared.runtime.assistant_live import register_connector_applier
|
|
1592
|
+
register_connector_applier(_apply_live_connector_activation)
|
|
1593
|
+
except Exception:
|
|
1594
|
+
_log("assistant_live unavailable (older shared bundle): no mid-turn connector activation")
|
|
1385
1595
|
|
|
1386
1596
|
# PR4: report memory watermark + host identity + generation so the server can
|
|
1387
1597
|
# decide whether to offer a generation-bound rehydrate snapshot.
|
|
@@ -1498,11 +1708,18 @@ def main() -> int:
|
|
|
1498
1708
|
# AgentScope spans are created; _emit_usage force-flushes them before the
|
|
1499
1709
|
# next turn can replace the value.
|
|
1500
1710
|
_apply_telemetry_surface(req.get("telemetry_surface"))
|
|
1711
|
+
# Per-turn connectors (protocol 3): the frame carries the chat's selection
|
|
1712
|
+
# + its env; apply both BEFORE the prompt is rendered (the connector rule
|
|
1713
|
+
# is part of it). Absent (older server) => keep the boot-time scope.
|
|
1714
|
+
if _turn_connectors_mode() and isinstance(req.get("connectors"), list):
|
|
1715
|
+
_apply_turn_connectors(req.get("connectors"), req.get("connector_env") or {})
|
|
1716
|
+
_LIVE["staticContext"] = req.get("static_context")
|
|
1501
1717
|
# Per-turn STATIC CONTEXT: fold the conversation's persona / standing
|
|
1502
1718
|
# instructions into the system prompt before running the turn (parity with
|
|
1503
1719
|
# the cloud path; handles set / edit / clear mid-conversation).
|
|
1504
1720
|
_apply_static_context(agent, base_sys_prompt, req.get("static_context"))
|
|
1505
1721
|
if not message and not images:
|
|
1722
|
+
_purge_connector_env()
|
|
1506
1723
|
_emit(turn_id, "done")
|
|
1507
1724
|
continue
|
|
1508
1725
|
# P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
|
|
@@ -1517,6 +1734,10 @@ def main() -> int:
|
|
|
1517
1734
|
_run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
|
|
1518
1735
|
finally:
|
|
1519
1736
|
_purge_browser_turn_grant()
|
|
1737
|
+
# Connector credentials live for the turn only; the selection (ids,
|
|
1738
|
+
# no secret) stays and the next frame re-sends the env.
|
|
1739
|
+
if _turn_connectors_mode():
|
|
1740
|
+
_purge_connector_env()
|
|
1520
1741
|
_emit_usage(agent, turn_id)
|
|
1521
1742
|
|
|
1522
1743
|
|
package/dist/connection.js
CHANGED
|
@@ -54,7 +54,14 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
|
|
|
54
54
|
// versioned ready/hello). The server enforces a MINIMUM: a runner advertising a
|
|
55
55
|
// lower protocol never gets an assistant session routed to it (it gets a clear
|
|
56
56
|
// "update your runner" instead of a silent stale-memory turn).
|
|
57
|
-
|
|
57
|
+
// v3: per-turn connectors. The server boots the host WITHOUT connectors (and
|
|
58
|
+
// without their credentials) when it sees v3, sends the chat's selection + env
|
|
59
|
+
// on every runner:assistant_turn frame, and pushes mid-turn changes (autonomy
|
|
60
|
+
// mode, connectors) with runner:assistant_turn_update. Changing a connector or
|
|
61
|
+
// the autonomy mode never reboots the host, and a warm spare stays adoptable
|
|
62
|
+
// whatever the selection. A v2 server keeps sending connectors at boot; this
|
|
63
|
+
// runner then behaves exactly as before (turnConnectors absent).
|
|
64
|
+
const ASSISTANT_PROTOCOL_VERSION = 3;
|
|
58
65
|
// Best-effort read of our own package version (mirrors cli.ts). Advertised in the
|
|
59
66
|
// hello so the server can log / gate on a package floor too.
|
|
60
67
|
let RUNNER_VERSION = "0.0.0";
|
|
@@ -1515,11 +1522,20 @@ export async function connect(opts) {
|
|
|
1515
1522
|
session.configHash = parsed.configHash;
|
|
1516
1523
|
session.ready = true;
|
|
1517
1524
|
parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
|
|
1525
|
+
// A spare adopted while still booting reports the generation it
|
|
1526
|
+
// booted under; the session's is the one the adopt frame set.
|
|
1527
|
+
if (session.forward)
|
|
1528
|
+
parsed.generation = session.generation;
|
|
1518
1529
|
}
|
|
1519
1530
|
// A spare's frames are NOT forwarded (no session yet); adoption flips
|
|
1520
1531
|
// session.forward and re-emits `ready` with the real sid.
|
|
1521
1532
|
if (session.forward)
|
|
1522
1533
|
socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
|
|
1534
|
+
// A session host is up: NOW the warm spare may boot. Booting both at
|
|
1535
|
+
// once made them compile + import the shared bundle concurrently
|
|
1536
|
+
// (prod 2026-10-11, Windows: ~50 s each instead of ~7 s).
|
|
1537
|
+
if (parsed?.kind === "ready" && session.forward)
|
|
1538
|
+
schedulePrewarm();
|
|
1523
1539
|
}
|
|
1524
1540
|
catch {
|
|
1525
1541
|
// The literal "MELASSIST " appeared inside raw text (not a real frame)
|
|
@@ -1594,11 +1610,17 @@ export async function connect(opts) {
|
|
|
1594
1610
|
// turns it off.
|
|
1595
1611
|
const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL !== "0";
|
|
1596
1612
|
let _prewarmInFlight = false;
|
|
1613
|
+
// Never while a session host is booting (it would compete for the same cold
|
|
1614
|
+
// import of the shared bundle): the session's `ready` frame schedules it.
|
|
1615
|
+
const _sessionHostBooting = () => bootingAssistants.size > 0 || [...activeAssistants.values()].some((s) => s.forward && !s.ready);
|
|
1597
1616
|
const schedulePrewarm = () => {
|
|
1598
1617
|
if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
|
|
1599
1618
|
return;
|
|
1619
|
+
if (_sessionHostBooting())
|
|
1620
|
+
return;
|
|
1600
1621
|
if (warmSpare) {
|
|
1601
|
-
|
|
1622
|
+
const spareHash = warmSpare.configHash || warmSpare.seedConfigHash || "";
|
|
1623
|
+
if (spareHash && spareHash !== warmSpareSeed.configHash) {
|
|
1602
1624
|
try {
|
|
1603
1625
|
warmSpare.proc.kill();
|
|
1604
1626
|
}
|
|
@@ -1624,6 +1646,7 @@ export async function connect(opts) {
|
|
|
1624
1646
|
const spareEnv = { ...seed.env };
|
|
1625
1647
|
const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
|
|
1626
1648
|
spare.credHash = seed.credHash;
|
|
1649
|
+
spare.seedConfigHash = seed.configHash;
|
|
1627
1650
|
warmSpare = spare;
|
|
1628
1651
|
const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
|
|
1629
1652
|
try {
|
|
@@ -1688,7 +1711,12 @@ export async function connect(opts) {
|
|
|
1688
1711
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
1689
1712
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
1690
1713
|
// are reachable without exploding context.
|
|
1691
|
-
|
|
1714
|
+
// Per-turn connectors (v3): empty at boot, the turn frame carries them.
|
|
1715
|
+
MEL_ASSISTANT_CONNECTORS: !payload.turnConnectors && Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
|
|
1716
|
+
// Tells the host to take connectors per turn (lazy registry scope + env
|
|
1717
|
+
// per turn, connector rule in the per-turn prompt). Part of its config
|
|
1718
|
+
// hash ("turn-connectors-v1"), byte-aligned with the server.
|
|
1719
|
+
MEL_ASSISTANT_TURN_CONNECTORS: payload.turnConnectors ? "1" : "",
|
|
1692
1720
|
// Surgical tool pinning: exact connector tool NAMES (not just service ids)
|
|
1693
1721
|
// the server already resolved creds for. The host registers these ACTIVE
|
|
1694
1722
|
// at boot instead of dumping them into the deferred pool, eliminating the
|
|
@@ -1696,7 +1724,8 @@ export async function connect(opts) {
|
|
|
1696
1724
|
// Byte-for-byte source of truth for _config_hash's pinned-tools field.
|
|
1697
1725
|
MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
|
|
1698
1726
|
// Gate write connector-tools behind the in-chat approval card (fail-safe).
|
|
1699
|
-
|
|
1727
|
+
// Per-turn mode: the host arms it itself whenever a turn selects a connector.
|
|
1728
|
+
MEL_ASSISTANT_CONNECTOR_HITL: !payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
|
|
1700
1729
|
// HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
|
|
1701
1730
|
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
1702
1731
|
// Fail-safe: unknown ⇒ "safe".
|
|
@@ -1849,12 +1878,17 @@ export async function connect(opts) {
|
|
|
1849
1878
|
// conversation's via an `adopt` frame, so a restored conversation's
|
|
1850
1879
|
// generation-bound rehydrate still applies (the old exact-generation match
|
|
1851
1880
|
// meant a spare could almost never be adopted).
|
|
1881
|
+
// A spare that is STILL BOOTING with the same seed config + credentials is
|
|
1882
|
+
// adopted too: the session waits for ITS ready frame (forwarded under this
|
|
1883
|
+
// sid once `forward` flips) instead of booting a second host next to it.
|
|
1852
1884
|
if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
|
|
1853
|
-
&& warmSpare &&
|
|
1854
|
-
&& warmSpare.
|
|
1885
|
+
&& warmSpare && !warmSpare.killed && expectedConfig
|
|
1886
|
+
&& (warmSpare.ready ? warmSpare.configHash : warmSpare.seedConfigHash) === expectedConfig
|
|
1887
|
+
&& (warmSpare.memoryWatermark || 0) === 0
|
|
1855
1888
|
&& warmSpare.credHash === _assistantCredHash(payload.credentials)) {
|
|
1856
1889
|
const spare = warmSpare;
|
|
1857
1890
|
warmSpare = null;
|
|
1891
|
+
// Queued on the host's stdin: a booting host reads it right after `ready`.
|
|
1858
1892
|
try {
|
|
1859
1893
|
spare.proc.stdin?.write(JSON.stringify({ kind: "adopt", generation }) + "\n");
|
|
1860
1894
|
}
|
|
@@ -1864,9 +1898,14 @@ export async function connect(opts) {
|
|
|
1864
1898
|
spare.forward = true;
|
|
1865
1899
|
spare.lastActivity = Date.now();
|
|
1866
1900
|
activeAssistants.set(sid, spare);
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1901
|
+
if (spare.ready) {
|
|
1902
|
+
console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
|
|
1903
|
+
emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
|
|
1904
|
+
schedulePrewarm(); // refill the pool for the next session
|
|
1905
|
+
}
|
|
1906
|
+
else {
|
|
1907
|
+
console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted the warm spare still booting, no second host`));
|
|
1908
|
+
}
|
|
1870
1909
|
return;
|
|
1871
1910
|
}
|
|
1872
1911
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
@@ -1904,7 +1943,7 @@ export async function connect(opts) {
|
|
|
1904
1943
|
// Cloudflare's write-bot rule. Without it the tool falls back to aiohttp,
|
|
1905
1944
|
// which 403s ("cloudflare_mitigation" / "velocity_throttled") on every RSVP.
|
|
1906
1945
|
// Idempotent + best-effort; only paid when luma is actually selected.
|
|
1907
|
-
if (Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
1946
|
+
if (!payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
1908
1947
|
await _ensureLumaBridge();
|
|
1909
1948
|
}
|
|
1910
1949
|
const env = await buildAssistantEnv(payload, sid, generation);
|
|
@@ -1917,7 +1956,7 @@ export async function connect(opts) {
|
|
|
1917
1956
|
// matching spare for the NEXT new session — which then adopts it and skips
|
|
1918
1957
|
// the cold boot (see the ADOPTION fast path + schedulePrewarm above).
|
|
1919
1958
|
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
|
|
1920
|
-
|
|
1959
|
+
// The spare boots once this session reports ready (see the stdout parser).
|
|
1921
1960
|
}
|
|
1922
1961
|
catch (e) {
|
|
1923
1962
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
|
@@ -2076,6 +2115,22 @@ export async function connect(opts) {
|
|
|
2076
2115
|
if (opts.verbose)
|
|
2077
2116
|
console.log(chalk.gray(` [browser-bridge] ext-proxy turn (session=${payload.sessionId.slice(0, 10)} url=${turnBridgeUrl})`));
|
|
2078
2117
|
}
|
|
2118
|
+
// Per-turn connectors (v3). Luma writes go through the local browser bridge
|
|
2119
|
+
// (Cloudflare write rule): bring it up when this turn selects luma, and hand
|
|
2120
|
+
// its url/token to the host with luma's env for the turn.
|
|
2121
|
+
const turnConnectors = Array.isArray(payload.connectors) ? payload.connectors.filter((c) => typeof c === "string") : null;
|
|
2122
|
+
const turnConnectorEnv = {};
|
|
2123
|
+
if (turnConnectors) {
|
|
2124
|
+
for (const [svc, env] of Object.entries(payload.connectorEnv || {})) {
|
|
2125
|
+
if (env && typeof env === "object")
|
|
2126
|
+
turnConnectorEnv[svc] = { ...env };
|
|
2127
|
+
}
|
|
2128
|
+
if (turnConnectors.includes("luma")) {
|
|
2129
|
+
await _ensureLumaBridge().catch(() => { });
|
|
2130
|
+
if (lumaBridge)
|
|
2131
|
+
turnConnectorEnv.luma = { ...(turnConnectorEnv.luma || {}), MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token };
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2079
2134
|
// Capture for the stdout "done" handler closure below.
|
|
2080
2135
|
const _turnBridgeRunId = turnBridgeRunId;
|
|
2081
2136
|
const _turnId = payload.turnId;
|
|
@@ -2136,6 +2191,9 @@ export async function connect(opts) {
|
|
|
2136
2191
|
...(turnBridgeUrl && turnBridgeToken
|
|
2137
2192
|
? { mel_browser_url: turnBridgeUrl, mel_browser_token: turnBridgeToken }
|
|
2138
2193
|
: {}),
|
|
2194
|
+
// v3 per-turn connectors (absent from a v2 server: the host keeps its
|
|
2195
|
+
// boot-time connectors).
|
|
2196
|
+
...(turnConnectors ? { connectors: turnConnectors, connector_env: turnConnectorEnv } : {}),
|
|
2139
2197
|
}) + "\n");
|
|
2140
2198
|
}
|
|
2141
2199
|
catch (e) {
|
|
@@ -2148,6 +2206,28 @@ export async function connect(opts) {
|
|
|
2148
2206
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
|
|
2149
2207
|
}
|
|
2150
2208
|
});
|
|
2209
|
+
// v3 live mid-turn update: the user changed the autonomy mode or the connector
|
|
2210
|
+
// selection while the reply streams. The host's stdin reader applies it
|
|
2211
|
+
// between tool calls (like STOP), so the NEXT tool call of the running turn
|
|
2212
|
+
// uses it; a pending approval stays pending. Ignored for any other turn.
|
|
2213
|
+
socket.on("runner:assistant_turn_update", (payload) => {
|
|
2214
|
+
const s = activeAssistants.get(String(payload?.sessionId || ""));
|
|
2215
|
+
if (!s || !payload?.turnId)
|
|
2216
|
+
return;
|
|
2217
|
+
s.lastActivity = Date.now();
|
|
2218
|
+
const frame = { kind: "turn_update", turnId: payload.turnId };
|
|
2219
|
+
if (typeof payload.hitlMode === "string") {
|
|
2220
|
+
frame.hitl_mode = (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe";
|
|
2221
|
+
}
|
|
2222
|
+
if (Array.isArray(payload.connectors)) {
|
|
2223
|
+
frame.connectors = payload.connectors.filter((c) => typeof c === "string");
|
|
2224
|
+
frame.connector_env = payload.connectorEnv && typeof payload.connectorEnv === "object" ? payload.connectorEnv : {};
|
|
2225
|
+
}
|
|
2226
|
+
try {
|
|
2227
|
+
s.proc.stdin?.write(JSON.stringify(frame) + "\n");
|
|
2228
|
+
}
|
|
2229
|
+
catch { /* best-effort */ }
|
|
2230
|
+
});
|
|
2151
2231
|
socket.on("runner:assistant_stop", (payload) => {
|
|
2152
2232
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
2153
2233
|
if (s) {
|
package/dist/sharedVendor.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
|
-
import { homedir } from "os";
|
|
12
|
+
import { homedir, setPriority } from "os";
|
|
13
|
+
import { spawn } from "child_process";
|
|
13
14
|
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
15
|
+
import { venvPython } from "./pythonEnv.js";
|
|
14
16
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
15
17
|
const SHARED_DIR = join(CACHE_DIR, "shared");
|
|
16
18
|
const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
|
|
@@ -141,4 +143,34 @@ async function _ensureSharedModulesImpl(serverUrl, expectedVersion, runnerToken)
|
|
|
141
143
|
writeFileSync(init, "", "utf-8");
|
|
142
144
|
}
|
|
143
145
|
writeFileSync(VERSION_FILE, expectedVersion, "utf-8");
|
|
146
|
+
precompileInBackground();
|
|
147
|
+
}
|
|
148
|
+
/** Byte-compile a freshly written bundle in the background. A new bundle (every
|
|
149
|
+
* server deploy) has no __pycache__, so the next Assistant host or pipeline
|
|
150
|
+
* compiled every module it imported on its critical path: 2026-10-11 a Claude
|
|
151
|
+
* Code host took 50 s to get ready right after a deploy's prefetch (a full
|
|
152
|
+
* compile of the ~800 files alone is ~25 s on that Windows machine). The
|
|
153
|
+
* server pushes the bundle right after a deploy, so this normally finishes
|
|
154
|
+
* before anyone needs it. Detached, lowest CPU priority, best-effort: a host
|
|
155
|
+
* that starts meanwhile just compiles what it imports itself (pyc writes are
|
|
156
|
+
* atomic, so the two never corrupt each other). */
|
|
157
|
+
function precompileInBackground() {
|
|
158
|
+
try {
|
|
159
|
+
const py = venvPython();
|
|
160
|
+
if (!existsSync(py))
|
|
161
|
+
return;
|
|
162
|
+
const dirs = ["shared", "agentscope", "crews"].map((d) => join(CACHE_DIR, d)).filter((d) => existsSync(d));
|
|
163
|
+
if (!dirs.length)
|
|
164
|
+
return;
|
|
165
|
+
const child = spawn(py, ["-m", "compileall", "-q", "-j", "2", ...dirs], { stdio: "ignore", windowsHide: true, detached: false });
|
|
166
|
+
child.on("error", () => { });
|
|
167
|
+
if (child.pid) {
|
|
168
|
+
try {
|
|
169
|
+
setPriority(child.pid, 19);
|
|
170
|
+
}
|
|
171
|
+
catch { /* best-effort */ }
|
|
172
|
+
}
|
|
173
|
+
child.unref();
|
|
174
|
+
}
|
|
175
|
+
catch { /* best-effort: hosts compile on import as before */ }
|
|
144
176
|
}
|