跳转到内容

包

面向开发者。从开发者手册选择 API 接入、App 发布或项目贡献。

OpenBot 是一个 pnpm 工作区。每个包只做一件事。openbot 把各部分组装成服务器。其他库可以单独使用。

所有包都命名为 @openbot-build/<name>,版本为 0.1.0,并使用 effect@4.0.5。它们还没有发布到 npm。请在工作区中用 "workspace:*" 使用它们。


包 用途
openbot 服务器、它的插件和它的模型提供方。请看从代码中运行。
api 带类型的 HTTP 契约。服务器、客户端和 API 参考都使用它。
client TypeScript 客户端。请看客户端。
core Bot 核心:Bot、Chat、Turn、存储、插件和技能。
protocol OpenBot App Protocol:发布、查找、连接 App,并为 App 建立索引。
protocol-test App Protocol 的公开测试服务。请看协议测试。
cli openbot 命令。对于 openbot <name>,它会运行 openbot-<name>。
computer openbot computer 命令,用来配对和连接你自己的 Computer。
docs 本文档网站。

下面的示例省略了模型。要运行它们,请提供一个 LanguageModel Layer,例如来自 openbot 中的某个模型提供方。


@openbot-build/core 负责运行 Bot。它有以下主要部分:

模块 作用
Bot Bot 定义:工具、插件、提供方、压缩和批准。一个定义可以服务多个 Chat。
ChatRuntime 运行 Turn:deliver、execute、stream、interrupt、drain、wait 等。
ChatStore、ChatStoreRepository、Store 保存 Chat:每个 Chat 一条小记录,再加一个只会增长的 Item 日志。
ChatStoreMemory 把所有内容都放在内存中的存储,用于测试和脚本。
Chat、Item、Turn、ChatState 数据类型。Chat 中还有 Approvals 服务。
ContextCompaction、SummaryCompaction 让很长的历史变短,好让模型放得下。
Plugin、PluginComposition、PluginState 创建和组合插件,并为插件提供保存的状态。
Skill、SkillMap 描述技能,并维护技能列表。

一个 Chat(聊天)是一段没有终点的对话。每条输入都先进入它的收件箱(inbox)。输入会启动一个 Turn(轮次),或者引导正在运行的 Turn。一个 Turn 可以包含多个模型步骤和工具调用。Turn 从不等待:它运行,然后结束。存储把 Chat 的 Item 保存在一个只会增长的日志中,所以很长的 Chat 保存起来也不贵。

import { ChatRuntime, ChatStoreMemory, Store } from "@openbot-build/core"
import { Effect } from "effect"
import { Toolkit } from "effect/ai"
const program = Effect.scoped(
Effect.gen(function* () {
const definition = yield* Store.define({
stores: {},
schemaVersion: "chat/1",
})
const store = yield* ChatStoreMemory.make(definition)
const runtime = yield* ChatRuntime.make({
store,
bot: { toolkit: Toolkit.empty },
})
const view = yield* store.create({})
return yield* runtime.execute({
chat: view.chat.id,
input: "Explain closures",
})
}),
)

创建运行时时,把模型和工具服务提供给 ChatRuntime.make。它的方法会保留这些服务,包括之后由收件箱启动的 Turn。请通过 bot.approvals、bot.compaction 和 operations 显式传入可选功能;调用方上下文中的服务不会启用功能。 drain 返回 Option<Delivery>,没有可启动的输入时返回 None。 ConfigurationOperations.contextLimits 返回 Option<Config.ContextLimits>。 Live.check 返回 Effect,不支持所需能力时以 LiveError 失败。

没有给出 expectedRevision 时,store.modifyEffect 遇到写入冲突会最多重试五次。每次重试都会读取当前 Chat 并重新运行更新,所以更新不能产生外部副作用。显式给出 expectedRevision 时,冲突会直接失败,不会重试。

Operation.Store 的方法在存储失败时返回 Operation.StoreError。 get 和 finish 返回 Option<Record>;None 表示没有匹配的记录或没有新的结束状态。每次调用可以通过 RunInput.acquire 在自己的 Scope 中持有资源,直到工作、保存结束状态和通知全部结束。首次写入失败时,调用会留在当前 Turn 中。结束状态写入失败时,Chat 仍会收到通知,并记录失败。

