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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ refusing to boot over.
The example package ships four: `/find-a-document`, `/whats-changed`, `/who-owns-this` and
`/check-a-claim`.

### The tools a skill needs can be picked where the skill is written

A package's skills arrive with their tools declared. A skill somebody writes here could not: the
`tools` field existed on the save endpoint and on no screen, so a skill written in the product declared
nothing, and the only way to change that was to call the API by hand. Writing or editing a skill now
lists the tools of every connected server, grouped by server, with the ones that change something
marked.

Picking a tool here is not granting it. The offer is still intersected with what the Bot was granted,
so a skill naming a tool its Bot does not hold selects the skill and loads nothing — which is why
anybody may write a skill while connecting a server stays an administrator's decision. The screen says
so, next to the choice.

A tool the skill names that no connected server offers is shown too, under its own heading, rather
than left out. A package ships skills declaring tools for connectors nobody has added yet, and a
skill outlives the server it was written against, so a screen that drew only what matched was
stating part of the declaration as though it were all of it.

## 0.0.3

### A Bot is offered the tools its message needs, not every tool it holds
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/skills/edit-skill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function EditSkill({ slug }: { slug: string }) {
title: skill.title,
summary: skill.summary ?? "",
instructions: skill.instructions,
// Defaulted, so a skill written before this field existed opens with nothing ticked rather
// than with an undefined the form would refuse to submit.
tools: skill.tools ?? [],
}}
error={saveSkill.error}
/*
Expand Down
9 changes: 9 additions & 0 deletions app/src/components/skills/skill-fields.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useForm } from "@tanstack/react-form";
import { SkillTools } from "@/components/skills/skill-tools";
import { Button } from "@/components/ui/button";
import {
Field,
Expand Down Expand Up @@ -188,6 +189,14 @@ export function SkillFields({
);
}}
</form.Field>
<form.Field name="tools">
{(field) => (
<SkillTools
onChange={field.handleChange}
selected={field.state.value}
/>
)}
</form.Field>
</FieldGroup>

{/*
Expand Down
173 changes: 173 additions & 0 deletions app/src/components/skills/skill-tools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
import { undeclaredElsewhere } from "@/lib/skills/form";

/**
* Which tools this skill says it needs.
*
* WHY IT MATTERS THAT THIS EXISTS AT ALL. Selection is over skills: before a run the deployment asks
* its own model which skills the message needs, and the Bot is built with those skills' tools plus
* every granted tool no skill claims. A deployment where no skill declares anything has nothing to
* select on, so every Bot is offered its whole catalogue — which is the case the narrowing was built
* to fix. Until this screen existed the only way to declare a tool was to call the API by hand.
*
* A DECLARATION IS NOT A GRANT, and this is the one thing the screen has to get across. Anybody
* signed in may write a skill; adding an MCP server is an administrator's decision. If naming a tool
* here made it callable, the first surface would be a way around the second. It does not: the offer
* is intersected with what the Bot was granted, so a skill naming a tool its Bot does not hold
* selects the skill and loads nothing. Said in the hint rather than left for somebody to discover,
* because the failure it prevents is somebody ticking boxes expecting access.
*
* PART OF THE FORM, unlike the Agents toggles below it. What a skill needs is the author's draft
* until they press save, so unticking one and closing the panel changes nothing. Granting is the
* opposite and saves on press, which is why the two look different on purpose.
*/
export function SkillTools({
selected,
onChange,
}: {
selected: string[];
onChange: (refs: string[]) => void;
}) {
const plugins = useQuery(pluginsPageQueryOptions());
const held = new Set(selected);

const toggle = (ref: string) => {
/*
* Rebuilt rather than mutated, and filtered rather than spliced, so the array handed to the form
* is a new one. React Form compares by reference; mutating in place leaves the field's value
* looking unchanged and the submit button disabled on a form the person has edited.
*/
onChange(
held.has(ref)
? selected.filter((each) => each !== ref)
: [...selected, ref],
);
};

/*
* Only servers that actually offer something. A server whose tools have never been refreshed has
* an empty list, and a heading with nothing under it reads as a tool that failed to draw.
*/
const servers = (plugins.data?.servers ?? []).filter(
(server) => server.tools.length > 0,
);

/*
* Declared, and offered by nothing this deployment has connected.
*
* Shown rather than dropped, because the alternative is a screen that states part of the
* declaration as though it were all of it. A package ships skills declaring tools for connectors
* nobody has added yet, and a person's own skill outlives the server it was written against, so
* this is the ordinary case rather than a corner of one. Left out, a skill needing two tools drew
* one and the second was invisible to the person governing it.
*
* Only computed once the list has actually loaded: while the query is pending every ref looks
* unmatched, and flashing the whole declared set into this group and out again would be worse
* than showing nothing for a moment.
*/
const elsewhere = plugins.data
? undeclaredElsewhere(
selected,
servers.flatMap((server) => server.tools.map((tool) => tool.ref)),
)
: [];

return (
<Field>
<FieldLabel>Tools it needs</FieldLabel>

{plugins.isPending ? null : plugins.error ? (
<p className="text-destructive text-xs" role="alert">
Could not load the tools this deployment has.
</p>
) : servers.length === 0 ? (
<p className="text-muted-foreground text-xs">
No connected server offers a tool yet. A skill can still be written —
most are instructions rather than tool use.
</p>
) : (
<div className="flex flex-col gap-3">
{servers.map((server) => (
<div className="flex flex-col gap-1.5" key={server.id}>
<p className="text-muted-foreground text-xs">{server.title}</p>
<div className="flex flex-wrap gap-2">
{server.tools.map((tool) => {
const on = held.has(tool.ref);
return (
<Button
key={tool.ref}
onClick={() => toggle(tool.ref)}
size="sm"
/*
* The vendor's own tool name, not the namespaced one a model sees. The person
* reading this is matching it against the vendor's documentation.
*/
title={tool.description}
type="button"
variant={on ? "default" : "outline"}
>
{tool.name}
{/*
* Writes are marked. Which tools a skill pulls in is a governance question, and
* "this one changes something at the vendor" is the part worth seeing before
* ticking it rather than after.
*/}
{tool.effect === "write" ? (
<span
aria-label="changes something"
className="ml-1 opacity-60"
role="img"
>
</span>
) : null}
</Button>
);
})}
</div>
</div>
))}
</div>
)}

{elsewhere.length > 0 ? (
<div className="flex flex-col gap-1.5">
<p className="text-muted-foreground text-xs">Not connected here</p>
<div className="flex flex-wrap gap-2">
{elsewhere.map((ref) => (
<Button
key={ref}
onClick={() => toggle(ref)}
size="sm"
/*
* The whole ref, not a tool name. There is no server here to put it under, and the
* server id is the part that says which connector is missing.
*/
title={`${ref} — no connected server offers this`}
type="button"
variant="secondary"
>
{ref}
</Button>
))}
</div>
<p className="text-muted-foreground text-xs">
This skill names these, and no server connected here offers them —
because the connector has not been added, or was removed. They cost
nothing and load nothing until it exists. Click one to stop naming
it.
</p>
</div>
) : null}

<p className="text-muted-foreground text-xs">
Picking this skill is what loads these tools for a turn. It does not
grant them — a Bot still only calls what it was granted, so naming a
tool here gives nobody access to it.
</p>
</Field>
);
}
8 changes: 8 additions & 0 deletions app/src/lib/plugins/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export type SkillInput = {
summary?: string;
instructions: string;
global?: boolean;
/**
* The tools this skill says it needs, as `<serverId>/<toolName>` refs.
*
* Sent on every save, including empty, because the server replaces the set rather than merging
* into it: omitting the field to mean "leave them alone" and sending `[]` to mean "clear them"
* would be the same request from a form that just had its last one unticked.
*/
tools?: string[];
};

