【免费下载链接】OpenBot
Open-source AI coworkers that each get a computer of their own: a browser, files and tools, with every action decided before it happens and recorded after. Bring any AG-UI agent.
一个租户包里要新增一位 AI 同事(coworker),过去只能去改agents.yaml这个所有人都在编辑的文件;要把某位同事交给别人,也只能从中间撕一段 YAML 出来。OpenBot 的 fintech 示例包给出的解法是:在agents.yaml旁边放一个agents/目录,一个文件声明一位同事,加载器把两处一起读。这既解决了“多人改同一个文件”的冲突,也让一位同事变成可复制、可删除、可转交的独立单元。读完本文你将掌握agents/目录的加载规则、文件格式与全部校验行为,能据此从catalog/里挑选同事落地到自己的部署,并清楚知道删除文件之后会发生什么。
这个目录要解决的具体问题
examples/fintech/agents/README.md用三句话说明了设计意图和约束:
- 目录里的一个文件声明一位同事,加载器会把它和
../agents.yaml一起读; - 两种写法并存,只把内容全放在
agents.yaml里的包行为不变; - 包默认从
../agents.yaml启动两位同事(General Assistant 与 Knowledge),因此全新部署打开时是一个简短花名册,而不是十几个没人要的 Bot;另外两条记录只有在端点配置好之后才会注册; - 增加一位同事,从
../catalog复制一个文件进来;删除一位同事,删掉它的文件。
配套的examples/fintech/catalog/README.md进一步说明了为什么“一个文件一位同事”值得做:catalog/里的东西不会被加载,它是一份“可以拿走的同事”清单——把文件复制进agents/,下次启动就注册。而agents.yaml单文件模式的问题在于:新增一位同事意味着修改别人也在改的文件,转交一位同事意味着给对方一段要粘贴进自己文件中间的碎片。目录模式下,一位同事就是一个文件。
需要提前记住一条边界:这些文件不是授权(grant)。同事只声明技能(skill),技能再声明工具,而它实际能调用什么,取决于管理员授予了什么。因此往agents/里复制文件,增加的是“指令”,不是“能力”。这一点在examples/fintech/skills.yaml的头部注释里被反复强调,也是整个包模型的安全底座。
启动时的加载链路
从源码结构看,加载发生在服务启动早期,顺序是“先读包、后同步进库”:
server/src/index.ts调用loadTenantPackage(config.tenantPackageDirectory)。注意它被提前到线程标识创建之前——包决定了部署名,而频道存储需要这个名字才能生成 thread id(见该行上方的注释)。server/src/index.ts调用synchronizeTenantPackage(database, tenantPackage),在一个事务里把同事、频道、技能和包授予写进数据库。- 包目录由环境变量
TENANT_PACKAGE_DIR指定,默认值是../examples/fintech(相对server/解析),见server/src/config.ts。因此默认部署跑的就是本文讨论的这个 fintech 包。
读取目录的函数在server/src/tenant-package.ts的readAgentFiles,它决定了目录语义的全部细节:
const directory = join(sourcePath, "agents"); const entries = await readdir(directory).catch( (error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" || error.code === "ENOTDIR") return []; throw error; }, ); const filenames = entries .filter((entry) => entry.endsWith(".yaml") || entry.endsWith(".yml")) .sort();四条规则可以直接从这段代码读出:
- 目录可以不存在:没有
agents/就是“所有同事都在一个文件里”的老包,继续正常加载; - 只读
.yaml和.yml:所以在agents/旁边放一个 README 不会被当作同事去解析; - 按文件名排序:注释里说明了原因——顺序决定了重复 id 会被归咎于哪个文件,而“在另一台机器上报出另一个文件”的拒绝信息没人能据此修复;
${NAME}在这里同样展开:与agents.yaml完全一致。
排序行为有对应测试:server/tests/tenant-package-agent-files.test.ts写入b-second.yaml、a-first.yml和一个README.md,断言新增同事的顺序是["first", "second"],README 被忽略。
单个文件的写法:独立文档或agents:列表
agents/下的一个文件支持两种形态。第一种是直接写一位同事:
# examples/fintech/agents/expense-review.yaml,从 catalog/ 复制而来 id: expense-review name: Expense Review title: Finance Operations role_description: Check one expense claim at a time against the policy as it is written. avatar_seed: expense-review type: built-in system_prompt: Quote the clause you relied on, and leave the decision to a person. skills: - find-a-document第二种是把agents:列表照搬过来,和agents.yaml一样:
agents: - id: expense-review name: Expense Review title: Finance Operations role_description: Check one expense claim at a time against the policy as it is written. type: built-in system_prompt: Quote the clause you relied on, and leave the decision to a person.第二种形态存在的理由是迁移体验:有人拆分现有agents.yaml时会把列表语法一起带过来,“一种能解析、另一种不能”会把一次搬移变成一道谜题。两种写法都有测试覆盖,见server/tests/tenant-package-agent-files.test.ts。
合并逻辑在server/src/tenant-package.ts的collectAgents:先用agents.yaml的agents:列表,再逐个文件;文件里若没有agents键,就把整个文档当作一位同事([document])。这也解释了为什么独立文档里不能缺字段——整个文档都会被当成同事记录去校验。
字段与类型:与agents.yaml完全一致
目录里的文件走的是同一个parseAgents,所以字段规则没有第二套。每个同事必须有id、name、title、role_description、type:
| 字段 | 必填性 | 说明 |
|---|---|---|
id | 必填 | 非空字符串,且不能是部署路由名或“个人 OpenBot 浏览器”形态的 id |
name | 必填 | 非空字符串 |
title | 必填 | 字符串,最长 120 字符 |
role_description | 必填 | 非空字符串,远程同事唯一的指令来源 |
type | 必填 | 只能是built-in、remote-ag-ui、remote-mastra |
avatar_seed | 可选 | 缺省时回落到id |
skills | 可选 | slug 数组,必须是本包skills.yaml里声明的 |
按type分叉的字段是:
| 类型 | 必需字段 | 额外说明 |
|---|---|---|
built-in | system_prompt | 会被写成configuration.systemPrompt |
remote-ag-ui | endpoint | 端点为空则该同事被丢弃而非报错 |
remote-mastra | endpoint | 可选remote_agent_id指定 Mastra 花名册中的某一个 |
type的映射与校验见server/src/tenant-package.ts:built-in→built_in、remote-ag-ui→remote_ag_ui、remote-mastra→remote_mastra,其他值直接抛出agent.type must be built-in, remote-ag-ui or remote-mastra。
一个容易忽略的细节:built-in同事拿到的是system_prompt,而远程同事根本没有这个字段,role_description是它从包里收到的唯一指令。所以远程同事的那句话要写成完整的工作简报,而不是一个列表标签——这一点在docs/configuration.md里有明确说明。
id的两条硬性约束
id不是随便起的。parseAgents里有两道检查:
if (DEPLOYMENT_ROUTES.has(id)) { throw new Error( `${source}: agent.id "${id}" is reserved for a deployment route and cannot name a Bot`, ); } if (isPersonBrowserId(id)) { throw new Error( `${source}: agent.id "${id}" is the shape of a person's OpenBot browser and cannot name a Bot`, ); }第一道防的是权限绕过:computer 路由的 bot-access 守卫会对部署路由名让步,而请求无法区分“名叫policy的 Bot”和/policy这个路由本身,这样的 Bot 会被提供给任何能登录的人。第二道防的是“撞电脑”:个人 OpenBot 浏览器的 id 与 Bot 的 id 同形,重名会让 Bot 与那位用户共用一台电脑。由于包里的id是唯一能自定义 Bot 标识的途径(通过 API 创建的都是agent_<uuid>),在校验阶段拒绝比事后迁移更彻底。
校验:拒绝时要能指出是哪个文件
agents/的整个价值在于文件可以被复制来复制去,所以“复制错了”必须是一个能被立刻定位的错误。collectAgents的注释写得很直白:偏好某一个会让“部署运行哪个同事”取决于目录被读取的顺序,而一个把同一位同事用不同文件名复制了两次的克隆,将永远不会发现问题。
因此重复id是报错,并且两个文件都会被点名。测试里的期望字符串是:
agent "knowledge" is declared in both agents.yaml and agents/knowledge.yaml其余校验同样会带上agents/<filename>前缀,例如把type写成smoke:
agents/broken.yaml: agent.type must be built-in这来自collectAgents里const source = \agents/${file.filename}`的传参方式,以及parseAgents把source` 拼进每一条错误消息的做法。
除了类型与重复 id,目录里的文件还要过这些关:
- 技能必须属于本包:
skills:里写了本包skills.yaml没有的 slug,会得到agent "expense-review" names skill "quote-the-expense-policy", which this package does not ship。这条检查是刻意的:两个文件由同一个人一起交付,匹配不上就是拼写错误,而“静默不挂载任何技能”的拼写错误最难被发现——Bot 只是永远不做工具收窄,部署看起来还在正常工作; - 同一技能不能写两次:
agent "x" names skill "triage" twice。原因是同步时一次多行INSERT ... ON CONFLICT,Postgres 遇到重复行会直接拒绝,服务器会在启动时死于一条 SQL 错误; - slug 形状:
^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$,即小写字母、数字和连字符,2–40 字符,首尾必须是字母或数字。它和 skills API、前端表单用的是同一个模式,否则包能种出用户既打不出来也编辑不了的/命令。
空端点:不是错误,是“还没配置”
agents.yaml里的risk-analyst和picked-harness都用了${VAR:-}形式。解析后端点为空字符串时,parseAgents不会报错,而是把 id 收进omittedAgentIds并跳过这条记录:
if (type === "remote_ag_ui" || type === "remote_mastra") { const endpoint = typeof agent.endpoint === "string" ? agent.endpoint.trim() : ""; if (!endpoint) { omittedAgentIds.add(id); return []; } }随后validateTenantPackage把这个集合用于两件事:从频道的permitted_agents里过滤掉这些 id(否则“频道引用了未知 agent”会误伤),以及把它们交给同步函数去停用对应的 profile。这就是“一条记录在没人挑选之前自己消失”的实现方式——注册不需要 API,选择变成一个设置项,剩下的交给种子同步。
还有一个反过来的规则,测试位于server/tests/tenant-package-duplicates.test.ts:某个 id 在agents.yaml里端点为空白,但在agents/risk.yaml里被正式声明,那它就是“已声明”,不会进omittedAgentIds,也不会被从频道里剔除。真实声明胜过空白,且与目录读取顺序无关:
for (const id of agentIds) omittedAgentIds.delete(id);环境变量插值:包描述部署,地址属于环境
loadTenantPackage对五个必需文件、skills.yaml和每个agents/文件都调用expandEnvironment。规则在server/src/tenant-package.ts:
${NAME}取环境变量;${NAME:-fallback}在未设置或为空串时取 fallback;- 既没有值也没有 fallback 时抛错,并指明是哪个文件在要这个变量:
${filename} refers to ${NAME}, which is not set in this environment.
这解释了picked-harness那一行的设计:type: ${PICKED_HARNESS_KIND:-remote-ag-ui}的类型本身也是插值的。如果写成字面量remote-mastra,任何早于该类型的服务器都会因为“未知agent.type”拒绝整个文件——而拒绝一位同事就是拒绝整个文件,部署根本起不来。插值后,旧服务器看到的是remote-ag-ui,只有在真的有人选了 Mastra 时才会变化。
校验和:删除和新增都是“包变更”
agents/里的文件参与包的 SHA-256 校验和:
checksum: createHash("sha256") .update( [ ...contents, skills, ...agentFiles.map((file) => `${file.filename}\n${file.contents}`), ].join("\n"), ) .digest("hex"),注释说明了原因:agents/进校验和的理由与skills.yaml相同——在这里新增、修改或删除一位同事都是包变更;没注意到的部署会继续跑着它启动时的花名册,而仓库里写的已经是另一回事。server/tests/tenant-package-agent-files.test.ts直接改一个title,断言校验和随之改变。
实战:从 catalog 里落地一位同事
catalog/里有十份可直接复制的同事,覆盖财务、会议、发布、支持、入职、研究、招聘、可靠性、采购和产品反馈。它们的共同点是role_description的写法:写清这份工作是什么、绝对不能做什么、以及找不到东西时该说什么。catalog/README.md的判断是——一句话的描述只会得到一个回答含糊的同事。
以examples/fintech/catalog/ticket-triage.yaml为例,它的约束写得非常具体:“你起草,人发送。绝不回复客户、绝不关闭工单、绝不承诺日期、退款或修复。如果工具报错或说未连接,说出来并停下,而不是猜工单系统会说什么。”它的system_prompt则规定了输出形状:ask、urgency、归属团队,每条都要附上工单里导致这个判断的那句话。
落地步骤:
# 1. 复制一位同事进 agents/(目录不存在就创建) mkdir -p examples/fintech/agents cp examples/fintech/catalog/ticket-triage.yaml examples/fintech/agents/ # 2. 确认它声明了 skills.yaml 里存在的技能 grep -A3 '^skills:' examples/fintech/agents/ticket-triage.yaml # 3. 只跑解析层测试,验证加载与校验行为 cd server && bun test tests/tenant-package-agent-files.test.ts tests/tenant-package-duplicates.test.tsticket-triage.yaml声明了find-a-document和who-owns-this,两者都在examples/fintech/skills.yaml中定义,因此校验会通过。反过来说,如果你从外部拿来一位同事、它声明了本包没有的 slug,服务会在启动时拒绝并说出是哪个文件——这正是你想要的失败方式。
复制完之后,别忘了频道:新增的同事如果不在任何channels.yaml的permitted_agents里,它虽然注册了,但没有对话入口。频道里引用未知 id 也会报channel references unknown agent "..."。
一个可以带走的检查清单
- 文件必须落在包的
agents/下,扩展名.yaml或.yml; - 独立文档形态要写全
id、name、title、role_description、type,以及按类型分叉的system_prompt或endpoint; id不能与agents.yaml或其他文件重复,也不能是部署路由名;skills:里的每个 slug 都必须在同一包的skills.yaml里存在,且不能重复;- 引用它的频道要同步更新
permitted_agents; - 需要端点时用
${VAR:-},让未配置的部署自动丢弃这条记录而不是启动失败。
删除文件之后会发生什么
这是“删一位同事”最容易踩空的地方。删除文件在下一次启动时生效:包不再声明它,校验和变化,同步逻辑也不再 upsert 它。但已经注册过的同事不会自动消失——它的行还在,仍归这个包所有,因此仍会出现在列表里,并且仍然受删除保护。
真正会被自动退休的只有一种:端点为空的远程同事(即进omittedAgentIds的那些),同步函数会为它们设置deleted_at:
await transaction .update(agentProfiles) .set({ deletedAt: now, updatedAt: now }) .where( and( isNull(agentProfiles.ownerUserId), isNull(agentProfiles.deletedAt), inArray( agentProfiles.agentId, transaction.select({ id: agentTable.id }).from(agentTable).where(/* 属于本包且在被省略的 id 中 */), ), ), );要退休一个已经从包里删掉的同事,只能把它的agent_profiles行的deleted_at设上——这也正是“删除一位由人创建的 Bot”所做的事。断言deletedAt的条件里有两个isNull,意味着这个操作只会作用在“包拥有、且尚未被删除”的行上,不会碰到用户自己的 profile。
同步阶段:文件声明如何变成数据库行
校验通过之后,synchronizeTenantPackage在一个事务里完成写入,顺序值得了解,因为它决定了“改文件”和“改界面”的边界:
- 先检查保留路由名是否已经作为 Bot 存在,存在则拒绝启动(不回滚地半同步比拒绝更糟);
- upsert 部署包记录(含
sourcePath与checksum); - 停用被显式省略的远程同事,但保留会话成员关系与可读历史;
- 逐位 upsert
agents与agent_profiles,setWhere保证只更新本包拥有的行;如果撞上用户自己创建的 agent 或 profile,会抛出collides with a user-created agent/collides with a user-owned profile; - 逐频道 upsert 并重建
channel_agents关联; - 技能按 slug upsert,
origin记为catalogue、owner_user_id为空,因此所有人都能在/菜单里看到。如果这个 slug 已经被某个人写的技能占用,包会让位,只打一条package-skill-skipped警告并跳过——因为任何人都能写技能,若在那里抛错,一个人就能靠抢名字让整个部署起不来; - 包授予的技能以
grantedBy = "tenant-package"写入,且每次同步先删除“上一次由包授予的全部记录”,所以包停止授予某个技能时会收回它;但管理员在 Skills 页面手工授予的那条带着自己的用户 id,会被保留。
这也解释了为什么agents/里的skills:是安全的:它种下的是技能授予,而不是 MCP 授予。技能是指令、不带来能力,实际可调用的工具永远是“声明”与“授予”的交集。
两种写法怎么选
| 场景 | 建议 |
|---|---|
| 包很小、同事固定、单人维护 | 全部放agents.yaml,行为与旧包完全一致 |
| 多人协作、要频繁增删同事 | 用agents/,一位同事一个文件,减少编辑冲突 |
| 要把某位同事交给另一个部署 | 用agents/,直接给文件,不必让对方粘贴片段 |
| 只想看看有哪些现成的同事 | 看catalog/,它不会被加载 |
两种方式不是二选一:agents.yaml先读,agents/逐个追加,重复 id 会被拒绝。因此你可以保留general-assistant与knowledge这两个“开箱即有”的同事在agents.yaml里,把后续按业务线加入的同事都放进agents/,让花名册保持初始化时的简短形状——这正是 fintech 包选择的布局。
小结
examples/fintech/agents/README.md描述的机制很小:一个目录、一个文件一位同事、与agents.yaml并存。但它背后是三条一致的工程判断:同事应当是文件(可复制、可删除、可转交),歧义应当在启动时拒绝(重复 id 点名两个文件、技能拼写错误点名文件、错误信息永远带agents/<filename>),以及声明不等于授权(技能只写指令,工具仍由管理员授予)。理解这三点之后,catalog/的十份模板就不只是可以复制的样例,而是一套你可以照着写、照着改动、照着交接的租户包工作方式。
【免费下载链接】OpenBot
Open-source AI coworkers that each get a computer of their own: a browser, files and tools, with every action decided before it happens and recorded after. Bring any AG-UI agent.
相关推荐
OpenBot Agent Designer 技能实战:用 create_agent 与 read_agent 造一个"只做一件事"的持久 Agent
OpenBot Agent Designer 技能实战:用 create_agent 与 read_agent 造一个"只做一件事"的持久 Agent 在 Op
桌面应用AI Agent人工智能多智能体代码智能体浏览器控制本地部署MCP 服务WiX Toolset v3 实战:使用 DirectorySearchRef 复用 DirectorySearch,在同一目录下定位多个文件
WiX Toolset v3 实战:使用 DirectorySearchRef 复用 DirectorySearch,在同一目录下定位多个文件 在 WiX To
开发工具构建工具WeKan 多租户设计全解析:从"每租户一个进程"到"以组织为租户"的落地实践
WeKan 多租户设计全解析:从"每租户一个进程"到"以组织为租户"的落地实践 导读 本文以 docs/Design/Multitenancy/Multiten
后端前端协同办公项目管理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考