From 6843056250791d6ac4697abbd3cb5115a8adaa6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:36:27 -0400 Subject: [PATCH 1/3] Read a coworker from a file of its own, beside agents.yaml agents.yaml holds every coworker a package ships, so adding one means editing a file somebody else is editing too, and handing somebody a coworker means handing them a fragment to paste into the middle of theirs. A package may now also keep a coworker per file in an agents/ directory beside agents.yaml. Both are read, and a package that keeps everything in agents.yaml loads exactly as it did. A file holds the coworker on its own, or a list under `agents:` for somebody splitting an existing file up. Only .yaml and .yml are read, so a README beside them is left alone. Row parsing moves into parseAgents, which takes the file it is reading so a refusal can name it: "agent.id is required" is no use when there are eleven files it could be in. Every check that applied to agents.yaml applies here, including the skill-slug check that fails a typo at boot rather than attaching nothing in silence. Two declarations of the same id stop the server and both files are named. Preferring one would make the roster depend on the order a directory listing came back in, and a clone that copied the same file in twice would never be told. The directory is in the package checksum for the reason skills.yaml is: a coworker added, edited or removed there is a package change, and a deployment that did not notice would go on running the roster it booted with. The new cases live in their own test file because tenant-package.test.ts opens a database at import and none of this touches one. Refs #397 --- server/src/tenant-package.ts | 303 +++++++++++++----- .../tests/tenant-package-agent-files.test.ts | 207 ++++++++++++ 2 files changed, 421 insertions(+), 89 deletions(-) create mode 100644 server/tests/tenant-package-agent-files.test.ts diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts index e6cf9765f..62170a115 100644 --- a/server/src/tenant-package.ts +++ b/server/src/tenant-package.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { parse } from "yaml"; @@ -116,9 +116,24 @@ type PackageFiles = { * package had until now. */ skills?: string; + /** + * A coworker per file, from `agents/` beside `agents.yaml`, in the order they should be read. + * + * `agents.yaml` holds every coworker in one file, so adding one means editing a file somebody + * else is also editing, and sending one means sending a fragment of it. A directory makes a + * coworker a thing you can copy in, delete, or hand to somebody. Both are read, and a package + * with only `agents.yaml` is unchanged. + */ + agentFiles?: PackageAgentFile[]; themeCss: string; }; +/** One file from `agents/`, kept with its name so a refusal can say which file it came from. */ +export type PackageAgentFile = { + filename: string; + contents: string; +}; + /** * A skill the package ships, and the tools it says it needs. * @@ -330,6 +345,152 @@ export function expandEnvironment( ); } +/** + * The coworkers one YAML document declares, in the order it declares them. + * + * `source` names the file in any refusal, because a package can now declare coworkers in more than + * one place and "agent.id is required" is no use when there are eleven files it could be in. + * + * A remote coworker whose endpoint interpolates to nothing is dropped rather than refused, and its + * id is collected so a channel naming it is dropped too. That is what lets a package carry a row + * for a Bot somebody has not picked yet. + */ +function parseAgents( + values: unknown[], + source: string, + omittedAgentIds: Set, +): TenantAgent[] { + return values.flatMap((value) => { + const agent = asRecord(value, "agent"); + const type: TenantAgent["type"] | undefined = + agent.type === "built-in" + ? "built_in" + : agent.type === "remote-ag-ui" + ? "remote_ag_ui" + : // A Mastra server, dialled through `@ag-ui/mastra` rather than an AG-UI route of its + // own. Seedable like the others: it is an address, and the same one this deployment + // would have been given by hand. + agent.type === "remote-mastra" + ? "remote_mastra" + : undefined; + if (!type) { + throw new Error( + `${source}: agent.type must be built-in, remote-ag-ui or remote-mastra`, + ); + } + const id = requiredString(agent.id, "agent.id"); + /* + * A Bot may not be named after a deployment route. + * + * The computer router's bot-access guard steps aside for those names, and a request cannot + * tell a Bot called `policy` from `/policy` itself, so such a Bot would be served to anybody + * who can sign in without the guard ever being asked. A package id is the only way a Bot gets + * a chosen id, everything created through the API being `agent_`, so refusing it here + * closes it rather than moving it. + */ + if (DEPLOYMENT_ROUTES.has(id)) { + throw new Error( + `${source}: agent.id "${id}" is reserved for a deployment route and cannot name a Bot`, + ); + } + if (type === "remote_ag_ui" || type === "remote_mastra") { + const endpoint = + typeof agent.endpoint === "string" ? agent.endpoint.trim() : ""; + if (!endpoint) { + omittedAgentIds.add(id); + return []; + } + } + return [ + { + id, + name: requiredString(agent.name, "agent.name"), + title: requiredString(agent.title, "agent.title"), + roleDescription: requiredString( + agent.role_description, + "agent.role_description", + ), + avatarSeed: + agent.avatar_seed === undefined + ? undefined + : requiredString(agent.avatar_seed, "agent.avatar_seed"), + type, + configuration: + type === "built_in" + ? { + systemPrompt: requiredString( + agent.system_prompt, + "agent.system_prompt", + ), + } + : { + endpoint: requiredString(agent.endpoint, "agent.endpoint"), + /* + * Which agent on that server, when the server is a roster. + * + * Optional, and only meaningful for Mastra: a package naming one gets that one, + * and a package naming none gets the only agent there or a refusal. Carried here + * so a seeded Mastra Bot is as specific as one added by hand. See + * `pickFromRoster`. + */ + ...(type === "remote_mastra" && + typeof agent.remote_agent_id === "string" && + agent.remote_agent_id.trim().length > 0 + ? { remoteAgentId: agent.remote_agent_id.trim() } + : {}), + }, + skills: + agent.skills === undefined || agent.skills === null + ? [] + : stringArray(agent.skills, "agent.skills"), + }, + ]; + }); +} + +/** + * Every coworker the package declares: `agents.yaml` first, then one file at a time from `agents/`. + * + * A file under `agents/` may hold a list under `agents:`, the way `agents.yaml` does, or the one + * coworker on its own. The second is the point of the directory — a coworker somebody sends you is + * a file you drop in, not a fragment to paste into the middle of a file you already have. + * + * Two declarations of the same id are refused, and the refusal names both files. Preferring one + * would make which coworker a deployment runs depend on the order a directory happened to be read + * in, and a clone that copied a file in twice under different names would never find out. + */ +function collectAgents( + agentsYaml: Record, + agentFiles: PackageAgentFile[], + omittedAgentIds: Set, +): TenantAgent[] { + const agents = parseAgents( + asList(agentsYaml.agents, "agents.yaml agents"), + "agents.yaml", + omittedAgentIds, + ); + const declaredIn = new Map(agents.map((agent) => [agent.id, "agents.yaml"])); + for (const file of agentFiles) { + const source = `agents/${file.filename}`; + const document = yaml(file.contents, source); + const values = + document.agents === undefined + ? [document] + : asList(document.agents, `${source} agents`); + for (const agent of parseAgents(values, source, omittedAgentIds)) { + const existing = declaredIn.get(agent.id); + if (existing) { + throw new Error( + `agent "${agent.id}" is declared in both ${existing} and ${source}`, + ); + } + declaredIn.set(agent.id, source); + agents.push(agent); + } + } + return agents; +} + export function validateTenantPackage(files: PackageFiles): TenantPackage { if (files.themeCss.trim()) { validateThemeCss(files.themeCss); @@ -348,93 +509,10 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { const skin = brand.skin === undefined ? undefined : asRecord(brand.skin, "brand.skin"); const omittedAgentIds = new Set(); - const agents = asList(agentsYaml.agents, "agents.yaml agents").flatMap( - (value) => { - const agent = asRecord(value, "agent"); - const type: TenantAgent["type"] | undefined = - agent.type === "built-in" - ? "built_in" - : agent.type === "remote-ag-ui" - ? "remote_ag_ui" - : // A Mastra server, dialled through `@ag-ui/mastra` rather than an AG-UI route of its - // own. Seedable like the others: it is an address, and the same one this deployment - // would have been given by hand. - agent.type === "remote-mastra" - ? "remote_mastra" - : undefined; - if (!type) { - throw new Error( - "agent.type must be built-in, remote-ag-ui or remote-mastra", - ); - } - const id = requiredString(agent.id, "agent.id"); - /* - * A Bot may not be named after a deployment route. - * - * The computer router's bot-access guard steps aside for those names, and a request cannot - * tell a Bot called `policy` from `/policy` itself, so such a Bot would be served to anybody - * who can sign in without the guard ever being asked. A package id is the only way a Bot gets - * a chosen id, everything created through the API being `agent_`, so refusing it here - * closes it rather than moving it. - */ - if (DEPLOYMENT_ROUTES.has(id)) { - throw new Error( - `agent.id "${id}" is reserved for a deployment route and cannot name a Bot`, - ); - } - if (type === "remote_ag_ui" || type === "remote_mastra") { - const endpoint = - typeof agent.endpoint === "string" ? agent.endpoint.trim() : ""; - if (!endpoint) { - omittedAgentIds.add(id); - return []; - } - } - return [ - { - id, - name: requiredString(agent.name, "agent.name"), - title: requiredString(agent.title, "agent.title"), - roleDescription: requiredString( - agent.role_description, - "agent.role_description", - ), - avatarSeed: - agent.avatar_seed === undefined - ? undefined - : requiredString(agent.avatar_seed, "agent.avatar_seed"), - type, - configuration: - type === "built_in" - ? { - systemPrompt: requiredString( - agent.system_prompt, - "agent.system_prompt", - ), - } - : { - endpoint: requiredString(agent.endpoint, "agent.endpoint"), - /* - * Which agent on that server, when the server is a roster. - * - * Optional, and only meaningful for Mastra: a package naming one gets that one, - * and a package naming none gets the only agent there or a refusal. Carried here - * so a seeded Mastra Bot is as specific as one added by hand. See - * `pickFromRoster`. - */ - ...(type === "remote_mastra" && - typeof agent.remote_agent_id === "string" && - agent.remote_agent_id.trim().length > 0 - ? { remoteAgentId: agent.remote_agent_id.trim() } - : {}), - }, - skills: - agent.skills === undefined || agent.skills === null - ? [] - : stringArray(agent.skills, "agent.skills"), - }, - ]; - }, + const agents = collectAgents( + agentsYaml, + files.agentFiles ?? [], + omittedAgentIds, ); const agentIds = new Set(agents.map((agent) => agent.id)); const packageSkills = parseTenantSkills(skillsYaml.skills); @@ -566,6 +644,42 @@ function parseTenantSkills(value: unknown): TenantSkill[] { }); } +/** + * The coworker files beside `agents.yaml`, read in a fixed order. + * + * No directory is a package that keeps every coworker in one file, which is every package written + * before this and stays supported. `.yaml` and `.yml` only, so a README or an editor's leftovers + * sitting in there is not something the deployment tries to parse. + * + * Sorted by filename rather than taken in the order the filesystem answers, because the order + * decides which file a duplicate id is blamed on, and a refusal that names a different file on + * another machine is not one anybody can act on. + * + * `${NAME}` is expanded here exactly as it is in `agents.yaml`: these are the clone's own files, + * written by whoever wrote the rest of the package. + */ +async function readAgentFiles(sourcePath: string): Promise { + 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(); + return await Promise.all( + filenames.map(async (filename) => ({ + filename, + contents: expandEnvironment( + await readFile(join(directory, filename), "utf8"), + `agents/${filename}`, + ), + })), + ); +} + export async function loadTenantPackage( sourcePath: string, ): Promise { @@ -604,6 +718,7 @@ export async function loadTenantPackage( if (error.code === "ENOENT") return ""; throw error; }); + const agentFiles = await readAgentFiles(sourcePath); const tenantPackage = validateTenantPackage({ brand, agents, @@ -611,6 +726,7 @@ export async function loadTenantPackage( model, knowledge, skills, + agentFiles, themeCss, }); @@ -619,8 +735,17 @@ export async function loadTenantPackage( sourcePath, // `skills` is in the checksum, so editing it is a package change like any other and the // deployment notices on the next boot rather than reporting itself unchanged. + // `agents/` is in the checksum for the reason `skills` is: a coworker added, edited or removed + // there is a package change, and a deployment that did not notice would go on running the + // roster it booted with while the repository said otherwise. checksum: createHash("sha256") - .update([...contents, skills].join("\n")) + .update( + [ + ...contents, + skills, + ...agentFiles.map((file) => `${file.filename}\n${file.contents}`), + ].join("\n"), + ) .digest("hex"), }; } diff --git a/server/tests/tenant-package-agent-files.test.ts b/server/tests/tenant-package-agent-files.test.ts new file mode 100644 index 000000000..d77349c29 --- /dev/null +++ b/server/tests/tenant-package-agent-files.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadTenantPackage, + validateTenantPackage, +} from "../src/tenant-package"; + +/** + * A coworker as a file of its own. + * + * `agents.yaml` holds every coworker a package ships, so adding one means editing a file somebody + * else is editing too, and handing somebody a coworker means handing them a fragment to paste into + * the middle of theirs. A directory beside it makes a coworker a file: copy it in, delete it, send + * it. These cases are the ones that decide whether both can be read at once without the package + * becoming ambiguous about which coworkers it declares. + * + * Kept out of `tenant-package.test.ts` because everything here is about parsing files, and that + * suite opens a database at import. + */ + +const base = { + brand: "tenant: { id: fintech, product_name: Ledgerline }", + agents: + "agents: [{ id: knowledge, name: Knowledge, title: Company Knowledge, role_description: Answer company questions., type: built-in, system_prompt: Answer from knowledge. }]", + channels: "channels: []", + model: + "model: { provider: openai, credential_secret_ref: openai-key, default_model: gpt-5.6-terra }", + knowledge: "sources: []", + themeCss: "", +}; + +const expenseReview = `id: expense-review +name: Expense Review +title: Finance Operations +role_description: Check one expense claim 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. +`; + +describe("a coworker declared in a file of its own", () => { + test("is loaded alongside the ones in agents.yaml", () => { + const { agents } = validateTenantPackage({ + ...base, + agentFiles: [ + { filename: "expense-review.yaml", contents: expenseReview }, + ], + }); + + expect(agents.map((agent) => agent.id)).toEqual([ + "knowledge", + "expense-review", + ]); + expect(agents[1]).toMatchObject({ + id: "expense-review", + name: "Expense Review", + type: "built_in", + configuration: { + systemPrompt: + "Quote the clause you relied on, and leave the decision to a person.", + }, + }); + }); + + test("may also be written as a list, the way agents.yaml is", () => { + // Somebody splitting an existing `agents.yaml` up copies the list syntax across with it, and a + // file that parses one way and not the other would make that a puzzle rather than a move. + const { agents } = validateTenantPackage({ + ...base, + agentFiles: [ + { + filename: "pair.yaml", + contents: `agents:\n${expenseReview + .trimEnd() + .split("\n") + .map((line, index) => (index === 0 ? ` - ${line}` : ` ${line}`)) + .join("\n")}\n`, + }, + ], + }); + + expect(agents.map((agent) => agent.id)).toEqual([ + "knowledge", + "expense-review", + ]); + }); + + test("is refused when another file already declares that id, and both are named", () => { + // Preferring one would make the roster depend on the order a directory was read in, and a + // clone that copied the same coworker in twice would never be told. + expect(() => + validateTenantPackage({ + ...base, + agentFiles: [ + { + filename: "knowledge.yaml", + contents: expenseReview.replace("expense-review", "knowledge"), + }, + ], + }), + ).toThrow( + 'agent "knowledge" is declared in both agents.yaml and agents/knowledge.yaml', + ); + }); + + test("is refused for the same reasons a row in agents.yaml is, and says which file", () => { + expect(() => + validateTenantPackage({ + ...base, + agentFiles: [ + { + filename: "broken.yaml", + contents: expenseReview.replace("type: built-in", "type: smoke"), + }, + ], + }), + ).toThrow("agents/broken.yaml: agent.type must be built-in"); + }); + + test("names a skill this package does not ship and is refused", () => { + // The check that already protects `agents.yaml` reaches a coworker arriving this way too, so a + // typo in a file somebody copied in fails at boot rather than attaching nothing in silence. + expect(() => + validateTenantPackage({ + ...base, + agentFiles: [ + { + filename: "expense-review.yaml", + contents: `${expenseReview}skills:\n - quote-the-expense-policy\n`, + }, + ], + }), + ).toThrow( + 'agent "expense-review" names skill "quote-the-expense-policy", which this package does not ship', + ); + }); +}); + +describe("reading the agents directory from disk", () => { + async function packageWith(files: Record) { + const directory = await mkdtemp(join(tmpdir(), "openbot-package-")); + await cp( + fileURLToPath(new URL("../../examples/fintech", import.meta.url)), + directory, + { recursive: true }, + ); + await mkdir(join(directory, "agents"), { recursive: true }); + for (const [filename, contents] of Object.entries(files)) { + await writeFile(join(directory, "agents", filename), contents, "utf8"); + } + return directory; + } + + test("a package with no agents directory loads exactly as it did", async () => { + const directory = await mkdtemp(join(tmpdir(), "openbot-package-")); + await cp( + fileURLToPath(new URL("../../examples/fintech", import.meta.url)), + directory, + { recursive: true }, + ); + await rm(join(directory, "agents"), { recursive: true, force: true }); + + const tenantPackage = await loadTenantPackage(directory); + + expect( + tenantPackage.agents.some((agent) => agent.id === "general-assistant"), + ).toBe(true); + await rm(directory, { recursive: true, force: true }); + }); + + test("files are read in filename order, and anything that is not YAML is left alone", async () => { + const directory = await packageWith({ + "b-second.yaml": expenseReview.replaceAll("expense-review", "second"), + "a-first.yml": expenseReview.replaceAll("expense-review", "first"), + "README.md": "Not a coworker, and not something to parse.", + }); + + const { agents } = await loadTenantPackage(directory); + const added = agents + .map((agent) => agent.id) + .filter((id) => id === "first" || id === "second"); + + expect(added).toEqual(["first", "second"]); + await rm(directory, { recursive: true, force: true }); + }); + + test("editing one of those files changes the package checksum", async () => { + // The checksum is how a running deployment notices the repository said something new. A + // coworker added or edited here is a package change like any other. + const directory = await packageWith({ + "expense-review.yaml": expenseReview, + }); + const before = (await loadTenantPackage(directory)).checksum; + + await writeFile( + join(directory, "agents", "expense-review.yaml"), + expenseReview.replace("Finance Operations", "Finance"), + "utf8", + ); + const after = (await loadTenantPackage(directory)).checksum; + + expect(after).not.toBe(before); + await rm(directory, { recursive: true, force: true }); + }); +}); From b148fc2a98e556735217d23201261aa4a9571d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:36:55 -0400 Subject: [PATCH 2/3] Offer ten example coworkers, each doing one job Three coworkers is enough to prove the format and not enough to give anybody ideas, and writing a role_description cold is the part that decides whether a coworker answers usefully or vaguely. The first hurdle after docker compose up is not configuration, it is what now. Ten more ship in examples/fintech/agents/, one file each: reading an expense claim against the policy as written, turning a meeting note into the follow-ups actually in it, drafting release notes from what shipped, triaging a support ticket, answering a new starter from the handbook, writing a brief that names what it could not find, writing up an interview with question, answer and observation kept apart, handing an on-call shift over from the record, assembling what is known before a renewal decision, and grouping customer feedback into themes it can cite. They are deliberately unlike each other, because the point is that somebody recognises their own job in one. Each says what the job is, what the coworker must not do, and what to say when it cannot find something, because that is the part worth copying. They grant nothing. A coworker names skills, a skill names tools, and what it may call is what an administrator has granted, so a file dropped in here adds an instruction and no capability. Each names only skills this package already ships, and a clone deletes the ones it does not want. Ten rather than the twenty-eight written for #299: shipping all of them would put a directory nobody reads into every clone. The rest stay in awesome-openbot-agents, outside this repository and inherited by nobody. Refs #397 --- CHANGELOG.md | 25 +++++++++++++ README.md | 2 +- docs/architecture.md | 3 ++ docs/configuration.md | 36 +++++++++++++++++-- examples/fintech/agents/README.md | 19 ++++++++++ examples/fintech/agents/expense-review.yaml | 28 +++++++++++++++ examples/fintech/agents/feedback-digest.yaml | 24 +++++++++++++ examples/fintech/agents/interview-notes.yaml | 21 +++++++++++ .../fintech/agents/meeting-follow-ups.yaml | 24 +++++++++++++ examples/fintech/agents/onboarding-buddy.yaml | 24 +++++++++++++ examples/fintech/agents/oncall-handover.yaml | 23 ++++++++++++ examples/fintech/agents/release-notes.yaml | 22 ++++++++++++ examples/fintech/agents/research-desk.yaml | 23 ++++++++++++ examples/fintech/agents/ticket-triage.yaml | 22 ++++++++++++ examples/fintech/agents/vendor-review.yaml | 23 ++++++++++++ 15 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 examples/fintech/agents/README.md create mode 100644 examples/fintech/agents/expense-review.yaml create mode 100644 examples/fintech/agents/feedback-digest.yaml create mode 100644 examples/fintech/agents/interview-notes.yaml create mode 100644 examples/fintech/agents/meeting-follow-ups.yaml create mode 100644 examples/fintech/agents/onboarding-buddy.yaml create mode 100644 examples/fintech/agents/oncall-handover.yaml create mode 100644 examples/fintech/agents/release-notes.yaml create mode 100644 examples/fintech/agents/research-desk.yaml create mode 100644 examples/fintech/agents/ticket-triage.yaml create mode 100644 examples/fintech/agents/vendor-review.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b072624..0df268bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A coworker can be a file of its own + +The example package declared every coworker in one `agents.yaml`, so adding one meant editing a file +somebody else was editing too, and handing somebody a coworker meant handing them a fragment to +paste into the middle of theirs. A package may now also keep a coworker per file in an `agents/` +directory beside `agents.yaml`, and both are read. A package that keeps everything in `agents.yaml` +loads exactly as before. A file holds the coworker on its own or a list under `agents:`, only +`.yaml` and `.yml` are read, and files are read in filename order. Two declarations of the same id +stop the server and both files are named, rather than one quietly winning on the order a directory +was listed in. The directory is in the package checksum, so a coworker added or edited there is a +package change a running deployment notices. + +### Ten more example coworkers, each doing one job + +The example package shipped three coworkers, which is enough to prove the format and not enough to +give anybody ideas, and writing a `role_description` cold is the part that decides whether a +coworker answers usefully or vaguely. Ten more ship in `examples/fintech/agents/`, one file each: +reading an expense claim against the policy as written, turning a meeting note into the follow-ups +actually in it, drafting release notes from what shipped, triaging a support ticket, answering a new +starter from the handbook, writing a brief that names what it could not find, writing up an +interview with question, answer and observation kept apart, handing an on-call shift over from the +record, assembling what is known before a renewal decision, and grouping customer feedback into +themes it can cite. Each says what the job is, what the coworker must not do, and what to say when +it cannot find something. They grant nothing: a coworker names skills, a skill names tools, and what +it may call is what an administrator has granted. Delete the ones you do not want. ## 0.0.13 ### Fresh desktop setup installs its runtime before sign-in diff --git a/README.md b/README.md index a05e4f76e..72ac81476 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ your own machine. An agent platform that runs inside your own infrastructure. Docker Compose brings up every part of it, the data sits in your PostgreSQL, and the model is yours to choose: no model ships in the box, and an administrator supplies the credential, which is encrypted at rest and never logged. -Three coworkers ship in the example package, and they are configuration rather than code: **General Assistant** for everyday work, **Knowledge** for company questions, **Risk Analyst** for risk and compliance. Add your own by editing `agents.yaml` or from `/agents` in the UI. +Fourteen coworkers ship in the example package, and they are configuration rather than code: **General Assistant** for everyday work and **Knowledge** for company questions, a **Risk Analyst** reached as an endpoint, and ten in `examples/fintech/agents/` that each do one job — reading an expense claim against the policy as written, turning a meeting note into the follow-ups actually in it, drafting release notes from what shipped, triaging a ticket, answering a new starter from the handbook, writing a brief that names what it could not find, writing up an interview, handing an on-call shift over, assembling what is known before a renewal, and grouping customer feedback into themes it can cite. Add your own by dropping a file in that directory, by editing `agents.yaml`, or from `/agents` in the UI. Anything a Bot does to a computer, a file, an MCP server or a component goes through one gateway that decides and records it. That is the difference between an agent that can use your tools and an agent you can let near them. diff --git a/docs/architecture.md b/docs/architecture.md index 1879e3805..5e46c5f1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -325,6 +325,9 @@ Required package files: - `model.yaml` - `knowledge.yaml` +Optional: `skills.yaml`, `theme.css`, and an `agents/` directory holding a coworker per file, read +alongside `agents.yaml`. See [configuration.md](configuration.md#agents). + The server validates the package at startup. Channel agent IDs must match declared agents. Knowledge sources currently support Google Drive and Microsoft OneDrive declarations. Connector credentials are stored through the credential vault and referenced by id, not stored inline in YAML. diff --git a/docs/configuration.md b/docs/configuration.md index 9c50d4503..0976fbc00 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -404,7 +404,7 @@ Set `OPENBOT_ONE_COMPUTER_EACH=false` when using `start.sh` to run all Bots agai ## Tenant package -The tenant package contains five required YAML files, and one optional: +The tenant package contains five required YAML files, and two optional: ```text examples/fintech/ @@ -413,7 +413,9 @@ examples/fintech/ ├── channels.yaml ├── model.yaml ├── knowledge.yaml -└── skills.yaml (optional) +├── skills.yaml (optional) +└── agents/ (optional) + └── expense-review.yaml ``` ### `brand.yaml` @@ -480,6 +482,36 @@ name is unset or empty, which is how the example package points at the Bot in th requiring any configuration. A name with neither a value nor a fallback stops the server with a message saying which file wanted it, rather than leaving a Bot pointed at an address nobody meant. +### `agents/` + +A coworker may also be one file of its own, in an `agents/` directory beside `agents.yaml`. Both are +read, and a package that keeps every coworker in `agents.yaml` is unchanged. + +```yaml +# examples/fintech/agents/expense-review.yaml +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 +``` + +The file holds the coworker on its own, as above, or a list under `agents:` the way `agents.yaml` +does. Only `.yaml` and `.yml` are read, so a README beside them is left alone. Files are read in +filename order, and every check that applies to a row in `agents.yaml` applies here too: a refusal +names the file it came from. + +Two files declaring the same `id`, or a file repeating an id `agents.yaml` already uses, stop the +server and both files are named. Nothing wins by being read later — which coworker a deployment runs +should not depend on what a directory listing happened to return. + +The directory is in the package checksum, so adding, editing or deleting a coworker there is a +package change like any other and a running deployment notices it on the next boot. + ### `channels.yaml` ```yaml diff --git a/examples/fintech/agents/README.md b/examples/fintech/agents/README.md new file mode 100644 index 000000000..3725683cb --- /dev/null +++ b/examples/fintech/agents/README.md @@ -0,0 +1,19 @@ +# One coworker per file + +A file in here declares one coworker, and the loader reads it alongside `../agents.yaml`. Both work, +and a package that keeps everything in `agents.yaml` is unchanged. + +The point of the directory is that a coworker becomes a thing you can handle: copy one in, delete +one you do not want, send one to somebody. Nothing here is a grant. A coworker names skills, a skill +names tools, and what it may actually call is what an administrator has granted it — so a file +dropped in here adds an instruction and no capability. + +These ten are meant to give you somewhere to start rather than to cover a matrix, and they are +deliberately unlike each other: finance, meetings, releases, support, onboarding, research, hiring, +reliability, procurement and product feedback. The part worth +copying is the shape of `role_description`: it says what the job is, what the coworker must not do, +and what to say when it cannot find something. A one-line description gets you a coworker that +answers vaguely. + +More of them, outside this repository and not inherited by your clone: +[awesome-openbot-agents](https://github.com/jerelvelarde/awesome-openbot-agents). diff --git a/examples/fintech/agents/expense-review.yaml b/examples/fintech/agents/expense-review.yaml new file mode 100644 index 000000000..67d04e53a --- /dev/null +++ b/examples/fintech/agents/expense-review.yaml @@ -0,0 +1,28 @@ +# Reads one claim against the policy as written, and hands the decision to a person. +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, and hand a person the + decision rather than making it. Find the policy and quote the clause you are relying on: a limit + you remember is not a limit this company set. Where the policy does not cover what the claim + describes, say that it does not say, and do not reason out what it probably intends. Never + approve, never reject, and never tell anybody they will be reimbursed. List what is missing from + the claim — a receipt, a date, the attendees — as things somebody can go and get, not as a + judgement about the person who filed it. If a figure on a receipt is not legible, say it is not + legible rather than reading the likeliest number. If a tool reports an error or says it is not + connected, say so and stop. +avatar_seed: expense-review +type: built-in +system_prompt: >- + Report each line of a claim as one of four things and nothing else. WITHIN POLICY: quote the + clause. OVER A LIMIT: give the amount, the limit, and the clause that sets it. NOT COVERED: say + the policy is silent on this category, plainly, because "the policy does not say" is the finding + somebody needs and a plausible inference is not. INCOMPLETE: name the one thing missing. End with + what a person has to decide, written as a question. +# Naming a skill grants nothing. Until somebody connects a document source and grants these tools, +# this Bot says it cannot see the policy and declines to review a claim, which is the right answer +# rather than a broken one: an expense review done from memory is worse than no review. +skills: + - find-a-document + - check-a-claim diff --git a/examples/fintech/agents/feedback-digest.yaml b/examples/fintech/agents/feedback-digest.yaml new file mode 100644 index 000000000..73ccf0afe --- /dev/null +++ b/examples/fintech/agents/feedback-digest.yaml @@ -0,0 +1,24 @@ +# Groups what customers said into themes, and keeps every theme attached to its quotes. +id: feedback-digest +name: Feedback Digest +title: Product Research +role_description: >- + Group what customers said into themes, and keep every theme attached to the sentences that + produced it. Quote at least two, name who said them and when: a theme you cannot cite is a theme + you invented. Count accounts rather than messages and say the number out loud, because five + messages from one frustrated customer is one customer. Never promote a suggestion into a + commitment — "three accounts asked for single sign-on" is the finding, and "we should build single + sign-on" is not yours to write. Keep a customer's words as their words rather than tidying them + into product language, and where the feedback contradicts itself, give both sides instead of the + larger one. Say what period you read and what you could not read. If a tool reports an error or + says it is not connected, say so rather than digesting the part you could reach as though it were + all of it. +avatar_seed: feedback-digest +type: built-in +system_prompt: >- + One theme per section, with the number of accounts, at least two quotes in the customer's own + words, and who said them and when. State the period you read at the top and what you could not + read at the bottom. +skills: + - find-a-document + - check-a-claim diff --git a/examples/fintech/agents/interview-notes.yaml b/examples/fintech/agents/interview-notes.yaml new file mode 100644 index 000000000..7d185a062 --- /dev/null +++ b/examples/fintech/agents/interview-notes.yaml @@ -0,0 +1,21 @@ +# Writes up one interview, keeping question, answer and observation apart. +id: interview-notes +name: Interview Notes +title: Recruiting +role_description: >- + Write up one interview from what the interviewer tells you and from nothing else. Keep three + things apart and label them: the question that was asked, what the candidate answered, and what + the interviewer observed. A conclusion is a fourth thing, and it belongs in the notes only where + the interviewer stated one, recorded as theirs and in their words. Never promote an impression + into evidence — "seemed unprepared" is an observation and "is unprepared" is a claim these notes + cannot carry. Where something was not covered, write that it was not covered, rather than leaving + a silence a later reader will fill in. Leave out anything said about age, health, family, religion + or nationality, say that you left something out, and do not repeat it. Do not decide whether to + hire, do not compare this candidate with another, and do not soften or sharpen what was said. You + are writing one record that somebody may have to stand behind months from now. +avatar_seed: interview-notes +type: built-in +system_prompt: >- + One section per question, each holding what was asked, what was answered, and what the interviewer + observed, under those three labels. Anything the interviewer concluded goes at the end, attributed + to them. What was not covered is written down as not covered. diff --git a/examples/fintech/agents/meeting-follow-ups.yaml b/examples/fintech/agents/meeting-follow-ups.yaml new file mode 100644 index 000000000..6d8ab0b7d --- /dev/null +++ b/examples/fintech/agents/meeting-follow-ups.yaml @@ -0,0 +1,24 @@ +# Turns one set of notes into the follow-ups actually in them, and leaves the unowned ones unowned. +id: meeting-follow-ups +name: Meeting Follow-ups +title: Meetings +role_description: >- + Read one set of meeting notes and produce the follow-ups that are in them. A follow-up is + something a named person said they would do. "We should", "somebody ought to" and "it would be + good if" are not follow-ups, and a decision nobody was recorded as making is not a decision — + report it as undecided rather than choosing a decider. Quote the line each follow-up came from, so + whoever reads it can see you did not invent it. Where the notes give no date, write that the notes + do not say; where two people are named for one thing, list both rather than picking the likelier. + Never create a task in anybody's tracker, never message an owner, and never carry a follow-up + forward from an earlier meeting unless these notes mention it. If the notes are too thin to yield + anything, say so and say which part is missing — that sentence is what gets the next set of notes + written better. +avatar_seed: meeting-follow-ups +type: built-in +system_prompt: >- + Work from the notes in front of you and from nothing else. Give each follow-up as the thing to be + done, the person recorded as doing it, the date if the notes give one, and the line you took it + from. List what was discussed and left undecided separately, under that heading, without + resolving it. +skills: + - who-owns-this diff --git a/examples/fintech/agents/onboarding-buddy.yaml b/examples/fintech/agents/onboarding-buddy.yaml new file mode 100644 index 000000000..be4a5efcb --- /dev/null +++ b/examples/fintech/agents/onboarding-buddy.yaml @@ -0,0 +1,24 @@ +# Answers a new starter from the written handbook, and hands over what it does not cover. +id: onboarding-buddy +name: Onboarding Buddy +title: People Operations +role_description: >- + Help one new starter through their first weeks. Answer from the written handbook and the policies + it points at: quote the passage you used and name the document and its date. Where the handbook + does not cover something, say so and hand the question to a person — do not reason out what the + policy probably is, because a new starter cannot tell your reasoning from your sources. Pay, + leave, notice, immigration, expenses and anything with a legal edge are always the written answer + or a named person, never yours. Never tell a starter something their manager has not decided, and + never say an approval will be granted. Treat what the starter tells you as theirs: do not carry a + worry they mentioned into anything a manager reads unless they asked you to. If a tool reports an + error or says it is not connected, say so rather than answering from memory. +avatar_seed: onboarding-buddy +type: built-in +system_prompt: >- + Quote the passage, name the document, and give the date it was last changed — a rule nobody has + revisited in four years is still the rule, and the person asking should be told both halves of + that. Anything the handbook does not cover goes to a named person, with what you searched for + written down so they are not starting over. +skills: + - find-a-document + - check-a-claim diff --git a/examples/fintech/agents/oncall-handover.yaml b/examples/fintech/agents/oncall-handover.yaml new file mode 100644 index 000000000..0f4a66218 --- /dev/null +++ b/examples/fintech/agents/oncall-handover.yaml @@ -0,0 +1,23 @@ +# Writes the handover one shift leaves the next, from the record and nothing else. +id: oncall-handover +name: On-call Handover +title: Reliability +role_description: >- + Write the handover one shift leaves the next, from the record and from nothing else. An alert that + fired and cleared is reported with both times and no theory about why; you never state a cause. + Where the log is empty, say the log is empty rather than saying nothing happened — those are + different facts and only one of them is in front of you. Put anything still open at the top, with + the last thing anybody wrote on it and who wrote it. Where a page has no owner recorded, say it + has none rather than naming whoever touched it last. You never page anybody, never acknowledge, + close or silence an alert, and never tell the next on-call what to do about something. If a tool + reports an error or says it is not connected, say so and stop: a handover assembled from a partial + read is worse than no handover, because the reader cannot tell which half is missing. +avatar_seed: oncall-handover +type: built-in +system_prompt: >- + Still open first, each with its last update and who wrote it. Then what fired and cleared, with + both times. Then what you could not read, named. No causes, no advice, and no tidying an empty log + into a quiet shift. +skills: + - whats-changed + - who-owns-this diff --git a/examples/fintech/agents/release-notes.yaml b/examples/fintech/agents/release-notes.yaml new file mode 100644 index 000000000..8e7471564 --- /dev/null +++ b/examples/fintech/agents/release-notes.yaml @@ -0,0 +1,22 @@ +# Drafts notes from what shipped, and brackets what it was not given. +id: release-notes +name: Release Notes +title: Release Communications +role_description: >- + Write the notes for one release from the list of changes you were given and from nothing else. Do + not describe a change that is not in the list, however sure you are that it shipped. For each one, + say what a person can now do that they could not do before; where a change has no effect anybody + outside the team would notice, keep it out of the body and count it in a line at the end. Never + call a fix a feature, a workaround a fix or a rename an improvement. Never invent a version + number, a date or an upgrade step: where the change does not say, write that it does not say and + leave the line for a person. Group entries by what somebody was trying to do rather than by + component, because nobody reading release notes knows your components. You draft; a person + publishes. Never post, send or file the notes anywhere yourself. +avatar_seed: release-notes +type: built-in +system_prompt: >- + Write for the person deciding whether to upgrade. One entry per change somebody outside the team + would notice, in their words rather than the commit's. Anything you were not given — a version, a + date, a migration step — is written as a bracketed gap for a person to fill, never guessed. +skills: + - whats-changed diff --git a/examples/fintech/agents/research-desk.yaml b/examples/fintech/agents/research-desk.yaml new file mode 100644 index 000000000..cfbba5165 --- /dev/null +++ b/examples/fintech/agents/research-desk.yaml @@ -0,0 +1,23 @@ +# Reads around a question and writes a brief, naming what it could not find. +id: research-desk +name: Research Desk +title: Briefings +role_description: >- + Answer a question by reading around it and writing a brief somebody can act on. Search the sources + you can reach before writing anything, read what you find rather than answering from a title, and + name every document, page or address you used. Say what you could not find as plainly as what you + did: a gap you name is useful and a gap you fill from memory is not. Keep a brief to what was + asked, with the finding first, the evidence under it and the open questions last. Never present + your own recollection as something a source said, and never carry a claim forward from an earlier + message without checking it again. If a tool reports an error or says it is not connected, say so + rather than working around it quietly. +avatar_seed: research-desk +type: built-in +system_prompt: >- + Finding first, in one sentence somebody could act on. Then the evidence, each line naming the + document or page it came from. Then what you looked for and did not find, and the questions still + open. Never let a summary of a source outrank the source. +skills: + - find-a-document + - find-a-notion-page + - check-a-claim diff --git a/examples/fintech/agents/ticket-triage.yaml b/examples/fintech/agents/ticket-triage.yaml new file mode 100644 index 000000000..8f947690d --- /dev/null +++ b/examples/fintech/agents/ticket-triage.yaml @@ -0,0 +1,22 @@ +# Decides what one ticket is asking, how urgent it is, and who should have it. Sends nothing. +id: ticket-triage +name: Ticket Triage +title: Support Operations +role_description: >- + Take one incoming ticket at a time and decide three things about it: what the person is actually + asking, how urgent it is, and who should have it. Read the ticket in full before deciding + anything, and look for the runbook or the earlier ticket that already covers it rather than + reasoning it out from first principles. Give your reasoning in one or two sentences, and say which + of the three you are unsure about instead of hiding it in a confident sentence. You draft; a + person sends. Never reply to a customer, never close a ticket, and never promise a date, a refund + or a fix. If a tool reports an error or says it is not connected, say so and stop rather than + guessing at what the ticket system would have told you. +avatar_seed: ticket-triage +type: built-in +system_prompt: >- + Answer with the ask, the urgency, and the team or person it belongs to, each with the sentence + from the ticket that led you there. Name the runbook or earlier ticket you relied on. Where you + are unsure of one of the three, say which one and what would settle it. +skills: + - find-a-document + - who-owns-this diff --git a/examples/fintech/agents/vendor-review.yaml b/examples/fintech/agents/vendor-review.yaml new file mode 100644 index 000000000..bd124901e --- /dev/null +++ b/examples/fintech/agents/vendor-review.yaml @@ -0,0 +1,23 @@ +# Assembles what is known before a renewal decision, and decides nothing. +id: vendor-review +name: Vendor Review +title: Procurement +role_description: >- + Assemble what is known about a vendor before somebody decides whether to renew. Read the renewal + date and the notice period off the contract every time and quote the clause: thirty days is a + habit, not a term. Give what we spent, over what period, and name the document each figure came + from. Where the contract is silent — no notice period, no price protection, no way out — say it is + silent rather than filling the gap with what is usual. Anything you read on the vendor's own site + is dated: give the page and the day you read it. Do not recommend renewing, cancelling or + renegotiating, do not call a price high or a vendor risky, and do not estimate a saving. Somebody + whose decision this is will read you, and a summary that has already decided is one they cannot + check. +avatar_seed: vendor-review +type: built-in +system_prompt: >- + Dates and money first, each quoted from the document it came from and named. What the contract + does not say goes in its own section, as silence rather than as a guess. End with what the person + deciding still has to find out. +skills: + - find-a-document + - check-a-claim From 09a72ddadee6471bf141b5e0a281b3bdaf5a2a53 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 18 Sep 2026 21:00:52 -0700 Subject: [PATCH 3/3] Say thirteen, which is what the sentence lists --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e11cde1d..c4cdafe64 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ your own machine. An agent platform that runs inside your own infrastructure. Docker Compose brings up every part of it, the data sits in your PostgreSQL, and the model is yours to choose: no model ships in the box, and an administrator supplies the credential, which is encrypted at rest and never logged. -Fourteen coworkers ship in the example package, and they are configuration rather than code: **General Assistant** for everyday work and **Knowledge** for company questions, a **Risk Analyst** reached as an endpoint, and ten in `examples/fintech/agents/` that each do one job — reading an expense claim against the policy as written, turning a meeting note into the follow-ups actually in it, drafting release notes from what shipped, triaging a ticket, answering a new starter from the handbook, writing a brief that names what it could not find, writing up an interview, handing an on-call shift over, assembling what is known before a renewal, and grouping customer feedback into themes it can cite. Add your own by dropping a file in that directory, by editing `agents.yaml`, or from `/agents` in the UI. +Thirteen coworkers ship in the example package, and they are configuration rather than code: **General Assistant** for everyday work and **Knowledge** for company questions, a **Risk Analyst** reached as an endpoint, and ten in `examples/fintech/agents/` that each do one job — reading an expense claim against the policy as written, turning a meeting note into the follow-ups actually in it, drafting release notes from what shipped, triaging a ticket, answering a new starter from the handbook, writing a brief that names what it could not find, writing up an interview, handing an on-call shift over, assembling what is known before a renewal, and grouping customer feedback into themes it can cite. Add your own by dropping a file in that directory, by editing `agents.yaml`, or from `/agents` in the UI. Anything a Bot does to a computer, a file, an MCP server or a component goes through one gateway that decides and records it. That is the difference between an agent that can use your tools and an agent you can let near them.