feat(profiles): persist ACP AgentProfiles alongside legacy LLM profiles#3433
feat(profiles): persist ACP AgentProfiles alongside legacy LLM profiles#3433simonrosenberg wants to merge 3 commits into
Conversation
Profiles can now be saved as either an OpenHands (LLM-only) profile or an ACP profile (acp_server / acp_model / command / args / env). This is the backend half of agent-canvas#669, which reframes the user-facing concept around a kind-discriminated AgentProfile and needs ACP profiles to exist to drive the in-conversation runtime-compatibility picker. Store (LLMProfileStore): - save_acp() / load_acp() persist and round-trip an ACPAgentSettings, with acp_env + bookkeeping llm secrets encrypted at rest when a cipher is set (reuses the existing AgentSettings secret serializers / validators). - profile_kind() reports "openhands" | "acp" by peeking agent_kind. - list_summaries() now tags each profile with kind and, for ACP, acp_server / acp_model (model mirrors acp_model for chip consumers). - OpenHands profiles stay bare-LLM files and load()->LLM is unchanged, so existing runtime callers (switch_profile, fallback, subagents) keep working; load() raises on an ACP profile rather than mis-parsing it. Router (/api/profiles): - ProfileInfo gains kind / acp_server / acp_model. - SaveProfileRequest accepts either the legacy `llm` payload (openhands) or a full `agent_settings` payload (acp); exactly one is required. - GET / activate are kind-aware: activating an ACP profile flips agent_kind and applies the saved ACP launch fields (including acp_env credentials); client-encrypted secrets are decrypted via the cipher context. Tests cover save/load/list/kind, legacy compat, get masking, activate, the exactly-one-payload guard, and an end-to-end cipher round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ QA Report: PASS
ACP AgentProfiles worked end-to-end through the real agent-server API, while legacy LLM profiles continued to save and list correctly.
Does this PR achieve its stated goal?
Yes. The PR set out to persist kind-discriminated ACP AgentProfiles alongside legacy LLM profiles, mask/encrypt secrets, expose ACP summary metadata, and activate ACP profiles into agent settings. I verified this by running the agent server and making real HTTP requests: base main rejects an ACP agent_settings save with 422, while this PR saves the same payload, lists it as kind: acp, masks secrets on GET, stores the env secret encrypted at rest, activates it into agent_kind: acp, and round-trips an API-returned encrypted secret into a second profile.
| Phase | Result |
|---|---|
| Environment Setup | ✅ make build completed successfully; no tests/linters/pre-commit hooks were run. |
| CI Status | 🟡 At QA time: 12 successful, 15 pending, 1 skipped, 0 failing checks. |
| Functional Verification | ✅ Real HTTP requests against running agent-server instances verified the changed profile behavior. |
Functional Verification
Test 1: ACP profile save behavior before vs after the PR
Step 1 — Establish baseline without the fix:
Checked out origin/main, started the agent server with an isolated HOME, then ran:
curl -sS -w '\\nHTTP_STATUS:%{http_code}\\n' -H 'Content-Type: application/json' -d '{"agent_settings":{"agent_kind":"acp","acp_server":"claude-code","acp_model":"claude-opus-4-7","acp_command":["npx","-y","@anthropic-ai/claude-code-acp"],"acp_args":[],"acp_env":{"ANTHROPIC_API_KEY":"<dummy-secret>"}}}' http://127.0.0.1:8133/api/profiles/local-claudeObserved:
{"detail":[{"type":"missing","loc":["body","llm"],"msg":"Field required", ...}]}
HTTP_STATUS:422This confirms the old API only accepted the legacy llm payload and could not save an ACP AgentProfile.
Step 2 — Apply the PR's changes:
Checked out feat-acp-agent-profiles at dc06e907b048b0dffdd76e469e44ff51c651ab26, started the agent server with isolated HOME and OH_SECRET_KEY.
Step 3 — Re-run with the fix in place:
Ran the same ACP save request against the PR server:
{"name":"local-claude","message":"Profile 'local-claude' saved"}
HTTP_STATUS:201This shows the new agent_settings ACP save path works.
Test 2: Listing, masking, encryption at rest, activation, and legacy compatibility
Against the PR server, I first saved a legacy LLM profile:
curl -H 'Content-Type: application/json' -d '{"llm":{"model":"openai/gpt-4o","api_key":"<dummy-secret>"}}' http://127.0.0.1:8134/api/profiles/legacy-gptObserved HTTP_STATUS:201, confirming backward-compatible legacy saves still work.
Then I listed profiles:
{
"profiles": [
{"name":"legacy-gpt","kind":"openhands","model":"openai/gpt-4o","acp_server":null,"acp_model":null,"api_key_set":true},
{"name":"local-claude","kind":"acp","model":"claude-opus-4-7","acp_server":"claude-code","acp_model":"claude-opus-4-7","api_key_set":true}
],
"active_profile": null
}
HTTP_STATUS:200This confirms summaries are kind-aware and ACP model mirrors acp_model.
A masked GET for the ACP profile returned masked acp_env:
{"config":{"agent_kind":"acp","acp_env":{"ANTHROPIC_API_KEY":"**********"},"acp_model":"claude-opus-4-7"},"api_key_set":true}
HTTP_STATUS:200The persisted file under the isolated profile directory did not contain the dummy plaintext secret and instead contained an encrypted gAAAA... value:
PLAINTEXT_SECRET_NOT_FOUND
"ANTHROPIC_API_KEY": "gAAAAABqGZZg_..."
After activating the ACP profile:
{"name":"local-claude","message":"Profile 'local-claude' activated and applied to current settings","llm_applied":true}
HTTP_STATUS:200Fetching settings with plaintext exposure showed the ACP launch config applied:
{
"agent_settings": {
"agent_kind": "acp",
"acp_server": "claude-code",
"acp_model": "claude-opus-4-7",
"acp_env": {"ANTHROPIC_API_KEY": "<dummy-secret>"}
}
}
HTTP_STATUS:200This confirms activation flips the current agent settings to ACP and decrypts the saved env credential for runtime use.
Test 3: Mutually exclusive payload guard and encrypted client round-trip
Providing both payloads returned the documented 422:
{"detail":"Provide exactly one of `llm` or `agent_settings`."}
HTTP_STATUS:422For encrypted client round-trip, I saved a source ACP profile, fetched it with X-Expose-Secrets: encrypted, then saved a second profile using that encrypted payload and activated it. The API returned an encrypted-looking secret prefix and confirmed plaintext was not returned at that stage:
{
"status": "encrypted payload captured",
"agent_kind": "acp",
"secret_prefix": "gAAAAABqGZ",
"secret_is_plaintext": false
}Activating the copied profile produced:
{
"agent_kind": "acp",
"acp_server": "claude-code",
"acp_model": "claude-opus-4-7",
"secret_round_tripped": true
}This verifies encrypted secrets returned by the API can be submitted back and decrypted during save/activation.
Issues Found
None.
This QA review was created by an AI agent (OpenHands) on behalf of the user.
… acp
The activate endpoint's OpenHands branch sent only `{"llm": ...}`, which
deep-merges into the current settings — so when the active agent was ACP it
stayed `agent_kind: "acp"` and the OpenHands profile never took effect. Include
`agent_kind: "openhands"` in the diff so the discriminated union flips back
(leftover acp_* keys are ignored by OpenHandsAgentSettings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Backend half of agent-canvas#669 — reframe the user-facing concept around a kind-discriminated AgentProfile. Today
/api/profilesonly persists LLM configs; this lets a profile also be an ACP profile (provider / model / command / args / env), which the agent-canvas in-conversation runtime-compatibility picker needs in order to offer ACP→ACP live switches and to grey out cross-kind profiles with a reason.Scope is deliberately bounded to ACP profiles + legacy compatibility (per the issue's "ACP profile" data-model direction). OpenHands profiles remain LLM-only for now; condenser / MCP / verification / conversation-settings snapshots are out of scope.
Store (
LLMProfileStore)save_acp()/load_acp()persist and round-trip anACPAgentSettings.acp_envvalues and the bookkeepingllmsecrets are encrypted at rest when a cipher is configured, reusing the existingAgentSettingssecret serializers / validators.profile_kind()reports"openhands"|"acp"by peekingagent_kind.list_summaries()tags each profile withkind, and for ACP addsacp_server/acp_model(modelmirrorsacp_modelso chip consumers still render).load() -> LLMis unchanged, so existing runtime callers (switch_profile, fallback strategy, subagents) keep working.load()raises on an ACP profile instead of mis-parsing it.Router (
/api/profiles)ProfileInfogainskind/acp_server/acp_model.SaveProfileRequestaccepts either the legacyllmpayload (saved asopenhands) or a fullagent_settingspayload (acpsaved as an ACP profile); exactly one is required.GETandactivateare kind-aware. Activating an ACP profile flipsagent_kindtoacpand applies the saved launch fields (includingacp_envcredentials); client-encrypted secrets are decrypted via the cipher context.Tests
tests/sdk/llm/test_llm_profile_store.pyandtests/agent_server/test_profiles_router.pycover save/load/list/kind, legacy compat, GET masking, ACP activate flippingagent_kind, the exactly-one-payload guard, and an end-to-end cipher round-trip (encrypted at rest, masked on GET, decrypted on activate). All existing profile tests still pass;ruff+pyrightpre-commit hooks pass.🤖 Generated with Claude Code
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:ddef396-pythonRun
All tags pushed for this build
About Multi-Architecture Support
ddef396-python) is a multi-arch manifest supporting both amd64 and arm64ddef396-python-amd64) are also available if needed