Skip to content
Closed
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
54 changes: 36 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,30 @@ The Automation Service owns automation definitions, cron scheduling, webhooks, r

## Development

### Local Docker execution

Set `AUTOMATION_AGENT_SERVER_URL` and `AUTOMATION_AGENT_SERVER_API_KEY` to an
agent-server running in Docker conversation mode. Set
`AUTOMATION_DOCKER_AGENT_PROFILE` to the UUID of a saved agent profile on that
server. Each bundle then gets its own Docker conversation and `/workspace`.
The server must support scoped runtime routes, runtime credential provisioning,
and runtime release. `AUTOMATION_DOCKER_MAX_CONCURRENT_RUNS` defaults to 2;
use the agent-server container CPU, memory, and PID settings to bound each run.

Bundles receive `AUTOMATION_CONVERSATION_ID`, an inner `AGENT_SERVER_URL`, and
only that runtime's `SESSION_API_KEY`. The outer server key and shared automation
callback key are not forwarded. The watchdog polls the bundle's scoped bash
result and releases finished containers while preserving conversation history.
This opt-in mode currently uses one configured agent profile for all bundles;
the existing local and Cloud execution modes retain their defaults.
### 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
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.

Both modes supply `AUTOMATION_CONVERSATION_ID`, `AGENT_SERVER_URL`,
`SESSION_API_KEY`, and `WORKSPACE_BASE`, and use conversation-scoped upload,
bash execution, and completion verification. Local workspaces live in per-run
subdirectories of the configured workspace root. Docker workspaces use
`/workspace` and receive only the selected inner session key, never the outer
server key or shared callback key. Local mode retains its existing single-tenant
server credential boundary; a local workspace is not a security sandbox.

`AUTOMATION_CONVERSATION_MAX_CONCURRENT_RUNS` defaults to 2. Docker servers must
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 earlier `AUTOMATION_DOCKER_AGENT_PROFILE` and related Docker-only settings
remain compatibility aliases for existing deployments. Without either profile
setting, existing local and Cloud dispatch behavior is unchanged.

### Prerequisites

Expand Down Expand Up @@ -119,8 +127,18 @@ 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.

For different role permissions, set `AUTOMATION_DOCKER_AGENT_PROFILE_OVERRIDES`
For different role permissions, set `AUTOMATION_AGENT_PROFILE_OVERRIDES`
to a JSON object mapping automation UUIDs to saved agent profile UUIDs. Unmapped
automations use `AUTOMATION_DOCKER_AGENT_PROFILE`. This host-controlled mapping
automations use `AUTOMATION_AGENT_PROFILE`. This host-controlled mapping
lets deterministic jobs select a profile with no model credential or agent tools,
while implementation and review jobs select only their required tools/model.

### SDK Client Integration Dependency

