Skip to content
Draft
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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ The Automation Service owns automation definitions, cron scheduling, webhooks, r
### Conversation execution in local or Docker workspaces

Set `AUTOMATION_AGENT_SERVER_URL`, `AUTOMATION_AGENT_SERVER_API_KEY`, and
`AUTOMATION_AGENT_PROFILE` (a saved agent profile UUID). The backend reads the
an `agent_profile_id` on each automation (a saved agent profile UUID).
The selected profile is snapshotted when the run is queued. The backend reads the
server's authoritative `conversation_runtime` and provisions a run conversation
using the same API and profile in either mode. No bundle configuration or workflow
branch changes when switching workspace kind.
Expand All @@ -46,6 +47,18 @@ support runtime credential provisioning and release; bound container CPU, memory
and PIDs in the server configuration. Completed Docker runtimes are released while
history remains; the persistent local server and its history are retained.

The definition and each queued run store `agent_profile_id`. Editing a definition
affects future runs; already queued runs retain their selected profile ID. The
Agent Server resolves that profile at dispatch, including its model, tools, and
`secret_refs`. Missing profiles or secrets fail creation rather than falling back
to a more privileged agent. Setting the field to `null` uses the deployment
default. A separate `model` selection is rejected when an agent profile is set.

Profile selection is advertised by the `agentProfiles` capability when an Agent
Server is configured. Cloud dispatch without a configured server retains its
existing behavior and rejects explicit profile selections. Local workspaces
share the host security boundary; use Docker for process isolation.

### Prerequisites

- Python 3.12+
Expand Down Expand Up @@ -129,6 +142,24 @@ containers/ # Docker configuration

This service is deployed via the [deploy repository](https://github.com/All-Hands-AI/deploy). Docker images are automatically built and pushed to `ghcr.io/openhands/automation` on every push to main and on tags.

Each automation chooses its own saved agent profile through the create or patch
API. Profile IDs are included in git sync and run history. For example:

```json
{"agent_profile_id": "11111111-1111-4111-8111-111111111111"}
```

The automation definition contains no token values or host-side override map.
Manage credential availability in the selected profile's `secret_refs` and the
Agent Server's existing secret store.

Git sync preserves the runner files committed under `tarball/`; changing only
`agent_profile_id` in YAML does not regenerate them. Before enabling a profile on
an older preset through Git, upgrade its runner to the current preset version
that attaches to the provisioned conversation. Selecting the profile through the
API refreshes generated preset runners automatically. Git imports enforce the
same profile/model selection rules as the API.

### SDK Integration Dependency

The conversation backend and Agent Server execution helpers reuse `RemoteConversation` and `RemoteWorkspace`
Expand Down
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 and snapshot it on queued runs.

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:
for table in ("automations", "automation_runs"):
op.add_column(table, sa.Column("agent_profile_id", sa.Uuid(), nullable=True))


def downgrade() -> None:
for table in ("automation_runs", "automations"):
op.drop_column(table, "agent_profile_id")
10 changes: 6 additions & 4 deletions openhands/automation/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ def get_backend(run: AutomationRun) -> ExecutionBackend:
config = get_config()
settings = config.service

profile_id = str(run.agent_profile_id) if run.agent_profile_id else ""
if profile_id and not settings.is_local_mode:
raise ValueError("Agent profiles require a configured Agent Server")

if settings.is_local_mode:
backend_type = (
ConversationAgentServerBackend
if settings.agent_profile
else LocalAgentServerBackend
ConversationAgentServerBackend if profile_id else LocalAgentServerBackend
)
backend = backend_type(
agent_server_url=settings.agent_server_url,
Expand All @@ -59,7 +61,7 @@ def get_backend(run: AutomationRun) -> ExecutionBackend:
sandbox_agent_server_url=settings.sandbox_agent_server_url or None,
)
if isinstance(backend, ConversationAgentServerBackend):
backend.agent_profile_id = settings.agent_profile
backend.agent_profile_id = profile_id
return backend
else:
return CloudSandboxBackend(
Expand Down
2 changes: 2 additions & 0 deletions openhands/automation/backends/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def _create_conversation(self) -> None:
workspace=LocalWorkspace(working_dir=workspace.working_dir),
conversation_id=self.conversation_id,
agent_profile_id=UUID(self.agent_profile_id),
plugins=(self._run.automation.preset_metadata or {}).get("plugins"),
max_iterations=160,
tags={"automationrun": str(self._run.id)},
),
Expand Down Expand Up @@ -118,6 +119,7 @@ def build_env_vars(self) -> dict[str, str]:
)
),
"AUTOMATION_CONVERSATION_ID": str(self.conversation_id),
"AUTOMATION_AGENT_PROFILE_ID": self.agent_profile_id,
"WORKSPACE_BASE": self.get_work_dir(str(self._run.id)),
"SESSION_API_KEY": self.runtime_api_key,
}
Expand Down
18 changes: 17 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,17 @@ async def validate_draft(
)
)

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
1 change: 0 additions & 1 deletion openhands/automation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,6 @@ class ServiceSettings(BaseSettings):
agent_server_url: str = ""
agent_server_api_key: str = ""
# Shared conversation execution; the server advertises its workspace runtime.
agent_profile: str = ""
conversation_max_concurrent_runs: int = Field(default=2, ge=1)

