feat(agent-server): support deployment context on profile launches#4030
Conversation
…path Introduce AgentLaunchOverrides with a system_message_suffix_append field on StartConversationRequest. The server applies it to the resolved agent after agent/profile resolution, reusing the same seam that appends worktree guidance. This lets clients carry deployment-specific, client-computed enrichments (e.g. a <RUNTIME_SERVICES> block) onto the agent_profile_id launch path, which otherwise discards the client agent. Overrides are strictly additive (append-only, cannot swap the LLM or replace config) and apply uniformly to both launch paths. They are folded into the resolved agent at creation and excluded from persistence to avoid double application on resume. Fixes #4029 Co-authored-by: openhands <openhands@all-hands.dev>
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
||||||||||||||||||||||||||||||||||||||||
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ QA Report: PASS
Profile-launched conversations now carry client-supplied launch-time suffix additions through the real agent-server API, and the folded override does not persist as a separate request field.
Does this PR achieve its stated goal?
Yes. The PR set out to let clients send deployment-specific prompt enrichments on the agent_profile_id launch path; I verified this by creating real LLM and agent profiles through the API, then creating a conversation with agent_profile_id plus agent_launch_overrides. On origin/main, the same request created the conversation but the resolved agent suffix remained only PROFILE_BASELINE; on the PR branch, the resolved suffix became `PROFILE_BASELINE
<RUNTIME_SERVICES>..., confirming the enrichment survives profile resolution. I also restarted the API app and reloaded the conversation; the runtime block appeared exactly once and agent_launch_overrides` was absent from the response, matching the no-double-application goal.
| Phase | Result |
|---|---|
| Environment Setup | ✅ make build completed successfully with uv sync --dev and editable packages installed. |
| CI Status | ⏳ 23 successful, 6 pending, 1 skipped, 0 failing at review time. |
| Functional Verification | ✅ Real API requests exercised profile creation, profile-backed conversation launch, and reload behavior. |
Functional Verification
Test 1: Profile launch path carries runtime suffix after the PR
Step 1 — Reproduce / establish baseline without the fix:
Checked out origin/main and ran a FastAPI API scenario that:
POST /api/profiles/qa-llmPOST /api/agent-profiles/qa-agentwithsystem_message_suffix: PROFILE_BASELINEPOST /api/conversationswithagent_profile_idandagent_launch_overrides.system_message_suffix_appendGET /api/conversations/{id}and inspected the resolved agent context
Command:
git checkout --detach origin/main
QA_HOME=$(mktemp -d) QA_WS=$(mktemp -d) HOME="$QA_HOME" QA_LABEL=BASE OPENHANDS_SUPPRESS_BANNER=1 uv run python /tmp/qa_agent_launch_overrides.pyObserved output excerpt:
POST /api/profiles/qa-llm 201
POST /api/agent-profiles/qa-agent 201
BASE_HEAD 98f56248
POST /api/conversations 201
launched_agent_profile {'agent_profile_id': '7f6066f8-90bd-49b3-8fe6-707f87ebced6', 'revision': 0}
agent_launch_overrides_in_response <missing>
suffix_exact PROFILE_BASELINE
suffix_contains_runtime False
This confirms the old behavior: a client could include the launch-time field in the JSON payload, but the profile-resolved agent did not receive the runtime-services suffix.
Step 2 — Apply the PR's changes:
Checked out openhands/launch-time-agent-overrides at 29a30cd2.
Step 3 — Re-run with the fix in place:
Command:
git checkout openhands/launch-time-agent-overrides
QA_HOME=$(mktemp -d) QA_WS=$(mktemp -d) HOME="$QA_HOME" QA_LABEL=PR OPENHANDS_SUPPRESS_BANNER=1 uv run python /tmp/qa_agent_launch_overrides.pyObserved output excerpt:
POST /api/profiles/qa-llm 201
POST /api/agent-profiles/qa-agent 201
PR_HEAD 29a30cd2
POST /api/conversations 201
launched_agent_profile {'agent_profile_id': 'd607827e-14bd-44f1-ad0c-e3028c3b95c8', 'revision': 0}
agent_launch_overrides_in_response <missing>
suffix_exact PROFILE_BASELINE
<RUNTIME_SERVICES>backend=/api/automation</RUNTIME_SERVICES>
suffix_contains_runtime True
This confirms the PR behavior: the same profile-backed launch request now folds the client-owned runtime-services block into the resolved agent's agent_context.system_message_suffix.
Test 2: Folded override survives reload without double application
Step 1 — Establish the relevant prior state:
The PR description says overrides should be excluded from persistence after being folded into the resolved agent, avoiding double application on resume.
Step 2 — Apply the PR's changes:
Used the PR branch (29a30cd2) and created a conversation without an initial message so no LLM execution was needed.
Step 3 — Run create + reload verification:
Command:
QA_HOME=$(mktemp -d) QA_WS=$(mktemp -d) HOME="$QA_HOME" OPENHANDS_SUPPRESS_BANNER=1 uv run python - <<'PY'
# Creates API profiles, POSTs /api/conversations with agent_profile_id and
# agent_launch_overrides, then creates a new TestClient app and GETs the same
# conversation after startup reload.
PYObserved output excerpt:
create_status 201
created_execution_status idle
created_override_field <missing>
created_runtime_count 1
created_suffix PROFILE_BASELINE
<RUNTIME_SERVICES>backend=/api/automation</RUNTIME_SERVICES>
reload_status_present True
reloaded_execution_status idle
reloaded_override_field <missing>
reloaded_runtime_count 1
reloaded_suffix PROFILE_BASELINE
<RUNTIME_SERVICES>backend=/api/automation</RUNTIME_SERVICES>
This shows the override is persisted only as part of the resolved agent suffix, not as a separate request field, and reloading the conversation does not append it a second time.
Issues Found
None.
This QA review was created by an AI agent (OpenHands) on behalf of the user.
Final verdict: PASS
enyst
left a comment
There was a problem hiding this comment.
Are we sure this design is the best? Maybe we could consider first if we can detach all/most of the runtime config from the profile or even from the agent context?
|
@enyst — good call, I went and traced this end-to-end rather than just wiring the field, and I think you're right: runtime/deployment topology doesn't belong on What I found on the way: The two "obvious" clean fixes don't apply here:
The channel the framework actually wants isn't Proposed redesign (replaces the
(The Tradeoff to flag: this is a real SDK-core change (agent / prompt-registry / request / state) + a canvas change, versus the minimal PR here that's already green. I think it's worth it — it's the correct layering and it lets canvas drop the special-case. Before I rebuild: does conversation-scoped |
|
@OpenHands understand this PR’s goal and the discussion here in top comments. (clone yourself also smolpaws/smolpaws and look at the skills .agents/skills/ teach-me and show-me) I want to understand the alternative designs if we move runtime services out of agent context; and their implications. Where else could/should it be, how will it work then for prompt construction (also including at conversation restore time, not only new conversation) Make an explainer for this or these alternative designs, on enyst.github.io, using those skills as you see fit. Goal is think with me. Do not modify this PR. Push to main on enyst io the explainer. |
|
@enyst I traced this through current SDK + Canvas main, including create, persistence, cold restore, prompt rendering, plugins/hooks, and both tool-registration paths. My recommendation is: do not merge the generic The important lifecycle split is:
Why #4030 is the wrong boundary even though it works: the override field is excluded, but its arbitrary text is folded into I suggest replacing this PR's model with a narrow typed
For Alternatives are weaker:
This boundary gives profile portability and local/cloud parity, prevents deployment data from entering stored profiles, keeps resume deterministic, preserves API compatibility through optional additive fields, and uses the stricter existing collision rules for UI tools. Cost is higher than this green PR (SDK request/state/prompt section + Canvas migration), but it fixes the model rather than adding another generic escape hatch. |
Co-authored-by: openhands <openhands@all-hands.dev>
|
@enyst I rebuilt the PR around the detached boundary we discussed. The generic The request accepts constrained service facts rather than prompt prose (machine identifier, URL from the agent namespace, API/docs locations, optional auth header/env-var metadata), with validation against extra fields, duplicate/prompt-shaped names, multiline URLs, and invalid auth identifiers. Restore retains the stored context and existing I left Local verification: 268 selected profile/service/prompt/ACP/remote/OpenAPI tests pass; pre-commit formatting, lint, typing, import rules, and tool-registration checks pass. CI is now running on |
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
|
@enyst I simplified the implementation after weighing the migration cost more heavily. The final diff is now two production files plus one focused test file, not a new conversation-runtime subsystem. This keeps the important boundary—deployment data cannot be saved into a profile and the client cannot submit arbitrary system-prompt prose—while accepting the pragmatic compromise that the rendered launch snapshot lives in the conversation's resolved Canvas's Local verification: 160 profile/service/OpenAPI tests pass; pre-commit and Python API compatibility checks pass. Head: |
|
Canvas follow-up: OpenHands/agent-canvas#1650. It removes the local default-profile workaround, sends typed runtime services on profile launches, and uses client_tools for OpenHands canvas_ui. It is draft and explicitly depends on this SDK change. |
Co-authored-by: openhands <openhands@all-hands.dev>
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ QA Report: PASS
Profile launches now preserve runtime deployment context and runtime-default tools when exercised through the real agent-server API.
Does this PR achieve its stated goal?
Yes. The PR set out to let Canvas/profile launches use the real default or named Agent Profile while keeping runtime-owned defaults and launch-only deployment context. I verified that the same profile-launch request on main lost the suffix and did not include a dynamically registered canvas_ui runtime default, while the PR branch materialized both canvas_ui and the trimmed runtime suffix, persisted them exactly once, and kept explicit bare profiles bare.
| Phase | Result |
|---|---|
| Environment Setup | ✅ uv sync --dev completed; local agent-server started on 127.0.0.1:8765 |
| CI Status | ⏳ 29 checks successful, 2 in progress (qa-changes, Build & Push (java-arm64)), 12 skipped; no local test suite run |
| Functional Verification | ✅ Real HTTP profile-launch requests and SDK scripts verified the changed behavior |
Functional Verification
Test 1: Profile launch with Canvas-like runtime tool and deployment suffix
Step 1 — Reproduce / establish baseline without the fix:
Checked out main at 56ac3171, started the real server with --extra-python-path /tmp/qa-runtime-tools, saved a minimal default LLM profile through the API, then posted this Canvas-like request to POST /api/conversations:
{
"agent_profile_id": "<active default profile id>",
"workspace": {"kind": "LocalWorkspace", "working_dir": "/tmp/qa-main-workspace-custom"},
"autotitle": false,
"tool_module_qualnames": {"canvas_ui": "canvas_ui_tool"},
"agent_launch_additions": {
"system_message_suffix_append": "<RUNTIME_SERVICES>custom module</RUNTIME_SERVICES>"
}
}Observed on main:
HTTP=201
id= ccecfdbd-ed22-4fec-97c0-5fdc9b3c917d
status= idle
tools= ['terminal', 'file_editor', 'task_tracker', 'browser_tool_set']
suffix= None
detail= None
This confirms the old profile-launch path created a conversation but lost the launch-time suffix and did not include the dynamically supplied canvas_ui runtime-default tool.
Step 2 — Apply the PR's changes:
Checked out openhands/launch-time-agent-overrides at 30a50d81 and restarted the same local agent-server with the same custom tool module path.
Step 3 — Re-run with the fix in place:
Posted the equivalent request to POST /api/conversations on the PR branch and then fetched the conversation back with GET /api/conversations/{id}:
HTTP=201
id= 077b002f-ecfc-4f9a-a570-e228ae993779
status= idle
tools= ['terminal', 'file_editor', 'task_tracker', 'browser_tool_set', 'canvas_ui']
suffix_count= 1
suffix= <RUNTIME_SERVICES>
* QA runtime: http://localhost:18001
</RUNTIME_SERVICES>
launched= {'agent_profile_id': '45eca3b0-0ed8-44e5-9388-07ea1f052393', 'revision': 0}
has_agent_launch_additions= False
detail= None
Fetch after creation showed the materialized state persisted as expected:
HTTP=200
suffix_count= 1
tools= ['terminal', 'file_editor', 'task_tracker', 'browser_tool_set', 'canvas_ui']
has_agent_launch_additions= False
This shows the PR imports the runtime tool module before profile resolution, adds the runtime default to a default-tool profile, applies the deployment suffix after resolution, and stores the materialized agent without retaining the launch-only request field.
Test 2: SDK-visible launch additions, runtime defaults, and legacy default profile behavior
Step 1 — Baseline on main:
Ran a small SDK script that constructed a legacy default profile, attempted to import AgentLaunchAdditions, and attempted to query runtime-default tools after importing the custom canvas_ui_tool module:
legacy_default_tools= []
AgentLaunchAdditions= ImportError cannot import name 'AgentLaunchAdditions' from 'openhands.sdk.conversation.request'
runtime_defaults= ImportError cannot import name 'list_usable_runtime_default_tools' from 'openhands.sdk.tool.registry'
This establishes that the base branch had no launch-additions API, no runtime-default registry API, and left untouched revision-0 default profiles with tools=[].
Step 2 — Re-run on the PR branch:
Ran the same script on 30a50d81:
legacy_default_tools= None
AgentLaunchAdditions= {'system_message_suffix_append': 'ctx'}
runtime_defaults= ['canvas_ui']
This confirms the PR exposes the new launch-additions model, lets runtime tool implementations opt into the runtime-default set, and upgrades the legacy untouched default profile shape to default-tool semantics.
Test 3: Explicit bare profile remains authoritative
Created a named OpenHands profile through POST /api/agent-profiles/qa-bare with tools: [], then launched it with the same tool_module_qualnames that registered canvas_ui as a runtime default:
SAVE_HTTP=201
{"name": "qa-bare", "message": "Agent profile 'qa-bare' saved"}
CREATE_HTTP=201
id= 91b94724-e08d-40e3-b559-6d6ac81c3a9a
detail= None
tools= []
launched= {'agent_profile_id': 'dc416d62-1779-4ecd-a613-d06e06bf720d', 'revision': 0}
This verifies the PR preserves explicit tools: [] as a bare profile rather than injecting runtime defaults into every OpenHands profile.
Issues Found
None.
This review was posted by an AI agent (OpenHands) on behalf of the user.
Co-authored-by: openhands <openhands@all-hands.dev>
|
Architecture simplification in |
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
|
Migration follow-up in |
Co-authored-by: openhands <openhands@all-hands.dev>
|
Superseding the earlier extra_default_tools proposal: default membership now lives beside tool registration via register_tool(..., default=True). The agent-server composes canonical defaults, delegation/browser policy, and usable registered defaults; it has no Canvas-specific name or deployment tool list. Canvas only preloads its module. The cross-repo smoke test resolves the standard tools plus canvas_ui through this registration path. |
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ QA Report: PASS
Manual API QA confirms this PR achieves its goal: profile launches now preserve deployment launch context while keeping Canvas-owned client tools additive.
Does this PR achieve its stated goal?
Yes. I exercised the agent-server API as a Canvas-like client by launching Agent Profiles with agent_profile_id, client_tools, and agent_launch_additions.system_message_suffix_append. On main, the same launch returned an idle conversation with system_message_suffix: null; on the PR branch, the launched conversation contained the runtime-services suffix exactly once, preserved client_tools, excluded launch-only metadata from the response, survived server restart without duplicate suffixes, and kept tools: [] profiles exact.
| Phase | Result |
|---|---|
| Environment Setup | ✅ uv sync --dev completed and local agent-server ran on both branches |
| CI Status | 🟡 Relevant checks are green; qa-changes was still in progress when checked, integration/example/eval jobs were skipped |
| Functional Verification | ✅ Verified launch additions, client tools, persistence, named empty-tools profiles, and v1 profile migration via real HTTP requests |
Functional Verification
Test 1: Profile launch with deployment context and client tools
Step 1 — Reproduce / establish baseline without the fix:
Ran the base branch server and launched the default Agent Profile through the public API:
git checkout main
HOME=/tmp/oh-qa-base-home uv run agent-server --host 127.0.0.1 --port 8123
curl -X POST http://127.0.0.1:8123/api/conversations -H 'Content-Type: application/json' --data-binary @/tmp/base_launch_payload.jsonObserved summary:
{
"http": "201",
"id": "e481855f-0695-45ec-8736-d6e35a4b863b",
"status": "idle",
"suffix": null,
"runtime_marker_count": 0,
"tool_names": ["terminal", "file_editor", "task_tracker", "browser_tool_set", "canvas_ui_client"],
"client_tools": ["canvas_ui_client"],
"has_agent_launch_additions_field": false
}This establishes the prior behavior: the server accepted/created the conversation and preserved client_tools, but did not materialize the runtime-services launch context into the resolved agent suffix.
Step 2 — Apply the PR's changes:
Checked out openhands/launch-time-agent-overrides at a115652ebc7839fb0057c678f593a2dfa2bf82e4 and ran the same server/API flow on port 8124.
Step 3 — Re-run with the fix in place:
Ran:
git checkout openhands/launch-time-agent-overrides
HOME=/tmp/oh-qa-pr-home uv run agent-server --host 127.0.0.1 --port 8124
curl -X POST http://127.0.0.1:8124/api/conversations -H 'Content-Type: application/json' --data-binary @/tmp/pr_launch_payload.jsonObserved summary:
{
"http": "201",
"id": "89079752-7cd5-4a9a-9c3b-bfa6477c8e03",
"status": "idle",
"suffix": "<RUNTIME_SERVICES>
* Automation: http://localhost:18001
</RUNTIME_SERVICES>",
"runtime_marker_count": 1,
"tool_names": ["terminal", "file_editor", "task_tracker", "browser_tool_set", "canvas_ui_client"],
"client_tools": ["canvas_ui_client"],
"has_agent_launch_additions_field": false
}This confirms the PR behavior: the deployment suffix is applied after profile resolution, client tools remain additive, and launch-only agent_launch_additions is not exposed/persisted as request metadata.
Test 2: Named profile keeps existing suffix and exact empty tool list
Step 1 — Establish profile scenario:
Created a named Agent Profile through the API with system_message_suffix: "PROFILE_BASELINE", tools: [], and revision 3, then launched it with the same Canvas-style client_tools and runtime-services launch addition.
Step 2 — Apply the PR's changes:
This scenario was exercised on the PR branch because the behavior under test is the new launch-addition contract.
Step 3 — Run with the fix in place:
Ran:
curl -X POST http://127.0.0.1:8124/api/agent-profiles/canvas-empty -H 'Content-Type: application/json' --data-binary @/tmp/pr_named_profile_payload.json
curl -X POST http://127.0.0.1:8124/api/conversations -H 'Content-Type: application/json' --data-binary @/tmp/pr_named_launch_payload.jsonObserved summary:
{
"http": "201",
"id": "d7a07cde-9b52-412e-af7f-56a6aebb8e41",
"suffix": "PROFILE_BASELINE
<RUNTIME_SERVICES>
* Automation: http://localhost:18001
</RUNTIME_SERVICES>",
"runtime_marker_count": 1,
"tool_names": ["canvas_ui_client"],
"client_tools": ["canvas_ui_client"]
}This confirms the named-profile path appends deployment context after existing profile suffix text and preserves tools: [] as no profile tools while still adding the Canvas-owned client tool.
Test 3: Persisted materialized agent restores without duplicate suffix
Step 1 — Establish launched conversation:
Used conversation 89079752-7cd5-4a9a-9c3b-bfa6477c8e03 from Test 1, which already had one runtime-services marker.
Step 2 — Restart with the PR's persisted state:
Stopped and restarted the same PR server/home directory, then fetched the stored conversation.
Step 3 — Re-read after restore:
Ran:
curl http://127.0.0.1:8124/api/conversations/89079752-7cd5-4a9a-9c3b-bfa6477c8e03Observed summary:
{
"id": "89079752-7cd5-4a9a-9c3b-bfa6477c8e03",
"status": "idle",
"suffix": "<RUNTIME_SERVICES>
* Automation: http://localhost:18001
</RUNTIME_SERVICES>",
"runtime_marker_count": 1,
"tool_names": ["terminal", "file_editor", "task_tracker", "browser_tool_set", "canvas_ui_client"],
"client_tools": ["canvas_ui_client"]
}This confirms restore uses the materialized agent and does not reapply the launch suffix.
Test 4: Legacy default profile migration is narrow
Step 1 — Establish legacy persisted files:
Wrote schema-v1 profile files under the server profile store: an untouched default profile with revision: 0 and tools: [], and a separate user-created explicit-empty profile with tools: [].
Step 2 — Apply the PR's changes:
Restarted the PR branch server to load those persisted profile files.
Step 3 — Query through the public API:
Ran:
curl http://127.0.0.1:8124/api/agent-profiles/default
curl http://127.0.0.1:8124/api/agent-profiles/explicit-emptyObserved summary:
{
"default": {"http": "200", "schema_version": 2, "name": "default", "revision": 0, "tools": null},
"explicit-empty": {"http": "200", "schema_version": 2, "name": "explicit-empty", "revision": 0, "tools": []}
}This confirms the migration is narrow: only the untouched schema-v1 default profile changes tools: [] to null; an explicit empty user profile remains empty.
Issues Found
None.
This QA review was created by an AI agent (OpenHands) on behalf of the user.
HUMAN:
Allow Canvas to launch the real default or named Agent Profile without losing deployment context or Canvas-owned tools.
AGENT:
Why
Agent Canvas needs to launch the selected Agent Profile by ID while adding deployment-specific runtime-service instructions. Canvas-owned UI tools already have a dedicated transport in
client_tools, so the server only needs a narrow launch-context contract rather than a second default-tool system.Summary
agent_launch_additions.system_message_suffix_appendfor deployment context applied after inline-agent or Agent Profile resolution.client_toolspath; this PR adds no tool-registration defaults and no/server_info.default_toolsfield.tools: nulluses the standard OpenHands tool set, while any list is exact, includingtools: []for no profile tools.defaultfrom the legacytools: []seed totools: null; user-created profiles and explicit empty lists remain unchanged.ClientToolSpecto verify client tools remain additive after profile resolution and survive persistence.REST API contract changes
Lifecycle
client_toolsand deployment context.The stored Agent Profile is never modified, and resume does not reapply the launch suffix.
How to Test
178 passedacross the focused profile, launch-addition, router, and store suites.Type
Downstream
canvas_ui_controlthroughclient_toolsand uses launch additions only for runtime-service context.Fixes #4029.
This PR was updated by an AI agent on behalf of the user.
Agent Server images for this PR
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:a115652-pythonRun
All tags pushed for this build
About Multi-Architecture Support
a115652-python) is a multi-arch manifest supporting both amd64 and arm64a115652-python-amd64) are also available if needed