本文是「深入 SwiftWork」系列第 4 篇(完结篇)。系列目录见这里。
前三篇讲了事件怎么从 SDK 流到 UI、时间线怎么渲染、工具卡片怎么可视化。这篇收尾,看 SwiftWork 的基础设施——数据怎么存、状态怎么恢复、Markdown 怎么渲染、代码怎么高亮、API Key 怎么管。
这些组件各自独立,但都是"让应用可用"的必要部分。
SwiftWork 用 SwiftData 做持久化,注册了四个模型:
// SwiftWorkApp.swift
.modelContainer(for: [
Session.self,
Event.self,
AppConfiguration.self,
PermissionRule.self
])
@Model
final class Session {
@Attribute(.unique) var id: UUID
var title: String
var createdAt: Date
var updatedAt: Date
var workspacePath: String?
@Relationship(deleteRule: .cascade, inverse: \Event.session)
var events: [Event]
}
@Relationship(deleteRule: .cascade) 意味着删除 Session 时自动删除它下面所有 Event。workspacePath 是可选的——用户可以给每个会话指定不同的工作目录。
@Model
final class Event {
@Attribute(.unique) var id: UUID
var sessionID: UUID
var eventType: String
var rawData: Data // JSON 序列化的 AgentEvent
var timestamp: Date
var order: Int
var session: Session?
}
第 1 篇讲过这个设计——rawData 是整个 AgentEvent 序列化后的 JSON blob。不拆成独立字段的原因是 metadata 的结构因事件类型而异,拆字段会导致大量空列和 Schema 频繁变更。
@Model
final class AppConfiguration {
@Attribute(.unique) var id: UUID
var key: String
var value: Data
var updatedAt: Date
}
通用的 key-value 存储。用 SwiftData 实现而不是 UserDefaults,因为 SwiftData 支持 async 访问、数据迁移和 iCloud 同步(将来可能用到)。存的值包括:
hasCompletedOnboarding — 是否完成首次引导selectedModel — 用户选择的模型lastActiveSessionID — 上次活跃的会话 IDwindowFrame — 窗口位置和大小inspectorVisible — Inspector 面板是否可见AppStateManager 负责在 App 重启后恢复用户的工作状态——上次打开的会话、窗口位置、Inspector 面板的开关。
@MainActor
@Observable
final class AppStateManager {
var lastActiveSessionID: UUID?
var windowFrame: NSRect?
var isInspectorVisible: Bool = false
func loadAppState() {
lastActiveSessionID = loadUUID(key: "lastActiveSessionID")
windowFrame = loadNSRect(key: "windowFrame")
isInspectorVisible = loadBool(key: "inspectorVisible")
}
func saveLastActiveSessionID(_ id: UUID?) { ... }
func saveWindowFrame(_ frame: NSRect) { ... }
func saveInspectorVisibility(_ visible: Bool) { ... }
}
底层用 AppConfiguration 的 key-value 存取:
private func saveString(_ string: String, forKey key: String) {
let descriptor = FetchDescriptor<AppConfiguration>(
predicate: #Predicate { $0.key == key }
)
if let existing = try? modelContext.fetch(descriptor).first {
existing.value = Data(string.utf8)
} else {
let config = AppConfiguration(key: key, value: Data(string.utf8))
modelContext.insert(config)
}
try? modelContext.save()
}
upsert 逻辑——先查有没有,有就更新,没有就插入。loadNSRect 把字符串转回 NSRect(用 NSRectFromString),loadBool 比较字符串 "true"。
状态保存不是在 App 退出时一次性完成的,而是在各个触发点分散保存:
| 状态 | 保存时机 |
|---|---|
lastActiveSessionID |
用户切换会话时(SessionViewModel.selectSession) |
windowFrame |
窗口移动/缩放时(500ms 节流)+ App 退出时 |
inspectorVisible |
Inspector 面板切换时 |
窗口位置的保存做了节流——didMoveNotification 和 didResizeNotification 触发频率很高,每次都写 SwiftData 不值得。用一个 500ms 的 Task.sleep 做防抖,只有最后一次移动/缩放才会真正保存:
// ContentView.swift
let saveWindowFrameThrottled: (Notification) -> Void = { _ in
saveTask?.cancel()
saveTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
if let window = mainWindow {
appStateManager.saveWindowFrame(window.frame)
}
}
}
App 启动时,ContentView.task 触发恢复:
.task {
settingsViewModel.configure(modelContext: modelContext)
hasCompletedOnboarding = settingsViewModel.isAPIKeyConfigured
&& !settingsViewModel.isFirstLaunch
if hasCompletedOnboarding == true {
configureAndRestoreState()
}
}
configureAndRestoreState 按顺序恢复:
AppStateManager,加载保存的状态SessionViewModel,获取会话列表lastActiveSessionID 选中对应会话isInspectorVisible窗口位置的恢复有一个时序问题——WindowAccessor 的回调是异步的,window 引用可能在 task 之后才到达。所以 onChange(of: mainWindow) 里也做了恢复:
.onChange(of: mainWindow) { _, newWindow in
if let newWindow {
restoreWindowFrame(in: newWindow)
}
}
Agent 的回复是 Markdown 格式的——标题、列表、代码块、粗体、链接。SwiftWork 用 Apple 的 swift-markdown 库解析 Markdown,然后用 Visitor 模式遍历 AST,生成 SwiftUI 视图。
macOS 上的 Markdown 渲染组件不多。AttributedString(markdown:) 只支持基础格式(粗体、链接),不支持代码块、表格、引用块。WebView 方案(用 Markdown.js 渲染到 HTML)引入了 WebKit 的依赖和内存开销。手写 Visitor 可以精确控制每个元素的渲染方式,而且不引入额外依赖。
private struct MarkdownToViewsVisitor: @preconcurrency MarkupVisitor {
private(set) var views: [AnyView] = []
mutating func visitHeading(_ heading: Heading) -> Result { ... }
mutating func visitParagraph(_ paragraph: Paragraph) -> Result { ... }
mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { ... }
mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { ... }
mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { ... }
mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { ... }
mutating func visitTable(_ table: Table) -> Result { ... }
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { ... }
}
每个 visit 方法处理一种 Markdown 节点,把生成的视图追加到 views 数组。最终 MarkdownRenderer.render() 返回这个数组,MarkdownContentView 用 ForEach 渲染。
段落、列表项里的内联格式(粗体、斜体、行内代码、链接)通过 collectAttributedString 处理。它递归遍历子节点,构建 AttributedString:
private mutating func collectAttributedString(from markup: any Markup) -> AttributedString {
var result = AttributedString()
for child in markup.children {
if let strong = child as? Strong {
var s = collectAttributedString(from: strong)
s.font = .body.bold()
result.append(s)
} else if let emphasis = child as? Emphasis {
var e = collectAttributedString(from: emphasis)
e.font = .body.italic()
result.append(e)
} else if let inlineCode = child as? InlineCode {
var codeAttr = AttributedString(inlineCode.code)
codeAttr.backgroundColor = Color.primary.opacity(0.06)
codeAttr.font = .system(.body, design: .monospaced)
result.append(codeAttr)
} else if let link = child as? MarkdownLink {
var linkAttr = AttributedString(collectInlineText(from: link))
linkAttr.foregroundColor = Color.accentColor
linkAttr.underlineStyle = .single
linkAttr.link = URL(string: link.destination)
result.append(linkAttr)
}
// ... SoftBreak, LineBreak, Strikethrough
}
return result
}
AttributedString 是 SwiftUI 原生支持的富文本类型。把它传给 SwiftUI.Text(attributed),SwiftUI 会按设定的 font、color、backgroundColor 渲染。行内代码得到灰色背景的等宽字体,链接得到蓝色下划线。
swift-markdown 和 SwiftUI 有类型名冲突——两者都有 Text、Link 等类型。解决方案是用 typealias:
private typealias MarkdownText = Markdown.Text
private typealias MarkdownLink = Markdown.Link
在 visitor 内部用 MarkdownText 和 MarkdownLink 引用 swift-markdown 的类型,SwiftUI.Text 引用 SwiftUI 的类型。
代码块的高亮用 John Sundell 的 Splash 库。目前只支持 Swift 语法高亮,其他语言 fallback 到等宽纯文本:
enum CodeHighlighter {
static func highlight(code: String, language: String?) -> AnyView {
let trimmedLanguage = language?.lowercased()
if trimmedLanguage == "swift" {
return highlightedSwiftView(code: code)
} else {
return plainCodeView(code: code)
}
}
private static func highlightedSwiftView(code: String) -> AnyView {
let theme = Theme.sundellsColors(withFont: Splash.Font(size: 13))
let format = AttributedStringOutputFormat(theme: theme)
let highlighter = SyntaxHighlighter(format: format)
let attributed = try? AttributedString(highlighter.highlight(code), including: \.appKit)
return AnyView(Text(attributed ?? AttributedString(code)))
}
}
Splash 的管线:源码字符串 → SyntaxHighlighter → AttributedStringOutputFormat → NSAttributedString → AttributedString → SwiftUI.Text。
为什么只支持 Swift?因为 Splash 只支持 Swift。如果要支持 Python/JavaScript/Bash,需要换一个多语言的高亮库(比如 Highlight.js 的 Swift wrapper),或者用 Tree-sitter。目前 Swift 代码块的高亮频率最高(SwiftWork 本身是 Swift 项目),先支持 Swift 够用。
API Key 不能明文存在 SwiftData 或 UserDefaults 里。SwiftWork 用 macOS Keychain 存储:
struct KeychainManager: KeychainManaging, Sendable {
func save(key: String, data: Data) throws {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
let status = SecItemAdd(query.merging([kSecValueData: data]), nil)
if status == errSecDuplicateItem {
SecItemUpdate(query, [kSecValueData: data])
}
}
func load(key: String) throws -> Data? {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query, &result)
if status == errSecItemNotFound { return nil }
return result as? Data
}
}
KeychainManaging 协议抽象了底层实现,方便测试时 mock。协议扩展提供了 saveAPIKey/getAPIKey/deleteAPIKey 的便捷方法。
Keychain 存储有两个好处:数据加密(系统级别的),以及不受 App Sandbox 的文件访问限制。
新建的会话标题是"新会话"。Agent 第一次执行完成后,TitleGenerator 用 LLM 根据对话内容生成一个简短的标题:
enum TitleGenerator {
static func generate(events: [AgentEvent], apiKey: String, ...) async -> String? {
guard !apiKey.isEmpty else { return nil }
let messages = events
.filter { $0.type == .userMessage || $0.type == .assistant }
.suffix(10) // 只取最近 10 条
.map { ["role": ..., "content": String($0.content.prefix(500))] }
let body = [
"model": model,
"max_tokens": 50,
"system": "根据以下对话内容,生成一个简短的标题(最多20个字符)。只输出标题。",
"messages": messages
]
// 调 LLM API,返回标题文本
}
}
触发时机在 WorkspaceView.setupTitleGeneration 里——通过 AgentBridge.onResult 回调,在 Agent 执行完成且会话标题还是"新会话"时触发:
agentBridge.onResult = { [weak session] _ in
guard let session, session.title == "新会话" else { return }
if let title = await TitleGenerator.generate(events: events, ...) {
sessionViewModel.updateSessionTitle(session, title: title)
}
}
这是一个轻量的 LLM 调用——只有 50 token 的输出限制,system prompt 很短,取最近的 10 条消息、每条截断到 500 字符。实测延迟在 1-2 秒,不影响用户体验。
SwiftWork 的数据层和服务组件各司其职:
| 组件 | 职责 |
|---|---|
| SwiftData | Session/Event/AppConfiguration 持久化 |
| AppStateManager | 应用状态恢复(会话、窗口、面板) |
| EventStore | 事件持久化协议,SwiftData 实现 |
| MarkdownRenderer | swift-markdown AST → SwiftUI 视图 |
| CodeHighlighter | Splash 语法高亮(Swift) |
| KeychainManager | API Key 安全存储 |
| TitleGenerator | LLM 自动生成会话标题 |
它们是前几篇讲的核心管线(AgentBridge → EventMapper → TimelineView)之外的"支撑层"。没有它们应用也能跑,但用户体验会差很多——没有持久化意味着每次重启都从零开始,没有 Markdown 渲染意味着 Agent 的回复是一堆原始文本,没有 Keychain 管理意味着 API Key 明文存储。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 3 篇。系列目录见这里。
前两篇讲了事件怎么从 SDK 流到 UI。这篇聚焦其中一类事件——工具调用的可视化。
Agent 调工具是 Agent 应用里最频繁的操作。一次典型任务可能调用二三十次工具——读文件、写文件、执行命令、搜索代码。如果每次工具调用都显示成一样的灰色方块,用户很难快速区分"Bash 在跑什么命令"、"Edit 在改哪个文件"。
SwiftWork 的解决方案是一套可扩展的工具渲染系统:每种工具注册一个渲染器,ToolCardView 根据工具名称查找对应的渲染器来显示。新增工具类型时,只需要写一个实现 ToolRenderable 协议的 struct,注册到 ToolRendererRegistry,不用改 TimelineView 的任何代码。
最简单的做法是给所有工具调用用同一个视图——显示工具名称、输入参数、输出结果。第 2 篇里的 ToolCallView 就是这个角色:
struct ToolCallView: View {
let event: AgentEvent
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "wrench.and.screwdriver")
Text(event.content) // 工具名称
}
Text(input) // 原始 JSON
}
}
}
这个视图对所有工具一视同仁——同样的扳手图标,同样的 JSON 输出。它作为 fallback 够用,但有几个问题:
git status),不是 {"command": "git status"}src/main.swift),不是完整的 JSON每个工具都有不同的"最有用的信息"。Tool Card 系统就是让每个工具自己决定怎么展示。
协议定义了工具渲染器的契约:
protocol ToolRenderable: Sendable {
/// 此渲染器处理的工具名称(与 SDK ToolUseData.toolName 匹配)
static var toolName: String { get }
/// 工具类型主题色(左边条、图标着色)
static var accentColor: Color { get }
/// 工具类型 SF Symbol 图标名
static var icon: String { get }
/// 根据工具内容生成 SwiftUI 视图
@ViewBuilder @MainActor
func body(content: ToolContent) -> any View
/// 生成摘要标题(折叠状态显示)
func summaryTitle(content: ToolContent) -> String
/// 生成副标题(如文件路径、命令摘要)
func subtitle(content: ToolContent) -> String?
}
协议扩展提供了默认值:
extension ToolRenderable {
static var accentColor: Color { .gray }
static var icon: String { "wrench.and.screwdriver" }
func summaryTitle(content: ToolContent) -> String {
content.toolName
}
func subtitle(content: ToolContent) -> String? {
nil
}
}
六个成员,三个有默认值。实现者只需要提供 toolName(静态路由键)和 body(渲染内容)。summaryTitle 和 subtitle 可以覆盖来提供更有意义的摘要,accentColor 和 icon 可以覆盖来做视觉区分。
注册表是一个 [String: ToolRenderable] 字典,用 toolName 做键:
@MainActor
@Observable
final class ToolRendererRegistry {
private var renderers: [String: any ToolRenderable] = [:]
init() {
register(BashToolRenderer())
register(FileEditToolRenderer())
register(SearchToolRenderer())
register(ReadToolRenderer())
register(WriteToolRenderer())
}
func register(_ renderer: any ToolRenderable) {
renderers[type(of: renderer).toolName] = renderer
}
func renderer(for toolName: String) -> (any ToolRenderable)? {
renderers[toolName]
}
}
init 时预注册 5 个内置渲染器。查找是 O(1) 的字典访问。@Observable 标记让 SwiftUI 在注册新渲染器时自动刷新——虽然目前的用法里渲染器在 init 时就注册完了,动态注册是留给插件系统准备的。
struct BashToolRenderer: ToolRenderable {
static let toolName = "Bash"
static let accentColor: Color = .green
static let icon: String = "terminal"
func summaryTitle(content: ToolContent) -> String {
// 从 input JSON 提取 command 字段
// {"command": "git status"} → "git status"
guard let json = parseInput(content),
let command = json["command"] as? String
else { return content.toolName }
return command
}
}
绿色主题 + 终端图标。summaryTitle 从 input JSON 提取 command 字段——折叠状态下用户直接看到正在跑什么命令。
struct ReadToolRenderer: ToolRenderable {
static let toolName = "Read"
static let accentColor: Color = .blue
static let icon: String = "doc.text"
func summaryTitle(content: ToolContent) -> String {
// {"file_path": "src/main.swift"} → "src/main.swift"
guard let json = parseInput(content),
let filePath = json["file_path"] as? String
else { return content.toolName }
return filePath
}
}
蓝色主题 + 文档图标。summaryTitle 提取文件路径。
struct WriteToolRenderer: ToolRenderable {
static let toolName = "Write"
static let accentColor: Color = .orange
static let icon: String = "pencil.and.outline"
func summaryTitle(content: ToolContent) -> String {
// 提取 file_path
}
func subtitle(content: ToolContent) -> String? {
// 提取 content 字段,截取前 80 字符
// {"content": "import Foundation\n..."} → "import Foundation..."
guard let json = parseInput(content),
let contentStr = json["content"] as? String, !contentStr.isEmpty
else { return nil }
return "\(contentStr.prefix(80))..."
}
}
橙色主题 + 铅笔图标。比 Read 多一个 subtitle——显示写入内容的前 80 个字符。因为写入的内容通常很长,subtitle 给用户一个快速预览。
struct FileEditToolRenderer: ToolRenderable {
static let toolName = "Edit"
static let accentColor: Color = .orange
static let icon: String = "pencil.line"
func summaryTitle(content: ToolContent) -> String {
// 提取 file_path
}
func subtitle(content: ToolContent) -> String? {
// 提取 old_string,截取前 50 字符
// {"old_string": "func hello() {"} → "Editing: func hello() {"
guard let json = parseInput(content),
let oldString = json["old_string"] as? String, !oldString.isEmpty
else { return nil }
return "Editing: \(oldString.prefix(50))"
}
}
橙色主题 + 编辑图标。subtitle 显示被替换的旧文本片段——让用户知道 Edit 在改哪一行。
struct SearchToolRenderer: ToolRenderable {
static let toolName = "Grep"
static let accentColor: Color = .purple
static let icon: String = "text.magnifyingglass"
func summaryTitle(content: ToolContent) -> String {
// 提取 pattern
}
func subtitle(content: ToolContent) -> String? {
// 提取 path
}
}
紫色主题 + 放大镜图标。summaryTitle 显示搜索 pattern,subtitle 显示搜索路径。
| 工具 | 颜色 | 图标 | summaryTitle | subtitle |
|---|---|---|---|---|
| Bash | 绿色 | terminal | 命令 | - |
| Read | 蓝色 | doc.text | 文件路径 | - |
| Write | 橙色 | pencil.and.outline | 文件路径 | 内容前 80 字符 |
| Edit | 橙色 | pencil.line | 文件路径 | 被替换文本前 50 字符 |
| Grep | 紫色 | text.magnifyingglass | 搜索 pattern | 搜索路径 |
五种工具在折叠状态下就能一眼区分:颜色不同、图标不同、摘要文本不同。
ToolCardView 是工具卡片的容器。它不做具体的渲染,而是委托给注册表里查到的渲染器:
struct ToolCardView: View {
let content: ToolContent
let registry: ToolRendererRegistry
let isSelected: Bool
let onSelect: () -> Void
@State private var isExpanded = false
var body: some View {
HStack(spacing: 0) {
// 左边条(3px,渲染器的主题色)
RoundedRectangle(cornerRadius: 2)
.fill(toolAccentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 0) {
titleRow // 始终可见
.onTapGesture {
onSelect()
withAnimation { isExpanded.toggle() }
}
if isExpanded {
expandedContent // 展开后可见
}
}
}
}
}
卡片分两层:titleRow(始终可见)和 expandedContent(点击展开)。
private var titleRow: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: toolIcon) // 渲染器的图标
.foregroundStyle(toolIconColor)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(resolvedSummaryTitle) // 渲染器的 summaryTitle
.fontWeight(.medium)
Spacer()
if content.status == .running {
ProgressView().controlSize(.mini) // 运行中转圈
}
Text(statusLabel) // pending / running / completed / failed
.font(.system(size: 9))
.background(statusColor.opacity(0.15))
}
Text(content.toolName) // 工具名称(小字)
if let subtitle = resolvedSubtitle { // 渲染器的 subtitle
Text(subtitle)
}
}
}
}
标题行从渲染器获取图标、颜色、摘要标题和副标题。状态标签(pending/running/completed/failed)由 ToolContent.status 决定,不在渲染器的控制范围内——它是通用的执行状态,跟工具类型无关。
private var expandedContent: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
// 工具特定的 body(从渲染器获取)
if let renderer = registry.renderer(for: content.toolName) {
AnyView(renderer.body(content: content))
} else {
genericToolBody // fallback
}
// 通用 INPUT 区域
if !content.input.isEmpty {
HStack {
Text("INPUT")
Spacer()
CopyButton(text: content.input)
}
Text(content.input)
.font(.system(.caption, design: .monospaced))
}
// 通用 OUTPUT 区域
if let output = content.output, !output.isEmpty {
ToolResultContentView(output: output, isError: content.isError)
}
}
}
展开内容分三块:
body**:工具特定的自定义内容。目前的 5 个内置渲染器都在 body 里显示了一个带图标的摘要块——和 titleRow 里的信息类似但更详细。将来可以为复杂工具(比如显示代码 diff 预览)提供更丰富的 body。ToolResultContentView,下一节讲。genericToolBody 是没有注册渲染器时的 fallback——只显示工具名和原始输入。
ToolResultContentView 有一个智能功能:自动检测输出内容是不是 diff 格式,如果是就用颜色标注。
private var isDiffContent: Bool {
let lines = output.components(separatedBy: "\n")
let diffLines = lines.filter { $0.hasPrefix("+") || $0.hasPrefix("-") || $0.hasPrefix("@@") }
return diffLines.count >= 2
}
检测逻辑:如果输出里至少有两行以 +、-、@@ 开头,就认为是 diff 内容。简单但够用——SDK 的 Edit 工具输出 diff 格式的结果。
Diff 渲染给每行加背景色:
private func diffLineView(_ line: String) -> some View {
Text(line)
.font(.system(.caption, design: .monospaced))
.padding(.horizontal, 4)
.background(diffLineBackground(line))
}
private func diffLineBackground(_ line: String) -> Color {
if line.hasPrefix("+") { return .green.opacity(0.15) } // 新增行
if line.hasPrefix("-") { return .red.opacity(0.15) } // 删除行
if line.hasPrefix("@@") { return .blue.opacity(0.1) } // 位置标记
return .clear
}
非 diff 内容按普通文本渲染,有截断逻辑——超过 5 行或 200 字符时折叠,带展开按钮。
假设 SDK 新增了一个 WebFetch 工具,你想在 SwiftWork 里给它一个专属的卡片样式。只需要两个步骤:
第一步:写渲染器
struct WebFetchToolRenderer: ToolRenderable {
static let toolName = "WebFetch"
static let accentColor: Color = .cyan
static let icon: String = "globe"
@MainActor
func body(content: ToolContent) -> any View {
// 自定义视图...
}
func summaryTitle(content: ToolContent) -> String {
// 从 input 提取 URL
guard let json = parseInput(content),
let url = json["url"] as? String
else { return content.toolName }
return url
}
}
第二步:注册
// ToolRendererRegistry.init()
register(WebFetchToolRenderer())
不需要改 TimelineView、ToolCardView 或任何其他文件。ToolCardView 在渲染时通过 registry.renderer(for:) 查找渲染器,查到了就用,查不到就用 fallback。
Tool Card 系统的设计思路是协议 + 注册表:
| 组件 | 职责 |
|---|---|
ToolRenderable |
定义渲染契约——工具名、颜色、图标、摘要、自定义视图 |
ToolRendererRegistry |
字典查找,toolName → ToolRenderable |
ToolCardView |
容器视图,委托给渲染器,处理通用逻辑(展开/折叠、状态标签、INPUT/OUTPUT 区域) |
ToolResultContentView |
输出渲染,自动 diff 检测 |
这个模式的好处是开放扩展、关闭修改。TimelineView 的分派逻辑(第 2 篇的 toolCardView(for:))不需要知道有多少种工具——它只查注册表。新增工具类型时,改动的范围限定在渲染器文件和注册表的 init 方法。
下一篇是最后一篇,看数据层——SwiftData 的会话/事件持久化、App 状态恢复、Markdown 渲染和代码高亮。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 2 篇。系列目录见这里。
第 1 篇讲了 AgentBridge 怎么把 SDK 的 AsyncStream<SDKMessage> 变成 [AgentEvent]。这篇看 [AgentEvent] 变成什么——TimelineView 怎么渲染 18 种事件、怎么处理滚屏行为、怎么在事件量很大时保持流畅。
TimelineView 是工作区的主体,占满了侧边栏和输入框之间的所有空间。它的视图层级很浅:
TimelineView
├── ScrollView
│ ├── topPlaceholder (虚拟化占位)
│ ├── LazyVStack
│ │ └── ForEach(virtualizedEvents) → eventView(for:)
│ ├── bottomPlaceholder (虚拟化占位)
│ ├── StreamingTextView (流式文本)
│ └── bottom-anchor (滚动锚点)
└── returnToBottomButton (回到底部)
没有事件时显示空状态:"发送消息开始与 Agent 对话"。有事件时进入 ScrollViewReader + LazyVStack 的结构。
eventView(for:) 是事件分派的核心。18 种 AgentEventType 映射到 8 种视图:
@ViewBuilder
private func eventView(for event: AgentEvent) -> some View {
switch event.type {
case .userMessage: UserMessageView(event: event)
case .partialMessage: EmptyView()
case .assistant: AssistantMessageView(event: event)
case .toolUse: toolCardView(for: event)
case .toolResult,
.toolProgress: pairedToolEventView(for: event)
case .result: ResultView(event: event)
case .system: systemOrThinking(event: event)
case .hookStarted, .hookProgress, .hookResponse,
.taskStarted, .taskProgress, .authStatus,
.filesPersisted, .localCommandOutput,
.promptSuggestion, .toolUseSummary:
SystemEventView(event: event)
case .unknown: UnknownEventView(event: event)
}
}
几个值得说的分派逻辑:
partialMessage 渲染为 EmptyView。 流式文本不走 ForEach(events),而是在 LazyVStack 下方用单独的 StreamingTextView 渲染。原因在第 1 篇讲过——partialMessage 只累积在 streamingText 里,不进 events 数组。这样避免了 ForEach 频繁插入/删除带来的闪烁和性能开销。
toolUse 走 toolCardView,toolResult/toolProgress 走 pairedToolEventView。 如果 toolContentMap 里有对应的条目(说明已经收到了配对的 toolUse),toolUse 渲染为 ToolCardView,配对的 toolResult/toolProgress 渲染为 EmptyView——因为它们的内容已经合并在卡片里了。如果 toolContentMap 里没有(比如历史事件加载不完整),就 fallback 到简单的 ToolCallView/ToolResultView。
system 类型需要区分"思考中"和普通系统事件。 systemOrThinking 方法检查 metadata 里的 subtype:
private func systemOrThinking(event: AgentEvent) -> some View {
let subtype = event.metadata["subtype"] as? String ?? ""
let isLastEvent = agentBridge.events.last?.id == event.id
if (subtype == "init" || subtype == "status") && isLastEvent {
ThinkingView() // 旋转齿轮 + "思考中..."
} else if subtype == "init" || subtype == "status" {
ThinkingView(isActive: false) // 对勾 + "Agent 已响应"
} else if let isError = event.metadata["isError"] as? Bool, isError {
SystemEventView(event: event, isError: true) // 红色错误条
} else {
SystemEventView(event: event) // 普通系统消息
}
}
只有最后一条 init/status 事件才显示旋转动画。历史事件显示静态的"Agent 已响应"。这避免了所有历史思考状态都在转圈的问题。
struct UserMessageView: View {
let event: AgentEvent
var body: some View {
HStack {
Spacer()
Text(event.content)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.blue.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
用户消息右对齐,蓝色半透明背景,圆角矩形。跟 ChatGPT 的消息布局一致。
struct AssistantMessageView: View {
let event: AgentEvent
var body: some View {
HStack(alignment: .top, spacing: 0) {
RoundedRectangle(cornerRadius: 1)
.fill(Color.secondary.opacity(0.3))
.frame(width: 2)
.padding(.trailing, 8)
MarkdownContentView(markdown: event.content)
Spacer()
}
}
}
左边一条灰色竖线做视觉分隔,内容用 MarkdownContentView 渲染。这个组件处理 Markdown 解析、代码高亮和长文本折叠,第 4 篇会详细讲。
struct ThinkingView: View {
var isActive: Bool = true
@State private var isAnimating = false
var body: some View {
HStack(spacing: 8) {
if isActive {
Image(systemName: "gearshape")
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false),
value: isAnimating)
Text("思考中...")
} else {
Image(systemName: "checkmark.circle")
Text("Agent 已响应")
}
Spacer()
}
.onAppear { if isActive { isAnimating = true } }
}
}
isActive 控制两种状态:旋转齿轮表示正在思考,绿色对勾表示思考完成。onAppear 触发动画,视图滚出屏幕再滚回来时不会重新触发。
struct ResultView: View {
let event: AgentEvent
// 从 metadata 提取 durationMs、totalCostUsd、numTurns
var body: some View {
HStack(spacing: 4) {
Image(systemName: statusIcon) // checkmark.circle / pause.circle / xmark.circle
.foregroundStyle(statusColor)
Text(subtype) // success / cancelled / error
}
// 下方显示:耗时 | 轮数 | 费用
HStack(spacing: 12) {
Label("\(duration)ms", systemImage: "clock")
Label("\(turns) 轮", systemImage: "arrow.triangle.2.circlepath")
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
}
}
Result 事件显示执行结果的概要统计——耗时多少毫秒、经过多少轮对话、花费多少美元。错误时红底高亮。
struct SystemEventView: View {
let event: AgentEvent
let isError: Bool
var body: some View {
HStack(spacing: 4) {
if isError {
RoundedRectangle(cornerRadius: 1).fill(Color.red).frame(width: 3)
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
} else {
Image(systemName: "info.circle").foregroundStyle(.secondary)
}
Text(event.content)
}
.background(isError ? Color.red.opacity(0.08) : Color.clear)
}
}
普通系统消息一行灰色文字 + info 图标。错误消息加红色左边条 + 红色背景 + 警告图标。
Agent 在执行时会持续产出事件。用户通常想看到最新的事件(自动滚到底部),但有时候想往上翻看历史。这两个需求是冲突的。
SwiftWork 用 ScrollModeManager 管理两种模式的切换:
enum ScrollMode {
case followLatest // 自动跟随最新事件
case manualBrowse // 用户手动浏览历史
}
@MainActor
@Observable
final class ScrollModeManager {
var scrollMode: ScrollMode = .followLatest
var showReturnToBottomButton: Bool {
scrollMode == .manualBrowse
}
private let nearBottomThreshold: CGFloat = 96
private let scrollUpThreshold: CGFloat = 16
private var cumulativeUpwardDelta: CGFloat = 0
}
自动跟随的条件: 当用户距底部不超过 96pt 时,自动切回 followLatest。每次新事件到来,TimelineView 自动滚到底部。
切到手动浏览的条件: 用户向上滚动超过 16pt 时,切到 manualBrowse。此时新事件不再触发自动滚动,右下角显示"回到底部"按钮。
// TimelineView.swift
.onChange(of: agentBridge.events.count) { _, newCount in
updateVisibleRangeForCount(newCount)
if scrollModeManager.scrollMode == .followLatest {
scrollToLast(proxy: proxy)
}
}
.onChange(of: agentBridge.streamingText) { _, _ in
if scrollModeManager.scrollMode == .followLatest {
scrollToLast(proxy: proxy)
}
}
两个 onChange 监听事件数量变化和流式文本变化。只有在 followLatest 模式下才自动滚动。
回到底部按钮: 点击后切回 followLatest,更新 visibleRange 到最新 50 条事件,动画滚到底部:
Button {
scrollModeManager.returnToBottom()
let total = agentBridge.events.count
let lower = max(0, total - 50)
visibleRange = lower..<total
withAnimation {
proxy.scrollTo("bottom-anchor", anchor: .bottom)
}
}
当事件数量超过几百条时,全部渲染会导致 LazyVStack 创建大量视图,滚动掉帧。SwiftWork 用 visibleRange + renderBuffer 做虚拟化——只渲染可见区域附近的 ±20 条事件。
@MainActor
final class TimelineVirtualizationManager {
let renderBuffer = 20
func eventsToRender(visibleRange: Range<Int>, allEvents: [AgentEvent]) -> [AgentEvent] {
guard !allEvents.isEmpty else { return [] }
let lower = max(0, visibleRange.lowerBound - renderBuffer)
let upper = min(allEvents.count, visibleRange.upperBound + renderBuffer)
guard lower < upper else { return [] }
return Array(allEvents[lower..<upper])
}
}
传入 ForEach 的不是 agentBridge.events,而是 virtualizedEvents——经过虚拟化裁剪后的子集:
private var virtualizedEvents: [AgentEvent] {
let allEvents = agentBridge.events
if allEvents.isEmpty { return [] }
if visibleRange.isEmpty {
let upper = allEvents.count
let lower = max(0, upper - 50)
return virtualizationManager.eventsToRender(visibleRange: lower..<upper, allEvents: allEvents)
}
return virtualizationManager.eventsToRender(visibleRange: visibleRange, allEvents: allEvents)
}
被裁掉的区域用占位符撑高度,保持滚动条的位置准确:
private var topPlaceholder: some View {
let upper = max(0, visibleRange.lowerBound - virtualizationManager.renderBuffer)
return Group {
if upper > 0 && !visibleRange.isEmpty {
Spacer().frame(height: CGFloat(upper) * estimatedRowHeight)
}
}
}
estimatedRowHeight 取 80pt——一个经验值,大部分事件视图的高度在这个范围附近。不需要精确,只需要让滚动条的大致位置正确。
visibleRange 在几个关键时刻更新:
.task(id: agentBridge.events.first?.id)):设为最后 50 条事件.onChange(of: events.count)):如果在 followLatest 模式,滑动窗口保持最新 50 条目前没有实现滚动过程中的 visibleRange 动态更新——用户向上滚动浏览大量历史事件时,visibleRange 不会跟着滚动位置变化。这是一个已知的限制,将来可以通过 onAppear/onDisappear 回调或 ScrollView 的 offset 监听来实现。
首次加载事件列表时,SwiftUI 的 ScrollView 默认从顶部开始渲染。如果会话有几百条事件,用户会先看到顶部的事件,然后闪一下跳到底部。这个闪烁在每次切换会话时都会出现。
SwiftWork 的解决方案:延迟 150ms 后再滚动到底部,等 LazyVStack 完成首屏渲染:
.task(id: agentBridge.events.first?.id) {
hasCompletedInitialScroll = false
guard !agentBridge.events.isEmpty else { return }
scrollModeManager.scrollMode = .followLatest
visibleRange = 0..<0
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled else { return }
let total = agentBridge.events.count
let lower = max(0, total - 50)
visibleRange = lower..<total
withAnimation {
proxy.scrollTo("bottom-anchor", anchor: .bottom)
}
hasCompletedInitialScroll = true
}
hasCompletedInitialScroll 标记位控制后续的滚动模式切换——在初始滚动完成之前,onChange(of: scrollPositionId) 不会触发模式切换,避免干扰。
TimelineView 的设计可以概括为三个子系统:
| 子系统 | 解决的问题 | 实现 |
|---|---|---|
| 事件分派 | 18 种类型到 8 种视图 | eventView(for:) + ViewBuilder |
| 滚屏控制 | 自动跟随 vs 手动浏览 | ScrollModeManager + scrollPosition |
| 虚拟化 | 大量事件时的渲染性能 | visibleRange + renderBuffer + 占位符 |
事件分派是纯粹的视图逻辑——根据 event.type 选择对应的视图组件。滚屏控制和虚拟化是 TimelineView 独有的性能问题,跟 SDK 集成层无关。
下一篇看 Tool Card 系统——ToolRenderable 协议怎么让每种工具有自己的渲染器,以及 ToolRendererRegistry 怎么做到不改动时间线代码就能新增工具类型。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 1 篇。系列目录见这里。
第 0 篇画了全景图——AsyncStream<SDKMessage> → AgentBridge → EventMapper → SwiftUI。这篇拆开中间两层:AgentBridge 和 EventMapper,看它们怎么把 SDK 的消息流变成 SwiftUI 可以直接消费的事件列表。
先说结论:AgentBridge 是整个应用里最复杂的单个文件。它同时做了五件事——消费 Stream、映射事件、配对工具内容、持久化数据、管理内存。每一件都不难,但五件叠在一起要处理不少状态。这篇文章逐个讲清楚。
回顾一下 SDK 提供的核心接口(第 1 篇讲过的):
// SDK 的 Agent.stream() 返回 AsyncStream<SDKMessage>
let agent = createAgent(options: ...)
for await message in agent.stream("hello") {
switch message {
case .assistant(let data): ...
case .toolUse(let data): ...
case .toolResult(let data): ...
// 18 种类型
}
}
SDK 给你一个 AsyncStream<SDKMessage>——一个异步事件流。SwiftUI 需要一个 [AgentEvent]——一个可以在主线程渲染的数组。AgentBridge 就是这两者之间的桥。
它的核心状态只有几个:
@MainActor
@Observable
final class AgentBridge {
var events: [AgentEvent] = [] // SwiftUI 消费的事件数组
var isRunning = false // Agent 是否在执行
var streamingText: String = "" // 流式文本的累积缓冲区
var toolContentMap: [String: ToolContent] = [:] // 工具内容配对
var errorMessage: String? // 错误信息
@ObservationIgnored private var agent: Agent?
@ObservationIgnored private var currentTask: Task<Void, Never>?
// ...
}
@MainActor 保证所有状态都在主线程访问。@Observable 让 SwiftUI 自动追踪变化。@ObservationIgnored 标记的 agent 和 currentTask 不需要触发 UI 更新——它们是实现细节,不是 UI 状态。
用户在输入框打字,按回车。InputBarView 调用 agentBridge.sendMessage(text)。接下来发生的事情:
func sendMessage(_ text: String) {
guard let agent, !text.isEmpty else { return }
if isRunning { cancelExecution() } // 如果正在跑,先停掉
// 1. 用户消息立即追加到事件列表
let userEvent = AgentEvent(type: .userMessage, content: text, timestamp: .now)
appendAndPersist(userEvent)
errorMessage = nil
isRunning = true
// 2. 递增 generation 计数器(用于检测过期的 cancel)
activeTaskGeneration &+= 1
let myGeneration = activeTaskGeneration
// 3. 在后台 Task 中消费 stream
currentTask = Task { [weak self] in
guard let self else { return }
var receivedResult = false
let stream = agent.stream(text)
for await message in stream {
guard !Task.isCancelled else { break }
if case .userMessage = message { continue }
let event = EventMapper.map(message)
// 流式文本走单独的缓冲区,不进 events 数组
if event.type == .partialMessage {
self.streamingText += event.content
continue
}
if event.type == .assistant {
self.streamingText = ""
}
if event.type == .result {
receivedResult = true
self.onResult?(event.content)
}
self.appendAndPersist(event)
}
// 流结束但没收到 result → 异常终止
if !Task.isCancelled && !receivedResult {
self.appendAndPersist(AgentEvent(
type: .system,
content: "Agent 流异常结束,未收到完整响应。",
metadata: ["isError": true],
timestamp: .now
))
}
self.finalizeToolContentMap()
if self.activeTaskGeneration == myGeneration {
self.currentTask = nil
}
self.isRunning = false
}
}
几个值得注意的设计决策:
用户消息不等 Stream。 用户消息直接追加到 events,不等 SDK 的 AsyncStream 返回 .userMessage。这样 UI 可以立即显示用户输入,不用等网络往返。Stream 里收到的 .userMessage 被 continue 跳过。
流式文本有单独的缓冲区。 partialMessage 不进 events 数组,而是累积到 streamingText。当收到完整的 .assistant 事件时,清空 streamingText。这样 SwiftUI 的 TimelineView 可以用一个单独的 StreamingTextView 渲染正在输入的文本,而 ForEach(events) 不需要频繁插入再删除。
Generation 计数器防止 cancel 竞态。 activeTaskGeneration 是一个递增的计数器。每次 sendMessage 都递增它,记录自己的 generation。Stream 结束后检查 if self.activeTaskGeneration == myGeneration,只有当前 generation 匹配时才清空 currentTask。这防止了用户快速连续发消息时的 cancel 竞态——前一个 Stream 的 cancel 回调不会把新一个 Task 的引用清掉。
EventMapper 做的事情很纯粹:SDKMessage → AgentEvent。没有副作用,没有状态。
struct EventMapper {
static func map(_ message: SDKMessage) -> AgentEvent {
switch message {
case .partialMessage(let data):
return AgentEvent(type: .partialMessage, content: data.text, timestamp: .now)
case .assistant(let data):
return AgentEvent(type: .assistant, content: data.text,
metadata: ["model": data.model, "stopReason": data.stopReason],
timestamp: .now)
case .toolUse(let data):
return AgentEvent(type: .toolUse, content: data.toolName,
metadata: ["toolName": data.toolName, "toolUseId": data.toolUseId,
"input": data.input],
timestamp: .now)
case .toolResult(let data):
return AgentEvent(type: .toolResult, content: data.content,
metadata: ["toolUseId": data.toolUseId, "isError": data.isError],
timestamp: .now)
case .toolProgress(let data):
return AgentEvent(type: .toolProgress, content: data.toolName,
metadata: ["toolUseId": data.toolUseId, "toolName": data.toolName,
"elapsedTimeSeconds": data.elapsedTimeSeconds ?? 0],
timestamp: .now)
case .result(let data):
return AgentEvent(type: .result, content: data.text,
metadata: ["subtype": data.subtype.rawValue, "numTurns": data.numTurns,
"durationMs": data.durationMs, "totalCostUsd": data.totalCostUsd],
timestamp: .now)
case .system(let data):
return AgentEvent(type: .system, content: data.message,
metadata: ["subtype": data.subtype.rawValue], timestamp: .now)
// hook、task、auth 等消息全部映射为 system 类型
case .hookStarted, .hookProgress, .hookResponse,
.taskStarted, .taskProgress,
.authStatus, .filesPersisted,
.localCommandOutput, .promptSuggestion, .toolUseSummary:
return AgentEvent(type: .system, content: extractContent(from: message),
metadata: extractMetadata(from: message), timestamp: .now)
case .userMessage(let data):
return AgentEvent(type: .userMessage, content: data.message, timestamp: .now)
}
}
}
映射策略:
assistant、toolUse、toolResult、toolProgress、result、userMessage 各自对应一个 AgentEventTypehookStarted/hookProgress/hookResponse、taskStarted/taskProgress、authStatus、filesPersisted 等 10 种 SDK 消息全部映射成 .system 类型,通过 metadata 区分具体子类型metadata 字典里,UI 视图按 key 取用为什么要用 metadata: [String: any Sendable] 而不是给每种事件类型定义单独的 struct?因为 metadata 是一个灵活的字典——新增事件类型时只需要在 EventMapper 里加一个 case,不需要定义新的模型类型。代价是类型安全性降低,取值时需要 as? 转换。对于 UI 层来说,这个取舍是合理的——事件数据只在渲染时读取,不需要编译期类型检查。
SDK 的工具调用经历三个阶段:toolUse(开始)→ toolProgress(进度更新)→ toolResult(完成)。它们是三个独立的 SDKMessage,但 UI 需要展示为一个完整的工具卡片——包含工具名称、输入参数、执行进度、输出结果。
这就是 toolContentMap 的用途。它用 toolUseId 做键,把三个阶段的事件合并成一个 ToolContent:
// AgentBridge+ToolContentMap.swift
func processToolContentMap(for event: AgentEvent) {
switch event.type {
case .toolUse:
let content = ToolContent.fromToolUseEvent(event)
toolContentMap[content.toolUseId] = content
case .toolProgress:
let toolUseId = event.metadata["toolUseId"] as? String ?? ""
if let existing = toolContentMap[toolUseId] {
toolContentMap[toolUseId] = existing.applyingProgress(event)
}
case .toolResult:
let resultContent = ToolContent.fromToolResultEvent(event)
let toolUseId = resultContent.toolUseId
if let existing = toolContentMap[toolUseId] {
toolContentMap[toolUseId] = ToolContent(
toolName: existing.toolName,
toolUseId: existing.toolUseId,
input: existing.input,
output: resultContent.output,
isError: resultContent.isError,
status: resultContent.status,
elapsedTimeSeconds: existing.elapsedTimeSeconds
)
}
default:
break
}
}
配对过程:
toolUse → 创建 ToolContent,状态 .pendingtoolProgress → 更新已有条目,状态改为 .running,记录耗时toolResult → 合并输出和错误状态,状态改为 .completed 或 .failedToolContent 是一个 struct,每次更新都创建新副本。AgentBridge 的 toolContentMap 是 @Observable 追踪的属性,所以每次赋值都会触发 SwiftUI 更新。这意味着工具卡片可以实时显示进度变化。
还有一个 finalizeToolContentMap 方法——在 Stream 结束时调用,把所有还在 .pending 或 .running 状态的工具标记为 .completed。防止 Stream 异常终止时,UI 上永远停着一个转圈的进度条。
每条事件都经过 appendAndPersist,同时更新内存数组和数据库:
private func appendAndPersist(_ event: AgentEvent) {
events.append(event)
processToolContentMap(for: event)
guard event.type != .partialMessage,
let eventStore, let currentSession else { return }
totalPersistedEvents += 1
try eventStore.persist(event, session: currentSession, order: eventOrder)
eventOrder += 1
trimOldEvents()
}
持久化通过 EventStoring 协议抽象:
@MainActor
protocol EventStoring {
func persist(_ event: AgentEvent, session: Session, order: Int) throws
func fetchEvents(for sessionID: UUID) throws -> [AgentEvent]
func fetchEvents(for sessionID: UUID, offset: Int, limit: Int) throws -> [AgentEvent]
func totalEventCount(for sessionID: UUID) throws -> Int
}
目前只有一个实现 SwiftDataEventStore,用 SwiftData 的 ModelContext 做存储。序列化是手写的 JSON——EventSerializer 把 AgentEvent 转成 [String: Any] 的字典再压成 Data:
// SwiftData 的 Event 模型
@Model
final class Event {
@Attribute(.unique) var id: UUID
var sessionID: UUID
var eventType: String
var rawData: Data // JSON 序列化的 AgentEvent
var timestamp: Date
var order: Int
var session: Session?
}
为什么把 metadata 塞进 rawData 而不是拆成独立的 SwiftData 字段?因为 metadata 的内容因事件类型而异——toolUse 有 toolName/toolUseId/input,result 有 numTurns/durationMs/totalCostUsd。拆成独立字段会导致大量空列,而且每次新增事件类型都要改 Schema。用一个 JSON blob 存储,读取时再反序列化,更灵活。
持久化的写入时机是每条事件一次。对于 Agent 的一次典型执行(可能产生 50-100 条事件),这意味着 50-100 次 SwiftData 写入。实测没有性能问题——SwiftData 在内存中缓存,批量刷盘。如果将来事件量更大,可以改成批量写入。
Agent 的一次复杂执行可能产生上千条事件。全部留在内存里不现实。AgentBridge 用了两层策略:
private let maxInMemory = 500
func trimOldEvents() {
guard events.count > maxInMemory else { return }
let removeCount = events.count - maxInMemory
let removed = Array(events.prefix(removeCount))
events.removeFirst(removeCount)
trimmedEventCount += removeCount
for event in removed {
if event.type == .toolUse {
let toolUseId = event.metadata["toolUseId"] as? String ?? ""
toolContentMap.removeValue(forKey: toolUseId)
}
}
}
内存数组最多保留 500 条事件。超出部分从头部删除,同时清理 toolContentMap 里对应的条目。trimmedEventCount 记录已经删除了多少条,用于分页查询时的偏移计算。
切换会话时,loadEvents 按总量决定加载策略:
func loadEvents(for session: Session) {
clearEvents()
currentSession = session
guard let eventStore else { return }
let total = try eventStore.totalEventCount(for: session.id)
totalPersistedEvents = total
if total > 1000 {
// 大会话:只加载第一页
let firstPage = try eventStore.fetchEvents(for: session.id, offset: 0, limit: 50)
events = firstPage
eventOrder = total
} else {
// 小会话:全部加载
let persisted = try eventStore.fetchEvents(for: session.id)
events = persisted
eventOrder = persisted.count
}
rebuildToolContentMap()
}
用户向上滚动时,loadMoreEvents 按页追加:
func loadMoreEvents() {
guard let eventStore, let currentSession else { return }
let offset = trimmedEventCount + events.count
guard offset < totalPersistedEvents else { return }
let remaining = totalPersistedEvents - offset
let limit = min(pageSize, remaining)
let nextPage = try eventStore.fetchEvents(for: currentSession.id, offset: offset, limit: limit)
events.append(contentsOf: nextPage)
rebuildToolContentMap()
}
hasMoreEvents 是一个计算属性,SwiftUI 可以用它显示"加载更多"按钮:
var hasMoreEvents: Bool {
totalPersistedEvents > trimmedEventCount + events.count
}
SDK 的 permissionMode: .default 会在工具执行前询问用户是否允许。AgentBridge 通过 setCanUseTool 回调接入这个机制:
private func setupPermissionCallback() {
agent?.setCanUseTool { [weak self] tool, input, _ in
guard let self else { return .allow() }
return await self.handlePermission(tool: tool, input: input)
}
}
PermissionHandler 先检查已有的权限规则(用户之前选过"始终允许"的工具)。如果规则匹配,直接放行。如果没有匹配的规则,弹出一个原生的 SwiftUI sheet 让用户审批:
var pendingPermissionRequest: PendingPermissionRequest?
PendingPermissionRequest 内部用一个 CheckedContinuation 挂起异步执行,等用户点击"允许一次"/"始终允许"/"拒绝"后恢复:
private func presentPermissionDialog(...) async -> CanUseToolResult {
let request = PendingPermissionRequest(...)
self.pendingPermissionRequest = request
let dialogResult = await request.waitForResult() // 挂起,等 UI 操作
self.pendingPermissionRequest = nil
switch dialogResult {
case .allowOnce: // 本次允许
case .alwaysAllow: // 写入持久规则
case .deny: // 拒绝
}
}
这个设计把 SDK 的同步权限检查(canUseTool 回调)和 SwiftUI 的异步 UI 交互(用户点击按钮)桥接在一起,靠 Swift 的 async/await + CheckedContinuation 实现。
AgentBridge 的配置入口是 configure:
func configure(apiKey: String, baseURL: String?, model: String, workspacePath: String?) {
let options = AgentOptions(
apiKey: apiKey,
model: model,
baseURL: baseURL,
maxTurns: 10,
permissionMode: .default,
cwd: workspacePath,
tools: getAllBaseTools(tier: .core)
)
self.agent = createAgent(options: options)
setupPermissionCallback()
}
每次用户切换会话,WorkspaceView 会重新调用 configure(因为不同会话可能有不同的 workspace path):
// WorkspaceView.swift
.onChange(of: session.id) { _, _ in
agentBridge.clearEvents()
configureAgent() // 重新创建 Agent
loadPersistedEvents() // 加载该会话的历史事件
setupTitleGeneration() // 设置自动标题
}
clearEvents 做完整的重置——清空事件数组、取消正在执行的 Task、重置分页状态:
func clearEvents() {
events = []
streamingText = ""
errorMessage = nil
isRunning = false
toolContentMap = [:]
currentTask?.cancel()
currentTask = nil
eventOrder = 0
totalPersistedEvents = 0
trimmedEventCount = 0
}
AgentBridge 承担了五个职责:
| 职责 | 实现方式 |
|---|---|
| 消费 Stream | Task 里 for await 循环,cancel 时 Task.cancel() |
| 映射事件 | EventMapper.map() 纯函数 |
| 配对工具内容 | toolContentMap: [String: ToolContent] |
| 持久化 | EventStoring 协议 + SwiftData 实现 |
| 内存管理 | 500 条滑动窗口 + 按需分页加载 |
整条管线在 @MainActor 上运行,SwiftUI 通过 @Observable 自动响应变化。视图层不需要知道 Stream 的存在,不需要知道 SDK 的类型,只需要处理 AgentEvent 和 ToolContent。
下一篇看事件时间线——TimelineView 怎么渲染 18 种事件、怎么做虚拟化、怎么处理流式文本和滚动行为。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 0 篇。系列目录见这里。
前面七篇文章加上番外篇,我们把 Open Agent SDK 的内部机制翻了个底朝天——Agent Loop、工具系统、MCP 集成、多 Agent 协作、会话持久化、多 LLM 支持。番外篇还把 SDK 塞进了一个 macOS 原生应用 Motive 里跑了跑。
但 Motive 只是一个替换后端的实验。真正的问题是:拿到 SDK 之后,怎么从零开始构建一个完整的 Agent 应用? SDK 给了你 Agent 的"大脑"——但用户看到的不是 Agent Loop,而是一个界面。Agent 在调工具、读文件、执行命令的时候,用户需要知道它在干什么、进展如何、结果是什么。
这就是 SwiftWork 要解决的问题——一个 macOS 原生的 Agent 可视化工作台。
SwiftWork 是一个 macOS 原生的 AI Agent 桌面应用。它做的事情用一句话概括:让用户看到 Agent 正在做什么。
具体来说:
这不是一个终端里的 CLI 工具,也不是一个网页应用。它是用 SwiftUI 写的原生 macOS 应用,用 @Observable 做状态管理,用 SwiftData 做数据持久化,用 Apple 原生的渲染管线做 Markdown 和代码高亮。
做 SwiftWork 有两个动机。
第一,SDK 需要一个"展示应用"。 SDK 的 31 个示例项目覆盖了各种用法——流式输出、自定义工具、MCP 集成——但都是命令行工具。SDK 的能力需要一个 GUI 来完整展示,尤其是工具调用的可视化、事件流的实时渲染这些 CLI 做不好的事情。
第二,Agent 应用的可视化是一个被低估的问题。 当前的 Agent 应用(包括 Claude Code 自己)在终端里跑,用户看到的是滚动的文字流。但 Agent 在执行一个复杂任务时,可能调用十几次工具、读写多个文件、执行多条命令。终端里的线性输出很难让用户理解全局进展。SwiftWork 试图用事件时间线和工具卡片来改善这个问题。
SwiftWork 采用事件驱动架构。整条数据流是一条单向管线:
SDK Agent Loop
│
│ AsyncStream<SDKMessage>
▼
AgentBridge (@Observable)
│
│ EventMapper.map() → AgentEvent
▼
AgentBridge.events: [AgentEvent]
│
│ SwiftUI 自动响应 @Observable 变化
▼
TimelineView → 各 EventView
四个角色:
| 组件 | 职责 |
|---|---|
| Agent Loop | SDK 提供,跑 Agent 的推理循环,产出 SDKMessage 流 |
| AgentBridge | 消费 AsyncStream<SDKMessage>,映射成 AgentEvent,管理生命周期 |
| EventMapper | 纯函数,SDKMessage → AgentEvent 的类型映射 |
| TimelineView | SwiftUI 视图,消费 AgentEvent 数组,渲染时间线 |
核心设计决策:视图不直接接触 SDK 类型。 AgentEvent 是 SwiftWork 自己定义的 UI 模型,跟 SDK 的 SDKMessage 完全解耦。视图只认 AgentEvent,不知道也不关心事件来自哪个 SDK 版本。
用户发一条消息,整条管线的运转过程:
// AgentBridge.swift
func sendMessage(_ text: String) {
// 用户消息直接追加到事件列表
let userEvent = AgentEvent(type: .userMessage, content: text, timestamp: .now)
appendAndPersist(userEvent)
isRunning = true
// 在后台 Task 中消费 stream
currentTask = Task { [weak self] in
let stream = agent.stream(text)
for await message in stream {
let event = EventMapper.map(message)
self.appendAndPersist(event)
}
self.isRunning = false
}
}
用户消息先追加到事件列表(即时显示),然后启动一个 Task 来消费 SDK 的 AsyncStream。
EventMapper 是一个纯函数,把 SDK 的 18 种 SDKMessage 映射成 SwiftWork 的 AgentEventType:
// EventMapper.swift
static func map(_ message: SDKMessage) -> AgentEvent {
switch message {
case .assistant(let data):
return AgentEvent(type: .assistant, content: data.text,
metadata: ["model": data.model, "stopReason": data.stopReason], timestamp: .now)
case .toolUse(let data):
return AgentEvent(type: .toolUse, content: data.toolName,
metadata: ["toolName": data.toolName, "toolUseId": data.toolUseId, "input": data.input],
timestamp: .now)
case .toolResult(let data):
return AgentEvent(type: .toolResult, content: data.content,
metadata: ["toolUseId": data.toolUseId, "isError": data.isError], timestamp: .now)
// ... 18 种消息类型
}
}
为什么要有这一层映射?因为 SDK 的类型是给 Agent 运行时用的,它包含很多 UI 不需要的细节。AgentEvent 只保留 UI 渲染需要的字段:类型、内容、元数据、时间戳。视图不需要知道 SDKMessage 的枚举定义,只需要处理 AgentEventType。
每条事件都经过 appendAndPersist,同时更新内存数组和 SwiftData 数据库:
private func appendAndPersist(_ event: AgentEvent) {
events.append(event)
processToolContentMap(for: event)
guard event.type != .partialMessage,
let eventStore, let currentSession else { return }
try eventStore.persist(event, session: currentSession, order: eventOrder)
eventOrder += 1
trimOldEvents()
}
注意 partialMessage 不持久化——它是流式文本的中间片段,累积完成后会生成一条完整的 .assistant 事件。
AgentBridge 标记了 @Observable。当 events 数组变化时,TimelineView 自动重新渲染:
// TimelineView.swift
ForEach(virtualizedEvents) { event in
eventView(for: event)
}
eventView 根据 event.type 分派到不同的视图组件——UserMessageView、AssistantMessageView、ToolCardView、SystemEventView 等。
SwiftWork/
├── App/
│ ├── SwiftWorkApp.swift # @main 入口,注册 SwiftData 模型
│ └── ContentView.swift # NavigationSplitView 根视图
├── Models/
│ ├── UI/ # UI 模型层
│ │ ├── AgentEvent.swift # 事件模型(SwiftUI 渲染用)
│ │ ├── AgentEventType.swift # 18 种事件类型枚举
│ │ ├── ToolContent.swift # 工具内容(配对 toolUse + toolResult)
│ │ ├── PermissionDecision.swift # 权限决策
│ │ └── AppError.swift # 错误模型
│ └── SwiftData/ # 持久化模型层
│ ├── Session.swift # 会话
│ ├── Event.swift # 持久化事件
│ ├── AppConfiguration.swift # 应用配置
│ └── PermissionRule.swift # 权限规则
├── ViewModels/
│ ├── SessionViewModel.swift # 会话 CRUD
│ └── SettingsViewModel.swift # 设置管理
├── Views/
│ ├── Sidebar/ # 会话列表
│ ├── Workspace/
│ │ ├── Timeline/
│ │ │ ├── TimelineView.swift # 时间线主视图 + 虚拟化
│ │ │ ├── EventViews/ # 各事件类型视图 + ToolCardView
│ │ │ │ ├── ToolRenderers/ # 5 个内置工具渲染器
│ │ │ │ ├── StreamingTextView.swift
│ │ │ │ ├── MarkdownContentView.swift
│ │ │ │ └── ...
│ │ │ └── Inspector/ # 事件详情面板
│ │ └── InputBar/ # 消息输入框
│ ├── Settings/ # 设置界面
│ ├── Onboarding/ # 首次启动引导
│ └── Permission/ # 权限审批弹窗
├── SDKIntegration/
│ ├── AgentBridge.swift # SDK ↔ ViewModel 桥接
│ ├── AgentBridge+ToolContentMap.swift # 工具内容配对逻辑
│ ├── EventMapper.swift # SDKMessage → AgentEvent
│ ├── ToolRenderable.swift # 工具渲染协议
│ └── ToolRendererRegistry.swift # 工具渲染注册表
├── Services/
│ ├── CodeHighlighter.swift # Splash 代码高亮
│ ├── MarkdownRenderer.swift # swift-markdown 渲染
│ ├── KeychainManager.swift # API Key 安全存储
│ ├── EventStore.swift # 事件持久化接口
│ ├── AppStateManager.swift # 应用状态保存/恢复
│ └── TitleGenerator.swift # 自动生成会话标题
└── Utils/
└── Extensions/ # 颜色、日期格式化等
结构上的核心分层:
AgentEvent)是给 SwiftUI 渲染用的,SwiftData 模型(Event)是给持久化用的。两者之间有转换逻辑。OpenAgentSDK。| 组件 | 选择 | 原因 |
|---|---|---|
| 语言 | Swift 6.1 严格并发 | Agent SDK 要求,Sendable 保证线程安全 |
| UI | SwiftUI + @Observable |
macOS 14+ 支持,跟 Swift 并发配合好 |
| 持久化 | SwiftData | 跟 SwiftUI 深度集成,比 Core Data 简洁 |
| Markdown | swift-markdown (Apple) | 原生 Apple 库,CommonMark 兼容 |
| 代码高亮 | Splash (John Sundell) | 轻量、支持 Swift/Python/JS/Bash |
| 自动更新 | Sparkle 2.x | macOS 应用更新的标准方案 |
| Agent SDK | Open Agent SDK | 自己写的 SDK,当然用自己的 |
这篇文章给了一个全景图。接下来的文章会逐层拆开,看每个子系统怎么实现:
AsyncStream<SDKMessage>、映射事件、管理生命周期ToolRenderable 协议和可扩展的工具渲染器相关链接:
本文是「深入 Open Agent SDK (Swift)」系列番外篇。系列目录见这里。
前七篇文章从各个子系统分析了 Open Agent SDK 的设计。但 SDK 写得好不好,最终得放到真实项目里验证。这篇文章记录我把 SDK 集成到一个开源 macOS 原生 Agent 应用——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 进程(如果没在跑的话)POST /sessions 创建会话POST /sessions/{id}/prompt 发送 prompt这套架构能用,但有几个问题:
opencode CLI,Motive 还要处理二进制签名、路径查找SDK 的出现正好给了另一种可能——把 Agent Loop 直接跑在应用进程内。
我想做的替换:不启动外部进程,不经过 HTTP,直接在 Motive 进程内用 SDK 的 Agent.stream() 跑 Agent Loop。
目标架构:
Motive App (SwiftUI)
└── BackendBridge (enum wrapper)
├── .opencode → OpenCodeBridge (原有架构,保留)
└── .sdk → SDKBridge (新增,用 OpenAgentSDK)
└── Agent.stream() → 直接在进程内跑 Agent Loop
保留原有的 OpenCodeBridge 作为备选,让用户可以在设置中切换后端类型。这是一个务实的决定——万一 SDK 后端有问题,用户还能切回去。
原有的 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?因为 OpenCodeBridge 和 SDKBridge 的能力不完全一样。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 是整个替换的核心。它是一个 actor,负责:
Configuration(API key、model、MCP servers 等)createAgent() 创建 AgentAgent.stream() 获取流式响应SDKMessage 映射成 Motive 已有的 OpenCodeEventactor 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。
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
))
}
注意几个细节:
"anthropic"、"openai"),SDK 用 LLMProvider 枚举,这里做了转换这是最核心的方法。用户每次发 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 会冲突。
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 不知道也不关心事件来自哪个后端。
这不是一次顺利的替换。以下是我遇到的真实问题。
这是最头疼的问题。macOS 的 GUI 应用不继承用户的 shell 环境。SDK 的 MCPStdioTransport 用 Process 启动 MCP 子进程时,PATH 里没有 nvm、homebrew 等路径——MCP 服务器找不到 node、python。
解决方案:在 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 环境。
SDK 的 assembleFullToolPool() 在没有 MCP 服务器时走了一条短路径——只返回 baseTools(用户自定义工具),不包含内置的 Core 和 Specialist 工具。这意味着如果不配 MCP,Agent 连 Read、Write、Bash 都没有。
修复:在 createAgent() 里始终传入 core + specialist 工具:
let coreTools = getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist)
return OpenAgentSDK.createAgent(options: AgentOptions(
// ...
tools: coreTools, // 始终包含
// ...
))
AppState.start() 里异步配置 bridge,但用户可能在配置完成之前就发了 prompt。这导致 "SDK bridge not configured" 错误。
修复:在每次 submitIntent 和 resumeSession 之前都调用 configureBridge(),确保配置是最新的:
func submitIntent(...) async {
await configureBridge() // 先确保配置完成
// 然后检查配置是否成功
guard configuration != nil else { ... }
// ...
}
OpenAgentSDK 的类型命名跟 Swift 标准库有冲突——SDK 里有个 Task 类型(用于任务追踪),跟 Swift 并发的 Task 撞了。直接写 Task { } 编译器会找错类型。
用 typealias 解决:
private typealias _Task = _Concurrency.Task
然后所有地方用 _Task { } 代替 Task { }。
不是所有 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。
为了让 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 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)总共净增约 600 行,换来的是去掉了对外部二进制的依赖。
这次集成验证了 SDK 在以下方面的工程表现:
能用的部分:
Agent.stream() 的 AsyncStream<SDKMessage> 接口简洁,可以直接用在 SwiftUI 的响应式流程里SessionStore 的会话持久化开箱即用,不需要自己管理 JSON 文件permissionMode: .bypassPermissions 适合桌面应用的自动执行场景需要注意的部分:
Task 命名冲突需要手动解决assembleFullToolPool() 在无 MCP 时的短路径行为需要了解清楚整体评价: SDK 的 API 设计对 GUI 应用集成是友好的。核心的 createAgent + stream 两个调用就替代了原来启动外部进程 + HTTP 服务 + SSE 客户端 + REST API 客户端四个组件。对于一个 361 行的 actor 来说,这个替换比是合理的。
完整代码在 terryso/motive,已经合并了 SDK 后端,可以直接 clone 下来跑。
系列文章:
相关链接:
本文是「深入 Open Agent SDK (Swift)」系列第六篇(完结篇)。
一个 Agent 不应该绑定单一 LLM 提供商。不同任务适合不同模型——简单问题用便宜模型,复杂推理用贵模型,有些场景甚至需要本地模型。而且运行时的需求也在变化:用户可能中途要求更深度的思考,可能发现预算快用完了需要降级,可能想切换到本地模型省点钱。
Open Agent SDK 的做法是:定义一个统一的 LLMClient 协议,Anthropic 和 OpenAI 兼容提供商各有一个实现,Agent 内部全部用 Anthropic 格式处理。切换提供商只需要改一个配置参数,运行时还能动态切模型、调思考深度、控预算。
这篇文章分析 SDK 的多提供商适配机制和运行时控制能力。
先看协议定义:
public protocol LLMClient: Sendable {
nonisolated func sendMessage(
model: String,
messages: [[String: Any]],
maxTokens: Int,
system: String?,
tools: [[String: Any]]?,
toolChoice: [String: Any]?,
thinking: [String: Any]?,
temperature: Double?
) async throws -> [String: Any]
nonisolated func streamMessage(
model: String,
messages: [[String: Any]],
maxTokens: Int,
system: String?,
tools: [[String: Any]]?,
toolChoice: [String: Any]?,
thinking: [String: Any]?,
temperature: Double?
) async throws -> AsyncThrowingStream<SSEEvent, Error>
}
两个核心方法,一个阻塞一个流式。参数列表覆盖了主流 LLM API 的全部能力:模型选择、消息历史、token 上限、系统提示、工具定义、工具选择策略、思考配置、温度。
关键决策:返回值统一用 Anthropic 格式的字典。不管是 Anthropic 原生 API 还是 OpenAI 兼容 API,最终 Agent 内部拿到的都是同一种结构——content 数组里是 {"type": "text", "text": "..."} 或 {"type": "tool_use", "name": "...", "input": {...}},stop_reason 是 end_turn / tool_use / max_tokens。这样 Agent Loop 的处理逻辑不需要关心底层是哪家 API。
流式返回用 AsyncThrowingStream<SSEEvent, Error>,SSEEvent 是枚举:
public enum SSEEvent: @unchecked Sendable {
case messageStart(message: [String: Any])
case contentBlockStart(index: Int, contentBlock: [String: Any])
case contentBlockDelta(index: Int, delta: [String: Any])
case contentBlockStop(index: Int)
case messageDelta(delta: [String: Any], usage: [String: Any])
case messageStop
case ping
case error(data: [String: Any])
}
7 种事件类型,覆盖了 Anthropic Messages API 流式响应的全部事件。OpenAI 兼容层的流式输出会被转换成同样的 SSEEvent 序列。
AnthropicClient 是 LLMClient 的 Anthropic 原生实现,用 actor 保证并发安全:
public actor AnthropicClient: LLMClient {
private let apiKey: String
private let baseURL: URL // 默认 https://api.anthropic.com
private let urlSession: URLSession
public init(apiKey: String, baseURL: String? = nil, urlSession: URLSession? = nil) {
self.apiKey = apiKey
self.baseURL = URL(string: baseURL ?? "https://api.anthropic.com")!
self.urlSession = urlSession ?? URLSession.shared
}
}
请求就是 POST 到 /v1/messages,header 里放 x-api-key 和 anthropic-version:
private nonisolated func buildRequest(body: [String: Any]) throws -> URLRequest {
var request = URLRequest(url: URL(string: baseURL.absoluteString + "/v1/messages")!)
request.httpMethod = "POST"
request.timeoutInterval = 300
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
return request
}
因为用的是 Anthropic 原生 API,所以 sendMessage 的请求体和响应体不需要格式转换——请求参数直接拼成字典发出去,响应直接解析成字典返回。流式模式也是直接解析 Anthropic 的 SSE 文本。
安全方面有个细节:所有错误信息都会把 API Key 替换成 ***,防止 key 泄露到日志里:
let safeMessage = errorMessage.replacingOccurrences(of: apiKey, with: "***")
AnthropicClient 直接支持 Extended Thinking。Agent 在配置了 ThinkingConfig 时,会把 thinking 参数传进来:
if let thinking {
body["thinking"] = thinking
}
这个参数在 Anthropic API 里控制 Claude 是否进行深度思考以及思考的 token 预算。
OpenAIClient 是重头戏。它要做的事情是:接受 Anthropic 格式的参数,转换成 OpenAI Chat Completion API 格式发出去,再把 OpenAI 格式的响应转换回 Anthropic 格式。Agent 内部完全不知道底层是 OpenAI 兼容 API。
public actor OpenAIClient: LLMClient {
private let apiKey: String
private let baseURL: URL // 默认 https://api.openai.com/v1
public init(apiKey: String, baseURL: String? = nil, urlSession: URLSession? = nil) {
self.apiKey = apiKey
self.baseURL = URL(string: baseURL ?? "https://api.openai.com/v1")!
self.urlSession = urlSession ?? URLSession.shared
}
}
请求发到 /chat/completions,用 Bearer token 认证——这是 OpenAI 兼容 API 的标准做法。只要提供商支持 /v1/chat/completions 端点,就能用这个 Client 连接。
Anthropic 和 OpenAI 的消息格式有几个关键差异,转换时都要处理:
1. System 消息的位置
Anthropic 把 system prompt 作为顶层参数传,OpenAI 把它作为第一条 role: "system" 消息:
if let system {
result.append(["role": "system", "content": system])
}
2. Tool Result 的表示方式
Anthropic 把多个 tool_result 打包在一个 role: "user" 消息的 content 数组里,OpenAI 要求每个 tool result 是一条独立的 role: "tool" 消息:
let toolResults = blocks.filter { $0["type"] as? String == "tool_result" }
if !toolResults.isEmpty {
return toolResults.map { block in
[
"role": "tool",
"tool_call_id": block["tool_use_id"] as? String ?? "",
"content": block["content"] ?? "",
]
}
}
3. Tool Use 的表示方式
Anthropic 在 content 数组里用 type: "tool_use" 块,OpenAI 用 tool_calls 数组放在 message 顶层:
result["tool_calls"] = toolUseBlocks.enumerated().map { index, block in
let inputDict = block["input"] as? [String: Any] ?? [:]
let arguments = (try? JSONSerialization.data(withJSONObject: inputDict, options: []))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return [
"id": block["id"] as? String ?? "call_\(index)",
"type": "function",
"function": [
"name": block["name"] as? String ?? "",
"arguments": arguments, // OpenAI 要求 JSON 字符串,不是字典
],
]
}
注意 OpenAI 的 arguments 必须是 JSON 字符串而不是字典对象,这里做了序列化。
OpenAI 的响应结构(choices[0].message)要转成 Anthropic 格式:
// stop_reason 映射
private static func mapStopReason(_ finishReason: String) -> String {
switch finishReason {
case "stop": return "end_turn"
case "tool_calls": return "tool_use"
case "length": return "max_tokens"
default: return finishReason
}
}
// usage 映射
usage = [
"input_tokens": openAIUsage["prompt_tokens"] as? Int ?? 0,
"output_tokens": openAIUsage["completion_tokens"] as? Int ?? 0,
]
流式的转换更复杂。OpenAI 的流式格式(data: {"choices":[{"delta":{...}}]})要逐块转成 Anthropic 的 SSEEvent 序列:
messageStartcontentBlockDelta(type: "text_delta")contentBlockStart(type: "tool_use"),参数 delta → contentBlockDelta(type: "input_json_delta")contentBlockStop + messageDelta + messageStop转换函数要跟踪当前有多少个 content block、文本块是否关闭、哪些 tool call 块还在打开状态,才能正确生成 index。代码里还加了一个安全检查——确保 messageStop 一定会被发出,即使原始流没有正常结束。
连接不同的 OpenAI 兼容提供商只需要改 baseURL 和 model:
// DeepSeek
let agent = createAgent(options: AgentOptions(
apiKey: "sk-...",
model: "deepseek-chat",
baseURL: "https://api.deepseek.com/v1",
provider: .openai
))
// Ollama 本地
let localAgent = createAgent(options: AgentOptions(
apiKey: "ollama", // Ollama 不需要 key,随便填
model: "qwen3:8b",
baseURL: "http://localhost:11434/v1",
provider: .openai
))
// GLM
let glmAgent = createAgent(options: AgentOptions(
apiKey: "xxx.glm-xxx",
model: "glm-4-plus",
baseURL: "https://open.bigmodel.cn/api/paas/v4",
provider: .openai
))
SDK 支持在运行时动态切换模型,不需要重新创建 Agent:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
fallbackModel: "claude-haiku-4-5" // 主模型挂了用这个
))
// 先用 sonnet 跑一个简单问题
let result1 = await agent.prompt("What is 2 + 3?")
print(result1.costBreakdown)
// [CostBreakdownEntry(model: "claude-sonnet-4-6", inputTokens: 45, outputTokens: 3, costUsd: 0.000180)]
// 切换到 opus 跑推理密集型问题
try agent.switchModel("claude-opus-4-6")
let result2 = await agent.prompt("Explain the difference between structs and classes in Swift.")
print(result2.costBreakdown)
// [CostBreakdownEntry(model: "claude-opus-4-6", inputTokens: 52, outputTokens: 156, costUsd: 0.011970)]
switchModel() 的实现:
public func switchModel(_ model: String) throws {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw SDKError.invalidConfiguration("Model name cannot be empty")
}
let oldModel = self.model
self.model = trimmed
self.options.model = trimmed
Logger.shared.info("Agent", "model_switch", data: ["from": oldModel, "to": trimmed])
}
不做白名单校验——传什么模型名就用什么,API 层面不支持的模型会在请求时报错。这样设计是因为 OpenAI 兼容提供商的模型名无法穷举。
fallbackModel 是在 AgentOptions 里配置的备用模型。主模型彻底失败(重试耗尽)后,SDK 会自动用 fallback model 重试一次:
if let fallbackModel = self.options.fallbackModel, fallbackModel != self.model {
let fallbackResponse = try await retryClient.sendMessage(
model: fallbackModel,
messages: retryMessages, ...
)
// 临时切到 fallback model 跑 cost tracking
let originalModel = self.model
self.model = fallbackModel
// ... 处理响应
}
CostBreakdownEntry 按模型名分组记录每次查询的费用:
public struct CostBreakdownEntry: Sendable, Equatable {
public let model: String
public let inputTokens: Int
public let outputTokens: Int
public let costUsd: Double
}
一次查询里如果中途切了模型(或触发了 fallback),QueryResult.costBreakdown 会包含多个条目,每个模型的花费分开算。费用根据内置的价格表计算:
public nonisolated(unsafe) var MODEL_PRICING: [String: ModelPricing] = [
"claude-opus-4-6": ModelPricing(input: 15.0 / 1_000_000, output: 75.0 / 1_000_000),
"claude-sonnet-4-6": ModelPricing(input: 3.0 / 1_000_000, output: 15.0 / 1_000_000),
"claude-haiku-4-5": ModelPricing(input: 0.8 / 1_000_000, output: 4.0 / 1_000_000),
// ...
]
自定义模型可以通过 registerModel(_:pricing:) 注册价格:
registerModel("glm-4-plus", pricing: ModelPricing(
input: 0.1 / 1_000_000, output: 0.1 / 1_000_000
))
SDK 用 ThinkingConfig 枚举控制 LLM 的深度思考能力:
public enum ThinkingConfig: Sendable, Equatable {
case adaptive // 模型自己决定要不要思考
case enabled(budgetTokens: Int) // 指定思考的 token 预算
case disabled // 关闭深度思考
}
三种模式各有用途:
EffortLevel 是更高层级的抽象,映射到具体的 thinking token 预算:
public enum EffortLevel: String, Sendable, CaseIterable {
case low // 1024 tokens
case medium // 5120 tokens
case high // 10240 tokens
case max // 32768 tokens
public var budgetTokens: Int {
switch self {
case .low: return 1024
case .medium: return 5120
case .high: return 10240
case .max: return 32768
}
}
}
在 AgentOptions 里设置:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
effort: .high // 10240 thinking tokens
))
setMaxThinkingTokens() 可以在查询之间调整思考预算:
// 普通问题,少给点思考 token
try agent.setMaxThinkingTokens(2048)
let r1 = await agent.prompt("Summarize this file.")
// 遇到复杂推理问题,加大预算
try agent.setMaxThinkingTokens(16000)
let r2 = await agent.prompt("Design a concurrent data structure for...")
// 关闭思考
try agent.setMaxThinkingTokens(nil)
传正整数就启用思考并设预算,传 nil 就关闭。传 0 或负数会抛 SDKError.invalidConfiguration。
ModelInfo 描述了每个模型支持哪些能力:
public struct ModelInfo: Sendable, Equatable {
public let value: String
public let displayName: String
public let description: String
public let supportsEffort: Bool
public let supportedEffortLevels: [EffortLevel]?
public let supportsAdaptiveThinking: Bool?
public let supportsFastMode: Bool?
}
这样 UI 层可以根据模型能力动态展示可选项。
Skills 是 SDK 里一种特殊的扩展机制——本质上是"带工具限制的 prompt 模板"。一个 Skill 定义了一组 prompt 指令、允许使用的工具子集、可选的模型覆盖。
public struct Skill: Sendable {
public let name: String
public let description: String
public let aliases: [String] // 别名,如 ["ci"] 代表 commit
public let userInvocable: Bool // 用户能否通过 /command 调用
public let toolRestrictions: [ToolRestriction]? // 限制可用工具,nil = 全部可用
public let modelOverride: String? // 执行时覆盖模型
public let isAvailable: @Sendable () -> Bool // 运行时可用性检查
public let promptTemplate: String // prompt 模板内容
public let whenToUse: String? // 告诉 LLM 什么时候该用这个 skill
public let argumentHint: String? // 参数提示,如 "[message]"
public let baseDir: String? // skill 目录的绝对路径
public let supportingFiles: [String] // 支撑文件(引用、脚本等)
}
SDK 预定义了 5 个常用 Skill,通过 BuiltInSkills 命名空间访问:
| Skill | 别名 | 允许的工具 | 功能 |
|---|---|---|---|
commit |
ci |
bash, read, glob, grep | 分析 git diff,生成 commit message |
review |
review-pr, cr |
bash, read, glob, grep | 从 5 个维度审查代码变更 |
simplify |
— | bash, read, grep, glob | 审查代码的复用、质量、效率 |
debug |
investigate, diagnose |
read, grep, glob, bash | 分析错误,定位根因 |
test |
run-tests |
bash, read, write, glob, grep | 生成测试用例并执行 |
每个 Skill 都限制了工具范围。比如 commit 只允许 bash、read、glob、grep——不需要写文件。debug 也是只读的(read、grep、glob、bash),只做诊断不做修改。test 是唯一允许 write 的内置 Skill,因为要创建测试文件。
test Skill 还有一个运行时可用性检查:
isAvailable: {
let cwd = FileManager.default.currentDirectoryPath
let testIndicators = [
"Package.swift", "pytest.ini", "jest.config",
"vitest.config", "Cargo.toml", "go.mod",
]
for indicator in testIndicators {
if FileManager.default.fileExists(atPath: cwd + "/" + indicator) {
return true
}
}
return false
}
只有检测到测试框架配置文件时,test Skill 才对用户可见。
SkillRegistry 是线程安全的 skill 管理器,用 DispatchQueue 保护并发访问:
public final class SkillRegistry: @unchecked Sendable {
private var skills: [String: Skill] = [:]
private var orderedNames: [String] = []
private var aliases: [String: String] = [:]
private let queue = DispatchQueue(label: "com.openagentsdk.skillregistry")
public func register(_ skill: Skill) { ... }
public func find(_ name: String) -> Skill? { ... } // 按名称或别名查找
public var allSkills: [Skill] { ... }
public var userInvocableSkills: [Skill] { ... }
}
注册、查找、替换、删除都是 queue.sync 保护的操作。别名在注册时自动建立映射——注册 BuiltInSkills.commit 后,registry.find("ci") 也能找到它。
Skills 不需要全部代码注册。SkillLoader 可以从文件系统自动发现 skill——只要一个目录里包含 SKILL.md 文件,就会被识别为一个 skill 包。
扫描目录按优先级从低到高:
~/.config/agents/skills (最低优先级)
~/.agents/skills
~/.claude/skills
$PWD/.agents/skills
$PWD/.claude/skills (最高优先级)
同名 skill 后发现的覆盖先发现的(last-wins)。
SKILL.md 用 YAML frontmatter 定义元数据:
---
name: polyv-live-cli
description: 管理保利威直播服务
aliases: live, plv
allowed-tools: Bash, Read, Write, Glob
when-to-use: user asks about live streaming management
argument-hint: [action] [options]
---
# polyv-live-cli Skill
你是保利威直播服务的管理助手...
frontmatter 里的 allowed-tools 会被解析成 ToolRestriction 数组,限制这个 skill 执行时只能用指定的工具。
SkillLoader 采用"渐进式加载"策略:只加载 SKILL.md 的 Markdown body 作为 prompt 模板,支撑文件(references、scripts、templates)只记录路径不加载内容。Agent 需要时通过 Read/Bash 工具按需读取。
let registry = SkillRegistry()
registry.register(BuiltInSkills.commit)
registry.register(BuiltInSkills.review)
// 从文件系统发现自定义 skills
let count = registry.registerDiscoveredSkills()
// 或指定目录
registry.registerDiscoveredSkills(from: ["/opt/custom-skills"])
// 或只注册白名单里的
registry.registerDiscoveredSkills(skillNames: ["polyv-live-cli"])
ToolRestriction 枚举定义了可以被限制的工具:
public enum ToolRestriction: String, Sendable, CaseIterable {
case bash, read, write, edit, glob, grep
case webFetch, webSearch, askUser, toolSearch
case agent, sendMessage
case taskCreate, taskList, taskUpdate, taskGet, taskStop, taskOutput
case teamCreate, teamDelete
case notebookEdit, skill
}
当一个 Skill 设了 toolRestrictions: [.bash, .read, .glob],执行时 Agent 只能用这三个工具,其他工具调用会被拦截。
要让 Agent 能用 Skills,需要把 SkillTool 加到工具列表里:
var tools = getAllBaseTools(tier: .core)
tools.append(createSkillTool(registry: registry))
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
permissionMode: .bypassPermissions,
tools: tools
))
// Agent 会根据 system prompt 里的 skill 列表自动发现并调用
let result = await agent.prompt("Use the commit skill to analyze current changes")
SkillRegistry.formatSkillsForPrompt() 会生成一段 skill 列表注入到 system prompt 里,包含每个 skill 的名称、描述和触发条件。LLM 看到这个列表后就知道该在什么场景下调用哪个 skill。
maxBudgetUsd 设置查询的费用上限:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
maxBudgetUsd: 0.05 // 最多花 5 美分
))
每个 turn 结束后检查累计费用:
if let budget = options.maxBudgetUsd, totalCostUsd > budget {
status = .errorMaxBudgetUsd
break
}
超出预算时立即退出循环。已产生的文本和 token 统计仍然保留在 QueryResult 里——你拿到的是部分结果,不是空白的。
两种方式中断正在进行的查询:
// 方式 1:调用 interrupt()
agent.interrupt()
// 方式 2:取消 Task
let task = Task {
await agent.prompt("Long running query...")
}
// 稍后
task.cancel()
interrupt() 内部设置了 _interrupted 标志并取消 stream task。Agent Loop 在多个检查点检查这个标志(循环入口、只读/变更工具之间、SSE 事件循环内部、工具执行前后),检测到后立即退出。
运行时可以切换权限模式和工具授权回调:
// 切换权限模式
agent.setPermissionMode(.askForPermission)
// 设置自定义授权回调(优先级高于 permissionMode)
agent.setCanUseTool { toolName, input in
if toolName == "Bash" {
return .deny("Bash is disabled")
}
return .allow
}
// 恢复到 permissionMode 控制
agent.setCanUseTool(nil)
setCanUseTool 的回调优先于 permissionMode。调 setPermissionMode() 会清空之前设的回调。
SDK 支持通过环境变量配置,优先级是:代码设置 > 环境变量 > 默认值。
| 环境变量 | 对应字段 | 默认值 |
|---|---|---|
CODEANY_API_KEY |
apiKey |
nil |
CODEANY_MODEL |
model |
claude-sonnet-4-6 |
CODEANY_BASE_URL |
baseURL |
nil(用提供商默认) |
用 SDKConfiguration.resolved() 合并:
// 代码设置的值优先,没设的从环境变量读
let config = SDKConfiguration.resolved(overrides: SDKConfiguration(
apiKey: "sk-...", // 优先于 CODEANY_API_KEY
model: "claude-sonnet-4-6" // 优先于 CODEANY_MODEL
))
// 只用环境变量
let envConfig = SDKConfiguration.fromEnvironment()
所有 LLM 请求经过 withRetry 包装:
public struct RetryConfig: Sendable {
public let maxRetries: Int // 最多重试次数,默认 3
public let baseDelayMs: Int // 基础延迟,默认 2000ms
public let maxDelayMs: Int // 最大延迟,默认 30000ms
public let retryableStatusCodes: Set<Int> // 默认 [429, 500, 502, 503, 529]
}
指数退避 + 25% 随机抖动,避免惊群效应。只有 SDKError.apiError 且状态码在可重试集合里才会重试,其他错误直接抛出。
let delay = config.baseDelayMs * (1 << attempt)
let jitterMs = Int(Double(delay) * 0.25 * (Double.random(in: -1...1)))
let totalMs = max(0, min(delay + jitterMs, config.maxDelayMs))
六篇文章写完了,覆盖了 Open Agent SDK (Swift) 的完整架构:
从 Agent Loop 这个核心出发,工具系统是循环里的"执行"环节,MCP 是外部工具扩展,多 Agent 是协作模式,会话是状态持久化,安全和 Hook 是管控机制,而本文讲的多提供商和运行时控制是灵活性的保障——让同一个 Agent 能根据场景选择最合适的模型和控制策略。
系列文章:
GitHub:terryso/open-agent-sdk-swift
本文是「深入 Open Agent SDK (Swift)」系列第五篇。
Agent 不只是一次性问答工具。真正有用的 Agent 要做到三件事:记住上下文(上次聊到哪了)、控制权限(哪些操作能做)、审计行为(谁在什么时候干了什么)。Open Agent SDK 用四个子系统来覆盖这些需求——SessionStore、PermissionPolicy、SandboxSettings、HookRegistry。
这篇文章分析这四个子系统的实现细节,看它们各自怎么工作,以及怎么组合起来构建一个安全的 Agent。
Agent Loop 每次运行会产生一组 messages 数组。如果不保存,进程退出就没了。SessionStore 负责把这些对话历史持久化到磁盘,下次启动时恢复。
SessionStore 是一个 actor,所有方法都需要 await 调用。默认把会话存在 ~/.open-agent-sdk/sessions/ 目录下,每个 session 一个子目录,里面放一个 transcript.json。
let sessionStore = SessionStore() // 默认路径
let sessionStore = SessionStore(sessionsDir: "/custom/path") // 自定义路径
SessionStore 提供五个核心方法,覆盖会话的完整生命周期。
save — 保存会话。把 messages 数组和元数据序列化成 JSON 写入磁盘:
try await sessionStore.save(
sessionId: "my-session",
messages: messages,
metadata: PartialSessionMetadata(
cwd: "/project",
model: "claude-sonnet-4-6",
summary: "代码分析会话",
tag: "analysis",
firstPrompt: "分析项目结构"
)
)
存储结构长这样:
~/.open-agent-sdk/sessions/
my-session/
transcript.json // { "metadata": {...}, "messages": [...] }
文件权限是 0600,目录权限是 0700——只有当前用户能读写。每次 save 会保留第一次创建时的 createdAt 时间戳,只更新 updatedAt。
load — 加载会话。从磁盘读取 transcript.json,反序列化为 SessionData:
if let data = try await sessionStore.load(sessionId: "my-session") {
print("Messages: \(data.metadata.messageCount)")
print("Model: \(data.metadata.model)")
// data.messages 是 [[String: Any]] 数组
}
load 支持分页参数 limit 和 offset,不需要加载全部消息时可以只取尾部:
// 只加载最近 50 条消息
let recent = try await sessionStore.load(sessionId: "my-session", limit: 50, offset: nil)
list — 列出所有会话,按 updatedAt 降序排列(最近的在前):
let sessions = try await sessionStore.list(limit: 10)
for session in sessions {
print("\(session.id) — \(session.summary ?? "(无标题)") [\(session.messageCount) 条消息]")
}
SessionMetadata 包含 id、cwd、model、createdAt、updatedAt、messageCount,以及可选的 summary、tag、firstPrompt、gitBranch、fileSize。
fork — 分叉会话。从已有会话复制消息到新 session,可以指定截断点:
// 完整复制
let newId = try await sessionStore.fork(sourceSessionId: "my-session")
// 只复制前 10 条消息
let truncatedId = try await sessionStore.fork(
sourceSessionId: "my-session",
upToMessageIndex: 10
)
// 指定新 session ID
let customId = try await sessionStore.fork(
sourceSessionId: "my-session",
newSessionId: "forked-session"
)
delete — 删除整个会话目录:
let deleted = try await sessionStore.delete(sessionId: "my-session")
此外还有 rename(改标题)和 tag(打标签)两个辅助方法。
把 SessionStore 注入 Agent 后,SDK 提供三种恢复策略:
1. 指定 sessionId 恢复
最直接的方式:给定一个 session ID,Agent 启动时从 SessionStore 加载历史消息,追加到 messages 数组前面:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
sessionStore: sessionStore,
sessionId: "my-session" // 指定恢复哪个 session
))
2. continueRecentSession — 自动接续最近的会话
不知道 session ID 时,让 SDK 自动找最近的一个:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
sessionStore: sessionStore,
continueRecentSession: true // 自动加载最近的 session
))
内部实现是调 sessionStore.list() 取第一个(已按 updatedAt 降序排列),把它的 ID 作为恢复目标。
3. forkSession + resumeSessionAt — 分叉并截断
在已有会话的基础上分叉一个新分支,还可以截断到指定消息:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
sessionStore: sessionStore,
sessionId: "my-session",
forkSession: true, // 复制到新 session
resumeSessionAt: "msg-uuid-123" // 截断到这条消息
))
SDK 内部的解析顺序是:先 continueRecentSession 确定 session ID,再 forkSession 创建分叉,再 resumeSessionAt 截断历史。这三个选项可以独立使用也可以组合。
SessionStore 在 session ID 校验上做了路径遍历防护:
private func validateSessionId(_ sessionId: String) throws {
guard !sessionId.isEmpty else {
throw SDKError.sessionError(message: "Session ID must not be empty")
}
let forbidden = ["/", "\\", ".."]
for component in forbidden {
if sessionId.contains(component) {
throw SDKError.sessionError(message: "Session ID contains invalid character: '\(component)'")
}
}
}
session ID 里不能包含 /、\、..——防止攻击者通过构造 ID 来读写预期之外的路径。
会话持久化解决了"记住"的问题,权限控制解决的是"能做什么"的问题。
SDK 定义了 6 种权限模式:
| 模式 | 行为 |
|---|---|
default |
每次工具执行前询问用户 |
plan |
只读工具直接执行,写操作需要确认 |
auto |
自动执行所有工具,危险操作除外 |
acceptEdits |
文件编辑自动执行,其他操作需要确认 |
dontAsk |
不询问用户,根据上下文自动判断 |
bypassPermissions |
跳过所有权限检查 |
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
permissionMode: .plan // 只读工具直接跑,写操作要确认
))
permissionMode 是全局开关,粒度比较粗。如果你需要按工具名称或工具属性做精细控制,用 canUseTool 回调:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
permissionMode: .bypassPermissions,
canUseTool: { tool, input, context in
if tool.name == "Bash" {
return CanUseToolResult.deny("Bash is not allowed")
}
return nil // nil 表示"我没意见,交给 permissionMode 决定"
}
))
canUseTool 返回 CanUseToolResult?。返回 nil 表示该回调没有意见,交给下一个检查环节;返回非 nil 结果时,SDK 用回调的决定,不再看 permissionMode。
CanUseToolResult 有三个工厂方法:
CanUseToolResult.allow() // 允许
CanUseToolResult.deny("原因") // 拒绝
CanUseToolResult.allowWithInput(modifiedInput) // 允许但修改输入参数
allowWithInput 比较少见但很实用——你可以在权限检查时修改工具的输入参数。比如把文件写入路径重定向到安全目录。
直接写闭包虽然灵活,但不方便复用。SDK 提供了 PermissionPolicy 协议,把权限判断封装成可组合的策略:
public protocol PermissionPolicy: Sendable {
func evaluate(
tool: ToolProtocol,
input: Any,
context: ToolContext
) async -> CanUseToolResult?
}
SDK 内置了四个策略:
ToolNameAllowlistPolicy — 白名单,只允许指定的工具:
let policy = ToolNameAllowlistPolicy(allowedToolNames: ["Read", "Glob", "Grep"])
// Write、Edit、Bash 等工具全部被拒绝
ToolNameDenylistPolicy — 黑名单,拒绝指定的工具:
let policy = ToolNameDenylistPolicy(deniedToolNames: ["Bash", "Write"])
// 其他工具正常执行
ReadOnlyPolicy — 只允许只读工具(isReadOnly == true):
let policy = ReadOnlyPolicy()
// Read、Glob、Grep、WebSearch 等只读工具允许
// Write、Edit、Bash 等变更工具被拒绝
CompositePolicy — 组合多个策略,按顺序评估:
let policy = CompositePolicy(policies: [
ToolNameDenylistPolicy(deniedToolNames: ["Bash"]),
ReadOnlyPolicy()
])
// 先检查黑名单(Bash 被拒绝),再检查只读策略
CompositePolicy 的评估规则:
用 canUseTool(policy:) 桥接函数把策略转成回调:
let policy = CompositePolicy(policies: [
ToolNameDenylistPolicy(deniedToolNames: ["Bash"]),
ReadOnlyPolicy()
])
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
permissionMode: .bypassPermissions,
canUseTool: canUseTool(policy: policy)
))
权限控制管的是"这个工具能不能执行",沙盒管的是"这个操作在不在允许范围内"。比如 Bash 工具通过了权限检查,但你还得确保它不会 rm -rf /。
let sandbox = SandboxSettings(
// 路径控制
allowedReadPaths: ["/project/"],
allowedWritePaths: ["/project/build/"],
deniedPaths: ["/etc/", "/var/"],
// 命令控制
deniedCommands: ["rm", "sudo"], // 黑名单
// allowedCommands: ["git", "swift"], // 白名单(和黑名单二选一)
// 行为控制
allowNestedSandbox: false,
autoAllowBashIfSandboxed: false, // 沙箱激活时自动批准 Bash
allowUnsandboxedCommands: false,
enableWeakerNestedSandbox: false,
// 网络控制
network: SandboxNetworkConfig(
allowedDomains: ["api.example.com"],
allowLocalBinding: false
)
)
路径和命令各有两种模式:
allowedReadPaths / allowedWritePaths 是白名单(空数组=全部允许),deniedPaths 是黑名单(优先级更高)allowedCommands 是白名单(设为非 nil 就只允许列出的命令),deniedCommands 是黑名单。allowedCommands 优先级高于 deniedCommandsSandboxChecker 是一个无状态的枚举类,提供 isPathAllowed、checkPath、isCommandAllowed、checkCommand 四个静态方法。isXxx 返回 Bool,checkXxx 不通过时抛出 SDKError.permissionDenied。
路径检查用前缀匹配加段边界保证:
// /project/ 匹配 /project/src/file.swift
// /project/ 不匹配 /project-backup/file.swift
SandboxChecker.isPathAllowed("/project/src/main.swift", for: .read, settings: sandbox)
// -> true
SandboxChecker.isPathAllowed("/project-backup/old.swift", for: .read, settings: sandbox)
// -> false(段边界不匹配)
实现关键在于 SandboxPathNormalizer——先把路径规范化(解析 ..、.、symlink),再做前缀比较时保证尾部有 / 来强制段边界。
// 路径遍历攻击会被 normalize 掉
let normalized = SandboxPathNormalizer.normalize("/project/src/../../etc/passwd")
// -> "/etc/passwd",然后被 deniedPaths 拦截
命令检查分三个阶段:
bash -c "cmd"、$(cmd)、`cmd` 等绕过模式/usr/bin/rm -rf /tmp 提取出 rm// 黑名单里有 "rm"
SandboxChecker.isCommandAllowed("rm -rf /tmp", settings: blocklist)
// -> false
// 路径形式的命令也能识别
SandboxChecker.isCommandAllowed("/usr/bin/rm -rf /tmp", settings: blocklist)
// -> false(提取 basename 得到 "rm")
// 反斜杠绕过
SandboxChecker.isCommandAllowed("\\rm -rf /tmp", settings: blocklist)
// -> false(去掉前导 \ 后得到 "rm")
// 引号绕过
SandboxChecker.isCommandAllowed("\"rm\" -rf /tmp", settings: blocklist)
// -> false(去掉引号后得到 "rm")
// 子 shell 绕过
SandboxChecker.isCommandAllowed("bash -c \"rm -rf /tmp\"", settings: blocklist)
// -> false(递归检查内部命令)
对于无法可靠解析的命令(比如多层嵌套的 bash -c "bash -c 'rm ...'"),默认拒绝。
命令参数中的文件路径也会被提取并检查——如果命令里出现了 deniedPaths 中的路径,命令也会被拒绝。
这个选项是沙盒和权限系统的桥梁。当 autoAllowBashIfSandboxed = true 时,Bash 工具会跳过 canUseTool 权限回调检查,但仍然经过 SandboxChecker.checkCommand() 的命令过滤。
设计思路是:如果你已经配了完善的沙盒规则,Bash 命令能做什么已经被限制住了,不需要再弹一次权限确认。
前三个系统解决的是"能不能做"的问题,Hook 系统解决的是"做了之后要知道"和"做之前要干预"的问题。
SDK 定义了 24 个生命周期事件:
| 事件 | 触发时机 |
|---|---|
preToolUse |
工具执行前 |
postToolUse |
工具执行成功后 |
postToolUseFailure |
工具执行失败后 |
sessionStart |
Agent 会话开始 |
sessionEnd |
Agent 会话结束 |
stop |
Agent Loop 停止 |
subagentStart |
子 Agent 启动 |
subagentStop |
子 Agent 完成 |
userPromptSubmit |
用户提交 prompt |
permissionRequest |
权限检查发生 |
permissionDenied |
权限被拒绝 |
taskCreated |
任务创建 |
taskCompleted |
任务完成 |
configChange |
配置变更 |
cwdChanged |
工作目录变更 |
fileChanged |
文件变更 |
notification |
通知事件 |
preCompact |
对话压缩前 |
postCompact |
对话压缩后 |
teammateIdle |
团队成员空闲 |
setup |
Agent 初始化 |
worktreeCreate |
工作树创建 |
worktreeRemove |
工作树移除 |
Hook 有两种实现方式:函数回调和 Shell 命令。
函数 Hook — Swift 闭包,适合进程内逻辑:
await registry.register(.preToolUse, definition: HookDefinition(
handler: { input in
// input 是 HookInput,包含 event、toolName、toolInput、sessionId 等
return HookOutput(message: "拦截成功", block: true)
}
))
Shell Hook — 外部命令,适合集成非 Swift 脚本:
await registry.register(.preToolUse, definition: HookDefinition(
command: "python3 /path/to/check.py" // HookInput 通过 stdin JSON 传入
))
Shell Hook 通过 ShellHookExecutor 执行:用 /bin/bash -c 启动进程,把 HookInput 序列化为 JSON 写入 stdin,从 stdout 读取 HookOutput JSON。Shell 命令的标准输出如果不是合法 JSON,会被包装成 HookOutput(message: stdout)。
Shell Hook 的环境变量里会注入 HOOK_EVENT、HOOK_TOOL_NAME、HOOK_SESSION_ID、HOOK_CWD,方便脚本直接用环境变量判断上下文。
HookRegistry 是一个 actor,内部维护 [HookEvent: [HookDefinition]] 映射:
let registry = HookRegistry()
// 注册函数 Hook
await registry.register(.preToolUse, definition: HookDefinition(
handler: { input in
return HookOutput(message: "Bash blocked", block: true)
},
matcher: "Bash" // 只匹配 Bash 工具
))
// 注册 Shell Hook
await registry.register(.postToolUse, definition: HookDefinition(
command: "/usr/bin/logger 'Tool executed'",
timeout: 5000 // 5 秒超时
))
// 执行所有注册在某事件上的 Hook
let results = await registry.execute(.preToolUse, input: hookInput)
// results: [HookOutput],包含所有匹配的 Hook 的返回值
matcher 过滤:每个 HookDefinition 可以设一个 matcher(正则表达式)。执行时先检查 input.toolName 是否匹配 matcher,不匹配就跳过这个 Hook。matcher 为 nil 时匹配所有工具。
超时处理:函数 Hook 用 withThrowingTaskGroup 实现超时——把实际执行和 Task.sleep 放在同一个 TaskGroup 里,谁先完成用谁。超时的 Hook 不影响其他 Hook 执行。Shell Hook 通过 DispatchQueue.asyncAfter 设置超时,到时间就 terminate 进程。
执行顺序:同一事件上的 Hook 按注册顺序串行执行。
HookOutput 可以做这些事:
HookOutput(
message: "日志消息", // 附加信息
block: true, // 拦截操作
notification: HookNotification( // 发送通知
title: "警告",
body: "检测到危险操作",
level: .warning
),
permissionUpdate: PermissionUpdate( // 动态修改权限
tool: "Bash",
behavior: .deny
),
systemMessage: "请在沙箱内操作", // 注入系统消息
reason: "安全策略", // 拦截原因
updatedInput: ["command": "echo safe"], // 修改工具输入
decision: .block // 显式 approve/block
)
其中 block: true 会阻止工具执行,返回一个错误结果给 LLM。permissionUpdate 可以在 Hook 运行时动态修改工具权限。updatedInput 可以替换工具的输入参数。
四个子系统各有分工:
下面用一个完整的例子展示怎么把它们组合起来:
import Foundation
import OpenAgentSDK
// 1. 创建 SessionStore
let sessionStore = SessionStore()
// 2. 创建 HookRegistry,注册审计和安全拦截
let hookRegistry = HookRegistry()
// 记录所有工具执行
await hookRegistry.register(.postToolUse, definition: HookDefinition(
handler: { input in
if let toolName = input.toolName {
print("[审计] 工具 \(toolName) 执行完成")
}
return nil
}
))
// 拦截 Bash 中的危险命令
await hookRegistry.register(.preToolUse, definition: HookDefinition(
handler: { input in
return HookOutput(
message: "Bash 被安全策略拦截",
block: true,
decision: .block
)
},
matcher: "Bash"
))
// 记录权限拒绝事件
await hookRegistry.register(.permissionDenied, definition: HookDefinition(
handler: { input in
print("[安全告警] 权限被拒绝: \(input.error ?? "unknown")")
return nil
}
))
// 会话生命周期追踪
await hookRegistry.register(.sessionStart, definition: HookDefinition(
handler: { _ in print("[会话] 开始"); return nil }
))
await hookRegistry.register(.sessionEnd, definition: HookDefinition(
handler: { _ in print("[会话] 结束"); return nil }
))
// 3. 配置沙盒:限制路径和命令
let sandbox = SandboxSettings(
allowedReadPaths: ["/project/"],
allowedWritePaths: ["/project/src/", "/project/tests/"],
deniedPaths: ["/etc/", "/var/", "/tmp/"],
deniedCommands: ["rm", "sudo", "chmod", "chown"],
autoAllowBashIfSandboxed: false,
allowNestedSandbox: false
)
// 4. 配置权限策略:只读 + 排除 Bash
let policy = CompositePolicy(policies: [
ToolNameDenylistPolicy(deniedToolNames: ["Bash"]),
ReadOnlyPolicy()
])
// 5. 创建 Agent,注入所有组件
let agent = createAgent(options: AgentOptions(
apiKey: "sk-...",
model: "claude-sonnet-4-6",
systemPrompt: "你是一个代码分析助手。只能读取文件,不能修改。",
maxTurns: 10,
permissionMode: .bypassPermissions,
canUseTool: canUseTool(policy: policy),
sessionStore: sessionStore,
sessionId: "analysis-session",
hookRegistry: hookRegistry,
sandbox: sandbox
))
// 6. 执行查询
let result = await agent.prompt("分析项目中的 Swift 源文件结构")
print(result.text)
// 7. 后续恢复会话
let resumedAgent = createAgent(options: AgentOptions(
apiKey: "sk-...",
model: "claude-sonnet-4-6",
permissionMode: .bypassPermissions,
canUseTool: canUseTool(policy: policy),
sessionStore: sessionStore,
sessionId: "analysis-session", // 同一个 session ID,自动恢复历史
hookRegistry: hookRegistry,
sandbox: sandbox
))
let continued = await resumedAgent.prompt("继续分析测试文件")
print(continued.text)
这个 Agent 的安全特性:
/project/ 下的文件,不能碰 /etc/、/var/多层防御的好处是:即使某一层的配置有疏漏,其他层还能兜底。比如你误把 Bash 加进了白名单,Hook 的 matcher 还能拦截;即使 Hook 没拦住,沙盒的命令过滤还能挡。
SessionStore、PermissionPolicy、SandboxSettings、HookRegistry 四个系统各管一件事,但组合起来就是一套完整的安全框架:
下一篇看 SDK 的 多 LLM 提供商:怎么同时支持 Anthropic、OpenAI 和其他 LLM,Provider 协议的设计,以及运行时切换模型的机制。
系列文章:
GitHub:terryso/open-agent-sdk-swift
本文是「深入 Open Agent SDK (Swift)」系列第四篇。
单个 Agent 再强,也只是一个执行者。真实的开发任务往往是多步骤、多角色的:先有人探索代码库,有人设计方案,再有人写代码、跑测试。一个 Agent 单干,上下文容易膨胀,效率也上不去。
Open Agent SDK 从三个层面解决这个问题:
这篇文章逐一分析这三个层面的实现,最后看它们怎么组合起来做任务编排。
子 Agent 的生成不是 AgentTool 直接 new 一个 Agent 出来——中间隔了一层协议。SubAgentSpawner 定义在 Types/AgentTypes.swift 里:
public protocol SubAgentSpawner: Sendable {
func spawn(
prompt: String,
model: String?,
systemPrompt: String?,
allowedTools: [String]?,
maxTurns: Int?
) async -> SubAgentResult
func spawn(
prompt: String,
model: String?,
systemPrompt: String?,
allowedTools: [String]?,
maxTurns: Int?,
disallowedTools: [String]?,
mcpServers: [AgentMcpServerSpec]?,
skills: [String]?,
runInBackground: Bool?,
isolation: String?,
name: String?,
teamName: String?,
mode: PermissionMode?,
resume: String?
) async -> SubAgentResult
}
两个方法,一个基础版(5 个参数),一个增强版(13 个参数)。协议还提供了默认实现,增强版直接调用基础版,这样已有的实现类不用改代码就能兼容。
为什么要把 spawner 放在 Types/ 而不是 Core/?因为 Tools/Advanced/AgentTool.swift 需要用它,但 Tools/ 不应该导入 Core/。把协议定义在 Types/,具体实现放在 Core/,通过 ToolContext.agentSpawner 注入——这是 SDK 里常见的依赖倒置。
DefaultSubAgentSpawner 在 Core/DefaultSubAgentSpawner.swift 里,做了这几件事:
final class DefaultSubAgentSpawner: SubAgentSpawner, @unchecked Sendable {
private let apiKey: String
private let baseURL: String?
private let parentModel: String
private let parentTools: [ToolProtocol]
private let provider: LLMProvider
private let client: (any LLMClient)?
func spawn(...) async -> SubAgentResult {
// 1. 过滤掉 AgentTool,防止无限递归
var subTools = parentTools.filter { $0.name != "Agent" }
// 2. 如果指定了 allowedTools,进一步过滤
if let allowed = allowedTools, !allowed.isEmpty {
let allowedSet = Set(allowed)
subTools = subTools.filter { allowedSet.contains($0.name) }
}
// 3. disallowedTools 再过一遍(优先级高于 allowedTools)
if let disallowed = disallowedTools, !disallowed.isEmpty {
let disallowedSet = Set(disallowed)
subTools = subTools.filter { !disallowedSet.contains($0.name) }
}
// 4. 创建子 Agent 并执行
let options = AgentOptions(
apiKey: apiKey,
model: model ?? parentModel,
systemPrompt: systemPrompt,
maxTurns: maxTurns ?? 10,
tools: subTools
)
let agent = Agent(options: options)
let result = await agent.prompt(prompt)
return SubAgentResult(
text: result.text.isEmpty
? "(Subagent completed with no text output)"
: result.text,
toolCalls: [],
isError: result.status != .success
)
}
}
几个关键点:
allowedTools / disallowedTools 限制spawn() 后会 await,等子 Agent 跑完才继续AgentTool 是暴露给 LLM 的工具。LLM 调用 Agent 工具时传入 prompt 和参数,AgentTool 负责调用 spawner 生成子 Agent。
它内置了两种预定义的子 Agent 类型:
private let BUILTIN_AGENTS: [String: AgentDefinition] = [
"Explore": AgentDefinition(
name: "Explore",
description: "Fast agent specialized for exploring codebases...",
systemPrompt: "You are a codebase exploration agent. Search through files and code to answer questions...",
tools: ["Read", "Glob", "Grep", "Bash"],
maxTurns: 10
),
"Plan": AgentDefinition(
name: "Plan",
description: "Software architect agent for designing implementation plans...",
systemPrompt: "You are a software architect. Design implementation plans...",
tools: ["Read", "Glob", "Grep", "Bash"],
maxTurns: 10
),
]
LLM 调用 AgentTool 时,通过 subagent_type 字段指定用哪种:
{
"prompt": "Explore the project structure and find all Swift source files",
"description": "Explore codebase",
"subagent_type": "Explore"
}
AgentTool 还支持一堆可选参数:model(指定模型)、maxTurns(覆盖轮次上限)、run_in_background(后台运行)、isolation(隔离模式,比如 worktree)、team_name(关联团队)、mode(权限模式)。这些参数直接透传给 spawner。
SDK 自带了一个 SubagentExample,演示了主 Agent 作为协调者,通过 AgentTool 委派 Explore 子 Agent 的完整流程:
// 主 Agent 的系统提示
let systemPrompt = """
You are a coordinator agent. When given a task, you should delegate it to a sub-agent \
using the Agent tool. The Agent tool will spawn a specialized agent (e.g., "Explore" type) \
that can use Read, Glob, Grep, and Bash tools to investigate the codebase. \
After the sub-agent returns its findings, summarize the results for the user.
"""
// 注册工具:核心工具 + AgentTool
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: defaultModel,
systemPrompt: systemPrompt,
maxTurns: 10,
tools: getAllBaseTools(tier: .core) + [createAgentTool()]
))
// 发任务——主 Agent 会调用 AgentTool 委派给 Explore 子 Agent
for await message in agent.stream("""
Explore the current project directory. Find all Swift source files, \
examine the project structure, and provide a summary. \
Use the Agent tool to delegate this task to an Explore sub-agent.
""") {
switch message {
case .toolUse(let data):
if data.toolName == "Agent" {
print("[Sub-agent Delegation: \(data.toolName)]")
}
case .toolResult(let data):
print("[Result: \(data.content.prefix(200))]")
case .result(let data):
print("Turns: \(data.numTurns), Cost: $\(data.totalCostUsd)")
default:
break
}
}
执行流程:用户发 prompt -> 主 Agent 判断需要探索代码库 -> 调用 AgentTool -> AgentTool 通过 spawner 生成 Explore 子 Agent -> 子 Agent 用 Glob/Grep/Read 执行探索 -> 结果返回给主 Agent -> 主 Agent 汇总后回复用户。
子 Agent 解决了"谁干活"的问题,Task 系统解决的是"活干了多少、谁在干、结果是什么"的问题。
TaskStore 是一个 Swift Actor,保证并发安全:
public actor TaskStore {
private var tasks: [String: Task] = [:]
private var taskCounter: Int = 0
public func create(
subject: String,
description: String? = nil,
owner: String? = nil,
status: TaskStatus = .pending
) -> Task {
taskCounter += 1
let id = "task_\(taskCounter)"
let now = dateFormatter.string(from: Date())
let task = Task(
id: id, subject: subject, description: description,
status: status, owner: owner,
createdAt: now, updatedAt: now
)
tasks[id] = task
return task
}
}
用 Actor 而不是普通类,意味着所有方法都是隐式串行化的——不需要自己加锁。多个 Agent 同时创建任务不会出现竞态条件。
Task 有 5 种状态,流转规则很明确:
public enum TaskStatus: String, Sendable, Equatable, Codable {
case pending // 等待开始
case inProgress // 进行中
case completed // 已完成
case failed // 失败
case cancelled // 已取消
}
状态转换有约束:pending 和 inProgress 可以转到任何状态,但 completed、failed、cancelled 是终态,不可再变:
private func isValidTransition(from: TaskStatus, to: TaskStatus) -> Bool {
switch from {
case .pending, .inProgress:
return true
case .completed, .failed, .cancelled:
return false // 终态,不能再转
}
}
画成状态图:
pending ──→ inProgress ──→ completed
│ │
│ ├──→ failed
│ │
└──→ cancelled ←──┘
TaskStatus 还有个贴心的 parse() 方法,同时支持 camelCase(inProgress)和 snake_case(in_progress),因为 LLM 返回的 JSON 格式不一定统一:
public static func parse(_ string: String) -> TaskStatus? {
if let direct = TaskStatus(rawValue: string) { return direct }
// snake_case → camelCase
let camel = string
.split(separator: "_")
.enumerated()
.map { $0.offset == 0 ? String($0.element) : String($0.element).capitalized }
.joined()
return TaskStatus(rawValue: camel)
}
一个 Task 实例除了基本的状态追踪,还预留了依赖关系和元数据:
public struct Task: Sendable, Equatable, Codable {
public let id: String
public var subject: String
public var description: String?
public var status: TaskStatus
public var owner: String? // 谁在干
public let createdAt: String
public var updatedAt: String
public var output: String? // 结果
public var blockedBy: [String]? // 被哪些任务阻塞
public var blocks: [String]? // 阻塞了哪些任务
public var metadata: [String: String]?
}
blockedBy 和 blocks 字段说明 Task 系统预留了任务依赖的能力——任务 A 可以声明"我需要等任务 B 和 C 完成才能开始"。
SDK 提供了三个工具让 LLM 操作 Task 系统:
TaskCreate -- 创建任务:
public func createTaskCreateTool() -> ToolProtocol {
return defineTool(
name: "TaskCreate",
description: "Create a new task for tracking work progress.",
inputSchema: taskCreateSchema,
isReadOnly: false
) { (input: TaskCreateInput, context: ToolContext) in
guard let taskStore = context.taskStore else {
return ToolExecuteResult(content: "Error: TaskStore not available.", isError: true)
}
let initialStatus: TaskStatus = input.status.flatMap { TaskStatus.parse($0) } ?? .pending
let task = await taskStore.create(
subject: input.subject,
description: input.description,
owner: input.owner,
status: initialStatus
)
return ToolExecuteResult(
content: "Task created: \(task.id) - \"\(task.subject)\" (\(task.status.rawValue))",
isError: false
)
}
}
TaskList -- 列出任务(支持按 status 和 owner 过滤):
// LLM 可以查 "列出所有 pending 状态的任务" 或 "列出分配给 agent-1 的任务"
let tasks = await taskStore.list(status: status, owner: input.owner)
TaskUpdate -- 更新任务(状态、描述、负责人、输出):
do {
let task = try await taskStore.update(
id: input.id,
status: status,
description: input.description,
owner: input.owner,
output: input.output
)
return ToolExecuteResult(
content: "Task updated: \(task.id) - \(task.status.rawValue) - \"\(task.subject)\"",
isError: false
)
} catch let error as TaskStoreError {
return ToolExecuteResult(content: "Error: \(error.localizedDescription)", isError: true)
}
注意 TaskUpdate 会抛出 invalidStatusTransition 错误——比如试图把一个 completed 的任务改成 inProgress,LLM 会收到错误提示,可以据此调整策略。
Task 系统追踪"做什么",Team 系统解决"谁跟谁一组"。
和 TaskStore 一样,TeamStore 也是 Actor:
public actor TeamStore {
private var teams: [String: Team] = [:]
private var teamCounter: Int = 0
public func create(
name: String,
members: [TeamMember] = [],
leaderId: String = "self"
) -> Team {
teamCounter += 1
let id = "team_\(teamCounter)"
let team = Team(
id: id, name: name, members: members,
leaderId: leaderId,
createdAt: dateFormatter.string(from: Date()),
status: .active
)
teams[id] = team
return team
}
}
Team 有两种状态:active 和 disbanded。删除 Team 不是真删,而是把状态改成 disbanded——标记为 disbanded 的 Team 不允许添加/移除成员。
public enum TeamRole: String, Sendable, Equatable, Codable {
case leader // 团队领导
case member // 普通成员
}
public struct TeamMember: Sendable, Equatable, Codable {
public let name: String
public let role: TeamRole
}
TeamCreateTool 创建 Team 时,所有传入的成员默认都是 member 角色,leaderId 默认是 "self"(即创建者自己):
let members: [TeamMember] = input.members?.map { TeamMember(name: $0) } ?? []
let team = await teamStore.create(
name: input.name,
members: members,
leaderId: "self"
)
TeamStore 还提供了动态管理成员的能力:
// 添加成员
try teamStore.addMember(teamId: "team_1", member: TeamMember(name: "agent-coder"))
// 移除成员
try teamStore.removeMember(teamId: "team_1", agentName: "agent-coder")
// 查找某个 Agent 属于哪个团队
let team = await teamStore.getTeamForAgent(agentName: "agent-coder")
getTeamForAgent 对消息传递很重要——发消息时需要知道发件人属于哪个 Team,才能验证收件人是不是队友。
除了 TeamStore,还有一个 AgentRegistry 负责追踪所有活跃的 Agent:
public actor AgentRegistry {
private var agents: [String: AgentRegistryEntry] = [:]
private var nameIndex: [String: String] = [:] // name -> agentId
public func register(agentId: String, name: String, agentType: String) throws -> AgentRegistryEntry {
if nameIndex[name] != nil {
throw AgentRegistryError.duplicateAgentName(name: name)
}
let entry = AgentRegistryEntry(...)
agents[agentId] = entry
nameIndex[name] = agentId
return entry
}
public func getByName(name: String) -> AgentRegistryEntry? {
guard let agentId = nameIndex[name] else { return nil }
return agents[agentId]
}
}
名字唯一性约束——同一个 AgentRegistry 里不能注册两个同名的 Agent。nameIndex 是一个反查索引,支持 O(1) 的名字查找。
有了 Team,Agent 之间需要能通信。SDK 用的是邮箱模式(Mailbox)——发消息不直接推给对方,而是放进对方的邮箱,对方自己来取。
public actor MailboxStore {
private var mailboxes: [String: [AgentMessage]] = [:]
// 点对点发送
public func send(from: String, to: String, content: String, type: AgentMessageType = .text) {
let message = AgentMessage(from: from, to: to, content: content,
timestamp: dateFormatter.string(from: Date()), type: type)
if mailboxes[to] == nil { mailboxes[to] = [] }
mailboxes[to]?.append(message)
}
// 广播——发给所有有邮箱的 Agent
public func broadcast(from: String, content: String, type: AgentMessageType = .text) {
let timestamp = dateFormatter.string(from: Date())
for (agentName, _) in mailboxes {
let message = AgentMessage(from: from, to: agentName, content: content,
timestamp: timestamp, type: type)
mailboxes[agentName]?.append(message)
}
}
// 读取并清空邮箱
public func read(agentName: String) -> [AgentMessage] {
guard let messages = mailboxes[agentName] else { return [] }
mailboxes[agentName] = [] // 读完清空
return messages
}
}
三个核心操作:send(点对点)、broadcast(广播)、read(读取)。read 是破坏性读取——读一次邮箱就清空了。broadcast 只发给已经有邮箱的 Agent,不会凭空创建邮箱。
消息类型除了普通文本(.text),还有 .shutdownRequest、.shutdownResponse、.planApprovalResponse——这些特殊类型用于团队管理的协调操作。
SendMessageTool 做了三层校验:
// 1. 必须有 MailboxStore
guard let mailboxStore = context.mailboxStore else { ... }
// 2. 必须有 TeamStore
guard let teamStore = context.teamStore else { ... }
// 3. 必须知道发送者是谁
guard let senderName = context.senderName else { ... }
// 4. 发送者必须在某个 Team 里
guard let team = await teamStore.getTeamForAgent(agentName: senderName) else { ... }
// 5. 收件人必须是同 Team 的成员
let isMember = team.members.contains { $0.name == input.to }
guard isMember else { ... }
广播用 "*" 作为收件人:
{ "to": "*", "message": "Phase 1 complete, starting Phase 2." }
点对点用具体名字:
{ "to": "agent-coder", "message": "Here's the spec for module A." }
校验不通过时返回错误信息,LLM 能看到哪些成员可用,可以调整发送目标。
单个 Agent、Task、Team、Mailbox 各自能做什么清楚了。实际场景中怎么组合?看一个典型的工作流。
最简单的模式。主 Agent 收到复杂任务后,同时启动多个子 Agent 各自处理一部分:
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
systemPrompt: """
You are a coordinator. Break complex tasks into subtasks, \
delegate each to an Explore sub-agent, then synthesize results.
""",
maxTurns: 20,
tools: getAllBaseTools(tier: .core) + [
createAgentTool(),
createTaskCreateTool(),
createTaskUpdateTool(),
createTaskListTool()
],
taskStore: TaskStore()
))
LLM 可能这样编排:
TaskCreate("Analyze module A") -- 创建任务Agent(prompt: "Analyze module A", subagent_type: "Explore") -- 委派子 AgentTaskUpdate(id: "task_1", status: "completed", output: result) -- 标记完成需要多个 Agent 长期协作时,用 Team + Mailbox:
let mailboxStore = MailboxStore()
let teamStore = TeamStore()
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
agentName: "coordinator",
mailboxStore: mailboxStore,
teamStore: teamStore,
tools: getAllBaseTools(tier: .core) + [
createAgentTool(),
createTeamCreateTool(),
createTeamDeleteTool(),
createSendMessageTool(),
createTaskCreateTool(),
createTaskListTool(),
createTaskUpdateTool()
]
))
LLM 的编排可能像这样:
TeamCreate(name: "refactor-team", members: ["explorer", "planner", "coder"]) -- 建团队TaskCreate("Explore codebase", owner: "explorer") -- 创建任务Agent(prompt: "...", name: "explorer", subagent_type: "Explore") -- 启动探索 AgentSendMessage(to: "planner", message: "Exploration done, here's the summary...") -- 通知规划 AgentTaskCreate("Write implementation plan", owner: "planner") -- 下一个任务用 Task 系统做工作队列,主 Agent 创建一批任务,子 Agent 逐个领取执行:
主 Agent:
TaskCreate("Fix bug #1") → task_1 (pending)
TaskCreate("Fix bug #2") → task_2 (pending)
TaskCreate("Add feature X") → task_3 (pending)
子 Agent A:
TaskList(status: "pending") → [task_1, task_2, task_3]
TaskUpdate(task_1, status: "in_progress", owner: "agent-a")
... 干活 ...
TaskUpdate(task_1, status: "completed", output: "Fixed by ...")
子 Agent B:
TaskList(status: "pending") → [task_2, task_3]
TaskUpdate(task_2, status: "in_progress", owner: "agent-b")
... 干活 ...
TaskStore 是 Actor,多个 Agent 并发更新同一条任务不会出问题(先到先得),但不会自动分配——需要 LLM 自己协调谁认领哪个任务。
这套多 Agent 协作机制有几个设计选择:
为什么子 Agent 不能再生子 Agent? DefaultSubAgentSpawner 在创建子 Agent 时过滤掉了 AgentTool。这是有意的限制——如果不限制,一个 Agent 生成一个 Agent 再生成一个 Agent,递归深度不可控,token 消耗也会指数级增长。
为什么消息是拉取(Pull)不是推送(Push)? MailboxStore.read() 是破坏性读取,Agent 需要主动调用才能收到消息。这比推送模式简单得多——不需要维护回调、不需要处理 Agent 离线的情况。代价是实时性差,但在 Agent Loop 的工具调用频率下(每个 turn 都可以调工具),拉取的延迟可以接受。
为什么 Task 的状态机没有自动流转? blockedBy 字段只是声明了依赖关系,但 TaskStore.update() 不会自动检查前置任务是否完成。这意味着"等任务 A 做完再做任务 B"这个逻辑需要 LLM 自己实现——调 TaskList 看状态,再决定下一步。这是一个务实的取舍:自动依赖解析可以加,但对 LLM 来说,显式检查反而更可控。
Open Agent SDK 的多 Agent 协作由三层构成:
SubAgentSpawner 协议和 AgentTool 实现,主 Agent 在运行时动态生成子 Agent 委派任务,内置 Explore 和 Plan 两种类型TaskStore Actor 的任务追踪,有明确的状态机(pending -> inProgress -> completed/failed/cancelled),终态不可逆转TeamStore 管理团队和成员,MailboxStore 实现邮箱式消息传递,支持点对点和广播三层可以独立使用,也可以组合——用 Task 追踪进度,用 Team 组织成员,用 Mailbox 协调通信,用子 Agent 执行具体工作。
下一篇会看 SDK 的 会话持久化:Agent 对话历史怎么存、怎么恢复、怎么在重启后继续之前的工作。
系列文章:
GitHub:terryso/open-agent-sdk-swift
本文是「深入 Open Agent SDK (Swift)」系列第三篇。
上一篇看了 SDK 内置的 34 个工具——文件读写、Bash 执行、代码搜索,覆盖了常见的开发场景。但 Agent 的能力不可能只靠内置工具撑满。你需要连接数据库、调用企业 API、操作内部系统——这些事情需要一个标准化的接入方式。
MCP(Model Context Protocol)就是干这个的。这篇文章看 Open Agent SDK 怎么通过 MCP 协议把外部工具接到 Agent Loop 里。
MCP 是 Anthropic 提出的一个开放协议,定义了 LLM 应用和外部工具/数据源之间的通信标准。思路是:
为什么 Agent 需要它?因为不可能把所有工具都写进 SDK。有了 MCP,任何人都可以写一个 MCP Server(比如 @modelcontextprotocol/server-filesystem),任何 Agent 都能对接——不需要改 SDK 代码,不需要写适配器,配一行就接上了。
Open Agent SDK 的 MCP 集成分两条路:
InProcessMCPServer 把 SDK 工具包装成 MCP Server,零协议开销下面逐个看。
SDK 用 McpServerConfig 枚举统一了所有传输方式:
public enum McpServerConfig: Sendable, Equatable {
case stdio(McpStdioConfig) // 子进程 stdin/stdout
case sse(McpTransportConfig) // Server-Sent Events
case http(McpTransportConfig) // HTTP POST
case sdk(McpSdkServerConfig) // 进程内,零开销
case claudeAIProxy(McpClaudeAIProxyConfig) // ClaudeAI 代理
}
最常用的方式。Agent 启动一个子进程,通过 stdin/stdout 交换 JSON-RPC 消息。适用于 Node.js/Python 写的 MCP Server:
let servers: [String: McpServerConfig] = [
"filesystem": .stdio(McpStdioConfig(
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
)),
"git": .stdio(McpStdioConfig(
command: "uvx",
args: ["mcp-server-git"],
env: ["GIT_REPO_PATH": "/my/repo"]
))
]
MCPStdioTransport 内部用 Foundation 的 Process 启动子进程,用 FileDescriptor 做底层 I/O。几个细节:
which 查找。找不到就当文件路径用CODEANY_API_KEY 默认不会传给子进程,除非你在 env 里显式指定远程 MCP Server 通过 HTTP 连接,区分两种模式:
// SSE 模式(长连接,服务端推送)
let sseServer: [String: McpServerConfig] = [
"remote-tools": .sse(McpTransportConfig(
url: "https://mcp.example.com/sse",
headers: ["Authorization": "Bearer token123"]
))
]
// HTTP 模式(请求-响应)
let httpServer: [String: McpServerConfig] = [
"api-tools": .http(McpTransportConfig(
url: "https://mcp.example.com/api"
))
]
SSE 适合需要服务端主动推送的场景,HTTP 适合简单的请求-响应。两者底层都用 HTTPClientTransport,区别在 streaming 参数。McpSseConfig 和 McpHttpConfig 实际上是 McpTransportConfig 的别名:
public typealias McpSseConfig = McpTransportConfig
public typealias McpHttpConfig = McpTransportConfig
不走任何网络协议,直接在进程内把工具注册进去。后面第六部分单独讲。
连接 ClaudeAI 的代理端点,用 server ID 做认证:
let proxyServer: [String: McpServerConfig] = [
"claude-tools": .claudeAIProxy(McpClaudeAIProxyConfig(
url: "https://claudeai.example.com/proxy",
id: "server-abc-123"
))
]
内部实现就是 HTTP 传输加了一个 X-ClaudeAI-Server-ID header。
Agent 怎么把 MCP 工具合并到自己的工具池里?从 assembleFullToolPool() 追踪:
func assembleFullToolPool() async -> ([ToolProtocol], MCPClientManager?) {
let baseTools = options.tools ?? []
guard let mcpServers = options.mcpServers, !mcpServers.isEmpty else {
return (baseTools, nil)
}
// 第一步:分离 SDK 配置和外部配置
let (sdkTools, externalServers) = await Self.processMcpConfigs(mcpServers)
// 第二步:连接外部 MCP 服务器
var externalTools: [ToolProtocol] = []
var manager: MCPClientManager? = nil
if !externalServers.isEmpty {
let mcpManager = MCPClientManager()
await mcpManager.connectAll(servers: externalServers)
externalTools = await mcpManager.getMCPTools()
manager = mcpManager
}
// 第三步:合并所有工具
let allMCPTools = sdkTools + externalTools
let pool = assembleToolPool(
baseTools: getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist),
customTools: baseTools,
mcpTools: allMCPTools,
allowed: options.allowedTools,
disallowed: options.disallowedTools
)
return (pool, manager)
}
三步走:
1. 分离配置。 processMcpConfigs() 把 .sdk 配置和外部配置(stdio/sse/http)分开。SDK 配置直接从 InProcessMCPServer 提取工具,用 SdkToolWrapper 加上命名空间前缀;外部配置留给 MCPClientManager 处理。
2. 连接外部服务器。 MCPClientManager 是一个 actor,用 withTaskGroup 并发连接所有服务器。每个连接经历四步:
创建 Transport → 启动连接 → MCP 握手 (initialize) → listTools() 发现工具
发现的工具被包装成 MCPToolDefinition——一个遵循 ToolProtocol 的结构体。工具名按 mcp__{serverName}__{toolName} 格式命名,避免跟内置工具冲突。比如 filesystem 服务器上的 read_file 工具,最终叫 mcp__filesystem__read_file。
3. 组装工具池。 MCP 工具和内置工具、自定义工具合并,经过 allowedTools / disallowedTools 过滤,形成最终的工具池。LLM 看到的是过滤后的完整工具列表。
完整的端到端使用代码:
let agent = createAgent(options: AgentOptions(
apiKey: "sk-...",
model: "claude-sonnet-4-6",
permissionMode: .bypassPermissions,
mcpServers: [
"filesystem": .stdio(McpStdioConfig(
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
))
]
))
// Agent Loop 启动时自动连接 MCP 服务器、发现工具、合并到工具池
let result = await agent.prompt("List all files in /tmp and read the first one")
MCP 服务器不是连上就完事了。运行过程中你可能需要查状态、重连、开关、甚至动态替换服务器集合。SDK 提供了四个方法。
let status = await agent.mcpServerStatus()
for (name, info) in status {
print("\(name): \(info.status.rawValue)") // connected / failed / pending / disabled / needsAuth
print(" tools: \(info.tools)") // ["read_file", "write_file", ...]
if let error = info.error {
print(" error: \(error)")
}
}
McpServerStatus 有五个状态值(跟 TypeScript SDK 对齐):
| 状态 | 含义 |
|---|---|
connected |
已连接,工具可用 |
failed |
连接失败 |
pending |
正在连接 |
disabled |
被用户禁用 |
needsAuth |
需要认证 |
网络抖动或服务端重启后,手动重连某个服务器:
try await agent.reconnectMcpServer(name: "filesystem")
内部实现:断开旧连接 → 清理状态 → 用初始配置重新走一遍连接流程。MCPClientManager 在首次连接时保存了原始配置(originalConfigs),重连时直接用它。
临时禁用某个服务器(断开连接但保留配置),之后还能再开:
// 禁用
try await agent.toggleMcpServer(name: "filesystem", enabled: false)
// 重新启用
try await agent.toggleMcpServer(name: "filesystem", enabled: true)
运行时替换整个 MCP 服务器集合。SDK 做了 diff:新增的连接、删除的断开、配置变化的重新连接:
let result = try await agent.setMcpServers([
"filesystem": .stdio(McpStdioConfig(
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
)),
"database": .stdio(McpStdioConfig(
command: "python3",
args: ["-m", "my_db_server"]
))
])
print("Added: \(result.added)") // ["database"]
print("Removed: \(result.removed)") // 之前有但现在没有的
print("Errors: \(result.errors)") // 连接失败的
MCPClientManager.setServers() 的 diff 逻辑看一下:
public func setServers(_ servers: [String: McpServerConfig]) async -> McpServerUpdateResult {
let existingNames = Set(originalConfigs.keys)
let newNames = Set(servers.keys)
let addedNames = newNames.subtracting(existingNames)
let removedNames = existingNames.subtracting(newNames)
// 配置变化的视为 remove + add
let changedNames = newNames.intersection(existingNames).filter { name in
originalConfigs[name] != servers[name]
}
let effectiveAdded = addedNames.union(changedNames)
// ...执行连接和断开
}
先删除不再需要的,再连接新增和变化的。变化的服务器会被完全重建,不是热更新。这对于长运行的 Agent 应用很重要——你可以在不重启 Agent 的情况下调整 MCP 配置。
MCP 协议除了工具(Tools)还有资源(Resources)。工具是"做事情",资源是"读数据"——比如一个数据库 MCP Server 可以暴露一个 query 工具,同时暴露 tables 资源让 Agent 看有哪些表。
SDK 内置了两个资源相关工具:ListMcpResources 和 ReadMcpResource。
列出所有已连接 MCP 服务器的可用资源:
// LLM 看到的工具描述:
// "List available resources from connected MCP servers.
// Resources can include files, databases, and other data sources."
// 可选参数:server — 按服务器名过滤
内部实现通过 MCPResourceProvider 协议查询每个连接:
public protocol MCPResourceProvider: Sendable {
func listResources() async -> [MCPResourceItem]?
func readResource(uri: String) async throws -> MCPReadResult
}
资源用 MCPResourceItem 表示——有名字、描述、URI。
读取指定 URI 的资源内容:
// LLM 看到的工具:
// "Read a specific resource from an MCP server."
// 参数:server(服务器名)、uri(资源 URI)
两个工具都是只读的,通过 ToolContext.mcpConnections 拿到连接信息——不用全局变量,线程安全。
InProcessMCPServer 是 SDK 里一个独特的设计。它让你用 defineTool() 创建工具,然后包装成一个 MCP Server——但实际上不走 MCP 协议。
为什么?因为有些场景你只是想把自己的工具加到 Agent 的工具池里,不需要跨进程通信。直接调函数比走 JSON-RPC 序列化高效得多。
// 用 defineTool 创建工具
struct WeatherInput: Codable {
let city: String
}
let weatherTool = defineTool(
name: "get_weather",
description: "Get the current weather for a given city.",
inputSchema: [
"type": "object",
"properties": [
"city": ["type": "string", "description": "The city name"]
],
"required": ["city"]
],
isReadOnly: true
) { (input: WeatherInput, context: ToolContext) -> String in
let data: [String: String] = [
"Beijing": "Sunny, 22C",
"Tokyo": "Cloudy, 18C",
]
return data[input.city] ?? "No data for \(input.city)"
}
// 包装为 InProcessMCPServer
let server = InProcessMCPServer(
name: "weather", // 工具名将是 mcp__weather__get_weather
version: "1.0.0",
tools: [weatherTool],
cwd: "/tmp"
)
// 通过 asConfig() 生成配置,注入 Agent
let agent = createAgent(options: AgentOptions(
apiKey: "sk-...",
model: "claude-sonnet-4-6",
mcpServers: ["weather": await server.asConfig()]
))
InProcessMCPServer 是一个 actor,有两种工作模式:
SDK 内部模式(常用): processMcpConfigs() 检测到 .sdk 配置时,直接调用 server.getTools() 拿到工具列表,用 SdkToolWrapper 加上命名空间前缀。整个过程中工具的 call() 方法直接被调用,没有任何序列化开销:
private struct SdkToolWrapper: ToolProtocol, Sendable {
let serverName: String
let innerTool: ToolProtocol
var name: String { "mcp__\(serverName)__\(innerTool.name)" }
func call(input: Any, context: ToolContext) async -> ToolResult {
return await innerTool.call(input: input, context: context)
}
}
注意 SdkToolWrapper 的 call() 直接转发到 innerTool——没有 JSON-RPC,没有 Value 转换,就是直接调函数。
外部客户端模式: 如果有外部 MCP Client 想连进来,createSession() 创建一个 InMemoryTransport 对,跑完整的 MCP 握手。这种场景下才有协议开销:
public func createSession() async throws -> (Server, InMemoryTransport) {
let mcpServer = await getOrCreateMCPServer()
let session = await mcpServer.createSession()
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
try await session.start(transport: serverTransport)
return (session, clientTransport)
}
InProcessMCPServer 内部维护了一个 MCPServer 实例(懒加载),注册工具时把每个 ToolProtocol 的 call() 包装成 MCP 的 handler closure——处理参数格式转换([String: Value] 到 [String: Any])、构建 ToolContext、处理错误结果。
__(双下划线),因为会跟命名空间前缀 mcp__{server}__{tool} 冲突。构造器里有 precondition 检查isError: true 时,MCP 层面会抛出 ToolExecutionError,让 MCP 协议返回 isError: trueassertionFailure,说明是代码 bug(比如重复的工具名)这是 AdvancedMCPExample 示例的核心部分,展示了多工具注册和错误处理:
// 天气工具 — 返回 String
let weatherTool = defineTool(
name: "get_weather",
description: "Get the current weather for a given city.",
inputSchema: [
"type": "object",
"properties": [
"city": ["type": "string", "description": "The city name"]
],
"required": ["city"]
],
isReadOnly: true
) { (input: WeatherInput, context: ToolContext) -> String in
let data: [String: String] = [
"Beijing": "Sunny, 22C, humidity 45%",
"Tokyo": "Cloudy, 18C, humidity 65%",
]
return data[input.city] ?? "No data for \(input.city)"
}
// 邮箱验证 — 返回 ToolExecuteResult,包含错误处理
let validationTool = defineTool(
name: "validate_email",
description: "Validate an email address.",
inputSchema: [
"type": "object",
"properties": [
"email": ["type": "string", "description": "The email address"]
],
"required": ["email"]
],
isReadOnly: true
) { (input: ValidateInput, context: ToolContext) -> ToolExecuteResult in
if !input.email.contains("@") {
return ToolExecuteResult(
content: "Invalid email: '\(input.email)' missing '@'",
isError: true
)
}
return ToolExecuteResult(content: "Email '\(input.email)' is valid.", isError: false)
}
// 打包为 MCP 服务器
let utilityServer = InProcessMCPServer(
name: "utility",
version: "1.0.0",
tools: [weatherTool, validationTool],
cwd: "/tmp"
)
// 创建 Agent
let agent = createAgent(options: AgentOptions(
apiKey: apiKey,
model: "claude-sonnet-4-6",
systemPrompt: "You have weather and email validation tools.",
permissionMode: .bypassPermissions,
mcpServers: ["utility": await utilityServer.asConfig()]
))
// LLM 会自动调用 mcp__utility__get_weather 或 mcp__utility__validate_email
let result = await agent.prompt("Check weather in Tokyo and validate [email protected]")
print(result.text)
工具返回错误时,Agent 不会崩溃。错误信息喂回 LLM,LLM 看到后会调整策略——比如告诉用户邮箱格式不对。
选传输方式。 进程内的工具用 InProcessMCPServer(SDK 模式),外部工具用 stdio(本地)或 HTTP/SSE(远程)。不要用 stdio 去连远程服务,也不要用 HTTP 去连本地命令行工具。
命名要规范。 MCP 工具名是 mcp__{server}__{tool} 三段式。server name 简短有意义,不要用双下划线。filesystem 比 fs-tools-v2 好,因为 LLM 看到 mcp__filesystem__read_file 能直接猜出含义。
错误要包容。 MCPClientManager 的连接失败不会炸掉 Agent——失败的服务器 status 标记为 error,贡献零工具。Agent Loop 照样跑,只是少了那些工具。设计你的系统时也应该遵循这个原则:外部服务不可用时降级运行,不要整体崩溃。
运行时管理用好。 长运行的 Agent 应用应该在启动后检查 mcpServerStatus(),失败的用 reconnectMcpServer() 重试。需要动态调整时用 setMcpServers() 而不是重建 Agent。
系列文章:
GitHub:terryso/open-agent-sdk-swift