Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
pointer-only — unlike the LLM ``/activate`` it must **not** write
``agent_settings`` (the creation-time-only contract).

``POST /{name}/materialize`` performs a dry-run resolve of a profile's LLM and
MCP references and returns :class:`~openhands.sdk.profiles.AgentProfileDiagnostics`
(never raises on dangling refs — those appear in the body).
``POST /{name}/materialize`` performs a dry-run resolve of a stored profile, or
of a draft body, and returns
:class:`~openhands.sdk.profiles.AgentProfileDiagnostics` (never raises on
dangling refs — those appear in the body).
"""

import asyncio
Expand All @@ -28,15 +29,16 @@
get_llm_profile_store,
get_settings_store,
)
from openhands.agent_server.profile_launch import gather_profile_launch_inputs
from openhands.agent_server.profiles_router import MAX_PROFILES, _has_api_key
from openhands.agent_server.skills_service import discover_profile_skills
from openhands.sdk.llm import LLM
from openhands.sdk.llm.llm_profile_store import (
ProfileLimitExceeded as LLMProfileLimitExceeded,
)
from openhands.sdk.logger import get_logger
from openhands.sdk.profiles import (
SEED_PROFILE_NAME,
AgentProfile,
AgentProfileDiagnostics,
AgentProfileStore,
ProfileLimitExceeded,
Expand Down Expand Up @@ -98,6 +100,16 @@ class ActivateAgentProfileResponse(BaseModel):
agent_settings_applied: bool = False


class MaterializeAgentProfileRequest(BaseModel):
profile: AgentProfile | None = Field(
default=None,
description=(
"Draft profile to evaluate instead of the stored one. The path name "
"overrides the draft's name."
),
)


class RenameAgentProfileRequest(BaseModel):
new_name: str = Field(
...,
Expand Down Expand Up @@ -488,23 +500,30 @@ def set_pointer(settings: PersistedSettings) -> PersistedSettings:
response_model=AgentProfileDiagnostics,
)
async def materialize_agent_profile(
request: Request, name: ProfileName
request: Request,
name: ProfileName,
body: MaterializeAgentProfileRequest | None = None,
) -> AgentProfileDiagnostics:
"""Dry-run resolve a profile's LLM/MCP references; return a diagnostics report.
"""Dry-run resolve a profile the way a launch would; return a diagnostics report.

Dangling LLM/MCP references are reported in the body (valid=False) rather
than raising — the only error status is 404 (unknown profile name).
resolved_settings is redacted (api_key_set booleans; no raw secrets).
Resolves the stored profile ``name``, or ``body.profile`` when given (so an
editor can preview before saving). Dangling LLM/MCP references are reported
in the body (valid=False) rather than raising — the only error statuses are
404 (unknown stored profile) and 422 (invalid draft). resolved_settings is
redacted (api_key_set booleans; no raw secrets).
"""
store = get_agent_profile_store()
try:
with store_errors():
profile = store.load(name)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent profile '{name}' not found",
)
if body is not None and body.profile is not None:
profile = body.profile.model_copy(update={"name": name})
else:
store = get_agent_profile_store()
try:
with store_errors():
profile = store.load(name)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent profile '{name}' not found",
)

