@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.
@@ -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:
@@ -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 labels "
790
- "you read, collect to gather items across scrolls, for_each to walk list rows) "
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
- connector_rule = (
840
- # Surgical tool pinning: these exact tool names are already ACTIVE (not
841
- # deferred) — telling the model to search_tools/activate_tool them first
842
- # would be a wasted round-trip against a tool that's already callable.
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
 
@@ -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";
@@ -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
- if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
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
- 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" : "",
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
- 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" : "",
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 && warmSpare.ready && !warmSpare.killed && expectedConfig
1854
- && 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
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
- console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
1868
- emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
1869
- 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
+ }
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
- schedulePrewarm();
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) {
@@ -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.60",
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",