本文是「深入 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 要:
- 启动一个外部
opencode serve进程(如果没在跑的话) - 通过 REST API
POST /sessions创建会话 - 通过 REST API
POST /sessions/{id}/prompt发送 prompt - 通过 SSE 连接接收事件流(文本片段、工具调用、完成信号等)
这套架构能用,但有几个问题:
- 依赖外部二进制 :用户要自己安装
opencodeCLI,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:
swift
enum BackendBridge {
case opencode(OpenCodeBridge)
case sdk(SDKBridge)
func submitIntent(text: String, cwd: String, ...) async { ... }
func interrupt() async { ... }
func stop() async { ... }
// ...
}
为什么不用 protocol?因为 OpenCodeBridge 和 SDKBridge 的能力不完全一样。OpenCodeBridge 有权限请求(permission)、问题回复(question)等 SDK 后端不需要的概念。用 enum 可以在共享接口上做统一分派,同时保留各自特有的方法:
swift
// 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,负责:
- 接收
Configuration(API key、model、MCP servers 等) - 用 SDK 的
createAgent()创建 Agent - 调用
Agent.stream()获取流式响应 - 把 SDK 的
SDKMessage映射成 Motive 已有的OpenCodeEvent
配置
swift
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
swift
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 都走这里:
swift
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 层做映射:
swift
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 的 MCPStdioTransport 用 Process 启动 MCP 子进程时,PATH 里没有 nvm、homebrew 等路径------MCP 服务器找不到 node、python。
解决方案:在 buildSDKMcpServers() 里手动构建扩展 PATH:
swift
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 连 Read、Write、Bash 都没有。
修复:在 createAgent() 里始终传入 core + specialist 工具:
swift
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" 错误。
修复:在每次 submitIntent 和 resumeSession 之前都调用 configureBridge(),确保配置是最新的:
swift
func submitIntent(...) async {
await configureBridge() // 先确保配置完成
// 然后检查配置是否成功
guard configuration != nil else { ... }
// ...
}
坑 4:Swift Task 命名冲突
OpenAgentSDK 的类型命名跟 Swift 标准库有冲突------SDK 里有个 Task 类型(用于任务追踪),跟 Swift 并发的 Task 撞了。直接写 Task { } 编译器会找错类型。
用 typealias 解决:
swift
private typealias _Task = _Concurrency.Task
然后所有地方用 _Task { } 代替 Task { }。
坑 5:API Key 可选问题
不是所有 LLM 提供商都需要 API key。本地运行的 Ollama、LM Studio 就不需要。但 SDK 默认要求 API key 不为空。
修复:在配置时检查 provider 是否允许空 API key:
swift
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 时注入。
swift
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 CLI | SPM 依赖,无需额外安装 |
| 调试 | 跨进程,需要看外部日志 | 进程内,Xcode 断点直接打 |
| 事件映射 | SSE JSON → OpenCodeEvent | SDKMessage → 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 下来跑。
相关链接:
- PR :terryso/motive#1
- SDK :terryso/open-agent-sdk-swift
- Motive :terryso/motive