---
title:"深入 Open Agent SDK(番外篇):实战验证——把 SDK 塞进一个 macOS 原生 Agent 应用"date:2026.04.30category:[tech]tags:[AI, Swift, Agent, SDK, macOS, 实战, 开源]status:published
---

深入 Open Agent SDK(番外篇):实战验证——把 SDK 塞进一个 macOS 原生 Agent 应用

把 Open Agent SDK 集成到开源 macOS 原生 Agent 应用 Motive 中,用 SDKBridge 替换掉外部 opencode 进程,验证 SDK 在真实 GUI 应用场景下的工程表现。全文记录架构对比、踩坑记录和最终的集成代码。

2026.04.30·15 min read·16.0KB

本文是「深入 Open Agent SDK (Swift)」系列番外篇。系列目录见这里

前七篇文章从各个子系统分析了 Open Agent SDK 的设计。但 SDK 写得好不好,最终得放到真实项目里验证。这篇文章记录我把 SDK 集成到一个开源 macOS 原生 Agent 应用——Motive——的完整过程:从理解原有架构到实现替换,以及一路上踩过的坑。

Motive 是什么

Motive 是一个 macOS 原生的 AI Agent 桌面应用,用 SwiftUI 写的。它的核心交互是:用户输入 prompt → Agent 在后台跑 Agent Loop(调工具、读文件、执行命令)→ 流式输出结果到 UI。

在集成 SDK 之前,Motive 的 Agent 后端长这样:

Motive App (SwiftUI)
  └── OpenCodeBridge (actor)
        ├── OpenCodeServer  — 启动外部 opencode 二进制进程 (opencode serve)
        ├── SSEClient       — 通过 Server-Sent Events 接收流式事件
        └── OpenCodeAPIClient — 通过 REST API 发送 prompt、回复权限请求

每次用户发 prompt,Motive 要:

  1. 启动一个外部 opencode serve 进程(如果没在跑的话)
  2. 通过 REST API POST /sessions 创建会话
  3. 通过 REST API POST /sessions/{id}/prompt 发送 prompt
  4. 通过 SSE 连接接收事件流(文本片段、工具调用、完成信号等)

这套架构能用,但有几个问题:

  • 依赖外部二进制:用户要自己安装 opencode CLI,Motive 还要处理二进制签名、路径查找
  • 进程间通信开销:REST API + SSE 意味着事件要经过 HTTP 序列化/反序列化
  • 启动延迟:外部进程冷启动需要时间
  • 调试困难:跨进程的问题很难定位

SDK 的出现正好给了另一种可能——把 Agent Loop 直接跑在应用进程内。

目标:SDKBridge

我想做的替换:不启动外部进程,不经过 HTTP,直接在 Motive 进程内用 SDK 的 Agent.stream() 跑 Agent Loop。

目标架构:

Motive App (SwiftUI)
  └── BackendBridge (enum wrapper)
        ├── .opencode → OpenCodeBridge  (原有架构,保留)
        └── .sdk      → SDKBridge       (新增,用 OpenAgentSDK)
              └── Agent.stream() → 直接在进程内跑 Agent Loop

保留原有的 OpenCodeBridge 作为备选,让用户可以在设置中切换后端类型。这是一个务实的决定——万一 SDK 后端有问题,用户还能切回去。

第一步:BackendBridge 抽象层

原有的 OpenCodeBridge 是一个 actor,Motive 的 AppState 直接跟它交互。现在要加一个平行的 SDKBridge,需要一个分派层。

我用了一个 enum 而不是 protocol:

enum BackendBridge {
    case opencode(OpenCodeBridge)
    case sdk(SDKBridge)

    func submitIntent(text: String, cwd: String, ...) async { ... }
    func interrupt() async { ... }
    func stop() async { ... }
    // ...
}

为什么不用 protocol?因为 OpenCodeBridgeSDKBridge 的能力不完全一样。OpenCodeBridge 有权限请求(permission)、问题回复(question)等 SDK 后端不需要的概念。用 enum 可以在共享接口上做统一分派,同时保留各自特有的方法:

// OpenCode-only 方法,SDK 后端直接 no-op
func replyToQuestion(requestID: String, answers: [[String]], ...) async {
    guard case .opencode(let bridge) = self else { return }
    await bridge.replyToQuestion(requestID: requestID, answers: answers, ...)
}

对于 AppState 来说,大部分代码不需要改——它调 bridge.submitIntent(),至于底层是 HTTP 还是 SDK,它不关心。

第二步:SDKBridge 核心——361 行的 Actor

SDKBridge 是整个替换的核心。它是一个 actor,负责:

  1. 接收 Configuration(API key、model、MCP servers 等)
  2. 用 SDK 的 createAgent() 创建 Agent
  3. 调用 Agent.stream() 获取流式响应
  4. 把 SDK 的 SDKMessage 映射成 Motive 已有的 OpenCodeEvent

配置

actor SDKBridge {
    struct Configuration: Sendable {
        let apiKey: String
        let model: String
        let provider: String        // "anthropic", "openai", etc.
        let baseURL: String?
        let debugMode: Bool
        let projectDirectory: String
        let mcpEntries: [String: MCPEntry]?
        let env: [String: String]?
        let skillDirectories: [String]?
    }

    struct MCPEntry: Sendable {
        let command: String
        let args: [String]?
        let env: [String: String]?
    }
}

