OpenHands 源码解析系列
第 7 讲:Agent 类型与 ACP 协议
基于 OpenHands 源码 · 2026-08-03
一、引言:从单一 Agent 到双轨架构
在 OpenHands 的架构演进中,Agent 从最初的单一 "OpenHands Agent" 逐渐扩展为支持多种智能体类型的双轨架构。第 7 讲我们将深入源码,理解 OpenHands 如何管理两种截然不同的 Agent 类型:原生 OpenHands Agent 和基于 ACP (Agent Client Protocol) 的第三方 Agent,以及它们如何在同一套会话生命周期中协同工作。
二、Agent 类型体系:agent_kind 枚举
OpenHands 使用 agent_kind 字段来区分不同类型的 Agent。这是一个核心路由字段,决定了会话的创建路径、Agent 实例化方式以及事件处理逻辑。
📄 openhands/app_server/app_conversation/app_conversation_models.py (第 125 行)
class AppConversationInfo(BaseModel):
"""Conversation info which does not contain status."""
id: OpenHandsUUID = Field(default_factory=uuid4)
# ...
agent_kind: str = 'openhands'
# ...
@computed_field
@property
def acp_server(self) -> str | None:
"""Active ACP provider key ('claude-code', 'codex', 'gemini-cli'), else None."""
if self.agent_kind != 'acp':
return None
return self.tags.get(ACP_SERVER_TAG_KEY)
可以看到,agent_kind 默认值为 'openhands'。当值变为 'acp' 时,系统通过 acp_server 计算属性从会话标签中提取 ACP 提供商标识(如 claude-code、codex、gemini-cli)。
📄 openhands/app_server/app_conversation/app_conversation_models.py (第 64-69 行)
class AgentType(Enum):
"""Agent type for conversation."""
DEFAULT = 'default'
PLAN = 'plan'
除了 agent_kind,OpenHands 还有 AgentType 枚举,用于区分 DEFAULT(标准执行模式)和 PLAN(规划模式)两种运行策略。
三、Agent 设置的双变体架构
OpenHands 的核心设计之一是将 Agent 配置抽象为鉴别联合类型 (Discriminated Union):OpenHandsAgentSettings 和 ACPAgentSettings。这两种设置在 AgentSettingsConfig 中作为互斥变体存在。
📄 openhands/app_server/settings/settings_models.py (第 40-48 行)
from openhands.sdk.settings import (
ACPAgentSettings,
AgentSettingsConfig,
ConversationSettings,
OpenHandsAgentSettings,
apply_agent_settings_diff,
default_agent_settings,
validate_agent_settings,
)
📄 openhands/app_server/settings/settings_models.py (第 706 行)
class Settings(BaseModel):
# ...
agent_settings: AgentSettingsConfig = Field(
default_factory=default_agent_settings
)
# ...
AgentSettingsConfig 是一个鉴别联合类型,SDK 根据 agent_kind 字段自动路由到正确的变体:
| 字段 | OpenHandsAgentSettings | ACPAgentSettings |
|---|---|---|
| agent_kind | 'openhands' | 'acp' |
| llm | 完整 LLM 配置 | 代理 LLM(不含 api_key) |
| acp_server | — | 'claude-code' / 'codex' 等 |
| acp_command | — | 启动命令列表 |
| mcp_config | 完整 MCP 配置 | 自定义 MCP 子集 |
| agent_context | 必填 | 可选(nullable) |
两种变体的字段差异是结构性的,而非简单的开关——这就是为什么直接深合并(deep-merge)会制造"混合体"(mongrel),导致验证失败。
四、Agent 类型切换机制
用户可以在设置中切换 Agent 类型。SDK 的 apply_agent_settings_diff 函数负责处理类型切换时的配置合并:
📄 openhands/app_server/settings/settings_models.py (第 852-855 行)
# The SDK owns the discriminated-union merge: replace on
# ``agent_kind`` change, deep-merge within a variant. Cross-kind
# config preservation tracked in OpenHands/OpenHands#14370.
new_settings = apply_agent_settings_diff(self.agent_settings, coerced)
关键行为:
🔹 同类型内更新:深合并(deep-merge),保留未修改的字段
🔹 跨类型切换:替换为新类型的默认配置,旧类型的配置不会携带到新类型
🔹 历史兼容:旧版 agent_kind: 'llm' 自动映射为 'openhands'
📄 tests/unit/app_server/test_settings_agent_kind_switch.py (第 49-64 行)
def test_kind_switch_does_not_raise():
"""OH → ACP → OH must not 500."""
s = Settings()
s.update(_set_openhands(llm_model='anthropic/claude-sonnet-4-5'))
s.update(_set_acp())
assert s.agent_settings.agent_kind == 'acp'
s.update(_set_openhands())
assert s.agent_settings.agent_kind == 'openhands'
def test_kind_switch_resets_new_kind_to_defaults():
"""Switching to a new kind starts from a fresh base."""
s = Settings()
s.update(_set_openhands(llm_model='anthropic/claude-sonnet-4-5'))
s.update(_set_acp())
# ACP base — llm defaults to the ACP sentinel, not the OH model.
assert s.agent_settings.agent_kind == 'acp'
assert s.agent_settings.llm.model != 'anthropic/claude-sonnet-4-5'
测试清楚地展示了:切换类型后,新类型的配置从默认值开始,旧类型的 LLM 模型不会泄漏到 ACP 变体中。
五、ACP 协议与会话创建双路径
OpenHands 的会话创建引擎 LiveStatusAppConversationService 根据 agent_kind 走两条完全不同的路径:
📄 openhands/app_server/app_conversation/live_status_app_conversation_service.py (第 570-585 行)
if request_agent.agent_kind == 'acp':
llm_model = request_agent.acp_model
agent_kind = 'acp'
# Persist the active ACP provider key so the conversation UI
# can resolve a brand label ("Claude Code", "Codex", …) via
# the SDK registry without keeping a per-conversation column.
if isinstance(profile_user.agent_settings, ACPAgentSettings):
tags[ACP_SERVER_TAG_KEY] = profile_user.agent_settings.acp_server
else:
llm_model = request_agent.llm.model
agent_kind = 'openhands'
路由逻辑:
🔹 ACPAgentSettings 路径:调用 _build_acp_start_conversation_request(),通过 acp_settings.create_agent() 创建 ACP Agent
🔹 OpenHandsAgentSettings 路径:走标准 create_request() 流程,使用完整的 LLM 配置
在 ACP 路径中,OpenHands 会做额外的安全处理:
📄 openhands/app_server/app_conversation/live_status_app_conversation_service.py (第 2379-2407 行)
# --- build the ACP agent ------------------------------------------
acp_settings = user.agent_settings # already verified to be ACPAgentSettings
assert isinstance(acp_settings, ACPAgentSettings)
# Isolate the CLI data dir onto the durable /workspace tree so the SDK
# self-resumes the provider session (session/load from base_state.json)
# across pause/resume — matching the regular-agent lifecycle (#1274).
# Strip llm.api_key/base_url to prevent proxy settings from leaking
# into the subprocess env (ACP CLIs handle their own LLM calls).
settings_update: dict[str, Any] = {
'acp_isolate_data_dir': True,
'llm': acp_settings.llm.model_copy(
update={'api_key': None, 'base_url': None}
),
}
# Forward the resolved profile's / user's custom MCP servers to the ACP
# subprocess (#15044 §7). Only custom servers — the system OpenHands MCP
# server (Tavily proxy) is runtime-internal and unreachable by an external
# ACP CLI, so it is intentionally not injected here.
acp_mcp_servers: dict[str, MCPServer] = {}
self._merge_custom_mcp_config(acp_mcp_servers, user)
if acp_mcp_servers:
settings_update['mcp_config'] = acp_mcp_servers
if system_message_suffix:
settings_update['agent_context'] = AgentContext(
system_message_suffix=system_message_suffix
)
acp_settings_for_agent = acp_settings.model_copy(update=settings_update)
acp_agent = acp_settings_for_agent.create_agent()
这段代码展示了 ACP Agent 创建的关键安全设计:
🔹 acp_isolate_data_dir = True:隔离 CLI 数据目录到持久化的 /workspace 树
🔹 api_key: None, base_url: None:剥离 LLM 代理设置,防止泄漏到子进程环境
🔹 只转发自定义 MCP 服务器,系统级 MCP(如 Tavily 代理)不注入外部 ACP CLI
六、Agent Profile 系统
Agent Profile 是 OpenHands 的高级配置抽象——用户可以将一组 Agent 设置保存为"配置文件",在会话启动时一键加载。Profile 系统支持两种类型:
📄 openhands/app_server/settings/agent_profiles.py (第 43-55 行)
from openhands.sdk.profiles import (
ACPAgentProfile,
OpenHandsAgentProfile,
ProfileLimitExceeded,
validate_agent_profile,
)
MAX_AGENT_PROFILES: Final[int] = 50
_AgentProfile: TypeAlias = OpenHandsAgentProfile | ACPAgentProfile
Profile 系统的关键设计:
| 特性 | 说明 |
|---|---|
| 引用而非嵌入 | Profile 引用 LLM profile (llm_profile_ref) 和 MCP 服务器子集 (mcp_server_refs),而非嵌入完整配置 |
| ID 键控 | 集合以 UUID 为键,但 API 以名称操作(用户友好) |
| 修订号追踪 | 每次修改递增 revision,会话启动时记录 LaunchedAgentProfile |
| 无密存储 | Profile 本身不含密钥(#4017),加密边界在 org.agent_profiles 列 |
| 最多 50 个 | MAX_AGENT_PROFILES = 50,与本地 agent-server 保持一致 |
📄 openhands/app_server/settings/agent_profiles.py (第 134-154 行)
def list_summaries(self) -> _ProfileSummaries:
"""Metadata projection {id, name, agent_kind, revision,
llm_profile_ref, mcp_server_refs} per profile."""
summaries: list[dict[str, Any]] = []
for pid, profile in self.profiles.items():
summaries.append(
{
'id': pid,
'name': profile.name,
'agent_kind': profile.agent_kind,
'revision': profile.revision,
'llm_profile_ref': (
profile.llm_profile_ref
if isinstance(profile, OpenHandsAgentProfile)
else None
),
'mcp_server_refs': profile.mcp_server_refs,
}
)
return summaries
注意 ACP 类型的 Profile 没有 llm_profile_ref(返回 None),因为 ACP Agent 自己管理 LLM 调用。
七、ACP 提供商检测与标签系统
OpenHands 通过会话标签(tags)系统记录 ACP 提供商信息,而非专用数据库列:
📄 openhands/app_server/app_conversation/app_conversation_models.py (第 30-48 行)
# Canonical conversation-tag key under which the active ACP provider key
# ('claude-code', 'codex', 'gemini-cli') is stored. Synced with agent-canvas.
# Constrained to ^[a-z0-9]+$ by the SDK validator — no underscores allowed.
# The typed ``AppConversationInfo.acp_server`` field is a projection of this tag.
ACP_SERVER_TAG_KEY = 'acpserver'
# Conversation-tag key pinning the resolved (grouped) workspace path at creation
ARCHIVE_WORKSPACE_PATH_TAG_KEY = 'archiveworkspacepath'
# Conversation-tag keys recording which Agent Profile launched the conversation
AGENT_PROFILE_ID_TAG_KEY = 'agentprofileid'
AGENT_PROFILE_REVISION_TAG_KEY = 'agentprofilerevision'
标签系统的设计哲学:利用已有的 tags 字典列承载元数据,避免数据库迁移。计算属性(@computed_field)将这些原始标签投影为类型安全的字段。
ACP 提供商检测通过 detect_acp_provider_by_command 函数实现:
📄 openhands/app_server/event_callback/webhook_router.py (第 333-359 行)
async def _resolve_acp_server_key(agent: Any, user_id: str | None) -> str | None:
"""Resolve the ACP provider key for a conversation whose tag is not yet set.
Prefer the conversation's own launch command (ACPAgent.acp_command): it
is authoritative for the agent that is actually running and matched against
the SDK registry by detect_acp_provider_by_command. Only fall back to the
user's saved settings when the command is unknown (a custom server) or absent
(older agent payloads).
"""
command = getattr(agent, 'acp_command', None)
if command:
provider = detect_acp_provider_by_command(command)
if provider is not None:
return provider.key
try:
settings_store = await shared.SettingsStoreImpl.get_instance(user_id)
settings = await settings_store.load() if settings_store else None
agent_settings = getattr(settings, 'agent_settings', None)
if isinstance(agent_settings, ACPAgentSettings):
return agent_settings.acp_server
except Exception:
_logger.warning(
'Failed to resolve ACP server key for user %s', user_id, exc_info=True
)
return None
检测优先级:
🔹 第一优先:从会话的 acp_command 命令检测(最权威)
🔹 第二优先:从用户保存的设置中回退读取
🔹 兜底:返回 None(自定义服务器或未识别的命令)
八、会话触发器 (ConversationTrigger)
OpenHands 支持多种会话触发渠道,每种渠道对应不同的 Agent 行为:
📄 openhands/app_server/app_conversation/app_conversation_models.py (第 50-61 行)
class ConversationTrigger(Enum):
RESOLVER = 'resolver'
GUI = 'gui'
SUGGESTED_TASK = 'suggested_task'
REMOTE_API_KEY = 'openhands_api'
SLACK = 'slack'
MICROAGENT_MANAGEMENT = 'microagent_management'
JIRA = 'jira'
JIRA_DC = 'jira_dc'
LINEAR = 'linear'
BITBUCKET = 'bitbucket'
AUTOMATION = 'automation'
触发器枚举覆盖了从 GUI 交互到 Slack/Jira/Linear 等第三方集成的全场景。在 webhook 回调中,系统会根据标签自动检测自动化触发器:
📄 openhands/app_server/event_callback/webhook_router.py (第 392-397 行)
# Determine trigger - check if tags indicate automation, then fall back to existing
trigger = detect_automation_trigger(
existing.trigger,
merged_tags,
conversation_id=str(conversation_info.id),
sandbox_id=sandbox_record.id,
)
九、架构总结
Agent 类型与 ACP 协议架构总览
双轨 Agent 架构
🔹 OpenHands Agent (agent_kind='openhands')
原生 Agent,SDK 直接管理 LLM 调用、工具执行、事件循环
🔹 ACP Agent (agent_kind='acp')
通过 ACP 协议启动外部 CLI(Claude Code / Codex / Gemini CLI),OpenHands 作为编排层
关键设计模式
🔹 鉴别联合 (Discriminated Union):AgentSettingsConfig 在两种变体间路由,避免配置混合
🔹 引用式 Profile:Agent Profile 引用 LLM Profile 和 MCP 服务器,而非嵌入完整配置
🔹 标签投影:ACP 提供商信息存储在 tags 字典中,通过 @computed_field 投影为类型安全字段
🔹 安全隔离:ACP 路径剥离 LLM api_key/base_url,防止代理设置泄漏到子进程
🔹 修订号追踪:Profile 每次修改递增 revision,会话启动时记录 LaunchedAgentProfile 溯源
OpenHands 的 Agent 类型体系展现了一个成熟的编排架构:它不是简单地将 Agent 硬编码为单一实现,而是通过鉴别联合、Profile 系统、标签投影等机制,构建了一个可扩展的多 Agent 平台。ACP 协议的引入使得 OpenHands 能够容纳 Claude Code、Codex、Gemini CLI 等第三方智能体,同时保持统一的会话生命周期管理。
📚 系列导航
← 第 6 讲:沙箱生命周期 (Docker/K8s/Remote)
→ 第 8 讲:Skill 系统
关注公众号「AI技术推荐官」获取更多源码解析内容
夜雨聆风