Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ authentication, and execution details.
- **Profiles:** named variations of the base config. Use profiles to vary the
harness, model, MCP, tools, skills, telemetry, or environment context without
editing `agent.yaml`.
- **Tools policy:** use top-level `tools.blocked` for harness-neutral blocked
tool policy. Names are interpreted by the selected adapter:

```yaml
tools:
blocked:
- browser
- shell
```

Hermes maps these names to disabled toolsets, Claude maps them to
`disallowed_tools`, Deep Agents enforces them with middleware, and adapters
without a native deny mechanism route the policy as unsupported.
- **Adapters:** harness-specific integrations selected by `harness.adapter_id`.
The Hermes adapter lives under `adapters/hermes/`; the Codex CLI
adapter lives under `adapters/codex-cli/`; the
Expand Down
14 changes: 8 additions & 6 deletions adapters/claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,25 @@ Configure portable capabilities through the normalized `FabricConfig` fields:
`provider="anthropic"`; normalized hosted/custom provider resolution is
tracked in [FABRIC-64](https://linear.app/nvidia/issue/FABRIC-64/add-normalized-model-provider-resolution-and-harness-compatibility).
- `environment.workspace` sets the Claude working directory.
- `tools` sets the base Claude tool list.
- `tools.blocked` maps to Claude `disallowed_tools` using Claude-native tool
names.
- `mcp` configures stdio, HTTP, streamable HTTP, or SSE servers. For stdio,
Fabric parses `url` as a command plus arguments.
- `skills.paths` names skill directories that contain `SKILL.md`. The adapter
stages these directories as a local Claude plugin for the invocation.

Only Claude-specific controls belong in `harness.settings`:

- `system_prompt`, `allowed_tools`, `disallowed_tools`, and `permission_mode`
- `system_prompt`, `allowed_tools`, and `permission_mode`
- `max_turns`, `max_budget_usd`, and `timeout_seconds`
- `setting_sources` (defaults to `[]` for deterministic isolation)
- `cli_path` for testing or an explicitly installed Claude Code executable
- `nemo_relay_command` for an explicitly installed NeMo Relay CLI executable
- `env` for variables explicitly forwarded to Claude Code

Putting `model_name`, `cwd`, `tools`, `mcp_servers`, or `skills` in
`harness.settings` is an error. Use the corresponding normalized field so the
same consumer configuration can compose with other adapters.
Putting `model_name`, `cwd`, `tools`, `disallowed_tools`, `mcp_servers`, or
`skills` in `harness.settings` is an error. Use the corresponding normalized
field so the same consumer configuration can compose with other adapters.

The adapter filters the inherited environment before launching Claude Code.
It retains portable OS/config variables, the selected model's `api_key_env`,
Expand Down Expand Up @@ -116,6 +117,7 @@ from nemo_fabric import (
ModelConfig,
RuntimeConfig,
SkillConfig,
ToolsConfig,
)

base_dir = Path("/workspace/review-agent")
Expand All @@ -139,7 +141,7 @@ config = FabricConfig(
},
runtime=RuntimeConfig(artifacts="./artifacts"),
environment=EnvironmentConfig(provider="local", workspace="."),
tools=["Read", "Glob", "Grep"],
tools=ToolsConfig(blocked=["WebFetch"]),
mcp=McpConfig(
servers={
"repo": McpServerConfig(
Expand Down
2 changes: 1 addition & 1 deletion adapters/claude/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"callable": "run"
},
"config": {
"accepts": ["models", "tools", "mcp", "skills", "telemetry"]
"accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"]
},
"telemetry": {
"providers": {
Expand Down
102 changes: 27 additions & 75 deletions adapters/claude/src/nemo_fabric_adapters/claude/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,26 @@
import os
import shlex
import shutil
from dataclasses import asdict, dataclass, is_dataclass
from dataclasses import asdict
from dataclasses import dataclass
from dataclasses import is_dataclass
from hashlib import sha256
from pathlib import Path
from typing import Any

from claude_agent_sdk import (
CLIConnectionError,
CLIJSONDecodeError,
CLINotFoundError,
ClaudeAgentOptions,
ClaudeSDKError,
Message,
ProcessError,
ResultMessage,
query,
)
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk import ClaudeSDKError
from claude_agent_sdk import CLIConnectionError
from claude_agent_sdk import CLIJSONDecodeError
from claude_agent_sdk import CLINotFoundError
from claude_agent_sdk import Message
from claude_agent_sdk import ProcessError
from claude_agent_sdk import ResultMessage
from claude_agent_sdk import query
from claude_agent_sdk._errors import MessageParseError

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.utils as common_utils

from nemo_fabric_adapters.common import relay_gateway
from nemo_fabric_adapters.common import relay_hooks
from nemo_fabric_adapters.common import utils as common_utils

PERMISSION_MODES = {
"default",
Expand All @@ -47,6 +45,7 @@
"model_name": "FabricConfig.models",
"cwd": "FabricConfig.environment.workspace",
"tools": "FabricConfig.tools",
"disallowed_tools": "FabricConfig.tools.blocked",
"mcp_servers": "FabricConfig.mcp",
"skills": "FabricConfig.skills",
}
Expand Down Expand Up @@ -267,33 +266,6 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]:
return result


def _normalized_tools(
payload: dict[str, Any], *, include_skills: bool
) -> list[str] | dict[str, Any] | None:
native = (
_mapping(common_utils.capability_plan(payload), name="capability_plan").get(
"native"
)
or {}
)
if not _mapping(native, name="capability_plan.native").get("tools_configured"):
return None
tools = common_utils.fabric_config(payload).get("tools")
if tools is not None and not isinstance(tools, (list, dict)):
raise AdapterConfigError("claude_invalid_configuration", "tools is invalid")
if isinstance(tools, list):
normalized = _string_list(tools, name="tools")
if include_skills and "Skill" not in normalized:
normalized.append("Skill")
return normalized
if isinstance(tools, dict) and tools != {"type": "preset", "preset": "claude_code"}:
raise AdapterConfigError(
"claude_invalid_configuration",
"tools preset must be {'type': 'preset', 'preset': 'claude_code'}",
)
return tools


def _native_skill_paths(payload: dict[str, Any]) -> list[Path]:
native = (
_mapping(common_utils.capability_plan(payload), name="capability_plan").get(
Expand All @@ -302,12 +274,8 @@ def _native_skill_paths(payload: dict[str, Any]) -> list[Path]:
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"
)
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]


Expand Down Expand Up @@ -516,11 +484,9 @@ def build_options(
cwd=resolve_cwd(payload),
model=selected_model(payload),
system_prompt=system_prompt,
tools=_normalized_tools(payload, include_skills=has_skill_plugin),
tools=None,
allowed_tools=_string_list(settings.get("allowed_tools"), name="allowed_tools"),
disallowed_tools=_string_list(
settings.get("disallowed_tools"), name="disallowed_tools"
),
disallowed_tools=common_utils.blocked_tools(payload),
permission_mode=permission_mode,
max_turns=max_turns,
max_budget_usd=max_budget,
Expand Down Expand Up @@ -575,14 +541,10 @@ def load_claude_session_id(
raise ValueError("missing Claude session")
return session_id
except (OSError, ValueError, json.JSONDecodeError) as error:
raise AdapterStateError(
"claude_invalid_runtime_state", "Claude runtime state is invalid"
) from error
raise AdapterStateError("claude_invalid_runtime_state", "Claude runtime state is invalid") from error


def save_claude_session_id(
payload: dict[str, Any], fabric_runtime_id: str, claude_session_id: str
) -> None:
def save_claude_session_id(payload: dict[str, Any], fabric_runtime_id: str, claude_session_id: str) -> None:
if not claude_session_id:
raise AdapterStateError(
"claude_invalid_runtime_state", "Claude session ID is missing"
Expand Down Expand Up @@ -623,13 +585,9 @@ def normalize_message(message: Message) -> dict[str, Any]:
return {"type": type(message).__name__, "message": _json_safe(message)}


def normalize_result(
payload: dict[str, Any], messages: list[Message], result: ResultMessage
) -> dict[str, Any]:
def normalize_result(payload: dict[str, Any], messages: list[Message], result: ResultMessage) -> dict[str, Any]:
del payload
failed = bool(result.is_error) or (
isinstance(result.subtype, str) and result.subtype.startswith("error_")
)
failed = bool(result.is_error) or (isinstance(result.subtype, str) and result.subtype.startswith("error_"))
error = None
if failed:
error = {
Expand Down Expand Up @@ -847,20 +805,14 @@ def run(payload: dict[str, Any]) -> dict[str, Any]:
except ClaudeAdapterError as error:
return adapter_failure(error)
except Exception: # Adapter boundary must always return normalized JSON.
return _failure(
"claude_adapter_internal_error", "Claude adapter failed unexpectedly"
)
return _failure("claude_adapter_internal_error", "Claude adapter failed unexpectedly")


def main() -> None:
try:
payload = common_utils.load_payload()
except (
Exception
): # Malformed invocation input must still satisfy the process contract.
output = _failure(
"claude_adapter_internal_error", "Claude adapter failed unexpectedly"
)
except Exception: # Malformed invocation input must still satisfy the process contract.
output = _failure("claude_adapter_internal_error", "Claude adapter failed unexpectedly")
else:
output = run(payload)
print(json.dumps(output, sort_keys=True))
Expand Down
23 changes: 20 additions & 3 deletions adapters/common/src/nemo_fabric_adapters/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ def virtualenv_subprocess_env() -> dict[str, str]:
scripts = virtualenv / ("Scripts" if os.name == "nt" else "bin")
path = env.get("PATH")
env["VIRTUAL_ENV"] = str(virtualenv)
env["PATH"] = os.pathsep.join(
part for part in (str(scripts), path) if part
)
env["PATH"] = os.pathsep.join(part for part in (str(scripts), path) if part)
env.pop("PYTHONHOME", None)
return env

Expand Down Expand Up @@ -166,6 +164,16 @@ def capability_plan(payload: dict[str, Any]) -> dict[str, Any]:
return payload.get("capability_plan") or payload.get("capabilities") or {}


def tools_config(payload: dict[str, Any]) -> dict[str, Any]:
tools = fabric_config(payload).get("tools") or {}
return tools if isinstance(tools, dict) else {}


def blocked_tools(payload: dict[str, Any]) -> list[str]:
blocked = tools_config(payload).get("blocked")
return normalize_list(blocked)


def normalize_list(value: Any) -> list[str]:
if value is None:
return []
Expand All @@ -176,6 +184,15 @@ def normalize_list(value: Any) -> list[str]:
return [str(item) for item in value if str(item)]


def merge_unique(*values: Any) -> list[str]:
merged: list[str] = []
for value in values:
for item in normalize_list(value):
if item not in merged:
merged.append(item)
return merged


def without_none(mapping: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in mapping.items() if value is not None}

Expand Down
25 changes: 11 additions & 14 deletions adapters/deepagents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,9 @@ Fabric maps the following into the harness:
- Configured MCP servers are loaded as Deep Agents tools via
`langchain-mcp-adapters`. A misconfigured server (non-mapping, empty target,
unsupported transport) is a normalized configuration failure, not a silent drop.
- `tools` (Fabric's `config.tools` allow-list) is enforced by a gating middleware
across the full tool surface — Deep Agents built-ins (including `task`), MCP
tools, and **delegated subagents** alike; tool calls whose name is not on the
list are blocked, so tools routed through the `task` tool cannot run ungated. A
non-list `tools` value is a normalized configuration failure rather than a
silently disabled allow-list.
- `tools.blocked` is enforced by middleware across the full tool surface — Deep
Agents built-ins (including `task`), MCP tools, and **delegated subagents**
alike. Use Deep Agents/native tool names in the blocked list.
- `harness.settings.deepagents` forwards a small set of **documented,
JSON-serializable** `create_deep_agent` options (currently `subagents` and
`interrupt_on`). It is not a general Python-object escape hatch: the SDK config
Expand All @@ -72,20 +69,20 @@ Fabric maps the following into the harness:

Deep Agents can delegate to subagents through its built-in `task` tool. Subagents
**inherit** the parent run's model, tools, skills, workspace, telemetry, and
permissions — in particular, the parent `config.tools` allow-list applies to
delegated execution, so a subagent cannot broaden capabilities beyond the parent.
Independently configured subagent tools, skills, models, MCP servers, middleware,
or permissions are **not** exposed through the Fabric SDK yet; a `subagents`
definition here only carries JSON-shaped fields. Deterministic verification of
delegated tool-gating is currently mock-based (see the adapter tests); a fuller
real-subagent contract is future work.
permissions. When `tools.blocked` is configured, Fabric supplies an explicitly
gated `general-purpose` subagent and gates every declarative local subagent, so
delegation cannot broaden capabilities beyond the parent. Remote and precompiled
subagents are rejected in that case because their execution cannot be governed by
the local middleware. Independently configured subagent tools, skills, models,
MCP servers, middleware, or permissions are **not** exposed through the Fabric SDK
yet; a `subagents` definition here only carries JSON-shaped fields.

The normalized result includes the final response, buffered messages and
per-step events, LangGraph thread id, token usage (and cost when the provider
reports it), and errors. Usage aggregates the current turn across the main agent
and any delegated subagents (streamed with `subgraphs=True`). Configuration and
preflight failures (a missing credential, an absent `deepagents` package, an
invalid allow-list, MCP server, or passthrough option) are returned as a
invalid MCP server, or a passthrough option) are returned as a
normalized failure result rather than a raw traceback.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Runtime Modes
Expand Down
2 changes: 1 addition & 1 deletion adapters/deepagents/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"requirements": {},
"config": {
"accepts": ["models", "tools", "mcp", "skills", "telemetry"]
"accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"]
},
"telemetry": {
"providers": {
Expand Down
Loading