Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d5f3775
Append AgentContext prompt extensions for ACP agents
xingyaoww Apr 24, 2026
c6c0528
Authenticate ACP ChatGPT sessions from Codex auth
xingyaoww Apr 24, 2026
b75e895
Fix ACP test typing
xingyaoww Apr 24, 2026
ba9263f
Limit ACP AgentContext to skill catalog
xingyaoww Apr 24, 2026
8d08e10
Add ACP prompt adapter for AgentContext
xingyaoww Apr 24, 2026
d36918d
Apply ACP context formatting
xingyaoww Apr 25, 2026
0f01f11
Merge branch 'main' into feat/acp-skill-prompt-adapter
xingyaoww Apr 25, 2026
b7847b8
Preserve legacy skills in ACP prompt context
xingyaoww Apr 25, 2026
16aabe6
Load repo AgentSkills in ACP review context
xingyaoww Apr 25, 2026
e4a3411
Reuse skill prompt rendering for ACP catalogs
xingyaoww Apr 25, 2026
bdca2e7
Share user prompt extension handling with ACP
xingyaoww Apr 25, 2026
05c8fc7
Simplify ACP prompt extension reuse
xingyaoww Apr 25, 2026
6b125ff
Reject secrets in ACP prompt context
xingyaoww Apr 25, 2026
12f4b38
Tighten ACP prompt context support
xingyaoww Apr 25, 2026
f766cb0
Address ACP prompt review coverage
xingyaoww Apr 25, 2026
5105e31
Remove unused include_user_suffix param from to_acp_prompt_context
openhands-agent Apr 26, 2026
3679a47
Revert top-level skills/ search path addition
openhands-agent Apr 26, 2026
cb216f8
Remove speculative parameters from ACP prompt context and get_user_me…
openhands-agent Apr 26, 2026
1b265d2
Merge branch 'main' into feat/acp-skill-prompt-adapter
xingyaoww Apr 27, 2026
1a31c5a
refactor: extract _partition_skills() and deduplicate test mock setup
openhands-agent Apr 27, 2026
f4819d3
Update openhands-sdk/openhands/sdk/context/agent_context.py
xingyaoww Apr 27, 2026
3dd3b87
refactor: remove unused include_content parameter from to_prompt()
openhands-agent Apr 27, 2026
12140a6
refactor: validate agent_context ACP compatibility at initialization …
openhands-agent Apr 27, 2026
06329c2
fix: use USERPROFILE fallback for Windows home directory in ChatGPT auth
openhands-agent Apr 27, 2026
46397c7
refactor: tag ACP-compatible fields via json_schema_extra instead of …
openhands-agent Apr 27, 2026
12a4c4b
Remove model_fields_set check for current_datetime in to_acp_prompt_c…
openhands-agent Apr 27, 2026
967c4b9
refactor: simplify to_acp_prompt_context — always emit datetime
openhands-agent Apr 27, 2026
15d4a63
refactor: deduplicate to_acp_prompt_context by reusing get_system_mes…
openhands-agent Apr 27, 2026
7b98547
refactor: prefer ChatGPT subscription over API keys, use Path.home()
openhands-agent Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions openhands-sdk/openhands/sdk/agent/acp_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
The Agent Client Protocol (ACP) lets OpenHands power conversations using
ACP-compatible servers (Claude Code, Gemini CLI, etc.) instead of direct
LLM calls. The ACP server manages its own LLM, tools, and execution;
the ACPAgent simply relays user messages and collects the response.
the ACPAgent relays user messages and collects the response. OpenHands
can still append prompt-only context, such as a skill catalog, to the
user message before it is sent to the ACP server.

Unlike the built-in Agent, one ACP ``step()`` maps to one complete remote
assistant turn. ACPAgent therefore emits a terminal ``FinishAction`` at the
Expand All @@ -21,6 +23,7 @@
import time
import uuid
from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Any

from acp.client.connection import ClientSideConnection
Expand Down Expand Up @@ -144,26 +147,36 @@ def _make_dummy_llm() -> LLM:

# ACP auth method ID → environment variable that supplies the credential.
# When the server reports auth_methods, we pick the first method whose
# required env var is set.
# required credential source is present.
# Note: claude-login is intentionally NOT included because Claude Code ACP
# uses bypassPermissions mode instead of API key authentication.
_AUTH_METHOD_ENV_MAP: dict[str, str] = {
"codex-api-key": "CODEX_API_KEY",
"openai-api-key": "OPENAI_API_KEY",
"gemini-api-key": "GEMINI_API_KEY",
}
_CHATGPT_AUTH_PATH = Path(".codex") / "auth.json"


def _select_auth_method(
auth_methods: list[Any],
env: dict[str, str],
) -> str | None:
"""Pick an auth method whose required env var is present.
"""Pick an auth method whose required credentials are present.

Returns the ``id`` of the first matching method, or ``None`` if no
env-var-based method is available (the server may not require auth).
supported credential source is available (the server may not require auth).

