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(