diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md
index 81fd40d4b..e0d90341d 100644
--- a/docs/reference/api/python-library-reference/index.md
+++ b/docs/reference/api/python-library-reference/index.md
@@ -43,6 +43,7 @@ SPDX-License-Identifier: Apache-2.0 */}
- [`types.EffectiveConfig`](./nemo_fabric.types.md#class-effectiveconfig): Immutable result of config loading and ordered profile application.
- [`types.ErrorInfo`](./nemo_fabric.types.md#class-errorinfo): Structured failure returned inside a normalized ``RunResult``.
- [`types.FabricEvent`](./nemo_fabric.types.md#class-fabricevent): One normalized lifecycle or invocation event.
+- [`types.RunOutput`](./nemo_fabric.types.md#class-runoutput): Normalized adapter output.
- [`types.RunPlan`](./nemo_fabric.types.md#class-runplan): Immutable execution plan produced before a runtime is started.
- [`types.RunResult`](./nemo_fabric.types.md#class-runresult): Normalized terminal result from one Fabric invocation.
- [`types.RuntimeCapabilities`](./nemo_fabric.types.md#class-runtimecapabilities): Operations declared by the resolved runtime and adapter.
diff --git a/docs/reference/api/python-library-reference/nemo_fabric.types.md b/docs/reference/api/python-library-reference/nemo_fabric.types.md
index e6f646b23..3b05fbfc4 100644
--- a/docs/reference/api/python-library-reference/nemo_fabric.types.md
+++ b/docs/reference/api/python-library-reference/nemo_fabric.types.md
@@ -818,6 +818,75 @@ Return an immutable view of preserved extension fields.
+---
+
+
+### classmethod `from_mapping`
+
+```python
+from_mapping(mapping: 'Mapping[str, Any]') → 'FabricMapping'
+```
+
+Validate and copy a mapping into the requested typed model.
+
+---
+
+
+### method `to_dict`
+
+```python
+to_dict() → dict[str, Any]
+```
+
+Return the same detached representation as ``to_mapping()``.
+
+---
+
+
+### method `to_mapping`
+
+```python
+to_mapping() → dict[str, Any]
+```
+
+Return a detached, JSON-compatible mapping for serialization.
+
+
+---
+
+
+## class `RunOutput`
+
+Normalized adapter output.
+
+``response`` is a known adapter response field whose value follows the core Fabric JSON contract. Other keys are adapter-specific extensions.
+
+
+### method `__init__`
+
+```python
+__init__(mapping: 'Mapping[str, Any]') → None
+```
+
+
+
+
+
+
+---
+
+### property extra_fields
+
+Return an immutable view of preserved extension fields.
+
+---
+
+### property response
+
+Return the raw ``response`` JSON value, or ``None`` when absent.
+
+
+
---
@@ -873,7 +942,7 @@ The model is both attribute-accessible and mapping-compatible. A harness failure
- `invocation_id`: Identifier for this invocation.
- `request_id`: Correlated request identifier.
- `status`: Terminal invocation status.
- - `output`: JSON-compatible harness output.
+ - `output`: Object-shaped adapter output as ``RunOutput``; non-object values are preserved as-is.
- `error`: Structured failure, or ``None`` on success.
- `artifacts`: Normalized artifact manifest.
- `telemetry`: Ordered telemetry references.
diff --git a/examples/code_review_agent/__main__.py b/examples/code_review_agent/__main__.py
index e7d11087e..4d646c776 100644
--- a/examples/code_review_agent/__main__.py
+++ b/examples/code_review_agent/__main__.py
@@ -54,9 +54,11 @@ async def main() -> None:
else:
output = await fabric.run(config, base_dir=BASE_DIR, input=args.input)
print(json.dumps(output.to_mapping(), indent=2))
+
if args.show_output and not args.plan:
- if isinstance(output.output, dict) and "response" in output.output:
- print(f"\n{output.output['response']}")
+ response = getattr(output.output, "response", None)
+ if response is not None:
+ print(f"\n{response}")
elif output.error is not None:
print(f"\n{output.error.message}")
else:
diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py
index 561fcb332..2af20b50a 100644
--- a/python/src/nemo_fabric/__init__.py
+++ b/python/src/nemo_fabric/__init__.py
@@ -38,6 +38,7 @@
EffectiveConfig,
ErrorInfo,
FabricEvent,
+ RunOutput,
RunPlan,
RunResult,
RuntimeCapabilities,
@@ -71,6 +72,7 @@
"FabricNativeUnavailableError",
"FabricRuntimeError",
"FabricStateError",
+ "RunOutput",
"RunPlan",
"RunRequest",
"RunResult",
diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py
index ec0d38774..393fad5dd 100644
--- a/python/src/nemo_fabric/types.py
+++ b/python/src/nemo_fabric/types.py
@@ -1205,6 +1205,24 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]:
return data
+class RunOutput(FabricMapping):
+ """Normalized adapter output.
+
+ ``response`` is a known adapter response field whose value follows the
+ core Fabric JSON contract. Other keys are adapter-specific extensions.
+ """
+
+ response: JSONValue | None
+ _fields = frozenset({"response"})
+ _json_fields = frozenset({"response"})
+
+ @property
+ def response(self) -> JSONValue | None:
+ """Return the raw ``response`` JSON value, or ``None`` when absent."""
+
+ return _snapshot_value(self._data.get("response"), json_value=True)
+
+
class RunResult(FabricMapping):
"""Normalized terminal result from one Fabric invocation.
@@ -1222,7 +1240,8 @@ class RunResult(FabricMapping):
invocation_id: Identifier for this invocation.
request_id: Correlated request identifier.
status: Terminal invocation status.
- output: JSON-compatible harness output.
+ output: Object-shaped adapter output as ``RunOutput``; non-object values
+ are preserved as-is.
error: Structured failure, or ``None`` on success.
artifacts: Normalized artifact manifest.
telemetry: Ordered telemetry references.
@@ -1239,7 +1258,7 @@ class RunResult(FabricMapping):
invocation_id: str
request_id: str
status: str
- output: Any
+ output: RunOutput | JSONValue
error: ErrorInfo | None
artifacts: ArtifactManifest
telemetry: Sequence[TelemetryRef]
@@ -1264,7 +1283,7 @@ class RunResult(FabricMapping):
"metadata",
}
)
- _json_fields = frozenset({"output", "metadata"})
+ _json_fields = frozenset({"metadata"})
@classmethod
def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]:
@@ -1298,4 +1317,9 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]:
FabricEvent.from_mapping(event) for event in data.get("events", [])
)
data["metadata"] = _mapping(data.get("metadata", {}), "result metadata")
+ raw_output = data.get("output")
+ if isinstance(raw_output, RunOutput):
+ data["output"] = raw_output
+ elif isinstance(raw_output, Mapping):
+ data["output"] = RunOutput.from_mapping(raw_output)
return data
diff --git a/tests/python/test_code_review_example.py b/tests/python/test_code_review_example.py
index 1062abd28..ab3a911a7 100644
--- a/tests/python/test_code_review_example.py
+++ b/tests/python/test_code_review_example.py
@@ -22,7 +22,7 @@
with_relay_openinference,
with_relay_otel,
)
-from nemo_fabric import Fabric, FabricConfig
+from nemo_fabric import Fabric, FabricConfig, RunOutput
def test_variant_builders_return_independent_complete_configs():
@@ -123,8 +123,8 @@ async def test_example_entrypoint_shows_response_after_normalized_output(
capsys,
):
result = MagicMock()
- result.output = {"response": "visible response"}
- result.to_mapping.return_value = {"output": result.output}
+ result.output = RunOutput.from_mapping({"response": "visible response"})
+ result.to_mapping.return_value = {"output": result.output.to_mapping()}
mock_fabric = MagicMock()
mock_fabric.run = AsyncMock(return_value=result)
monkeypatch.setattr(main_module, "Fabric", lambda: mock_fabric)
diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py
index 03544b2d0..9019743a3 100644
--- a/tests/python/test_sdk_contract.py
+++ b/tests/python/test_sdk_contract.py
@@ -33,6 +33,7 @@
HarnessConfig,
McpConfig,
MetadataConfig,
+ RunOutput,
RunPlan,
RunRequest,
RunResult,
@@ -714,11 +715,87 @@ def test_run_result_exposes_detached_json_values():
metadata["labels"].append("mutated")
future["values"].append(2)
- assert result.output == {"plugins": ["observability/nemo_relay"]}
+ assert result.output.to_mapping() == {"plugins": ["observability/nemo_relay"]}
assert result.metadata == {"labels": ["sdk"]}
assert result.extra_fields["future"] == {"values": [1]}
+def test_run_output_exposes_response_and_preserves_extensions():
+ output = RunOutput.from_mapping(
+ {
+ "response": "hello",
+ "thread_id": "abc",
+ }
+ )
+
+ assert output.response == "hello"
+ assert output["response"] == "hello"
+ assert output["thread_id"] == "abc"
+ assert output.to_mapping() == {
+ "response": "hello",
+ "thread_id": "abc",
+ }
+
+
+def test_run_result_wraps_object_output_as_run_output():
+ result = RunResult.from_mapping(
+ _run_result(output={"response": "hello", "usage": {"tokens": 1}})
+ )
+
+ assert isinstance(result.output, RunOutput)
+ assert result.output.response == "hello"
+ assert result.output["response"] == "hello"
+ assert result.output["usage"] == {"tokens": 1}
+ assert result.to_mapping()["output"] == {
+ "response": "hello",
+ "usage": {"tokens": 1},
+ }
+
+
+def test_run_output_omits_missing_response_from_mapping():
+ output = RunOutput.from_mapping({"thread_id": "abc"})
+
+ assert output.response is None
+ assert "response" not in output.to_mapping()
+ assert output.to_mapping() == {"thread_id": "abc"}
+
+
+def test_run_output_preserves_explicit_null_response():
+ output = RunOutput.from_mapping({"response": None})
+
+ assert output.response is None
+ assert output.to_mapping() == {"response": None}
+
+
+def test_run_output_preserves_non_string_response_without_raising():
+ output = RunOutput.from_mapping({"response": {"text": "hello"}})
+
+ assert output.response == {"text": "hello"}
+ assert output["response"] == {"text": "hello"}
+ assert output.to_mapping() == {"response": {"text": "hello"}}
+
+
+def test_run_result_preserves_structured_response_from_core_valid_output():
+ result = RunResult.from_mapping(
+ _run_result(output={"response": {"text": "hello"}, "usage": {"tokens": 1}})
+ )
+
+ assert isinstance(result.output, RunOutput)
+ assert result.output.response == {"text": "hello"}
+ assert result.output["response"] == {"text": "hello"}
+ assert result.to_mapping()["output"] == {
+ "response": {"text": "hello"},
+ "usage": {"tokens": 1},
+ }
+
+
+def test_run_result_preserves_non_object_output():
+ result = RunResult.from_mapping(_run_result(output="hello"))
+
+ assert result.output == "hello"
+ assert result.to_mapping()["output"] == "hello"
+
+
def test_run_result_normalizes_core_telemetry_reference():
result = RunResult.from_mapping(
_run_result(