Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Fabric provides:
- a versioned typed config contract, with `agent.yaml` as the portable file
format;
- profile-based config variation for evaluation and ablation runs;
- adapter descriptors for harness-specific launch and control;
- adapter descriptors for harness-specific launch, lifecycle control, and
supported execution strategies;
- a Rust core with a CLI and Python bindings;
- JSON Schema snapshots for the public config and runtime contract;
- normalized run results, artifact manifests, and telemetry references.
Expand Down
18 changes: 13 additions & 5 deletions adapters/claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,18 @@ for other supported installation methods.

## Execution Model

Each `invoke` starts a fresh adapter process. The adapter persists the terminal
Claude session ID under the Fabric artifact root, keyed by `runtime_id`, and
passes it as `ClaudeAgentOptions.resume` on the next invocation. One Fabric
runtime therefore maps to one Claude session even though no adapter process
stays resident.
The compatibility default, `process_per_invocation`, starts a fresh adapter
process for each `invoke`. Set
`harness.settings.runtime_strategy="persistent_local_host"` to keep one
adapter host for the Fabric runtime and process invocations in order. Both
strategies persist the terminal Claude session ID under the Fabric artifact
root, keyed by `runtime_id`, and pass it as `ClaudeAgentOptions.resume` on the
next invocation. One Fabric runtime therefore maps to one Claude session
independently of adapter-process lifetime.

The adapter does not declare `remote_service`. The Claude Agent SDK still uses
a local Claude Code control process, even when the selected model is remotely
hosted.

## Configuration

Expand All @@ -87,6 +94,7 @@ Configure portable capabilities through the normalized `FabricConfig` fields:

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