独立使用的 core 运行时只在一个进程中运行。每个 Turn 都在新的 Scope 中打开插件资源。后台调用会继续持有所需资源,直到调用结束。core 不向其他进程发送心跳或取消请求,也不在进程停止后恢复工作。服务器通过持久执行系统处理这些工作。

execute 启动一个 Turn,等待它结束,然后返回这个 Chat。stream 在 Turn 运行时提供事件。如果 Chat 中已经有一个 Turn 在运行,两者都会失败。

deliver 按收件箱的规则,一步把输入交给一个 Chat。Chat 空闲时,它启动一个 Turn;输入的 author 相同时,它引导正在运行的 Turn;否则它在收件箱中等待。它返回 started、steered 或 queued。每个 Turn 结束后,收件箱中的下一条输入启动下一个 Turn。

每个 Turn 都在自己的 fiber 中运行,这个 fiber 归运行时所有。fiber 是 Effect 中的轻量线程。运行时的 Scope 关闭时,每个正在运行的 Turn 都会停止,并以 Interrupted 结束。调用方用 stream 或 execute 读取的 Turn 也是这样。在收件箱中等待的输入会留在那里,新的运行时可以用 drain 启动它。如果 stream 的调用方提前停止读取,它的 Turn 也会停止。

events 提供一个 Chat 的事件。带上 after 时,它先提供这个修订之后的最近事件,这样新的订阅者可以赶上进度。运行时为此保留最近 64 个事件。慢的订阅者不会让内存增长:当有 1024 个事件在等它时,每个新事件都会挤掉最旧的一个。所以这样的订阅者可能会错过事件,但它总能得到最新的事件。

带 needsApproval 的工具在每次调用前都会先问。这时运行时会问 Approvals 服务。请在 Bot 定义中把它作为 approvals 提供。它对每次调用给出三种决定之一:

  • Chat.Decision.Allow() 让调用现在运行。
  • Chat.Decision.Deny({ reason }) 不运行这个调用。模型会看到原因。
  • Chat.Decision.Pending({ result }) 现在不运行这个调用。模型马上得到 result,Turn 继续。

没有这个服务时,每个这样的调用都是待定的。所以 Turn 从不等待。

当决策者之后允许了一个待定的调用时,用 deliver 和 approved 把这个调用送回去。调用的 ID 必须是新的。读到这条输入的 Turn 会运行这个调用,而且不会再问一次。请用 notice 标记来自你的程序的输入,这样模型就知道它不是来自人:

import { Chat } from "@openbot-build/core"
const runtime =
yield *
ChatRuntime.make({
store,
bot: {
toolkit,
approvals: {
decide: (request) =>
Effect.succeed(
Chat.Decision.Pending({ result: { pending: request.approvalId } }),
),
},
},
})
yield *
runtime.deliver({
chat: view.chat.id,
input: "[Host] A person allowed the email.",
notice: { kind: "request", ref: "req-1" },
approved: {
id: "approved-1",
name: "SendEmail",
params: { to: "team@example.com" },
},
})

Bot.Definition.registerTools 在 Turn 的作用域内打开工具,返回工具定义、处理器上下文、说明和步骤钩子。它不接收模型循环的继续函数或事件流。所有服务端功能都使用此注册方式。后台调用使用资源期间,资源保持打开。宿主在全部注册完成后统一应用审批和计划模式的规则。工具不会因为较晚注册而绕过规则。Bot 记忆只在模型需要输入时读取;后台调用重建工具时不读取记忆。

SummaryCompaction.prepare 只准备摘要输入,不调用模型。它保留最近的消息,并把有关联的工具调用放在一起。TurnRunner 宿主可以提供 summarize(selection, input),先保存模型结果,再更新上下文。独立使用时,默认调用当前作用域中的模型。服务端适配器复用其他模型步骤的调用日志和用量记录。接管后可以读取已保存的摘要,无需再次调用模型。摘要文本不会进入回复文本事件。

插件注册工具、步骤钩子和状态。Plugin.make 创建插件。构造函数在每个 Turn 阶段的作用域内运行。排队的宿主动作开始新阶段。阶段结束、失败或取消时释放资源。后台工具持有资源直到调用结束。构建错误会使该 Turn 失败。插件不能包裹或启动模型循环。

