Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### Knowledge searches instead of guessing

A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
four document skills it ships.

Knowledge is one of three coworkers in the box, described as answering company questions and citing
sources. The skills that would let it do that were seeded attached to nobody, so every clone started
with them paired to no Bot: the per-run narrowing that skills exist for was switched off until
somebody opened the Skills page and made the pairing by hand, in each deployment, again after each
new connector. The pairing belongs with the package, which wrote both files and knows which coworker
it meant them for.

THIS GRANTS NOTHING, which is what makes it safe to seed. A skill is an instruction; what a Bot may
call is its grants, and the offer each run is the intersection of the two. A skill naming a tool its
Bot does not hold loads nothing. Seeding an MCP grant would be the opposite, because those reach a
person's own account, so those stay an administrator's decision and are untouched here.

A redeploy takes back only what the package gave. Grants it made carry `tenant-package`, and a grant
an administrator made through the Skills page keeps their name and survives, because a deploy quietly
undoing a deliberate decision is the kind of change nobody traces back to the deploy that caused it.
A coworker naming a skill its package does not ship is refused at load rather than dropped, the same
as a channel naming an agent that is not there: a typo that silently attaches nothing looks exactly
like working.


### A Bot's computer is no longer on the same network as the database

Compose declared no networks, so every service shared one and reached the others by service name.
Expand Down
15 changes: 15 additions & 0 deletions examples/fintech/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ agents:
used. If you have no tool for a source, or a tool tells you it is not connected or reports an
error, say that plainly. Never answer from your own memory as though it came from a source, and
never claim you lack access to something a tool has just returned.
# The document skills, which is what makes the prompt above reachable rather than aspirational.
#
# THIS GRANTS NOTHING. A skill is an instruction, and what a Bot may call is its grants: the
# offer each run is the intersection of the two, so until somebody connects Drive and grants
# these tools, naming them here loads nothing and this Bot correctly says it has no source.
# The moment they do, it searches instead of guessing, with no second step to remember.
#
# Stated here rather than left to the Skills page because the package wrote both files and knows
# which Bot it meant them for. Left to a screen, every clone starts with the skills attached to
# nobody, which switches off the per-run narrowing they exist for.
skills:
- find-a-document
- check-a-claim
- whats-changed
- who-owns-this
# The Bot that ships in the box (agent-bot), addressed exactly the way a customer's own Bot would
# be: an AG-UI endpoint in the registry. Names in dollar-brace form are read from the environment,
# so the address belongs to the deployment. Replace it with your own service and nothing changes.
Expand Down
105 changes: 101 additions & 4 deletions server/src/tenant-package.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { DEPLOYMENT_ROUTES } from "./computer/deployment-routes";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { desc, eq, inArray, isNull } from "drizzle-orm";
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { parse } from "yaml";
import { DEPLOYMENT_ROUTES } from "./computer/deployment-routes";
import type { Database } from "./db/client";
import {
agentProfiles,
agents as agentTable,
channelAgents,
channels as channelTable,
deploymentPackages,
skillTools,
pluginGrants,
skills as skillTable,
skillTools,
} from "./db/schema";