ChatGPT subscription login (device-code flow stored in
``~/.codex/auth.json``) is checked first so it takes precedence over
explicit API keys, which serve as the fallback.
"""
method_ids = {m.id for m in auth_methods}
# Prefer ChatGPT subscription login when the auth file is present.
if "chatgpt" in method_ids:
if (Path.home() / _CHATGPT_AUTH_PATH).is_file():
return "chatgpt"
# Fall back to explicit API key env vars.
for method_id, env_var in _AUTH_METHOD_ENV_MAP.items():
if method_id in method_ids and env_var in env:
return method_id
Expand Down Expand Up @@ -810,7 +823,10 @@ def init_state(
)
)

# Validate no unsupported features
# Validate unsupported execution features. agent_context is allowed
# because it contributes prompt-only extensions to user messages; ACP
# server tools, MCP configuration, and context-window management remain
# owned by the server.
if self.tools:
raise NotImplementedError(
"ACPAgent does not support custom tools; "
Expand All @@ -826,11 +842,8 @@ def init_state(
"ACPAgent does not support condenser; "
"the ACP server manages its own context"
)
if self.agent_context is not None:
Comment thread
xingyaoww marked this conversation as resolved.
raise NotImplementedError(
"ACPAgent does not support agent_context; "
"configure the ACP server directly"
)
if self.agent_context:
self.agent_context.validate_acp_compatibility()

from openhands.sdk.utils.async_executor import AsyncExecutor

Expand Down Expand Up @@ -1100,6 +1113,24 @@ def _cancel_inflight_tool_calls(self) -> None:
exc_info=True,
)

def _build_acp_prompt(self, event: MessageEvent) -> str | None:
"""Build the prompt text for one ACP user turn."""
message = event.to_llm_message()
# Preserve all text blocks produced by the conversation layer, including
# any extended_content it already attached to the user turn.
text_parts = [
Comment thread
xingyaoww marked this conversation as resolved.
content.text
for content in message.content
if isinstance(content, TextContent) and content.text.strip()
]
if self.agent_context:
acp_prompt_context = self.agent_context.to_acp_prompt_context()
if acp_prompt_context:
text_parts.append(acp_prompt_context)
if not text_parts:
return None
return "\n\n".join(text_parts)
Comment thread
VascoSch92 marked this conversation as resolved.
Comment thread
xingyaoww marked this conversation as resolved.

@observe(name="acp_agent.step", ignore_inputs=["conversation", "on_event"])
def step(
self,
Expand All @@ -1110,15 +1141,13 @@ def step(
"""Send the latest user message to the ACP server and emit the response."""
state = conversation.state

# Find the latest user message
# Find the latest user message. Conversation implementations already
# attach per-turn AgentContext extensions to MessageEvent.extended_content;
# MessageEvent.to_llm_message() merges those extensions with the user text.
user_message = None
for event in reversed(list(state.events)):
if isinstance(event, MessageEvent) and event.source == "user":
# Extract text from the message
for content in event.llm_message.content:
if isinstance(content, TextContent) and content.text.strip():
user_message = content.text
break
user_message = self._build_acp_prompt(event)
if user_message:
break

Expand Down
93 changes: 74 additions & 19 deletions openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,25 @@ class AgentContext(BaseModel):
skills: list[Skill] = Field(
default_factory=list,
description="List of available skills that can extend the user's input.",
json_schema_extra={"acp_compatible": True},
)
system_message_suffix: str | None = Field(
default=None, description="Optional suffix to append to the system prompt."
default=None,
description="Optional suffix to append to the system prompt.",
json_schema_extra={"acp_compatible": True},
)
user_message_suffix: str | None = Field(
default=None, description="Optional suffix to append to the user's message."
default=None,
description="Optional suffix to append to the user's message.",
json_schema_extra={"acp_compatible": True},
)
load_user_skills: bool = Field(
default=False,
description=(
"Whether to automatically load user skills from ~/.openhands/skills/ "
"and ~/.openhands/microagents/ (for backward compatibility). "
),
json_schema_extra={"acp_compatible": True},
)
load_public_skills: bool = Field(
default=False,
Expand All @@ -71,13 +77,15 @@ class AgentContext(BaseModel):
"skills repository at https://github.com/OpenHands/extensions. "
"This allows you to get the latest skills without SDK updates."
),
json_schema_extra={"acp_compatible": True},
)
marketplace_path: str | None = Field(
default=DEFAULT_MARKETPLACE_PATH,
description=(
"Relative marketplace JSON path within the public skills repository. "
"Set to None to load all public skills without marketplace filtering."
),
json_schema_extra={"acp_compatible": True},
)
secrets: Mapping[str, SecretValue] | None = Field(
default=None,
Expand All @@ -87,6 +95,7 @@ class AgentContext(BaseModel):
"Values can be either strings or SecretSource instances "
"(str | SecretSource)."
),
json_schema_extra={"acp_compatible": False},
Comment thread
xingyaoww marked this conversation as resolved.
)
current_datetime: datetime | str | None = Field(
default_factory=datetime.now,
Expand All @@ -97,6 +106,7 @@ class AgentContext(BaseModel):
"included in the system prompt to give the agent awareness of "
"the current time context. Defaults to the current datetime."
),
json_schema_extra={"acp_compatible": True},
)

@field_validator("skills")
Expand Down Expand Up @@ -169,6 +179,27 @@ def get_formatted_datetime(self) -> str | None:
return self.current_datetime.isoformat()
return self.current_datetime

def _partition_skills(self) -> tuple[list[Skill], list[Skill]]:
"""Split skills into repo-context and available-skills lists.