MCPEntry 是中间类型——Motive 的配置系统有自己的 MCP 描述格式,在传入 SDK 之前转成 McpServerConfig.stdio

创建 Agent

private func createAgent(from config: Configuration, sessionId: String? = nil) -> Agent {
    let provider: LLMProvider = Self.anthropicProviders.contains(config.provider) ? .anthropic : .openai
    let mcpServers = config.mcpEntries?.mapValues { entry in
        McpServerConfig.stdio(McpStdioConfig(
            command: entry.command,
            args: entry.args,
            env: entry.env
        ))
    }

    // 始终包含 core + specialist 工具,确保基本能力
    let coreTools = getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist)

    return OpenAgentSDK.createAgent(options: AgentOptions(
        apiKey: config.apiKey,
        model: config.model,
        baseURL: config.baseURL,
        provider: provider,
        permissionMode: .bypassPermissions,
        cwd: config.projectDirectory,
        tools: coreTools,
        mcpServers: mcpServers,
        sessionStore: sessionStore,
        sessionId: sessionId,
        skillDirectories: config.skillDirectories,
        logLevel: config.debugMode ? .debug : .none,
        env: config.env
    ))
}

注意几个细节:

  • provider 映射:Motive 用字符串("anthropic""openai"),SDK 用 LLMProvider 枚举,这里做了转换
  • core + specialist 工具:始终包含基础工具,即使 MCP 服务器连接失败,Agent 也有读写文件、执行命令的能力
  • sessionStore + sessionId:传入 SessionStore 让 SDK 自动持久化对话历史,传入 sessionId 实现会话恢复

流式响应:submitIntent

这是最核心的方法。用户每次发 prompt 都走这里:

func submitIntent(
    text: String,
    cwd: String,
    agent: String? = nil,
    forceNewSession: Bool = false,
    correlationId: String? = nil
) async {
    guard let config = configuration else {
        eventContinuation.yield(OpenCodeEvent(kind: .error, rawJson: "", text: "SDK bridge not configured"))
        return
    }

    let sessionId = forceNewSession ? UUID().uuidString : (currentSessionId ?? UUID().uuidString)
    currentSessionId = sessionId

    // 创建 Agent
    let sdkAgent = createAgent(from: config, sessionId: sessionId)
    self.agent = sdkAgent

    // 取消之前的流
    streamTask?.cancel()

    // 在后台 Task 中消费 stream
    streamTask = _Task { [weak self] in
        guard let self else { return }
        for await message in sdkAgent.stream(text) {
            guard !_Task.isCancelled else { return }
            await self.handleSDKMessage(message, sessionId: sessionId)
        }
    }
}

用 Swift 的 Task 包裹 stream()for await 循环,这样用户中断时可以 cancel 掉这个 Task。注意 _Task_Concurrency.Task 的别名——因为 OpenAgentSDK 里也有个 Task 类型,直接用 Task 会冲突。

SDKMessage → OpenCodeEvent 映射

Motive 的 UI 已经有一套基于 OpenCodeEvent 的事件处理系统。与其重写 UI 层,不如在 bridge 层做映射:

private func handleSDKMessage(_ message: SDKMessage, sessionId: String) {
    switch message {
    case .partialMessage(let data):
        eventContinuation.yield(OpenCodeEvent(kind: .assistant, rawJson: "", text: data.text))

    case .toolUse(let data):
        eventContinuation.yield(OpenCodeEvent(kind: .tool, rawJson: "", text: data.input,
            toolName: data.toolName, toolCallId: data.toolUseId))

    case .toolResult(let data):
        let output = data.isError ? "Error: \(data.content)" : data.content
        eventContinuation.yield(OpenCodeEvent(kind: .tool, rawJson: "", text: "",
            toolName: "Result", toolOutput: output, toolCallId: data.toolUseId))

    case .result(let data):
        // 映射 usage
        // 映射 finish / error
        ...

    default:
        break
    }
}

eventContinuation 是一个 AsyncStream<OpenCodeEvent>.Continuation,在初始化时传入。AppState 在 MainActor 上消费这个流,驱动 UI 更新。这个设计让 SDKBridge 和 OpenCodeBridge 共用同一套 UI 处理逻辑——AppState 不知道也不关心事件来自哪个后端。

第三步:踩过的坑

这不是一次顺利的替换。以下是我遇到的真实问题。

坑 1:macOS GUI 应用没有 shell PATH

这是最头疼的问题。macOS 的 GUI 应用不继承用户的 shell 环境。SDK 的 MCPStdioTransportProcess 启动 MCP 子进程时,PATH 里没有 nvmhomebrew 等路径——MCP 服务器找不到 nodepython

解决方案:在 buildSDKMcpServers() 里手动构建扩展 PATH:

let extendedPath = configManager.buildExtendedPath(base: ProcessInfo.processInfo.environment["PATH"])

