Skip to content

Commit 6a08ebf

Browse files
authored
fix(runtime): dynamic MAS workflow delegation in run-mas (#3)
MAS workflow.delegates_to becomes delegate_to_* tools on the entry agent. Adds manifest merge at run-mas bootstrap, tool ref resolution, engine tool dispatch, overlay params staging, docs/schemas, and tests.
1 parent c939107 commit 6a08ebf

38 files changed

Lines changed: 1190 additions & 213 deletions

ctl/src/mas/ctl/compose/backends/mas_runtime_py.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ def create_agent_runtime(self, bind: EffectiveBindManifest, agent_id: str) -> Ru
4141
if mp.is_file():
4242
agent_manifest = yaml.safe_load(mp.read_text(encoding="utf-8"))
4343
manifest_dir = mp.parent
44+
if agent_manifest:
45+
from mas.ctl.manifest.spec_bindings import parse_collaboration
46+
from mas.runtime.engine.tools import resolve_manifest_tool_refs
47+
48+
parse_collaboration((agent_manifest.get("spec") or {}).get("collaboration"))
49+
agent_manifest = resolve_manifest_tool_refs(agent_manifest, manifest_dir)
4450

4551
instance, _ = instantiate_runtime(
4652
InstantiationOptions(

ctl/src/mas/ctl/executor/run_mas.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ def execute_run_mas(
5959
validate=validate,
6060
)
6161
result = compose_run(req)
62+
from mas.ctl.session.params_sidecar import (
63+
apply_runtime_params_to_instance,
64+
params_from_mas_config,
65+
stage_runtime_params,
66+
)
67+
68+
runtime_params = params_from_mas_config(result.mas_config)
69+
if runtime_params:
70+
stage_runtime_params(runtime_params)
71+
6272
composed = compose_application(result.mas_config, mas_id=result.mas_id)
6373
plan = compose_placement_from_deployment(result.deployment, composed)
6474
bind = compose_effective_bind(
@@ -106,8 +116,31 @@ def execute_run_mas(
106116
instance.driver.agent_id = str(entry or "agent")
107117

108118
agent_manifest = _load_agent_manifest(bind, str(entry or ""))
119+
agent_manifest_path = _agent_manifest_path(bind, str(entry or ""))
120+
entry_manifest_dir = agent_manifest_path.parent if agent_manifest_path else base
121+
from mas.ctl.manifest.mas_agent_merge import enrich_entry_agent_for_delegation, wire_entry_engine_delegation
122+
123+
enriched_manifest = enrich_entry_agent_for_delegation(
124+
agent_manifest or {},
125+
result.mas_config,
126+
manifest_dir=entry_manifest_dir,
127+
)
128+
wire_entry_engine_delegation(
129+
getattr(getattr(instance, "driver", None), "engine", None),
130+
enriched_manifest,
131+
entry_manifest_dir,
132+
run_turn=_make_workflow_send(
133+
materialized,
134+
display=display,
135+
verbose=verbose,
136+
from_agent=str(entry or ""),
137+
),
138+
entry_agent_id=str(entry or ""),
139+
)
140+
if runtime_params:
141+
apply_runtime_params_to_instance(runtime_params, instance)
109142
hitl_responder, _ = resolve_hitl_from_manifest(
110-
agent_manifest,
143+
enriched_manifest,
111144
session_interactive=interactive or not auto_hitl,
112145
)
113146
if hitl_responder is not None:
@@ -299,8 +332,15 @@ def _entry_agent(mas_config: dict) -> str | None:
299332

300333

301334
def _load_agent_manifest(bind: Any, agent_id: str) -> dict | None:
335+
mp = _agent_manifest_path(bind, agent_id)
336+
if mp is None or not mp.is_file():
337+
return None
302338
import yaml
303339

340+
return yaml.safe_load(mp.read_text(encoding="utf-8"))
341+
342+
343+
def _agent_manifest_path(bind: Any, agent_id: str) -> Path | None:
304344
for agent in bind.agents:
305345
if agent.agent_id != agent_id:
306346
continue
@@ -311,6 +351,6 @@ def _load_agent_manifest(bind: Any, agent_id: str) -> dict | None:
311351
if not mp.is_absolute():
312352
base = bind.mas_base_dir or Path.cwd()
313353
mp = (base / mp).resolve()
314-
if mp.is_file():
315-
return yaml.safe_load(mp.read_text(encoding="utf-8"))
354+
return mp
316355
return None
356+
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Merge MAS workflow onto the entry agent manifest at run-mas bootstrap."""
4+
5+
from __future__ import annotations
6+
7+
import copy
8+
import logging
9+
from collections.abc import Callable
10+
from pathlib import Path
11+
from typing import Any
12+
13+
from mas.ctl.manifest.spec_bindings import parse_collaboration
14+
from mas.runtime.boundary.delegation.llm_delegator import LlmDelegator
15+
from mas.runtime.boundary.delegation.policy import delegation_targets
16+
from mas.runtime.engine.llm_live import LiveLlmEngine
17+
from mas.runtime.engine.tools import resolve_manifest_tool_refs
18+
19+
logger = logging.getLogger(__name__)
20+
21+
RunTurnFn = Callable[[str, str], str]
22+
23+
24+
def _leaf_engine(engine: Any) -> Any:
25+
"""Unwrap infra pipeline facades to the engine that handles LLM/tool IO."""
26+
seen: set[int] = set()
27+
current = engine
28+
while current is not None and id(current) not in seen:
29+
seen.add(id(current))
30+
inner = getattr(current, "inner", None)
31+
if inner is None:
32+
return current
33+
current = inner
34+
return current
35+
36+
37+
def enrich_entry_agent_for_delegation(
38+
agent_manifest: dict[str, Any],
39+
mas_config: dict[str, Any],
40+
*,
41+
manifest_dir: Path | None = None,
42+
) -> dict[str, Any]:
43+
"""Attach MAS ``workflow`` to the entry agent; resolve tool refs."""
44+
spec = agent_manifest.get("spec") or {}
45+
parse_collaboration(spec.get("collaboration"))
46+
out = copy.deepcopy(agent_manifest)
47+
mas_spec = mas_config.get("spec", mas_config) if isinstance(mas_config, dict) else {}
48+
wf = mas_spec.get("workflow")
49+
if isinstance(wf, dict):
50+
spec_out = out.setdefault("spec", {})
51+
if spec_out.get("workflow") and spec_out.get("workflow") != wf:
52+
logger.warning(
53+
"entry agent spec.workflow replaced by MAS workflow (MAS topology wins)"
54+
)
55+
# wf comes from mas_config (not agent_manifest); copy once to avoid shared refs.
56+
spec_out["workflow"] = copy.deepcopy(wf)
57+
if manifest_dir is not None:
58+
resolve_manifest_tool_refs(out, manifest_dir, inplace=True)
59+
return out
60+
61+
62+
def wire_entry_engine_delegation(
63+
engine: Any,
64+
manifest: dict[str, Any],
65+
manifest_dir: Path,
66+
*,
67+
run_turn: RunTurnFn,
68+
entry_agent_id: str,
69+
) -> None:
70+
"""Set enriched manifest on the entry engine and bind ``LlmDelegator`` when peers exist.
71+
72+
When peers exist, ``use_tool_loop`` is enabled on the leaf engine so the LLM can
73+
emit ``delegate_to_*`` tool calls. A manifest or instantiation that set
74+
``use_tool_loop=False`` is overridden with a warning.
75+
"""
76+
if engine is None:
77+
return
78+
leaf = _leaf_engine(engine)
79+
leaf.manifest = manifest
80+
if isinstance(leaf, LiveLlmEngine):
81+
leaf.manifest_dir = manifest_dir
82+
peers = delegation_targets(manifest, agent_id=entry_agent_id)
83+
if not peers:
84+
leaf.delegation = None
85+
return
86+
leaf.delegation = LlmDelegator(run_turn=run_turn)
87+
if hasattr(leaf, "use_tool_loop"):
88+
if not leaf.use_tool_loop:
89+
logger.warning(
90+
"entry agent %r: enabling use_tool_loop for dynamic delegation (%d peers)",
91+
entry_agent_id,
92+
len(peers),
93+
)
94+
leaf.use_tool_loop = True
95+
96+
97+
def reset_engine_delegation(engine: Any) -> None:
98+
"""Clear delegate caches at the start of each user turn."""
99+
while engine is not None:
100+
delegation = getattr(engine, "delegation", None)
101+
reset_fn = getattr(delegation, "reset_session", None)
102+
if callable(reset_fn):
103+
reset_fn()
104+
engine = getattr(engine, "inner", None)

ctl/src/mas/ctl/manifest/spec_bindings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ def parse_collaboration(raw: Any) -> None:
216216
if not isinstance(raw, dict):
217217
raise SpecBindingError(f"spec.collaboration must be an object, got {type(raw).__name__}")
218218
_reject_unknown_keys(raw, allowed=_COLLABORATION_KEYS, field="spec.collaboration")
219+
if raw.get("ref"):
220+
raise SpecBindingError(
221+
"spec.collaboration.ref is not supported in this release; omit spec.collaboration"
222+
)
223+
typ = raw.get("type")
224+
if isinstance(typ, str) and typ.strip() and typ.strip().lower() != "none":
225+
raise SpecBindingError(
226+
f"spec.collaboration.type {typ.strip()!r} is not supported in this release; "
227+
"omit spec.collaboration or set type: none"
228+
)
219229

220230

221231
def parse_context_manager(raw: Any) -> None:

ctl/src/mas/ctl/session/controller.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ def run_turn(self, text: str, *, turn_id: str | None = None, auto_hitl: bool = T
142142
return self._run_user_turn(text, turn_id=turn_id, auto_hitl=auto_hitl)
143143

144144
def _run_user_turn(self, text: str, *, turn_id: str | None = None, auto_hitl: bool = True) -> TurnResult:
145+
from mas.ctl.manifest.mas_agent_merge import reset_engine_delegation
146+
147+
reset_engine_delegation(getattr(self.instance.driver, "engine", None))
145148
self._turn += 1
146149
tid = turn_id or f"u{self._turn}"
147150
self.display.on_user(text, turn_id=tid)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Stage overlay ``spec.params`` for tools without polluting the project tree."""
4+
5+
from __future__ import annotations
6+
7+
import os
8+
import tempfile
9+
from collections.abc import MutableMapping
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import yaml
14+
15+
MAS_RUNTIME_ARTIFACTS_ENV = "MAS_RUNTIME_ARTIFACTS_DIR"
16+
17+
18+
def stage_runtime_params(
19+
params: dict[str, Any],
20+
*,
21+
environ: MutableMapping[str, str] | None = None,
22+
) -> Path | None:
23+
"""Write params to a process-scoped runtime dir and expose via ``MAS_RUNTIME_ARTIFACTS_DIR``.
24+
25+
Writes ``artifacts/scene.yaml`` under ``$XDG_RUNTIME_DIR/mas-ctl/run-<pid>`` (or temp).
26+
Tool modules that read fixture paths from the filesystem should use that env var until
27+
they consume ``ctx.runtime_params`` from the engine tool loop.
28+
"""
29+
if not params:
30+
return None
31+
32+
env = os.environ if environ is None else environ
33+
base = env.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
34+
root = Path(base) / "mas-ctl" / f"run-{os.getpid()}"
35+
sidecar_dir = root / "artifacts"
36+
sidecar_dir.mkdir(parents=True, exist_ok=True)
37+
sidecar_path = sidecar_dir / "scene.yaml"
38+
sidecar_path.write_text(
39+
yaml.safe_dump(params, default_flow_style=False, allow_unicode=True),
40+
encoding="utf-8",
41+
)
42+
env[MAS_RUNTIME_ARTIFACTS_ENV] = str(root)
43+
return sidecar_path
44+
45+
46+
def apply_runtime_params_to_instance(params: dict[str, Any], instance: Any) -> None:
47+
"""Thread overlay params through the runtime ctx for in-process tool modules."""
48+
if not params:
49+
return
50+
ctx = getattr(getattr(instance, "driver", None), "ctx", None)
51+
if ctx is None:
52+
return
53+
# TODO: tool execution should read ctx.runtime_params; env sidecar is interim.
54+
ctx.runtime_params = dict(params)
55+
56+
57+
def params_from_mas_config(mas_config: dict[str, Any]) -> dict[str, Any]:
58+
spec = mas_config.get("spec", mas_config) if isinstance(mas_config, dict) else {}
59+
raw = spec.get("params") or {}
60+
return dict(raw) if isinstance(raw, dict) else {}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import pytest
5+
6+
from mas.ctl.manifest.spec_bindings import SpecBindingError, parse_collaboration
7+
8+
9+
def test_parse_collaboration_rejects_non_none_type():
10+
with pytest.raises(SpecBindingError, match="llm-delegator"):
11+
parse_collaboration({"type": "llm-delegator"})
12+
13+
14+
def test_parse_collaboration_rejects_ref():
15+
with pytest.raises(SpecBindingError, match="ref"):
16+
parse_collaboration({"ref": "module://example.Plugin"})
17+
18+
19+
def test_parse_collaboration_allows_none():
20+
parse_collaboration({"type": "none"})

0 commit comments

Comments
 (0)