Categorization rules (shared by system-message and ACP adapters):
- AgentSkills-format: always in available_skills (progressive disclosure).
Triggers also auto-inject via ``get_user_message_suffix``.
- Legacy with ``trigger=None``: full content in REPO_CONTEXT (always active).
- Legacy with triggers: listed in available_skills, injected on trigger.

Returns:
``(repo_skills, available_skills)`` tuple.
"""
repo_skills: list[Skill] = []
available_skills: list[Skill] = []
for s in self.skills:
if s.is_agentskills_format or s.trigger is not None:
available_skills.append(s)
else:
repo_skills.append(s)
return repo_skills, available_skills

def get_system_message_suffix(
self,
llm_model: str | None = None,
Expand Down Expand Up @@ -198,23 +229,7 @@ def get_system_message_suffix(
- Legacy with trigger=None: Full content in <REPO_CONTEXT> (always active)
- Legacy with triggers: Listed in <available_skills>, injected on trigger
"""
# Categorize skills based on format and trigger:
# - AgentSkills-format: always in available_skills (progressive disclosure)
# - Legacy: trigger=None -> REPO_CONTEXT, else -> available_skills
repo_skills: list[Skill] = []
available_skills: list[Skill] = []

for s in self.skills:
if s.is_agentskills_format:
# AgentSkills: always list (triggers also auto-inject via
# get_user_message_suffix)
available_skills.append(s)
elif s.trigger is None:
# Legacy OpenHands: no trigger = full content in REPO_CONTEXT
repo_skills.append(s)
else:
# Legacy OpenHands: has trigger = list in available_skills
available_skills.append(s)
repo_skills, available_skills = self._partition_skills()

# Gate vendor-specific repo skills based on model family.
if llm_model or llm_model_canonical:
Expand Down Expand Up @@ -277,6 +292,46 @@ def get_system_message_suffix(
return self.system_message_suffix.strip()
return None

def validate_acp_compatibility(self) -> None:
"""Raise if this context uses fields unsupported by ACP prompt mode.

Compatibility is determined by the ``acp_compatible`` tag in each
field's ``json_schema_extra``.
"""
acp_compatible = {
name
for name, info in type(self).model_fields.items()
if isinstance(info.json_schema_extra, dict)
and info.json_schema_extra.get("acp_compatible") is True
}
unsupported = set(self.model_fields_set) - acp_compatible
if unsupported:
fields = ", ".join(sorted(unsupported))
raise NotImplementedError(
f"ACP prompt context does not support AgentContext field(s): {fields}"
)

def to_acp_prompt_context(self) -> str | None:
"""Return the AgentContext fields that ACP can consume as prompt text.

ACP servers own their tools, MCP servers, hooks, and execution model, so
this adapter only emits prompt-only context. Unsupported AgentContext
semantics (e.g. secrets) are rejected by
:meth:`validate_acp_compatibility`.

The rendering reuses :meth:`get_system_message_suffix` with the same
``system_message_suffix.j2`` template so that ACP agents receive the
identical prompt layout as the general agent (minus secrets).

``user_message_suffix`` is a compatible field but is not emitted here
because ``LocalConversation`` already applies it through
``event.to_llm_message()``; including it would duplicate it.
"""
self.validate_acp_compatibility()
# ACP doesn't support secrets (enforced above) and has no model-specific
# skill filtering, so we delegate to the shared renderer with no extras.
return self.get_system_message_suffix()

def get_user_message_suffix(
self, user_message: Message, skip_skill_names: list[str]
) -> tuple[TextContent, list[str]] | None:
Expand Down
6 changes: 3 additions & 3 deletions openhands-sdk/openhands/sdk/settings/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,9 @@ def _start_request_kwargs(self, **kwargs: Any) -> dict[str, Any]:
payload["agent"] = self.agent_settings.create_agent()

# --- secrets (from agent's context) ---------------------------------
# ACPAgent doesn't carry an ``agent_context`` at all; its context is
# owned by the subprocess. ``getattr(..., None)`` keeps this no-op
# for the ACP variant.
# ACPAgent may carry prompt-only context, but its execution context is
# owned by the subprocess. ``getattr(..., None)`` keeps this no-op for
# agents without AgentContext.
agent = payload.get("agent")
if "secrets" not in payload and agent is not None:
ctx = getattr(agent, "agent_context", None)
Expand Down
Loading
Loading