import { Plugin } from "@openbot-build/core"
import { Context, Effect } from "effect"
const logging = Plugin.make({ id: Plugin.Id.make("logging") })(() =>
Effect.succeed({
tools: {},
handlers: Context.empty(),
beforeStep: ({ kind }) =>
(kind === "model"
? Effect.log("A model step started")
: Effect.void
).pipe(Effect.as(true)),
}),
)

PluginComposition.merge(a, b) 先构建 a,再构建 b。步骤前和步骤后的钩子都按此顺序运行。步骤前钩子返回 false 时,跳过后续检查并结束 Turn。b 的工具定义和处理器优先。ToolRegistration.merge 对普通注册使用相同规则。时间限制作为数据交给运行器,运行器从已保存的 Turn 开始时间计算,重启不会重置时限。组合多个时限时,最短时限生效。

插件可以在 Chat 记录或状态中保存数据。把 plugin.stores 传给 Store.define,然后使用 PluginState.make。

插件存储提供四种操作。get 读取值。snapshot 还返回 Chat 的版本号。 setAt 只在版本号仍匹配时写入。modify 在一次操作中读取并修改值。

传给 modify 的函数返回 { result } 时保留原值,返回 { result, value: Option.some(value) } 时替换值,返回 { result, value: Option.none() } 时删除值。保留原值的操作不写入,也不发送事件。这个函数必须是纯函数:不能读取时钟、生成 ID 或执行 I/O。写入冲突可能使它再次运行。需要 I/O 时,先读取 snapshot,在 modify 之外完成操作,再用 setAt 写入。旧版本号会使写入失败;setAt 不会重复外部操作。

Skill.Descriptor 描述一个技能。SkillMap.Catalog 是一个技能列表。当两个列表中有相同的技能 ID 时,后面的那个生效。查找、加载和运行技能由你的程序负责。


@openbot-build/openbot 包含 OpenBot 的所有插件。服务器在每个 Turn 中都使用它的插件,例如 Bot、App 和工作插件。工作插件为 Bot 提供待办列表、提醒和计划模式。你也可以在自己的 ChatRuntime 中使用 Turn 限制插件。

Auth.handle(request) 返回包含响应的 Effect。Chats.wait 的超时接受 Duration.Input,例如 "30 seconds"。HTTP 路由仍通过 timeoutMs 接受毫秒数。

BotMemory 包含 Bot 记忆的类型和辅助函数。服务器用它们实现记忆 Bot 和记忆 API。记忆 API 的 schema 在 api 中,名为 BotMemory。

BotMemory.apply 接受 ids 数组,其中每个操作按顺序对应一个新 ID。 BotMemory.edit 的 id 是一个新的 ID 字符串。调用方在调用这些纯辅助函数前生成 ID。

有三个插件可以限制每个 Turn。你可以只用其中一个,也可以用 PluginComposition.merge 把它们合在一起。每个插件只计算自己的 Turn。

TurnLimiter 在达到设定的模型步骤数后停止 Turn。第一次模型请求算作一步。

import { TurnLimiter } from "@openbot-build/openbot"
const runtime =
yield *
ChatRuntime.make({
store,
bot: { plugin: TurnLimiter.make({ maxSteps: 4 }) },
})

它会以以下两种错误之一失败:

错误 何时发生
InvalidMaxSteps maxSteps 不是正整数。
ModelStepLimitExceeded 达到限制后,Turn 还需要再走一个模型步骤。它带有 maxSteps 和 history。

TokenLimiter 在 Turn 用掉设定数量的 token 后停止它。token 是模型读或写的一小段文字。插件把每个模型步骤的输入和输出 token 加在一起。已经开始的模型步骤可以完成。当总数达到 maxTokens 时,下一个模型步骤会失败。

import { TokenLimiter } from "@openbot-build/openbot"
const plugin = TokenLimiter.make({ maxTokens: 100_000 })
错误 何时发生
InvalidMaxTokens maxTokens 不是正整数。
TokenLimitExceeded 达到限制后,Turn 还需要再走一个模型步骤。它带有 maxTokens、usedTokens 和 history。

TimeLimiter 在设定的时间过后停止 Turn。maxDuration 可以是毫秒数,也可以是 "5 minutes" 这样的文字。时间一到,插件会马上停止 Turn。模型步骤或工具调用进行到一半时也会被停止。

