Skip to content

feat(agent-server): reintroduce ACP remote runtime via v2 conversations API#2462

Closed
simonrosenberg wants to merge 3 commits intomainfrom
feat/acp-remote-runtime-v2-api-fresh
Closed

feat(agent-server): reintroduce ACP remote runtime via v2 conversations API#2462
simonrosenberg wants to merge 3 commits intomainfrom
feat/acp-remote-runtime-v2-api-fresh

Conversation

@simonrosenberg
Copy link
Copy Markdown
Collaborator

@simonrosenberg simonrosenberg commented Mar 16, 2026

Summary

  • reintroduce the useful ACP remote-runtime work from Enable ACPAgent on RemoteRuntime API #2190
  • keep the existing /api/conversations REST contract backward-compatible for older clients
  • add a parallel v2 conversations API that supports polymorphic Agent | ACPAgent payloads
  • route remote ACPAgent conversations through the new v2 contract while preserving existing defaults for regular Agent

Why this exists

#2190 was useful, but it was reverted by #2451 because eagerly registering ACPAgent changed the existing agent request/response schema on /api/conversations.

Older clients that POST a plain agent object without agent.kind started failing with 422, and the OpenAPI contract changed in place. That was a real breaking change.

This PR takes the additive path instead:

  • v1 stays stable and continues to accept/return the legacy Agent contract
  • v2 introduces the ACP-capable polymorphic contract
  • the SDK uses v2 only when the configured agent is an ACPAgent

Implementation notes

  • add StartConversationRequestV2 / ConversationInfoV2 plus ACPEnabledAgent
  • add /api/v2/conversations endpoints alongside the existing v1 endpoints
  • preserve v1 search/get/list behavior for legacy Agent conversations only
  • keep the ACPAgent runtime, cleanup, Docker, eval, and example improvements from the original work
  • restore default remote conversation paths for non-ACP agents

Test plan

  • uv run pytest tests/agent_server/test_conversation_router.py tests/agent_server/test_conversation_router_v2.py tests/agent_server/test_openapi_discriminator.py tests/sdk/agent/test_acp_agent.py tests/sdk/conversation/remote/test_remote_conversation.py

References


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 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:2251e5d-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:2251e5d-golang-amd64
ghcr.io/openhands/agent-server:2251e5d-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:2251e5d-golang-arm64
ghcr.io/openhands/agent-server:2251e5d-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:2251e5d-java-amd64
ghcr.io/openhands/agent-server:2251e5d-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:2251e5d-java-arm64
ghcr.io/openhands/agent-server:2251e5d-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:2251e5d-python-amd64
ghcr.io/openhands/agent-server:2251e5d-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-amd64
ghcr.io/openhands/agent-server:2251e5d-python-arm64
ghcr.io/openhands/agent-server:2251e5d-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-arm64
ghcr.io/openhands/agent-server:2251e5d-golang
ghcr.io/openhands/agent-server:2251e5d-java
ghcr.io/openhands/agent-server:2251e5d-python

About Multi-Architecture Support

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

simonrosenberg and others added 2 commits March 16, 2026 11:48
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 16, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 16, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 16, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   api.py1681292%74, 86, 101, 107, 278, 281, 285–287, 289, 295, 336
   conversation_router_acp.py34197%95
   conversation_service.py4199677%102–103, 130, 133, 135, 142–148, 176, 183, 204, 275, 294, 300, 305, 311, 319–320, 329–332, 341, 355–357, 364, 389–390, 429, 432, 449–453, 455–456, 459–460, 463–468, 548, 555–559, 562–563, 567–571, 574–575, 579–583, 586–587, 593–598, 605–606, 610, 612–613, 618–619, 625–626, 633–634, 638–640, 658, 682, 914, 917
   event_service.py3208174%55–56, 74–76, 85–89, 92–95, 115, 219, 236, 290–291, 295, 303, 306, 352–353, 369, 371, 375–377, 381, 390–391, 393, 397, 403, 405, 413–418, 555, 557–558, 562, 576–578, 580, 584–587, 591–594, 602–605, 625, 629–634, 646–647, 649–650, 657–658, 660–661, 665, 671, 688–689
openhands-sdk/openhands/sdk/agent
   acp_agent.py3897879%194–196, 250–253, 255–256, 283, 285, 289, 295, 306–307, 312, 379, 481–482, 493, 498, 529, 539, 544, 555–558, 564–566, 569–571, 573, 575–576, 578, 580, 585, 594–595, 599–600, 604, 611–617, 627–632, 634, 643–645, 648–649, 655–659, 661, 663–664, 672, 709, 713–714, 958–959
   base.py1872288%200, 257–259, 289, 293–297, 345–347, 357, 367, 375–376, 480, 517–518, 528–529
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py4002693%288, 293, 321, 364, 382, 398, 463, 641–642, 645, 797, 805, 807, 811–812, 823, 825–827, 852, 924, 1050, 1054, 1124, 1131–1132
   remote_conversation.py60710682%69, 71, 142, 169, 182, 184–187, 197, 219–220, 225–228, 311, 321–323, 329, 370, 512–515, 517, 537–541, 546–549, 552, 564–568, 713–714, 718–719, 733, 757–758, 777, 788–789, 809–812, 814–815, 839–841, 844–848, 850–851, 855, 857–865, 867, 904, 1034, 1102–1103, 1107, 1112–1116, 1122–1128, 1141–1142, 1228, 1235, 1241–1242, 1301–1302, 1320–1321
TOTAL20815518475% 

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.

🟡 Taste Rating: Acceptable - Solid architecture using API versioning to avoid breaking changes. A few maintainability concerns around error handling consistency and code duplication, but the core approach is sound. The polymorphic agent handling and v1/v2 split are pragmatic solutions to a real backward compatibility problem.

_prompt, timeout=self.acp_prompt_timeout
)
break # Success, exit retry loop
except TimeoutError:
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.

🟠 Important: TimeoutError inconsistency - The general Exception handler re-raises with a comment explaining this is needed for LocalConversation.run() to work correctly, but TimeoutError does not re-raise. This creates an inconsistency where timeouts set error status but allow execution to continue normally, while other errors break the loop. Consider re-raising TimeoutError as well, or document why timeout is treated as a soft error.



def _uses_v2_conversation_contract(agent: AgentBase) -> bool:
return getattr(agent, "kind", agent.__class__.__name__) == "ACPAgent"
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.

🟠 Important: Fragile type checking - Using string comparison getattr(agent, "kind", agent.__class__.__name__) == "ACPAgent" is fragile. If the class is renamed or the kind field is missing, this breaks silently. Consider using isinstance() with proper imports.

)
return results

async def _notify_conversation_webhooks(self, conversation_info: BaseModel):
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.

🟡 Suggestion: Type signature too broad - The parameter type is BaseModel but all callers pass ConversationInfoV2. Tightening this to ConversationInfoV2 would provide better type safety and make the intended usage clearer.

# Collect all conversations with their info
all_conversations = []
for id, event_service in self._event_services.items():
if not include_v2 and not _is_v1_conversation(event_service.stored):
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.

🟡 Suggestion: Code duplication pattern - The include_v2 flag creates parallel code paths in _search_conversations and _count_conversations that must be kept in sync. Any bug fix needs to be applied to both branches. This works but creates maintenance burden. Consider if the v1/v2 filtering could be unified to reduce duplication. Not blocking - the current approach is clear and testable.

Co-authored-by: openhands <openhands@all-hands.dev>
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