diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index da212d390..378b8128d 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -66,6 +66,7 @@ "additionalProperties": false }, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/adapters/claude/pyproject.toml b/adapters/claude/pyproject.toml index b980d8a96..9f707eec4 100644 --- a/adapters/claude/pyproject.toml +++ b/adapters/claude/pyproject.toml @@ -25,6 +25,7 @@ license-files = ["LICENSE"] readme = "pypi.md" requires-python = ">=3.11" dependencies = [ + "nemo-fabric-adapter-contract == 0.2.0", "nemo-fabric-adapters-common == 0.2.0", "tomli-w~=1.2", ] @@ -51,4 +52,5 @@ include = ["nemo_fabric_adapters.claude*"] "share/nemo-fabric/adapters/claude" = ["fabric-adapter.json"] [tool.uv.sources] +nemo-fabric-adapter-contract = { path = "../../adapter-contract", editable = true } nemo-fabric-adapters-common = { path = "../common", editable = true } diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index b2caf092c..3287b2908 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -31,6 +31,10 @@ from claude_agent_sdk import ResultMessage from claude_agent_sdk import HookMatcher from claude_agent_sdk._errors import MessageParseError +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 RuntimeContext from nemo_fabric_adapters.common import lifecycle from nemo_fabric_adapters.common import relay_artifacts from nemo_fabric_adapters.common import relay_gateway @@ -140,16 +144,6 @@ class AdapterRelayError(ClaudeAdapterError): """NeMo Relay setup or lifecycle failure.""" -def _mapping(value: Any, *, name: str) -> dict[str, Any]: - if value is None: - return {} - if not isinstance(value, dict): - raise AdapterConfigError( - "claude_invalid_configuration", f"{name} must be a mapping" - ) - return value - - def _string_list(value: Any, *, name: str) -> list[str]: if value is None: return [] @@ -176,13 +170,14 @@ def _positive_number(value: Any, *, name: str) -> float: return number -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( - "claude_invalid_request", "NeMo Fabric runtime ID is required" - ) - return value +def _runtime_context(payload: dict[str, Any]) -> RuntimeContext: + try: + return RuntimeContext.from_mapping(payload.get("runtime_context")) + except ContractValidationError as error: + raise lifecycle.LifecycleError( + "claude_invalid_runtime_context", + "Claude runtime context is invalid", + ) from error def request_prompt(payload: dict[str, Any]) -> str: @@ -193,82 +188,58 @@ def request_prompt(payload: dict[str, Any]) -> str: return value -def _settings(payload: dict[str, Any]) -> dict[str, Any]: - return _mapping(common_utils.settings_payload(payload), name="harness.settings") +def _settings(config: AgentConfig) -> dict[str, Any]: + return config.harness.settings if config.harness is not None else {} -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_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( + "claude_invalid_configuration", + "Claude requires a default model or exactly one model", + ) + return model -def _resolve_path(payload: dict[str, Any], value: str | Path) -> Path: +def _resolve_path(base_dir: str, value: str | Path) -> Path: path = Path(value) if not path.is_absolute(): - path = Path(common_utils.base_dir(payload)) / path + path = Path(base_dir) / path return path -def resolve_cwd(payload: dict[str, Any]) -> Path: - environment = common_utils.environment_payload(payload) - workspace = environment.get("workspace") - return _resolve_path(payload, workspace or common_utils.base_dir(payload)) +def resolve_cwd(runtime_context: RuntimeContext, base_dir: str) -> Path: + return _resolve_path(base_dir, runtime_context.environment.workspace or base_dir) -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: - raise AdapterConfigError( - "claude_invalid_configuration", - "selected model provider must be a non-empty string", - ) - if not isinstance(value, str) or not value: - raise AdapterConfigError( - "claude_invalid_configuration", "model must be a non-empty string" - ) - return value.removeprefix("anthropic/") if provider == "anthropic" else value +def selected_model(model: AgentModelConfig) -> str: + return ( + model.model.removeprefix("anthropic/") + if model.provider == "anthropic" + else model.model + ) -def _anthropic_base_url(model: dict[str, Any]) -> str | None: - base_url = common_utils.get_base_url(model) - if base_url is None: +def _anthropic_base_url(model: AgentModelConfig) -> str | None: + if model.base_url is None: return None - if not isinstance(base_url, str) or not base_url: - raise AdapterConfigError( - "claude_invalid_configuration", - "selected model base_url must be a non-empty string", - ) - base_url = base_url.rstrip("/") - return ( - base_url.removesuffix("/v1") - if model.get("provider") != "anthropic" - else base_url - ) + base_url = model.base_url.rstrip("/") + return base_url.removesuffix("/v1") if model.provider != "anthropic" else base_url def _model_environment( - payload: dict[str, Any], environment: dict[str, str] + model: AgentModelConfig, environment: dict[str, str] ) -> dict[str, str]: - model = _selected_model_config(payload) - provider = model.get("provider") - api_key_env = model.get("api_key_env") - if not isinstance(api_key_env, str) or not api_key_env: - if api_key_env is None: - api_key = None - else: - raise AdapterConfigError( - "claude_invalid_configuration", - "selected model api_key_env must be a non-empty string", - ) - else: - api_key = environment.get(api_key_env) or os.environ.get(api_key_env) - if provider != "anthropic" and api_key_env is None: + api_key_env = model.api_key_env + api_key = ( + environment.get(api_key_env) or os.environ.get(api_key_env) + if api_key_env + else None + ) + if model.provider != "anthropic" and api_key_env is None: raise AdapterConfigError( "claude_invalid_configuration", "selected model api_key_env is required for a custom " @@ -280,7 +251,7 @@ def _model_environment( f"{api_key_env} is required for the selected model provider", ) base_url = _anthropic_base_url(model) - if provider != "anthropic" and not base_url: + if model.provider != "anthropic" and not base_url: raise AdapterConfigError( "claude_invalid_configuration", "selected model base_url is required for a custom " @@ -291,28 +262,24 @@ def _model_environment( values["ANTHROPIC_API_KEY"] = api_key if base_url: values["ANTHROPIC_BASE_URL"] = base_url - if provider != "anthropic": + if model.provider != "anthropic": values["ANTHROPIC_AUTH_TOKEN"] = "" return values -def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: - servers = _native_mcp_server_specs(payload) +def _mcp_servers(config: AgentConfig) -> dict[str, Any]: + servers = config.mcp.servers if config.mcp is not None else {} result: dict[str, Any] = {} for name, server in sorted(servers.items()): - transport = server.get("transport") - url = server.get("url") - if not isinstance(url, str) or not url: - raise AdapterConfigError( - "claude_invalid_configuration", "MCP server URL is required" - ) + transport = server.transport + url = server.url if transport == "stdio": result[name] = { "type": "stdio", "command": url, - "args": common_utils.normalize_list(server.get("args")), + "args": server.args, } - if env := server.get("env"): + if env := server.env: result[name]["env"] = env elif transport in {"http", "streamable-http"}: result[name] = {"type": "http", "url": url} @@ -323,7 +290,7 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: "claude_invalid_configuration", f"unsupported MCP transport: {transport}", ) - if headers := server.get("custom_headers"): + if headers := server.custom_headers: try: common_utils.validate_http_headers(name, headers) result[name]["headers"] = headers @@ -332,30 +299,18 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: "claude_invalid_configuration", str(error) ) from error - auth = server.get("authentication") + auth = server.authentication if auth is not None: raise AdapterConfigError( "claude_invalid_configuration", - f"MCP server {name!r} {auth.get('type')!r} authentication is not supported by Claude", + f"MCP server {name!r} {auth.type!r} authentication is not supported by Claude", ) return result -def _native_mcp_server_specs(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: - native = ( - _mapping(common_utils.capability_plan(payload), name="capability_plan").get( - "native" - ) - or {} - ) - servers = _mapping(native, name="capability_plan.native").get("mcp_servers") or {} - return { - name: _mapping(raw, name=f"MCP server {name}") - for name, raw in _mapping(servers, name="native MCP servers").items() - } - - -def _stage_mcp_config(payload: dict[str, Any]) -> ClaudeMcpSettings | None: +def _stage_mcp_config( + config: AgentConfig, runtime_context: RuntimeContext, base_dir: str +) -> ClaudeMcpSettings | None: # Dictionary-valued ClaudeAgentOptions.mcp_servers are JSON-serialized by # claude-agent-sdk into the literal `--mcp-config` command-line argument, # where MCP credentials can be observed by process-inspection tools. Passing @@ -365,10 +320,10 @@ def _stage_mcp_config(payload: dict[str, Any]) -> ClaudeMcpSettings | None: # staged file if the process is terminated before runtime cleanup can remove # it. The file remains owner-only as defense in depth and is retained until # the SDK client disconnects normally. - servers = _mcp_servers(payload) + servers = _mcp_servers(config) if not servers: return None - fabric_runtime_id = runtime_id(payload) + fabric_runtime_id = runtime_context.runtime_id environment: dict[str, str] = {} def project_environment_value(server_name: str, value_name: str, value: str) -> str: @@ -384,22 +339,8 @@ def project_environment_value(server_name: str, value_name: str, value: str) -> for server_name, server in servers.items(): raw_environment = server.get("env") if raw_environment is not None: - server_environment = _mapping( - raw_environment, - name=f"MCP server {server_name} env", - ) projected_environment: dict[str, str] = {} - for variable_name, value in sorted(server_environment.items()): - if not isinstance(variable_name, str) or not variable_name: - raise AdapterConfigError( - "claude_invalid_configuration", - f"MCP server {server_name} env names must be non-empty strings", - ) - if not isinstance(value, str): - raise AdapterConfigError( - "claude_invalid_configuration", - f"MCP server {server_name} env values must be strings", - ) + for variable_name, value in sorted(raw_environment.items()): projected_environment[variable_name] = project_environment_value( server_name, variable_name, value ) @@ -427,7 +368,7 @@ def project_header_reference(match: re.Match[str]) -> str: server["headers"] = projected_headers config_root = ( - _artifact_root(payload) + _artifact_root(runtime_context, base_dir) / ".fabric" / "claude" / "mcp" @@ -463,25 +404,15 @@ def _cleanup_mcp_config(config_path: Path | None) -> None: LOGGER.exception("Claude MCP runtime configuration could not be removed") -def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: - native = ( - _mapping(common_utils.capability_plan(payload), name="capability_plan").get( - "native" - ) - or {} - ) - values = _mapping(native, name="capability_plan.native").get("skill_paths") or [] - if not isinstance(values, list) or any( - not isinstance(value, (str, Path)) for value in values - ): - raise AdapterConfigError( - "claude_invalid_configuration", "native skill_paths must be a list of paths" - ) - return [_resolve_path(payload, value) for value in values] +def _native_skill_paths(config: AgentConfig, base_dir: str) -> list[Path]: + values = config.skills.paths if config.skills is not None else [] + return [_resolve_path(base_dir, value) for value in values] -def _stage_skill_plugin(payload: dict[str, Any]) -> list[dict[str, str]]: - skill_paths = _native_skill_paths(payload) +def _stage_skill_plugin( + config: AgentConfig, runtime_context: RuntimeContext, base_dir: str +) -> list[dict[str, str]]: + skill_paths = _native_skill_paths(config, base_dir) if not skill_paths: return [] @@ -502,9 +433,13 @@ def _stage_skill_plugin(payload: dict[str, Any]) -> list[dict[str, str]]: names.add(name) skills.append((name, skill_path)) - plugin_key = sha256(runtime_id(payload).encode()).hexdigest() + plugin_key = sha256(runtime_context.runtime_id.encode()).hexdigest() plugin_root = ( - _artifact_root(payload) / ".fabric" / "claude" / "plugins" / plugin_key + _artifact_root(runtime_context, base_dir) + / ".fabric" + / "claude" + / "plugins" + / plugin_key ) if plugin_root.exists(): shutil.rmtree(plugin_root) @@ -557,15 +492,20 @@ def _stage_relay_plugin(plugin_path: Path, executable: Path) -> None: ) -def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | None: +def prepare_claude_relay( + agent_name: str, + model: AgentModelConfig, + runtime_context: RuntimeContext, + base_dir: str, +) -> ClaudeRelaySettings | None: """Generate Relay gateway and Claude hook configuration.""" - if not common_utils.relay_enabled(payload): + if runtime_context.telemetry is None or not runtime_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(), + Path(base_dir).resolve(), command, ) except FileNotFoundError as error: @@ -576,7 +516,14 @@ def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | 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": {"models": {"default": model.to_mapping()}}, + "runtime_context": runtime_context.to_mapping(), + } + ) config_path, plugin_config_path = common_utils.write_relay_configs( relay_config={"agents": {"claude": {"command": "claude"}}}, plugin_config=plugin_config, @@ -606,7 +553,7 @@ def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | None: bind=gateway_bind, url=f"http://{gateway_bind}", log_path=config_path.parent / "gateway.log", - anthropic_base_url=_anthropic_base_url(_selected_model_config(payload)), + anthropic_base_url=_anthropic_base_url(model), ) plugin_path = config_path.parent / "claude-plugin" try: @@ -628,11 +575,11 @@ def discard_stderr(_: str) -> None: """Consume Claude Code stderr without exposing it through Fabric artifacts.""" -def tool_policy_hooks(payload: dict[str, Any]) -> dict[str, list[HookMatcher]] | None: +def tool_policy_hooks(config: AgentConfig) -> dict[str, list[HookMatcher]] | None: """Enforce the normalized tool policy across built-in, MCP, and plugin tools.""" - enabled = common_utils.enabled_tools(payload) - blocked = set(common_utils.blocked_tools(payload)) + enabled = config.tools.enabled if config.tools is not None else None + blocked = set(config.tools.blocked if config.tools is not None else []) if enabled is None and not blocked: return None enabled_set = None if enabled is None else set(enabled) @@ -662,17 +609,20 @@ async def enforce_policy( def build_options( - payload: dict[str, Any], + config: AgentConfig, + runtime_context: RuntimeContext, + base_dir: str, *, relay: ClaudeRelaySettings | None = None, ) -> ClaudeAgentOptions: - settings = _settings(payload) + settings = _settings(config) + model = _selected_model_config(config) permission_mode = settings.get("permission_mode") if permission_mode is not None and permission_mode not in PERMISSION_MODES: raise AdapterConfigError( "claude_invalid_configuration", "permission_mode is invalid" ) - max_turns = common_utils.max_turns(payload) + max_turns = config.runtime.max_turns if config.runtime is not None else None max_budget = settings.get("max_budget_usd") if max_budget is not None: max_budget = _positive_number(max_budget, name="max_budget_usd") @@ -684,39 +634,43 @@ def build_options( ) cli_path = os.environ.get("FABRIC_TEST_CLAUDE_CLI_PATH") - system_prompt = common_utils.system_instruction(payload) - enabled_tools = common_utils.enabled_tools(payload) + instructions = config.instructions + system_prompt = ( + instructions.system.content if instructions and instructions.system else None + ) + enabled_tools = config.tools.enabled if config.tools is not None else None allowed_tools = ( enabled_tools if permission_mode == "dontAsk" and enabled_tools is not None else [] ) - plugins = _stage_skill_plugin(payload) + plugins = _stage_skill_plugin(config, runtime_context, base_dir) has_skill_plugin = bool(plugins) if relay is not None: plugins.append({"type": "local", "path": str(relay.plugin_path)}) environment = child_environment( - payload, + model, + runtime_context, relay_gateway_url=relay.gateway.url if relay is not None else None, ) - mcp = _stage_mcp_config(payload) + mcp = _stage_mcp_config(config, runtime_context, base_dir) if mcp is not None: environment.update(mcp.environment) try: return ClaudeAgentOptions( - cwd=resolve_cwd(payload), - model=selected_model(payload), + cwd=resolve_cwd(runtime_context, base_dir), + model=selected_model(model), system_prompt=system_prompt, tools=enabled_tools, allowed_tools=allowed_tools, - disallowed_tools=common_utils.blocked_tools(payload), - hooks=tool_policy_hooks(payload), + disallowed_tools=config.tools.blocked if config.tools is not None else [], + hooks=tool_policy_hooks(config), permission_mode=permission_mode, max_turns=max_turns, max_budget_usd=max_budget, setting_sources=sources, - cli_path=_resolve_path(payload, cli_path) if cli_path else None, + cli_path=_resolve_path(base_dir, cli_path) if cli_path else None, mcp_servers=mcp.config_path if mcp is not None else {}, strict_mcp_config=True, skills="all" if has_skill_plugin else None, @@ -729,21 +683,19 @@ def build_options( raise -def timeout_seconds(payload: dict[str, Any]) -> float: - value = common_utils.timeout_seconds(payload, default=1800) - return _positive_number(value, name="timeout_seconds") +def timeout_seconds() -> float: + return 1800.0 def _remaining_timeout(deadline: float) -> float: return max(0.0, deadline - asyncio.get_running_loop().time()) -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(runtime_context: RuntimeContext, base_dir: str) -> Path: + root = runtime_context.artifacts.root if root: return Path(root) - return Path(common_utils.base_dir(payload)) / "artifacts" / "claude" + return Path(base_dir) / "artifacts" / "claude" def _json_safe(value: Any) -> Any: @@ -772,10 +724,7 @@ def _result_failed(result: ResultMessage) -> bool: ) -def normalize_result( - payload: dict[str, Any], messages: list[Message], result: ResultMessage -) -> dict[str, Any]: - del payload +def normalize_result(messages: list[Message], result: ResultMessage) -> dict[str, Any]: failed = _result_failed(result) error = None if failed: @@ -845,7 +794,8 @@ def sdk_failure(error: BaseException) -> dict[str, Any]: def child_environment( - payload: dict[str, Any], + model: AgentModelConfig, + runtime_context: RuntimeContext, *, relay_gateway_url: str | None = None, ) -> dict[str, str]: @@ -853,14 +803,12 @@ def child_environment( values.update( {name: os.environ[name] for name in INHERITED_ENV_NAMES if name in os.environ} ) - - model = _selected_model_config(payload) - api_key_env = model.get("api_key_env") - if isinstance(api_key_env, str) and api_key_env in os.environ: + api_key_env = model.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 = runtime_context.environment.env values.update(configured) - model_environment = _model_environment(payload, values) + model_environment = _model_environment(model, values) conflicts = sorted( name for name, value in model_environment.items() @@ -904,7 +852,8 @@ def _relay_output( def _start_relay_gateway( - payload: dict[str, Any], + runtime_context: RuntimeContext, + base_dir: str, relay: ClaudeRelaySettings | None, ) -> subprocess.Popen[Any] | None: if relay is None: @@ -912,7 +861,7 @@ def _start_relay_gateway( try: return relay_gateway.start_relay_gateway( launch=relay.gateway, - cwd=resolve_cwd(payload), + cwd=resolve_cwd(runtime_context, base_dir), ) except relay_gateway.RelayGatewayError as error: raise AdapterRelayError( @@ -986,7 +935,7 @@ class ClaudeRuntime: """One connected Claude SDK client owned by a Fabric runtime.""" def __init__(self) -> None: - self._start_payload: dict[str, Any] | None = None + self._agent_config: AgentConfig | None = None self._fabric_runtime_id: str | None = None self._claude_session_id: str | None = None self._client: ClaudeSDKClient | None = None @@ -1002,12 +951,21 @@ async def start(self, payload: dict[str, Any]) -> None: "Claude runtime is already started", ) try: - fabric_runtime_id = runtime_id(payload) - relay = prepare_claude_relay(payload) + agent_config = payload["config"] + runtime_context = _runtime_context(payload) + base_dir = common_utils.base_dir(payload) + model = _selected_model_config(agent_config) + fabric_runtime_id = runtime_context.runtime_id + relay = prepare_claude_relay( + common_utils.agent_name(payload), model, runtime_context, base_dir + ) self._relay = relay - self._gateway_process = _start_relay_gateway(payload, relay) - options = build_options(payload, relay=relay) - + self._gateway_process = _start_relay_gateway( + runtime_context, base_dir, relay + ) + options = build_options( + agent_config, runtime_context, base_dir, relay=relay + ) if isinstance(options.mcp_servers, Path): self._mcp_config_path = options.mcp_servers @@ -1023,40 +981,34 @@ async def start(self, payload: dict[str, Any]) -> None: self._cleanup_failed_start() raise - self._start_payload = payload + self._agent_config = agent_config self._fabric_runtime_id = fabric_runtime_id self._client = client async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: client = self._client - start_payload = self._start_payload + agent_config = self._agent_config fabric_runtime_id = self._fabric_runtime_id - if client is None or start_payload is None or fabric_runtime_id is None: + if client is None or agent_config is None or fabric_runtime_id is None: raise lifecycle.LifecycleError( "claude_runtime_not_started", "Claude runtime is not started", ) - if runtime_id(invocation) != fabric_runtime_id: + runtime_context = _runtime_context(invocation) + if runtime_context.runtime_id != fabric_runtime_id: raise lifecycle.LifecycleError( "claude_runtime_mismatch", "Claude invocation does not match the connected runtime", ) - payload = { - **start_payload, - "runtime_context": invocation.get("runtime_context"), - "request": invocation.get("request"), - } if self._unusable: return _failure( "claude_runtime_unavailable", "Claude runtime cannot accept another invocation after a runtime failure", ) - invocation_deadline = asyncio.get_running_loop().time() + timeout_seconds( - payload - ) + invocation_deadline = asyncio.get_running_loop().time() + timeout_seconds() try: - prompt = request_prompt(payload) + prompt = request_prompt(invocation) except ClaudeAdapterError as error: output = adapter_failure(error) except ClaudeSDKError as error: @@ -1070,7 +1022,6 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: else None ) output = await self._run_query( - payload, client, prompt, _remaining_timeout(invocation_deadline), @@ -1105,7 +1056,6 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: async def _run_query( self, - payload: dict[str, Any], client: ClaudeSDKClient, prompt: str, remaining_timeout: float, @@ -1133,7 +1083,7 @@ async def _run_query( if result is None or not _result_failed(result): raise LOGGER.exception("Claude SDK stream raised after a failed terminal result") - output = self._normalize_invocation(payload, messages, result) + output = self._normalize_invocation(messages, result) else: if result is None: self._unusable = True @@ -1141,17 +1091,16 @@ async def _run_query( "claude_missing_result", "Claude returned no terminal result" ) else: - output = self._normalize_invocation(payload, messages, result) + output = self._normalize_invocation(messages, result) return output def _normalize_invocation( self, - payload: dict[str, Any], messages: list[Message], result: ResultMessage, ) -> dict[str, Any]: try: - output = normalize_result(payload, messages, result) + output = normalize_result(messages, result) if not output["failed"]: invalid_session = _validate_result_session( self._claude_session_id, result @@ -1168,7 +1117,7 @@ def _normalize_invocation( async def stop(self) -> None: client = self._client self._client = None - self._start_payload = None + self._agent_config = None self._fabric_runtime_id = None self._claude_session_id = None self._unusable = True @@ -1227,7 +1176,7 @@ async def _interrupt_failed_invocation(self) -> None: def main() -> None: """Serve the persistent local-host lifecycle protocol.""" - lifecycle.serve(ClaudeRuntime) + lifecycle.serve(ClaudeRuntime, config_loader=AgentConfig.from_mapping) if __name__ == "__main__": diff --git a/adapters/claude/uv.lock b/adapters/claude/uv.lock index 227982408..47f64c253 100644 --- a/adapters/claude/uv.lock +++ b/adapters/claude/uv.lock @@ -348,11 +348,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, ] +[[package]] +name = "nemo-fabric-adapter-contract" +version = "0.2.0" +source = { editable = "../../adapter-contract" } + +[package.metadata] +requires-dist = [{ name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12,<3" }] +provides-extras = ["pydantic"] + [[package]] name = "nemo-fabric-adapters-claude" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "nemo-fabric-adapter-contract" }, { name = "nemo-fabric-adapters-common" }, { name = "tomli-w" }, ] @@ -369,6 +379,7 @@ harness = [ requires-dist = [ { name = "claude-agent-sdk", marker = "extra == 'full'", specifier = "==0.2.120" }, { name = "claude-agent-sdk", marker = "extra == 'harness'", specifier = "==0.2.120" }, + { name = "nemo-fabric-adapter-contract", editable = "../../adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, { name = "tomli-w", specifier = "~=1.2" }, ] diff --git a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json index da212d390..378b8128d 100644 --- a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json @@ -66,6 +66,7 @@ "additionalProperties": false }, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/examples/harbor/swebench/adapters/claude/fabric-adapter.json b/examples/harbor/swebench/adapters/claude/fabric-adapter.json index da212d390..378b8128d 100644 --- a/examples/harbor/swebench/adapters/claude/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/claude/fabric-adapter.json @@ -66,6 +66,7 @@ "additionalProperties": false }, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/tests/adapters/test_adapter_package_metadata.py b/tests/adapters/test_adapter_package_metadata.py index ba45d3a68..679db758f 100644 --- a/tests/adapters/test_adapter_package_metadata.py +++ b/tests/adapters/test_adapter_package_metadata.py @@ -64,6 +64,7 @@ def load_pyproject(path: str) -> dict: ( "adapters/claude", [ + f"nemo-fabric-adapter-contract == {PACKAGE_VERSION}", f"nemo-fabric-adapters-common == {PACKAGE_VERSION}", "tomli-w~=1.2", ], diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index d197358d1..782dc1cb8 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -28,6 +28,8 @@ from claude_agent_sdk import SystemMessage from claude_agent_sdk import TextBlock from claude_agent_sdk._errors import MessageParseError +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapters.claude import adapter ROOT = Path(__file__).resolve().parents[2] @@ -47,11 +49,55 @@ def lifecycle_invocation(payload: dict[str, Any]) -> dict[str, Any]: return { - "runtime_context": payload["runtime_context"], + "runtime_context": { + **payload["runtime_context"], + "request_id": payload["request"]["request_id"], + }, "request": payload["request"], } +def agent_config(payload: dict[str, Any]) -> AgentConfig: + return AgentConfig.from_mapping(payload["config"]) + + +def runtime_context(payload: dict[str, Any]) -> RuntimeContext: + return RuntimeContext.from_mapping( + {**payload["runtime_context"], "request_id": payload["request"]["request_id"]} + ) + + +def build_options(payload: dict[str, Any], *, relay=None) -> ClaudeAgentOptions: + return adapter.build_options( + agent_config(payload), + runtime_context(payload), + payload["base_dir"], + relay=relay, + ) + + +def prepare_claude_relay(payload: dict[str, Any]): + config = agent_config(payload) + return adapter.prepare_claude_relay( + payload["agent_name"], + adapter._selected_model_config(config), + runtime_context(payload), + payload["base_dir"], + ) + + +def lifecycle_start(payload: dict[str, Any]) -> dict[str, Any]: + return { + **payload, + "config": agent_config(payload), + "runtime_context": { + **payload["runtime_context"], + "request_id": (payload.get("request") or {}).get("request_id", "request-1"), + }, + "request": None, + } + + def install_fake_client( monkeypatch: pytest.MonkeyPatch, response_factory: Callable[[MagicMock], AsyncIterator[Message]], @@ -150,6 +196,7 @@ def test_claude_descriptor_is_narrow_and_versioned(): "additionalProperties": False, }, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", @@ -184,7 +231,6 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "base_dir": str(tmp_path), "config": { "harness": { - "adapter_id": "nvidia.fabric.claude", "settings": { "permission_mode": "dontAsk", "max_budget_usd": 1.5, @@ -194,7 +240,7 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "instructions": { "system": {"content": "Review carefully.", "mode": "replace"} }, - "runtime": {"timeout_seconds": 30, "max_turns": 4}, + "runtime": {"max_turns": 4}, "models": { "default": { "provider": "anthropic", @@ -203,11 +249,30 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: } }, "tools": {"blocked": ["Bash"]}, + "skills": {"paths": [str(skill_path)]}, + "mcp": { + "servers": { + "repo": { + "transport": "stdio", + "url": "repo-mcp", + "args": ["--root", ".", "--config", "repo config.json"], + "env": {"REPO_MCP_MODE": "mcp-secret-value"}, + }, + "docs": { + "transport": "streamable-http", + "url": "https://mcp.example.test", + }, + } + }, }, "runtime_context": { "runtime_id": "runtime-claude-1", "invocation_id": "invocation-1", "environment": { + "environment_id": "environment-claude-1", + "provider": "local", + "control_location": "in_env_control", + "ownership": "caller_owned", "workspace": str(workspace), "env": {"ANTHROPIC_API_KEY": "configured-secret"}, }, @@ -238,7 +303,7 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_payload): - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) assert options.cwd == Path( claude_payload["runtime_context"]["environment"]["workspace"] ) @@ -290,29 +355,40 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p assert "ANTHROPIC_BASE_URL" not in options.env -@pytest.mark.parametrize("authentication_type", ["oauth2", "service_account"]) -def test_claude_rejects_mcp_authentication(claude_payload, authentication_type): - server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] - server["authentication"] = {"type": authentication_type} +@pytest.mark.parametrize( + "authentication", + [ + {"type": "oauth2"}, + { + "type": "service_account", + "client_id": "client", + "client_secret_env": "CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + }, + ], +) +def test_claude_rejects_mcp_authentication(claude_payload, authentication): + server = claude_payload["config"]["mcp"]["servers"]["docs"] + server["authentication"] = authentication with pytest.raises( adapter.AdapterConfigError, match="not supported by Claude" ) as caught: - adapter.build_options(claude_payload) + build_options(claude_payload) assert caught.value.code == "claude_invalid_configuration" -def test_claude_maps_mcp_custom_headers(claude_payload): - server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] +def test_claude_maps_mcp_custom_headers(claude_payload, monkeypatch): + server = claude_payload["config"]["mcp"]["servers"]["docs"] server["custom_headers"] = { "X-Tenant": "${FABRIC_TEST_MCP_HEADER}", "X-Windows": "%FABRIC_TEST_WINDOWS_HEADER%", } - os.environ["FABRIC_TEST_MCP_HEADER"] = "fabric" - os.environ["FABRIC_TEST_WINDOWS_HEADER"] = "windows" + monkeypatch.setenv("FABRIC_TEST_MCP_HEADER", "fabric") + monkeypatch.setenv("FABRIC_TEST_WINDOWS_HEADER", "windows") - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) mcp_servers = json.loads(options.mcp_servers.read_text(encoding="utf-8"))[ "mcpServers" ] @@ -331,9 +407,7 @@ async def test_claude_invoke_passes_remaining_budget_to_query( claude_payload, monkeypatch ): runtime = adapter.ClaudeRuntime() - runtime._start_payload = { - key: value for key, value in claude_payload.items() if key != "request" - } + runtime._agent_config = agent_config(claude_payload) runtime._fabric_runtime_id = claude_payload["runtime_context"]["runtime_id"] runtime._client = MagicMock(spec=adapter.ClaudeSDKClient) run_query = AsyncMock(return_value={"completed": False}) @@ -347,9 +421,13 @@ async def test_claude_invoke_passes_remaining_budget_to_query( after = loop.time() invocation_deadline = remaining_timeout.call_args.args[0] - assert before + 30 <= invocation_deadline <= after + 30 + assert ( + before + adapter.timeout_seconds() + <= invocation_deadline + <= after + adapter.timeout_seconds() + ) remaining_timeout.assert_called_once_with(invocation_deadline) - assert run_query.await_args.args[3] == 7.0 + assert run_query.await_args.args[2] == 7.0 async def test_tool_policy_hooks_gate_built_in_and_mcp_tools(claude_payload): @@ -358,7 +436,7 @@ async def test_tool_policy_hooks_gate_built_in_and_mcp_tools(claude_payload): "blocked": ["Bash"], } - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) assert options.tools == ["Read", "Edit"] assert options.allowed_tools == ["Read", "Edit"] @@ -379,7 +457,7 @@ def test_enabled_tools_do_not_populate_allowed_tools_in_default_mode(claude_payl claude_payload["config"]["harness"]["settings"]["permission_mode"] = "default" claude_payload["config"]["tools"] = {"enabled": ["Read"], "blocked": []} - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) assert options.permission_mode == "default" assert options.tools == ["Read"] @@ -403,9 +481,9 @@ def relay_payload_fixture(claude_payload, tmp_path) -> dict[str, Any]: encoding="utf-8", ) os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(relay_intent_path) - claude_payload["telemetry_plan"] = { - "providers": ["relay"], + claude_payload["runtime_context"]["telemetry"] = { "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, } return claude_payload @@ -442,7 +520,7 @@ def test_prepare_claude_relay_writes_gateway_config_and_complete_hook_plugin( ), ) - relay = adapter.prepare_claude_relay(relay_payload) + relay = prepare_claude_relay(relay_payload) assert relay is not None assert relay.gateway.executable == executable @@ -521,9 +599,9 @@ def test_build_options_adds_relay_plugin_and_gateway_environment( ) ), ) - relay = adapter.prepare_claude_relay(relay_payload) + relay = prepare_claude_relay(relay_payload) - options = adapter.build_options(relay_payload, relay=relay) + options = build_options(relay_payload, relay=relay) assert options.env["NEMO_RELAY_GATEWAY_URL"] == relay.gateway.url assert options.env["ANTHROPIC_BASE_URL"] == relay.gateway.url @@ -537,7 +615,7 @@ def test_build_options_adds_relay_plugin_and_gateway_environment( def test_build_options_does_not_enable_skills_for_relay_plugin_alone( relay_payload, tmp_path ): - relay_payload["capability_plan"]["native"]["skill_paths"] = [] + relay_payload["config"]["skills"]["paths"] = [] relay = adapter.ClaudeRelaySettings( gateway=adapter.relay_gateway.RelayGatewayLaunch( executable=tmp_path / "nemo-relay", @@ -550,7 +628,7 @@ def test_build_options_does_not_enable_skills_for_relay_plugin_alone( plugin_path=tmp_path / "relay-plugin", ) - options = adapter.build_options(relay_payload, relay=relay) + options = build_options(relay_payload, relay=relay) assert options.tools is None assert options.skills is None @@ -560,18 +638,25 @@ def test_build_options_does_not_enable_skills_for_relay_plugin_alone( def test_build_options_maps_blocked_tools_to_disallowed_tools(claude_payload): claude_payload["config"]["tools"] = {"blocked": ["Bash", "WebFetch"]} - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) assert options.tools is None assert options.disallowed_tools == ["Bash", "WebFetch"] def test_build_options_rejects_skill_path_without_skill_manifest(claude_payload): - skill_path = Path(claude_payload["capability_plan"]["native"]["skill_paths"][0]) + skill_path = Path(claude_payload["config"]["skills"]["paths"][0]) (skill_path / "SKILL.md").unlink() - with pytest.raises(adapter.AdapterConfigError, match="SKILL.md"): - adapter.build_options(claude_payload) + with pytest.raises(adapter.AdapterConfigError, match=r"SKILL\.md"): + build_options(claude_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 == "claude_invalid_runtime_context" def test_build_options_maps_custom_provider_to_claude_gateway_environment( @@ -589,7 +674,7 @@ def test_build_options_maps_custom_provider_to_claude_gateway_environment( claude_payload["runtime_context"]["environment"]["env"].pop("ANTHROPIC_API_KEY") os.environ["ACME_API_KEY"] = "acme-secret" - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) assert options.model == "aws/anthropic/claude-opus-4-5" assert options.env["ANTHROPIC_BASE_URL"] == "https://acme.example" @@ -612,7 +697,7 @@ def test_build_options_requires_custom_provider_api_key_env( claude_payload["runtime_context"]["environment"]["env"].pop("ANTHROPIC_API_KEY") with pytest.raises(adapter.AdapterConfigError, match="api_key_env is required"): - adapter.build_options(claude_payload) + build_options(claude_payload) def test_build_options_requires_custom_provider_credential(claude_payload): @@ -628,7 +713,7 @@ def test_build_options_requires_custom_provider_credential(claude_payload): os.environ.pop("ACME_API_KEY", None) with pytest.raises(adapter.AdapterConfigError, match="ACME_API_KEY is required"): - adapter.build_options(claude_payload) + build_options(claude_payload) def test_build_options_requires_custom_provider_endpoint(claude_payload): @@ -645,7 +730,7 @@ def test_build_options_requires_custom_provider_endpoint(claude_payload): } with pytest.raises(adapter.AdapterConfigError, match="base_url is required"): - adapter.build_options(claude_payload) + build_options(claude_payload) @pytest.mark.parametrize( @@ -676,15 +761,17 @@ def test_build_options_rejects_model_environment_conflicts( adapter.AdapterConfigError, match=rf"environment\.env\.{name} conflicts", ): - adapter.build_options(claude_payload) + build_options(claude_payload) def test_selected_model_rejects_empty_provider(claude_payload): model = claude_payload["config"]["models"]["default"] model["provider"] = "" - with pytest.raises(adapter.AdapterConfigError, match="non-empty string"): - adapter.selected_model(claude_payload) + with pytest.raises(Exception, match="non-empty lowercase identifier"): + adapter.selected_model( + adapter._selected_model_config(agent_config(claude_payload)) + ) def test_normalize_result_exposes_session_usage_cost_and_buffered_events( @@ -711,7 +798,7 @@ def test_normalize_result_exposes_session_usage_cost_and_buffered_events( result="done", ) - output = adapter.normalize_result(claude_payload, messages, result) + output = adapter.normalize_result(messages, result) assert output["response"] == "done" assert output["session_id"] == "claude-session" @@ -770,7 +857,7 @@ async def interrupt(self): start_payload = dict(claude_payload) start_payload.pop("request") runtime = adapter.ClaudeRuntime() - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) mcp_config_path = clients[0].options.mcp_servers assert isinstance(mcp_config_path, Path) assert mcp_config_path.exists() @@ -813,7 +900,7 @@ async def connect(self): start_payload.pop("request") with pytest.raises(adapter.lifecycle.LifecycleError) as caught: - await adapter.ClaudeRuntime().start(start_payload) + await adapter.ClaudeRuntime().start(lifecycle_start(start_payload)) assert caught.value.code == "claude_connection_failed" assert len(staged_paths) == 1 @@ -888,7 +975,7 @@ async def interrupt(self): start_payload = dict(relay_payload) start_payload.pop("request") runtime = adapter.ClaudeRuntime() - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) first = await runtime.invoke(lifecycle_invocation(relay_payload)) relay_payload["runtime_context"]["invocation_id"] = "invocation-2" second = await runtime.invoke(lifecycle_invocation(relay_payload)) @@ -1000,7 +1087,7 @@ async def write_atif(): start_payload = { key: value for key, value in relay_payload.items() if key != "request" } - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) try: output = await runtime.invoke(lifecycle_invocation(relay_payload)) assert write_task is not None @@ -1057,7 +1144,7 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: start_payload = { key: value for key, value in relay_payload.items() if key != "request" } - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) try: output = await runtime.invoke(lifecycle_invocation(relay_payload)) unavailable = await runtime.invoke(lifecycle_invocation(relay_payload)) @@ -1128,7 +1215,7 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: start_payload = { key: value for key, value in relay_payload.items() if key != "request" } - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) output = await runtime.invoke(lifecycle_invocation(relay_payload)) with pytest.raises(adapter.lifecycle.LifecycleError) as caught: await runtime.stop() @@ -1193,7 +1280,7 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: start_payload = { key: value for key, value in relay_payload.items() if key != "request" } - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) mcp_config_root = runtime._mcp_config_path.parent output = await runtime.invoke(lifecycle_invocation(relay_payload)) with pytest.raises(adapter.lifecycle.LifecycleError) as caught: @@ -1248,7 +1335,7 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: start_payload = { key: value for key, value in relay_payload.items() if key != "request" } - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) try: if isinstance(failure, asyncio.CancelledError): with pytest.raises(asyncio.CancelledError): @@ -1292,7 +1379,7 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: start_payload = { key: value for key, value in claude_payload.items() if key != "request" } - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) output = await runtime.invoke(lifecycle_invocation(claude_payload)) await runtime.stop() @@ -1340,7 +1427,7 @@ async def test_runtime_start_reports_relay_failure_without_raw_diagnostic( key: value for key, value in relay_payload.items() if key != "request" } with pytest.raises(adapter.lifecycle.LifecycleError) as caught: - await runtime.start(start_payload) + await runtime.start(lifecycle_start(start_payload)) assert caught.value.code == "claude_relay_start_failed" assert caught.value.message == "NeMo Relay gateway failed to start" @@ -1393,7 +1480,7 @@ def test_build_options_forwards_anthropic_auth_environment( os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" os.environ.update(auth_environment) - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) forwarded_auth_environment = { name: options.env[name] @@ -1409,7 +1496,7 @@ def test_build_options_preserves_unix_user_for_cached_login( ): os.environ["USER"] = "fabric-user" - options = adapter.build_options(claude_payload) + options = build_options(claude_payload) assert options.env["USER"] == "fabric-user" @@ -1451,7 +1538,7 @@ def test_error_result_is_normalized_as_failure(claude_payload): errors=["provider-specific failure"], ) - output = adapter.normalize_result(claude_payload, [], result) + output = adapter.normalize_result([], result) assert output["failed"] is True assert output["error"] == { @@ -1472,7 +1559,7 @@ def test_error_subtype_is_failure_when_sdk_flag_is_false(claude_payload): session_id="claude-session", ) - output = adapter.normalize_result(claude_payload, [], result) + output = adapter.normalize_result([], result) assert output["completed"] is False assert output["failed"] is True @@ -1485,4 +1572,6 @@ def test_main_serves_persistent_runtime(monkeypatch): adapter.main() - serve.assert_called_once_with(adapter.ClaudeRuntime) + serve.assert_called_once_with( + adapter.ClaudeRuntime, config_loader=AgentConfig.from_mapping + ) diff --git a/uv.lock b/uv.lock index 5114d0571..3c51519b3 100644 --- a/uv.lock +++ b/uv.lock @@ -2270,6 +2270,7 @@ name = "nemo-fabric-adapters-claude" version = "0.2.0" source = { editable = "adapters/claude" } dependencies = [ + { name = "nemo-fabric-adapter-contract" }, { name = "nemo-fabric-adapters-common" }, { name = "tomli-w" }, ] @@ -2283,6 +2284,7 @@ harness = [ requires-dist = [ { name = "claude-agent-sdk", marker = "extra == 'full'", specifier = "==0.2.120" }, { name = "claude-agent-sdk", marker = "extra == 'harness'", specifier = "==0.2.120" }, + { name = "nemo-fabric-adapter-contract", editable = "adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "tomli-w", specifier = "~=1.2" }, ]