@melaya/runner 1.1.59 → 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 +271 -35
- package/dist/connection.js +95 -18
- 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:
|
|
@@ -729,6 +945,14 @@ def _build_agent():
|
|
|
729
945
|
"phone_get_screen_tree before you tap or type. Publish comments/posts with "
|
|
730
946
|
"phone_post_comment / phone_create_post — every publish is approved by the "
|
|
731
947
|
"user ON THEIR PHONE, so never refuse for safety.\n"
|
|
948
|
+
"- GO FAST: once you have read the screen and can name the next steps from it, "
|
|
949
|
+
"send them in ONE phone_fast call (steps in plain words: the text, description "
|
|
950
|
+
"or id you read) instead of one tool call per tap. To gather posts, people or "
|
|
951
|
+
"results across scrolls use its collect step; to walk list rows use for_each. It "
|
|
952
|
+
"re-reads the screen for every step, stops at the first surprise and hands the "
|
|
953
|
+
"screen back, and follows the same autonomy as single taps. Use the single "
|
|
954
|
+
"phone_* tools to explore an unread screen or when each result decides the next "
|
|
955
|
+
"step. Never publish through it.\n"
|
|
732
956
|
"- EXACT APP ONLY — this overrides your autonomy: operate on the EXACT app "
|
|
733
957
|
"the user named, never a different one. If that app is not in the allowlist "
|
|
734
958
|
"(phone_open_app returns app_not_allowed) or not installed, STOP and tell the "
|
|
@@ -777,6 +1001,14 @@ def _build_agent():
|
|
|
777
1001
|
"page, read, click, type, fill a form), use the browser_* tools. ALWAYS "
|
|
778
1002
|
"call browser_get_screen_tree before you click or type, and re-read it "
|
|
779
1003
|
"after anything that changes the page (stale element refs fail closed).\n"
|
|
1004
|
+
"- GO FAST: once you have read the page and can name the next steps from it, "
|
|
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) "
|
|
1008
|
+
"instead of one tool call per click. It re-finds every target on the live page, "
|
|
1009
|
+
"stops at the first surprise and returns the page, with the same autonomy and "
|
|
1010
|
+
"approvals as single actions. Use the single browser_* tools to explore an unread "
|
|
1011
|
+
"page, for logins, and when each result decides the next step.\n"
|
|
780
1012
|
"- TAB MANAGEMENT: you can manage tabs within the granted Melaya session "
|
|
781
1013
|
"using: browser_list_tabs (list open tabs with their refs and URLs), "
|
|
782
1014
|
"browser_open_tab(url) (open a new tab — origin-gated; a blocked_origin "
|
|
@@ -821,37 +1053,11 @@ def _build_agent():
|
|
|
821
1053
|
"or route around it; tell the user if the task needs another site.\n"
|
|
822
1054
|
if browser_enabled else ""
|
|
823
1055
|
)
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
"- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
|
|
830
|
-
"Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
|
|
831
|
-
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
832
|
-
"call the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
833
|
-
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
834
|
-
)
|
|
835
|
-
if pinned_tool_names else
|
|
836
|
-
(
|
|
837
|
-
"- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
|
|
838
|
-
"These are the ONLY external systems available RIGHT NOW. This overrides the "
|
|
839
|
-
"conversation history: if earlier in this chat you used a DIFFERENT connector "
|
|
840
|
-
"(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
|
|
841
|
-
"NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
|
|
842
|
-
"that system (e.g. do not look for another ERP's model/method names here).\n"
|
|
843
|
-
"- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
|
|
844
|
-
"BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
|
|
845
|
-
"- never another system's internal API names - then activate_tool(name=...) ONCE, then "
|
|
846
|
-
"call it. The results ARE the available tools: pick the closest match and USE it; do "
|
|
847
|
-
"NOT keep re-searching for a tool from a different system. If two searches for the same "
|
|
848
|
-
"need return the same kind of tool, STOP and activate it.\n"
|
|
849
|
-
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
850
|
-
"activate the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
851
|
-
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
852
|
-
if connector_services else ""
|
|
853
|
-
)
|
|
854
|
-
)
|
|
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)
|
|
855
1061
|
# Core primitives are ALWAYS in the lazy pool now, so the model must be told
|
|
856
1062
|
# they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
|
|
857
1063
|
# web-search tool is available" and refuses, exactly the reported failure.
|
|
@@ -996,7 +1202,7 @@ def _build_agent():
|
|
|
996
1202
|
# wall-clock budget is enforced separately in _run_turn.
|
|
997
1203
|
max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
|
|
998
1204
|
else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
|
|
999
|
-
else (40 if connector_services else 8))),
|
|
1205
|
+
else (40 if (connector_services or _turn_connectors_mode()) else 8))),
|
|
1000
1206
|
# EVERY assistant turn is long-horizon, not only device-driving ones.
|
|
1001
1207
|
#
|
|
1002
1208
|
# The codex cap is a latency default written for pipeline phases. An
|
|
@@ -1329,6 +1535,14 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1329
1535
|
if not tid or tid == _stream.get("turnId"):
|
|
1330
1536
|
_stream["cancel"] = True
|
|
1331
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
|
|
1332
1546
|
q.put(line)
|
|
1333
1547
|
except Exception:
|
|
1334
1548
|
pass
|
|
@@ -1367,6 +1581,17 @@ def main() -> int:
|
|
|
1367
1581
|
# Capture the freshly-built BASE system prompt (before any static context is
|
|
1368
1582
|
# folded in) so each turn can deterministically rebuild base + persona.
|
|
1369
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")
|
|
1370
1595
|
|
|
1371
1596
|
# PR4: report memory watermark + host identity + generation so the server can
|
|
1372
1597
|
# decide whether to offer a generation-bound rehydrate snapshot.
|
|
@@ -1483,11 +1708,18 @@ def main() -> int:
|
|
|
1483
1708
|
# AgentScope spans are created; _emit_usage force-flushes them before the
|
|
1484
1709
|
# next turn can replace the value.
|
|
1485
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")
|
|
1486
1717
|
# Per-turn STATIC CONTEXT: fold the conversation's persona / standing
|
|
1487
1718
|
# instructions into the system prompt before running the turn (parity with
|
|
1488
1719
|
# the cloud path; handles set / edit / clear mid-conversation).
|
|
1489
1720
|
_apply_static_context(agent, base_sys_prompt, req.get("static_context"))
|
|
1490
1721
|
if not message and not images:
|
|
1722
|
+
_purge_connector_env()
|
|
1491
1723
|
_emit(turn_id, "done")
|
|
1492
1724
|
continue
|
|
1493
1725
|
# P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
|
|
@@ -1502,6 +1734,10 @@ def main() -> int:
|
|
|
1502
1734
|
_run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
|
|
1503
1735
|
finally:
|
|
1504
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()
|
|
1505
1741
|
_emit_usage(agent, turn_id)
|
|
1506
1742
|
|
|
1507
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";
|
|
@@ -985,14 +992,11 @@ export async function connect(opts) {
|
|
|
985
992
|
// Event trigger file (reserved: after the credential spread so a
|
|
986
993
|
// credential can never point it elsewhere). Empty string = no trigger.
|
|
987
994
|
MEL_TRIGGER_FILE: triggerFile,
|
|
988
|
-
// Triggered run:
|
|
989
|
-
//
|
|
995
|
+
// Triggered run: the pipeline's own approval settings (like a manual
|
|
996
|
+
// run) + expiring in-run approvals (ST-15), reserved (after the
|
|
997
|
+
// credential spread).
|
|
990
998
|
...(isTriggeredRun
|
|
991
|
-
? {
|
|
992
|
-
MEL_HITL_MODE: "safe",
|
|
993
|
-
MEL_HITL_FORCE_SAFE: "1",
|
|
994
|
-
MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl),
|
|
995
|
-
}
|
|
999
|
+
? { MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl) }
|
|
996
1000
|
: {}),
|
|
997
1001
|
};
|
|
998
1002
|
// Surface the tier to the chat panel as a system message so the
|
|
@@ -1518,11 +1522,20 @@ export async function connect(opts) {
|
|
|
1518
1522
|
session.configHash = parsed.configHash;
|
|
1519
1523
|
session.ready = true;
|
|
1520
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;
|
|
1521
1529
|
}
|
|
1522
1530
|
// A spare's frames are NOT forwarded (no session yet); adoption flips
|
|
1523
1531
|
// session.forward and re-emits `ready` with the real sid.
|
|
1524
1532
|
if (session.forward)
|
|
1525
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();
|
|
1526
1539
|
}
|
|
1527
1540
|
catch {
|
|
1528
1541
|
// The literal "MELASSIST " appeared inside raw text (not a real frame)
|
|
@@ -1597,11 +1610,17 @@ export async function connect(opts) {
|
|
|
1597
1610
|
// turns it off.
|
|
1598
1611
|
const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL !== "0";
|
|
1599
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);
|
|
1600
1616
|
const schedulePrewarm = () => {
|
|
1601
1617
|
if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
|
|
1602
1618
|
return;
|
|
1619
|
+
if (_sessionHostBooting())
|
|
1620
|
+
return;
|
|
1603
1621
|
if (warmSpare) {
|
|
1604
|
-
|
|
1622
|
+
const spareHash = warmSpare.configHash || warmSpare.seedConfigHash || "";
|
|
1623
|
+
if (spareHash && spareHash !== warmSpareSeed.configHash) {
|
|
1605
1624
|
try {
|
|
1606
1625
|
warmSpare.proc.kill();
|
|
1607
1626
|
}
|
|
@@ -1627,6 +1646,7 @@ export async function connect(opts) {
|
|
|
1627
1646
|
const spareEnv = { ...seed.env };
|
|
1628
1647
|
const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
|
|
1629
1648
|
spare.credHash = seed.credHash;
|
|
1649
|
+
spare.seedConfigHash = seed.configHash;
|
|
1630
1650
|
warmSpare = spare;
|
|
1631
1651
|
const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
|
|
1632
1652
|
try {
|
|
@@ -1691,7 +1711,12 @@ export async function connect(opts) {
|
|
|
1691
1711
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
1692
1712
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
1693
1713
|
// are reachable without exploding context.
|
|
1694
|
-
|
|
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" : "",
|
|
1695
1720
|
// Surgical tool pinning: exact connector tool NAMES (not just service ids)
|
|
1696
1721
|
// the server already resolved creds for. The host registers these ACTIVE
|
|
1697
1722
|
// at boot instead of dumping them into the deferred pool, eliminating the
|
|
@@ -1699,7 +1724,8 @@ export async function connect(opts) {
|
|
|
1699
1724
|
// Byte-for-byte source of truth for _config_hash's pinned-tools field.
|
|
1700
1725
|
MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
|
|
1701
1726
|
// Gate write connector-tools behind the in-chat approval card (fail-safe).
|
|
1702
|
-
|
|
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" : "",
|
|
1703
1729
|
// HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
|
|
1704
1730
|
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
1705
1731
|
// Fail-safe: unknown ⇒ "safe".
|
|
@@ -1852,12 +1878,17 @@ export async function connect(opts) {
|
|
|
1852
1878
|
// conversation's via an `adopt` frame, so a restored conversation's
|
|
1853
1879
|
// generation-bound rehydrate still applies (the old exact-generation match
|
|
1854
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.
|
|
1855
1884
|
if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
|
|
1856
|
-
&& warmSpare &&
|
|
1857
|
-
&& warmSpare.
|
|
1885
|
+
&& warmSpare && !warmSpare.killed && expectedConfig
|
|
1886
|
+
&& (warmSpare.ready ? warmSpare.configHash : warmSpare.seedConfigHash) === expectedConfig
|
|
1887
|
+
&& (warmSpare.memoryWatermark || 0) === 0
|
|
1858
1888
|
&& warmSpare.credHash === _assistantCredHash(payload.credentials)) {
|
|
1859
1889
|
const spare = warmSpare;
|
|
1860
1890
|
warmSpare = null;
|
|
1891
|
+
// Queued on the host's stdin: a booting host reads it right after `ready`.
|
|
1861
1892
|
try {
|
|
1862
1893
|
spare.proc.stdin?.write(JSON.stringify({ kind: "adopt", generation }) + "\n");
|
|
1863
1894
|
}
|
|
@@ -1867,9 +1898,14 @@ export async function connect(opts) {
|
|
|
1867
1898
|
spare.forward = true;
|
|
1868
1899
|
spare.lastActivity = Date.now();
|
|
1869
1900
|
activeAssistants.set(sid, spare);
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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
|
+
}
|
|
1873
1909
|
return;
|
|
1874
1910
|
}
|
|
1875
1911
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
@@ -1907,7 +1943,7 @@ export async function connect(opts) {
|
|
|
1907
1943
|
// Cloudflare's write-bot rule. Without it the tool falls back to aiohttp,
|
|
1908
1944
|
// which 403s ("cloudflare_mitigation" / "velocity_throttled") on every RSVP.
|
|
1909
1945
|
// Idempotent + best-effort; only paid when luma is actually selected.
|
|
1910
|
-
if (Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
1946
|
+
if (!payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
1911
1947
|
await _ensureLumaBridge();
|
|
1912
1948
|
}
|
|
1913
1949
|
const env = await buildAssistantEnv(payload, sid, generation);
|
|
@@ -1920,7 +1956,7 @@ export async function connect(opts) {
|
|
|
1920
1956
|
// matching spare for the NEXT new session — which then adopts it and skips
|
|
1921
1957
|
// the cold boot (see the ADOPTION fast path + schedulePrewarm above).
|
|
1922
1958
|
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
|
|
1923
|
-
|
|
1959
|
+
// The spare boots once this session reports ready (see the stdout parser).
|
|
1924
1960
|
}
|
|
1925
1961
|
catch (e) {
|
|
1926
1962
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
|
@@ -2079,6 +2115,22 @@ export async function connect(opts) {
|
|
|
2079
2115
|
if (opts.verbose)
|
|
2080
2116
|
console.log(chalk.gray(` [browser-bridge] ext-proxy turn (session=${payload.sessionId.slice(0, 10)} url=${turnBridgeUrl})`));
|
|
2081
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
|
+
}
|
|
2082
2134
|
// Capture for the stdout "done" handler closure below.
|
|
2083
2135
|
const _turnBridgeRunId = turnBridgeRunId;
|
|
2084
2136
|
const _turnId = payload.turnId;
|
|
@@ -2139,6 +2191,9 @@ export async function connect(opts) {
|
|
|
2139
2191
|
...(turnBridgeUrl && turnBridgeToken
|
|
2140
2192
|
? { mel_browser_url: turnBridgeUrl, mel_browser_token: turnBridgeToken }
|
|
2141
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 } : {}),
|
|
2142
2197
|
}) + "\n");
|
|
2143
2198
|
}
|
|
2144
2199
|
catch (e) {
|
|
@@ -2151,6 +2206,28 @@ export async function connect(opts) {
|
|
|
2151
2206
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
|
|
2152
2207
|
}
|
|
2153
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
|
+
});
|
|
2154
2231
|
socket.on("runner:assistant_stop", (payload) => {
|
|
2155
2232
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
2156
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
|
}
|