GitHub Trending (API)短讯
PiDurableKit:面向 Apple 平台的耐用 AI 智能体框架,基于 JavaScriptCore 与 Swift API
finnvoor 发布开源项目 PiDurableKit,旨在为 Apple 平台提供耐用的 AI 智能体解决方案。该框架在 JavaScriptCore 引擎上运行 pi-durable 核心逻辑,并通过 Swift API 提供接口,便于开发者集成端侧智能体能力。
面向 iPhone、iPad、Mac 和 Vision Pro 的耐用 AI 智能体,由运行在 JavaScriptCore 中的 @earendil-works/pi-durable 提供支持。
对话、模型轮次、工具调用以及您的自定义状态都会在显示任何内容之前提交到存储中。如果 iOS 在模型轮次中途终止了您的应用,重新打开时存储会从停止的地方继续处理工作。
import PiDurableKit
let models = Models(credentials: .keychain)
try await models.setAPIKey(anthropicKey, for: .anthropic)
let harness = try await Harness.open(.sqlite(at: .documentsDirectory.appending(path: "agent.sqlite")), models: models)
let root = try await harness.root(agent: AgentChange(model: .anthropic("claude-sonnet-4-5")))
let submission = try await root.submit("What is the capital of France?")
let settled = try await submission.wait()
if case .done(_, let answer?) = settled.status {
let entry = try await root.commit { tx in try await tx.entry(answer) }
print(entry?.assistantMessage?.text ?? "")
}- Swift 工具、系统提示词片段、钩子和包装器,以普通闭包形式编写
- 子智能体、具有子任务的耐用任务以及原子事务
- 支持所有 pi-ai 提供商(Anthropic、OpenAI、Google、OpenRouter、Groq、xAI、Mistral 等)以及任何兼容 OpenAI 或 Anthropic 的服务器
- 支持 SQLite 或 JSONL 持久化并具备崩溃恢复功能,或内存存储
- pi-durable 的沙盒目录中的读/写/编辑工具
- 流式传输:对话视图(用于 SwiftUI)和编码代理风格事件的 AsyncSequences
- 引导、后续操作、中止、重置、压缩、分支、类型化文档和条目、用量与成本
- 通过 Keychain 中的令牌和自动刷新功能,登录 Claude Pro/Max、ChatGPT、GitHub Copilot 和 OpenRouter 的 OAuth
- 用于测试和 SwiftUI 预览的脚本化模拟提供商
需要 iOS 17、macOS 14、tvOS 17 或 visionOS 1。
安装
.package(url: "https://github.com/<you>/PiDurableKit.git", from: "1.0.0")并将 PiDurableKit 添加到您目标的依赖项中。
概念
Swift API 镜像了 pi-durable 的设计,使用 Swift 并发替代了 Chord 上下文(取消 Swift 任务即可取消等待;取消等待绝不会取消正在进行的工作)。
| pi-durable | PiDurableKit |
|---|---|
| createModels({ credentials }) + providers | Models(credentials:), models.register(CustomProvider(…)) |
| Harness.open(storage, { models, registry, settings }) | Harness.open(.sqlite(at:), models:, extensions:, settings:) |
| harness.root(), createConversation(), fork() | harness.root(agent:), harness.createConversation(agent:), conversation.fork(at:) |
| conversation.submit({ type: "input", … }) | conversation.submit("…", whenBusy:, requestId:) |
| conversation.submit({ type: "write", … }) | conversation.write(EntryDraft(…)) |
| submission.wait(), tx.entry(AssistantEntry, id) | submission.wait(), tx.entry(id)?.assistantMessage |
| conversation.configure(change) | conversation.configure(AgentChange(…)) |
| defineExtension, defineTool, section, hook | Extension, Tool, PromptSection, Hook |
| wrapTool, wrapSection | Wrap.tool, Wrap.section |
| an extension written in JavaScript | Extension(name, javaScript: source) |
| defineTask, TaskRuntime | TaskType, Phase, TaskRun |
| harness.commit(), conversation.commit(), Tx | harness.commit { tx in … }, conversation.commit { tx in … }, Transaction |
| ToolExecutionApi (commit, conversation, memo, …) | ToolCallContext |
| conversation.watch() / viewState() | conversation.changes() / conversation.views(), conversation.view() |
| watchEvents(harness, id) → snapshot, start(listener) | harness.watchEvents(id) → snapshot, for try await events in stream |
| defineDoc, defineDocFamily + tx.doc() | Document(…, scope:, history:, keyed:, migrate:), update(_:_:), values(of:) |
| defineEntry | EntryType, conversation.write(_:_:), entry.data(as:) |
| taskGraph(), watchTaskGraph(), inspect(), getTask() | taskGraph(), taskGraphs(), inspect(), task(_:) |
| HarnessOptions.env, CodingTools | ExecutionEnvironment.directory(_:) / .perConversation { target in … }, Extension.codingTools() |
| harness.subscribeCommits() | harness.commits() |
| SqliteStorage over a SqliteDatabase facade | Storage.sqlite(database:) with your SQLiteDatabase |
| pi-ai models.completeSimple(), streamSimple() | models.complete(_:context:options:), models.stream(_:context:options:) |
| ROOT_CONVERSATION_ID, ReadAfterWrite, StorageRejected | ConversationID.root, PiDurableError.readAfterWrite, .storageRejected |
| conversationCreated, init, now, onReport | onConversationCreated:, initialize:, clock:, onReport: |
| JsonlStorage, models.refresh() | Storage.jsonl(at:), models.refresh() |
工具
struct WeatherArguments: Decodable, Sendable { var city: String }
let weather = Tool(
"get_weather",
description: "Look up the current weather in a city",
parameters: .object(["city": .string("City name")])
) { (args: WeatherArguments, call) in
call.output("Looking up \(args.city)…\n") // streamed to the UI while the tool runs
return "Sunny and 22°C in \(args.city)" // a String, a ToolResult, or a JSONValue
}
let assistant = Extension("assistant") {
PromptSection("preamble", tag: false, text: "You are a concise assistant on an iPhone.")
PromptSection("today") { _ in Date.now.formatted(date: .complete, time: .omitted) }
weather
Hook.beforeTool { call, _ in
await askUserForApproval(call) ? .allow : .block("The user declined")
}
}
let harness = try await Harness.open(.sqlite(at: url), models: models, extensions: [assistant])每次调用都是一个独立的持久化任务。抛出异常会向模型返回错误结果。如果调用因崩溃而中断,仅在工具声明为 replay: .safe 时,重启后才会重新运行;否则模型将收到中断错误。返回 ToolResult.text(…).terminating() 可在不发起新的模型请求的情况下结束运行。
使用 harness.install(_:) 和 harness.uninstall(_:) 在运行时安装、替换或移除扩展。可通过 configure: 针对特定对话进行选择。
尝试异步配置对话,使用 AgentChange 参数指定模型为 OpenAI 的 gpt-4.1,思考级别设为高,移除助手扩展,并指示仅用法语回答。再次尝试异步配置对话,重置扩展和指令。
JavaScript 扩展
let source = #"""
const { defineExtension, defineTool } = require("@earendil-works/pi-durable");
const { Type } = require("@earendil-works/pi-ai");
module.exports = defineExtension({
name: "dice",
tools: [defineTool({
name: "roll", description: "Roll a die", parameters: Type.Object({ sides: Type.Number() }),
execute: async (args) => ({ content: [{ type: "text", text: String(1 + Math.floor(Math.random() * args.sides)) }] }),
})],
});
"""#
try await harness.install(Extension("dice", javaScript: source))该模块采用 CommonJS 格式,可以引入 @earendil-works/pi-durable(以及 /tools、/env)、@earendil-works/pi-ai 和 @earendil-works/chord/context。与 pi-durable 类似,扩展代码未进行沙盒隔离,因此请仅安装您信任的代码;它无法访问 PiDurableKit 自身的存储桥接接口。像其他扩展一样,在重启后恢复之前需要重新安装(通过 Harness.open(…, resume: false),安装,resume()),以便其待处理的工具调用得以继续。
PiDurable.documentation 是 pi-durable 自带的 README.md 文件和 TypeScript 声明,因为 npm 包会提供这些内容(而打包版本仅保留压缩后的代码)。将其提供给编写扩展的智能体,就像 pi 将智能体指向 pi 的文档一样。
Examples/PiChat 同时使用这两者,让智能体在其工作区中编写扩展并将其加载到运行中的会话中:它将文档复制到 /docs/pi-durable,将提示部分指向该位置,并通过 load_extension 工具安装模块。此流程属于应用策略,并非包的一部分。
子代理与事务
工具调用可以提交事务并驱动其他对话。这是 pi-durable 的前台子代理:子对话由调用拥有,因此中止调用也会中止子对话,而在崩溃重运行时能找到相同的子对话。
let subagent = Tool("subagent", description: "Delegate a task", parameters: .object(["task": .string]), replay: .safe) {
(args: SubagentArguments, call) -> String in
let child = try await call.commit { tx in
if let existing = try await tx.conversations(ownedBy: call.taskId, limit: 1).items.first { return existing.id }
let created = try await tx.createConversation(ownedBy: call.taskId)
try await tx.configure(created.id, AgentChange(model: .anthropic("claude-haiku-4-5"), extensions: .remove(assistant)))
return created.id
}
let handle = try await call.conversation(child)!
let settled = try await handle.submit(args.task, requestId: "subagent:\(call.taskId)").wait()
guard case .done(_, let answer?) = settled.status else { return "\(settled.status)" }
return try await call.commit { tx in try await tx.entry(answer)?.assistantMessage?.text ?? "" }
}ToolCallContext 还提供 memo(值在重运行时存活)、agent()、document(_:)、createTask、waitForTask 以及 details/diagnostic。结果可以通过 addingTools(…) 添加工具,工具可以修复参数(preparingArguments)或限制其输出(limitingOutput)。
harness.commit { tx in … } 和 conversation.commit { tx in … } 以原子方式执行任何一组读取和写入操作:创建对话和分支、追加条目、创建任务、配置代理以及读取或写入文档。抛出异常会回滚所有操作。
持久化任务
每个阶段转换都是一个检查点;崩溃后任务会从最后一个检查点继续。通过传递 version: 和 migrate: 来升级正在运行的任务,并通过 abort: 在任务被中止时撤销其效果。harness.taskGraph()、taskGraphs() 和 inspect() 可显示当前运行状态。
更多钩子和包装器
Extension("guard") {
Hook.beforeRequest { messages, _ in messages + [.user(UserMessage(content: [.text("Answer briefly.")]))] }
Hook.afterResponse { message, context in log(message.usage) }
Hook.afterTools { assistant, results, context in … }
Hook.beforeCompact { request, _ in request.reason == "manual" ? nil : .decline } // or .summary("…")
Wrap.tool("get_weather") { call, context, next in try await next(nil) } // decorate any extension's tool
Wrap.section("preamble") { input, next in (try await next()).map { $0 + "\nBe kind." } }
}钩子会获得带有 memo 和 document(_: ) 方法的 HookContext;部分(sections)会获得 input.shown(已生效的部分)和 input.document(_: )。
从工具或任务中调用模型
let summary = try await call.harness.models.complete(.anthropic("claude-haiku-4-5"), context: ModelContext(
systemPrompt: "Summarize in one sentence.", messages: [.user(UserMessage(content: [.text(text)]))]))
return ToolResult(content: [.text(summary.text)], usage: summary.usage) // the spend counts in pi.usagemodels.stream(...) 会产出文本和思考增量、已完成的工具调用以及最终消息。
文件
let harness = try await Harness.open(
.sqlite(at: url), models: models, extensions: [.codingTools()],
environment: .directory(URL.documentsDirectory.appending(path: "Workspace")))代理将目录视为根目录 / 且无法离开该目录;对话的当前工作目录是其内部的一个路径。iOS 上没有 Shell,因此不包含 bash。若要为每个对话提供独立的沙箱环境,可按对话选择,类似于 pi-durable 中的 env 函数:
environment: .perConversation { target in
let project = try await target.document(projectDocument).folder
return project.isEmpty ? nil : .directory(projects.appending(path: project))
}SwiftUI
struct ChatView: View { let conversation: Conversation @State private var view: ConversationView? @State private var draft = ""
var body: some View {
List {
ForEach(view?.entries ?? []) { entry in
if entry.kind == .user || entry.kind == .assistant { Text(entry.text) }
}
if let partial = view?.streamingMessage { Text(partial.text).foregroundStyle(.secondary) }
ForEach(view?.live.tools ?? []) { tool in Label(tool.output ?? tool.name, systemImage: "hammer") }
}
.safeAreaInset(edge: .bottom) {
TextField("Message", text: $draft).onSubmit {
let text = draft
draft = ""
Task { try await conversation.submit(text) }
}
}
.task {
do {
for try await view in conversation.views() { self.view = view }
} catch {}
}
}
}
``
views() 仅缓冲最新的视图,因此繁忙的 UI 会跳过中间状态。对于细粒度的增量更新(文本追加、工具输出),请使用代理事件:
``
let stream = try await harness.watchEvents(conversation.id)
initialize(stream.snapshot)
for try await events in stream { // 每次提交一个批次
for event in events { apply(event) }
}
``
繁忙对话
``
try await conversation.submit("Also run the tests") // 后续操作(默认)
try await conversation.submit("Use pnpm, not npm", whenBusy: .steer) // 加入正在运行的工作
try await conversation.submit("Only if idle", whenBusy: .reject) // 抛出 .conversationBusy
try await conversation.abort() // 停止并撤回排队中的输入
``
持久化和恢复
``
let harness = try await Harness.open(.sqlite(at: url), models: models, extensions: [assistant])
let root = try await harness.root() // 与上次相同的根节点
// 未完成的运行会自动继续(传递 resume: false 以延迟)。
let submission = try await root.submit("Hello", requestId: "greeting-1")
// 崩溃后,相同的 requestId 会找到相同的提交,而不是重复提交:
let again = try await root.submit("Hello", requestId: "greeting-1")
let settled = try await harness.submission(submission.id).wait()
``
重启后安装相同的扩展,以便挂起的工具调用可以恢复。
你自己的状态
``
struct Todos: Codable, Sendable { var items: [String] = [] } let todos = Document("app.todos", initial: Todos())
try await conversation.update(todos) { $0.items.append("Write docs") } // 一次原子提交
let current = try await conversation.document(todos)
for try await value in conversation.values(of: todos) { … }
``
文档也可以存在于会话中(scope: .session,通过 harness.document(_:)) 读取)或任务中(scope: .task),形成键控族(keyed: true,然后 key:),保留其历史(history: .rewindable,然后 asOf:),以及迁移(version: 2, migrate: { stored, fromVersion in … })。每次写入仅存储更改的内容。Harness.open(onConversationCreated:) 和 root(initialize:) / createConversation(initialize:) 在创建提交时播种文档。
``
let note = EntryType<Note>("app.note")
try await conversation.write(note, Note(text: "Opened settings"))
let notes = view.entries.compactMap { $0.data(as: note) }
``
在您自己的 SQLite 数据库上存储
pi-durable 的可移植 SQLite 存储在实现其小型外观的任何数据库上运行,因此它可以与应用程序其余部分共享数据库、使用加密或位于应用组中:
``
final class AppDatabase: SQLiteDatabase { /* execute, run, get, all, transaction, close */ }
let harness = try await Harness.open(.sqlite(database: AppDatabase(…)), models: models)
``
合同是 pi-durable 的:事务的工作使用传递给其主体的句柄,并且每个其他操作都会等待直到完成。
复制 harness
harness.commits() 在每次提交变更被存储时立即交付其变更内容:包括对话、条目、任务、提交和文档操作。
模型与提供商
Models() 注册所有内置的 pi-ai 提供商。iOS 没有环境变量,因此需显式传递密钥(将其存储在钥匙串中):
``
let models = Models(credentials: .keychain) try await models.setAPIKey(anthropicKey, for: .anthropic) try await models.setAPIKey(openAIKey, for: .openAI) let catalog = try await models.models(for: .anthropic)
// 您自己的提供商(pi-ai createProvider,例如 models.json 自定义提供商):
try await models.register(CustomProvider(
id: "gateway", baseURL: URL(string: "https://gateway.example.com/v1")!, api: .openAICompletions,
apiKey: gatewayKey, headers: ["X-Tenant": "acme"], // 随每个模型的请求发送
models: [
.init(id: "llama3.1:8b", contextWindow: 131_072, compat: ["supportsDeveloperRole": false]),
.init(id: "claude", api: .anthropicMessages, headers: ["X-Route": "anthropic"]), // 混合 API,按模型设置标头
]))
``
标头的用法与 pi-ai 相同:模型的标头(继承其提供商的标头)随该模型的请求一起发送,而 Settings.Stream.headers 随每个请求一起发送。
将提供商 API 密钥打包在应用内会使其暴露给任何检查该应用的人。在生产环境中,请将 CustomProvider(或 Settings.Stream.headers)指向您自己的后端代理。
使用订阅登录(OAuth)
支持 OAuth 登录的提供商(Anthropic 用于 Claude Pro/Max,OpenAI 用于 ChatGPT,GitHub Copilot,OpenRouter 等)会在 ProviderInfo.oauth 中报告此信息。将凭据保存在钥匙串中,以便在重新启动后保持登录状态:
``
let models = Models(credentials: .keychain)
// 在按钮操作中: try await models.login(to: .anthropic, interaction: .webAuthenticationSession()) // 从此刻起,请求将使用订阅;令牌会自动刷新,且刷新结果会写回。 let harness = try await Harness.open(.sqlite(at: url), models: models)
try await models.logout(from: .anthropic)
``
登录页面将在 ASWebAuthenticationSession 工作表中打开。提供商重定向到 http://localhost: /callback(其 OAuth 客户端注册的回调 URI),因此 PiDurableKit 在应用内部使用 Network.framework 运行 pi-ai 的回环重定向服务器,并仅绑定到回环接口。当收到重定向时,代码将被交换为令牌,并且工作表会自动关闭。关闭工作表将取消登录,取消调用 login 的任务也会取消登录。
使用 ChatGPT 登录会将应用安装注册到 OpenAI;login 会传递 Models.installationID(一个保存在 UserDefaults 中的 UUID),除非您提供自己的 installationID:。
设备代码登录(GitHub Copilot)会打开验证页面并将代码复制到剪贴板;传入 onDeviceCode: 以自行显示它。如需完全控制,请从闭包构建 LoginInteraction:presentSignInPage(显示 URL 直到取消)、prompt(回答选择/文本/代码问题)和 notify(进度、URL、设备代码)。
CredentialStore 是一个包含三个方法的协议,因此您也可以将凭据保存在其他地方,例如应用组钥匙串(.keychain(service:accessGroup:))或您自己的服务器。pi-ai 按提供商序列化刷新操作,因此并发请求永远不会重复刷新同一个令牌。
订阅登录使用提供商的第一方 OAuth 客户端,即 pi 和其他编码代理使用的相同客户端。在发布之前,请确认您的用途允许这样做。
Examples/PiChat 是一个小型 iPhone 聊天应用,支持通过 OAuth 登录 pi 所支持的每个提供商,并提供模型选择器。代理还可以运行你允许的快捷指令,通过捆绑的“PiChat Bridge”快捷指令,其通知自动化会执行这些快捷指令(参见 Examples/PiChat/Shortcuts;iOS 27)。当你离开应用时,回复仍在继续,并在实时活动(Live Activity)中显示进度(这是一个 BGContinuedProcessingTask,因此该示例需要 iOS 27)。打开 Examples/PiChat/PiChat.xcodeproj(Xcode 27 或更高版本),并将你的团队设置为在设备上运行它。使用 -demo 启动它以进行无需账户的脚本化对话,或使用 -demo -bench 5000 以生成 10,000 条消息、流式传输一条回复并滚动历史记录,同时在记录转录更新时间和延迟帧的同时显示和隐藏键盘(-keyboardLoop 每两秒切换一次键盘,用于录制其动画)。
测试与预览
``
let models = Models(builtinProviders: false) let faux = FauxProvider() try await models.register(faux) try await faux.append(.toolCall("get_weather", ["city": "Paris"])) try await faux.append(.text("It is sunny in Paris.")) // 或动态回答: try await faux.respond { messages, _ in .text("Echo: \(messages.last?.text ?? "")") }
let harness = try await Harness.open(models: models, extensions: [assistant])
let root = try await harness.root(agent: AgentChange(model: faux.model))
``
工作原理
Sources/PiDurableKit/Resources/pi-durable.js 是 pi-durable、pi-ai 和 chord 的一个单文件 JavaScriptCore 包,由 esbuild 从 JS/ 构建而成。JavaScriptCore 没有 Web 平台,因此该包携带了小型 polyfill,其平台部分由 Swift 实现:
| JavaScript | Swift |
| --- | --- |
| fetch, streaming Response.body | URLSession data delegate |
| node:sqlite (pi-durable 的 SQLite 存储) | SQLite3 |
| setTimeout, setInterval | DispatchQueue |
| crypto.getRandomValues | SecRandomCopyBytes |
| console | os.Logger (或 Runtime.Configuration.log) |
| crypto.subtle.digest (PKCE) | CryptoKit |
| node:http (OAuth 回环重定向) | Network.framework NWListener on the loopback interface |
| pi-ai CredentialStore | CredentialStore (Keychain, memory, or yours) |
TextEncoder/TextDecoder、AbortController、EventTarget、Blob/FormData 以及 Headers/Request/Response 由 TypeScript 实现;ReadableStream 来自 web-streams-polyfill,较新的 ECMAScript 内置函数来自 core-js。所有 JavaScript 代码均在每个 Runtime 的一个串行队列上运行,该队列同时也是拥有 JSContext 的 actor 的执行器。Swift 和 JavaScript 通过一个小型 JSON 桥接器进行通信(JS/src/bridge.ts)。
使用 Runtime(configuration:) 为代理提供独立的 JavaScript 线程或自定义 URLSessionConfiguration,并将其传递给 Models(runtime:)。
更新 pi-durable
捆绑的版本在 JS/package.json 中精确固定,并作为 PiDurable.version、PiDurable.piAIVersion 和 PiDurable.chordVersion 暴露。
``
cd JS
npm ci
npm run update # 最新的 pi-durable,包含其依赖的 pi-ai 和 chord 版本
npm run update -- 1.0.5 # 或特定版本
cd .. && swift test
``
update 命令会安装发布版本,针对新的类型定义对桥接器进行类型检查(以便上游 API 变更能引发明显错误),重新构建包,并重新生成 Generated/Versions.swift。提交结果。
在 CI 中,.github/workflows/update-pi-durable.yml 每天执行此操作(或在指定版本时按需执行),运行 macOS 和 iOS Simulator 测试套件,并打开一个拉取请求——当测试失败时为草稿状态。.github/workflows/ci.yml 检查提交的包是否与 package-lock.json 匹配,并在每次推送时运行测试。
许可证
PiDurableKit 自身的代码根据 CC0 1.0 贡献给公共领域。
JavaScript 捆绑包包含在 MIT、Apache-2.0、BSD-3-Clause 和 Unlicense 条款下的开源软件包(pi-durable、pi-ai、chord,Anthropic、OpenAI 和 Google SDK 及其依赖项)。它们的许可证要求随应用分发其通知:在致谢屏幕中显示 PiDurable.thirdPartyNotices,或将其添加到您的设置捆绑包中。构建过程会在每次更新时从捆绑的软件包重新生成通知,如果某个捆绑的软件包没有可包含的许可证,则构建会失败。
文档
该软件包包含 DocC 目录(Xcode 中的产品 ▸ 构建文档);CI 以警告即错误的方式对其进行构建。
开发
``
cd JS && npm ci && npm run build # 修改 JS/src 后执行
swift test # macOS
xcodebuild test -scheme PiDurableKit -destination 'platform=iOS Simulator,name=iPhone 17'
``
cd JS && npm test` 运行 JavaScript 单元测试。Swift 测试针对模拟 HTTP 服务器运行真实的 pi-ai 提供商 SDK(Anthropic、OpenAI Responses、OpenAI 兼容模式)以及 pi-ai 的真实 Anthropic OAuth 流程(PKCE、回环重定向、令牌交换、刷新),此外还使用假定的提供程序来处理行为、持久化和崩溃恢复。
译文已达到本站中文翻译的字数上限,剩余内容请查看原文。