From a4e422fb3576fe6210904e675cce6a3f3d688d97 Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Wed, 7 Oct 2026 19:05:04 -0700 Subject: [PATCH 1/7] fix: enable the OpenHands SDK default condenser The OpenHands SDK leaves Agent.condenser unset, and the adapter built its agent without one. Long runs therefore resent the full event history on every model request, and a context-window error from the model failed the invocation instead of triggering condensation. Configure the SDK default_condenser, the LLMSummarizingCondenser sizing (80 events, keep the first 4) that the OpenHands default agent uses. Pass the agent LLM object itself rather than a copy with a separate usage ID, so summarization calls accrue to the metrics the adapter reports as invocation usage. This changes default runtime behavior for OpenHands runs. The adapter descriptor declares no settings, so the condenser is not configurable. Signed-off-by: mnajafian-nv --- .../nemo_fabric_adapters/openhands/adapter.py | 6 ++++++ tests/adapters/test_openhands.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py b/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py index 2dd35a97b..cf3b6df91 100644 --- a/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py +++ b/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py @@ -47,6 +47,7 @@ class OpenHandsApi(NamedTuple): TerminalTool: Any FileEditorTool: Any get_agent_final_response: Any + default_condenser: Any def _load_openhands_api() -> OpenHandsApi: @@ -68,6 +69,7 @@ def _load_openhands_api() -> OpenHandsApi: from openhands.sdk import ConversationExecutionStatus from openhands.sdk import LLM from openhands.sdk import Tool + from openhands.sdk.context.condenser import default_condenser from openhands.sdk.conversation import get_agent_final_response from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.mcp import MCPServer @@ -88,6 +90,7 @@ def _load_openhands_api() -> OpenHandsApi: TerminalTool=TerminalTool, FileEditorTool=FileEditorTool, get_agent_final_response=get_agent_final_response, + default_condenser=default_condenser, ) @@ -342,6 +345,9 @@ async def start(self, payload: dict[str, Any]) -> None: "tools": _tools(api, config), "mcp_config": _mcp_servers(api, config), "agent_context": agent_context, + # Share the agent LLM instead of a copy so summarization calls + # accrue to the metrics that invoke() reports as usage. + "condenser": api.default_condenser(llm), } if instruction is not None and instruction.mode == "replace": agent_kwargs["system_prompt"] = instruction.content diff --git a/tests/adapters/test_openhands.py b/tests/adapters/test_openhands.py index 48c003e63..9f2501a5e 100644 --- a/tests/adapters/test_openhands.py +++ b/tests/adapters/test_openhands.py @@ -124,6 +124,7 @@ async def arun(): conversation.arun = AsyncMock(side_effect=arun) conversation.close = MagicMock() conversation_factory = MagicMock(return_value=conversation) + default_condenser = MagicMock() api = adapter.OpenHandsApi( Agent=agent_factory, @@ -138,6 +139,7 @@ async def arun(): TerminalTool=SimpleNamespace(name="TerminalTool"), FileEditorTool=SimpleNamespace(name="FileEditorTool"), get_agent_final_response=lambda events: events[-1] if events else "", + default_condenser=default_condenser, ) monkeypatch.setattr(adapter, "_load_openhands_api", lambda: api) calls.update( @@ -146,6 +148,7 @@ async def arun(): "agent_factory": agent_factory, "conversation": conversation, "conversation_factory": conversation_factory, + "default_condenser": default_condenser, "llm_factory": llm_factory, "mcp_server_factory": mcp_server_factory, "skill_type": skill_type, @@ -281,6 +284,21 @@ async def test_start_maps_normalized_configuration( assert profile_store.is_dir() +async def test_start_condenses_history_with_the_agent_llm( + openhands_payload: dict, + mock_openhands: dict, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("TEST_OPENHANDS_API_KEY", "test-key") + await adapter.OpenHandsRuntime().start(_start_payload(openhands_payload)) + + llm = mock_openhands["llm_factory"].return_value + default_condenser = mock_openhands["default_condenser"] + default_condenser.assert_called_once_with(llm) + agent_kwargs = mock_openhands["agent_factory"].call_args.kwargs + assert agent_kwargs["condenser"] is default_condenser.return_value + + async def test_replace_instruction_uses_exact_system_prompt( openhands_payload: dict, mock_openhands: dict, From b9922180c71374832c2e678d3be808e9a9847a9c Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Wed, 7 Oct 2026 19:05:05 -0700 Subject: [PATCH 2/7] docs: describe OpenHands history condensation The OpenHands adapter now summarizes older conversation history. Document when condensation triggers, that summarization calls count toward invocation usage, and that NeMo Fabric does not expose settings to disable or tune it. Signed-off-by: mnajafian-nv --- adapters/python/openhands/README.md | 2 ++ docs/integrations/harness/openhands.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/adapters/python/openhands/README.md b/adapters/python/openhands/README.md index f4c9cedd8..22ecd895c 100644 --- a/adapters/python/openhands/README.md +++ b/adapters/python/openhands/README.md @@ -74,6 +74,8 @@ directory. Each skill path must be a directory containing `SKILL.md`. The adapter disables ambient OpenHands user, public, project, and memory loading; only explicitly configured NeMo Fabric skills are loaded. +The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to disable or tune the condenser. + The initial adapter does not support Relay telemetry, native streaming, interactive confirmation, MCP authentication, or per-server MCP tool filters. diff --git a/docs/integrations/harness/openhands.mdx b/docs/integrations/harness/openhands.mdx index 14ca012bd..37e4cffb3 100644 --- a/docs/integrations/harness/openhands.mdx +++ b/docs/integrations/harness/openhands.mdx @@ -68,6 +68,8 @@ Skill paths resolve from the configuration base directory and must contain a `SKILL.md` file. The adapter disables ambient OpenHands user, public, project, and memory loading, so only explicitly configured NeMo Fabric skills load. +The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to disable or tune the condenser. + The initial integration does not support Relay telemetry, native streaming, interactive confirmation, MCP authentication, or per-server MCP tool filters. From 3c633555b2092e22ece67b6d5f1eadab4cb693c4 Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Wed, 7 Oct 2026 21:50:49 -0700 Subject: [PATCH 3/7] feat: add an OpenHands setting to turn off history condensation Condensing history by default adds summarization model calls and can change results for an unchanged configuration. Give users who need the previous behavior, for example to compare against earlier runs, an explicit opt-out through the adapter's existing settings schema rather than a new configuration surface. harness.settings.condenser accepts default, the OpenHands SDK default condenser, or none, which builds the agent without a condenser as before. Planning rejects any other value. Planning does not apply schema defaults, so the adapter treats a missing value as default. Regenerate the adapter catalog for the descriptor change and document the setting in the adapter README, the integration guide, and the adapter configuration matrix. Signed-off-by: mnajafian-nv --- adapters/README.md | 2 +- adapters/python/openhands/README.md | 13 +++++++++- .../openhands/openhands.fabric-adapter.json | 9 ++++++- .../nemo_fabric_adapters/openhands/adapter.py | 12 +++++++-- docs/integrations/harness/openhands.mdx | 13 +++++++++- .../nemo_fabric_adapter_catalog/catalog.json | 14 ++++++++-- tests/adapters/test_openhands.py | 26 +++++++++++++++++++ .../test_harness_settings_validation.py | 11 ++++++++ 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/adapters/README.md b/adapters/README.md index 13658a3da..d9d2507f4 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -132,7 +132,7 @@ and additive extension maps because their support does not vary by adapter: | `schema_version` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | | `metadata.name`, `.description` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | | `harness.adapter_id`, `.resolution` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `harness.settings` | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed `timeout` schema | Closed schema | Closed command, port-range, and timeout schema | Closed empty schema | Closed schema with no properties | No settings declared | Closed local-extension and Relay-extension schema | Closed `permission_mode` schema | Closed schema | Closed `base_url`, `api_type`, transport-timeout, and `relay_streaming` schema | +| `harness.settings` | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed `timeout` schema | Closed schema | Closed command, port-range, and timeout schema | Closed `condenser` schema | Closed schema with no properties | No settings declared | Closed local-extension and Relay-extension schema | Closed `permission_mode` schema | Closed schema | Closed `base_url`, `api_type`, transport-timeout, and `relay_streaming` schema | | `workflow.target_id`, `.settings` | No | No | No | No | No | No | No | No | No | No | No | No | No | No | | `models..provider` | `anthropic` uses native auth; custom names require an Anthropic Messages-compatible `base_url` and `api_key_env` | `openai` uses native auth; custom names require a Responses-compatible `base_url` and `api_key_env` | Dynamic LangChain provider; custom OpenAI-compatible endpoints require `base_url` and `api_key_env` | Dynamic Hermes provider | Configured provider | Configured provider | OpenClaw provider; custom providers require an OpenAI Chat Completions-compatible `base_url` | Prefixes unqualified model names for LiteLLM | Cline provider | OpenCode provider | Pi catalog provider | `openai` | Kilo provider | Configured provider | | `models..model` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes; passed to OpenCode | Yes; must exist in the Pi catalog | Yes | Yes | Yes | diff --git a/adapters/python/openhands/README.md b/adapters/python/openhands/README.md index 22ecd895c..0be22d540 100644 --- a/adapters/python/openhands/README.md +++ b/adapters/python/openhands/README.md @@ -74,7 +74,18 @@ directory. Each skill path must be a directory containing `SKILL.md`. The adapter disables ambient OpenHands user, public, project, and memory loading; only explicitly configured NeMo Fabric skills are loaded. -The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to disable or tune the condenser. +The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to tune the condenser. + +To keep the full conversation history and make no summarization calls, set the `condenser` harness setting to `none`. The default value, `default`, enables the condenser: + +```python +from nemo_fabric import HarnessConfig + +harness = HarnessConfig( + adapter_id="nvidia.fabric.openhands", + settings={"condenser": "none"}, +) +``` The initial adapter does not support Relay telemetry, native streaming, interactive confirmation, MCP authentication, or per-server MCP tool filters. diff --git a/adapters/python/openhands/openhands.fabric-adapter.json b/adapters/python/openhands/openhands.fabric-adapter.json index 3b3b69a18..1c3af5253 100644 --- a/adapters/python/openhands/openhands.fabric-adapter.json +++ b/adapters/python/openhands/openhands.fabric-adapter.json @@ -28,7 +28,14 @@ "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "properties": {}, + "properties": { + "condenser": { + "type": "string", + "enum": ["default", "none"], + "default": "default", + "description": "OpenHands conversation history condenser. Use none to keep the full history." + } + }, "additionalProperties": false }, "requirements": {}, diff --git a/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py b/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py index cf3b6df91..800cc830c 100644 --- a/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py +++ b/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py @@ -131,6 +131,13 @@ def _llm(api: OpenHandsApi, model: contract.AgentModelConfig) -> Any: return api.LLM(**kwargs) +def _condenser_enabled(config: contract.AgentConfig) -> bool: + """Return whether to condense history; planning validates the setting value.""" + + settings = config.harness.settings if config.harness is not None else {} + return settings.get("condenser", "default") != "none" + + def _workspace(context: contract.RuntimeContext, base_dir: str) -> Path: path = Path(context.environment.workspace or base_dir) if not path.is_absolute(): @@ -345,10 +352,11 @@ async def start(self, payload: dict[str, Any]) -> None: "tools": _tools(api, config), "mcp_config": _mcp_servers(api, config), "agent_context": agent_context, + } + if _condenser_enabled(config): # Share the agent LLM instead of a copy so summarization calls # accrue to the metrics that invoke() reports as usage. - "condenser": api.default_condenser(llm), - } + agent_kwargs["condenser"] = api.default_condenser(llm) if instruction is not None and instruction.mode == "replace": agent_kwargs["system_prompt"] = instruction.content agent = api.Agent(**agent_kwargs) diff --git a/docs/integrations/harness/openhands.mdx b/docs/integrations/harness/openhands.mdx index 37e4cffb3..47954f397 100644 --- a/docs/integrations/harness/openhands.mdx +++ b/docs/integrations/harness/openhands.mdx @@ -68,7 +68,18 @@ Skill paths resolve from the configuration base directory and must contain a `SKILL.md` file. The adapter disables ambient OpenHands user, public, project, and memory loading, so only explicitly configured NeMo Fabric skills load. -The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to disable or tune the condenser. +The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to tune the condenser. + +To keep the full conversation history and make no summarization calls, set the `condenser` harness setting to `none`. The default value, `default`, enables the condenser: + +```python +from nemo_fabric import HarnessConfig + +harness = HarnessConfig( + adapter_id="nvidia.fabric.openhands", + settings={"condenser": "none"}, +) +``` The initial integration does not support Relay telemetry, native streaming, interactive confirmation, MCP authentication, or per-server MCP tool filters. diff --git a/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json b/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json index bc88dd8f0..b0a386df0 100644 --- a/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json +++ b/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json @@ -1664,7 +1664,17 @@ "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "properties": {}, + "properties": { + "condenser": { + "default": "default", + "description": "OpenHands conversation history condenser. Use none to keep the full history.", + "enum": [ + "default", + "none" + ], + "type": "string" + } + }, "type": "object" } }, @@ -1673,7 +1683,7 @@ "name": "nemo-fabric-adapters-openhands", "version": "0.5.0" }, - "sha256": "151335fc5bbc1e1689112b0629aa0e517e4de9b4a5a3ba52cfa4a8ec7001ae7d", + "sha256": "062ef09ba5e82dfdf0b4374632a8b39b7ff975160b0c6f62e68d2f4ac6f0b59e", "source": "adapters/python/openhands/openhands.fabric-adapter.json" }, "nvidia.fabric.pi": { diff --git a/tests/adapters/test_openhands.py b/tests/adapters/test_openhands.py index 9f2501a5e..6dbf70ad7 100644 --- a/tests/adapters/test_openhands.py +++ b/tests/adapters/test_openhands.py @@ -202,6 +202,18 @@ def test_descriptor_declares_only_initial_openhands_surface(): "updates": False, "cancellation": False, } + assert descriptor["settings_schema"]["properties"] == { + "condenser": { + "type": "string", + "enum": ["default", "none"], + "default": "default", + "description": ( + "OpenHands conversation history condenser. Use none to keep " + "the full history." + ), + } + } + assert descriptor["settings_schema"]["additionalProperties"] is False assert "telemetry" not in descriptor @@ -299,6 +311,20 @@ async def test_start_condenses_history_with_the_agent_llm( assert agent_kwargs["condenser"] is default_condenser.return_value +async def test_start_without_condenser_when_disabled( + openhands_payload: dict, + mock_openhands: dict, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("TEST_OPENHANDS_API_KEY", "test-key") + openhands_payload["config"]["harness"] = {"settings": {"condenser": "none"}} + await adapter.OpenHandsRuntime().start(_start_payload(openhands_payload)) + + mock_openhands["default_condenser"].assert_not_called() + agent_kwargs = mock_openhands["agent_factory"].call_args.kwargs + assert agent_kwargs.get("condenser") is None + + async def test_replace_instruction_uses_exact_system_prompt( openhands_payload: dict, mock_openhands: dict, diff --git a/tests/python/test_harness_settings_validation.py b/tests/python/test_harness_settings_validation.py index bc8aa7cce..1be4ca7ec 100644 --- a/tests/python/test_harness_settings_validation.py +++ b/tests/python/test_harness_settings_validation.py @@ -160,6 +160,11 @@ }, id="openclaw", ), + pytest.param( + "nvidia.fabric.openhands", + {"condenser": "none"}, + id="openhands", + ), pytest.param( "nvidia.fabric.remote-agent", { @@ -489,6 +494,12 @@ def test_openclaw_rejects_relay(tmp_path: Path): "harness.settings.terminal_timeout", id="hermes-terminal-timeout", ), + pytest.param( + "nvidia.fabric.openhands", + {"condenser": "llm_summarizing"}, + "harness.settings.condenser", + id="openhands-condenser", + ), ], ) def test_invalid_settings_report_resolved_descriptor_and_path( From b68b30fd8d642d6b38ec6ba14e677249fc3d7caf Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Thu, 8 Oct 2026 00:27:18 -0700 Subject: [PATCH 4/7] fix: reject unknown OpenHands condenser setting values The adapter enabled the condenser for every condenser value other than "none". Planning rejects other values, but a host that starts the adapter without planning could pass "off" or "None" and silently get summarization calls, which cost model usage. Accept only "default" and "none" in the adapter and raise openhands_condenser_invalid for anything else, matching how an existing adapter re-validates its enum settings. Add a test for unknown string and null values. Signed-off-by: mnajafian-nv --- .../nemo_fabric_adapters/openhands/adapter.py | 14 +++++++++++-- tests/adapters/test_openhands.py | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py b/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py index 800cc830c..7ec53266a 100644 --- a/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py +++ b/adapters/python/openhands/src/nemo_fabric_adapters/openhands/adapter.py @@ -132,10 +132,20 @@ def _llm(api: OpenHandsApi, model: contract.AgentModelConfig) -> Any: def _condenser_enabled(config: contract.AgentConfig) -> bool: - """Return whether to condense history; planning validates the setting value.""" + """Return whether to condense history, rejecting values the descriptor forbids. + + Planning already validates the setting, but a host that skips planning must + not turn an unknown value into model-billed summarization calls. + """ settings = config.harness.settings if config.harness is not None else {} - return settings.get("condenser", "default") != "none" + condenser = settings.get("condenser", "default") + if condenser not in ("default", "none"): + raise lifecycle.LifecycleError( + "openhands_condenser_invalid", + "The OpenHands condenser setting must be 'default' or 'none'.", + ) + return condenser == "default" def _workspace(context: contract.RuntimeContext, base_dir: str) -> Path: diff --git a/tests/adapters/test_openhands.py b/tests/adapters/test_openhands.py index 6dbf70ad7..dea8718a1 100644 --- a/tests/adapters/test_openhands.py +++ b/tests/adapters/test_openhands.py @@ -325,6 +325,26 @@ async def test_start_without_condenser_when_disabled( assert agent_kwargs.get("condenser") is None +@pytest.mark.parametrize( + "condenser", ["off", "None", None], ids=["off", "string-None", "null"] +) +async def test_start_rejects_unknown_condenser_setting( + openhands_payload: dict, + mock_openhands: dict, + monkeypatch: pytest.MonkeyPatch, + condenser: str | None, +): + monkeypatch.setenv("TEST_OPENHANDS_API_KEY", "test-key") + openhands_payload["config"]["harness"] = {"settings": {"condenser": condenser}} + + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await adapter.OpenHandsRuntime().start(_start_payload(openhands_payload)) + + assert caught.value.code == "openhands_condenser_invalid" + mock_openhands["default_condenser"].assert_not_called() + mock_openhands["agent_factory"].assert_not_called() + + async def test_replace_instruction_uses_exact_system_prompt( openhands_payload: dict, mock_openhands: dict, From 66b72c53698afde23cd9eccd8bc3ad6e7ebf650d Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Thu, 8 Oct 2026 00:27:18 -0700 Subject: [PATCH 5/7] docs: note that OpenHands condensation uses max_turns iterations Each condensation returns from an OpenHands agent step, and the SDK counts every step toward max_iteration_per_run, which the adapter sets from runtime.max_turns. A run with a tight max_turns that used to finish can now stop at the iteration limit, so say so. Also state that the trigger is the history sent to the model rather than the total event count, replace the sentence that contradicted the condenser setting with "does not expose condenser tuning parameters", mention that other setting values are rejected, and wrap the new paragraphs like the surrounding text. Signed-off-by: mnajafian-nv --- adapters/python/openhands/README.md | 17 ++++++++++++++--- docs/integrations/harness/openhands.mdx | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/adapters/python/openhands/README.md b/adapters/python/openhands/README.md index 0be22d540..4c47fbc5d 100644 --- a/adapters/python/openhands/README.md +++ b/adapters/python/openhands/README.md @@ -74,9 +74,20 @@ directory. Each skill path must be a directory containing `SKILL.md`. The adapter disables ambient OpenHands user, public, project, and memory loading; only explicitly configured NeMo Fabric skills are loaded. -The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to tune the condenser. - -To keep the full conversation history and make no summarization calls, set the `condenser` harness setting to `none`. The default value, `default`, enables the condenser: +The adapter configures the OpenHands SDK standard summarizing condenser, +`default_condenser`, with the agent model. When the history sent to the model +exceeds 80 events, or exceeds the model input token limit when OpenHands knows +it, OpenHands replaces older events with a model-generated summary and keeps +the first 4 events. When the model rejects a request for exceeding its context +window, OpenHands condenses the history and continues instead of failing the +invocation. Summarization calls count toward invocation usage, and each +condensation step counts toward `runtime.max_turns`. NeMo Fabric does not +expose condenser tuning parameters. + +To keep the full conversation history and make no summarization calls, set the +`condenser` harness setting to `none`. The default value, `default`, enables +the condenser, and any other value is rejected. The following example turns +the condenser off: ```python from nemo_fabric import HarnessConfig diff --git a/docs/integrations/harness/openhands.mdx b/docs/integrations/harness/openhands.mdx index 47954f397..e1f7e5cee 100644 --- a/docs/integrations/harness/openhands.mdx +++ b/docs/integrations/harness/openhands.mdx @@ -68,9 +68,20 @@ Skill paths resolve from the configuration base directory and must contain a `SKILL.md` file. The adapter disables ambient OpenHands user, public, project, and memory loading, so only explicitly configured NeMo Fabric skills load. -The adapter configures the OpenHands SDK standard summarizing condenser, `default_condenser`, with the agent model. When the conversation exceeds 80 events, or exceeds the model input token limit when OpenHands knows it, OpenHands replaces older events with a model-generated summary and keeps the first 4 events. When the model rejects a request for exceeding its context window, OpenHands condenses the history and continues instead of failing the invocation. Summarization calls count toward invocation usage. NeMo Fabric does not expose settings to tune the condenser. - -To keep the full conversation history and make no summarization calls, set the `condenser` harness setting to `none`. The default value, `default`, enables the condenser: +The adapter configures the OpenHands SDK standard summarizing condenser, +`default_condenser`, with the agent model. When the history sent to the model +exceeds 80 events, or exceeds the model input token limit when OpenHands knows +it, OpenHands replaces older events with a model-generated summary and keeps +the first 4 events. When the model rejects a request for exceeding its context +window, OpenHands condenses the history and continues instead of failing the +invocation. Summarization calls count toward invocation usage, and each +condensation step counts toward `runtime.max_turns`. NeMo Fabric does not +expose condenser tuning parameters. + +To keep the full conversation history and make no summarization calls, set the +`condenser` harness setting to `none`. The default value, `default`, enables +the condenser, and any other value is rejected. The following example turns +the condenser off: ```python from nemo_fabric import HarnessConfig From 0576cada33991ff073bb4bf7962fd1d5905d1f15 Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Fri, 9 Oct 2026 13:49:45 -0700 Subject: [PATCH 6/7] test(openhands): cover condensation through the real SDK The condenser tests only checked how start() builds the agent, so nothing showed that a run condenses its history, keeps going, and reports the summarization call in invocation usage. Drive the adapter with OpenHands SDK 1.50.0 and a stubbed LiteLLM transport: once past the 80-event limit, and once after a context window error. Each case asserts one Condensation event, a successful finish, and usage that includes the summarization call. The test skips when the OpenHands SDK is not installed. Signed-off-by: mnajafian-nv --- tests/adapters/test_openhands.py | 98 ++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/adapters/test_openhands.py b/tests/adapters/test_openhands.py index dea8718a1..527b8f3cb 100644 --- a/tests/adapters/test_openhands.py +++ b/tests/adapters/test_openhands.py @@ -345,6 +345,104 @@ async def test_start_rejects_unknown_condenser_setting( mock_openhands["agent_factory"].assert_not_called() +# Event limit: the system prompt, the user message, and 40 think action and +# observation pairs exceed the 80-event limit, so the condenser runs before the +# 41st agent call. Context window error: the rejected 11th call triggers the +# condensation, and the 12th call finishes. +@pytest.mark.parametrize( + ("context_error_at_call", "expected_agent_calls"), + [(None, 41), (11, 12)], + ids=["event-limit", "context-window-error"], +) +async def test_invoke_condenses_history_and_counts_summary_usage( + openhands_payload: dict, + monkeypatch: pytest.MonkeyPatch, + context_error_at_call: int | None, + expected_agent_calls: int, +): + pytest.importorskip("openhands.sdk") + pytest.importorskip("openhands.tools") + from litellm.exceptions import ContextWindowExceededError + from litellm.types.utils import ModelResponse + from openhands.sdk.event import Condensation + from openhands.sdk.llm import llm as openhands_llm + + calls = {"agent": 0, "summary": 0} + + def respond(**kwargs) -> ModelResponse: + # Agent steps send tools; the condenser's summary request does not. + if kwargs.get("tools"): + calls["agent"] += 1 + if calls["agent"] == context_error_at_call: + raise ContextWindowExceededError( + message="context length exceeded", + model="test-model", + llm_provider="openai", + ) + tool, arguments = ( + # Distinct thoughts keep the SDK stuck detector quiet. + ("think", {"thought": f"Step {calls['agent']}."}) + if calls["summary"] == 0 + else ("finish", {"message": "done"}) + ) + message = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": f"call-{calls['agent']}", + "type": "function", + "function": {"name": tool, "arguments": json.dumps(arguments)}, + } + ], + } + prompt_tokens, completion_tokens = 10, 1 + else: + calls["summary"] += 1 + message = {"role": "assistant", "content": "Summary of earlier steps."} + prompt_tokens, completion_tokens = 1000, 100 + return ModelResponse( + model="test-model", + choices=[{"index": 0, "finish_reason": "stop", "message": message}], + usage={ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + ) + + monkeypatch.setattr( + openhands_llm, "litellm_completion", MagicMock(side_effect=respond) + ) + monkeypatch.setattr( + openhands_llm, "litellm_acompletion", AsyncMock(side_effect=respond) + ) + monkeypatch.setenv("TEST_OPENHANDS_API_KEY", "test-key") + config = openhands_payload["config"] + config["tools"] = {"enabled": [], "blocked": []} + config["runtime"] = {"max_turns": 100} + del config["skills"], config["mcp"] + runtime = adapter.OpenHandsRuntime() + await runtime.start(_start_payload(openhands_payload)) + try: + result = await runtime.invoke( + *_invocation(openhands_payload, "Think until the history condenses.") + ) + events = list(runtime._conversation.state.events) + finally: + await runtime.stop() + + assert result.status is AgentRunStatus.SUCCEEDED + assert result.output == {"response": "done"} + assert calls["summary"] == 1 + assert calls["agent"] == expected_agent_calls + assert sum(isinstance(event, Condensation) for event in events) == 1 + # A rejected request reports no usage; every other call counts once. + answered_calls = calls["agent"] - (context_error_at_call is not None) + assert result.usage.input_tokens == 10 * answered_calls + 1000 + assert result.usage.output_tokens == answered_calls + 100 + + async def test_replace_instruction_uses_exact_system_prompt( openhands_payload: dict, mock_openhands: dict, From da552dce16657f0cc6b5639c7a15105f61a2f6c9 Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Sat, 10 Oct 2026 00:54:27 -0700 Subject: [PATCH 7/7] ci: exercise OpenHands condensation with the SDK Signed-off-by: mnajafian-nv --- .github/workflows/ci_python.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci_python.yml b/.github/workflows/ci_python.yml index 3e7bdf1c6..8896573e3 100644 --- a/.github/workflows/ci_python.yml +++ b/.github/workflows/ci_python.yml @@ -105,6 +105,12 @@ jobs: set -euo pipefail just --set no_uv true test-python + - name: Test OpenHands condensation with the SDK + if: matrix.platform == 'linux-amd64' && matrix.python-version == '3.12' + run: | + uv pip install "openhands-sdk==1.50.0" "openhands-tools==1.50.0" + uv run --no-sync pytest tests/adapters/test_openhands.py -k test_invoke_condenses_history_and_counts_summary_usage -q + hermes: name: Hermes adapter (upstream Relay 0.9, Python 3.14) if: ${{ (inputs.ref_type || github.ref_type) != 'tag' && !inputs.skip_tests }}