From 5f932881931254b81a48a1756e636637bfe82d6a Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 16 Sep 2026 15:23:45 +0000 Subject: [PATCH] feat: select an agent profile for automation commands --- migrations/versions/025_add_agent_profile.py | 24 ++++++++++++++++ openhands/automation/capabilities_router.py | 19 ++++++++++++- openhands/automation/dispatcher.py | 2 ++ openhands/automation/models.py | 3 ++ openhands/automation/router.py | 22 +++++++++++--- openhands/automation/schemas.py | 11 +++++++ openhands/automation/utils/model_profiles.py | 16 +++++++++++ tests/test_db.py | 3 ++ tests/test_dispatcher.py | 19 +++++++++++++ tests/test_router.py | 30 ++++++++++++++++++++ 10 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 migrations/versions/025_add_agent_profile.py diff --git a/migrations/versions/025_add_agent_profile.py b/migrations/versions/025_add_agent_profile.py new file mode 100644 index 00000000..606ab7ff --- /dev/null +++ b/migrations/versions/025_add_agent_profile.py @@ -0,0 +1,24 @@ +"""Persist automation profile selection. + +Revision ID: 025 +Revises: 024 +""" + +import sqlalchemy as sa +from alembic import op + + +revision = "025" +down_revision = "024" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "automations", sa.Column("agent_profile_id", sa.Uuid(), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("automations", "agent_profile_id") diff --git a/openhands/automation/capabilities_router.py b/openhands/automation/capabilities_router.py index fbb9037b..4c01b91d 100644 --- a/openhands/automation/capabilities_router.py +++ b/openhands/automation/capabilities_router.py @@ -46,7 +46,10 @@ ) from openhands.automation.trigger_matcher import matches_trigger from openhands.automation.utils.cron import min_interval_seconds -from openhands.automation.utils.model_profiles import validate_model_profile_for_user +from openhands.automation.utils.model_profiles import ( + validate_agent_profile_selection, + validate_model_profile_for_user, +) from openhands.automation.utils.webhook import get_webhook_config @@ -119,6 +122,8 @@ async def get_capabilities( event_sources = sorted({*builtin, *await _custom_sources(user.org_id, session)}) features = [*_STATIC_FEATURES] + if config.service.is_local_mode: + features.append("agentProfiles") if event_sources: features.append("webhookDelivery") if config.kv.enabled: @@ -182,6 +187,18 @@ async def validate_draft( ) ) + if isinstance(draft, CreateAutomationRequest): + try: + validate_agent_profile_selection(draft.agent_profile_id, draft.model) + except HTTPException as e: + errors.append( + DraftValidationError( + field="agent_profile_id", + code="invalid_agent_profile", + message=str(e.detail), + ) + ) + trigger = draft.trigger if isinstance(trigger, CronTrigger): errors.extend(_cron_errors(trigger)) diff --git a/openhands/automation/dispatcher.py b/openhands/automation/dispatcher.py index e5b6cef6..8e88c16b 100644 --- a/openhands/automation/dispatcher.py +++ b/openhands/automation/dispatcher.py @@ -337,6 +337,8 @@ async def _fail( env_vars["AUTOMATION_EVENT_PAYLOAD"] = json.dumps( _build_event_payload(automation, run) ) + if automation.agent_profile_id: + env_vars["AUTOMATION_AGENT_PROFILE_ID"] = str(automation.agent_profile_id) # A subject-owning run must create its conversation under the id # `continue_conversation` addresses later, or every follow-up 404s and # silently starts a fresh thread. diff --git a/openhands/automation/models.py b/openhands/automation/models.py index 1d250491..2b346e13 100644 --- a/openhands/automation/models.py +++ b/openhands/automation/models.py @@ -73,6 +73,9 @@ class Automation(Base): # None is only used for legacy/local fallback. model: Mapped[str | None] = mapped_column(String(64), nullable=True) + # Profile IDs belong to the configured Agent Server, not to this database. + agent_profile_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) + # Trigger config — for MVP, only cron is supported. # Uses generic JSON type for cross-database compatibility (PostgreSQL + SQLite) trigger: Mapped[dict] = mapped_column(JSON, nullable=False) diff --git a/openhands/automation/router.py b/openhands/automation/router.py index 2cce3e7a..6537161e 100644 --- a/openhands/automation/router.py +++ b/openhands/automation/router.py @@ -58,7 +58,10 @@ from openhands.automation.utils.conversation_outcome import ( fetch_latest_finish_tool_response_for_run, ) -from openhands.automation.utils.model_profiles import resolve_model_profile_for_user +from openhands.automation.utils.model_profiles import ( + resolve_model_profile_for_user, + validate_agent_profile_selection, +) from openhands.automation.utils.run import ( create_pending_run, record_first_run_outcome, @@ -155,7 +158,12 @@ async def create_automation( org_id=user.org_id, session=session, ) - model = resolve_model_profile_for_user(body.model, user) + validate_agent_profile_selection(body.agent_profile_id, body.model) + model = ( + None + if body.agent_profile_id + else resolve_model_profile_for_user(body.model, user) + ) preset_metadata: dict[str, Any] | None = None if body.template is not None: @@ -166,6 +174,7 @@ async def create_automation( org_id=user.org_id, name=body.name, model=model, + agent_profile_id=body.agent_profile_id, preset_metadata=preset_metadata, trigger=body.trigger.model_dump(), tarball_path=body.tarball_path, @@ -311,8 +320,13 @@ async def update_automation( source="manual", ) - if "model" in update_data: - update_data["model"] = resolve_model_profile_for_user(body.model, user) + if "agent_profile_id" in update_data or "model" in update_data: + selected_profile = update_data.get("agent_profile_id", auto.agent_profile_id) + validate_agent_profile_selection(selected_profile, body.model) + if selected_profile: + update_data["model"] = None + elif "model" in update_data: + update_data["model"] = resolve_model_profile_for_user(body.model, user) original_prompt = auto.prompt for field, value in update_data.items(): diff --git a/openhands/automation/schemas.py b/openhands/automation/schemas.py index efb9996e..070d6d64 100644 --- a/openhands/automation/schemas.py +++ b/openhands/automation/schemas.py @@ -423,6 +423,11 @@ def validate_config_size(cls, v: dict[str, Any] | None) -> dict[str, Any] | None class CreateAutomationRequest(BaseModel): model_config = ConfigDict(extra="forbid") + agent_profile_id: uuid.UUID | None = Field( + default=None, + description="Agent profile selected for this automation.", + ) + name: str = Field(..., min_length=1, max_length=500) model: str | None = Field( default=None, @@ -523,6 +528,11 @@ class UpdateAutomationRequest(BaseModel): model_config = ConfigDict(extra="forbid") + agent_profile_id: uuid.UUID | None = Field( + default=None, + description="Agent profile selected for this automation.", + ) + name: str | None = Field(default=None, min_length=1, max_length=500) model: str | None = Field( default=None, @@ -862,6 +872,7 @@ class TelemetryConsentResponse(BaseModel): class AutomationResponse(BaseModel): + agent_profile_id: uuid.UUID | None = None id: uuid.UUID user_id: uuid.UUID org_id: uuid.UUID diff --git a/openhands/automation/utils/model_profiles.py b/openhands/automation/utils/model_profiles.py index 1b8e6365..afef5184 100644 --- a/openhands/automation/utils/model_profiles.py +++ b/openhands/automation/utils/model_profiles.py @@ -1,5 +1,7 @@ """Helpers for resolving and validating model profile selections.""" +import uuid + from fastapi import HTTPException, status from openhands.automation.auth import AuthenticatedUser @@ -37,3 +39,17 @@ def resolve_model_profile_for_user( model_profile = requested_profile or user.active_model_profile_name validate_model_profile_for_user(model_profile, user) return model_profile + + +def validate_agent_profile_selection( + agent_profile_id: uuid.UUID | None, model: str | None +) -> None: + """An agent profile owns its model and is resolved by the configured server.""" + if agent_profile_id is None: + return + from openhands.automation.config import get_config + + if not get_config().service.is_local_mode: + raise HTTPException(422, "Agent profiles require a configured Agent Server") + if model: + raise HTTPException(422, "An agent profile already specifies the model") diff --git a/tests/test_db.py b/tests/test_db.py index 5512989c..d6b23195 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -312,6 +312,9 @@ def test_migrations_run_on_sqlite(self, monkeypatch): column["name"] for column in inspector.get_columns("automation_runs") } assert "cost" in run_columns + assert "agent_profile_id" in { + column["name"] for column in inspector.get_columns("automations") + } engine.dispose() finally: diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index ec84ad84..534dfa4d 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -1290,6 +1290,7 @@ async def _dispatch( *, trigger: dict, subject_key: str | None, + agent_profile_id: uuid.UUID | None = None, ): """Drive _execute_run once; returns (env_vars, org_id, automation_id).""" async with async_session_factory() as session: @@ -1297,6 +1298,7 @@ async def _dispatch( user_id=TEST_USER_ID, org_id=TEST_ORG_ID, name="Mention Responder", + agent_profile_id=agent_profile_id, trigger=trigger, tarball_path="https://example.com/code.tar.gz", entrypoint="uv run main.py", @@ -1385,3 +1387,20 @@ async def test_run_without_a_subject_gets_no_id( ) assert "AUTOMATION_CONVERSATION_ID" not in env_vars + + @patch("openhands.automation.dispatcher.execute_in_context", new_callable=AsyncMock) + async def test_selected_agent_profile_is_available_to_the_command( + self, mock_execute, async_session_factory, mock_settings, mock_client + ): + selected = uuid.uuid4() + env_vars, _, _ = await self._dispatch( + mock_execute, + async_session_factory, + mock_settings, + mock_client, + trigger={"type": "cron", "schedule": "* * * * *", "timezone": "UTC"}, + subject_key=None, + agent_profile_id=selected, + ) + + assert env_vars["AUTOMATION_AGENT_PROFILE_ID"] == str(selected) diff --git a/tests/test_router.py b/tests/test_router.py index c42f3fb4..3c453aa9 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -312,6 +312,36 @@ async def test_disable_with_edits_as_non_creator_manager_returns_403( class TestCreateAutomation: """Tests for POST /v1 endpoint.""" + async def test_profile_round_trip(self, async_client, local_mode): + selected, replacement = uuid.uuid4(), uuid.uuid4() + response = await async_client.post( + "/api/automation/v1", + json={ + "name": "Independent reviewer", + "agent_profile_id": str(selected), + "trigger": {"type": "cron", "schedule": "*/5 * * * *"}, + "tarball_path": "s3://bucket/reviewer.tar.gz", + "entrypoint": "python3 main.py", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["agent_profile_id"] == str(selected) + path = "/api/automation/v1/" + data["id"] + changed = await async_client.patch( + path, json={"agent_profile_id": str(replacement)} + ) + assert changed.status_code == 200 + assert changed.json()["agent_profile_id"] == str(replacement) + cleared = await async_client.patch(path, json={"agent_profile_id": None}) + assert cleared.status_code == 200 + assert cleared.json()["agent_profile_id"] is None + assert (await async_client.get(path)).json()["agent_profile_id"] is None + invalid = await async_client.patch( + path, json={"agent_profile_id": "missing-profile"} + ) + assert invalid.status_code == 422 + async def test_create_automation_success( self, async_client, async_session, local_mode ):