const approvedThemeVariables = new Set([
Expand Down Expand Up @@ -125,6 +126,14 @@ export type TenantSkill = {
tools: string[];
};

/**
* The mark a grant this package made carries, in `plugin_grants.granted_by`.
*
* Every other value there is the id of the person who pressed the button, so this cannot collide
* with one, and it is what lets a redeploy take back only what the package gave.
*/
const PACKAGE_GRANT = "tenant-package";

type TenantAgent = {
id: string;
name: string;
Expand All @@ -133,6 +142,19 @@ type TenantAgent = {
avatarSeed?: string;
type: "built_in" | "remote_ag_ui";
configuration: Record<string, unknown>;
/**
* The package skills this coworker is given, by slug.
*
* SAFE TO SEED, unlike an MCP grant, and for a reason worth stating rather than assuming. A skill
* is an instruction and confers nothing: what a Bot may call is its grants, and the run-time offer
* is always the intersection of the two, so a skill naming a tool its Bot does not hold loads
* nothing. Seeding an MCP grant would be the opposite, since those reach a person's own account.
*
* Without this a fork boots with the skills its package ships attached to no Bot at all, and the
* per-run narrowing that skills exist for is switched off until somebody opens the Skills page and
* pairs them by hand. The pairing is the package's to state: it wrote both files.
*/
skills: string[];
};

type TenantChannel = {
Expand Down Expand Up @@ -361,11 +383,35 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage {
: {
endpoint: requiredString(agent.endpoint, "agent.endpoint"),
},
skills:
agent.skills === undefined || agent.skills === null
? []
: stringArray(agent.skills, "agent.skills"),
},
];
},
);
const agentIds = new Set(agents.map((agent) => agent.id));
const packageSkills = parseTenantSkills(skillsYaml.skills);
const skillSlugs = new Set(packageSkills.map((skill) => skill.slug));
for (const agent of agents) {
for (const slug of agent.skills) {
/*
* Checked against this package's own skills and nothing else, and refused rather than dropped.
*
* The two files ship together, so a slug matching none of them is a typo, and a typo that
* silently attaches no skill is the kind nobody finds: the Bot simply never narrows and the
* deployment looks like it is working. Deliberately not checked against skills already in the
* deployment, because those include any a person wrote, and a package must not be able to
* hand its Bots somebody else's instructions by naming their slug.
*/
if (!skillSlugs.has(slug)) {
throw new Error(
`agent "${agent.id}" names skill "${slug}", which this package does not ship`,
);
}
}
}
const channels = asList(channelsYaml.channels, "channels.yaml channels").map(
(value) => {
const channel = asRecord(value, "channel");
Expand Down Expand Up @@ -427,7 +473,7 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage {
defaultModel: requiredString(model.default_model, "model.default_model"),
},
knowledgeSources: sources,
skills: parseTenantSkills(skillsYaml.skills),
skills: packageSkills,
themeCss: files.themeCss,
};
}
Expand Down Expand Up @@ -688,6 +734,29 @@ export async function synchronizeTenantPackage(
* of shipping it. An unknown ref sits inert until its connector exists, because the run-time
* intersection only ever offers what the Bot was granted.
*/
/*
* Which coworkers asked for each skill, so the loop below can pair them as it seeds.
*
* Written wholesale under one marker: every grant this package made last time is removed first,
* so a package that stops giving a Bot a skill takes it back. Only its own, though. A grant an
* administrator made through the Skills page carries their id and survives, because retracting
* somebody's deliberate decision is not something a redeploy should do quietly.
*/
const wantedBy = new Map<string, string[]>();
for (const agent of tenantPackage.agents) {
for (const slug of agent.skills) {
wantedBy.set(slug, [...(wantedBy.get(slug) ?? []), agent.id]);
}
}
await transaction
.delete(pluginGrants)
.where(
and(
eq(pluginGrants.kind, "skill"),
eq(pluginGrants.grantedBy, PACKAGE_GRANT),
),
);

for (const skill of tenantPackage.skills) {
const [seeded] = await transaction
.insert(skillTable)
Expand Down Expand Up @@ -748,6 +817,34 @@ export async function synchronizeTenantPackage(
})),
);
}

/*
* Paired only with the skill this package actually owns.
*
* Inside this branch on purpose: the seed above skips a slug a person had already taken, and
* granting there would hand the package's Bots an instruction somebody else wrote under a name
* the package expected to be its own. Skipped, it keeps the existing warning and grants
* nothing, which is the safe half of the same decision.
*/
const agentIds = wantedBy.get(skill.slug) ?? [];
if (agentIds.length > 0) {
await transaction
.insert(pluginGrants)
.values(
agentIds.map((agentId) => ({
kind: "skill",
ref: seeded.id,
agentId,
grantedBy: PACKAGE_GRANT,
})),
)
.onConflictDoUpdate({
target: [pluginGrants.kind, pluginGrants.ref, pluginGrants.agentId],
// An administrator who granted this by hand keeps the credit; the package only ever
// adds what was missing, and the delete above already took back what it owned.
set: { updatedAt: new Date() },
});
}
}

return deploymentPackage;
Expand Down
Loading