# Optional override for the AGENT_SERVER_URL env var exported into the
Expand Down
6 changes: 3 additions & 3 deletions openhands/automation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ async def _poll_pending_runs(
Eagerly loads the ``automation`` relationship so that ``user_id``,
``org_id``, and tarball config are available for dispatch.
"""
run_profile = get_config().service.agent_profile
is_local = get_config().service.is_local_mode
active = []
if run_profile:
if is_local:
active = (
(
await session.execute(
Expand Down Expand Up @@ -165,7 +165,7 @@ async def _poll_pending_runs(
.order_by(AutomationRun.created_at.asc())
.limit(batch_size)
)
if run_profile and active:
if is_local and active:
select_query = select_query.where(AutomationRun.automation_id.not_in(active))

# Apply row locking for PostgreSQL only (SQLite doesn't support it)
Expand Down
12 changes: 12 additions & 0 deletions openhands/automation/git_sync/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pathlib import Path
from typing import Final, NamedTuple

from fastapi import HTTPException
from pydantic import TypeAdapter, ValidationError
from sqlalchemy import or_, select, update
from sqlalchemy.engine import CursorResult
Expand Down Expand Up @@ -64,6 +65,7 @@
from openhands.automation.schemas import Trigger, validate_command_string
from openhands.automation.storage import ObjectNotFoundError, get_file_store
from openhands.automation.utils import utcnow
from openhands.automation.utils.model_profiles import validate_agent_profile_selection
from openhands.automation.utils.periodic_loop import run_periodic_loop
from openhands.automation.utils.tarball_validation import (
build_internal_url,
Expand Down Expand Up @@ -501,13 +503,23 @@ async def _validate_and_resolve_fields(
fields.get("setup_script_path"), "setup_script_path"
)
timeout = validate_automation_timeout(fields.get("timeout"))
agent_profile_id = (
uuid.UUID(fields["agent_profile_id"])
if fields.get("agent_profile_id")
else None
)
try:
validate_agent_profile_selection(agent_profile_id, fields.get("model"))
except HTTPException as exc:
raise ValueError(exc.detail) from exc
tarball_path = await _resolve_tarball_path(
session, fields, deserialized, slug, existing, pending_storage_deletes, owner
)

return {
"name": name,
"model": fields.get("model"),
"agent_profile_id": agent_profile_id,
"trigger": trigger.model_dump(),
"entrypoint": entrypoint,
"setup_script_path": setup_script_path,
Expand Down
3 changes: 3 additions & 0 deletions openhands/automation/git_sync/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ def _automation_yaml_fields(
fields: dict[str, Any] = {
"name": automation.name,
"model": automation.model,
"agent_profile_id": str(automation.agent_profile_id)
if automation.agent_profile_id
else None,
"trigger": automation.trigger,
"setup_script_path": automation.setup_script_path,
"entrypoint": automation.entrypoint,
Expand Down
6 changes: 6 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 Expand Up @@ -168,6 +171,9 @@ class AutomationRun(Base):
default=AutomationRunStatus.PENDING,
)

# Snapshot the selected profile when queuing so edits affect future runs only.
agent_profile_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)

# Error details if status is FAILED
error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)

Expand Down
Loading
Loading