for entry in mcpEntries {
    var mergedEnv = spec.environment
    // ...
    mergedEnv["PATH"] = extendedPath  // 注入扩展 PATH
}

这样 MCP 子进程能找到正确的 node/python 可执行文件。OpenCode 后端没这个问题,因为 opencode CLI 是从终端启动的,自带完整 shell 环境。

坑 2:核心工具在无 MCP 时不加载

SDK 的 assembleFullToolPool() 在没有 MCP 服务器时走了一条短路径——只返回 baseTools(用户自定义工具),不包含内置的 Core 和 Specialist 工具。这意味着如果不配 MCP,Agent 连 ReadWriteBash 都没有。

修复:在 createAgent() 里始终传入 core + specialist 工具:

let coreTools = getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist)
return OpenAgentSDK.createAgent(options: AgentOptions(
    // ...
    tools: coreTools,  // 始终包含
    // ...
))

坑 3:时序问题——配置还没完成就发 prompt

AppState.start() 里异步配置 bridge,但用户可能在配置完成之前就发了 prompt。这导致 "SDK bridge not configured" 错误。

修复:在每次 submitIntentresumeSession 之前都调用 configureBridge(),确保配置是最新的:

func submitIntent(...) async {
    await configureBridge()  // 先确保配置完成
    // 然后检查配置是否成功
    guard configuration != nil else { ... }
    // ...
}

坑 4:Swift Task 命名冲突

OpenAgentSDK 的类型命名跟 Swift 标准库有冲突——SDK 里有个 Task 类型(用于任务追踪),跟 Swift 并发的 Task 撞了。直接写 Task { } 编译器会找错类型。

用 typealias 解决:

private typealias _Task = _Concurrency.Task

然后所有地方用 _Task { } 代替 Task { }

坑 5:API Key 可选问题

不是所有 LLM 提供商都需要 API key。本地运行的 Ollama、LM Studio 就不需要。但 SDK 默认要求 API key 不为空。

修复:在配置时检查 provider 是否允许空 API key:

if apiKey.isEmpty, !configManager.provider.allowsOptionalAPIKey {
    lastErrorMessage = "API key required for SDK backend. Check Settings."
    return
}

SDK 本身也支持空 API key——传入空字符串就行,它会跳过认证 header。

第四步:MCP 服务器配置 UI

为了让 SDK 后端能连接外部 MCP 工具,我在 Advanced Settings 里加了一个 MCP 服务器配置界面。用户可以添加自定义的 MCP stdio 服务器(配置命令、参数、环境变量),保存到 UserDefaults,然后在创建 Agent 时注入。

struct CustomMcpServerConfig: Codable, Identifiable {
    let id: UUID
    var name: String
    var command: String
    var args: [String]
    var env: [String: String]
    var enabled: Bool
}

这些自定义服务器在 buildSDKMcpServers() 里跟 Skill 系统注册的 MCP 服务器合并,一起传给 SDK。

架构对比

替换前后的关键差异:

方面OpenCode 后端SDK 后端
Agent 运行位置外部 opencode 进程应用进程内
通信方式REST API + SSE直接函数调用
启动延迟进程冷启动 ~2-5s毫秒级
额外依赖需要安装 opencode CLISPM 依赖,无需额外安装
调试跨进程,需要看外部日志进程内,Xcode 断点直接打
事件映射SSE JSON → OpenCodeEventSDKMessage → OpenCodeEvent
MCP 服务器opencode 内部管理应用层配置,通过 SDK 传入

替换后代码量对比:

  • SDKBridge.swift:361 行(新增)
  • BackendBridge.swift:134 行(新增)
  • AppState+Bridge.swift:+123/-16 行(修改)
  • AdvancedSettingsView.swift:+309/-44 行(MCP UI)
  • 其他测试和配置文件:+60/-8 行

总共净增约 600 行,换来的是去掉了对外部二进制的依赖。

验证结论

这次集成验证了 SDK 在以下方面的工程表现:

能用的部分:

  • Agent.stream()AsyncStream<SDKMessage> 接口简洁,可以直接用在 SwiftUI 的响应式流程里
  • SessionStore 的会话持久化开箱即用,不需要自己管理 JSON 文件
  • MCP stdio 连接在注入正确的 PATH 后稳定工作
  • 多 provider 支持(Anthropic/OpenAI 兼容)覆盖了 Motive 已有的 provider 列表
  • permissionMode: .bypassPermissions 适合桌面应用的自动执行场景

需要注意的部分:

  • macOS GUI 应用的环境变量(PATH)问题需要额外处理,这不是 SDK 的 bug,而是 macOS 的安全机制
  • Swift 并发的 Task 命名冲突需要手动解决
  • assembleFullToolPool() 在无 MCP 时的短路径行为需要了解清楚

整体评价: SDK 的 API 设计对 GUI 应用集成是友好的。核心的 createAgent + stream 两个调用就替代了原来启动外部进程 + HTTP 服务 + SSE 客户端 + REST API 客户端四个组件。对于一个 361 行的 actor 来说,这个替换比是合理的。

完整代码在 terryso/motive,已经合并了 SDK 后端,可以直接 clone 下来跑。


系列文章

相关链接