import { TimeLimiter } from "@openbot-build/openbot"
const plugin = TimeLimiter.make({ maxDuration: "5 minutes" })
错误 何时发生
InvalidMaxDuration maxDuration 不是一个有限的正时间。
TimeLimitExceeded Turn 运行的时间超过了限制。它带有 maxDuration 和 history。

@openbot-build/openbot 有三个模型提供方。每个提供方把一个模型选择变成一个 LanguageModel Layer。API 密钥由你提供。OpenBot 如何使用它们,请看模型。

import { Config, Model, Provider } from "@openbot-build/core"
import { OpenAiCompatProvider } from "@openbot-build/openbot"
import { Effect, Layer, Redacted } from "effect"
import { LanguageModel } from "effect/ai"
import { FetchHttpClient } from "effect/http"
const selection = Config.Selection.make({
provider: Provider.Id.make("openai-compat"),
model: Model.Id.make("gpt-4.1"),
})
const model = Layer.unwrap(
OpenAiCompatProvider.make({ max_output_tokens: 1024 }).model(selection),
).pipe(
Layer.provide(
OpenAiCompatProvider.layerClient({
apiKey: Redacted.make(process.env.OPENAI_API_KEY ?? ""),
apiUrl: "https://api.openai.com/v1",
}),
),
Layer.provide(FetchHttpClient.layer),
)
const program = LanguageModel.generateText({ prompt: "Hello" }).pipe(
Effect.provide(model),
)

省略 apiUrl 时,使用 https://api.openai.com/v1。模型选择中的 reasoningEffort 和 serviceTier 会替换默认值中的 reasoning_effort 和 service_tier。

用法相同,只是换成 OpenAiProvider 和提供方 ID openai。它使用 Responses API。它还有 ResponsesCompaction.layer(),用 Responses API 自带的压缩功能缩短很长的历史。

import { OpenAiProvider, ResponsesCompaction } from "@openbot-build/openbot"
const services = Layer.merge(
Layer.unwrap(OpenAiProvider.make().model(selection)),
ResponsesCompaction.layer(),
).pipe(
Layer.provide(OpenAiProvider.layerClient({ apiKey })),
Layer.provide(FetchHttpClient.layer),
)

从这个层取得压缩服务,再把它传给 Bot:

import { ContextCompaction } from "@openbot-build/core"
const runtime = Effect.gen(function* () {
const compaction = yield* ContextCompaction.ContextCompaction
return yield* ChatRuntime.make({ store, bot: { compaction } })
}).pipe(Effect.provide(services))

用法相同,只是换成 AnthropicProvider 和提供方 ID anthropic。它使用 Messages API。

import { AnthropicProvider } from "@openbot-build/openbot"
const provider = AnthropicProvider.make({ max_tokens: 1024 })

reasoningEffort 可以是 low、medium 或 high。serviceTier 可以是 auto 或 standard_only。其他值会在发出任何请求之前就失败。


@openbot-build/protocol 实现了 OpenBot App Protocol。发布者用它把 App 放到网上。宿主用它查找、连接和调用 App。

import { Document, McpPublisher } from "@openbot-build/protocol"
import { Effect, Schema } from "effect"
import { McpServer, Tool, Toolkit } from "effect/ai"
const tools = Toolkit.make(
Tool.make("Hello", {
parameters: Schema.Struct({ name: Schema.String }),
success: Schema.Struct({ greeting: Schema.String }),
}),
)
const handlers = tools.toLayer({
Hello: ({ name }) => Effect.succeed({ greeting: `Hello, ${name}` }),
})
const origin = "https://apps.example.com"
const publisher = McpPublisher.layer({
publication: {
card: {
$schema: Document.cardSchemaUrl,
name: "com.example/hello",
version: "1.0.0",
description: "Say hello",
remotes: [
{
type: "streamable-http",
url: origin + "/mcp",
supportedProtocolVersions: [Document.protocolVersion],
},
],
},
cardUrl: origin + "/mcp/server-card",
catalogUrl: origin + "/.well-known/ai-catalog.json",
mcpUrl: origin + "/mcp",
allowedOrigins: ["https://host.example.com"],
},
register: McpServer.registerToolkit(tools).pipe(Effect.provide(handlers)),
})

用 HttpRouter.serve 和一个 HTTP 服务器 Layer 来提供 publisher。完整指南请看协议。