- `runtime_strategy`: `process_per_invocation` or `persistent_local_host`
- `system_prompt`, `allowed_tools`, and `permission_mode`
- `max_turns`, `max_budget_usd`, and `timeout_seconds`
- `setting_sources` (defaults to `[]` for deterministic isolation)
Expand Down
4 changes: 4 additions & 0 deletions adapters/claude/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"config": {
"accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"]
},
"execution": {
"lifecycle_contract_version": "fabric.adapter.lifecycle/v1alpha1",
"strategies": ["process_per_invocation", "persistent_local_host"]
},
"telemetry": {
"providers": {
"relay": {
Expand Down
4 changes: 4 additions & 0 deletions adapters/claude/src/nemo_fabric_adapters/claude/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from claude_agent_sdk._errors import MessageParseError
from nemo_fabric_adapters.common import relay_gateway
from nemo_fabric_adapters.common import relay_hooks
from nemo_fabric_adapters.common import lifecycle
from nemo_fabric_adapters.common import utils as common_utils

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -885,6 +886,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]:


def main() -> None:
if lifecycle.is_lifecycle_host(os.environ):
lifecycle.serve(run)
return
try:
payload = common_utils.load_payload()
except (
Expand Down
13 changes: 10 additions & 3 deletions adapters/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,19 @@ to the Fabric config root. Fabric passes the resolved path through

## Execution Model

Each Fabric invocation starts a fresh SDK client and closes its app-server
transport before returning. The first invocation creates a Codex thread and
The compatibility default, `process_per_invocation`, starts a fresh adapter
process for each Fabric invocation. Set
`harness.settings.runtime_strategy="persistent_local_host"` to keep one
adapter host for the Fabric runtime and process invocations in order. The
current SDK integration creates and closes its app-server client for each turn
under either strategy. The first invocation creates a Codex thread and
persists its ID under the Fabric artifact root. Later invocations for the same
Fabric runtime resume that exact thread. Codex owns the transcript; Fabric owns
runtime-to-thread correlation, timeout, cancellation, and cleanup.

The adapter does not declare `remote_service`. The Codex SDK still uses a local
app-server control process, even when model inference is remotely hosted.

The result includes the SDK's typed terminal response, turn status, token
usage, timing, and completed thread items. It does not expose CLI commands,
return codes, stdout, or stderr.
Expand All @@ -83,6 +90,7 @@ Use normalized `FabricConfig` fields for portable configuration:

Codex-specific controls belong in `harness.settings`:

- `runtime_strategy`: `process_per_invocation` or `persistent_local_host`
- `sandbox`: `read-only`, `workspace-write`, or `danger-full-access`
- `approval_mode`: `auto_review` or `deny_all`
- `base_instructions` and `developer_instructions`
Expand Down Expand Up @@ -157,4 +165,3 @@ For Phoenix, native Codex OpenTelemetry targets the OTLP collector at
Relay OpenInference provides the semantic chain, LLM, and tool hierarchy with
decoded prompt, response, and token attributes. Prefer Relay OpenInference for
agent-turn inspection.

4 changes: 4 additions & 0 deletions adapters/codex/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"config": {
"accepts": ["models", "telemetry"]
},
"execution": {
"lifecycle_contract_version": "fabric.adapter.lifecycle/v1alpha1",
"strategies": ["process_per_invocation", "persistent_local_host"]
},
"telemetry": {
"providers": {
"relay": {
Expand Down
4 changes: 4 additions & 0 deletions adapters/codex/src/nemo_fabric_adapters/codex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

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.lifecycle as lifecycle

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the lint-compliant lifecycle import.

Ruff PLR0402 flags this alias form.

Proposed fix
-import nemo_fabric_adapters.common.lifecycle as lifecycle
+from nemo_fabric_adapters.common import lifecycle
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import nemo_fabric_adapters.common.lifecycle as lifecycle
from nemo_fabric_adapters.common import lifecycle
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 32-32: Use from nemo_fabric_adapters.common import lifecycle in lieu of alias

Replace with from nemo_fabric_adapters.common import lifecycle

(PLR0402)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/codex/src/nemo_fabric_adapters/codex/adapter.py` at line 32, Update
the lifecycle import in the adapter module to use Ruff’s lint-compliant import
form instead of aliasing the module to the same name. Preserve all existing
references to lifecycle and avoid unrelated changes.

Source: Linters/SAST tools

import nemo_fabric_adapters.common.utils as common_utils


Expand Down Expand Up @@ -911,6 +912,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]:


def main() -> None:
if lifecycle.is_lifecycle_host(os.environ):
lifecycle.serve(run)
return
try:
payload = common_utils.load_payload()
except Exception:
Expand Down
238 changes: 238 additions & 0 deletions adapters/common/src/nemo_fabric_adapters/common/lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Versioned lifecycle host for adapters that support persistent runtimes."""

from __future__ import annotations

import json
import os
import sys
import traceback
from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Mapping
from contextlib import contextmanager
from contextlib import redirect_stdout
from typing import Any
from typing import TextIO


CONTRACT_VERSION = "fabric.adapter.lifecycle/v1alpha1"
CONTRACT_ENV = "FABRIC_ADAPTER_LIFECYCLE_CONTRACT"

AdapterRun = Callable[[dict[str, Any]], dict[str, Any]]


def is_lifecycle_host(environ: Mapping[str, str]) -> bool:
"""Return whether Fabric requested the versioned lifecycle host protocol."""

return CONTRACT_ENV in environ


def _error(stage: str, code: str, message: str) -> dict[str, Any]:
return {
"stage": stage,
"code": code,
"message": message,
"retryable": False,
}


def _response(
operation: str,
*,
output: Any = None,
error: dict[str, Any] | None = None,
) -> dict[str, Any]:
outcome = (
{"status": "succeeded", "output": output}
if error is None
else {"status": "failed", "error": error}
)
return {
"contract_version": CONTRACT_VERSION,
"operation": operation,
"outcome": outcome,
}


def _runtime_id(message: dict[str, Any]) -> str | None:
operation = message.get("operation")
payload = message.get("payload") or {}
if operation == "start":
value = (payload.get("runtime") or {}).get("runtime_id")
elif operation == "invoke":
value = (payload.get("runtime_context") or {}).get("runtime_id")
else:
value = payload.get("runtime_id")
return value if isinstance(value, str) and value else None


@contextmanager
def _invocation_environment(payload: dict[str, Any]) -> Iterator[None]:
telemetry = (payload.get("runtime_context") or {}).get("telemetry") or {}
overlay = telemetry.get("env") if isinstance(telemetry, dict) else None
if not isinstance(overlay, dict) or any(
not isinstance(key, str) or not isinstance(value, str)
for key, value in overlay.items()
):
overlay = {}
previous = {key: os.environ.get(key) for key in overlay}
os.environ.update(overlay)
try:
yield
finally:
for key, value in previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value


def _handle_message(
message: dict[str, Any],
*,
run: AdapterRun,
active_runtime_id: str | None,
) -> tuple[dict[str, Any], str | None, bool]:
operation = message.get("operation")
if operation not in {"start", "invoke", "stop"}:
return (
_response(
"start",
error=_error(
"start", "lifecycle_invalid_operation", "Unknown lifecycle operation"
),
),
active_runtime_id,
False,
)
if message.get("contract_version") != CONTRACT_VERSION:
return (
_response(
operation,
error=_error(
operation,
"lifecycle_contract_mismatch",
f"Expected lifecycle contract {CONTRACT_VERSION}",
),
),
active_runtime_id,
False,
)

runtime_id = _runtime_id(message)
if runtime_id is None:
return (
_response(
operation,
error=_error(
operation,
"lifecycle_invalid_runtime",
"Lifecycle payload is missing a runtime ID",
),
),
active_runtime_id,
False,
)

if operation == "start":
if active_runtime_id is not None:
return (
_response(
operation,
error=_error(
operation,
"lifecycle_already_started",
"Lifecycle host already owns a runtime",
),
),
active_runtime_id,
False,
)
return _response(operation), runtime_id, False

if active_runtime_id != runtime_id:
return (
_response(
operation,
error=_error(
operation,
"lifecycle_runtime_mismatch",
"Lifecycle payload does not match the active runtime",
),
),
active_runtime_id,
False,
)
if operation == "invoke":
payload = message.get("payload")
if not isinstance(payload, dict):
return (
_response(
operation,
error=_error(
operation,
"lifecycle_invalid_payload",
"Invoke payload must be a mapping",
),
),
active_runtime_id,
False,
)
# Protocol stdout is reserved for one JSON response per line. Preserve
# incidental adapter/library output as diagnostics instead.
try:
with _invocation_environment(payload), redirect_stdout(sys.stderr):
output = run(payload)
except Exception:
traceback.print_exc(file=sys.stderr)
return (
_response(
operation,
error=_error(
operation,
"lifecycle_adapter_failure",
"Adapter failed while processing the invocation",
),
),
active_runtime_id,
False,
)
return _response(operation, output=output), active_runtime_id, False

return _response(operation), None, True


def serve(
run: AdapterRun,
*,
input_stream: TextIO = sys.stdin,
output_stream: TextIO = sys.stdout,
) -> None:
"""Serve ordered lifecycle requests for exactly one Fabric runtime."""

active_runtime_id: str | None = None
for line in input_stream:
try:
message = json.loads(line)
if not isinstance(message, dict):
raise TypeError("lifecycle request must be a mapping")
response, active_runtime_id, should_stop = _handle_message(
message,
run=run,
active_runtime_id=active_runtime_id,
)
except Exception as error: # Protocol boundary must retain diagnostics.
print(f"Invalid lifecycle request: {error}", file=sys.stderr, flush=True)
response = _response(
"start",
error=_error(
"start", "lifecycle_invalid_request", "Invalid lifecycle request"
),
)
should_stop = False
print(json.dumps(response, sort_keys=True), file=output_stream, flush=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep response serialization inside the protocol boundary.

A non-JSON-serializable adapter output raises here and terminates the persistent host without a normalized response. Catch serialization failures and emit a safe lifecycle error.

Proposed fix
-        print(json.dumps(response, sort_keys=True), file=output_stream, flush=True)
+        try:
+            encoded = json.dumps(response, sort_keys=True)
+        except (TypeError, ValueError) as error:
+            operation = response.get("operation")
+            if not isinstance(operation, str):
+                operation = "start"
+            print(
+                f"Invalid lifecycle response: {error}",
+                file=sys.stderr,
+                flush=True,
+            )
+            encoded = json.dumps(
+                _response(
+                    operation,
+                    error=_error(
+                        operation,
+                        "lifecycle_invalid_response",
+                        "Adapter returned an invalid lifecycle response",
+                    ),
+                ),
+                sort_keys=True,
+            )
+        print(encoded, file=output_stream, flush=True)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
print(json.dumps(response, sort_keys=True), file=output_stream, flush=True)
try:
encoded = json.dumps(response, sort_keys=True)
except (TypeError, ValueError) as error:
operation = response.get("operation")
if not isinstance(operation, str):
operation = "start"
print(
f"Invalid lifecycle response: {error}",
file=sys.stderr,
flush=True,
)
encoded = json.dumps(
_response(
operation,
error=_error(
operation,
"lifecycle_invalid_response",
"Adapter returned an invalid lifecycle response",
),
),
sort_keys=True,
)
print(encoded, file=output_stream, flush=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/common/src/nemo_fabric_adapters/common/lifecycle.py` at line 236,
Update the response output path around json.dumps in the lifecycle handler to
catch serialization failures, prevent the persistent host from terminating, and
emit a safe normalized lifecycle error response instead. Preserve the existing
successful JSON output behavior and flushing for serializable adapter results.

if should_stop:
break
3 changes: 3 additions & 0 deletions adapters/deepagents/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"config": {
"accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"]
},
"execution": {
"strategies": ["process_per_invocation"]
},
"telemetry": {
"providers": {
"relay": {
Expand Down
3 changes: 3 additions & 0 deletions adapters/hermes/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"telemetry"
]
},
"execution": {
"strategies": ["process_per_invocation"]
},
"telemetry": {
"providers": {
"relay": {
Expand Down
Loading
Loading