# Still needed here (unlike the profile load above): resolve_agent_profile_
# dry_run uses it to decrypt the *referenced LLM profile's* own secret.
Expand All @@ -513,35 +532,29 @@ async def materialize_agent_profile(
settings = get_settings_store(config).load() or PersistedSettings()
mcp_config = settings.agent_settings.mcp_config

# Discover skills off the event loop so the dry-run can report which skills
# (catalog minus ``disabled_skills``) resolve. Mirrors the launch rule in
# ``conversation_service._resolve_agent_from_profile`` so the preview matches
# a real launch: an ACP profile is only given a catalog where the CLI cannot
# read the user's own configuration (#4019). A discovery failure must not 500
# the preview: pass ``available_skills=None`` and surface the failure as its
# own diagnostic below.
discovery_error: str | None = None
available_skills = None
if profile.agent_kind == "openhands" or (
config.acp_skill_sourcing == "openhands_managed"
):
try:
available_skills = await asyncio.to_thread(discover_profile_skills)
except Exception as exc:
available_skills = None
discovery_error = str(exc)
logger.warning("Skill discovery failed during materialize: %s", exc)
inputs = await asyncio.to_thread(
gather_profile_launch_inputs, profile, config.acp_skill_sourcing
)
if inputs.skill_discovery_error is not None:
logger.warning(
"Skill discovery failed during materialize: %s",
inputs.skill_discovery_error,
)

llm_store = get_llm_profile_store()
diagnostics = resolve_agent_profile_dry_run(
profile,
llm_store=llm_store,
mcp_config=mcp_config,
available_skills=available_skills,
available_skills=inputs.available_skills,
cipher=cipher,
browser_available=inputs.browser_available,
)
if discovery_error is not None:
diagnostics.errors.append(f"Skill discovery failed: {discovery_error}")
# Reported rather than raised: a launch would fail on it, the preview must not.
if inputs.skill_discovery_error is not None:
diagnostics.errors.append(
f"Skill discovery failed: {inputs.skill_discovery_error}"
)
diagnostics.valid = False
diagnostics.resolved_settings = None
return diagnostics
6 changes: 6 additions & 0 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
from openhands.agent_server.vscode_service import get_vscode_service
from openhands.agent_server.workspaces_router import workspaces_router
from openhands.sdk.logger import DEBUG, get_logger
from openhands.sdk.tool import seal_tool_catalog
from openhands.sdk.utils.redact import sanitize_dict
from openhands.tools.terminal.constants import TMUX_SOCKET_NAME

Expand Down Expand Up @@ -171,6 +172,11 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
if not deferred:
emit_server_started()

# Every tool this deployment offers is registered by now (presets at
# import, plus any ``--import-modules``). Later registrations belong to
# one conversation and vanish on restart, so they stay out of the catalog.
seal_tool_catalog()

vscode_service = get_vscode_service()
tool_preload_service = get_tool_preload_service()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@
UpdateConversationRequest,
)
from openhands.agent_server.persistence import FileSecretsStore
from openhands.agent_server.profile_launch import gather_profile_launch_inputs
from openhands.agent_server.pub_sub import Subscriber
from openhands.agent_server.server_details_router import update_last_execution_time
from openhands.agent_server.skills_service import discover_profile_skills
from openhands.agent_server.telemetry import (
ConversationTelemetryContext,
DiagnosticEventFactory,
Expand Down Expand Up @@ -72,7 +72,6 @@
from openhands.sdk.git.utils import run_git_command, validate_git_repository
from openhands.sdk.mcp.utils import MCPToolProvider
from openhands.sdk.observability import OPERATION_METADATA_KEY, observe
from openhands.sdk.tool import BROWSER_TOOL_NAME, Tool, is_tool_usable
from openhands.sdk.tool.client_tool import register_client_tools
from openhands.sdk.utils.cipher import Cipher
from openhands.sdk.workspace import LocalWorkspace
Expand Down Expand Up @@ -402,31 +401,23 @@ def _resolve_agent_from_profile(
f"Failed to load agent profile '{profile_name}': {exc}"
) from exc

# OpenHands profiles get the discovered catalog minus their ``disabled_skills``
# deny-list. An ACP profile gets it only where the CLI cannot reach the user's
# own configuration (``openhands_managed``); under ``native`` it sources its
# own skills and OpenHands injects none (#4019). A genuine discovery failure
# fails the launch loudly rather than silently producing a zero-skill agent.
available_skills = None
wants_skills = profile.agent_kind == "openhands" or (
acp_skill_sourcing == "openhands_managed"
)
if wants_skills:
try:
available_skills = discover_profile_skills()
except Exception as exc:
raise ValueError(
f"Skill discovery failed for profile '{profile_name}': {exc}"
) from exc
inputs = gather_profile_launch_inputs(profile, acp_skill_sourcing)
# Fail loudly rather than silently launching a zero-skill agent.
if inputs.skill_discovery_error is not None:
raise ValueError(
f"Skill discovery failed for profile '{profile_name}': "
f"{inputs.skill_discovery_error}"
) from inputs.skill_discovery_error

llm_store = get_llm_profile_store()
try:
settings_config = resolve_agent_profile(
profile,
llm_store=llm_store,
mcp_config=mcp_config,
available_skills=available_skills,
available_skills=inputs.available_skills,
cipher=cipher,
browser_available=inputs.browser_available,
)
except (TypeError, ValueError) as exc:
raise ValueError(f"Profile '{profile_name}' failed to resolve: {exc}") from exc
Expand All @@ -442,17 +433,6 @@ def _resolve_agent_from_profile(
)

agent = settings_config.create_agent()
# Browser is deliberately absent from the deterministic SDK default
# (environment-dependent); this server knows its runtime, so it injects
# browser when usable. An explicit profile.tools list is authoritative.
if (
profile.agent_kind == "openhands"
and profile.tools is None
and is_tool_usable(BROWSER_TOOL_NAME)
):
agent = agent.model_copy(
update={"tools": [*agent.tools, Tool(name=BROWSER_TOOL_NAME)]}
)

launched = LaunchedAgentProfile(
agent_profile_id=profile.id,
Expand Down
44 changes: 44 additions & 0 deletions openhands-agent-server/openhands/agent_server/profile_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Deployment inputs an agent profile is resolved against.

Shared by conversation launch and the materialize preview so both resolve a
profile against the same skill catalog and runtime capabilities.
"""

from typing import NamedTuple

from openhands.agent_server.config import ACPSkillSourcing
from openhands.agent_server.skills_service import discover_profile_skills
from openhands.sdk.profiles import ACPAgentProfile, OpenHandsAgentProfile
from openhands.sdk.skills import Skill
from openhands.sdk.tool import BROWSER_TOOL_NAME, is_tool_usable


class ProfileLaunchInputs(NamedTuple):
available_skills: list[Skill] | None
skill_discovery_error: Exception | None
browser_available: bool


def gather_profile_launch_inputs(
profile: OpenHandsAgentProfile | ACPAgentProfile,
acp_skill_sourcing: ACPSkillSourcing,
) -> ProfileLaunchInputs:
"""Discover the skill catalog and probe this runtime for ``profile``.

An ACP profile only gets the managed skill catalog where its CLI cannot read
the user's own configuration (#4019).
"""
available_skills = None
discovery_error = None
if profile.agent_kind == "openhands" or acp_skill_sourcing == "openhands_managed":
try:
available_skills = discover_profile_skills()
except Exception as exc:
discovery_error = exc
return ProfileLaunchInputs(
available_skills=available_skills,
skill_discovery_error=discovery_error,
browser_available=(
profile.agent_kind == "openhands" and is_tool_usable(BROWSER_TOOL_NAME)
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class ServerInfo(BaseModel):
"credential_binding_v1",
"credential_binding_readiness_probe_v1",
"credential_binding_activation_guard_v1",
"tool_catalog_v1",
"agent_profile_draft_materialize_v1",
]
)
max_foreground_terminal_timeout_seconds: float | None = Field(
Expand Down
21 changes: 20 additions & 1 deletion openhands-agent-server/openhands/agent_server/tool_router.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""Tool router for OpenHands SDK."""

from fastapi import APIRouter
from pydantic import BaseModel

from openhands.sdk.tool.registry import list_registered_tools
from openhands.sdk.tool.registry import (
ToolCatalogEntry,
list_registered_tools,
list_tool_catalog,
)
from openhands.tools.preset.default import (
register_builtins_agents,
register_default_tools,
Expand All @@ -24,3 +29,17 @@ async def list_available_tools() -> list[str]:
"""List all available tools."""
tools = list_registered_tools()
return tools


class ToolCatalogResponse(BaseModel):
tools: list[ToolCatalogEntry]


@tool_router.get("/catalog")
async def get_tool_catalog() -> ToolCatalogResponse:
"""List the tools this server offers for configuring an agent.

Clients offer the ``user_selectable`` entries; ``usable`` says whether this
server's runtime can run the tool.
"""
return ToolCatalogResponse(tools=list_tool_catalog())
Loading
Loading