diff --git a/openhands-sdk/openhands/sdk/subagent/registry.py b/openhands-sdk/openhands/sdk/subagent/registry.py index 9e0f2eb0ff..b3bbcaa4de 100644 --- a/openhands-sdk/openhands/sdk/subagent/registry.py +++ b/openhands-sdk/openhands/sdk/subagent/registry.py @@ -245,10 +245,21 @@ def _factory(llm: "LLM") -> "Agent": ) tools.append(Tool(name=tool_name)) + # Build condenser if configured + condenser = None + if agent_def.condenser: + from openhands.sdk.context.condenser import LLMSummarizingCondenser + + condenser = LLMSummarizingCondenser( + llm=llm.model_copy(update={"usage_id": "condenser"}), + **agent_def.condenser, + ) + return Agent( llm=llm, tools=tools, agent_context=agent_context, + condenser=condenser, ) return _factory diff --git a/openhands-sdk/openhands/sdk/subagent/schema.py b/openhands-sdk/openhands/sdk/subagent/schema.py index c665ea3443..404bf0e434 100644 --- a/openhands-sdk/openhands/sdk/subagent/schema.py +++ b/openhands-sdk/openhands/sdk/subagent/schema.py @@ -19,6 +19,7 @@ "skills", "max_iteration_per_run", "profile_store_dir", + "condenser", } @@ -57,6 +58,35 @@ def _extract_skills(fm: dict[str, object]) -> list[str]: return skills +_CONDENSER_VALID_KEYS: Final[set[str]] = { + "max_size", + "max_tokens", + "keep_first", + "minimum_progress", + "hard_context_reset_max_retries", + "hard_context_reset_context_scaling", +} + + +def _extract_condenser(fm: dict[str, object]) -> dict[str, Any] | None: + """Extract condenser configuration from frontmatter.""" + condenser_raw = fm.get("condenser") + if condenser_raw is None: + return None + if not isinstance(condenser_raw, dict): + raise ValueError( + f"condenser must be a mapping of configuration parameters, " + f"got {type(condenser_raw)}" + ) + unknown_keys = set(condenser_raw.keys()) - _CONDENSER_VALID_KEYS + if unknown_keys: + raise ValueError( + f"Unknown condenser parameter(s): {sorted(unknown_keys)}. " + f"Valid parameters are: {sorted(_CONDENSER_VALID_KEYS)}" + ) + return condenser_raw + + def _extract_profile_store_dir(fm: dict[str, object]) -> str | None: """Extract profile store directory from frontmatter.""" profile_store_dir_raw = fm.get("profile_store_dir") @@ -121,6 +151,13 @@ class AgentDefinition(BaseModel): "It must be strictly positive, or None for default.", gt=0, ) + condenser: dict[str, Any] | None = Field( + default=None, + description="Condenser configuration for this agent. " + "Parameters are passed to LLMSummarizingCondenser (e.g., max_size, ...). " + "The condenser LLM is inherited from the agent's LLM.", + examples=[{"max_size": 100, "keep_first": 2}], + ) profile_store_dir: str | None = Field( default=None, description="Path to the directory where LLM profiles are stored. " @@ -139,6 +176,7 @@ def load(cls, agent_path: Path) -> AgentDefinition: - description: Description with optional tags for triggering - tools (optional): List of allowed tools - skills (optional): Comma-separated skill names or list of skill names + - condenser (optional): Condenser configuration (e.g., max_size, keep_first) - model (optional): Model profile to use (default: 'inherit') - color (optional): Display color - max_iterations_per_run: Max iteration per run @@ -165,6 +203,7 @@ def load(cls, agent_path: Path) -> AgentDefinition: tools: list[str] = _extract_tools(fm) skills: list[str] = _extract_skills(fm) max_iteration_per_run: int | None = _extract_max_iteration_per_run(fm) + condenser: dict[str, Any] | None = _extract_condenser(fm) profile_store_dir: str | None = _extract_profile_store_dir(fm) # Extract whenToUse examples from description @@ -181,6 +220,7 @@ def load(cls, agent_path: Path) -> AgentDefinition: tools=tools, skills=skills, max_iteration_per_run=max_iteration_per_run, + condenser=condenser, profile_store_dir=profile_store_dir, system_prompt=content, source=str(agent_path), diff --git a/tests/sdk/subagent/test_subagent_schema.py b/tests/sdk/subagent/test_subagent_schema.py index 69d5f8dac4..4307e22ef5 100644 --- a/tests/sdk/subagent/test_subagent_schema.py +++ b/tests/sdk/subagent/test_subagent_schema.py @@ -3,7 +3,11 @@ import pytest from pydantic import ValidationError -from openhands.sdk.subagent.schema import AgentDefinition, _extract_examples +from openhands.sdk.subagent.schema import ( + _CONDENSER_VALID_KEYS, + AgentDefinition, + _extract_examples, +) class TestAgentDefinition: @@ -319,6 +323,113 @@ def test_profile_store_dir_default_none(self): agent = AgentDefinition(name="test") assert agent.profile_store_dir is None + def test_condenser_default_none(self): + """Test that condenser defaults to None on direct construction.""" + agent = AgentDefinition(name="test") + assert agent.condenser is None + + def test_condenser_as_dict(self): + """Test creating AgentDefinition with condenser as dict.""" + config = {"max_size": 100, "keep_first": 2} + agent = AgentDefinition(name="condensed-agent", condenser=config) + assert agent.condenser == config + + def test_load_condenser_from_frontmatter(self, tmp_path: Path): + """Test loading condenser from YAML frontmatter.""" + agent_md = tmp_path / "condensed.md" + agent_md.write_text( + """--- +name: condensed-agent +condenser: + max_size: 100 + keep_first: 3 +--- + +You are an agent with condensation. +""" + ) + + agent = AgentDefinition.load(agent_md) + assert agent.condenser is not None + assert agent.condenser["max_size"] == 100 + assert agent.condenser["keep_first"] == 3 + + def test_load_condenser_not_in_metadata(self, tmp_path: Path): + """Test that condenser doesn't leak into metadata.""" + agent_md = tmp_path / "agent.md" + agent_md.write_text( + """--- +name: agent +condenser: + max_size: 50 +custom_field: value +--- + +Prompt. +""" + ) + agent = AgentDefinition.load(agent_md) + assert "condenser" not in agent.metadata + assert agent.metadata.get("custom_field") == "value" + + def test_load_condenser_non_dict_raises(self, tmp_path: Path): + """Test that non-dict condenser value raises ValueError.""" + agent_md = tmp_path / "bad-condenser.md" + agent_md.write_text( + """--- +name: bad-condenser +condenser: true +--- + +Prompt. +""" + ) + with pytest.raises(ValueError, match="must be a mapping"): + AgentDefinition.load(agent_md) + + def test_load_condenser_unknown_key_raises(self, tmp_path: Path): + """Test that unknown condenser parameters raise ValueError.""" + agent_md = tmp_path / "bad-condenser.md" + agent_md.write_text( + """--- +name: bad-condenser +condenser: + max_size: 100 + bogus_param: 42 +--- + +Prompt. +""" + ) + with pytest.raises(ValueError, match="Unknown condenser parameter"): + AgentDefinition.load(agent_md) + + def test_load_without_condenser(self, tmp_path: Path): + """Test that loading from file without condenser gives None.""" + agent_md = tmp_path / "agent.md" + agent_md.write_text( + """--- +name: no-condenser +--- + +Prompt. +""" + ) + agent = AgentDefinition.load(agent_md) + assert agent.condenser is None + + def test_condenser_valid_keys_match_llm_summarizing_condenser(self): + """Ensure _CONDENSER_VALID_KEYS stays in sync with LLMSummarizingCondenser.""" + from openhands.sdk.context.condenser import LLMSummarizingCondenser + + # Get all user-configurable fields (exclude 'llm' which is inherited) + condenser_fields = set(LLMSummarizingCondenser.model_fields.keys()) - {"llm"} + assert _CONDENSER_VALID_KEYS == condenser_fields, ( + f"_CONDENSER_VALID_KEYS is out of sync with LLMSummarizingCondenser. " + f"Missing: {condenser_fields - _CONDENSER_VALID_KEYS}, " + f"Extra: {_CONDENSER_VALID_KEYS - condenser_fields}" + ) + class TestExtractExamples: """Tests for _extract_examples function."""