From 0106d631adf6d9bc5bdb9a4e6d619ee062b5982a Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 14 Sep 2026 16:36:52 +0000 Subject: [PATCH] feat: select agent profiles for automation runs Co-authored-by: openhands --- README.md | 33 +++- migrations/versions/025_add_agent_profile.py | 24 +++ openhands/automation/backends/__init__.py | 10 +- openhands/automation/backends/conversation.py | 2 + openhands/automation/capabilities_router.py | 18 +- openhands/automation/config.py | 1 - openhands/automation/dispatcher.py | 6 +- openhands/automation/git_sync/loop.py | 12 ++ openhands/automation/git_sync/serializer.py | 3 + openhands/automation/models.py | 6 + openhands/automation/preset_router.py | 66 ++++++- .../automation/presets/plugin/sdk_main.py | 176 ++++++++++-------- .../automation/presets/prompt/sdk_main.py | 174 +++++++++-------- openhands/automation/router.py | 39 ++-- openhands/automation/schemas.py | 24 +++ openhands/automation/utils/model_profiles.py | 16 ++ openhands/automation/utils/run.py | 1 + openhands/automation/watchdog.py | 4 +- tests/test_backends.py | 1 + tests/test_cancel_run.py | 3 +- tests/test_conversation_backend.py | 54 +++++- tests/test_db.py | 4 + tests/test_git_sync.py | 39 ++++ tests/test_git_sync_serializer.py | 8 + tests/test_router.py | 81 ++++++++ 25 files changed, 607 insertions(+), 198 deletions(-) create mode 100644 migrations/versions/025_add_agent_profile.py diff --git a/README.md b/README.md index d6e12a0a..2f71084f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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+ @@ -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` diff --git a/migrations/versions/025_add_agent_profile.py b/migrations/versions/025_add_agent_profile.py new file mode 100644 index 00000000..8b378847 --- /dev/null +++ b/migrations/versions/025_add_agent_profile.py @@ -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") diff --git a/openhands/automation/backends/__init__.py b/openhands/automation/backends/__init__.py index d139148c..a82766b7 100644 --- a/openhands/automation/backends/__init__.py +++ b/openhands/automation/backends/__init__.py @@ -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, @@ -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( diff --git a/openhands/automation/backends/conversation.py b/openhands/automation/backends/conversation.py index 544aaeb7..294c82f2 100644 --- a/openhands/automation/backends/conversation.py +++ b/openhands/automation/backends/conversation.py @@ -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)}, ), @@ -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, } diff --git a/openhands/automation/capabilities_router.py b/openhands/automation/capabilities_router.py index fbb9037b..74d6588b 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,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)) diff --git a/openhands/automation/config.py b/openhands/automation/config.py index 67fcf3bb..f22716fe 100644 --- a/openhands/automation/config.py +++ b/openhands/automation/config.py @@ -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 diff --git a/openhands/automation/dispatcher.py b/openhands/automation/dispatcher.py index 4bac9001..25681877 100644 --- a/openhands/automation/dispatcher.py +++ b/openhands/automation/dispatcher.py @@ -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( @@ -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) diff --git a/openhands/automation/git_sync/loop.py b/openhands/automation/git_sync/loop.py index 5c3e363d..8515293f 100644 --- a/openhands/automation/git_sync/loop.py +++ b/openhands/automation/git_sync/loop.py @@ -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 @@ -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, @@ -501,6 +503,15 @@ 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 ) @@ -508,6 +519,7 @@ async def _validate_and_resolve_fields( 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, diff --git a/openhands/automation/git_sync/serializer.py b/openhands/automation/git_sync/serializer.py index 6e035202..0024826b 100644 --- a/openhands/automation/git_sync/serializer.py +++ b/openhands/automation/git_sync/serializer.py @@ -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, diff --git a/openhands/automation/models.py b/openhands/automation/models.py index 1d250491..e21ae48f 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) @@ -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) diff --git a/openhands/automation/preset_router.py b/openhands/automation/preset_router.py index bb66d049..52834af2 100644 --- a/openhands/automation/preset_router.py +++ b/openhands/automation/preset_router.py @@ -51,7 +51,10 @@ get_request_telemetry_context, ) from openhands.automation.utils import utcnow -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.tarball_validation import ( build_internal_url, build_upload_storage_path, @@ -145,6 +148,8 @@ class CreatePromptAutomationRequest(BaseModel): model_config = ConfigDict(extra="forbid") + agent_profile_id: uuid.UUID | None = None + name: str = Field(..., min_length=1, max_length=500) prompt: str = Field( ..., @@ -276,27 +281,38 @@ def _generate_tarball(prompt: str, repos: list[RepoSource] | None = None) -> byt _build_storage_path = build_upload_storage_path -def _replace_prompt_in_tarball(tarball_bytes: bytes, new_prompt: str) -> bytes | None: +def _replace_prompt_in_tarball( + tarball_bytes: bytes, + new_prompt: str, + runner_files: dict[str, str] | None = None, +) -> bytes | None: """Return a copy of a preset tarball with ``prompt.txt`` swapped for ``new_prompt``. - Every other member (``main.py``, ``setup.sh``, ``plugins_config.json``, - ``repos_config.json``, ...) is copied through unchanged, so plugin and repo - configuration are preserved and the working template is untouched. + Optional ``runner_files`` upgrade the generated runner when its profile + changes. All other members, including plugin and repository configuration, + are preserved. Returns ``None`` if the archive has no ``prompt.txt`` member — i.e. it is not a regenerable preset tarball — so the caller can leave the tarball as-is. """ out_buffer = io.BytesIO() found = False + replacements = {"prompt.txt": new_prompt, **(runner_files or {})} + seen = set() with ( tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as src, tarfile.open(fileobj=out_buffer, mode="w:gz") as dst, ): for member in src.getmembers(): + seen.add(member.name) if member.name == "prompt.txt": found = True + if member.name in replacements: _add_file_to_tar( - dst, "prompt.txt", new_prompt, mode=member.mode or 0o644 + dst, + member.name, + replacements[member.name], + mode=member.mode or 0o644, ) continue if member.isfile(): @@ -309,6 +325,8 @@ def _replace_prompt_in_tarball(tarball_bytes: bytes, new_prompt: str) -> bytes | dst.addfile(info, io.BytesIO(data)) else: dst.addfile(member) + for name in replacements.keys() - seen: + _add_file_to_tar(dst, name, replacements[name]) if not found: return None @@ -334,6 +352,8 @@ async def regenerate_preset_prompt_tarball( new_prompt: str, session: AsyncSession, background_tasks: BackgroundTasks, + *, + refresh_runner: bool = False, ) -> str | None: """Rebuild a preset automation's tarball with an updated prompt. @@ -343,7 +363,8 @@ async def regenerate_preset_prompt_tarball( running the original prompt. Reads the automation's current internal-upload tarball, swaps in ``new_prompt`` - (leaving all other files untouched), uploads the result as a new internal upload, + and optionally refreshes the preset runner when its agent profile changes. + Repository and plugin configuration are preserved. Uploads a new internal upload, and returns its ``oh-internal://`` URL for the caller to store on ``tarball_path``. The superseded upload is soft-deleted in the current transaction; its storage object is removed via ``background_tasks`` only after the transaction commits. @@ -373,7 +394,14 @@ async def regenerate_preset_prompt_tarball( # leaving the old prompt baked into the tarball. return None - new_tarball = _replace_prompt_in_tarball(current_tarball, new_prompt) + runner_files = None + if refresh_runner: + kind = (automation.preset_metadata or {}).get("preset_type") + if kind == "prompt": + runner_files = _load_prompt_preset_files() + elif kind == "plugin": + runner_files = _load_plugin_preset_files() + new_tarball = _replace_prompt_in_tarball(current_tarball, new_prompt, runner_files) if new_tarball is None: return None @@ -481,7 +509,12 @@ async def create_automation_from_prompt( response.status_code = status.HTTP_200_OK return AutomationResponse.model_validate(existing) - 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) + ) # 1. Generate tarball with SDK code, prompt, and optional repos config tarball_content = _generate_tarball(body.prompt, repos=body.repos) @@ -545,6 +578,7 @@ async def create_automation_from_prompt( prompt=body.prompt, preset_metadata=preset_metadata, model=model, + agent_profile_id=body.agent_profile_id, trigger=body.trigger.model_dump(), tarball_path=tarball_path, setup_script_path="setup.sh", @@ -628,6 +662,8 @@ class CreatePluginAutomationRequest(BaseModel): model_config = ConfigDict(extra="forbid") + agent_profile_id: uuid.UUID | None = None + name: str = Field(..., min_length=1, max_length=500) plugins: list[PluginSource] | None = Field( default=None, @@ -891,7 +927,16 @@ async def create_automation_from_plugin( response.status_code = status.HTTP_200_OK return AutomationResponse.model_validate(existing) - 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) + ) + if body.agent_profile_id and body.variants: + raise HTTPException( + 422, "Agent profiles cannot be combined with model experiment variants" + ) variants = _resolve_experiment_variant_models( body.variants, user, default_model=model ) @@ -978,6 +1023,7 @@ async def create_automation_from_plugin( prompt=body.prompt, preset_metadata=preset_metadata, model=model, + agent_profile_id=body.agent_profile_id, trigger=body.trigger.model_dump(), tarball_path=tarball_path, setup_script_path="setup.sh", diff --git a/openhands/automation/presets/plugin/sdk_main.py b/openhands/automation/presets/plugin/sdk_main.py index ac2aaa2c..f9a9f32c 100644 --- a/openhands/automation/presets/plugin/sdk_main.py +++ b/openhands/automation/presets/plugin/sdk_main.py @@ -74,6 +74,8 @@ import uuid from datetime import datetime, timezone + + # Detect execution mode based on AGENT_SERVER_URL presence agent_server_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") IS_LOCAL_MODE = bool(agent_server_url) @@ -184,10 +186,12 @@ def _phase_poster() -> None: # SDK imports (before workspace context so import errors are caught) -from openhands.sdk import Conversation, RemoteConversation from finish_tool_hook import finish_tool_required_hook_config + +from openhands.sdk import Conversation, RemoteConversation from openhands.tools.preset import TaskOutcome + try: from openhands.sdk.mcp.config import coerce_mcp_config as _coerce_mcp_config except ImportError: @@ -217,7 +221,6 @@ def _normalize_mcp_config(raw_mcp_config): return raw_mcp_config - def _build_conversation_title(event_context) -> str | None: """Build a descriptive conversation title from the automation event context. @@ -301,6 +304,7 @@ def _build_conversation_title(event_context) -> str | None: # -- All remaining setup happens inside the workspace context -- # This ensures failures trigger the __exit__ callback report_phase("Setting up workspace") + has_provisioned_conversation = bool(os.environ.get("AUTOMATION_AGENT_PROFILE_ID")) # Parse event payload if present (for event-triggered automations) event_context = None @@ -317,12 +321,19 @@ def _build_conversation_title(event_context) -> str | None: REPOS_CONFIG_FILE = os.path.join(SCRIPT_DIR, "repos_config.json") clone_result = None repo_dirs = [] + profile_repos_context = "" if os.path.exists(REPOS_CONFIG_FILE): print("\n=== CLONE REPOS ===") with open(REPOS_CONFIG_FILE) as f: repos_config = json.load(f) - if repos_config: + if repos_config and has_provisioned_conversation: + profile_repos_context = ( + "Check out these repositories in your workspace using only the credentials " + "available to your agent profile, then follow their repository guidance:\n" + + json.dumps(repos_config) + ) + elif repos_config: report_phase("Cloning repositories") clone_result = workspace.clone_repos(repos_config) print(f" cloned {clone_result.success_count}/{len(repos_config)} repos") @@ -335,13 +346,15 @@ def _build_conversation_title(event_context) -> str | None: # If repos were cloned, project skills are loaded from EACH cloned repo print("\n=== LOAD SKILLS ===") report_phase("Loading skills") - loaded_skills, agent_context = workspace.load_skills_from_agent_server( - project_dirs=repo_dirs if repo_dirs else None - ) + loaded_skills, agent_context = [], None + if not has_provisioned_conversation: + loaded_skills, agent_context = workspace.load_skills_from_agent_server( + project_dirs=repo_dirs if repo_dirs else None + ) print(f" loaded {len(loaded_skills)} skills") # Get repos context (mapping of URLs to local paths) - repos_context = "" + repos_context = profile_repos_context if clone_result and clone_result.repo_mappings: repos_context = workspace.get_repos_context(clone_result.repo_mappings) @@ -400,9 +413,7 @@ def _build_conversation_title(event_context) -> str | None: # the service could not deliver them as turns. They open the conversation # with this one instead of each starting a run of its own. if event_context and event_context.get("follow_up_turns"): - follow_ups = "\n\n".join( - str(turn) for turn in event_context["follow_up_turns"] - ) + follow_ups = "\n\n".join(str(turn) for turn in event_context["follow_up_turns"]) context_sections.append(f"""## Follow-up messages More activity arrived on the same subject while this run was queued: @@ -428,70 +439,76 @@ def _build_conversation_title(event_context) -> str | None: path_str = f" ({ps.repo_path})" if ps.repo_path else "" print(f" - {ps.source}{ref_str}{path_str}") - # Get LLM config via workspace/profile APIs - print("\n=== GET_LLM ===") - try: - llm = workspace.get_llm(profile_name=model_profile) - except FileNotFoundError: - if not model_profile: - raise - print( - f" profile {model_profile!r} not found; " - "falling back to active/default profile" + if has_provisioned_conversation: + # The server already resolved the model, tools, skills, MCP, and secrets. + # Attaching must not reload defaults or forward the host's secret store. + agent = None + secrets = {} + else: + # Get LLM config via workspace/profile APIs + print("\n=== GET_LLM ===") + try: + llm = workspace.get_llm(profile_name=model_profile) + except FileNotFoundError: + if not model_profile: + raise + print( + f" profile {model_profile!r} not found; " + "falling back to active/default profile" + ) + llm = workspace.get_llm() + print(f" profile: {model_profile or 'DEFAULT'}") + print(f" model: {llm.model}") + print(f" api_key present: {bool(llm.api_key)}") + + # Get secrets via workspace + print("\n=== GET_SECRETS ===") + secrets = {} + try: + secrets = workspace.get_secrets() + print(f" available: {list(secrets.keys()) or '(none)'}") + except Exception as e: + # Not a hard failure — user may not have secrets configured + print(f" get_secrets() failed (ok if no secrets): {e}") + + # Get MCP config via workspace + print("\n=== GET_MCP_CONFIG ===") + mcp_config = {} + try: + mcp_config = _normalize_mcp_config(workspace.get_mcp_config()) + if mcp_config: + print(f" servers: {list(mcp_config.keys())}") + else: + print(" no MCP servers configured") + except Exception as e: + # Not a hard failure — user may not have MCP configured + print(f" get_mcp_config() failed (ok if no MCP): {e}") + + # Get default agent with tools and condenser (CLI mode to disable browser) + print("\n=== AGENT ===") + report_phase("Configuring agent") + # Keep finish-tool schema wiring in sync with presets/prompt/sdk_main.py. + agent = get_default_agent( + llm=llm, + cli_mode=True, + finish_tool_response_schema=TaskOutcome, ) - llm = workspace.get_llm() - print(f" profile: {model_profile or 'DEFAULT'}") - print(f" model: {llm.model}") - print(f" api_key present: {bool(llm.api_key)}") - - # Get secrets via workspace - print("\n=== GET_SECRETS ===") - secrets = {} - try: - secrets = workspace.get_secrets() - print(f" available: {list(secrets.keys()) or '(none)'}") - except Exception as e: - # Not a hard failure — user may not have secrets configured - print(f" get_secrets() failed (ok if no secrets): {e}") - - # Get MCP config via workspace - print("\n=== GET_MCP_CONFIG ===") - mcp_config = {} - try: - mcp_config = _normalize_mcp_config(workspace.get_mcp_config()) - if mcp_config: - print(f" servers: {list(mcp_config.keys())}") - else: - print(" no MCP servers configured") - except Exception as e: - # Not a hard failure — user may not have MCP configured - print(f" get_mcp_config() failed (ok if no MCP): {e}") - - # Get default agent with tools and condenser (CLI mode to disable browser) - print("\n=== AGENT ===") - report_phase("Configuring agent") - # Keep finish-tool schema wiring in sync with presets/prompt/sdk_main.py. - agent = get_default_agent( - llm=llm, - cli_mode=True, - finish_tool_response_schema=TaskOutcome, - ) - # Add MCP config and agent_context using model_copy if configured - # (Plugin MCP configs will be merged when plugins are loaded) - agent_updates = {} - if mcp_config: - agent_updates["mcp_config"] = mcp_config - if agent_context: - agent_updates["agent_context"] = agent_context - if agent_updates: - agent = agent.model_copy(update=agent_updates) - - print(f" tools: {[t.name for t in agent.tools]}") - print(f" mcp_config: {'configured' if mcp_config else 'none'}") - print(f" skills: {len(loaded_skills) if loaded_skills else 0}") - condenser_name = type(agent.condenser).__name__ if agent.condenser else "none" - print(f" condenser: {condenser_name}") + # Add MCP config and agent_context using model_copy if configured + # (Plugin MCP configs will be merged when plugins are loaded) + agent_updates = {} + if mcp_config: + agent_updates["mcp_config"] = mcp_config + if agent_context: + agent_updates["agent_context"] = agent_context + if agent_updates: + agent = agent.model_copy(update=agent_updates) + + print(f" tools: {[t.name for t in agent.tools]}") + print(f" mcp_config: {'configured' if mcp_config else 'none'}") + print(f" skills: {len(loaded_skills) if loaded_skills else 0}") + condenser_name = type(agent.condenser).__name__ if agent.condenser else "none" + print(f" condenser: {condenser_name}") # Create conversation with plugins print("\n=== CONVERSATION ===") @@ -554,7 +571,14 @@ def event_callback(event) -> None: automation_conversation_id = os.environ.get("AUTOMATION_CONVERSATION_ID") if automation_conversation_id: conversation_kwargs["conversation_id"] = uuid.UUID(automation_conversation_id) - conversation = Conversation(**conversation_kwargs) + if has_provisioned_conversation: + conversation = RemoteConversation.attach( + workspace=workspace, + conversation_id=uuid.UUID(os.environ["AUTOMATION_CONVERSATION_ID"]), + callbacks=[event_callback], + ) + else: + conversation = Conversation(**conversation_kwargs) assert isinstance(conversation, RemoteConversation) print(f" conversation created: {type(conversation).__name__}") print(f" plugins loaded: {len(plugin_sources)}") @@ -567,11 +591,7 @@ def event_callback(event) -> None: conversation_title = _build_conversation_title(event_context) if conversation_title: try: - resp = workspace.client.patch( - f"/api/conversations/{conversation.id}", - json={"title": conversation_title}, - ) - resp.raise_for_status() + conversation.set_title(conversation_title) print(f" title: {conversation_title}") except Exception as e: # Not a hard failure — autotitle fallback still applies diff --git a/openhands/automation/presets/prompt/sdk_main.py b/openhands/automation/presets/prompt/sdk_main.py index 20ac68da..71f9d920 100644 --- a/openhands/automation/presets/prompt/sdk_main.py +++ b/openhands/automation/presets/prompt/sdk_main.py @@ -77,6 +77,8 @@ import uuid from datetime import datetime, timezone + + # Detect execution mode based on AGENT_SERVER_URL presence agent_server_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") IS_LOCAL_MODE = bool(agent_server_url) @@ -188,10 +190,12 @@ def _phase_poster() -> None: # SDK imports (before workspace context so import errors are caught) -from openhands.sdk import Conversation, RemoteConversation from finish_tool_hook import finish_tool_required_hook_config + +from openhands.sdk import Conversation, RemoteConversation from openhands.tools.preset import TaskOutcome + try: from openhands.sdk.mcp.config import coerce_mcp_config as _coerce_mcp_config except ImportError: @@ -220,7 +224,6 @@ def _normalize_mcp_config(raw_mcp_config): return raw_mcp_config - def _build_conversation_title(event_context) -> str | None: """Build a descriptive conversation title from the automation event context. @@ -308,6 +311,7 @@ def _build_conversation_title(event_context) -> str | None: # -- All remaining setup happens inside the workspace context -- # This ensures failures trigger the __exit__ callback report_phase("Setting up workspace") + has_provisioned_conversation = bool(os.environ.get("AUTOMATION_AGENT_PROFILE_ID")) # Parse event payload if present (for event-triggered automations) event_context = None @@ -324,12 +328,19 @@ def _build_conversation_title(event_context) -> str | None: REPOS_CONFIG_FILE = os.path.join(SCRIPT_DIR, "repos_config.json") clone_result = None repo_dirs = [] + profile_repos_context = "" if os.path.exists(REPOS_CONFIG_FILE): print("\n=== CLONE REPOS ===") with open(REPOS_CONFIG_FILE) as f: repos_config = json.load(f) - if repos_config: + if repos_config and has_provisioned_conversation: + profile_repos_context = ( + "Check out these repositories in your workspace using only the credentials " + "available to your agent profile, then follow their repository guidance:\n" + + json.dumps(repos_config) + ) + elif repos_config: report_phase("Cloning repositories") clone_result = workspace.clone_repos(repos_config) print(f" cloned {clone_result.success_count}/{len(repos_config)} repos") @@ -342,13 +353,15 @@ def _build_conversation_title(event_context) -> str | None: # If repos were cloned, project skills are loaded from EACH cloned repo print("\n=== LOAD SKILLS ===") report_phase("Loading skills") - loaded_skills, agent_context = workspace.load_skills_from_agent_server( - project_dirs=repo_dirs if repo_dirs else None - ) + loaded_skills, agent_context = [], None + if not has_provisioned_conversation: + loaded_skills, agent_context = workspace.load_skills_from_agent_server( + project_dirs=repo_dirs if repo_dirs else None + ) print(f" loaded {len(loaded_skills)} skills") # Get repos context (mapping of URLs to local paths) - repos_context = "" + repos_context = profile_repos_context if clone_result and clone_result.repo_mappings: repos_context = workspace.get_repos_context(clone_result.repo_mappings) @@ -379,9 +392,7 @@ def _build_conversation_title(event_context) -> str | None: # the service could not deliver them as turns. They open the conversation # with this one instead of each starting a run of its own. if event_context and event_context.get("follow_up_turns"): - follow_ups = "\n\n".join( - str(turn) for turn in event_context["follow_up_turns"] - ) + follow_ups = "\n\n".join(str(turn) for turn in event_context["follow_up_turns"]) context_sections.append(f"""## Follow-up messages More activity arrived on the same subject while this run was queued: @@ -397,69 +408,75 @@ def _build_conversation_title(event_context) -> str | None: {USER_PROMPT}""" - # Get LLM config via workspace/profile APIs - print("\n=== GET_LLM ===") - try: - llm = workspace.get_llm(profile_name=model_profile) - except FileNotFoundError: - if not model_profile: - raise - print( - f" profile {model_profile!r} not found; " - "falling back to active/default profile" + if has_provisioned_conversation: + # The server already resolved the model, tools, skills, MCP, and secrets. + # Attaching must not reload defaults or forward the host's secret store. + agent = None + secrets = {} + else: + # Get LLM config via workspace/profile APIs + print("\n=== GET_LLM ===") + try: + llm = workspace.get_llm(profile_name=model_profile) + except FileNotFoundError: + if not model_profile: + raise + print( + f" profile {model_profile!r} not found; " + "falling back to active/default profile" + ) + llm = workspace.get_llm() + print(f" profile: {model_profile or 'DEFAULT'}") + print(f" model: {llm.model}") + print(f" api_key present: {bool(llm.api_key)}") + + # Get secrets via workspace + print("\n=== GET_SECRETS ===") + secrets = {} + try: + secrets = workspace.get_secrets() + print(f" available: {list(secrets.keys()) or '(none)'}") + except Exception as e: + # Not a hard failure — user may not have secrets configured + print(f" get_secrets() failed (ok if no secrets): {e}") + + # Get MCP config via workspace + print("\n=== GET_MCP_CONFIG ===") + mcp_config = {} + try: + mcp_config = _normalize_mcp_config(workspace.get_mcp_config()) + if mcp_config: + print(f" servers: {list(mcp_config.keys())}") + else: + print(" no MCP servers configured") + except Exception as e: + # Not a hard failure — user may not have MCP configured + print(f" get_mcp_config() failed (ok if no MCP): {e}") + + # Get default agent with tools and condenser (CLI mode to disable browser) + print("\n=== AGENT ===") + report_phase("Configuring agent") + # Keep finish-tool schema wiring in sync with presets/plugin/sdk_main.py. + agent = get_default_agent( + llm=llm, + cli_mode=True, + finish_tool_response_schema=TaskOutcome, ) - llm = workspace.get_llm() - print(f" profile: {model_profile or 'DEFAULT'}") - print(f" model: {llm.model}") - print(f" api_key present: {bool(llm.api_key)}") - - # Get secrets via workspace - print("\n=== GET_SECRETS ===") - secrets = {} - try: - secrets = workspace.get_secrets() - print(f" available: {list(secrets.keys()) or '(none)'}") - except Exception as e: - # Not a hard failure — user may not have secrets configured - print(f" get_secrets() failed (ok if no secrets): {e}") - - # Get MCP config via workspace - print("\n=== GET_MCP_CONFIG ===") - mcp_config = {} - try: - mcp_config = _normalize_mcp_config(workspace.get_mcp_config()) - if mcp_config: - print(f" servers: {list(mcp_config.keys())}") - else: - print(" no MCP servers configured") - except Exception as e: - # Not a hard failure — user may not have MCP configured - print(f" get_mcp_config() failed (ok if no MCP): {e}") - - # Get default agent with tools and condenser (CLI mode to disable browser) - print("\n=== AGENT ===") - report_phase("Configuring agent") - # Keep finish-tool schema wiring in sync with presets/plugin/sdk_main.py. - agent = get_default_agent( - llm=llm, - cli_mode=True, - finish_tool_response_schema=TaskOutcome, - ) - # Add MCP config and agent_context using model_copy if configured - agent_updates = {} - if mcp_config: - agent_updates["mcp_config"] = mcp_config - if agent_context: - agent_updates["agent_context"] = agent_context - if agent_updates: - agent = agent.model_copy(update=agent_updates) - - print(f" tools: {[t.name for t in agent.tools]}") - print(f" mcp_config: {'configured' if mcp_config else 'none'}") - print(f" skills: {len(loaded_skills) if loaded_skills else 0}") - condenser_name = type(agent.condenser).__name__ if agent.condenser else "none" - print(f" condenser: {condenser_name}") + # Add MCP config and agent_context using model_copy if configured + agent_updates = {} + if mcp_config: + agent_updates["mcp_config"] = mcp_config + if agent_context: + agent_updates["agent_context"] = agent_context + if agent_updates: + agent = agent.model_copy(update=agent_updates) + + print(f" tools: {[t.name for t in agent.tools]}") + print(f" mcp_config: {'configured' if mcp_config else 'none'}") + print(f" skills: {len(loaded_skills) if loaded_skills else 0}") + condenser_name = type(agent.condenser).__name__ if agent.condenser else "none" + print(f" condenser: {condenser_name}") # Create conversation print("\n=== CONVERSATION ===") @@ -507,7 +524,14 @@ def event_callback(event) -> None: automation_conversation_id = os.environ.get("AUTOMATION_CONVERSATION_ID") if automation_conversation_id: conversation_kwargs["conversation_id"] = uuid.UUID(automation_conversation_id) - conversation = Conversation(**conversation_kwargs) + if has_provisioned_conversation: + conversation = RemoteConversation.attach( + workspace=workspace, + conversation_id=uuid.UUID(os.environ["AUTOMATION_CONVERSATION_ID"]), + callbacks=[event_callback], + ) + else: + conversation = Conversation(**conversation_kwargs) assert isinstance(conversation, RemoteConversation) print(f" conversation created: {type(conversation).__name__}") @@ -517,11 +541,7 @@ def event_callback(event) -> None: conversation_title = _build_conversation_title(event_context) if conversation_title: try: - resp = workspace.client.patch( - f"/api/conversations/{conversation.id}", - json={"title": conversation_title}, - ) - resp.raise_for_status() + conversation.set_title(conversation_title) print(f" title: {conversation_title}") except Exception as e: # Not a hard failure — autotitle fallback still applies diff --git a/openhands/automation/router.py b/openhands/automation/router.py index 9ad93b3d..4a0d75e1 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,10 +320,19 @@ 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 + profile_changed = ( + "agent_profile_id" in update_data + and auto.agent_profile_id != update_data["agent_profile_id"] + ) for field, value in update_data.items(): setattr(auto, field, value) @@ -323,13 +341,12 @@ async def update_automation( # changes, rebuild the tarball so the next dispatch runs the new prompt # instead of the original baked one. Skipped when the value is unchanged (a # no-op edit), or for non-preset automations. - if ( - "prompt" in update_data - and isinstance(auto.prompt, str) - and auto.prompt != original_prompt + if isinstance(auto.prompt, str) and ( + ("prompt" in update_data and auto.prompt != original_prompt) + or (profile_changed and auto.preset_metadata) ): new_tarball_path = await regenerate_preset_prompt_tarball( - auto, auto.prompt, session, background_tasks + auto, auto.prompt, session, background_tasks, refresh_runner=profile_changed ) if new_tarball_path is not None: auto.tarball_path = new_tarball_path @@ -903,9 +920,7 @@ async def cancel_run( properties={"trigger_source": "manual"}, ) - from openhands.automation.config import get_config - - if get_config().service.agent_profile: + if run.agent_profile_id: from openhands.automation.backends import get_backend # Release the transaction before waiting for Docker to stop. diff --git a/openhands/automation/schemas.py b/openhands/automation/schemas.py index efb9996e..827bc76f 100644 --- a/openhands/automation/schemas.py +++ b/openhands/automation/schemas.py @@ -423,6 +423,12 @@ 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="Selected agent profile; null uses the deployment default. " + "The profile owns agent settings and secret selection.", + ) + name: str = Field(..., min_length=1, max_length=500) model: str | None = Field( default=None, @@ -523,6 +529,12 @@ class UpdateAutomationRequest(BaseModel): model_config = ConfigDict(extra="forbid") + agent_profile_id: uuid.UUID | None = Field( + default=None, + description="Selected agent profile; null uses the deployment default. " + "The profile owns agent settings and secret selection.", + ) + name: str | None = Field(default=None, min_length=1, max_length=500) model: str | None = Field( default=None, @@ -862,6 +874,12 @@ class TelemetryConsentResponse(BaseModel): class AutomationResponse(BaseModel): + agent_profile_id: uuid.UUID | None = Field( + default=None, + description="Selected agent profile; null uses the deployment default. " + "The profile owns agent settings and secret selection.", + ) + id: uuid.UUID user_id: uuid.UUID org_id: uuid.UUID @@ -939,6 +957,12 @@ def normalize_phase(cls, v: Any) -> Any: class AutomationRunResponse(BaseModel): """Response for a single automation run.""" + agent_profile_id: uuid.UUID | None = Field( + default=None, + description="Selected agent profile; null uses the deployment default. " + "The profile owns agent settings and secret selection.", + ) + id: uuid.UUID automation_id: uuid.UUID status: RunStatus 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/openhands/automation/utils/run.py b/openhands/automation/utils/run.py index 7fe41731..e5459f57 100644 --- a/openhands/automation/utils/run.py +++ b/openhands/automation/utils/run.py @@ -181,6 +181,7 @@ async def create_pending_run( now = utcnow() run = AutomationRun( + agent_profile_id=automation.agent_profile_id, id=uuid.uuid4(), automation_id=automation.id, status=AutomationRunStatus.PENDING, diff --git a/openhands/automation/watchdog.py b/openhands/automation/watchdog.py index 9262bb7e..682ff1d1 100644 --- a/openhands/automation/watchdog.py +++ b/openhands/automation/watchdog.py @@ -175,7 +175,7 @@ def _should_cleanup_sandbox_after_terminal( the sandbox carrying a live conversation is already excluded here. """ return ( - bool(run.sandbox_id) or bool(get_config().service.agent_profile) + bool(run.sandbox_id) or bool(run.agent_profile_id) ) and keep_alive is not True @@ -574,7 +574,7 @@ async def mark_stale_runs( AutomationRun.bash_command_id.isnot(None) | (AutomationRun.timeout_at < now) ) - if settings.agent_profile + if settings.is_local_mode else AutomationRun.timeout_at < now ), ) diff --git a/tests/test_backends.py b/tests/test_backends.py index 8f48eb0e..c8d74aa3 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -455,6 +455,7 @@ def mock_run(self): """Create a mock AutomationRun.""" run = MagicMock() run.sandbox_id = "sandbox-123" + run.agent_profile_id = None return run def test_local_mode(self, monkeypatch, mock_run): diff --git a/tests/test_cancel_run.py b/tests/test_cancel_run.py index e548a79b..90003eba 100644 --- a/tests/test_cancel_run.py +++ b/tests/test_cancel_run.py @@ -169,7 +169,6 @@ async def test_cancel_conversation_run_without_cloud_id( from openhands.automation import backends from openhands.automation.config import clear_config_cache - monkeypatch.setenv("AUTOMATION_AGENT_PROFILE", str(uuid.uuid4())) clear_config_cache() backend = Mock( cleanup_after_verification=AsyncMock( @@ -183,6 +182,8 @@ async def test_cancel_conversation_run_without_cloud_id( _, run = await _create_automation_with_run( async_session, status=AutomationRunStatus.RUNNING ) + run.agent_profile_id = uuid.uuid4() + await async_session.commit() run_id = str(run.id) resp = await async_client.post(f"/api/automation/v1/runs/{run_id}/cancel") assert resp.status_code == 200 diff --git a/tests/test_conversation_backend.py b/tests/test_conversation_backend.py index e31827c1..1d732f8d 100644 --- a/tests/test_conversation_backend.py +++ b/tests/test_conversation_backend.py @@ -82,6 +82,7 @@ def respond(request): "AGENT_SERVER_URL", "SESSION_API_KEY", "AUTOMATION_CONVERSATION_ID", + "AUTOMATION_AGENT_PROFILE_ID", "WORKSPACE_BASE", } assert env["AUTOMATION_CONVERSATION_ID"] == conversation_id @@ -136,24 +137,61 @@ def respond(request): ) -def test_default_profile_selects_shared_backend( - monkeypatch, -): +@pytest.mark.parametrize("explicit", [False, True]) +def test_run_profile_selects_shared_backend(monkeypatch, explicit): from openhands.automation.backends import get_backend from openhands.automation.config import clear_config_cache - automation_id = uuid4() + selected = uuid4() monkeypatch.setenv("AUTOMATION_AGENT_SERVER_URL", "http://server") - monkeypatch.setenv("AUTOMATION_AGENT_PROFILE", "default-profile") clear_config_cache() try: - backend = get_backend(AutomationRun(id=uuid4(), automation_id=automation_id)) - assert type(backend) is ConversationAgentServerBackend - assert backend.agent_profile_id == "default-profile" + backend = get_backend( + AutomationRun( + id=uuid4(), + agent_profile_id=selected if explicit else None, + ) + ) + if explicit: + assert type(backend) is ConversationAgentServerBackend + assert backend.agent_profile_id == str(selected) + else: + from openhands.automation.backends.local import LocalAgentServerBackend + + assert type(backend) is LocalAgentServerBackend finally: clear_config_cache() +@pytest.mark.asyncio +async def test_failed_credential_handoff_releases_runtime( + sdk_http_transport, monkeypatch +): + backend = ConversationAgentServerBackend( + "http://server", + "host-key", + AutomationRun(id=uuid4(), automation=Automation(name="reviewer")), + ) + backend.agent_profile_id = str(uuid4()) + requests = [] + + def respond(request): + requests.append(request) + if request.url.path == "/server_info": + return httpx.Response(200, json={"conversation_runtime": "docker"}) + return httpx.Response(409 if request.url.path.endswith("credentials") else 200) + + monkeypatch.setattr(backend, "_create_conversation", lambda: None) + sdk_http_transport(respond) + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.HTTPStatusError): + await backend.get_execution_context(client) + assert requests[-1].method == "DELETE" + assert requests[-1].url.path.endswith("/runtime") + with pytest.raises(RuntimeError, match="not been provisioned"): + backend.build_env_vars() + + @pytest.mark.asyncio async def test_cleanup_uses_sdk_lifecycle_timeout(): # A runtime release can exceed HTTPX's implicit five-second client timeout. diff --git a/tests/test_db.py b/tests/test_db.py index 8df9f9cc..b7ed064f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -259,6 +259,10 @@ 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 run_columns + assert "agent_profile_id" in { + column["name"] for column in inspector.get_columns("automations") + } engine.dispose() finally: diff --git a/tests/test_git_sync.py b/tests/test_git_sync.py index 4b835121..57cb1582 100644 --- a/tests/test_git_sync.py +++ b/tests/test_git_sync.py @@ -1835,6 +1835,45 @@ async def test_yaml_only_edit_does_not_create_a_new_upload( uploads = (await session.execute(select(TarballUpload))).scalars().all() assert len(uploads) == upload_count_before + @pytest.mark.parametrize("model", [None, "explicit-model"]) + async def test_profile_import_uses_api_validation( + self, + sqlite_session_factory, + file_store, + git_settings, + service_settings, + origin, + model, + ): + automation_id = await _create_internal_automation( + sqlite_session_factory, file_store + ) + await run_sync_cycle( + sqlite_session_factory, LOCAL_ORG_ID, git_settings, service_settings + ) + selected = uuid.uuid4() + await self._push_yaml_edit( + origin, + "editor-profile", + "agent_profile_id: null", + f"agent_profile_id: {selected}", + ) + if model: + await self._push_yaml_edit( + origin, "editor-model", "model: null", f"model: {model}" + ) + + await run_sync_cycle( + sqlite_session_factory, LOCAL_ORG_ID, git_settings, service_settings + ) + async with sqlite_session_factory() as session: + automation = await session.get(Automation, automation_id) + assert automation.agent_profile_id == (None if model else selected) + assert automation.model is None + assert ( + len((await session.execute(select(TarballUpload))).scalars().all()) == 1 + ) + async def test_superseded_upload_is_soft_deleted_when_the_tarball_changes( self, sqlite_session_factory, file_store, git_settings, service_settings, origin ): diff --git a/tests/test_git_sync_serializer.py b/tests/test_git_sync_serializer.py index 9bcaab35..e62ad0f6 100644 --- a/tests/test_git_sync_serializer.py +++ b/tests/test_git_sync_serializer.py @@ -387,3 +387,11 @@ def test_repacking_is_stable_across_framings(self): {"b.py": (b"y", 0o755), "a.py": (b"x", 0o644)} ) assert canonical_tarball_bytes(first) == canonical_tarball_bytes(second) + + +def test_profile_reference_survives_git_round_trip(): + selected = uuid.uuid4() + automation = _make_automation(agent_profile_id=selected) + restored = deserialize_automation(serialize_automation(automation, None)) + assert restored is not None + assert restored.fields["agent_profile_id"] == str(selected) diff --git a/tests/test_router.py b/tests/test_router.py index c42f3fb4..f1e2bd60 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -312,6 +312,45 @@ 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_and_queued_run_snapshot( + self, async_client, async_session, local_mode + ): + from openhands.automation.utils.run import create_pending_run + + 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"] + automation = await async_session.get(Automation, uuid.UUID(data["id"])) + queued = await create_pending_run(async_session, automation) + await async_session.commit() + 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) + await async_session.refresh(queued) + assert queued.agent_profile_id == selected + 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 ): @@ -1200,6 +1239,48 @@ async def test_delete_automation_already_deleted(self, async_client, async_sessi class TestUpdateAutomation: + async def test_selecting_profile_refreshes_existing_preset_runner( + self, async_client, async_session, preset_store, local_mode + ): + automation = await _seed_prompt_preset_automation( + async_session, preset_store, "Keep this task" + ) + automation.preset_metadata = {"preset_type": "prompt"} + await async_session.commit() + upload_id = parse_internal_upload_id(automation.tarball_path) + assert upload_id is not None + old_path = _build_storage_path(TEST_ORG_ID, TEST_USER_ID, upload_id) + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for name, data in { + "main.py": b"legacy runner", + "prompt.txt": b"Keep this task", + "repos_config.json": b"[]", + }.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + preset_store._storage[old_path] = buffer.getvalue() + response = await async_client.patch( + f"/api/automation/v1/{automation.id}", + json={"agent_profile_id": str(uuid.uuid4())}, + ) + assert response.status_code == 200 + new_id = parse_internal_upload_id(response.json()["tarball_path"]) + assert new_id is not None and new_id != upload_id + new_path = _build_storage_path(TEST_ORG_ID, TEST_USER_ID, new_id) + with tarfile.open( + fileobj=io.BytesIO(preset_store._storage[new_path]), mode="r:gz" + ) as tar: + runner = tar.extractfile("main.py") + prompt = tar.extractfile("prompt.txt") + repos = tar.extractfile("repos_config.json") + assert runner and prompt and repos + assert b"conversation = RemoteConversation.attach(" in runner.read() + assert "agent_profile.py" not in tar.getnames() + assert prompt.read() == b"Keep this task" + assert repos.read() == b"[]" + """Tests for PATCH /v1/{id} endpoint.""" async def test_update_automation_name(self, async_client, async_session):