Skip to content

feat(profiles): persist ACP AgentProfiles alongside legacy LLM profiles#3433

Open
simonrosenberg wants to merge 3 commits into
mainfrom
feat-acp-agent-profiles
Open

feat(profiles): persist ACP AgentProfiles alongside legacy LLM profiles#3433
simonrosenberg wants to merge 3 commits into
mainfrom
feat-acp-agent-profiles

Conversation

@simonrosenberg
Copy link
Copy Markdown
Member

@simonrosenberg simonrosenberg commented May 29, 2026

What

Backend half of agent-canvas#669 — reframe the user-facing concept around a kind-discriminated AgentProfile. Today /api/profiles only 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 an ACPAgentSettings. acp_env values and the bookkeeping llm secrets are encrypted at rest when a cipher is configured, reusing the existing AgentSettings secret serializers / validators.
  • profile_kind() reports "openhands" | "acp" by peeking agent_kind.
  • list_summaries() tags each profile with kind, and for ACP adds acp_server / acp_model (model mirrors acp_model so chip consumers still render).
  • Backward compatible: OpenHands profiles stay bare-LLM files and load() -> LLM is 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)

  • ProfileInfo gains kind / acp_server / acp_model.
  • SaveProfileRequest accepts either the legacy llm payload (saved as openhands) or a full agent_settings payload (acp saved as an ACP profile); exactly one is required.
  • GET and activate are kind-aware. Activating an ACP profile flips agent_kind to acp and applies the saved launch fields (including acp_env credentials); client-encrypted secrets are decrypted via the cipher context.

Tests

tests/sdk/llm/test_llm_profile_store.py and tests/agent_server/test_profiles_router.py cover save/load/list/kind, legacy compat, GET masking, ACP activate flipping agent_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 + pyright pre-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

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:ddef396-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-ddef396-python \
  ghcr.io/openhands/agent-server:ddef396-python

All tags pushed for this build

ghcr.io/openhands/agent-server:ddef396-golang-amd64
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-golang-amd64
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-golang-amd64
ghcr.io/openhands/agent-server:ddef396-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:ddef396-golang-arm64
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-golang-arm64
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-golang-arm64
ghcr.io/openhands/agent-server:ddef396-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:ddef396-java-amd64
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-java-amd64
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-java-amd64
ghcr.io/openhands/agent-server:ddef396-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:ddef396-java-arm64
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-java-arm64
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-java-arm64
ghcr.io/openhands/agent-server:ddef396-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:ddef396-python-amd64
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-python-amd64
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-python-amd64
ghcr.io/openhands/agent-server:ddef396-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:ddef396-python-arm64
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-python-arm64
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-python-arm64
ghcr.io/openhands/agent-server:ddef396-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:ddef396-golang
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-golang
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-golang
ghcr.io/openhands/agent-server:ddef396-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:ddef396-java
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-java
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-java
ghcr.io/openhands/agent-server:ddef396-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:ddef396-python
ghcr.io/openhands/agent-server:ddef396f5ce5a0fc31ddcbb8bc3751f77349dfe0-python
ghcr.io/openhands/agent-server:feat-acp-agent-profiles-python
ghcr.io/openhands/agent-server:ddef396-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., ddef396-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., ddef396-python-amd64) are also available if needed

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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   profiles_router.py2091592%152, 176, 242, 244, 278–280, 344, 346, 544–549
openhands-sdk/openhands/sdk/llm
   llm_profile_store.py163994%90–91, 288, 291–292, 317–318, 326–327
TOTAL29304661277% 

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

✅ 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-claude

Observed:

{"detail":[{"type":"missing","loc":["body","llm"],"msg":"Field required", ...}]}
HTTP_STATUS:422

This 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:201

This 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-gpt

Observed 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:200

This 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:200

The 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:200

Fetching 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:200

This 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:422

For 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.

Debug Agent and others added 2 commits May 29, 2026 16:18
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants