Skip to content

[AgentProfile][agent-server] agent_profile_id at conversation start + LaunchedProfile provenance#3784

Merged
simonrosenberg merged 7 commits into
mainfrom
agent-profile-conv-start
Jun 18, 2026
Merged

[AgentProfile][agent-server] agent_profile_id at conversation start + LaunchedProfile provenance#3784
simonrosenberg merged 7 commits into
mainfrom
agent-profile-conv-start

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Jun 18, 2026

Copy link
Copy Markdown
Member

HUMAN:

Agent profile ID resolution at conversation start: accepts a stable UUID, resolves server-side via the profile store and cipher, stamps LaunchedProfile provenance on the stored conversation, and maps resolution errors (ProfileNotFound → 404, DanglingMcpServerRef → 422).


AGENT:

20/20 new tests pass locally. Full agent-server suite: 1241 passed, 1 pre-existing flake (TestAutoTitle — confirmed failing on main before this branch).

$ uv run pytest tests/agent_server/test_agent_profile_conv_start.py -v
TestStartConversationRequestValidation::test_agent_profile_id_alone_is_valid PASSED
TestStartConversationRequestValidation::test_agent_alone_is_valid PASSED
TestStartConversationRequestValidation::test_agent_profile_id_and_agent_is_invalid PASSED
TestStartConversationRequestValidation::test_agent_profile_id_and_agent_settings_is_invalid PASSED
TestStartConversationRequestValidation::test_no_agent_source_is_invalid PASSED
TestStartConversationRequestValidation::test_agent_profile_id_is_excluded_from_model_dump PASSED
TestResolveAgentFromProfile::test_unknown_id_raises_profile_not_found PASSED
TestResolveAgentFromProfile::test_openhands_profile_resolves_to_agent_and_stamps_launched PASSED
TestResolveAgentFromProfile::test_dangling_mcp_server_ref_propagates PASSED
TestResolveAgentFromProfile::test_acp_profile_resolves_to_acp_agent PASSED
TestConversationServiceStartFromProfile::test_start_from_profile_stamps_launched_profile_on_stored PASSED
TestConversationServiceStartFromProfile::test_profile_not_found_propagates PASSED
TestConversationServiceStartFromProfile::test_dangling_ref_propagates_from_service PASSED
TestConversationRouterProfileErrors::test_profile_not_found_returns_404 PASSED
TestConversationRouterProfileErrors::test_dangling_mcp_server_ref_returns_422 PASSED
TestLaunchedProfileRoundTrip::test_launched_profile_survives_stored_conversation_round_trip PASSED
TestLaunchedProfileRoundTrip::test_stored_conversation_without_profile_has_none PASSED
TestLaunchedProfileRoundTrip::test_launched_profile_in_conversation_info PASSED
TestLaunchedProfileRoundTrip::test_conversation_info_without_profile_is_none PASSED
TestLaunchedProfileRoundTrip::test_launched_profile_survives_json_serialization PASSED
======================== 20 passed in 0.29s ========================

$ uv run ruff check <changed files>
All checks passed!
$ uv run ruff format --check <changed files>
All files already formatted.

Why

ts-client deriveSwitchPlan (epic #3713) needs to know which agent profile launched a conversation without doing fragile settings-comparison. The resolution logic also needs cipher + store access that the SDK layer doesn't have.

Summary

  • agent_profile_id: UUID | None added to StartConversationRequest (carry-only exclude=True, mutually exclusive with agent/agent_settings; SDK enforces exclusivity only)
  • Server-side _resolve_agent_from_profile(profile_id, cipher): UUID→name via list_summaries(), load profile, pull global mcp_config from PersistedSettings, call resolve_agent_profile(), return built AgentBase + LaunchedProfile(id, revision)
  • _start_conversation runs resolution via asyncio.to_thread(), injects resolved agent into request, stamps launched_profile on StoredConversation; LaunchedProfile projected onto ConversationInfo
  • Router: ProfileNotFound → 404, DanglingMcpServerRef → 422

Issue Number

Closes #3720

How to Test

from openhands.sdk.conversation.request import StartConversationRequest
from openhands.sdk.workspace import LocalWorkspace
from uuid import uuid4

# Mutual exclusivity enforced at SDK layer
req = StartConversationRequest(agent_profile_id=uuid4(), workspace=LocalWorkspace(working_dir="/tmp"))
assert req.agent is None  # server resolves before use

# agent_profile_id not serialized (carry-only)
assert "agent_profile_id" not in req.model_dump(mode="json")

End-to-end: start a conversation with agent_profile_id set to an existing profile UUID via POST /api/conversations; GET /api/conversations/{id} should show launched_profile.profile_id and launched_profile.revision in the response.

Video/Screenshots

(unit tests cover all paths; no UI changes)

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes


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:7726004-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:7726004-golang-amd64
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-golang-amd64
ghcr.io/openhands/agent-server:agent-profile-conv-start-golang-amd64
ghcr.io/openhands/agent-server:7726004-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:7726004-golang-arm64
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-golang-arm64
ghcr.io/openhands/agent-server:agent-profile-conv-start-golang-arm64
ghcr.io/openhands/agent-server:7726004-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:7726004-java-amd64
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-java-amd64
ghcr.io/openhands/agent-server:agent-profile-conv-start-java-amd64
ghcr.io/openhands/agent-server:7726004-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:7726004-java-arm64
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-java-arm64
ghcr.io/openhands/agent-server:agent-profile-conv-start-java-arm64
ghcr.io/openhands/agent-server:7726004-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:7726004-python-amd64
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-python-amd64
ghcr.io/openhands/agent-server:agent-profile-conv-start-python-amd64
ghcr.io/openhands/agent-server:7726004-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:7726004-python-arm64
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-python-arm64
ghcr.io/openhands/agent-server:agent-profile-conv-start-python-arm64
ghcr.io/openhands/agent-server:7726004-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:7726004-golang
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-golang
ghcr.io/openhands/agent-server:agent-profile-conv-start-golang
ghcr.io/openhands/agent-server:7726004-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:7726004-java
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-java
ghcr.io/openhands/agent-server:agent-profile-conv-start-java
ghcr.io/openhands/agent-server:7726004-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:7726004-python
ghcr.io/openhands/agent-server:7726004067ab9b2b20536b4951f20c6bdd15fdbf-python
ghcr.io/openhands/agent-server:agent-profile-conv-start-python
ghcr.io/openhands/agent-server:7726004-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 7726004-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., 7726004-python-amd64) are also available if needed

… LaunchedProfile provenance (#3720)

Accept `agent_profile_id: UUID | None` on `StartConversationRequest` (mutually
exclusive with `agent`/`agent_settings`, carry-only `exclude=True`). The SDK
validator enforces exclusivity; resolution runs server-side so stores and the
cipher are available.

Resolution path (`_resolve_agent_from_profile`):
- look up profile name from stable UUID via `AgentProfileStore.list_summaries()`
- load and decrypt the profile (`AgentProfileStore.load`)
- pull global `mcp_config` from `PersistedSettings` (already-decrypted by the
  file-store cipher context)
- call `resolve_agent_profile()` → `AgentSettingsConfig.create_agent()`
- return the built `AgentBase` and a `LaunchedProfile(id, revision)` provenance

`_start_conversation` calls this via `asyncio.to_thread()` before the agent-
requirement check, injects the resolved agent onto the request, and stamps the
provenance on `StoredConversation`. `_compose_conversation_info` projects it
onto `ConversationInfo`. `ProfileNotFound` maps to 404; `DanglingMcpServerRef`
maps to 422 in the router.

`LaunchedProfile` is a new declared field on both `StoredConversation` and
`ConversationInfo` so ts-client `deriveSwitchPlan` can identify the active
profile without fragile settings-comparison.

Tests (20): SDK mutual-exclusivity, `_resolve_agent_from_profile` helper
(OpenHands, ACP, unknown-id, dangling-ref), service-layer propagation, router
HTTP mapping, and `LaunchedProfile` JSON round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_router.py1851194%248, 348, 482–485, 497–500, 535
   conversation_service.py64912580%146–147, 156, 182–183, 187–188, 193, 275–276, 279–280, 290, 416–417, 448, 451, 458–464, 491, 497, 593, 599, 604, 610, 618–619, 628–631, 640, 652, 660, 705–706, 767, 808–812, 814–815, 818–819, 829, 841–846, 944, 951–955, 958–959, 963–967, 970–971, 975–979, 982–983, 1005–1006, 1010–1011, 1013–1015, 1017, 1020, 1028–1032, 1041, 1049–1053, 1056–1061, 1063–1064, 1078, 1088, 1092, 1094–1095, 1100–1101, 1107–1108, 1118, 1136–1137, 1167, 1182, 1215, 1515, 1518
openhands-sdk/openhands/sdk/conversation
   request.py861088%73, 260, 266, 272, 274–275, 278–279, 290, 299
openhands-sdk/openhands/sdk/profiles
   agent_profile.py901385%237, 240, 294–296, 299–302, 306–307, 311, 335
   agent_profile_store.py124794%79–80, 318–322
TOTAL32782692078% 

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Profile-based conversation start works end-to-end through the real agent-server API, including provenance stamping, persistence across restart, validation, and profile-resolution error responses.

Does this PR achieve its stated goal?

Yes. The PR sets out to allow agent_profile_id at conversation start, resolve it server-side into an agent, and expose launched_profile provenance on conversation info; I verified this with real HTTP requests against a running local agent-server. On main, the same profile-start payload is rejected with 422 because no agent/agent_settings is present; on this PR it returns 201 with an Agent built from the saved profile and launched_profile: {profile_id, revision} in both create and GET responses, and the same provenance survives a server restart.

Phase Result
Environment Setup make build completed successfully; local agent-server started with isolated temp HOME/profile stores.
CI Status ⚠️ GitHub checks were not fully green at observation time: PR description check and agent-server stress check failed, several jobs pending; I did not rerun CI.
Functional Verification ✅ Real /api/conversations requests verified profile launch, direct launch compatibility, 404/422 error paths, and restart persistence.
Functional Verification

Test 1: Before/after profile-based conversation start

Step 1 — Establish baseline without the fix:
Checked out main at b0be8a52, started python -m openhands.agent_server on localhost, and sent the same real API request a client would send:

curl -sS -w '
HTTP_STATUS:%{http_code}
'   -H 'Content-Type: application/json'   --data @/tmp/start_profile_payload.json   http://127.0.0.1:18385/api/conversations

Observed:

{
  "http_status": 422,
  "detail": [{
    "msg": "Value error, Either `agent` or `agent_settings` must be provided"
  }]
}

This confirms the old behavior: agent_profile_id alone could not start a conversation.

Step 2 — Apply the PR's changes:
Checked out agent-profile-conv-start at ae6e1150, saved a real LLMProfileStore entry and OpenHandsAgentProfile in an isolated temp HOME, then started the PR agent-server.

Step 3 — Re-run with the fix in place:
Ran the same POST against the PR server:

curl -sS -w '
HTTP_STATUS:%{http_code}
'   -H 'Content-Type: application/json'   --data '{"agent_profile_id":"5bb6063d-4bf1-442b-93ad-c99c3ff5f9a5","workspace":{"kind":"LocalWorkspace","working_dir":"/tmp/ohqa-workspace-pr-3784"}}'   http://127.0.0.1:18384/api/conversations

Observed relevant response fields:

{
  "http_status": 201,
  "id": "21195fd5-9619-43ad-a02d-ccbfb0523901",
  "execution_status": "idle",
  "launched_profile": {
    "profile_id": "5bb6063d-4bf1-442b-93ad-c99c3ff5f9a5",
    "revision": 7
  },
  "agent_kind": "Agent",
  "agent_model": "gpt-4o-mini",
  "agent_usage_id": "qa"
}

This shows the server accepted agent_profile_id, resolved it into an OpenHands Agent, and returned the promised launched-profile provenance.

Test 2: ConversationInfo exposes provenance and persists across restart

Baseline: prior to the PR, the profile-start request cannot create a conversation, so there is no ConversationInfo.launched_profile to retrieve.

With the PR: after the 201 response above, I fetched the conversation via HTTP:

curl -sS -w '
HTTP_STATUS:%{http_code}
'   http://127.0.0.1:18384/api/conversations/21195fd5-9619-43ad-a02d-ccbfb0523901

Observed:

{
  "http_status": 200,
  "id": "21195fd5-9619-43ad-a02d-ccbfb0523901",
  "launched_profile": {
    "profile_id": "5bb6063d-4bf1-442b-93ad-c99c3ff5f9a5",
    "revision": 7
  }
}

I then created a fresh PR conversation, stopped the PR server, restarted it, and fetched that conversation again:

{
  "http_status": 200,
  "id": "f8bd172c-689b-4b13-a7a8-63dcec4046eb",
  "launched_profile": {
    "profile_id": "5bb6063d-4bf1-442b-93ad-c99c3ff5f9a5",
    "revision": 7
  }
}

This confirms the provenance is exposed through the API and survives normal server reload/persistence.

Test 3: Error and compatibility paths

Unknown profile ID:

{
  "http_status": 404,
  "detail": "Agent profile with id '00000000-0000-0000-0000-000000000001' not found"
}

agent_profile_id combined with agent_settings:

{
  "http_status": 422,
  "detail": [{
    "msg": "Value error, `agent_profile_id` is mutually exclusive with `agent` and `agent_settings`"
  }]
}

Profile referencing missing MCP server:

{
  "http_status": 422,
  "detail": {
    "message": "MCP server ref(s) not present in the user's MCP config: 'missing-mcp-server'",
    "dangling_mcp_server_refs": ["missing-mcp-server"]
  }
}

Existing direct agent_settings start path:

{
  "http_status": 201,
  "execution_status": "idle",
  "launched_profile": null,
  "agent_kind": "Agent",
  "agent_model": "gpt-4o-mini",
  "agent_usage_id": "direct"
}

These responses match the PR description's intended 404/422 mappings and show the existing direct-start path still works with launched_profile: null.

Issues Found

None from functional QA.

This review was created by an AI agent (OpenHands) on behalf of the user.

Copy link
Copy Markdown
Collaborator

🔍 Review in progress…

We are performing the review through OpenHands Cloud Automation. You can log in and view the conversation here.

…ered id→name scans

Three callers open-coded the same O(n) list_summaries scan to resolve a
stable UUID to a profile name.  Add a single store method so callers don't
each re-implement it:

  store.name_for_id(profile_id) -> str | None

Update _resolve_agent_from_profile in conversation_service to use it, and
fix test mocks accordingly (mock name_for_id instead of list_summaries).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ QA Report: PASS WITH ISSUES

The agent-server API path works end-to-end for starting from agent_profile_id, but the documented SDK model_dump() usage for a profile-only request currently crashes.

Does this PR achieve its stated goal?

Mostly yes. I started a real local agent-server, seeded a real LLM profile + agent profile in the profile store, and POST /api/conversations with only agent_profile_id returned 201 with the expected launched_profile.profile_id and revision; GET returned the same provenance even after a server restart. The documented error mappings also worked (ProfileNotFound → 404, dangling MCP ref → 422). However, the PR description's SDK carry-only serialization example does not work: a profile-only StartConversationRequest(...).model_dump(mode="json") raises PydanticSerializationError because agent remains None.

Phase Result
Environment Setup make build completed and local server launched on both main and PR branch
CI Status 🟡 GitHub checks: 34 successful, 3 skipped, 1 in-progress qa-changes check when observed
Functional Verification ⚠️ HTTP/API feature verified; SDK serialization issue found
Functional Verification

Test 1: Conversation start from agent_profile_id (before/after)

Step 1 — Establish baseline on origin/main:
Seeded a real temporary profile store, started python -m openhands.agent_server --host 127.0.0.1 --port 8765, then ran:

curl -sS -w "\nHTTP_STATUS:%{http_code}\n"   -H "Content-Type: application/json"   --data @/tmp/qa-profile-payload.json   http://127.0.0.1:8765/api/conversations

Observed:

{"detail":[{"type":"value_error","msg":"Value error, Either `agent` or `agent_settings` must be provided"}]}
HTTP_STATUS:422

This confirms the pre-PR server did not accept agent_profile_id as a conversation-start source.

Step 2 — Apply the PR's changes:
Checked out agent-profile-conv-start at ae6e1150bc9abdcb6af5a55b5216689e4f1672c7 and restarted the same local server with the same seeded profile store.

Step 3 — Re-run with the PR:
Ran the same POST /api/conversations request with only agent_profile_id and workspace. Observed:

{
  "id": "10a35fef-b6c1-47d4-bffc-b533463631dd",
  "execution_status": "idle",
  "launched_profile": {
    "profile_id": "3a4261cd-4915-4075-84c7-928074268de7",
    "revision": 3
  },
  "agent": {"llm": {"model": "gpt-4o-mini", "usage_id": "qa-llm"}}
}
HTTP_STATUS:201

This shows the server resolved the stored profile into an agent and stamped the expected LaunchedProfile provenance.

Test 2: Provenance is readable and persists across restart

After the successful create, ran:

curl -sS -w "\nHTTP_STATUS:%{http_code}\n"   http://127.0.0.1:8765/api/conversations/10a35fef-b6c1-47d4-bffc-b533463631dd

Observed the same launched_profile with profile_id=3a4261cd-4915-4075-84c7-928074268de7 and revision=3 with HTTP_STATUS:200.

Then stopped and restarted the PR server and ran the same GET again. The response still included:

"launched_profile": {
  "profile_id": "3a4261cd-4915-4075-84c7-928074268de7",
  "revision": 3
}
HTTP_STATUS:200

This confirms the provenance is stored, not only held in memory.

Test 3: Profile resolution error mappings

Unknown profile ID:

curl -sS -w "\nHTTP_STATUS:%{http_code}\n"   -H "Content-Type: application/json"   --data '{"agent_profile_id":"11111111-1111-4111-8111-111111111111","workspace":{"kind":"LocalWorkspace","working_dir":"/tmp/oh-qa-workspace.qBzIQJ"}}'   http://127.0.0.1:8765/api/conversations

Observed:

{"detail":"Agent profile with id '11111111-1111-4111-8111-111111111111' not found"}
HTTP_STATUS:404

Dangling MCP server ref profile:

{"detail":{"message":"MCP server ref(s) not present in the user's MCP config: 'missing-mcp-server'","dangling_mcp_server_refs":["missing-mcp-server"]}}
HTTP_STATUS:422

This matches the PR's claimed 404 and 422 behavior.

Test 4: SDK request object behavior

Ran the PR description's direct SDK usage pattern:

req = StartConversationRequest(
    agent_profile_id=profile_id,
    workspace=LocalWorkspace(working_dir="/tmp/oh-qa-sdk"),
)
print("profile-only request valid:", req.agent is None, req.agent_profile_id == profile_id)
print(req.model_dump(mode="json"))

Observed:

profile-only request valid: True True
model_dump succeeded: False
model_dump error type: PydanticSerializationError
model_dump error excerpt: Error calling function `_serialize_with_mcp_handling`: AttributeError: 'NoneType' object has no attribute 'model_dump'
mutual exclusivity enforced: True

This confirms profile-only validation works, but the documented carry-only serialization behavior does not.

Issues Found

  • 🟠 Issue: Profile-only StartConversationRequest.model_dump(mode="json") crashes with PydanticSerializationError, so the SDK "How to Test" / carry-only serialization path is not functional even though the raw HTTP API path works.

This review was created by an AI agent (OpenHands) on behalf of the user.

Comment thread openhands-sdk/openhands/sdk/conversation/request.py
…est is dumped

`StartConversationRequest` with `agent_profile_id` set holds `agent=None`
(the server resolves it). Pydantic's `model_dump` calls the `AgentBase`
model_serializer with `self=None`, crashing. The `@field_serializer("agent",
mode="wrap")` short-circuits before the model_serializer fires and returns
`None` directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

all-hands-bot commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Verified with real local agent-server HTTP requests that agent_profile_id conversation start works end-to-end and preserves launched_profile provenance.

Does this PR achieve its stated goal?

Yes. On main, the same POST /api/conversations payload with only agent_profile_id was rejected with HTTP 422 because the server still required agent/agent_settings; on this PR branch, the server accepted the profile ID, resolved the stored profile into an agent, returned HTTP 201, and exposed launched_profile.profile_id + revision in the response. I also restarted the PR server and confirmed GET /api/conversations/{id} still returned the provenance, and verified the promised 404/422 error mappings.

Phase Result
Environment Setup uv environment was usable; local agent-server started on both base and PR branches.
CI Status ⏳ At check time: 21 successful, 7 pending, 1 skipped, 0 failing. I did not run tests/linters locally.
Functional Verification ✅ Profile launch, provenance response/persistence, error mappings, and direct-agent regression path all behaved as claimed.
Functional Verification

Test 1: Before/after profile launch through POST /api/conversations

Step 1 — Establish baseline on main without the fix:
Started the local agent-server from main, created an LLM profile and an OpenHands agent profile through the HTTP API, then posted a conversation start request containing only agent_profile_id:

POST /api/conversations
{"agent_profile_id":"50bc6860-88cf-4311-8a0f-2265114add00","workspace":{"working_dir":"/tmp/oh-qa-main-work","kind":"LocalWorkspace"}}

Observed:

{"detail":[{"type":"value_error","loc":["body"],"msg":"Value error, Either `agent` or `agent_settings` must be provided"}]}
HTTP:422

This confirms the previous behavior did not allow starting a conversation from an agent profile ID alone.

Step 2 — Apply the PR's changes:
Switched back to agent-profile-conv-start at 474950f7f3ed87825ebb39d3b4197045c816c967, started the local agent-server, and used the same API flow.

Step 3 — Re-run with the PR branch:

POST /api/conversations
{"agent_profile_id":"50bc6860-88cf-4311-8a0f-2265114add00","workspace":{"working_dir":"/tmp/oh-qa-work","kind":"LocalWorkspace"}}

Observed selected response fields:

{
  "id": "cdc30258-e0ab-49c3-b162-faeae0185692",
  "execution_status": "idle",
  "launched_profile": {
    "profile_id": "50bc6860-88cf-4311-8a0f-2265114add00",
    "revision": 0
  },
  "agent.llm.usage_id": "qa-llm"
}
HTTP:201

This shows the PR resolves the stored profile server-side, starts the conversation, and projects the launched profile provenance in the response.

Test 2: launched_profile persists across server restart

Started a fresh PR-branch conversation from the profile ID and captured the response:

{
  "id": "03b9a2c4-0402-4211-97fc-457e9759189b",
  "launched_profile": {
    "profile_id": "50bc6860-88cf-4311-8a0f-2265114add00",
    "revision": 1
  }
}
HTTP:201

Then stopped and restarted the PR server and fetched the same conversation:

GET /api/conversations/03b9a2c4-0402-4211-97fc-457e9759189b

Observed selected fields:

{
  "id": "03b9a2c4-0402-4211-97fc-457e9759189b",
  "launched_profile": {
    "profile_id": "50bc6860-88cf-4311-8a0f-2265114add00",
    "revision": 1
  }
}
HTTP:200

This confirms the provenance is stored, not just present in the initial in-memory response.

Test 3: Error mappings for bad profile references

Unknown profile ID:

{"detail":"Agent profile with id '00000000-0000-0000-0000-000000000999' not found"}
HTTP:404

Profile with missing MCP server reference:

{"detail":{"message":"MCP server ref(s) not present in the user's MCP config: 'missing-server'","dangling_mcp_server_refs":["missing-server"]}}
HTTP:422

These match the PR's claimed ProfileNotFound → 404 and DanglingMcpServerRef → 422 behavior.

Test 4: Direct agent start regression check

Started a conversation with a concrete agent payload and no agent_profile_id:

{
  "id": "af45ed77-55c3-4c46-aa8c-9b2dde63b4fd",
  "execution_status": "idle",
  "launched_profile": null
}
HTTP:201

This confirms existing direct-agent starts still work and do not get false profile provenance.

Issues Found

None.

This review was created by an AI agent (OpenHands) on behalf of the user.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Taste Rating: Acceptable - The overall data flow is straightforward and the provenance model is useful, but one configuration/cipher bug in the profile-resolution path keeps this from approval.

[CRITICAL ISSUES]

  • See inline comment in conversation_service.py: profile launch reads PersistedSettings through an unconfigured settings-store singleton, so MCP server refs/global settings can come from the wrong persistence dir or fail to decrypt if this is the first settings-store access after boot.
  • Existing unresolved thread: the profile-only StartConversationRequest serialization path still needs a clear answer; at current head model_dump(mode="json") drops agent_profile_id and emits agent: null, so that dump is not a usable start-conversation payload. I’m not opening a duplicate inline thread.

[TESTING]

  • Ran uv run pytest tests/agent_server/test_agent_profile_conv_start.py -q locally: 20 passed.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    This touches conversation start, server-side profile resolution, persisted metadata, and MCP/secret-bearing configuration. Existing direct-agent starts appear unaffected, but profile launches need to reliably use the server-configured settings/cipher path.

VERDICT:
Needs rework: Fix the settings-store initialization/cipher path before this should be approved; keep the existing serialization review thread resolved or addressed as well.

KEY INSIGHT:
The new agent_profile_id field is only a pointer; all correctness depends on resolving that pointer against the same configured stores/cipher the rest of the server uses.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

Comment thread openhands-agent-server/openhands/agent_server/conversation_service.py Outdated

@VascoSch92 VascoSch92 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.

Profile-only StartConversationRequest can't be serialized model_dump()/model_dump_json() raise PydanticSerializationError.

The agent field is typed non-optional AgentBase but defaults to None, and AgentBase's @model_serializer (base.py:341) calls self.model_dump() which Pydantic invokes even when the value is None. Before this PR, _require_agent guaranteed agent was never None, so the path was unreachable; making agent=None valid (profile-only) now exposes it.

This means the PR's own "How to Test" snippet (req.model_dump(mode="json")) crashes. The live feature isn't broken — the TS client posts raw JSON and the server resolves agent before its own model_dump (conversation_service.py:687 runs before :751) — but it's a real footgun for any Python caller, and the "carry-only" framing implies dump should work. Note test_agent_profile_id_is_excluded_from_model_dump only dumps an agent request, so it hides this.

Suggest guarding the AgentBase serializer against a None/non-agent self (or omitting agent when None), plus a test that dumps a profile-only request.

…ngleton

The free function _resolve_agent_from_profile called get_settings_store()
without a Config, so if it ran before any other handler had initialised the
singleton the cipher was missing and MCP config values stayed encrypted.

Extract _load_mcp_config() which builds a short-lived FileSettingsStore from
the ConversationService's conversations_dir + cipher — exactly mirroring
_get_persistence_dir() in the store module — and pass the result in as an
explicit mcp_config parameter, removing the singleton dependency from the
resolver entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

🔍 Review in progress…\n\nWe are performing the review through OpenHands Cloud Automation. You can log in and view the conversation here.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Verified the agent-server API now starts conversations from agent_profile_id, exposes launched_profile, preserves it across restart, and maps profile-resolution errors as described.

Does this PR achieve its stated goal?

Yes. On main, the same POST /api/conversations payload with only agent_profile_id is rejected before profile resolution with 422 and the message "Either agent or agent_settings must be provided". On this PR branch, the server accepted an existing profile UUID, resolved it into an Agent, returned launched_profile.profile_id + revision from both POST and GET, preserved that provenance after server restart, and returned the promised 404 / 422 responses for unknown profile and dangling MCP references.

Phase Result
Environment Setup make build completed successfully; no test suite, linter, formatter, or pre-commit run by QA
CI Status ⚠️ Completed checks observed green; build image jobs, windows binary, and qa-changes were still pending at last refresh
Functional Verification ✅ Real agent-server processes were started and exercised through HTTP requests plus SDK object construction
Functional Verification

Test 1: Conversation start from an existing profile UUID

Step 1 — Reproduce / establish baseline without the fix:
Ran git switch main && bash /tmp/qa_agent_profile_baseline.sh && git switch agent-profile-conv-start. The script created a real LLM profile + agent profile, started python -m openhands.agent_server, then posted to /api/conversations with only agent_profile_id:

--- POST existing profile on base branch ---
HTTP/1.1 422 Unprocessable Content
{"detail":[{"type":"value_error","loc":["body"],"msg":"Value error, Either `agent` or `agent_settings` must be provided",...}]}

This confirms the old behavior: even with an existing profile UUID, the API could not start a conversation from agent_profile_id alone.

Step 2 — Apply the PR's changes:
Switched back to agent-profile-conv-start at 5b3e6dd83558561e7eb097c19b1fed453052c19f.

Step 3 — Re-run with the fix in place:
Ran bash /tmp/qa_agent_profile_api.sh, which created real persisted profiles, started the agent-server, posted the same shape of request, then fetched the conversation:

PROFILE_ID=93e92b89-5944-44d5-a1ad-26c86f4fee13
--- POST existing profile ---
{"id":"0f4bb216-e3a0-46b7-8738-92f6569ed36f", ... "launched_profile":{"profile_id":"93e92b89-5944-44d5-a1ad-26c86f4fee13","revision":11}, ... "agent":{..."kind":"Agent"}}
--- GET conversation info ---
{"id":"0f4bb216-e3a0-46b7-8738-92f6569ed36f", ... "launched_profile":{"profile_id":"93e92b89-5944-44d5-a1ad-26c86f4fee13","revision":11}, ...}
--- validate launched_profile values ---
{'profile_id_matches': True, 'revision': 11, 'agent_kind': 'Agent', 'execution_status': 'idle'}

This shows the new API behavior works end-to-end: the profile ID resolves server-side, a conversation is created idle, and ConversationInfo includes the expected provenance.

Test 2: Profile resolution error mapping

Step 1 — Baseline:
The base-branch command in Test 1 rejects agent_profile_id before profile resolution, so profile-specific 404/422 behavior cannot be reached there.

Step 2 — Apply the PR's changes:
Used the PR branch server.

Step 3 — Re-run with error inputs:
The existing-profile script also posted an unknown UUID, and a separate script created a profile with mcp_server_refs=["missing-mcp-server"]:

--- POST unknown profile ---
HTTP/1.1 404 Not Found
{"detail":"Agent profile with id '00000000-0000-0000-0000-000000000001' not found"}

--- POST profile with missing MCP server ref ---
HTTP/1.1 422 Unprocessable Content
{"detail":{"message":"MCP server ref(s) not present in the user's MCP config: 'missing-mcp-server'","dangling_mcp_server_refs":["missing-mcp-server"]}}

This confirms the router maps ProfileNotFound to 404 and DanglingMcpServerRef to 422 with the missing-ref list exposed.

Test 3: LaunchedProfile survives stored conversation reload

Step 1 — Baseline:
Before the PR, conversations cannot be launched from agent_profile_id, so no profile provenance is written for this path.

Step 2 — Apply the PR's changes:
Used the PR branch server with isolated HOME and OH_PERSISTENCE_DIR.

Step 3 — Re-run after restart:
Ran bash /tmp/qa_agent_profile_restart.sh, which started the server, created the conversation from a profile, stopped the exact server PID, restarted the server against the same persistence directory, and fetched the conversation by ID:

PROFILE_ID=29358fa0-63b9-4f04-8818-6647af1d19af
CONV_ID=e16474a1-325b-4dfc-a322-9a607522fbd5
--- GET conversation after restart ---
{"id":"e16474a1-325b-4dfc-a322-9a607522fbd5", ... "launched_profile":{"profile_id":"29358fa0-63b9-4f04-8818-6647af1d19af","revision":17}, ...}
--- validate restarted response ---
{'profile_id_matches_after_restart': True, 'revision_after_restart': 17, 'execution_status': 'idle'}

This confirms the provenance is not only returned from the live service; it is stored and loaded back after restart.

Test 4: SDK request behavior

Ran a short user-style Python script constructing StartConversationRequest directly:

profile_id accepted: True
agent is None before server resolution: True
agent_profile_id in model_dump json: False
mutual exclusion error contains expected text: True

This confirms the SDK carries agent_profile_id for local construction/validation, leaves agent unset for server resolution, excludes the profile ID from serialized request data, and enforces mutual exclusivity with a direct agent.

Issues Found

None.

This review was created by an AI agent (OpenHands) on behalf of the user.

all-hands-bot commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid, focused change. The carry-only agent_profile_id (exclude=True) plus the wrap-serializer guard at request.py:297-301 makes the profile-only round-trip work end-to-end — I confirmed locally that StartConversationRequest(agent_profile_id=…, workspace=…).model_dump(mode="json") produces a clean dict with agent: null and no agent_profile_id key. Validation (mutually exclusive / agent_profile_id) is concise; the HTTP status mapping (404 / 422) is well-isolated in the router; new tests cover the persistence round-trip and ConversationInfo propagation.

One small clean-up: the two except … : raise blocks in conversation_service.py:321-324 are no-ops. ProfileNotFound and DanglingMcpServerRef inherit from Exception, not ValueError/TypeError, so neither is caught by the (TypeError, ValueError) arm below. Drop them and let the exceptions propagate naturally.

See inline for details.

Comment thread openhands-agent-server/openhands/agent_server/conversation_service.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/conversation_service.py Outdated
Comment thread openhands-sdk/openhands/sdk/conversation/request.py
- Drop dead `except ProfileNotFound: raise` / `except DanglingMcpServerRef:
  raise` blocks — these exceptions are not ValueError/TypeError so they were
  no-ops; let them propagate naturally.
- Drop `_load_mcp_config` helper and its duplicated persistence-dir derivation.
  Instead, initialise the settings-store singleton with the server cipher in
  `ConversationService.get_instance(config)` (one-time, before any request can
  run), then call `get_settings_store()` in `_start_conversation` — no
  duplication, correct cipher guaranteed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

all-hands-bot commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Taste Rating: Acceptable - the server-side profile resolution flow is straightforward, but the API request model currently hides the new field from normal serialization.

[CRITICAL ISSUES]

  • See the inline comment on agent_profile_id: clients using StartConversationRequest.model_dump(...) will drop the UUID before making POST /api/conversations, so the new start-from-profile path is not usable through the SDK request model.

[TESTING GAPS]

  • Add a regression test for serializing a profile-start request as an outgoing API payload; it should prove agent_profile_id remains present for transport while still being omitted from persisted StoredConversation metadata.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    This touches the public conversation-start REST contract and server-side agent construction. The implementation is mostly additive and well-scoped, but losing the profile UUID at the SDK serialization boundary would break the new feature for Python/API clients that rely on the shared request model.

VERDICT:
Needs follow-up before approval: Keep the field serializable for API transport and exclude it only on the persistence path.

KEY INSIGHT:
The same Pydantic model is serving both as the inbound API contract and the persisted conversation base, so persistence-only exclusions must not be encoded on the API field itself.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

Comment thread openhands-sdk/openhands/sdk/conversation/request.py Outdated

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Profile-based conversation start works end-to-end: I created real stored profiles, started the actual agent-server, POSTed /api/conversations with agent_profile_id, and verified provenance survives a server restart.

Does this PR achieve its stated goal?

Yes. The PR set out to accept a stable agent profile UUID at conversation start, resolve it server-side, stamp LaunchedProfile provenance, expose it through conversation info, and map resolution errors. I verified that a real HTTP POST with an existing profile UUID returns 201 Created with launched_profile.profile_id and revision, the same data is returned by GET after restarting the server, unknown profiles return 404, and dangling MCP refs return 422 with dangling_mcp_server_refs.

Phase Result
Environment Setup make build completed successfully; no test suite, linter, formatter, or pre-commit run was executed.
CI Status 🟡 At observation time: 22 completed checks successful, 7 skipped, 7 still in progress (qa-changes + Docker image builds); no failures observed.
Functional Verification ✅ Real SDK and HTTP API behavior matched the PR description, including persistence and error paths.
Functional Verification

Test 1: Baseline vs PR SDK request behavior

Step 1 — Reproduce / establish baseline without the fix:
Checked out main (cf563075) and ran a real SDK construction with only agent_profile_id:

git checkout main
uv run python - <<'PY'
from uuid import uuid4
from pydantic import ValidationError
from openhands.sdk.conversation.request import StartConversationRequest
from openhands.sdk.workspace import LocalWorkspace
try:
    StartConversationRequest(agent_profile_id=uuid4(), workspace=LocalWorkspace(working_dir='/tmp/oh-qa-baseline'))
except ValidationError as exc:
    print('BASELINE_VALIDATION_ERROR')
    print(str(exc).split('\n')[1])
PY

Observed:

BASELINE_VALIDATION_ERROR
  Value error, Either `agent` or `agent_settings` must be provided

This confirms the prior SDK entry point could not represent the profile-only start request.

Step 2 — Apply the PR's changes:
Checked out agent-profile-conv-start at 9074b800.

Step 3 — Re-run with the fix in place:

uv run python - <<'PY'
from uuid import UUID
from pydantic import ValidationError
from openhands.sdk import LLM, Agent
from openhands.sdk.conversation.request import StartConversationRequest
from openhands.sdk.workspace import LocalWorkspace
profile_id = UUID('11111111-1111-4111-8111-111111111111')
req = StartConversationRequest(agent_profile_id=profile_id, workspace=LocalWorkspace(working_dir='/tmp/oh-qa-workspace'))
print('PR_PROFILE_ONLY_VALID', req.agent_profile_id, req.agent)
print('PR_MODEL_DUMP_HAS_AGENT_PROFILE_ID', 'agent_profile_id' in req.model_dump(mode='json'))
try:
    StartConversationRequest(agent_profile_id=profile_id, agent=Agent(llm=LLM(model='gpt-4o-mini'), tools=[]), workspace=LocalWorkspace(working_dir='/tmp/oh-qa-workspace'))
except ValidationError as exc:
    print('PR_MUTUAL_EXCLUSIVITY_ERROR')
    print(str(exc).split('\n')[1])
PY

Observed:

PR_PROFILE_ONLY_VALID 11111111-1111-4111-8111-111111111111 None
PR_MODEL_DUMP_HAS_AGENT_PROFILE_ID False
PR_MUTUAL_EXCLUSIVITY_ERROR
  Value error, `agent_profile_id` is mutually exclusive with `agent` and `agent_settings`

This shows the PR enables profile-only request construction, keeps agent_profile_id carry-only for dumps, and enforces the claimed exclusivity.

Test 2: End-to-end HTTP start from stored profile + persisted provenance

Step 1 — Establish baseline:
Before this PR, Test 1 shows the SDK could not construct the profile-only start request; no server-side profile resolution/provenance path was available through the conversation start contract.

Step 2 — Apply the PR's changes and create real profile data:
Using isolated HOME=/tmp/oh-qa-profile-home, I saved a real LLM profile and agent profile through the public stores:

llm_base_dir /tmp/oh-qa-profile-home/.openhands/profiles
agent_base_dir /tmp/oh-qa-profile-home/.openhands/agent-profiles
summaries [{'id': '11111111-1111-4111-8111-111111111111', 'name': 'qa-profile', 'agent_kind': 'openhands', 'revision': 7, 'llm_profile_ref': 'qa-llm', 'mcp_server_refs': None}]
name_for_id qa-profile

Then I started the actual server:

HOME=/tmp/oh-qa-profile-home OH_CONVERSATIONS_PATH=/tmp/oh-qa-conversations OH_SESSION_API_KEYS_0=qa-key uv run python -m openhands.agent_server

The server responded on http://127.0.0.1:8000/server_info with HTTP/1.1 200 OK.

Step 3 — Re-run with the fix in place:

curl -i -X POST http://127.0.0.1:8000/api/conversations \
  -H 'Content-Type: application/json' \
  -H 'X-Session-API-Key: qa-key' \
  --data '{"agent_profile_id":"11111111-1111-4111-8111-111111111111","workspace":{"kind":"LocalWorkspace","working_dir":"/tmp/oh-qa-workspace"}}'

Observed key response fields:

HTTP/1.1 201 Created
"launched_profile":{"profile_id":"11111111-1111-4111-8111-111111111111","revision":7}
"agent":{"llm":{"model":"gpt-4o-mini", ... "usage_id":"qa-profile-llm"}}

I then restarted the exact server process and retrieved the same conversation:

curl -i http://127.0.0.1:8000/api/conversations/8c9e9f39-bf33-46ca-805a-a597af17a936 \
  -H 'X-Session-API-Key: qa-key'

Observed:

HTTP/1.1 200 OK
AFTER_RESTART_LAUNCHED_PROFILE {'profile_id': '11111111-1111-4111-8111-111111111111', 'revision': 7}
AFTER_RESTART_AGENT_MODEL gpt-4o-mini

This confirms the server resolved the UUID to a stored profile, built the agent from the referenced LLM profile, returned the LaunchedProfile in conversation info, and persisted it across process restart.

Test 3: HTTP error mappings for resolution failures

Step 1 — Establish baseline:
The PR description claims new profile resolution errors should map to HTTP 404 and 422. These paths are new to the profile-start API.

Step 2 — Apply the PR's changes:
With the same running PR server, I POSTed an unknown profile UUID and a real profile with mcp_server_refs=['missing-server'].

Step 3 — Run error cases:

--- missing profile ---
HTTP/1.1 404 Not Found
{"detail":"Agent profile with id '33333333-3333-4333-8333-333333333333' not found"}

--- dangling mcp ---
HTTP/1.1 422 Unprocessable Content
{"detail":{"message":"MCP server ref(s) not present in the user's MCP config: 'missing-server'","dangling_mcp_server_refs":["missing-server"]}}

This confirms the user-facing API maps ProfileNotFound to 404 and DanglingMcpServerRef to 422 with the expected missing-reference list.

Test 4: Existing direct-agent conversation start still works

Step 1 — Establish baseline:
Direct agent starts are existing behavior and should not stamp launched-profile provenance.

Step 2 — Apply the PR's changes:
Generated a normal StartConversationRequest(agent=Agent(...)) payload from the SDK and POSTed it to the same real server.

Step 3 — Run direct-agent start:

DIRECT_PAYLOAD_HAS_AGENT_PROFILE_ID False
HTTP/1.1 201 Created
DIRECT_STATUS_LAUNCHED_PROFILE None
DIRECT_AGENT_MODEL gpt-4o-mini

This confirms existing direct agent starts still succeed and leave launched_profile as null.

Issues Found

None.

This review was created by an AI agent (OpenHands) on behalf of the user.

…3784)

Remove exclude=True from agent_profile_id on the API request model so
model_dump() includes it for HTTP transport. Override the field with
exclude=True on StoredConversation (the persistence model) instead, and
exclude it explicitly when building the request_data dict so the
mutual-exclusivity validator is not triggered after resolution.

Add regression tests covering both the transport and persistence paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@simonrosenberg

Copy link
Copy Markdown
Member Author

Fixed in 7726004: removed exclude=True from agent_profile_id on StartConversationRequest so model_dump() includes it for HTTP transport. The persistence-only exclusion is now on StoredConversation (which overrides the field with exclude=True) and enforced explicitly via exclude={'agent_profile_id'} when building request_data.

all-hands-bot commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Started the agent-server on both main and this PR branch, then verified via real HTTP requests that conversations can now launch from an agent_profile_id, expose launched_profile provenance, and return the promised resolver errors.

Does this PR achieve its stated goal?

Yes. On main, the same POST /api/conversations payload with an existing profile UUID is rejected because only agent or agent_settings are accepted. On this PR branch, the same user flow returns 201 Created, resolves the profile into an OpenHands agent using the stored LLM profile, and both POST/GET conversation responses include launched_profile.profile_id + revision; the persisted meta.json also stores launched_profile without re-persisting raw agent_profile_id. I also verified the two claimed error mappings over HTTP: unknown profile UUID returns 404, and dangling MCP refs return 422 with dangling_mcp_server_refs.

Phase Result
Environment Setup make build completed and the server started from the documented uv run python -m openhands.agent_server command.
CI Status ⏳ 21 checks passing, 8 pending at observation time (mostly Agent Server image builds plus this QA workflow), 3 skipped.
Functional Verification ✅ Real API requests confirmed the new profile-launch behavior and error handling.
Functional Verification

Test 1: Start a conversation from an existing agent profile UUID

Step 1 — Establish baseline on origin/main:

Started the server with isolated persistence on port 8124, created an LLM profile and agent profile through the real API, then ran:

curl -i -X POST http://127.0.0.1:8124/api/conversations \
  -H 'Content-Type: application/json' \
  --data-binary @start_conversation.json

Payload:

{"agent_profile_id":"955e4d88-1368-4c76-a14c-341629185592","workspace":{"kind":"LocalWorkspace","working_dir":"/tmp/oh-qa-main-C30xFZ/workspace"},"autotitle":false}

Observed:

HTTP/1.1 422 Unprocessable Content
{"detail":[{"type":"value_error","loc":["body"],"msg":"Value error, Either `agent` or `agent_settings` must be provided", ...}]}

This confirms the old behavior: even with a real stored profile UUID, conversation start could not use agent_profile_id.

Step 2 — Apply the PR's changes:

Checked out agent-profile-conv-start at 7726004067ab9b2b20536b4951f20c6bdd15fdbf, re-synced the environment, and started the same server on port 8123 with isolated persistence.

Step 3 — Re-run with the fix in place:

Created the same kind of LLM profile and agent profile through /api/profiles/qa-llm and /api/agent-profiles/qa-agent, then ran:

curl -i -X POST http://127.0.0.1:8123/api/conversations \
  -H 'Content-Type: application/json' \
  --data-binary @start_conversation.json

Payload:

{"agent_profile_id":"955e4d88-1368-4c76-a14c-341629185592","workspace":{"kind":"LocalWorkspace","working_dir":"/tmp/oh-qa-pr-GekZwc/workspace"},"autotitle":false}

Observed:

HTTP/1.1 201 Created
{"id":"8acdc193-75d5-4671-8023-6b117c3a6cfd", ..., "launched_profile":{"profile_id":"955e4d88-1368-4c76-a14c-341629185592","revision":2}, "agent":{"llm":{"model":"openai/gpt-4o-mini", ...}}}

This confirms the PR resolves the profile server-side and starts a stored conversation with provenance.

Test 2: Conversation info and persistence expose provenance correctly

After the PR-created conversation, ran:

curl -f http://127.0.0.1:8123/api/conversations/8acdc193-75d5-4671-8023-6b117c3a6cfd

Selected response fields:

{
  "id": "8acdc193-75d5-4671-8023-6b117c3a6cfd",
  "launched_profile": {
    "profile_id": "955e4d88-1368-4c76-a14c-341629185592",
    "revision": 2
  },
  "agent_model": "openai/gpt-4o-mini",
  "agent_profile_id_present_in_response": false
}

Then inspected the persisted conversation metadata side effect:

{
  "launched_profile": {
    "profile_id": "955e4d88-1368-4c76-a14c-341629185592",
    "revision": 2
  },
  "agent_profile_id_present_in_metadata": false
}

This shows clients get the intended LaunchedProfile provenance, while the carry-only agent_profile_id is not persisted back into stored conversation metadata.

Test 3: Profile resolution errors map to the claimed HTTP statuses

With the PR server running, ran an unknown-profile launch request:

curl -i -X POST http://127.0.0.1:8123/api/conversations \
  -H 'Content-Type: application/json' \
  --data-binary @start_unknown.json

Observed:

HTTP/1.1 404 Not Found
{"detail":"Agent profile with id '11e46f0b-02bb-44fa-91ab-b13978a30f7a' not found"}

Then created an agent profile referencing missing-mcp-server and started a conversation with its UUID:

HTTP/1.1 422 Unprocessable Content
{"detail":{"message":"MCP server ref(s) not present in the user's MCP config: 'missing-mcp-server'","dangling_mcp_server_refs":["missing-mcp-server"]}}

This confirms the promised ProfileNotFound → 404 and DanglingMcpServerRef → 422 behavior through the real router.

Issues Found

None.

This QA review was created by an AI agent (OpenHands) on behalf of the user.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review of PR #3784 (HEAD 7726004, on top of fixes 9074b80 and 474950f):

This PR cleanly addresses all three concerns flagged in my earlier review:

  1. Profile-only request serialization (request.py L289). The new @field_serializer("agent", mode="wrap") on StartConversationRequest short-circuits when value is None, so StartConversationRequest(agent_profile_id=..., workspace=...).model_dump(mode="json") no longer raises PydanticSerializationError. I verified this end-to-end in a fresh venv: the dump returns agent=None, includes agent_profile_id, and round-trips through json.dumps.

  2. Settings-store singleton initialization order. start_conversation now calls get_settings_store() (parameterless), which get_instance() routes to the lazily-created singleton that has already been bound to the merged Config during deferred-init (init_router.py L209). No more risk of an unconfigured singleton for the cipher.

  3. No more duplicated persistence-dir derivation. The review-feedback commit 9074b80 dropped the helper that was recreating the settings-store path; the service now uses get_settings_store().load() uniformly, and the mcp_config derivation commit 5b3e6dd pulls the cipher from the same singleton. Both fixes share one source of truth.

Additional observations:

  • StoredConversation(StartConversationRequest) with agent_profile_id excluded keeps the persisted JSON shape backwards-compatible (confirmed via model_dump(..., exclude={"agent_profile_id"})).
  • name_for_id is only invoked after the store returns the profile, so it is safe against None UUIDs from the request.
  • CI on HEAD is green for all functional checks (tests, breakage checks, persisted-settings compatibility, agent-server multi-OS builds). The only "in-progress" jobs are multi-arch image pushes, which are post-merge artifacts.

Approving. Nice iteration cycle.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

@simonrosenberg

Copy link
Copy Markdown
Member Author

@VascoSch92 good catch — fixed in 474950f. Added a @field_serializer("agent", mode="wrap") on StartConversationRequest that short-circuits before AgentBase._serialize_with_mcp_handling when agent is None, so model_dump() on a profile-only request returns {"agent": null, ...} without hitting the serializer. The new test test_agent_profile_id_present_in_request_payload now dumps a profile-only request (no agent) and asserts the field is present — it would have caught the crash before.

@simonrosenberg simonrosenberg merged commit 76e0e79 into main Jun 18, 2026
41 of 42 checks passed
@simonrosenberg simonrosenberg deleted the agent-profile-conv-start branch June 18, 2026 12:12
@openhands-development

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

@OpenHands OpenHands deleted a comment from openhands-ai Bot Jun 18, 2026
Comment thread openhands-agent-server/openhands/agent_server/models.py
@openhands-development

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

Comment thread openhands-sdk/openhands/sdk/profiles/agent_profile.py
@openhands-development

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

Comment thread tests/agent_server/test_agent_profile_conv_start.py
@openhands-development

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

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.

[AgentProfile][agent-server] Accept agent_profile_id at conversation start + stamp LaunchedProfile provenance

4 participants