From 9508f5ed42cac9b80786997324f98d3e78ff7dcf Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:49:47 -0500 Subject: [PATCH 1/3] feat(python): add RunOutput response contract Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- .../api/python-library-reference/index.md | 1 + .../nemo_fabric.types.md | 70 ++++++++++++++++++- examples/code_review_agent/__main__.py | 6 +- python/src/nemo_fabric/__init__.py | 2 + python/src/nemo_fabric/types.py | 38 +++++++++- tests/python/test_code_review_example.py | 6 +- tests/python/test_sdk_contract.py | 57 ++++++++++++++- 7 files changed, 170 insertions(+), 10 deletions(-) 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..ce6cdfa1b 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,74 @@ 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 the canonical user-facing response text when present. Additional keys are adapter-specific extension fields. + + +### method `__init__` + +```python +__init__(mapping: 'Mapping[str, Any]') → None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + +--- + +### property response + +Return the canonical response text, or ``None`` when absent. + + + --- @@ -873,7 +941,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..e869a20a5 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -1205,6 +1205,32 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: return data +class RunOutput(FabricMapping): + """Normalized adapter output. + + ``response`` is the canonical user-facing response text when present. + Additional keys are adapter-specific extension fields. + """ + + response: str | None + _fields = frozenset({"response"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + if "response" in data: + response = data["response"] + if response is not None and not isinstance(response, str): + raise FabricConfigError("run output response must be a string or null") + return data + + @property + def response(self) -> str | None: + """Return the canonical response text, or ``None`` when absent.""" + + value = self._data.get("response") + return None if value is None else value + + class RunResult(FabricMapping): """Normalized terminal result from one Fabric invocation. @@ -1222,7 +1248,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 +1266,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 +1291,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 +1325,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..f64ba95f8 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,65 @@ 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_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( From 40072912b77ee4181e2b3a9b0af39eab3f62957f Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:08:08 -0500 Subject: [PATCH 2/3] test(python): cover invalid RunOutput response Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- .../api/python-library-reference/nemo_fabric.types.md | 1 + tests/python/test_sdk_contract.py | 5 +++++ 2 files changed, 6 insertions(+) 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 ce6cdfa1b..bc5e42f55 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.types.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.types.md @@ -856,6 +856,7 @@ Return a detached, JSON-compatible mapping for serialization. ## class `RunOutput` + Normalized adapter output. ``response`` is the canonical user-facing response text when present. Additional keys are adapter-specific extension fields. diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index f64ba95f8..9b1ae2067 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -767,6 +767,11 @@ def test_run_output_preserves_explicit_null_response(): assert output.to_mapping() == {"response": None} +def test_run_output_rejects_non_string_response(): + with pytest.raises(FabricConfigError, match="run output response must be a string or null"): + RunOutput.from_mapping({"response": 123}) + + def test_run_result_preserves_non_object_output(): result = RunResult.from_mapping(_run_result(output="hello")) From a235ea06be06b7c2e7c7920b21c9d5b93571d3a8 Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:09:32 -0500 Subject: [PATCH 3/3] fix(python): preserve JSON RunOutput response values Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- .../nemo_fabric.types.md | 4 ++-- python/src/nemo_fabric/types.py | 22 ++++++------------ tests/python/test_sdk_contract.py | 23 ++++++++++++++++--- 3 files changed, 29 insertions(+), 20 deletions(-) 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 bc5e42f55..3b05fbfc4 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.types.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.types.md @@ -859,7 +859,7 @@ Return a detached, JSON-compatible mapping for serialization. Normalized adapter output. -``response`` is the canonical user-facing response text when present. Additional keys are adapter-specific extension fields. +``response`` is a known adapter response field whose value follows the core Fabric JSON contract. Other keys are adapter-specific extensions. ### method `__init__` @@ -883,7 +883,7 @@ Return an immutable view of preserved extension fields. ### property response -Return the canonical response text, or ``None`` when absent. +Return the raw ``response`` JSON value, or ``None`` when absent. diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py index e869a20a5..393fad5dd 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -1208,27 +1208,19 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: class RunOutput(FabricMapping): """Normalized adapter output. - ``response`` is the canonical user-facing response text when present. - Additional keys are adapter-specific extension fields. + ``response`` is a known adapter response field whose value follows the + core Fabric JSON contract. Other keys are adapter-specific extensions. """ - response: str | None + response: JSONValue | None _fields = frozenset({"response"}) - - @classmethod - def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: - if "response" in data: - response = data["response"] - if response is not None and not isinstance(response, str): - raise FabricConfigError("run output response must be a string or null") - return data + _json_fields = frozenset({"response"}) @property - def response(self) -> str | None: - """Return the canonical response text, or ``None`` when absent.""" + def response(self) -> JSONValue | None: + """Return the raw ``response`` JSON value, or ``None`` when absent.""" - value = self._data.get("response") - return None if value is None else value + return _snapshot_value(self._data.get("response"), json_value=True) class RunResult(FabricMapping): diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index 9b1ae2067..9019743a3 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -767,9 +767,26 @@ def test_run_output_preserves_explicit_null_response(): assert output.to_mapping() == {"response": None} -def test_run_output_rejects_non_string_response(): - with pytest.raises(FabricConfigError, match="run output response must be a string or null"): - RunOutput.from_mapping({"response": 123}) +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():