/** A curated server from the catalogue, which supplies the URL. */
Expand Down
9 changes: 9 additions & 0 deletions app/src/lib/plugins/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export type PluginSkill = {
origin: string;
installedBy: string | null;
grantedTo: string[];
/**
* The tools this skill says it needs, as `<serverId>/<toolName>` refs.
*
* A declaration, not a grant, and the difference is the whole reason anybody may write a skill:
* naming a tool here cannot make it callable. Selection intersects this with what the Bot was
* granted, so a skill naming a tool its Bot does not hold selects the skill and loads nothing.
* `grantedTo` on the tool side is what decides.
*/
tools: string[];
};

export type CatalogueItem = {
Expand Down
34 changes: 34 additions & 0 deletions app/src/lib/skills/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export const skillFormSchema = z.object({
.string()
.trim()
.min(1, "Instructions are required — this is what the Bot follows."),
/**
* The tools this skill says it needs, as `<serverId>/<toolName>` refs.
*
* No minimum, and no validation of the refs themselves. A skill needing no tool is ordinary — most
* are prose — and the server already refuses a ref it has never seen with a sentence naming it,
* which is a better answer than anything this file could reconstruct about which tools exist.
*
* Part of the form rather than saved on press, unlike granting. What a skill needs is the author's
* draft until they save it, so unticking one and closing the panel has to change nothing.
*/
tools: z.array(z.string()),
});

export type SkillFormValues = z.infer<typeof skillFormSchema>;
Expand All @@ -43,4 +54,27 @@ export const emptySkillForm: SkillFormValues = {
title: "",
summary: "",
instructions: "",
tools: [],
};

/**
* The declared refs no connected server offers.
*
* WHY THIS EXISTS. The picker draws the tools of the servers this deployment has connected, and a
* skill's declared set is not confined to those: a package ships skills declaring tools for
* connectors nobody has added yet, and a person's own skill outlives the server it was written
* against. Rendering only what matched meant the screen showed a subset of the declaration and
* presented it as the whole thing — a skill needing two tools drew one, and nothing said the other
* was there. Editing it silently kept a tool the author had just been shown they did not have.
*
* A wrong number on a screen somebody governs with is worse than no number, so the leftovers are
* named rather than dropped. They are not an error: a ref for a connector that does not exist here
* loads nothing, because the offer is intersected with the Bot's grants.
*/
export function undeclaredElsewhere(
selected: readonly string[],
offered: readonly string[],
): string[] {
const known = new Set(offered);
return selected.filter((ref) => !known.has(ref));
}
Loading