乐于分享
好东西不私藏

OpenHands 源码-Agent 类型与 ACP 协议

OpenHands 源码-Agent 类型与 ACP 协议

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-codecodexgemini-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)OpenHandsAgentSettingsACPAgentSettings。这两种设置在 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技术推荐官」获取更多源码解析内容