@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.
@@ -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
- toolkit = build_lazy_toolkit(
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
- connector_rule = (
825
- # Surgical tool pinning: these exact tool names are already ACTIVE (not
826
- # deferred) — telling the model to search_tools/activate_tool them first
827
- # would be a wasted round-trip against a tool that's already callable.
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
 
@@ -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
- const ASSISTANT_PROTOCOL_VERSION = 2;
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: forced-safe HITL + expiring in-run approvals (ST-15),
989
- // also reserved (after the credential spread).
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
- if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
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
- MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
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
- MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
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 && warmSpare.ready && !warmSpare.killed && expectedConfig
1857
- && warmSpare.configHash === expectedConfig && (warmSpare.memoryWatermark || 0) === 0
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
- console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
1871
- emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
1872
- schedulePrewarm(); // refill the pool for the next session
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
- schedulePrewarm();
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) {
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.59",
3
+ "version": "1.1.61",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",