diff --git a/external/nat/README.md b/external/nat/README.md index c6186b28d..29e349a2a 100644 --- a/external/nat/README.md +++ b/external/nat/README.md @@ -15,16 +15,18 @@ northbound `FabricConfig`, or depend on Pydantic for its contract boundary. ## Configuration Boundary -NeMo Fabric owns portable configuration. `workflow` selects a Fabric-enumerated -agent factory and `tools.definitions` supplies named functions and function -groups that the adapter resolves as installed NAT components. +NeMo Fabric owns portable configuration. `workflow` selects the existing +portable ReAct alias or an installed NAT registry factory. +`tools.definitions` supplies named functions and function groups that the +adapter resolves as installed NAT components. | NeMo Fabric input | NAT configuration | | --- | --- | | `models.` | `llms.`; every NeMo Fabric model-role name is preserved | -| `instructions.system` | Built-in `react_agent` workflow `additional_instructions`; other workflow types reject this field in the initial adapter | -| `workflow.entrypoint.kind=factory` | Resolve a Fabric-enumerated agent intent | +| `instructions.system` | Built-in shared and per-user ReAct workflow `additional_instructions`; other workflow types reject this field in the initial adapter | +| `workflow.entrypoint.kind=factory` | Resolve a NAT registry factory | | `workflow.entrypoint.ref=fabric.agent.react` | NAT `react_agent` workflow factory | +| Any other `workflow.entrypoint.ref` | Forward the short or fully qualified NAT registry type unchanged | | `workflow.settings` | Remaining `workflow` component fields | | `tools.definitions.` with `kind=function` | NAT `functions.`; `ref` becomes `_type` | | `tools.definitions.` with `kind=function_group` | NAT `function_groups.`; `ref` becomes `_type` | @@ -34,15 +36,52 @@ groups that the adapter resolves as installed NAT components. The adapter loads installed `nat.components` entry points before NAT validates the generated configuration. A custom function or function group is supplied as an installed NAT component package and selected by `tools.definitions.ref`. -No Python callable crosses the configuration contract. A custom adapter may -publish a broader workflow schema without changing this shared NAT adapter. +No Python callable crosses the configuration contract. Installed NAT owns the +accepted registry types and validates their native settings. + +NAT validates registry references after loading installed component entry +points. The adapter does not maintain a workflow catalog and forwards any +installed workflow registry reference. Execution is limited to workflows whose +component graph can be expressed through the translated surfaces above: +workflow settings, LLMs, functions, function groups, and MCP. Workflows that +require other top-level NAT configuration sections, such as embedders, memory, +object stores, retrievers, or middleware, are outside this initial reference. + +The adapter translates portable system instructions only for the exact shared +and per-user ReAct configuration shapes whose fields it knows. Normalized tool +policy and MCP configuration work with any workflow whose native settings +expose a string-list `tool_names` field. Other workflow-specific configuration +remains in `workflow.settings`. At runtime, `start` loads components, enters one `WorkflowBuilder`, creates a `SessionManager` with that shared builder, and retains both resources. Each `invoke` opens a session from the retained manager, enters `session.run(...)`, -and awaits `runner.result()`. `stop` shuts down the session manager and exits -the builder context. This first reference does not claim cancellation, service, -streaming, or live-update support. +and awaits `runner.result()`. The adapter reads NAT's session-manager metadata +to determine whether invocation identity is required, validates and forwards +that identity to `SessionManager.session(...)`, and leaves builder creation, +caching, and cleanup to NAT. Repeated requests for one user reuse NAT's cached +builder, different users remain isolated, and separate NeMo Fabric runtimes +own separate session managers. + +After starting a multi-turn runtime, invoke a per-user workflow with a typed +request: + +```python +from nemo_fabric import RunRequest + +result = await runtime.invoke( + request=RunRequest( + input="What did I ask previously?", + context={"user_id": "user-123"}, + ) +) +``` + +`stop` first shuts down the session manager, including NAT's cleanup task and +cached per-user builders, and then exits the shared builder context. NAT can +also evict inactive per-user builders according to its session cleanup policy. +This reference does not claim cancellation, service, streaming, or live-update +support. ## MCP Tool Filters diff --git a/external/nat/fabric-adapter.json b/external/nat/fabric-adapter.json index 8e1416b34..8506ee868 100644 --- a/external/nat/fabric-adapter.json +++ b/external/nat/fabric-adapter.json @@ -21,11 +21,13 @@ "properties": { "kind": { "const": "factory", - "description": "Resolve a Fabric-enumerated agent intent through this adapter." + "description": "Resolve a NAT registry factory through this adapter." }, "ref": { - "const": "fabric.agent.react", - "description": "Build Fabric's ReAct agent intent with the NAT react_agent factory." + "type": "string", + "minLength": 1, + "pattern": "^\\S+$", + "description": "Resolve an installed NAT workflow registry type; fabric.agent.react is retained as the portable ReAct alias." } }, "required": ["kind", "ref"], diff --git a/external/nat/src/nemo_fabric_adapters/nat/adapter.py b/external/nat/src/nemo_fabric_adapters/nat/adapter.py index 3461ac462..d1bb2d00b 100644 --- a/external/nat/src/nemo_fabric_adapters/nat/adapter.py +++ b/external/nat/src/nemo_fabric_adapters/nat/adapter.py @@ -6,7 +6,9 @@ The adapter builds one in-memory NAT configuration from Fabric's normalized configuration and adapter-owned NAT component settings. One persistent adapter -host owns the resulting workflow for the complete Fabric runtime lifecycle. +host owns the shared builder and session manager for the complete Fabric +runtime lifecycle. NAT owns workflow-specific session behavior beneath that +manager, including per-user workflow builders. """ from __future__ import annotations @@ -29,10 +31,15 @@ WORKFLOW_FACTORY_KIND = "factory" FABRIC_REACT_AGENT = "fabric.agent.react" FUNCTION_GROUP_SEPARATOR = "__" -REACT_AGENT_REFS = frozenset( +# This allowlist selects the field codec for NAT's known ReAct configuration +# models. It never determines shared versus per-user lifecycle; SessionManager +# owns that decision. +REACT_CONFIG_TYPES = frozenset( { "react_agent", + "per_user_react_agent", "nat.plugins.langchain.agent.react_agent/react_agent", + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", } ) RESERVED_MODEL_SETTINGS = frozenset( @@ -98,13 +105,6 @@ def _nat_workflow(agent_config: AgentConfig) -> dict[str, Any]: f"workflow.entrypoint.kind must equal {WORKFLOW_FACTORY_KIND!r}", field="workflow.entrypoint.kind", ) - if entrypoint.ref != FABRIC_REACT_AGENT: - raise _config_error( - "nat_invalid_workflow", - f"NAT does not support workflow factory {entrypoint.ref!r}", - field="workflow.entrypoint.ref", - ) - settings = workflow_config.settings if "_type" in settings: raise _config_error( @@ -114,8 +114,10 @@ def _nat_workflow(agent_config: AgentConfig) -> dict[str, Any]: ) workflow = copy.deepcopy(settings) - workflow["_type"] = "react_agent" - if _is_react_agent(workflow): + workflow["_type"] = ( + "react_agent" if entrypoint.ref == FABRIC_REACT_AGENT else entrypoint.ref + ) + if _uses_react_config_shape(workflow): workflow.setdefault("tool_names", []) return workflow @@ -214,8 +216,8 @@ def _nat_llms(agent_config: AgentConfig) -> dict[str, dict[str, Any]]: return llms -def _is_react_agent(workflow: dict[str, Any]) -> bool: - return workflow.get("_type") in REACT_AGENT_REFS +def _uses_react_config_shape(workflow: dict[str, Any]) -> bool: + return workflow.get("_type") in REACT_CONFIG_TYPES def _apply_system_instruction( @@ -226,7 +228,7 @@ def _apply_system_instruction( return workflow = config["workflow"] - if not _is_react_agent(workflow): + if not _uses_react_config_shape(workflow): raise _config_error( "nat_system_instruction_unsupported", "instructions.system is supported only for a NAT react_agent workflow", @@ -636,7 +638,11 @@ def build_nat_config(agent_config: AgentConfig) -> Any: ) from error -def _session_kwargs(request: dict[str, Any]) -> dict[str, str]: +def _session_kwargs( + request: dict[str, Any], + *, + require_user_id: bool = False, +) -> dict[str, str]: context = request.get("context") if context is None: context = {} @@ -648,11 +654,18 @@ def _session_kwargs(request: dict[str, Any]) -> dict[str, str]: "conversation_id": context.get("conversation_id"), "user_message_id": context.get("user_message_id") or request.get("request_id"), } + user_id = values["user_id"] + if require_user_id and (not isinstance(user_id, str) or not user_id.strip()): + raise ValueError( + "NAT per-user workflow requires request.context.user_id " + "as a non-empty string" + ) + result: dict[str, str] = {} for name, value in values.items(): if value is None: continue - if not isinstance(value, str) or not value: + if not isinstance(value, str) or not value.strip(): raise ValueError( f"NAT invocation request context {name} must be a non-empty string" ) @@ -767,7 +780,12 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: "NAT invocation request must be a mapping", ) try: - session_kwargs = _session_kwargs(request) + session_kwargs = _session_kwargs( + request, + require_user_id=bool( + getattr(self._sessions, "is_workflow_per_user", False) + ), + ) except ValueError as error: return _failure_output("nat_invalid_request", str(error)) diff --git a/tests/adapters/test_external_nat_adapter.py b/tests/adapters/test_external_nat_adapter.py index 638e9c60f..3ac59636b 100644 --- a/tests/adapters/test_external_nat_adapter.py +++ b/tests/adapters/test_external_nat_adapter.py @@ -133,6 +133,7 @@ def mock_nat_fixture(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: mock_sessions = MagicMock(name="sessions") mock_sessions.session = MagicMock(return_value=mock_session_context) mock_sessions.shutdown = AsyncMock() + mock_sessions.is_workflow_per_user = False mock_session_manager = MagicMock(name="SessionManager") mock_session_manager.create = AsyncMock(return_value=mock_sessions) @@ -245,7 +246,10 @@ def test_descriptor_declares_exact_source_reference_contract(): assert workflow_schema["additionalProperties"] is False entrypoint_schema = workflow_schema["properties"]["entrypoint"] assert entrypoint_schema["properties"]["kind"]["const"] == "factory" - assert entrypoint_schema["properties"]["ref"]["const"] == "fabric.agent.react" + ref_schema = entrypoint_schema["properties"]["ref"] + assert ref_schema["type"] == "string" + assert ref_schema["minLength"] == 1 + assert ref_schema["pattern"] == r"^\S+$" assert entrypoint_schema["required"] == ["kind", "ref"] assert entrypoint_schema["additionalProperties"] is False assert workflow_schema["properties"]["settings"]["properties"]["_type"] is False @@ -355,22 +359,108 @@ def test_system_instruction_rejects_duplicate_nat_instruction_source(make_payloa assert error.value.code == "nat_system_instruction_conflict" -def test_react_agent_without_tool_names_defaults_to_empty_list(make_payload): - payload = make_payload(workflow=_fabric_workflow(llm_name="default")) +@pytest.mark.parametrize( + ("ref", "expected_type", "uses_react_config_shape"), + [ + ("fabric.agent.react", "react_agent", True), + ("react_agent", "react_agent", True), + ( + "nat.plugins.langchain.agent.react_agent/react_agent", + "nat.plugins.langchain.agent.react_agent/react_agent", + True, + ), + ("per_user_react_agent", "per_user_react_agent", True), + ( + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", + True, + ), + ("tool_calling_agent", "tool_calling_agent", False), + ( + "nat.plugins.langchain.agent.tool_calling_agent/tool_calling_agent", + "nat.plugins.langchain.agent.tool_calling_agent/tool_calling_agent", + False, + ), + ("third.party/react_agent", "third.party/react_agent", False), + ("third.party/per_user_react_agent", "third.party/per_user_react_agent", False), + ], +) +def test_workflow_refs_pass_through_with_react_tool_defaults( + make_payload, + ref: str, + expected_type: str, + uses_react_config_shape: bool, +): + payload = make_payload(workflow=_fabric_workflow(ref, llm_name="default")) result = adapter.build_nat_config_mapping(payload["config"]) - assert result["workflow"]["tool_names"] == [] + assert result["workflow"]["_type"] == expected_type + if uses_react_config_shape: + assert result["workflow"]["tool_names"] == [] + else: + assert "tool_names" not in result["workflow"] -def test_shared_nat_adapter_rejects_an_unknown_fabric_factory(make_payload): - payload = make_payload(workflow=_fabric_workflow("fabric.agent.custom")) +@pytest.mark.parametrize( + "ref", + ["third.party/react_agent", "third.party/per_user_react_agent"], +) +def test_arbitrary_registry_ref_does_not_inherit_react_field_translation( + make_payload, + ref: str, +): + payload = make_payload( + workflow=_fabric_workflow(ref, llm_name="default"), + instruction="Portable instruction", + ) with pytest.raises(adapter.lifecycle.LifecycleError) as error: adapter.build_nat_config_mapping(payload["config"]) - assert error.value.code == "nat_invalid_workflow" - assert error.value.metadata["field"] == "workflow.entrypoint.ref" + assert error.value.code == "nat_system_instruction_unsupported" + + +@pytest.mark.parametrize( + "ref", + [ + "per_user_react_agent", + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", + ], +) +def test_per_user_react_maps_system_instruction_and_tool_policy( + make_payload, + ref: str, +): + payload = make_payload( + workflow=_fabric_workflow( + ref, + llm_name="default", + tool_names=["clock", "unused"], + ), + functions={ + "clock": {"_type": "current_datetime"}, + "unused": {"_type": "unused_function"}, + }, + instruction="Use the clock for time questions.", + tools={"enabled": ["clock"]}, + ) + + result = adapter.build_nat_config_mapping(payload["config"]) + + assert result["workflow"]["additional_instructions"] == ( + "Use the clock for time questions." + ) + assert result["workflow"]["tool_names"] == ["clock"] + assert result["functions"] == {"clock": {"_type": "current_datetime"}} + + +def test_nat_registry_ref_passes_through_without_adapter_catalog(make_payload): + payload = make_payload(workflow=_fabric_workflow("installed.custom/workflow")) + + result = adapter.build_nat_config_mapping(payload["config"]) + + assert result["workflow"] == {"_type": "installed.custom/workflow"} def test_missing_root_workflow_is_rejected(make_payload): @@ -388,7 +478,6 @@ def test_missing_root_workflow_is_rejected(make_payload): ("workflow", "field"), [ (_fabric_workflow(kind="python_callable"), "workflow.entrypoint.kind"), - (_fabric_workflow("fabric.agent.unknown"), "workflow.entrypoint.ref"), (_fabric_workflow(_type="react_agent"), "workflow.settings._type"), ], ) @@ -439,6 +528,56 @@ def test_typed_examples_project_and_translate_through_one_nat_adapter( } +@pytest.mark.parametrize( + ("ref", "expected_type", "uses_react_config_shape"), + [ + ("per_user_react_agent", "per_user_react_agent", True), + ( + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", + True, + ), + ("tool_calling_agent", "tool_calling_agent", False), + ( + "nat.plugins.langchain.agent.tool_calling_agent/tool_calling_agent", + "nat.plugins.langchain.agent.tool_calling_agent/tool_calling_agent", + False, + ), + ], +) +def test_nat_workflow_refs_plan_through_generic_descriptor( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ref: str, + expected_type: str, + *, + uses_react_config_shape: bool, +): + descriptor = ROOT / "external" / "nat" / "fabric-adapter.json" + staged_descriptor = tmp_path / "adapters" / "nat" / "fabric-adapter.json" + staged_descriptor.parent.mkdir(parents=True) + staged_descriptor.write_text( + descriptor.read_text(encoding="utf-8"), encoding="utf-8" + ) + namespace = runpy.run_path( + str(ROOT / "external" / "nat" / "examples" / "calculator.py") + ) + config = namespace["build_config"]() + config.workflow.entrypoint.ref = ref + if not uses_react_config_shape: + config.instructions = None + config.mcp = None + config.tools = None + monkeypatch.setenv("NVIDIA_API_KEY", "test-key") + + plan = Fabric().plan(config, base_dir=tmp_path) + + assert plan.config.workflow.entrypoint.ref == ref + southbound = AgentConfig.from_mapping(plan.to_mapping()["agent_config"]) + nat_config = adapter.build_nat_config_mapping(southbound) + assert nat_config["workflow"]["_type"] == expected_type + + def test_calculator_example_uses_the_source_stdio_server(): namespace = runpy.run_path( str(ROOT / "external" / "nat" / "examples" / "calculator.py") @@ -486,9 +625,27 @@ def test_build_typed_config_discovers_components_before_validation( mock_nat["discover"].assert_called_once_with(mock_nat["plugin_types"].CONFIG_OBJECT) +@pytest.mark.parametrize( + ("ref", "expected_type"), + [ + ("fabric.agent.react", "react_agent"), + ("per_user_react_agent", "per_user_react_agent"), + ( + "nat.plugins.langchain.agent.react_agent/per_user_react_agent", + "per_user_react_agent", + ), + ("tool_calling_agent", "tool_calling_agent"), + ( + "nat.plugins.langchain.agent.tool_calling_agent/tool_calling_agent", + "tool_calling_agent", + ), + ], +) def test_build_typed_config_contract_with_installed_nat( make_payload, monkeypatch: pytest.MonkeyPatch, + ref: str, + expected_type: str, ): config_module = pytest.importorskip( "nat.data_models.config", @@ -500,7 +657,7 @@ def test_build_typed_config_contract_with_installed_nat( ) monkeypatch.setenv("NVIDIA_API_KEY", "test-key") payload = make_payload( - workflow=_fabric_workflow(llm_name="default"), + workflow=_fabric_workflow(ref, llm_name="default"), models={ "default": { "provider": "nvidia", @@ -513,10 +670,82 @@ def test_build_typed_config_contract_with_installed_nat( result = adapter.build_nat_config(payload["config"]) assert isinstance(result, config_module.Config) - assert result.workflow.type == "react_agent" + assert result.workflow.type == expected_type assert result.llms["default"].model_name == "nvidia/test-model" +async def test_installed_nat_reuses_isolates_and_cleans_per_user_builders( + make_payload, + monkeypatch: pytest.MonkeyPatch, +): + session_module = pytest.importorskip( + "nat.runtime.session", + reason="NAT is not installed in the base Fabric test environment", + ) + per_user_builder_module = pytest.importorskip( + "nat.builder.per_user_workflow_builder", + reason="The installed NAT version does not support per-user workflows", + ) + pytest.importorskip( + "nat.plugins.langchain.agent.react_agent", + reason="The NAT LangChain extra is not installed", + ) + config = adapter.build_nat_config( + make_payload( + workflow=_fabric_workflow( + "per_user_react_agent", + llm_name="default", + ) + )["config"] + ) + shared_builder = MagicMock(name="shared-builder") + workflows = [MagicMock(name="workflow-a"), MagicMock(name="workflow-b")] + builders: list[MagicMock] = [] + for index, workflow in enumerate(workflows): + builder = MagicMock(name=f"per-user-builder-{index}") + builder.__aenter__ = AsyncMock(return_value=builder) + builder.__aexit__ = AsyncMock(return_value=False) + builder.populate_builder = AsyncMock() + builder.build = AsyncMock(return_value=workflow) + builders.append(builder) + mock_per_user_builder = MagicMock( + name="PerUserWorkflowBuilder", + side_effect=builders, + ) + monkeypatch.setattr( + per_user_builder_module, + "PerUserWorkflowBuilder", + mock_per_user_builder, + ) + + manager = await session_module.SessionManager.create( + config=config, + shared_builder=shared_builder, + ) + assert manager.is_workflow_per_user is True + try: + async with manager.session(user_id="user-a") as first_a: + first_a_workflow = first_a.workflow + async with manager.session(user_id="user-b") as first_b: + first_b_workflow = first_b.workflow + async with manager.session(user_id="user-a") as second_a: + second_a_workflow = second_a.workflow + finally: + await manager.shutdown() + + assert first_a_workflow is workflows[0] + assert first_b_workflow is workflows[1] + assert second_a_workflow is workflows[0] + assert mock_per_user_builder.call_args_list == [ + call(user_id="user-a", shared_builder=shared_builder), + call(user_id="user-b", shared_builder=shared_builder), + ] + for builder in builders: + builder.populate_builder.assert_awaited_once_with(config) + builder.build.assert_awaited_once_with(entry_function=None) + builder.__aexit__.assert_awaited_once_with(None, None, None) + + @pytest.mark.parametrize( ("server_policy", "expected_group"), [ @@ -812,6 +1041,156 @@ async def test_runtime_reuses_one_builder_across_invocations_and_cleans_up( assert mock_nat["builder_context"].__aexit__.await_count == 1 +async def test_per_user_runtime_forwards_identity_and_cleans_nat_before_builder( + make_payload, + make_invocation_payload, + mock_nat, +): + mock_nat["sessions"].is_workflow_per_user = True + events: list[str] = [] + mock_nat["runner"].result = AsyncMock( + side_effect=[{"answer": "a1"}, {"answer": "b1"}, {"answer": "a2"}] + ) + mock_nat["sessions"].shutdown.side_effect = lambda: events.append("sessions") + mock_nat["builder_context"].__aexit__.side_effect = lambda *_args: ( + events.append("builder") or False + ) + runtime = adapter.NatRuntime() + await runtime.start( + make_payload( + workflow=_fabric_workflow( + "nat.plugins.langchain.agent.react_agent/per_user_react_agent" + ) + ) + ) + + results = [ + await runtime.invoke( + make_invocation_payload( + input_value=message, + request_id=f"message-{index}", + context={"user_id": user}, + ) + ) + for index, (user, message) in enumerate( + [("user-a", "first"), ("user-b", "first"), ("user-a", "second")], + start=1, + ) + ] + await runtime.stop() + + assert [result["response"] for result in results] == [ + {"answer": "a1"}, + {"answer": "b1"}, + {"answer": "a2"}, + ] + assert mock_nat["sessions"].session.call_args_list == [ + call(user_id="user-a", user_message_id="message-1"), + call(user_id="user-b", user_message_id="message-2"), + call(user_id="user-a", user_message_id="message-3"), + ] + mock_nat["workflow_builder"].from_config.assert_called_once_with( + config=mock_nat["typed_config"] + ) + mock_nat["session_manager"].create.assert_awaited_once_with( + config=mock_nat["typed_config"], + shared_builder=mock_nat["builder"], + ) + assert events == ["sessions", "builder"] + + +@pytest.mark.parametrize( + "context", + [ + None, + {}, + {"user_id": ""}, + {"user_id": " \t "}, + {"user_id": 42}, + ], +) +async def test_per_user_runtime_rejects_missing_or_invalid_user_before_session( + make_payload, + make_invocation_payload, + mock_nat, + context: dict[str, Any] | None, +): + mock_nat["sessions"].is_workflow_per_user = True + runtime = adapter.NatRuntime() + await runtime.start(make_payload(workflow=_fabric_workflow("tool_calling_agent"))) + try: + result = await runtime.invoke(make_invocation_payload(context=context)) + finally: + await runtime.stop() + + assert result["error"] == { + "code": "nat_invalid_request", + "message": ( + "NAT per-user workflow requires request.context.user_id " + "as a non-empty string" + ), + "retryable": False, + } + mock_nat["sessions"].session.assert_not_called() + + +async def test_runtime_defaults_missing_per_user_metadata_to_shared( + make_payload, + make_invocation_payload, + mock_nat, +): + del mock_nat["sessions"].is_workflow_per_user + runtime = adapter.NatRuntime() + await runtime.start(make_payload(workflow=_fabric_workflow("per_user_react_agent"))) + try: + result = await runtime.invoke(make_invocation_payload()) + finally: + await runtime.stop() + + assert result["response"] == {"answer": 42} + mock_nat["sessions"].session.assert_called_once_with( + user_message_id="request-1" + ) + + +async def test_independent_fabric_runtimes_create_separate_nat_managers( + make_payload, + mock_nat, +): + builders: list[MagicMock] = [] + builder_contexts: list[MagicMock] = [] + managers: list[MagicMock] = [] + for index in range(2): + builder = MagicMock(name=f"builder-{index}") + builder_context = MagicMock(name=f"builder-context-{index}") + builder_context.__aenter__ = AsyncMock(return_value=builder) + builder_context.__aexit__ = AsyncMock(return_value=False) + manager = MagicMock(name=f"manager-{index}") + manager.shutdown = AsyncMock() + builders.append(builder) + builder_contexts.append(builder_context) + managers.append(manager) + + mock_nat["workflow_builder"].from_config.side_effect = builder_contexts + mock_nat["session_manager"].create.side_effect = managers + first_payload = make_payload(workflow=_fabric_workflow("per_user_react_agent")) + second_payload = make_payload(workflow=_fabric_workflow("per_user_react_agent")) + second_payload["runtime_context"]["runtime_id"] = "runtime-2" + first_runtime = adapter.NatRuntime() + second_runtime = adapter.NatRuntime() + await first_runtime.start(first_payload) + await second_runtime.start(second_payload) + await first_runtime.stop() + await second_runtime.stop() + + assert mock_nat["session_manager"].create.await_args_list == [ + call(config=mock_nat["typed_config"], shared_builder=builders[0]), + call(config=mock_nat["typed_config"], shared_builder=builders[1]), + ] + managers[0].shutdown.assert_awaited_once_with() + managers[1].shutdown.assert_awaited_once_with() + + async def test_start_rejects_an_already_started_runtime(make_payload, mock_nat): runtime = adapter.NatRuntime() await runtime.start(make_payload())