The conversation backend and Agent Server execution helpers use public clients
from [software-agent-sdk #5010](https://github.com/OpenHands/software-agent-sdk/pull/5010).
This draft pins the SDK implementation by immutable Git commit so its tests and
source installation are reproducible. Replace that integration pin with the SDK
release before merging. The live factory additionally integrates the server
runtime stack; those server changes are separate from this client dependency. Workflow bundles
receive the same environment contract in local and Docker workspaces.
11 changes: 7 additions & 4 deletions openhands/automation/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from openhands.automation.backends.base import ExecutionBackend, ExecutionContext
from openhands.automation.backends.cloud import CloudSandboxBackend
from openhands.automation.backends.conversation import ConversationAgentServerBackend
from openhands.automation.backends.docker import DockerAgentServerBackend
from openhands.automation.backends.local import LocalAgentServerBackend

Expand All @@ -46,7 +47,9 @@ def get_backend(run: AutomationRun) -> ExecutionBackend:

if settings.is_local_mode:
backend_type = (
DockerAgentServerBackend
ConversationAgentServerBackend
if settings.agent_profile
else DockerAgentServerBackend
if settings.docker_agent_profile
else LocalAgentServerBackend
)
Expand All @@ -58,9 +61,9 @@ def get_backend(run: AutomationRun) -> ExecutionBackend:
callback_api_key=settings.local_api_key,
sandbox_agent_server_url=settings.sandbox_agent_server_url or None,
)
if isinstance(backend, DockerAgentServerBackend):
backend.agent_profile_id = settings.docker_agent_profile_overrides.get(
str(run.automation_id), settings.docker_agent_profile
if isinstance(backend, ConversationAgentServerBackend):
backend.agent_profile_id = settings.run_agent_profile_overrides.get(
str(run.automation_id), settings.run_agent_profile
)
return backend
else:
Expand Down
123 changes: 123 additions & 0 deletions openhands/automation/backends/conversation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""One bundle-facing conversation contract for local and Docker workspaces."""

from __future__ import annotations

import httpx

from openhands.automation.backends.base import ExecutionContext
from openhands.automation.backends.local import LocalAgentServerBackend
from openhands.automation.utils.agent_server import (
VerificationResult,
verify_run_on_agent_server,
)
from openhands.sdk.client import AsyncAgentServerClient


class ConversationAgentServerBackend(LocalAgentServerBackend):
agent_profile_id: str
runtime_api_key: str = ""
_runtime_kind: str | None = None

async def _resolve_runtime(self, client: httpx.AsyncClient) -> str:
if self._runtime_kind is None:
info = await AsyncAgentServerClient(
self.agent_server_url, self.api_key, http_client=client
).get_server_info()
self._runtime_kind = info["conversation_runtime"]
if self._runtime_kind not in ("local", "docker"):
raise ValueError("Unsupported agent-server conversation runtime")
return self._runtime_kind

@property
def api_prefix(self) -> str:
return (
AsyncAgentServerClient(self.agent_server_url, self.api_key)
.runtime(str(self._run.id))
.api_prefix
)

async def get_execution_context(
self, client: httpx.AsyncClient
) -> ExecutionContext:
runtime_kind = await self._resolve_runtime(client)
server = AsyncAgentServerClient(
self.agent_server_url, self.api_key, http_client=client
)
await server.create_conversation(
conversation_id=str(self._run.id),
agent_profile_id=self.agent_profile_id,
working_dir=self.get_work_dir(str(self._run.id)),
title=self._run.automation.name,
max_iterations=160,
tags={"automationrun": str(self._run.id)},
)
self.runtime_api_key = self.api_key if runtime_kind == "local" else ""
if runtime_kind == "docker":
try:
self.runtime_api_key = await server.runtime(
str(self._run.id)
).get_session_key()
except Exception:
await self.release_context(
client, ExecutionContext(self.agent_server_url, self.api_key)
)
raise
return ExecutionContext(
agent_url=self.agent_server_url,
session_key=self.api_key,
api_prefix=self.api_prefix,
)

def build_env_vars(self) -> dict[str, str]:
if not self.runtime_api_key:
raise RuntimeError("Runtime credentials have not been provisioned")
return {
"AGENT_SERVER_URL": (
self.sandbox_agent_server_url
or (
"http://127.0.0.1:8000"
if self._runtime_kind == "docker"
else self.agent_server_url
)
),
"AUTOMATION_CONVERSATION_ID": str(self._run.id),
"WORKSPACE_BASE": self.get_work_dir(str(self._run.id)),
"SESSION_API_KEY": self.runtime_api_key,
}

def get_work_dir(self, run_id: str) -> str:
if self._runtime_kind is None:
raise RuntimeError(
"Resolve the server runtime before selecting a workspace"
)
return (
"/workspace"
if self._runtime_kind == "docker"
else super().get_work_dir(run_id)
)

async def release_context(
self, client: httpx.AsyncClient, ctx: ExecutionContext
) -> None:
if await self._resolve_runtime(client) == "local":
return # Persistent server and conversation history belong to the host.
await (
AsyncAgentServerClient(ctx.agent_url, self.api_key, http_client=client)
.runtime(str(self._run.id))
.release()
)

async def verify_run(self, run_id: str) -> VerificationResult:
return await verify_run_on_agent_server(
agent_url=self.agent_server_url,
session_key=self.api_key,
run_id=run_id,
bash_command_id=self._run.bash_command_id,
api_prefix=self.api_prefix,
)

async def cleanup_after_verification(self, run_id: str) -> None: # noqa: ARG002
async with httpx.AsyncClient() as client:
await self.release_context(
client, ExecutionContext(self.agent_server_url, self.api_key)
)
98 changes: 4 additions & 94 deletions openhands/automation/backends/docker.py
Original file line number Diff line number Diff line change
@@ -1,97 +1,7 @@
"""Run local automation bundles inside agent-server Docker conversations."""
"""Compatibility backend for the original Docker-only profile setting."""

from __future__ import annotations
from openhands.automation.backends.conversation import ConversationAgentServerBackend

import httpx

from openhands.automation.backends.base import ExecutionContext
from openhands.automation.backends.local import LocalAgentServerBackend
from openhands.automation.utils.agent_server import (
VerificationResult,
verify_run_on_agent_server,
)


class DockerAgentServerBackend(LocalAgentServerBackend):
agent_profile_id: str
runtime_api_key: str = ""

@property
def api_prefix(self) -> str:
return f"/api/conversations/{self._run.id}"

async def get_execution_context(
self, client: httpx.AsyncClient
) -> ExecutionContext:
response = await client.post(
f"{self.agent_server_url}/api/conversations",
headers={"X-Session-API-Key": self.api_key},
json={
"conversation_id": str(self._run.id),
"agent_profile_id": self.agent_profile_id,
"workspace": {"kind": "LocalWorkspace", "working_dir": "/workspace"},
"title": self._run.automation.name,
"max_iterations": 160,
"tags": {"automationrun": str(self._run.id)},
},
timeout=180,
)
response.raise_for_status()
try:
credentials = await client.post(
f"{self.agent_server_url}{self.api_prefix}/runtime/credentials",
headers={"X-Session-API-Key": self.api_key},
)
credentials.raise_for_status()
self.runtime_api_key = credentials.json()["session_api_key"]
if not self.runtime_api_key:
raise ValueError("Runtime returned an empty session credential")
except Exception:
await self.release_context(
client, ExecutionContext(self.agent_server_url, self.api_key)
)
raise
return ExecutionContext(
agent_url=self.agent_server_url,
session_key=self.api_key,
api_prefix=self.api_prefix,
)

def build_env_vars(self) -> dict[str, str]:
if not self.runtime_api_key:
raise RuntimeError("Runtime credentials have not been provisioned")
return {
"AGENT_SERVER_URL": "http://127.0.0.1:8000",
"AUTOMATION_CONVERSATION_ID": str(self._run.id),
"WORKSPACE_BASE": "/workspace",
"SESSION_API_KEY": self.runtime_api_key,
}

def get_work_dir(self, run_id: str) -> str: # noqa: ARG002
return "/workspace"

async def release_context(
self, client: httpx.AsyncClient, ctx: ExecutionContext
) -> None:
response = await client.delete(
f"{ctx.agent_url}{self.api_prefix}/runtime",
headers={"X-Session-API-Key": self.api_key},
timeout=60,
)
if response.status_code != 404:
response.raise_for_status()

async def verify_run(self, run_id: str) -> VerificationResult:
return await verify_run_on_agent_server(
agent_url=self.agent_server_url,
session_key=self.api_key,
run_id=run_id,
bash_command_id=self._run.bash_command_id,
api_prefix=self.api_prefix,
)

async def cleanup_after_verification(self, run_id: str) -> None: # noqa: ARG002
async with httpx.AsyncClient() as client:
await self.release_context(
client, ExecutionContext(self.agent_server_url, self.api_key)
)
class DockerAgentServerBackend(ConversationAgentServerBackend):
_runtime_kind = "docker"
26 changes: 26 additions & 0 deletions openhands/automation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,9 +564,35 @@ class ServiceSettings(BaseSettings):
# - Authenticates using local_api_key instead of OpenHands SaaS API
agent_server_url: str = ""
agent_server_api_key: str = ""
# Shared conversation execution; the server advertises its workspace runtime.
agent_profile: str = ""
agent_profile_overrides: dict[str, str] = Field(default_factory=dict)
conversation_max_concurrent_runs: int = Field(default=2, ge=1)
# Compatibility for early Docker-only deployments.
docker_agent_profile: str = ""
docker_agent_profile_overrides: dict[str, str] = Field(default_factory=dict)
docker_max_concurrent_runs: int = Field(default=2, ge=1)

@property
def run_agent_profile(self) -> str:
return self.agent_profile or self.docker_agent_profile

@property
def run_agent_profile_overrides(self) -> dict[str, str]:
return (
self.agent_profile_overrides
if self.agent_profile
else self.docker_agent_profile_overrides
)

@property
def run_concurrency_limit(self) -> int:
return (
self.conversation_max_concurrent_runs
if self.agent_profile
else self.docker_max_concurrent_runs
)

# Optional override for the AGENT_SERVER_URL env var exported into the
# in-sandbox bash chain by LocalAgentServerBackend.build_env_vars.
# When empty, defaults to agent_server_url (the URL the backend itself
Expand Down
Loading
Loading