Skip to content

feat: copy the run's agent profile into its cloud sandbox - #614

Open
simonrosenberg wants to merge 2 commits into
mainfrom
plugin-spec-dispatch-conversation
Open

simonrosenberg wants to merge 2 commits into
mainfrom
plugin-spec-dispatch-conversation

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Why

A plugin-spec conversation run starts with RemoteConversation.create(agent_profile_id=…) on its workspace's agent server. That works locally, where the agent server holds the user's profiles. On OHE/Cloud the profiles live on the app server and the run's new sandbox starts with an empty profile store, so the launch returns 404 Agent profile with id '…' not found (#600). Profile-based automations, and with them sub-agents (#557), have no working path on OHE. Custom scripts that start a conversation from the run's profile hit the same 404.

Summary

  • Cloud backend, seed_agent_profile. Once the sandbox is running, the service copies the automation's agent profile into the sandbox's agent server, together with what the profile references:
    • the profile: GET /api/agent-profiles and GET /api/agent-profiles/{name} on the app server, then POST /api/agent-profiles/{name} on the sandbox;
    • its llm_profile_ref, key included, from the app server's GET /api/v1/users/me?expose_secrets=true (which requires the sandbox's session key), then POST /api/profiles/{name}. This is what the app server's _seed_sandbox_profiles does when it starts a conversation itself;
    • the MCP servers named by mcp_server_refs (all of them when it's null, as the resolver does), via PATCH /api/settings.
  • Dispatcher. For every run with an agent profile, plugin-spec or not, it calls the backend's seed_agent_profile before the run's command starts and passes the returned id as AUTOMATION_AGENT_PROFILE_ID. The sandbox's agent server assigns its own id when it creates a profile, so the backend reads that id back.
  • Local backend. No change. The agent server already holds the profile, and the base method returns the id unchanged.
  • Stock runner. It keeps RemoteConversation.create, and now forwards the SDK's built-in sub-agent definitions (agent_definitions) in the start request. Agent-server images ship without them (Docker conversation runtime: built-in sub-agents are not registered (task tool fails with "none registered") software-agent-sdk#5380), so on OHE a profile with task_tool_set was offered task but every call failed with Unknown agent 'general-purpose'. Available types: none registered. Enterprise forwards the same definitions for the conversations it starts.

Issue Number

Fixes #601

Design

Before After
OHE/Cloud, run with an agent profile 404 at launch: the sandbox has no profiles the profile and what it references are in the sandbox before the run starts
Who resolves the profile on OHE nobody the sandbox's agent server, with the SDK's launch pipeline
Profile, its LLM profile or a referenced MCP server missing — run FAILED at dispatch (operation=seed_agent_profile), or the launch fails on the dangling reference. No silent fallback
Local mode, presets, runs without a profile unchanged unchanged

Why the service copies the profile, rather than the app server starting the conversation. This keeps RemoteConversation.create, the SDK's way to start a conversation, working in the sandbox for the stock runner and for custom scripts alike. A sandbox lives for one run, so the copy can't go stale. And because the sandbox's agent server resolves the profile itself, the profile's tools and secret_refs apply on OHE today, without waiting for OpenHands/enterprise#763.

Run state. A failed copy goes through the existing dispatch-failure path: release_context deletes the sandbox, then _fail records a status_detail classified by run_status_detail_from_exception. App-server calls retry 429s and re-mint the key on 401/403, using the backend's existing helpers.

Secrets. Unchanged: the runner still sends them with update_secrets. The sandbox's agent server applies the launched profile's secret_refs there too, so a profile only gets the secrets it names. One consequence, the same on main: a profile whose secret_refs doesn't name AUTOMATION_SESSION_URL doesn't get it.

Code: seed_agent_profile in backends/cloud.py and backends/base.py, step 6 in dispatcher.py, and the runner's start request in presets/plugin_spec/sdk_main.py.

How to Test

Unit tests. uv run python -m pytest tests/ on this branch, rebased on main 1.23.1: 2125 passed, 7 skipped. Pre-commit is clean. New tests:

  • TestCloudSeedAgentProfile in tests/test_backends.py: what's copied and where; mcp_server_refs null copies every server; an unknown profile or LLM profile writes nothing to the sandbox; local returns the id unchanged.
  • TestExecuteRunSeedsAgentProfile in tests/test_dispatcher.py: the command gets the sandbox's id; a failed copy fails the run before any command and releases the sandbox; a run without a profile copies nothing.
  • tests/test_plugin_specs.py: the generated runner forwards discover_builtin_agents() as agent_definitions.
  • Each was checked against a deliberately broken implementation: the env id not replaced, MCP refs ignored, no id read-back, the sandbox not released, and the runner without agent_definitions. In each case one test failed.

End-to-end, local mode. Native processes, no Docker: this branch stacked with #615, openhands-agent-server==1.56.0, a mock LLM, and the prompt-task plugin (OpenHands/extensions#799) on a profile with task_tool_set.

  • The run is COMPLETED, launched from the chosen profile.
  • Conversation tools: terminal, file_editor, task_tracker, task_tool_set. The model is offered task. A mock LLM doesn't exercise the delegation itself; the OHE run below does.
  • After deleting the profile, a dispatch is FAILED at preflight_plugin, with no command and no conversation.

End-to-end on OHE Replicated (fleet instance shared-8, 2026-10-20).

  • Stack: OHE 0.85.0 (enterprise 1.74.0, agent-server 1.56.0). Only the automation image is swapped, for feat: let users choose a plugin-spec automation's agent profile #615's build, which contains this PR. Test channel simon/qa-automation-plugin-spec-profiles.
  • User setup:
    • two MCP servers, qa-deepwiki and qa-unused;
    • two custom secrets, QA_SECRET_ALLOWED and QA_SECRET_BLOCKED;
    • an agent profile with tools terminal, file_editor, task_tracker, task_tool_set, the install's Default LLM profile, mcp_server_refs: [qa-deepwiki] and secret_refs: [QA_SECRET_ALLOWED].
  • Automation: prompt-task (feat(plugins): add prompt-task, the stock plugin for prompt automations extensions#799) with that profile, keep_alive: true so the sandbox could be read afterwards, dispatched manually. The prompt asks the agent to start one sub-agent that replies PONG.
Check Result
Run COMPLETED, conversation_id recorded; enterprise lists the conversation with all its events
Sandbox's agent profiles default plus the copied profile, under the sandbox's own id; the conversation launched from that id
Sandbox's LLM profiles Default, with its key: the conversation runs openhands/claude-sonnet-4-5-20250929
Sandbox's MCP servers qa-deepwiki only; the model was offered its 3 tools
Conversation tools exactly the profile's 4, no browser tools
Registered secrets QA_SECRET_ALLOWED only
task lists bash-runner, code-explorer, general-purpose; the model delegated to general-purpose, which answered PONG
Profile whose LLM profile doesn't exist FAILED at dispatch, operation=seed_agent_profile, AgentProfileSeedError; sandbox deleted, no conversation
Deleted profile FAILED at preflight_plugin, no sandbox

The first OHE build of this approach (without 669cddb) passed every row except task. Its description read "None: every registered agent uses tools this agent does not have", and both of the model's calls failed with Unknown agent 'general-purpose'. Available types: none registered. In that sandbox, POST /api/sub-agents with only built-ins requested returned an empty list. With the definitions forwarded in a start request, the same sandbox delegated and got PONG.

Notes


I'm an AI agent (Claude Code) helping @simonrosenberg.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Warning

Your comment is too long (maximum is 65536 characters), so the coverage report was not added. See the job log for how to reduce it.

@simonrosenberg

Copy link
Copy Markdown
Member Author

The cloud path checks out on Beta, so I've added the results under How to Test → Cloud path on Beta.

  • Enterprise starts the run's conversation in an automation-created sandbox.
  • launched_agent_profile matches the requested profile, the conversation has task_tool_set, and the plugin loads.
  • An unknown profile falls back with launched_agent_profile: null, which this PR's check fails on.
  • A reused conversation_id works, both in the same sandbox and in a second one.

Everything created on Beta has been deleted. Marking this ready for review.

I'm an AI agent (Claude Code) helping @simonrosenberg.

@simonrosenberg
simonrosenberg marked this pull request as ready for review October 20, 2026 08:36
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot October 20, 2026 08:56

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Summary

Moves plugin-spec conversation startup from the sandbox's own agent-server to the profile's owner at dispatch: a new ExecutionBackend.create_conversation (local POST /api/conversations, cloud POST /api/v1/app-conversations + start-task polling), a dispatcher step that fails the run when launched_agent_profile is not the requested profile, and a runner that attaches instead of creating. Fixes the OHE 404 in #601.

What I checked on head 3302d12

  • Scope belongs here: no cross-repo move; this is the automation service's own dispatch/runner path.
  • POST /api/conversations bodies built by the local backend validate against the pinned SDK's StartConversationRequest (checked directly with the workspace venv), including the subject-derived conversation_id, user_id, plugins (with repo_path), and observability fields.
  • Provenance read is sound: launched_agent_profile is projected onto ConversationInfo by the pinned agent-server's _compose_conversation_info, and the paired agent-server (the openhands-sdk==1.56.0 pin) exposes it. _check_launched_profile correctly treats a present-but-different or absent profile as a failure and does not classify it transient.
  • Error classification is grounded: 429 is retried (_retry), 401/403 re-mints (_with_auth_retry), other 4xx becomes a non-transient ConversationLaunchError with its status code, and 5xx/transport fall through to run_status_detail_from_exception. Failure path releases the context before _fail, consistent with the existing dispatch-failure contract.
  • Script runners are untouched (create_conversation is only reached for runner == "conversation"), and AUTOMATION_CONVERSATION_ID is still only set for such runs.
  • Tests: tests/test_backends.py (43 passed) and tests/test_plugin_specs.py pass locally. The new TestExecuteRunPluginSpecConversation cases need Postgres via testcontainers, which this environment cannot start (no Docker socket); GitHub Actions unit-tests and backend are green on the exact head.

Residual, explicitly deferred (not blocking this PR)

The runner still calls workspace.get_secrets(agent_profile_id=...) against the sandbox agent-server. On OHE the enterprise profile id is not in that server's store, so the call 404s and the runner injects no secrets; profile secret_refs/tool allowlists are not enforced there. This is unchanged behavior from the base and is acknowledged in the PR's Notes and in #601/#600 as pending OpenHands/enterprise#763. It is not over-exposure, so I am not treating it as a defect of this PR, but the corresponding #601 acceptance criterion is not met by this change alone.

Head is at the requested SHA, CI is green, and the change is focused and well tested. No material correctness, security, or compatibility defect found on the current head.

✅ APPROVED

Comment thread openhands/automation/backends/base.py Outdated


@dataclass(frozen=True)
class ConversationLaunch:

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.

can we be defined in the sdk layer so we can dedup potentially across the other services

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gone in the rework (5ddbf46). The service no longer starts the conversation, so ConversationLaunch and the backends' create_conversation are removed.


I'm an AI agent (Claude Code) helping @simonrosenberg.

Comment on lines +217 to +220
if not automation_conversation_id:
raise RuntimeError(
"Plugin-spec conversation runner requires AUTOMATION_CONVERSATION_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.

can we just generate one if its not provided, seems like better ux than hard failing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gone in the rework (5ddbf46). The runner uses RemoteConversation.create again, and AUTOMATION_CONVERSATION_ID is back to its meaning on main: set only for continue_conversation runs, and the runner creates the conversation either way.


I'm an AI agent (Claude Code) helping @simonrosenberg.

print(f" plugins loaded: {len(plugin_sources)}")
# attach() does not register the conversation, and the completion
# callback reports only a registered one.
workspace.register_conversation(str(conversation.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.

we intentionally don't manually register the conversation this way, the sdk layer should be responsible for doing it

out of curiosity, why aren't we using RemoteConversation.create? If this runs in sandbox in cloud, there should still be an agent server there to handle the conversation requests

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. The rework (5ddbf46) drops the manual call: the runner uses RemoteConversation.create again, which registers the conversation itself. Your question is answered in the cloud.py thread: the service now copies the profile into the sandbox instead.


I'm an AI agent (Claude Code) helping @simonrosenberg.

Comment thread openhands/automation/backends/cloud.py Outdated
if ctx.sandbox_id and ctx.api_url and ctx.api_key:
await delete_sandbox(client, ctx.api_url, ctx.api_key, ctx.sandbox_id)

async def create_conversation(

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.

Ah so the issue is that the agent server, when started in a remote sandbox, doesn't already contain the agent profiles. So we need to go through the app servers conversation creation flow, instead of interacting with the agent server directly

Seems like the fix may be to instantiate the agent server with the correct profiles from the start?

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.

The reason I say this is because RemoteConversation in the SDK is how we prescribe users start conversations, and having them reimplement this API in their custom scripts seems inconvenient

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I guess new profiles can be added after the agent server started, so that's a bit unclear.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ah actually this agent-server only lives during the automation run so no problem with user creating profiles

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, and it's simpler. An automation's sandbox lives for one run, so a profile copied in at dispatch can't go stale. Here's how I'd rework this PR:

  • Cloud backend, once the sandbox is ready: if the run has an agent profile, copy it into the sandbox's agent-server.
    • The profile: GET /api/agent-profiles/{name} on the app server, then POST /api/agent-profiles/{name} on the sandbox.
    • Its llm_profile_ref, with the key, from GET /api/v1/users/me?expose_secrets=true, then POST /api/profiles/{name}. This is what _seed_sandbox_profiles does when the app server starts a conversation.
    • The MCP servers in its mcp_server_refs, from the same response.
  • Runner: back to RemoteConversation.create(StartConversationRequest(agent_profile_id=…)). Custom scripts in the run can do the same.
  • Removed: the backends' create_conversation, ConversationLaunch, the dispatcher step, the provenance check and AUTOMATION_CONVERSATION_ID. This also settles your other three comments, since create() registers the conversation itself.
  • Local mode: unchanged from main; the profile is already in the agent-server.

One wrinkle: the sandbox's agent-server assigns a new id when it creates a profile. The service reads that id back and passes it as AUTOMATION_AGENT_PROFILE_ID.

Side effects:

Does this match what you had in mind?


I'm an AI agent (Claude Code) helping @simonrosenberg.

@malhotra5 malhotra5 left a comment

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.

Marking as request changes till comments are resolved, imho there is an alternate solution we can use

@simonrosenberg
simonrosenberg force-pushed the plugin-spec-dispatch-conversation branch from 3302d12 to 5ddbf46 Compare October 20, 2026 18:07
@simonrosenberg simonrosenberg changed the title feat: start plugin-spec conversations from the agent profile at dispatch feat: copy the run's agent profile into its cloud sandbox Oct 20, 2026
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot October 20, 2026 18:38
simonrosenberg and others added 2 commits October 20, 2026 23:02
On OHE/Cloud a run's sandbox starts with an empty agent-profile store, so
`RemoteConversation.create(agent_profile_id=...)` returns 404 there. Before
the run's command starts, the cloud backend now copies the automation's
agent profile from the app server into the sandbox's agent server, along
with the LLM profile and MCP servers it references, and passes the id the
agent server assigned as AUTOMATION_AGENT_PROFILE_ID. A profile that can't
be copied fails the run at dispatch. Local mode is unchanged.

The stock plugin-spec runner passes its secrets in the start request, so
the agent server applies the profile's secret_refs to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s conversation

The agent-server image ships without its built-in sub-agent definitions
(OpenHands/software-agent-sdk#5380), so on OHE a profile with task_tool_set
offered task but every call failed with "none registered". Forward them in
the start request, as enterprise does for the conversations it starts.

Also restore update_secrets for the run's secrets: the agent server applies
the launched profile's secret_refs there too, so moving them into the start
request changed nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonrosenberg
simonrosenberg force-pushed the plugin-spec-dispatch-conversation branch from 360cff9 to 669cddb Compare October 20, 2026 21:02
@all-hands-bot

Copy link
Copy Markdown
Contributor

⏳ Review waiting on checks

The current head 669cddba26ce still has checks that have not finished, so no review conversation was started:

  • unit-tests

No action is needed. The scheduled scan retries once every check on the head reports a conclusion.

This is an automated check - no AI was used to generate this comment.

@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot October 20, 2026 21:17
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot October 20, 2026 23:21

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Reviewed the live head 669cddba26ce (the PR title/body have been reworked since the review request in the trigger: the approach is now "the service copies the run's agent profile into the sandbox's agent server before the run starts", not "the service starts the conversation"). The change fits this repository: dispatch/scheduling and backend orchestration live here, and the runner-side change is a single start-request field.

What I verified against the workspace code and the pinned dependencies (openhands-sdk/openhands-workspace 1.56.0):

  • Secret scoping actually holds. The runner's get_secrets(agent_profile_id=...) still passes the parameter to an endpoint that ignores it, but the filtering that matters happens on the sandbox's agent server: event_service.update_secrets filters incoming secrets by stored.launched_agent_profile.allows_secret(...) at 1.56.0, and the request path that launches from agent_profile_id populates launched_agent_profile. AUTOMATION_SESSION_URL goes through the same filter. So a profile's secret_refs is enforced regardless of what the app-server secrets endpoint returns.
  • The resolved-LLM-key read is guarded. GET /api/v1/users/me?expose_secrets=true calls validate_session_key_ownership against the sandbox session key the backend sends, so the copy cannot read another user's key.
  • The app-server contract the new code depends on exists. /api/agent-profiles list items carry id/name, GET /api/agent-profiles/{name} returns the SDK AgentProfile body, and settings.agent_settings.mcp_config is the dict[str, MCPServer] shape that PATCH /api/settings (agent_settings_diff) accepts on the sandbox agent server.
  • The runner's agent_definitions is valid and idempotent. It is a field of StartConversationRequest at 1.56.0, is forwarded by RemoteConversation.create, and the server registers definitions with register_agent_if_absent, so re-sending the built-ins is a no-op once the image ships them. openhands-tools (which provides discover_builtin_agents) is installed by the preset setup.sh at the same version the runner is generated against.
  • Failure handling is on the existing dispatch path: release_context then _fail, so a refused copy leaves no sandbox and no command; the new tests assert exactly that (bash_command_id is None, operation=seed_agent_profile).
  • CI on this head: unit-tests, backend, Build and Push Automation Image, and the PR-title/description checks are all success. The new backend tests exercise real request/response paths via httpx.MockTransport rather than asserting on mocks of the code under test.

Non-blocking observation, demonstrated on this head: the new step rewrites only the env var, so env_vars["AUTOMATION_AGENT_PROFILE_ID"] becomes the sandbox's id while resolved_runtime["agent_profile_id"] (set in step 5 and persisted) keeps the app-server id. The runner reads only the env var, so launch behavior is correct, but the stored run record is inconsistent for anyone debugging a run.

No material correctness, security, compatibility, or acceptance-criteria defect found on this head.

✅ APPROVED

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Create plugin-spec conversations from the agent profile at dispatch (local and OHE)

3 participants