diff --git a/.pr/orchestration-agent.png b/.pr/orchestration-agent.png new file mode 100644 index 00000000..a46fb1ee Binary files /dev/null and b/.pr/orchestration-agent.png differ diff --git a/.pr/orchestration-evidence.md b/.pr/orchestration-evidence.md new file mode 100644 index 00000000..99b550ed --- /dev/null +++ b/.pr/orchestration-evidence.md @@ -0,0 +1,12 @@ +# Orchestration guide evidence + +September 13, 2026. Guide source: `5b5bf8a8`. + +The actual Mintlify page rendered with HTTP 200 and no browser errors. Both Python blocks were extracted unchanged from the guide and executed against an isolated Docker Agent Server containing the unreleased SDK client. The first uploaded a harmless archive and created `Scheduled automation`. A preceding user message configured the workflow to reply `DOCS_ORCHESTRATION_PASS` without tools, GitHub access, secrets, or file changes; it used `run=False`. The second block attached, ran the model, and printed that response. Canvas displayed the resulting conversation and response. + +![Rendered guide](https://raw.githubusercontent.com/OpenHands/docs/4b0e728244ae9cbcdd52e8b3b61dfe2cc22511b3/.pr/orchestration-guide.png) +![Documented example's actual Canvas response](https://raw.githubusercontent.com/OpenHands/docs/4b0e728244ae9cbcdd52e8b3b61dfe2cc22511b3/.pr/orchestration-agent.png) + +[Credential-free result](https://github.com/OpenHands/docs/blob/4b0e728244ae9cbcdd52e8b3b61dfe2cc22511b3/.pr/orchestration-live.json). The disposable runtime was released after capture. No application repository was modified. The example calls `register_default_tools()` before attachment; this run verifies a simple model response, not every registered tool. The source-built SDK satisfies unreleased prerequisites; this does not claim the current PyPI package contains these APIs. + +Reproduce by extracting the guide's Python blocks, supplying an isolated server URL/key and saved profile, calling `dispatch()` with a fixture archive, and executing the attachment block with the returned conversation ID/workspace. Capture the Canvas response before releasing its runtime. diff --git a/.pr/orchestration-guide.png b/.pr/orchestration-guide.png new file mode 100644 index 00000000..4500ea74 Binary files /dev/null and b/.pr/orchestration-guide.png differ diff --git a/.pr/orchestration-live.json b/.pr/orchestration-live.json new file mode 100644 index 00000000..f538cc0c --- /dev/null +++ b/.pr/orchestration-live.json @@ -0,0 +1,13 @@ +{ + "conversation_id": "c2f311e4-e42b-4adf-a793-3a9c3e5efa09", + "canvas_url": "http://127.0.0.1:9101/conversations/c2f311e4-e42b-4adf-a793-3a9c3e5efa09", + "source": "docs793 exact two Python blocks; configured benign workflow via preceding user message", + "working_dir": "/workspace", + "title": "Scheduled automation", + "dispatch_upload_pass": true, + "attachment_real_model_turn_pass": true, + "expected_response": "DOCS_ORCHESTRATION_PASS", + "registered_default_tools": true, + "application_repository_access": false, + "canvas_response_visible": true +} \ No newline at end of file diff --git a/docs.json b/docs.json index e5aef5e5..a7bd86ad 100644 --- a/docs.json +++ b/docs.json @@ -401,6 +401,7 @@ "group": "Remote Agent Server", "pages": [ "sdk/guides/agent-server/overview", + "sdk/guides/agent-server/orchestration-clients", "sdk/guides/agent-server/local-server", "sdk/guides/agent-server/conversation-goals", "sdk/guides/agent-server/docker-sandbox", @@ -853,4 +854,4 @@ "destination": "/openhands/usage/automations/overview" } ] -} \ No newline at end of file +} diff --git a/sdk/guides/agent-server/orchestration-clients.mdx b/sdk/guides/agent-server/orchestration-clients.mdx new file mode 100644 index 00000000..71e16a45 --- /dev/null +++ b/sdk/guides/agent-server/orchestration-clients.mdx @@ -0,0 +1,189 @@ +--- +title: Orchestrate Agent Server Conversations +description: Create conversations from agent profiles and control their workspaces with the existing SDK APIs. +--- + + +This guide accompanies [software-agent-sdk #5010](https://github.com/OpenHands/software-agent-sdk/pull/5010). +Use a build containing that change and the server's conversation-scoped runtime APIs. +Publish this guide with the corresponding SDK release. + + +Use `RemoteConversation.create` to create an agent conversation from a saved +profile and `RemoteConversation.attach` to connect to an existing conversation. +Use `RemoteWorkspace` for its files, commands, and runtime lifecycle. +`AsyncRemoteWorkspace` provides the same workspace operations for async callers. + +The SDK owns HTTP routes, authentication headers, and conversation scope. The +caller owns scheduling, admission limits, workflow selection, and completion +policy. Canvas uses the TypeScript client for the same API boundary. + +## Create a Conversation From a Saved Profile + +A dispatcher selects a unique conversation ID and workspace directory. Use +`/workspace` for a Docker conversation, or an absolute local run directory for a +local server. `workspace.get_server_info()["conversation_runtime"]` reports the +server's configured runtime mode. This selection belongs to the dispatcher; +the automation bundle receives the same environment in either mode. + +```python +import os +from contextlib import closing +from uuid import UUID, uuid4 + +from openhands.sdk import RemoteConversation, RemoteWorkspace +from openhands.sdk.conversation.request import StartConversationRequest +from openhands.sdk.workspace import LocalWorkspace +from openhands.tools import register_default_tools + +register_default_tools() +conversation_id = uuid4() +working_dir = os.environ["WORKSPACE_BASE"] + +with RemoteWorkspace( + host=os.environ["AGENT_SERVER_URL"], + api_key=os.environ["SESSION_API_KEY"], + working_dir=working_dir, + runtime_conversation_id=conversation_id, +) as workspace: + with closing(RemoteConversation.create( + workspace=workspace, + request=StartConversationRequest( + agent_profile_id=UUID(os.environ["AGENT_PROFILE_ID"]), + workspace=LocalWorkspace(working_dir=working_dir), + conversation_id=conversation_id, + tags={"automationrun": str(conversation_id)}, + ), + visualizer=None, + )) as conversation: + conversation.set_title("Scheduled automation") + workspace.file_upload(b"Prepared by the dispatcher\n", f"{working_dir}/input.txt") +``` + +`create` sends a typed creation request directly to the server. The server +resolves the saved profile. The dispatcher does not load its model, +tools, or secret values. `runtime_conversation_id` binds the workspace's existing +file, command, and Git operations to that conversation and cannot be changed +on the workspace instance. Omitting it selects the legacy host workspace; +runtime credential handoff and release require an explicit conversation scope. + +## Start and Observe a Bundle + +After uploading and extracting its bundle, a dispatcher starts the configured +entrypoint with `workspace.start_command(command, timeout=...)`. This returns a +command ID without waiting. `workspace.get_command_output(command_id)` returns +the latest matching output record; `None` or a missing exit code means the +command has not completed. Always inspect the actual command's exit code before +accepting completion. `workspace.execute_command(...)` remains the foreground +operation and returns a `CommandResult`. + +Async callers use the same scope and operations: + +```python +import os + +from openhands.sdk.workspace import AsyncRemoteWorkspace + + +async def start_worker(conversation_id, working_dir, command): + async with AsyncRemoteWorkspace( + host=os.environ["AGENT_SERVER_URL"], + api_key=os.environ["SESSION_API_KEY"], + working_dir=working_dir, + runtime_conversation_id=conversation_id, + ) as workspace: + return await workspace.start_command(command, timeout=2400) +``` + +## The Bundle Interface + +Supply `AGENT_SERVER_URL`, `SESSION_API_KEY`, `AUTOMATION_CONVERSATION_ID`, and +`WORKSPACE_BASE` to each bundle. The same code works in either workspace: + +```python +import os +from contextlib import closing +from uuid import UUID + +from openhands.tools import register_default_tools +from openhands.sdk import RemoteConversation, RemoteWorkspace +from openhands.sdk.conversation.response_utils import get_agent_final_response + +register_default_tools() + +with RemoteWorkspace( + host=os.environ["AGENT_SERVER_URL"], + api_key=os.environ["SESSION_API_KEY"], + working_dir=os.environ["WORKSPACE_BASE"], +) as workspace: + with closing(RemoteConversation.attach( + workspace=workspace, + conversation_id=UUID(os.environ["AUTOMATION_CONVERSATION_ID"]), + visualizer=None, + )) as conversation: + conversation.send_message("Perform the configured workflow.") + conversation.run(timeout=2400) + print(get_agent_final_response(conversation.state.events)) +``` + +`attach` fetches an existing conversation and connects to its server-resolved +agent, including ACP agents. A missing or inaccessible conversation fails; +attachment never creates a conversation or reloads its profile. Run completion, +callbacks, structured events, and errors follow the existing conversation lifecycle. Read `conversation.state.events` +for the event history. + +`delete_on_close` defaults to `False`, so closing this handle preserves the +server conversation and the caller-owned workspace. Register the remote agent's +tool schemas before connecting: `register_default_tools()` covers standard +tools, including browser events; register additional custom tools separately. + +## Select Secrets With an Agent Profile + +Profile secret selection requires the profile `secret_refs` and Docker integration +changes tracked in [SDK #5014](https://github.com/OpenHands/software-agent-sdk/issues/5014). +Keep this section unpublished until that integration is released. + +Servers with profile-secret enforcement advertise `profile_secret_scope_v1` in +`/server_info.capabilities`; clients should use that capability instead of a guessed +release version when offering a scope picker. + +Set `secret_refs` on the saved agent profile to an explicit list of secret names, +for example `["GITHUB_REVIEWER_TOKEN"]`. The profile stores names only. A launch +using that profile receives the selected saved secrets without the dispatcher +copying every secret into the request. An empty list permits no user secrets; +`null` preserves the existing request-only, unrestricted behavior. + +The Agent Server applies the same selection before local conversation construction +and before Docker secret materialization. Extra request secrets are discarded +before their lookup runs. For a selected name present in the server's secret store, +the stored value takes precedence over a caller-supplied value or lookup. Names +missing from the store can still match explicitly supplied conversation secrets. +The agent's secret registry advertises selected names and descriptions in the +system prompt and exports selected values to tools when needed. Values are never +included in the prompt. Docker handoff encrypts them with the runtime's key. + +LLM profile credentials and selected MCP credentials have their own delivery +channels. Restrict `mcp_server_refs`, tools, and other profile capabilities as well. +A secret allow-list does not narrow the external provider's token permissions: +issue repository-scoped credentials appropriate to each automation's role. + +## Credentials and Lifecycle Ownership + +Only a trusted orchestrator should call `workspace.get_runtime_session_key()`. On a Docker +server supporting credential handoff, it returns the selected inner runtime's key. +The dispatcher supplies that key and the runtime-reachable server URL to the +worker. Keep the outer control-plane key outside the worker. + +Local workspaces retain the local server's credential boundary and are not +security sandboxes. Use Docker isolation and role-scoped external-service grants +when running untrusted repository code. + +After a Docker run has stopped and its result is recorded, the dispatcher calls +`workspace.release_runtime()`. This releases that runtime while preserving conversation +history. A missing runtime is treated as already released; other failures are +reported. A persistent local server should be retained by its owner. + +Workspace context managers close their own HTTP connections. Closing a +conversation handle does not release its runtime or delete history unless +`delete_on_close=True` was explicitly selected. These SDK operations do not +choose a workflow, post an acceptance decision, or schedule another run. diff --git a/sdk/guides/agent-server/overview.mdx b/sdk/guides/agent-server/overview.mdx index 6e4c40e5..694d7129 100644 --- a/sdk/guides/agent-server/overview.mdx +++ b/sdk/guides/agent-server/overview.mdx @@ -35,6 +35,8 @@ with APIRemoteWorkspace( # [!code ++] ``` +For dispatcher and automation integrations, see [Orchestrate Agent Server Conversations](/sdk/guides/agent-server/orchestration-clients). + ## What is a Remote Agent Server? A Remote Agent Server is an HTTP/WebSocket server that: