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
24 changes: 24 additions & 0 deletions migrations/versions/025_add_agent_profile.py
Original file line number Diff line number Diff line change
@@ -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")
19 changes: 18 additions & 1 deletion openhands/automation/capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions openhands/automation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: followup would be to modify the sdk workspace to support workspace.get_agent_profile helper that will return the profile based off 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.
Expand Down
3 changes: 3 additions & 0 deletions openhands/automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 18 additions & 4 deletions openhands/automation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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():
Expand Down
11 changes: 11 additions & 0 deletions openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
)

Comment on lines +531 to +535

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dup code block

name: str | None = Field(default=None, min_length=1, max_length=500)
model: str | None = Field(
default=None,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions openhands/automation/utils/model_profiles.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Comment on lines +52 to +53

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? Profiles won't work in remote deployments?

if model:
raise HTTPException(422, "An agent profile already specifies the model")
3 changes: 3 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -1290,13 +1290,15 @@ 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:
automation = Automation(
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",
Expand Down Expand Up @@ -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)
30 changes: 30 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
Loading