Skip to content

Commit 63e9e77

Browse files
simonrosenbergSimon Rosenbergclaude
authored
[AgentProfile][agent-server] materialize endpoint (resolve dry-run) (#3783)
Co-authored-by: Simon Rosenberg <simon@openhands.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b0be8a5 commit 63e9e77

2 files changed

Lines changed: 191 additions & 3 deletions

File tree

openhands-agent-server/openhands/agent_server/agent_profiles_router.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
pointer-only — unlike the LLM ``/activate`` it must **not** write
77
``agent_settings`` (the creation-time-only contract).
88
9-
``POST /{id}/materialize`` is a fast-follow once the resolver (#3717) lands; it
10-
is deliberately not implemented here so this router ships independently.
9+
``POST /{name}/materialize`` performs a dry-run resolve of a profile's LLM and
10+
MCP references and returns :class:`~openhands.sdk.profiles.AgentProfileDiagnostics`
11+
(never raises on dangling refs — those appear in the body).
1112
"""
1213

1314
import copy
@@ -31,13 +32,16 @@
3132
PersistedSettings,
3233
get_settings_store,
3334
)
35+
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
3436
from openhands.sdk.logger import get_logger
3537
from openhands.sdk.profiles import (
3638
ACPAgentProfile,
39+
AgentProfileDiagnostics,
3740
AgentProfileStore,
3841
OpenHandsAgentProfile,
3942
ProfileLimitExceeded,
4043
ProfileVerificationSettings,
44+
resolve_agent_profile_dry_run,
4145
validate_agent_profile,
4246
)
4347
from openhands.sdk.profiles.agent_profile_store import PROFILE_NAME_PATTERN
@@ -553,3 +557,45 @@ def set_pointer(settings: PersistedSettings) -> PersistedSettings:
553557
id=profile_id,
554558
message=f"Agent profile '{profile_id}' activated",
555559
)
560+
561+
562+
@agent_profiles_router.post(
563+
"/{name}/materialize",
564+
response_model=AgentProfileDiagnostics,
565+
)
566+
async def materialize_agent_profile(
567+
request: Request, name: ProfileName
568+
) -> AgentProfileDiagnostics:
569+
"""Dry-run resolve a profile's LLM/MCP references; return a diagnostics report.
570+
571+
Dangling LLM/MCP references are reported in the body (valid=False) rather
572+
than raising — the only error status is 404 (unknown profile name).
573+
resolved_settings is redacted (api_key_set booleans; no raw secrets).
574+
"""
575+
cipher = get_cipher(request)
576+
577+
store = AgentProfileStore()
578+
try:
579+
with _store_errors():
580+
profile = store.load(name, cipher=cipher)
581+
except FileNotFoundError:
582+
raise HTTPException(
583+
status_code=status.HTTP_404_NOT_FOUND,
584+
detail=f"Agent profile '{name}' not found",
585+
)
586+
587+
# The store leaves skills[].mcp_tools encrypted on load; decrypt so the
588+
# resolver builds settings from plaintext (not ciphertext) values.
589+
profile = _decrypt_profile_mcp_tools(profile, cipher)
590+
591+
config = get_config(request)
592+
settings = get_settings_store(config).load() or PersistedSettings()
593+
mcp_config = settings.agent_settings.mcp_config
594+
595+
llm_store = LLMProfileStore()
596+
return resolve_agent_profile_dry_run(
597+
profile,
598+
llm_store=llm_store,
599+
mcp_config=mcp_config,
600+
cipher=cipher,
601+
)

tests/agent_server/test_agent_profiles_router.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
from openhands.agent_server.api import create_app
1818
from openhands.agent_server.config import Config
1919
from openhands.agent_server.persistence import reset_stores
20-
from openhands.sdk.profiles import AgentProfileStore, OpenHandsAgentProfile
20+
from openhands.sdk.llm import LLM
21+
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
22+
from openhands.sdk.profiles import (
23+
ACPAgentProfile,
24+
AgentProfileStore,
25+
OpenHandsAgentProfile,
26+
)
2127

2228

2329
@pytest.fixture
@@ -676,3 +682,139 @@ def test_save_at_limit_returns_409(client, store, monkeypatch):
676682
response = client.post("/api/agent-profiles/second", json={"llm_profile_ref": "y"})
677683
assert response.status_code == 409
678684
assert "limit" in response.json()["detail"].lower()
685+
686+
687+
# ── Materialize (resolve dry-run) ────────────────────────────────────────────
688+
689+
690+
@pytest.fixture
691+
def temp_llm_profiles_dir():
692+
with tempfile.TemporaryDirectory() as tmpdir:
693+
llm_dir = Path(tmpdir) / "llm-profiles"
694+
llm_dir.mkdir(parents=True, exist_ok=True)
695+
yield llm_dir
696+
697+
698+
@pytest.fixture
699+
def client_with_llm_store(
700+
temp_agent_profiles_dir, temp_settings_dir, temp_llm_profiles_dir, monkeypatch
701+
):
702+
"""Test client with isolated agent-profile/settings/llm-profile dirs, no cipher."""
703+
reset_stores()
704+
monkeypatch.setenv("OH_PERSISTENCE_DIR", str(temp_settings_dir))
705+
config = Config(static_files_path=None, session_api_keys=[], secret_key=None)
706+
app = create_app(config)
707+
with (
708+
patch(
709+
"openhands.agent_server.agent_profiles_router.AgentProfileStore",
710+
lambda: AgentProfileStore(base_dir=temp_agent_profiles_dir),
711+
),
712+
patch(
713+
"openhands.agent_server.agent_profiles_router.LLMProfileStore",
714+
lambda: LLMProfileStore(base_dir=temp_llm_profiles_dir),
715+
),
716+
):
717+
yield TestClient(app)
718+
reset_stores()
719+
720+
721+
@pytest.fixture
722+
def llm_store(temp_llm_profiles_dir):
723+
return LLMProfileStore(base_dir=temp_llm_profiles_dir)
724+
725+
726+
def test_materialize_valid_openhands_profile(client_with_llm_store, store, llm_store):
727+
"""Valid OpenHands profile with a resolved LLM returns 200 + valid=True."""
728+
llm_store.save("base-llm", LLM(model="gpt-4o"), include_secrets=True)
729+
store.save(OpenHandsAgentProfile(name="p", llm_profile_ref="base-llm"))
730+
731+
response = client_with_llm_store.post("/api/agent-profiles/p/materialize")
732+
733+
assert response.status_code == 200
734+
body = response.json()
735+
assert body["valid"] is True
736+
assert body["agent_kind"] == "openhands"
737+
assert body["llm_profile_ref"] == "base-llm"
738+
assert body["llm_profile_resolved"] is True
739+
assert body["errors"] == []
740+
assert body["resolved_settings"] is not None
741+
assert body["dangling_mcp_server_refs"] == []
742+
743+
744+
def test_materialize_valid_acp_profile(client_with_llm_store, store):
745+
"""Valid ACP profile returns 200 + valid=True (no LLM ref needed)."""
746+
store.save(ACPAgentProfile(name="acp-p", acp_server="codex", acp_model="gpt-5.5"))
747+
748+
response = client_with_llm_store.post("/api/agent-profiles/acp-p/materialize")
749+
750+
assert response.status_code == 200
751+
body = response.json()
752+
assert body["valid"] is True
753+
assert body["agent_kind"] == "acp"
754+
assert body["errors"] == []
755+
assert body["resolved_settings"] is not None
756+
757+
758+
def test_materialize_dangling_llm_ref(client_with_llm_store, store):
759+
"""A profile referencing a missing LLM profile returns 200, valid=False."""
760+
store.save(OpenHandsAgentProfile(name="p", llm_profile_ref="nonexistent"))
761+
762+
response = client_with_llm_store.post("/api/agent-profiles/p/materialize")
763+
764+
assert response.status_code == 200
765+
body = response.json()
766+
assert body["valid"] is False
767+
assert body["llm_profile_ref"] == "nonexistent"
768+
assert body["llm_profile_resolved"] is False
769+
assert body["resolved_settings"] is None
770+
assert any("nonexistent" in e for e in body["errors"])
771+
772+
773+
def test_materialize_dangling_mcp_ref(client_with_llm_store, store, llm_store):
774+
"""A profile with a missing MCP server ref returns 200, valid=False."""
775+
llm_store.save("base-llm", LLM(model="gpt-4o"), include_secrets=True)
776+
store.save(
777+
OpenHandsAgentProfile(
778+
name="p",
779+
llm_profile_ref="base-llm",
780+
mcp_server_refs=["missing-server"],
781+
)
782+
)
783+
784+
response = client_with_llm_store.post("/api/agent-profiles/p/materialize")
785+
786+
assert response.status_code == 200
787+
body = response.json()
788+
assert body["valid"] is False
789+
assert body["dangling_mcp_server_refs"] == ["missing-server"]
790+
assert body["resolved_settings"] is None
791+
792+
793+
def test_materialize_unknown_name_returns_404(client_with_llm_store):
794+
"""Materializing an unknown profile name returns 404."""
795+
response = client_with_llm_store.post("/api/agent-profiles/ghost/materialize")
796+
797+
assert response.status_code == 404
798+
assert "not found" in response.json()["detail"].lower()
799+
800+
801+
def test_materialize_no_raw_secrets_in_resolved_settings(
802+
client_with_llm_store, store, llm_store
803+
):
804+
"""resolved_settings must not contain raw API key values."""
805+
raw_key = "sk-secret-key-should-not-appear"
806+
from pydantic import SecretStr
807+
808+
llm_store.save(
809+
"base-llm",
810+
LLM(model="gpt-4o", api_key=SecretStr(raw_key)),
811+
include_secrets=True,
812+
)
813+
store.save(OpenHandsAgentProfile(name="p", llm_profile_ref="base-llm"))
814+
815+
response = client_with_llm_store.post("/api/agent-profiles/p/materialize")
816+
817+
assert response.status_code == 200
818+
body = response.json()
819+
assert body["valid"] is True
820+
assert raw_key not in response.text

0 commit comments

Comments
 (0)