Say what a skill proposal changes, so a kept runbook never reads as saved

propose_skill took no strict schema, so a runbook sent under another key
was dropped and the saved one proposed again; the confirm still said
"Saved". Now an unknown key is an error, a change that changes nothing is
refused, and both the bot and the confirm outcome are told which fields
change and whether the runbook is among them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:29:55 +01:00
co-authored by Claude Opus 5.5
parent b9c5311fde
commit 027c67c0a8
6 changed files with 152 additions and 9 deletions
@@ -1,8 +1,14 @@
import type { ExtensionProposalView } from "@/src/types";
import proposalRef from "@/src/utils/proposal-ref";
import type { SkillProposalChanges } from "@/src/utils/skill-proposal-changes";
type Params = {
view: ExtensionProposalView;
/**
* For a change to a skill: what it makes different, so the bot knows
* whether its new runbook is in the proposal at all.
*/
skill_changes?: SkillProposalChanges;
};
/**
@@ -10,7 +16,7 @@ type Params = {
* its reply, and the facts the card shows (worked out by the app), so it
* tells the user the same thing the card does.
*/
export default function describeProposalForBot({ view }: Params) {
export default function describeProposalForBot({ view, skill_changes }: Params) {
const ref = proposalRef({ id: view.id });
const keys = view.envs.length
? view.envs
@@ -25,6 +31,7 @@ export default function describeProposalForBot({ view }: Params) {
return [
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
`The card shows the whole runbook${view.extension_id ? " and what changed" : ""}, and what it points bots at: hosts: ${line("Hosts")}; tool names: ${line("Tool names")}; about secrets: ${line("About secrets")}.`,
...(skill_changes ? [describeSkillChanges({ changes: skill_changes })] : []),
`In your reply, say in plain words what the skill makes bots do, anything in it that sends something out or touches secrets, and where its content came from.`,
`Don't say it's added: the user's next message says whether they confirmed it and what happened.`,
].join("\n");
@@ -37,3 +44,11 @@ export default function describeProposalForBot({ view }: Params) {
`Don't say it's added: the user's next message says whether they confirmed it and what happened.`,
].join("\n");
}
function describeSkillChanges({ changes }: { changes: SkillProposalChanges }) {
const runbook = changes.body_changed
? ` The runbook is the one you sent (${changes.body_chars} characters, was ${changes.was_body_chars}).`
: ` The runbook stays as saved: this proposal doesn't change it. If you meant to change it, propose again with the whole new runbook in body, and tell the user this card doesn't.`;
return `It changes: ${changes.fields.join(", ")}.${runbook}`;
}
@@ -7,6 +7,7 @@ import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import proposalRef from "@/src/utils/proposal-ref";
import skillProposalChanges from "@/src/utils/skill-proposal-changes";
import type { HarnessTool } from "../../../types";
import assertSuperAdminRun from "./assert-super-admin-run";
import describeProposalForBot from "./describe-proposal-for-bot";
@@ -46,7 +47,7 @@ type Args = {
export default function proposeSkillTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
return {
name: "propose_skill",
description: `Propose adding a skill (a runbook bots load with load_skill when its one-line description says it applies), or changing one's name, slug, description or body (skill_id), for the user to confirm. Nothing is saved until they press Confirm on the card. Changing one keeps every field you leave out; body replaces the whole runbook, so read it first with list_extensions and keep what still applies. A new one needs name, slug (lowercase words with hyphens, how bots load it), description and body. First review the runbook yourself and give it in research: what it makes bots do, any step that sends data, files or keys anywhere or reads secrets, and where the content came from (add the pages to sources if any of it came from one). Never put a key's value in a runbook. Check list_extensions first: if a skill already covers the job, propose a change to it instead.`,
description: `Propose adding a skill (a runbook bots load with load_skill when its one-line description says it applies), or changing one's name, slug, description or body (skill_id), for the user to confirm. Nothing is saved until they press Confirm on the card. Changing one keeps every field you leave out; body replaces the whole runbook, so read it first with list_extensions and keep what still applies: send the whole new runbook in body, and leave body out only to keep the saved one. A new one needs name, slug (lowercase words with hyphens, how bots load it), description and body. First review the runbook yourself and give it in research: what it makes bots do, any step that sends data, files or keys anywhere or reads secrets, and where the content came from (add the pages to sources if any of it came from one). Never put a key's value in a runbook. Check list_extensions first: if a skill already covers the job, propose a change to it instead.`,
schema: z.object({
skill_id: z.number().int().positive().optional(),
name: z.string().trim().max(80).optional(),
@@ -56,7 +57,12 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac
enabled: z.boolean().optional(),
all_bots: z.boolean().optional(),
...SkillReviewFields,
}),
})
/**
* A runbook sent under another name (`runbook`, `content`) would
* otherwise be dropped, and the saved one proposed again.
*/
.strict(),
run: async ({ args }) => {
await assertSuperAdminRun({ user_id });
@@ -74,7 +80,15 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac
},
});
if (data.body != current?.body) await assertSkillBodyFits({ body: data.body });
const changes = skillProposalChanges({ data, current });
if (current && !changes.fields.length) {
throw new Error(
`That changes nothing: it's skill #${current.id} exactly as saved${args.body === undefined ? " (you sent no body, so the runbook stayed the saved one)" : ""}. Send what should change, the whole new runbook in body.`,
);
}
if (changes.body_changed) await assertSkillBodyFits({ body: data.body });
const id = await createExtensionProposal({
user_id,
@@ -89,7 +103,10 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac
onPropose?.({ ref: proposalRef({ id }) });
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
return describeProposalForBot({
view: await grabExtensionProposalView({ proposal_id: id, user_id }),
skill_changes: current ? changes : undefined,
});
},
};
}