Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/ci_python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

relay-0-9:
name: Relay 0.9 (Python 3.12, linux-amd64)
# The locks resolve Relay 0.10; this lane keeps the lowest supported minor
Expand Down
2 changes: 1 addition & 1 deletion adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<role>.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.<role>.model` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes; passed to OpenCode | Yes; must exist in the Pi catalog | Yes | Yes | Yes |
Expand Down
24 changes: 24 additions & 0 deletions adapters/python/openhands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ 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 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

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.

Expand Down
9 changes: 8 additions & 1 deletion adapters/python/openhands/openhands.fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class OpenHandsApi(NamedTuple):
TerminalTool: Any
FileEditorTool: Any
get_agent_final_response: Any
default_condenser: Any


def _load_openhands_api() -> OpenHandsApi:
Expand All @@ -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
Expand All @@ -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,
)


Expand Down Expand Up @@ -128,6 +131,23 @@ def _llm(api: OpenHandsApi, model: contract.AgentModelConfig) -> Any:
return api.LLM(**kwargs)


def _condenser_enabled(config: contract.AgentConfig) -> bool:
"""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 {}
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:
path = Path(context.environment.workspace or base_dir)
if not path.is_absolute():
Expand Down Expand Up @@ -343,6 +363,10 @@ async def start(self, payload: dict[str, Any]) -> None:
"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.
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)
Expand Down
24 changes: 24 additions & 0 deletions docs/integrations/harness/openhands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ 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 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

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
Expand All @@ -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": {
Expand Down
162 changes: 162 additions & 0 deletions tests/adapters/test_openhands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -199,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


Expand Down Expand Up @@ -281,6 +296,153 @@ 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_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


@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()


# 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,
Expand Down
Loading
Loading