diff --git a/adapters/codex/fabric-adapter.json b/adapters/codex/fabric-adapter.json index 84333740a..2f7dbc80c 100644 --- a/adapters/codex/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -85,6 +85,7 @@ "additionalProperties": false }, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/adapters/codex/pyproject.toml b/adapters/codex/pyproject.toml index 3fb114738..cd5aaaca5 100644 --- a/adapters/codex/pyproject.toml +++ b/adapters/codex/pyproject.toml @@ -25,8 +25,8 @@ license-files = ["LICENSE"] readme = "pypi.md" requires-python = ">=3.11" dependencies = [ - "nemo-fabric-adapters-common == 0.2.0", "nemo-fabric-adapter-contract == 0.2.0", + "nemo-fabric-adapters-common == 0.2.0", "tomli-w~=1.2", ] diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index b86ecdf9b..911ce91aa 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -36,10 +36,13 @@ ) from openai_codex.types import Personality, ReasoningEffort, TurnStatus -from nemo_fabric_adapter_contract.models import AgentMcpServerConfig +from nemo_fabric_adapter_contract.codec import ContractValidationError +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapter_contract.models import AgentModelConfig from nemo_fabric_adapter_contract.models import McpAuthenticationConfig from nemo_fabric_adapter_contract.models import McpOAuth2Config from nemo_fabric_adapter_contract.models import McpServiceAccountConfig +from nemo_fabric_adapter_contract.models import RuntimeContext import nemo_fabric_adapters.common.relay_gateway as relay_gateway import nemo_fabric_adapters.common.relay_hooks as relay_hooks import nemo_fabric_adapters.common.relay_artifacts as relay_artifacts @@ -148,17 +151,8 @@ def _mapping(value: Any, *, name: str) -> dict[str, Any]: return value -def _settings(payload: dict[str, Any]) -> dict[str, Any]: - return _mapping(common_utils.settings_payload(payload), name="harness.settings") - - -def runtime_id(payload: dict[str, Any]) -> str: - value = common_utils.runtime_context(payload).get("runtime_id") - if not isinstance(value, str) or not value: - raise AdapterInputError( - "codex_invalid_request", "NeMo Fabric runtime ID is required" - ) - return value +def _settings(config: AgentConfig) -> dict[str, Any]: + return config.harness.settings if config.harness else {} def request_prompt(payload: dict[str, Any]) -> str: @@ -168,31 +162,18 @@ def request_prompt(payload: dict[str, Any]) -> str: return value -def _native_capabilities(payload: dict[str, Any]) -> dict[str, Any]: - plan = _mapping(common_utils.capability_plan(payload), name="capability_plan") - return _mapping(plan.get("native"), name="capability_plan.native") - - -def _native_mcp_server_specs( - payload: dict[str, Any], -) -> dict[str, AgentMcpServerConfig]: - servers = _mapping( - _native_capabilities(payload).get("mcp_servers"), - name="native MCP servers", - ) - result: dict[str, AgentMcpServerConfig] = {} - for name, raw in sorted(servers.items()): - server = dict(_mapping(raw, name=f"MCP server {name}")) - server.pop("exposure", None) - result[name] = AgentMcpServerConfig.from_mapping(server) - return result - - -def _native_mcp_servers(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: +def _native_mcp_servers(config: AgentConfig) -> dict[str, dict[str, Any]]: + servers = config.mcp.servers if config.mcp else {} result: dict[str, dict[str, Any]] = {} - for name, server in _native_mcp_server_specs(payload).items(): + for name, server in sorted(servers.items()): + transport = server.transport target = os.path.expandvars(server.url).strip() - normalized_transport = server.transport.strip().lower().replace("_", "-") + if not target: + raise AdapterConfigError( + "codex_invalid_configuration", + f"MCP server {name} URL is required", + ) + normalized_transport = transport.strip().lower().replace("_", "-") if normalized_transport == "stdio": result[name] = { "command": target, @@ -252,10 +233,10 @@ def _mcp_oauth_config(name: str, value: McpAuthenticationConfig) -> McpOAuth2Con return value -def _mcp_oauth_callback_url(payload: dict[str, Any]) -> str | None: +def _mcp_oauth_callback_url(config: AgentConfig) -> str | None: values = { oauth.redirect_uri - for name, server in _native_mcp_server_specs(payload).items() + for name, server in (config.mcp.servers if config.mcp else {}).items() if (authentication := server.authentication) and (oauth := _mcp_oauth_config(name, authentication)).redirect_uri } @@ -267,19 +248,12 @@ def _mcp_oauth_callback_url(payload: dict[str, Any]) -> str | None: return next(iter(values)) if values else None -def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: - values = _native_capabilities(payload).get("skill_paths", []) - if not isinstance(values, list) or any( - not isinstance(value, (str, Path)) or not str(value) for value in values - ): - raise AdapterConfigError( - "codex_invalid_configuration", - "native skill_paths must be a list of paths", - ) +def _native_skill_paths(config: AgentConfig, base_dir: str) -> list[Path]: + values = config.skills.paths if config.skills else [] paths: list[Path] = [] names: set[str] = set() - config_root = Path(common_utils.base_dir(payload)) + config_root = Path(base_dir) for value in values: skill_path = Path(value) if not skill_path.is_absolute(): @@ -327,10 +301,10 @@ async def _register_skill_roots(codex: AsyncCodex, skill_paths: list[Path]) -> N def _mcp_oauth_servers( - payload: dict[str, Any], + config: AgentConfig, ) -> dict[str, McpOAuth2Config]: result: dict[str, McpOAuth2Config] = {} - for name, server in _native_mcp_server_specs(payload).items(): + for name, server in (config.mcp.servers if config.mcp else {}).items(): authentication = server.authentication if not authentication: continue @@ -449,10 +423,10 @@ async def _login_mcp_server( async def _authenticate_mcp_servers( codex: AsyncCodex, thread: Any, - payload: dict[str, Any], + config: AgentConfig, invocation_timeout_seconds: float, ) -> None: - oauth_servers = _mcp_oauth_servers(payload) + oauth_servers = _mcp_oauth_servers(config) if not oauth_servers: return @@ -497,74 +471,55 @@ async def _authenticate_mcp_servers( ) from error -def resolve_cwd(payload: dict[str, Any]) -> Path: - environment = _mapping( - common_utils.environment_payload(payload), name="runtime environment" - ) - value = environment.get("workspace") or common_utils.base_dir(payload) - path = Path(str(value)) +def resolve_cwd(context: RuntimeContext, base_dir: str) -> Path: + path = Path(context.environment.workspace or base_dir) if not path.is_absolute(): - path = Path(common_utils.base_dir(payload)) / path + path = Path(base_dir) / path return path.resolve() -def _selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: - return _mapping( - common_utils.selected_model_config(payload), - name="selected model", - ) - - -def selected_model(payload: dict[str, Any]) -> str | None: - model_config = _selected_model_config(payload) - value = model_config.get("model") - if value is None: - return None - provider = model_config.get("provider") - if not isinstance(provider, str) or not provider: +def _selected_model_config(config: AgentConfig) -> AgentModelConfig: + model = config.models.get("default") + if model is None and len(config.models) == 1: + model = next(iter(config.models.values())) + if model is None: raise AdapterConfigError( "codex_invalid_configuration", - "selected model provider must be a non-empty string", - ) - if not isinstance(value, str) or not value: - raise AdapterConfigError( - "codex_invalid_configuration", "model must be a non-empty string" + "Codex requires a default model or exactly one model", ) - return value.removeprefix("openai/") if provider == "openai" else value + return model -def selected_model_provider(payload: dict[str, Any]) -> str: - provider = _selected_model_config(payload).get("provider") - if not isinstance(provider, str) or not provider: - raise AdapterConfigError( - "codex_invalid_configuration", - "selected model provider must be a non-empty string", - ) - return provider +def selected_model(config: AgentConfig) -> str: + model = _selected_model_config(config) + return ( + model.model.removeprefix("openai/") + if model.provider == "openai" + else model.model + ) -def custom_model_provider_config(payload: dict[str, Any]) -> dict[str, Any]: - model_config = _selected_model_config(payload) - provider = selected_model_provider(payload) +def custom_model_provider_config( + config: AgentConfig, context: RuntimeContext +) -> dict[str, Any]: + model_config = _selected_model_config(config) + provider = model_config.provider if provider == "openai": return {} - api_key_env = model_config.get("api_key_env") - if not isinstance(api_key_env, str) or not api_key_env: + api_key_env = model_config.api_key_env + if api_key_env is None: raise AdapterConfigError( "codex_invalid_configuration", "selected model api_key_env is required for a custom " "Responses-compatible provider", ) - if not ( - common_utils.environment_env(payload).get(api_key_env) - or os.environ.get(api_key_env) - ): + if not (context.environment.env.get(api_key_env) or os.environ.get(api_key_env)): raise AdapterConfigError( "codex_invalid_configuration", f"{api_key_env} is required for the selected model provider", ) - base_url = common_utils.get_base_url(model_config) - if not isinstance(base_url, str) or not base_url: + base_url = model_config.base_url + if not base_url: raise AdapterConfigError( "codex_invalid_configuration", "selected model base_url is required for a custom " @@ -582,16 +537,16 @@ def custom_model_provider_config(payload: dict[str, Any]) -> dict[str, Any]: } -def openai_model_provider_config(payload: dict[str, Any]) -> dict[str, Any]: - model_config = _selected_model_config(payload) - if model_config.get("provider") != "openai": +def openai_model_provider_config(config: AgentConfig) -> dict[str, Any]: + model_config = _selected_model_config(config) + if model_config.provider != "openai": return {} - base_url = common_utils.get_base_url(model_config) + base_url = model_config.base_url return {"openai_base_url": base_url.rstrip("/")} if base_url else {} -def sandbox(payload: dict[str, Any]) -> Sandbox: - value = _settings(payload).get("sandbox", "read-only") +def sandbox(config: AgentConfig) -> Sandbox: + value = _settings(config).get("sandbox", "read-only") try: return SANDBOXES[value] except (KeyError, TypeError) as error: @@ -601,8 +556,8 @@ def sandbox(payload: dict[str, Any]) -> Sandbox: ) from error -def approval_mode(payload: dict[str, Any]) -> ApprovalMode: - value = _settings(payload).get("approval_mode", "auto_review") +def approval_mode(config: AgentConfig) -> ApprovalMode: + value = _settings(config).get("approval_mode", "auto_review") try: return APPROVAL_MODES[value] except (KeyError, TypeError) as error: @@ -612,8 +567,8 @@ def approval_mode(payload: dict[str, Any]) -> ApprovalMode: ) from error -def timeout_seconds(payload: dict[str, Any]) -> float: - value = common_utils.timeout_seconds(payload, default=DEFAULT_TIMEOUT_SECONDS) +def timeout_seconds() -> float: + value = DEFAULT_TIMEOUT_SECONDS if isinstance(value, bool) or not isinstance(value, (int, float)): raise AdapterConfigError( "codex_invalid_configuration", "timeout_seconds must be positive" @@ -639,45 +594,32 @@ def _optional_string(settings: dict[str, Any], name: str) -> str | None: def child_environment( - payload: dict[str, Any], *, relay_gateway_url: str | None = None + config: AgentConfig, + context: RuntimeContext, + base_dir: str, + *, + relay_gateway_url: str | None = None, ) -> dict[str, str]: values = dict.fromkeys(os.environ, "") values.update( {name: os.environ[name] for name in INHERITED_ENV_NAMES if name in os.environ} ) - telemetry = common_utils.runtime_context(payload).get("telemetry") - if telemetry is None: - telemetry = {} - if not isinstance(telemetry, dict): - raise AdapterInputError( - "codex_invalid_request", "runtime_context.telemetry must be a mapping" - ) - telemetry_env = telemetry.get("env") - if telemetry_env is None: - telemetry_env = {} - if not isinstance(telemetry_env, dict) or any( - not isinstance(key, str) or not isinstance(value, str) - for key, value in telemetry_env.items() - ): - raise AdapterInputError( - "codex_invalid_request", - "runtime_context.telemetry.env must contain strings", - ) + telemetry_env = context.telemetry.env if context.telemetry else {} values.update(telemetry_env) - model_config = _selected_model_config(payload) - api_key_env = model_config.get("api_key_env") - if isinstance(api_key_env, str) and api_key_env in os.environ: + model_config = _selected_model_config(config) + api_key_env = model_config.api_key_env + if api_key_env is not None and api_key_env in os.environ: values[api_key_env] = os.environ[api_key_env] - configured = common_utils.environment_env(payload) + configured = context.environment.env values.update(configured) if ( - selected_model_provider(payload) == "openai" - and isinstance(api_key_env, str) + model_config.provider == "openai" + and api_key_env is not None and api_key_env in values ): values["OPENAI_API_KEY"] = values[api_key_env] - if selected_model_provider(payload) != "openai": - codex_home = state_dir(payload) / "custom-provider-home" + if model_config.provider != "openai": + codex_home = state_dir(context, base_dir) / "custom-provider-home" values["CODEX_HOME"] = str(codex_home) # The SDK overlays this mapping on the parent environment. An empty # originator is still treated as an override by Codex and produces invalid @@ -689,16 +631,15 @@ def child_environment( return values -def _artifact_root(payload: dict[str, Any]) -> Path: - artifacts = common_utils.runtime_context(payload).get("artifacts") or {} - root = artifacts.get("root") if isinstance(artifacts, dict) else None +def _artifact_root(context: RuntimeContext, base_dir: str) -> Path: + root = context.artifacts.root if root: return Path(str(root)) - return Path(common_utils.base_dir(payload)) / "artifacts" / "codex" + return Path(base_dir) / "artifacts" / "codex" -def state_dir(payload: dict[str, Any]) -> Path: - return _artifact_root(payload) / ".fabric" / "codex" +def state_dir(context: RuntimeContext, base_dir: str) -> Path: + return _artifact_root(context, base_dir) / ".fabric" / "codex" def _merge_config(target: dict[str, Any], layer: dict[str, Any]) -> None: @@ -745,11 +686,19 @@ def _apply_config_overrides(config: dict[str, Any], overrides: dict[str, Any]) - target[parts[-1]] = _json_value(value, name=f"config_overrides.{dotted_key}") -def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: - if "native" not in common_utils.telemetry_providers(payload): +def native_codex_telemetry_config(context: RuntimeContext) -> dict[str, Any]: + telemetry = context.telemetry + if telemetry is None or "native" not in telemetry.metadata.get( + "telemetry_providers", [] + ): return {} - telemetry_config = common_utils.native_telemetry_config(payload) + telemetry_config = telemetry.metadata.get("native_config", {}) + if not isinstance(telemetry_config, dict): + raise AdapterConfigError( + "codex_invalid_configuration", + "runtime_context.telemetry.metadata.native_config must be a mapping", + ) for component in telemetry_config.get("components") or []: if ( not isinstance(component, dict) @@ -790,15 +739,20 @@ def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: return {} -def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: +def prepare_codex_relay( + agent_name: str, + config: AgentConfig, + context: RuntimeContext, + base_dir: str, +) -> CodexRelaySettings | None: """Generate invocation-scoped Relay gateway configuration.""" - if not common_utils.relay_enabled(payload): + if context.telemetry is None or not context.telemetry.relay_enabled: return None command = os.environ.get("FABRIC_TEST_NEMO_RELAY_COMMAND", "nemo-relay") try: executable = relay_gateway.resolve_relay_command( - Path(common_utils.base_dir(payload)).resolve(), command + Path(base_dir).resolve(), command ) except FileNotFoundError as error: raise AdapterRelayError( @@ -807,7 +761,14 @@ def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: try: relay_contract = relay_gateway.relay_cli_contract(executable) - plugin_config = common_utils.load_relay_plugin_config(payload) + plugin_config = common_utils.load_relay_plugin_config( + { + "agent_name": agent_name, + "base_dir": base_dir, + "config": config.to_mapping(), + "runtime_context": context.to_mapping(), + } + ) config_path, plugin_config_path = common_utils.write_relay_configs( # Codex execution remains SDK-owned; Relay runs only as a gateway. relay_config={}, @@ -825,12 +786,7 @@ def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: "NeMo Relay runtime configuration is unavailable", ) - base_url = common_utils.get_base_url(_selected_model_config(payload)) - if base_url is not None and (not isinstance(base_url, str) or not base_url): - raise AdapterConfigError( - "codex_invalid_configuration", - "selected model base_url must be a non-empty string", - ) + base_url = _selected_model_config(config).base_url port = relay_gateway.find_available_tcp_port() bind = f"127.0.0.1:{port}" return CodexRelaySettings( @@ -847,25 +803,27 @@ def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: def thread_config( - payload: dict[str, Any], relay: CodexRelaySettings | None + config: AgentConfig, + context: RuntimeContext, + relay: CodexRelaySettings | None, ) -> dict[str, Any]: """Build request-scoped Codex config without writing a user profile.""" - config = native_codex_telemetry_config(payload) - _merge_config(config, custom_model_provider_config(payload)) - _merge_config(config, openai_model_provider_config(payload)) - mcp_servers = _native_mcp_servers(payload) + result = native_codex_telemetry_config(context) + _merge_config(result, custom_model_provider_config(config, context)) + _merge_config(result, openai_model_provider_config(config)) + mcp_servers = _native_mcp_servers(config) if mcp_servers: - config["mcp_servers"] = mcp_servers - if callback_url := _mcp_oauth_callback_url(payload): - config["mcp_oauth_callback_url"] = callback_url + result["mcp_servers"] = mcp_servers + if callback_url := _mcp_oauth_callback_url(config): + result["mcp_oauth_callback_url"] = callback_url overrides = _mapping( - _settings(payload).get("config_overrides"), + _settings(config).get("config_overrides"), name="harness.settings.config_overrides", ) - _apply_config_overrides(config, overrides) + _apply_config_overrides(result, overrides) if relay is not None: - provider = selected_model_provider(payload) + provider = _selected_model_config(config).provider transport_config = ( {"openai_base_url": relay.gateway.url} if provider == "openai" @@ -878,7 +836,7 @@ def thread_config( } ) _merge_config( - config, + result, { **transport_config, "features": { @@ -897,30 +855,35 @@ def thread_config( "bypass_hook_trust": True, }, ) - return config + return result def sdk_config( - payload: dict[str, Any], relay: CodexRelaySettings | None + config: AgentConfig, + context: RuntimeContext, + base_dir: str, + relay: CodexRelaySettings | None, ) -> CodexConfig: codex_bin = os.environ.get("FABRIC_TEST_CODEX_BIN") if codex_bin: path = Path(codex_bin) if not path.is_absolute(): - path = (Path(common_utils.base_dir(payload)) / path).resolve() + path = (Path(base_dir) / path).resolve() codex_bin = str(path) return CodexConfig( codex_bin=codex_bin, - cwd=str(resolve_cwd(payload)), + cwd=str(resolve_cwd(context, base_dir)), env=child_environment( - payload, + config, + context, + base_dir, relay_gateway_url=relay.gateway.url if relay is not None else None, ), ) -def _personality(payload: dict[str, Any]) -> Personality | None: - value = _optional_string(_settings(payload), "personality") +def _personality(config: AgentConfig) -> Personality | None: + value = _optional_string(_settings(config), "personality") if value is None: return None try: @@ -931,8 +894,8 @@ def _personality(payload: dict[str, Any]) -> Personality | None: ) from error -def _reasoning_effort(payload: dict[str, Any]) -> ReasoningEffort | None: - value = _optional_string(_settings(payload), "reasoning_effort") +def _reasoning_effort(config: AgentConfig) -> ReasoningEffort | None: + value = _optional_string(_settings(config), "reasoning_effort") if value is None: return None try: @@ -943,34 +906,36 @@ def _reasoning_effort(payload: dict[str, Any]) -> ReasoningEffort | None: ) from error -def _output_schema(payload: dict[str, Any]) -> dict[str, Any] | None: - value = _settings(payload).get("output_schema") +def _output_schema(config: AgentConfig) -> dict[str, Any] | None: + value = _settings(config).get("output_schema") if value is None: return None return _mapping(_json_value(value, name="output_schema"), name="output_schema") -def validate_runtime_payload(payload: dict[str, Any]) -> str: +def validate_runtime_payload( + config: AgentConfig, context: RuntimeContext, base_dir: str +) -> str: """Validate runtime-owned configuration before starting SDK or Relay processes.""" - settings = _settings(payload) - _native_skill_paths(payload) - fabric_runtime_id = runtime_id(payload) - resolve_cwd(payload) - selected_model(payload) - sandbox(payload) - approval_mode(payload) - timeout_seconds(payload) + settings = _settings(config) + _native_skill_paths(config, base_dir) + fabric_runtime_id = context.runtime_id + resolve_cwd(context, base_dir) + selected_model(config) + sandbox(config) + approval_mode(config) + timeout_seconds() for name in ( "developer_instructions", "service_tier", ): _optional_string(settings, name) - _personality(payload) - _reasoning_effort(payload) - _output_schema(payload) - child_environment(payload) - thread_config(payload, None) + _personality(config) + _reasoning_effort(config) + _output_schema(config) + child_environment(config, context, base_dir) + thread_config(config, context, None) return fabric_runtime_id @@ -1049,7 +1014,12 @@ def sdk_failure(error: BaseException) -> dict[str, Any]: def normalize_result( - payload: dict[str, Any], *, thread_id: str, result: Any + config: AgentConfig, + context: RuntimeContext, + base_dir: str, + *, + thread_id: str, + result: Any, ) -> dict[str, Any]: status = _json_safe(result.status) completed = ( @@ -1072,8 +1042,8 @@ def normalize_result( "harness": "codex", "adapter": "sdk", "mode": "codex_sdk_runtime", - "cwd": str(resolve_cwd(payload)), - "model": selected_model(payload), + "cwd": str(resolve_cwd(context, base_dir)), + "model": selected_model(config), "thread_id": thread_id, "turn_id": result.id, "turn_status": status, @@ -1086,7 +1056,7 @@ def normalize_result( "failed": not completed, "error": error, "events": [_json_safe(item) for item in result.items], - "state_dir": str(state_dir(payload)), + "state_dir": str(state_dir(context, base_dir)), } @@ -1103,48 +1073,66 @@ async def _interrupt_turn(handle: Any) -> None: def _thread_options( - payload: dict[str, Any], relay: CodexRelaySettings | None + config: AgentConfig, + context: RuntimeContext, + base_dir: str, + relay: CodexRelaySettings | None, ) -> dict[str, Any]: - settings = _settings(payload) + settings = _settings(config) return { - "approval_mode": approval_mode(payload), - "base_instructions": common_utils.system_instruction(payload), - "config": thread_config(payload, relay) or None, - "cwd": str(resolve_cwd(payload)), + "approval_mode": approval_mode(config), + "base_instructions": ( + config.instructions.system.content + if config.instructions and config.instructions.system + else None + ), + "config": thread_config(config, context, relay) or None, + "cwd": str(resolve_cwd(context, base_dir)), "developer_instructions": _optional_string(settings, "developer_instructions"), - "model": selected_model(payload), - "model_provider": selected_model_provider(payload), - "personality": _personality(payload), - "sandbox": sandbox(payload), + "model": selected_model(config), + "model_provider": _selected_model_config(config).provider, + "personality": _personality(config), + "sandbox": sandbox(config), "service_tier": _optional_string(settings, "service_tier"), } async def _open_thread( codex: AsyncCodex, - payload: dict[str, Any], + config: AgentConfig, + context: RuntimeContext, + base_dir: str, *, relay: CodexRelaySettings | None, ) -> Any: - options = _thread_options(payload, relay) + options = _thread_options(config, context, base_dir, relay) return await codex.thread_start(**options) async def _invoke_thread( - payload: dict[str, Any], thread: Any + config: AgentConfig, + context: RuntimeContext, + base_dir: str, + invocation: dict[str, Any], + thread: Any, ) -> tuple[dict[str, Any], bool]: """Run one turn and report whether the connected SDK transport remains usable.""" handle = None try: - async with asyncio.timeout(timeout_seconds(payload)): + async with asyncio.timeout(timeout_seconds()): handle = await thread.turn( - request_prompt(payload), - effort=_reasoning_effort(payload), - output_schema=_output_schema(payload), + request_prompt(invocation), + effort=_reasoning_effort(config), + output_schema=_output_schema(config), ) result = await handle.run() - return normalize_result(payload, thread_id=thread.id, result=result), True + return ( + normalize_result( + config, context, base_dir, thread_id=thread.id, result=result + ), + True, + ) except TimeoutError as error: await _interrupt_turn(handle) return sdk_failure(error), False @@ -1177,13 +1165,15 @@ def _relay_output( def _start_relay_gateway( - payload: dict[str, Any], relay: CodexRelaySettings | None + context: RuntimeContext, + base_dir: str, + relay: CodexRelaySettings | None, ) -> subprocess.Popen[Any] | None: if relay is None: return None try: return relay_gateway.start_relay_gateway( - launch=relay.gateway, cwd=resolve_cwd(payload) + launch=relay.gateway, cwd=resolve_cwd(context, base_dir) ) except relay_gateway.RelayGatewayError as error: raise AdapterRelayError( @@ -1222,11 +1212,23 @@ def _as_lifecycle_error(error: CodexAdapterError) -> lifecycle.LifecycleError: ) +def _runtime_context(payload: dict[str, Any]) -> RuntimeContext: + try: + return RuntimeContext.from_mapping(payload.get("runtime_context")) + except ContractValidationError as error: + raise lifecycle.LifecycleError( + "codex_invalid_runtime_context", + "Codex runtime context is invalid", + ) from error + + class CodexRuntime: """One Codex app-server client and thread owned by a Fabric runtime.""" def __init__(self) -> None: - self._start_payload: dict[str, Any] | None = None + self._config: AgentConfig | None = None + self._context: RuntimeContext | None = None + self._base_dir: str | None = None self._fabric_runtime_id: str | None = None self._client: AsyncCodex | None = None self._thread: Any = None @@ -1243,12 +1245,19 @@ async def start(self, payload: dict[str, Any]) -> None: ) try: - fabric_runtime_id = validate_runtime_payload(payload) - relay = prepare_codex_relay(payload) + agent_config = payload["config"] + context = _runtime_context(payload) + base_dir = common_utils.base_dir(payload) + fabric_runtime_id = validate_runtime_payload( + agent_config, context, base_dir + ) + relay = prepare_codex_relay( + common_utils.agent_name(payload), agent_config, context, base_dir + ) self._relay = relay - self._gateway_process = _start_relay_gateway(payload, relay) - client_config = sdk_config(payload, relay) - if selected_model_provider(payload) != "openai": + self._gateway_process = _start_relay_gateway(context, base_dir, relay) + client_config = sdk_config(agent_config, context, base_dir, relay) + if _selected_model_config(agent_config).provider != "openai": await asyncio.to_thread( Path(client_config.env["CODEX_HOME"]).mkdir, parents=True, @@ -1256,10 +1265,14 @@ async def start(self, payload: dict[str, Any]) -> None: ) client = AsyncCodex(config=client_config) self._client = client - await _register_skill_roots(client, _native_skill_paths(payload)) + await _register_skill_roots( + client, _native_skill_paths(agent_config, base_dir) + ) thread = await _open_thread( client, - payload, + agent_config, + context, + base_dir, relay=relay, ) except CodexAdapterError as error: @@ -1278,13 +1291,17 @@ async def start(self, payload: dict[str, Any]) -> None: await self._cleanup_failed_start() raise - self._start_payload = payload + self._config = agent_config + self._context = context + self._base_dir = base_dir self._fabric_runtime_id = fabric_runtime_id self._thread = thread async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: if ( - self._start_payload is None + self._config is None + or self._context is None + or self._base_dir is None or self._client is None or self._thread is None or self._fabric_runtime_id is None @@ -1293,16 +1310,15 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: "codex_runtime_not_started", "Codex runtime is not started", ) - if runtime_id(invocation) != self._fabric_runtime_id: + config = self._config + context = self._context + base_dir = self._base_dir + runtime_context = _runtime_context(invocation) + if runtime_context.runtime_id != self._fabric_runtime_id: raise lifecycle.LifecycleError( "codex_runtime_mismatch", "Codex invocation does not match the connected runtime", ) - payload = { - **self._start_payload, - "runtime_context": invocation.get("runtime_context"), - "request": invocation.get("request"), - } if self._unusable: return _failure( "codex_runtime_unavailable", @@ -1310,19 +1326,18 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: ) try: - request_prompt(payload) - invocation_timeout_seconds = timeout_seconds(payload) - _reasoning_effort(payload) - _output_schema(payload) + request_prompt(invocation) + invocation_timeout_seconds = timeout_seconds() + _reasoning_effort(config) + _output_schema(config) if not self._mcp_authentication_checked: await _authenticate_mcp_servers( self._client, self._thread, - self._start_payload, + config, invocation_timeout_seconds, ) self._mcp_authentication_checked = True - relay = self._relay atif_before = ( relay_artifacts.snapshot_atif_files(relay.plugin_config) @@ -1330,7 +1345,9 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: and relay_artifacts.expects_local_atif(relay.plugin_config) else None ) - output, usable = await _invoke_thread(payload, self._thread) + output, usable = await _invoke_thread( + config, runtime_context, base_dir, invocation, self._thread + ) if ( output.get("completed") and relay is not None @@ -1369,7 +1386,9 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: async def stop(self) -> None: client = self._client self._client = None - self._start_payload = None + self._config = None + self._context = None + self._base_dir = None self._thread = None self._fabric_runtime_id = None self._mcp_authentication_checked = False @@ -1424,7 +1443,7 @@ async def _cleanup_failed_start(self) -> None: def main() -> None: """Serve the persistent local-host lifecycle protocol.""" - lifecycle.serve(CodexRuntime) + lifecycle.serve(CodexRuntime, config_loader=AgentConfig.from_mapping) if __name__ == "__main__": diff --git a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json index 84333740a..2f7dbc80c 100644 --- a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json @@ -85,6 +85,7 @@ "additionalProperties": false }, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index a8c5fb719..70f96169d 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1816,6 +1816,9 @@ fn runtime_telemetry_context( Value::String(output_dir.to_string_lossy().into_owned()), ); } + if let Some(native_config) = &telemetry.native_config { + metadata.insert("native_config".to_string(), native_config.clone()); + } if !telemetry.adapter_outputs.is_empty() { metadata.insert( "adapter_outputs".to_string(), @@ -2454,7 +2457,7 @@ mod tests { use std::fs; use super::*; - use crate::config::{ResolveContext, resolve_run_plan_from_config}; + use crate::config::{ResolveContext, TelemetryProvider, resolve_run_plan_from_config}; fn local_host_plan(mode: &str) -> (PathBuf, RunPlan) { local_host_plan_with_relay(mode, false) @@ -2644,6 +2647,26 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn runtime_context_preserves_native_telemetry_config() { + let (root, mut plan) = local_host_plan("success"); + let native_config = serde_json::json!({"components": [{"kind": "observability"}]}); + plan.telemetry_plan = Some(TelemetryPlan { + providers: vec![TelemetryProvider::Native], + relay_enabled: false, + relay_project: None, + relay_output_dir: None, + relay_config: None, + native_config: Some(native_config.clone()), + adapter_outputs: Vec::new(), + }); + + let telemetry = runtime_telemetry_context(&plan, None).expect("telemetry context"); + + assert_eq!(telemetry.metadata["native_config"], native_config); + let _ = fs::remove_dir_all(root); + } + fn stopped_agents() -> Vec { TEST_STOPPED_AGENTS.lock().expect("stop tracker").clone() } diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index d82d80190..bbf0ce715 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -14,18 +14,35 @@ import pytest from nemo_fabric import Fabric +from nemo_fabric_adapter_contract.codec import ContractValidationError +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapters.codex import adapter from openai_codex import AsyncCodex, AsyncThread, AsyncTurnHandle from openai_codex.types import TurnStatus def lifecycle_start_payload(payload): - return {key: value for key, value in payload.items() if key != "request"} + config, context, _ = runtime_input(payload) + return { + **payload, + "config": config, + "runtime_context": context, + "request": None, + } + + +def runtime_input(payload): + return ( + AgentConfig.from_mapping(payload["config"]), + RuntimeContext.from_mapping(payload["runtime_context"]), + payload["base_dir"], + ) def lifecycle_invocation(payload): return { - "runtime_context": payload["runtime_context"], + "runtime_context": runtime_input(payload)[1], "request": payload["request"], } @@ -62,7 +79,6 @@ def codex_payload_fixture(tmp_path): "base_dir": str(tmp_path), "config": { "harness": { - "adapter_id": "nvidia.fabric.codex", "settings": { "sandbox": "workspace-write", "config_overrides": { @@ -86,7 +102,13 @@ def codex_payload_fixture(tmp_path): "runtime_id": "runtime-1", "invocation_id": "invocation-1", "request_id": "request-1", - "environment": {"workspace": str(workspace)}, + "environment": { + "environment_id": "environment-codex-1", + "provider": "local", + "control_location": "in_env_control", + "ownership": "caller_owned", + "workspace": str(workspace), + }, "artifacts": {"root": str(tmp_path / "artifacts")}, }, "request": {"input": "Inspect the change."}, @@ -94,9 +116,7 @@ def codex_payload_fixture(tmp_path): def configure_mcp(payload, servers): - capability_plan = payload.setdefault("capability_plan", {}) - native = capability_plan.setdefault("native", {}) - native["mcp_servers"] = servers + payload["config"]["mcp"] = {"servers": servers} def successful_result(response="done"): @@ -435,9 +455,7 @@ def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_code "X-Tenant": "fabric", "X-Unbraced": "unbraced", "X-Windows": ( - "windows" - if os.name == "nt" - else "%FABRIC_TEST_WINDOWS_HEADER%" + "windows" if os.name == "nt" else "%FABRIC_TEST_WINDOWS_HEADER%" ), "X-Static": "static", }, @@ -478,7 +496,7 @@ def test_codex_preserves_prefixed_environment_reference_as_static_header( }, ) - assert adapter._native_mcp_servers(codex_payload)["remote"] == { + assert adapter._native_mcp_servers(runtime_input(codex_payload)[0])["remote"] == { "url": "https://mcp.example.test/mcp", "http_headers": {"Authorization": "Bearer ${MCP_TOKEN}"}, } @@ -501,7 +519,7 @@ def test_codex_rejects_mcp_oauth_client_secret(codex_payload): ) with pytest.raises(adapter.AdapterConfigError, match="client_secret_env"): - adapter.thread_config(codex_payload, None) + adapter.thread_config(*runtime_input(codex_payload)[:2], relay=None) def test_codex_rejects_mcp_oauth_client_id(codex_payload): @@ -520,7 +538,7 @@ def test_codex_rejects_mcp_oauth_client_id(codex_payload): ) with pytest.raises(adapter.AdapterConfigError, match="client_id"): - adapter.thread_config(codex_payload, None) + adapter.thread_config(*runtime_input(codex_payload)[:2], relay=None) async def test_mcp_auth_statuses_paginates_until_cursor_is_none(): @@ -625,7 +643,7 @@ def test_codex_logs_into_mcp_server_before_first_turn( oauth_timeout, expected_timeout, ): - codex_payload["config"]["runtime"]["timeout_seconds"] = invocation_timeout + monkeypatch.setattr(adapter, "timeout_seconds", lambda: invocation_timeout) configure_mcp( codex_payload, { @@ -724,7 +742,7 @@ def test_codex_rejects_mcp_service_account_authentication(codex_payload): ) with pytest.raises(adapter.AdapterConfigError, match="service_account"): - adapter.thread_config(codex_payload, None) + adapter.thread_config(*runtime_input(codex_payload)[:2], relay=None) def test_sdk_registers_native_skill_roots(codex_payload, mock_codex, tmp_path): @@ -736,9 +754,7 @@ def test_sdk_registers_native_skill_roots(codex_payload, mock_codex, tmp_path): f"---\nname: {skill.name}\ndescription: Test skill.\n---\n", encoding="utf-8", ) - codex_payload["capability_plan"] = { - "native": {"skill_paths": ["skills/review", "skills/test"]} - } + codex_payload["config"]["skills"] = {"paths": ["skills/review", "skills/test"]} output = invoke_once(codex_payload) @@ -765,7 +781,7 @@ def test_sdk_closes_when_skill_registration_is_unavailable( "---\nname: review\ndescription: Test skill.\n---\n", encoding="utf-8", ) - codex_payload["capability_plan"] = {"native": {"skill_paths": ["skills/review"]}} + codex_payload["config"]["skills"] = {"paths": ["skills/review"]} mock_codex.skill_request = None error = runtime_start_error(codex_payload) @@ -792,7 +808,7 @@ def test_sdk_rejects_unsupported_mcp_transport(codex_payload, mock_codex, transp def test_sdk_rejects_invalid_native_skill_path(codex_payload, mock_codex, tmp_path): missing = tmp_path / "skills" / "missing" - codex_payload["capability_plan"] = {"native": {"skill_paths": [str(missing)]}} + codex_payload["config"]["skills"] = {"paths": [str(missing)]} error = runtime_start_error(codex_payload) @@ -803,12 +819,10 @@ def test_sdk_rejects_invalid_native_skill_path(codex_payload, mock_codex, tmp_pa @pytest.mark.parametrize("skill_paths", [None, "", {}, False]) def test_sdk_rejects_falsy_non_list_skill_paths(codex_payload, mock_codex, skill_paths): - codex_payload["capability_plan"] = {"native": {"skill_paths": skill_paths}} - - error = runtime_start_error(codex_payload) + codex_payload["config"]["skills"] = {"paths": skill_paths} - assert error.code == "codex_invalid_configuration" - assert error.message == "native skill_paths must be a list of paths" + with pytest.raises(Exception, match="must be an array"): + lifecycle_start_payload(codex_payload) mock_codex.assert_not_called() @@ -832,7 +846,7 @@ def test_sdk_test_override_resolves_relative_runtime_from_base_dir( ): monkeypatch.setenv("FABRIC_TEST_CODEX_BIN", codex_bin) - config = adapter.sdk_config(codex_payload, relay=None) + config = adapter.sdk_config(*runtime_input(codex_payload), relay=None) base_dir = Path(codex_payload["base_dir"]) assert config.codex_bin == str((base_dir / codex_bin).resolve()) @@ -844,7 +858,7 @@ def test_sdk_test_override_keeps_absolute_runtime_path( codex_bin = tmp_path / "bin" / ".." / "codex" monkeypatch.setenv("FABRIC_TEST_CODEX_BIN", str(codex_bin)) - config = adapter.sdk_config(codex_payload, relay=None) + config = adapter.sdk_config(*runtime_input(codex_payload), relay=None) assert config.codex_bin == str(codex_bin) @@ -856,9 +870,12 @@ async def test_persistent_runtime_reuses_one_client_and_thread( start_payload.pop("request") runtime = adapter.CodexRuntime() - await runtime.start(start_payload) + await runtime.start(lifecycle_start_payload(start_payload)) first = await runtime.invoke(lifecycle_invocation(codex_payload)) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" + codex_payload["runtime_context"]["request_id"] = "request-2" + second_artifacts = Path(codex_payload["base_dir"]) / "second-artifacts" + codex_payload["runtime_context"]["artifacts"] = {"root": str(second_artifacts)} codex_payload["request"]["input"] = "Continue." second = await runtime.invoke(lifecycle_invocation(codex_payload)) await runtime.stop() @@ -869,9 +886,35 @@ async def test_persistent_runtime_reuses_one_client_and_thread( client.thread_start.assert_awaited_once() assert client.thread.turn.await_count == 2 assert client.thread.turn.await_args_list[1].args[0] == "Continue." + assert second["state_dir"] == str(second_artifacts / ".fabric" / "codex") client.close.assert_awaited_once() +async def test_runtime_ignores_legacy_plan_fields(codex_payload, mock_codex): + codex_payload["capability_plan"] = { + "native": { + "mcp_servers": { + "ignored": { + "transport": "streamable-http", + "url": "https://mcp.example.test/ignored", + } + } + } + } + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } + + output = await invoke_once_async(codex_payload) + + assert ( + "mcp_servers" + not in mock_codex.instances[0].thread_start.await_args.kwargs["config"] + ) + assert "relay_runtime" not in output + + async def test_persistent_runtime_registers_skills_once_and_maps_mcp( codex_payload, mock_codex, tmp_path ): @@ -881,11 +924,7 @@ async def test_persistent_runtime_registers_skills_once_and_maps_mcp( "---\nname: review\ndescription: Test skill.\n---\n", encoding="utf-8", ) - codex_payload["capability_plan"] = { - "native": { - "skill_paths": ["skills/review"], - } - } + codex_payload["config"]["skills"] = {"paths": ["skills/review"]} configure_mcp( codex_payload, { @@ -899,7 +938,7 @@ async def test_persistent_runtime_registers_skills_once_and_maps_mcp( start_payload.pop("request") runtime = adapter.CodexRuntime() - await runtime.start(start_payload) + await runtime.start(lifecycle_start_payload(start_payload)) await runtime.invoke(lifecycle_invocation(codex_payload)) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" await runtime.invoke(lifecycle_invocation(codex_payload)) @@ -921,9 +960,9 @@ async def test_persistent_runtime_registers_skills_once_and_maps_mcp( async def test_persistent_runtime_owns_one_relay_gateway( codex_payload, mock_codex, monkeypatch, tmp_path ): - codex_payload["telemetry_plan"] = { - "providers": ["relay"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } gateway = adapter.relay_gateway.RelayGatewayLaunch( executable=tmp_path / "nemo-relay", @@ -946,7 +985,7 @@ async def test_persistent_runtime_owns_one_relay_gateway( start_payload.pop("request") runtime = adapter.CodexRuntime() - await runtime.start(start_payload) + await runtime.start(lifecycle_start_payload(start_payload)) await runtime.invoke(lifecycle_invocation(codex_payload)) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" await runtime.invoke(lifecycle_invocation(codex_payload)) @@ -964,9 +1003,9 @@ async def test_persistent_runtime_owns_one_relay_gateway( async def test_relay_waits_for_delayed_atif_before_collecting_artifacts( codex_payload, mock_codex, monkeypatch, tmp_path ): - codex_payload["telemetry_plan"] = { - "providers": ["relay"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } atif_dir = tmp_path / "relay" / "atif" atif_dir.mkdir(parents=True) @@ -1008,9 +1047,9 @@ async def write_atif(): async def test_relay_atif_timeout_fails_successful_turn_explicitly( codex_payload, mock_codex, monkeypatch, tmp_path ): - codex_payload["telemetry_plan"] = { - "providers": ["relay"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } atif_dir = tmp_path / "relay" / "atif" atif_dir.mkdir(parents=True) @@ -1173,7 +1212,9 @@ def test_custom_provider_requires_credential(codex_payload, mock_codex): assert error.code == "codex_invalid_configuration" assert "ACME_API_KEY is required" in error.message - assert not (adapter.state_dir(codex_payload) / "custom-provider-home").exists() + assert not ( + adapter.state_dir(*runtime_input(codex_payload)[1:]) / "custom-provider-home" + ).exists() mock_codex.assert_not_called() @@ -1198,9 +1239,9 @@ def test_custom_provider_requires_explicit_endpoint(codex_payload, mock_codex): def test_relay_uses_gateway_and_request_scoped_sdk_config( codex_payload, mock_codex, monkeypatch, tmp_path ): - codex_payload["telemetry_plan"] = { - "providers": ["relay"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } relay_config_path = tmp_path / "relay-config" / "config.toml" executable = tmp_path / "bin" / "nemo-relay" @@ -1270,9 +1311,9 @@ def test_relay_routes_custom_provider_through_gateway(codex_payload, tmp_path): "base_url": "https://acme.example/v1", } ) - codex_payload["telemetry_plan"] = { - "providers": ["relay"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } os.environ["ACME_API_KEY"] = "acme-secret" gateway = adapter.relay_gateway.RelayGatewayLaunch( @@ -1288,8 +1329,8 @@ def test_relay_routes_custom_provider_through_gateway(codex_payload, tmp_path): plugin_config={"version": 1, "components": []}, ) - adapter.validate_runtime_payload(codex_payload) - config = adapter.thread_config(codex_payload, relay) + adapter.validate_runtime_payload(*runtime_input(codex_payload)) + config = adapter.thread_config(*runtime_input(codex_payload)[:2], relay) assert config["model_providers"]["acme"] == { "name": "acme", @@ -1310,9 +1351,9 @@ def test_prepare_relay_reuses_one_resolved_executable( "base_url": "https://acme.example/v1/", } ) - codex_payload["telemetry_plan"] = { - "providers": ["relay"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } executable = tmp_path / "nemo-relay" config_path = tmp_path / "relay-config" / "config.toml" @@ -1324,17 +1365,17 @@ def test_prepare_relay_reuses_one_resolved_executable( ) ) write = MagicMock(return_value=(config_path, plugin_path)) + load = MagicMock(return_value={"version": 1, "components": []}) monkeypatch.setattr(adapter.relay_gateway, "resolve_relay_command", resolve) monkeypatch.setattr(adapter.relay_gateway, "relay_cli_contract", contract) monkeypatch.setattr(adapter.relay_gateway, "find_available_tcp_port", lambda: 43210) - monkeypatch.setattr( - adapter.common_utils, - "load_relay_plugin_config", - MagicMock(return_value={"version": 1, "components": []}), - ) + monkeypatch.setattr(adapter.common_utils, "load_relay_plugin_config", load) monkeypatch.setattr(adapter.common_utils, "write_relay_configs", write) - relay = adapter.prepare_codex_relay(codex_payload) + config, context, base_dir = runtime_input(codex_payload) + relay = adapter.prepare_codex_relay( + codex_payload["agent_name"], config, context, base_dir + ) assert relay is not None assert relay.gateway.executable == executable @@ -1345,6 +1386,14 @@ def test_prepare_relay_reuses_one_resolved_executable( "nemo-relay", ) contract.assert_called_once_with(executable) + load.assert_called_once_with( + { + "agent_name": "codex-test", + "base_dir": codex_payload["base_dir"], + "config": config.to_mapping(), + "runtime_context": context.to_mapping(), + } + ) write.assert_called_once_with( relay_config={}, plugin_config={"version": 1, "components": []}, @@ -1367,7 +1416,7 @@ def test_relay_stop_failure_is_reported_by_runtime_stop( gateway=gateway, plugin_config={"version": 1, "components": []}, ) - monkeypatch.setattr(adapter, "prepare_codex_relay", lambda _: relay) + monkeypatch.setattr(adapter, "prepare_codex_relay", lambda *_: relay) monkeypatch.setattr( adapter.relay_gateway, "start_relay_gateway", lambda **_: MagicMock() ) @@ -1406,24 +1455,28 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( }, } ) - codex_payload["telemetry_plan"] = { - "providers": ["native"], + codex_payload["runtime_context"]["telemetry"] = { "relay_enabled": False, - "native_config": { - "components": [ - { - "kind": "observability", - "enabled": True, - "config": { - "opentelemetry": { - "enabled": True, - "endpoint": "http://localhost:4318/v1/traces", - "transport": "http_binary", - "resource_attributes": {"deployment.environment": "test"}, - } - }, - } - ] + "metadata": { + "telemetry_providers": ["native"], + "native_config": { + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + "transport": "http_binary", + "resource_attributes": { + "deployment.environment": "test" + }, + } + }, + } + ] + }, }, } @@ -1448,7 +1501,24 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( assert turn["output_schema"]["required"] == ["summary"] -def test_timeout_interrupts_native_turn_and_closes_sdk(codex_payload, mock_codex): +def test_native_telemetry_requires_mapping(codex_payload): + codex_payload["runtime_context"]["telemetry"] = { + "relay_enabled": False, + "metadata": { + "telemetry_providers": ["native"], + "native_config": [], + }, + } + + with pytest.raises( + adapter.AdapterConfigError, match="native_config must be a mapping" + ): + adapter.native_codex_telemetry_config(runtime_input(codex_payload)[1]) + + +def test_timeout_interrupts_native_turn_and_closes_sdk( + codex_payload, mock_codex, monkeypatch +): mock_blocking_thread = mock_thread("thread-timeout") async def block(): @@ -1456,7 +1526,7 @@ async def block(): mock_blocking_thread.handle.run.side_effect = block mock_codex.next_thread = mock_blocking_thread - codex_payload["config"]["runtime"]["timeout_seconds"] = 0.01 + monkeypatch.setattr(adapter, "timeout_seconds", lambda: 0.01) output = invoke_once(codex_payload) @@ -1542,7 +1612,7 @@ def test_codex_config_resolves_sdk_adapter(): def test_environment_does_not_mutate_parent(codex_payload): os.environ["FABRIC_UNRELATED_SECRET"] = "parent-value" - child = adapter.child_environment(codex_payload) + child = adapter.child_environment(*runtime_input(codex_payload)) assert child["FABRIC_UNRELATED_SECRET"] == "" assert os.environ["FABRIC_UNRELATED_SECRET"] == "parent-value" @@ -1550,18 +1620,19 @@ def test_environment_does_not_mutate_parent(codex_payload): def test_environment_preserves_runtime_telemetry_env(codex_payload): codex_payload["runtime_context"]["telemetry"] = { + "relay_enabled": False, "env": { "FABRIC_RELAY_ENABLED": "true", "FABRIC_RELAY_CONFIG_PATH": "/tmp/relay.json", "CODEX_EXPLICIT": "telemetry", - } + }, } codex_payload["runtime_context"]["environment"]["env"] = { "CODEX_EXPLICIT": "configured" } os.environ["FABRIC_RELAY_CONFIG_PATH"] = "/tmp/parent-relay.json" - child = adapter.child_environment(codex_payload) + child = adapter.child_environment(*runtime_input(codex_payload)) assert child["FABRIC_RELAY_ENABLED"] == "true" assert child["FABRIC_RELAY_CONFIG_PATH"] == "/tmp/relay.json" @@ -1579,24 +1650,28 @@ def test_environment_preserves_runtime_telemetry_env(codex_payload): def test_environment_rejects_non_string_runtime_telemetry_env( codex_payload, telemetry_env ): - codex_payload["runtime_context"]["telemetry"] = {"env": telemetry_env} + codex_payload["runtime_context"]["telemetry"] = { + "relay_enabled": False, + "env": telemetry_env, + } - with pytest.raises( - adapter.AdapterInputError, - match=r"runtime_context\.telemetry\.env must contain strings", - ): - adapter.child_environment(codex_payload) + with pytest.raises(ContractValidationError, match=r"telemetry\.env"): + runtime_input(codex_payload) @pytest.mark.parametrize("telemetry", [[], "invalid"]) def test_environment_rejects_non_mapping_runtime_telemetry(codex_payload, telemetry): codex_payload["runtime_context"]["telemetry"] = telemetry - with pytest.raises( - adapter.AdapterInputError, - match=r"runtime_context\.telemetry must be a mapping", - ): - adapter.child_environment(codex_payload) + with pytest.raises(ContractValidationError, match="telemetry"): + runtime_input(codex_payload) + + +def test_runtime_context_validation_uses_lifecycle_error(): + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + adapter._runtime_context({"runtime_context": {}}) + + assert caught.value.code == "codex_invalid_runtime_context" def test_main_serves_persistent_runtime(monkeypatch): @@ -1605,4 +1680,6 @@ def test_main_serves_persistent_runtime(monkeypatch): adapter.main() - serve.assert_called_once_with(adapter.CodexRuntime) + serve.assert_called_once_with( + adapter.CodexRuntime, config_loader=AgentConfig.from_mapping + ) diff --git a/tests/integrations/test_harbor_runner.py b/tests/integrations/test_harbor_runner.py index 8f1be2882..765e11491 100644 --- a/tests/integrations/test_harbor_runner.py +++ b/tests/integrations/test_harbor_runner.py @@ -212,6 +212,9 @@ async def run_payloads(): def test_codex_adapter_maps_fabric_request_to_sdk(tmp_path): + from nemo_fabric_adapter_contract.models import AgentConfig + from nemo_fabric_adapter_contract.models import RuntimeContext + adapter = load_codex_adapter() payload = { @@ -233,16 +236,28 @@ def test_codex_adapter_maps_fabric_request_to_sdk(tmp_path): }, "runtime_context": { "runtime_id": "harbor-test", - "environment": {"workspace": str(tmp_path)}, + "invocation_id": "harbor-invocation", + "request_id": "harbor-request", + "environment": { + "environment_id": "harbor-environment", + "provider": "local", + "control_location": "in_env_control", + "ownership": "caller_owned", + "workspace": str(tmp_path), + }, + "artifacts": {}, }, "request": {"input": "Fix the calculator."}, } - assert adapter.selected_model(payload) == "gpt-5.4" - assert adapter.sandbox(payload) == adapter.Sandbox.workspace_write - assert adapter._reasoning_effort(payload) == adapter.ReasoningEffort.high - assert adapter.thread_config(payload, relay=None) == {} - assert adapter.resolve_cwd(payload) == tmp_path + config = AgentConfig.from_mapping(payload["config"]) + context = RuntimeContext.from_mapping(payload["runtime_context"]) + + assert adapter.selected_model(config) == "gpt-5.4" + assert adapter.sandbox(config) == adapter.Sandbox.workspace_write + assert adapter._reasoning_effort(config) == adapter.ReasoningEffort.high + assert adapter.thread_config(config, context, relay=None) == {} + assert adapter.resolve_cwd(context, payload["base_dir"]) == tmp_path def test_claude_calculator_run_uses_current_adapter_contract():