diff --git a/apps/hub/src/skills-mount.ts b/apps/hub/src/skills-mount.ts index 44793076e..e60fe76ce 100644 --- a/apps/hub/src/skills-mount.ts +++ b/apps/hub/src/skills-mount.ts @@ -69,7 +69,7 @@ export function mountSkills(deps: { const skill = byName.get(name); if (skill === undefined) { // Pinning a skill the pusher cannot see would advertise a - // skill `load_skill` will refuse to fetch at run time. Reject + // skill `skills_load` will refuse to fetch at run time. Reject // the push instead of shipping an index that lies. throw new SkillRegistryError( "not_found", diff --git a/packages/agent-directory/src/agent-workflow.ts b/packages/agent-directory/src/agent-workflow.ts index d744358ef..5837d13c5 100644 --- a/packages/agent-directory/src/agent-workflow.ts +++ b/packages/agent-directory/src/agent-workflow.ts @@ -39,11 +39,11 @@ export const AGENT_DEFINITION_STEP_ID = "agent"; * The tool package that turns a name in the `` index * into an actual skill body at run time. A definition that pins skills * must pin this too, or its prompt would tell the model to call a - * `load_skill` tool that does not exist. + * `skills_load` tool that does not exist. */ export const SKILLS_TOOL_PACKAGE_PIN = { name: "@corbits/tools-skills", - version: "0.0.1", + version: "0.0.2", } as const; /** diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 48c0f8608..70c7e0bc0 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -1402,11 +1402,11 @@ test("a create request indexes its pinned skills into the stored system prompt", const prompt = promptFrom(workflowJson); expect(prompt.startsWith("You are a careful research assistant.")).toBe(true); expect(prompt).toContain("- web-research: What web-research does."); - expect(prompt).toContain("load_skill"); - // The prompt tells the model to call `load_skill`, so the bundle that + expect(prompt).toContain("skills_load"); + // The prompt tells the model to call `skills_load`, so the bundle that // provides it must be pinned on the same push. expect(pinsFrom(workflowJson)).toEqual([ - { name: "@corbits/tools-skills", version: "0.0.1" }, + { name: "@corbits/tools-skills", version: "0.0.2" }, ]); }); diff --git a/packages/mocks/README.md b/packages/mocks/README.md index 0c7ff4138..53ad1e830 100644 --- a/packages/mocks/README.md +++ b/packages/mocks/README.md @@ -142,7 +142,7 @@ ollama.onChat(() => ollama.reply.malformedToolName()); | `.truncatedToolArgs(name, rawArgs)` | Arguments that are NOT valid JSON — a mid-stream cutoff, reaching the wire byte-for-byte. | | `.refusal(text?)` | The model declines to answer. | | `.oversized(approxChars?)` | A large enough text blob to exercise a truncation or blob-spill path. | -| `.hallucinatedToolName()` | A plausible-but-nonexistent tool name (`skills_load` instead of the real `load_skill`). | +| `.hallucinatedToolName()` | A model calling `load_skill` instead of the canonical `skills_load`. | `sequence([...])` scripts one reply per turn — turn 1 malformed, turn 2 normal — so CL-6478's "does the room survive?" contract is directly diff --git a/packages/mocks/src/ollama/scenarios.test.ts b/packages/mocks/src/ollama/scenarios.test.ts index c37345745..53c21ec3c 100644 --- a/packages/mocks/src/ollama/scenarios.test.ts +++ b/packages/mocks/src/ollama/scenarios.test.ts @@ -146,20 +146,20 @@ describe("ollama.reply adversarial scenarios", () => { expect(body.choices[0]?.finish_reason).toBe("length"); }); - test("hallucinatedToolName calls a plausible but nonexistent tool", async () => { + test("hallucinatedToolName calls load_skill instead of skills_load", async () => { const ollama = createOllamaMock(); ollama.onChat(() => ollama.reply.hallucinatedToolName()); const response = await chat(ollama.fetch, { model: "qwen3.8:27b", messages: [{ role: "user", content: "load the git skill" }], - tools: [{ type: "function", function: { name: "load_skill" } }], + tools: [{ type: "function", function: { name: "skills_load" } }], }); const body = (await response.json()) as ChatCompletionBody; const name = body.choices[0]?.message.tool_calls?.[0]?.function.name; - expect(name).toBe("skills_load"); - expect(name).not.toBe("load_skill"); + expect(name).toBe("load_skill"); + expect(name).not.toBe("skills_load"); }); }); diff --git a/packages/mocks/src/ollama/scenarios.ts b/packages/mocks/src/ollama/scenarios.ts index 40d0fd90c..e2012f7a4 100644 --- a/packages/mocks/src/ollama/scenarios.ts +++ b/packages/mocks/src/ollama/scenarios.ts @@ -12,9 +12,11 @@ import type { OllamaChatReply } from "./types"; const CL_6478_MALFORMED_TOOL_NAME = "@intx/tools-posix/sidecar-bundle:run_shell\n { expect(stanza).toContain("- summarize: Condenses a long thread."); }); - test("names load_skill as the way to read a body", () => { - expect(buildAvailableSkillsStanza(PINNED)).toContain("load_skill"); + test("names skills_load as the way to read a body", () => { + expect(buildAvailableSkillsStanza(PINNED)).toContain("skills_load"); }); test("is empty when nothing is pinned", () => { diff --git a/packages/skills/src/prompt.ts b/packages/skills/src/prompt.ts index f875bb38c..4338f8556 100644 --- a/packages/skills/src/prompt.ts +++ b/packages/skills/src/prompt.ts @@ -1,7 +1,7 @@ // The pinned-skills prompt index. A definition that pins skills gets an // `` stanza appended to its system prompt at push // time — name and description only, plus the instruction to call -// `load_skill` for the body. +// `skills_load` for the body. // // Index-only is the whole point: a skill body is arbitrarily long and // most turns need none of it, so inlining every pinned body would burn @@ -10,7 +10,7 @@ export const AVAILABLE_SKILLS_OPEN_TAG = ""; export const AVAILABLE_SKILLS_CLOSE_TAG = ""; -export const LOAD_SKILL_TOOL = "load_skill"; +export const SKILLS_LOAD_TOOL = "skills_load"; export type PinnedSkillIndexEntry = { readonly name: string; @@ -31,7 +31,7 @@ export function buildAvailableSkillsStanza( AVAILABLE_SKILLS_OPEN_TAG, "These skills are available to you. Only their names and descriptions", `are listed here. To read a skill's full instructions, call the`, - `\`${LOAD_SKILL_TOOL}\` tool with that skill's name — never assume its`, + `\`${SKILLS_LOAD_TOOL}\` tool with that skill's name — never assume its`, "contents from the description alone.", "", ...lines, diff --git a/packages/tool-registry-publish/src/describe.test.ts b/packages/tool-registry-publish/src/describe.test.ts index 324a9b622..e4e4aafa5 100644 --- a/packages/tool-registry-publish/src/describe.test.ts +++ b/packages/tool-registry-publish/src/describe.test.ts @@ -33,4 +33,17 @@ describe("describeCorbitsToolPackages", () => { const second = await describeCorbitsToolPackages(); expect(second).toBe(first); }); + + test("tools-skills grants exactly one qualifiedId for skills_load, not the old load_skill name", async () => { + const descriptions = await describeCorbitsToolPackages(); + const skills = descriptions.find( + (description) => description.name === "@corbits/tools-skills", + ); + expect(skills).toBeDefined(); + const qualifiedIds = skills?.tools.map((tool) => tool.qualifiedId) ?? []; + expect(qualifiedIds).toContain("@corbits/tools-skills/skills:skills_load"); + expect(qualifiedIds).not.toContain( + "@corbits/tools-skills/skills:load_skill", + ); + }); }); diff --git a/packages/tools-skills/README.md b/packages/tools-skills/README.md index 1e94ce15c..06dd44567 100644 --- a/packages/tools-skills/README.md +++ b/packages/tools-skills/README.md @@ -1,12 +1,12 @@ # @corbits/tools-skills -`skills_list`, `skills_search`, and `load_skill` as an `@intx/agent` tool +`skills_list`, `skills_search`, and `skills_load` as an `@intx/agent` tool bundle — the agent-facing half of the workbench's skill registry (`@corbits/skills`). A definition's pinned skills already appear in its system prompt's `` index, with names and descriptions only. -`load_skill` is how a model turns one of those names into the actual +`skills_load` is how a model turns one of those names into the actual instructions, so the index stays cheap and only the skills a turn truly needs are paid for in context. diff --git a/packages/tools-skills/package.json b/packages/tools-skills/package.json index 22153c5b0..e2f42ba37 100644 --- a/packages/tools-skills/package.json +++ b/packages/tools-skills/package.json @@ -1,8 +1,8 @@ { "name": "@corbits/tools-skills", "private": true, - "description": "skills_list / skills_search / load_skill as an @intx/agent tool bundle: calls the sanctioned workflow-skills HTTP surface (@corbits/skills) with the sidecar's own bearer token and the run's own address, and fails closed rather than returning an empty registry", - "version": "0.0.1", + "description": "skills_list / skills_search / skills_load as an @intx/agent tool bundle: calls the sanctioned workflow-skills HTTP surface (@corbits/skills) with the sidecar's own bearer token and the run's own address, and fails closed rather than returning an empty registry", + "version": "0.0.2", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/tools-skills/src/index.ts b/packages/tools-skills/src/index.ts index f96bc5391..b10bb2827 100644 --- a/packages/tools-skills/src/index.ts +++ b/packages/tools-skills/src/index.ts @@ -9,8 +9,8 @@ export { } from "./client"; export { skillsTools, - LOAD_SKILL_TOOL, SKILLS_LIST_TOOL, + SKILLS_LOAD_TOOL, SKILLS_SEARCH_TOOL, type WorkflowSkillsToolEnv, } from "./tool"; diff --git a/packages/tools-skills/src/tool.test.ts b/packages/tools-skills/src/tool.test.ts index d67e5e1c0..9c15ec756 100644 --- a/packages/tools-skills/src/tool.test.ts +++ b/packages/tools-skills/src/tool.test.ts @@ -2,8 +2,8 @@ import { afterEach, expect, test } from "bun:test"; import type { ToolCall } from "@intx/types/runtime"; import { - LOAD_SKILL_TOOL, SKILLS_LIST_TOOL, + SKILLS_LOAD_TOOL, SKILLS_SEARCH_TOOL, skillsTools, } from "./tool"; @@ -31,12 +31,12 @@ function stubFetch(respond: () => Response | Promise): void { globalThis.fetch = (async () => respond()) as unknown as typeof fetch; } -test("declares skills_list, skills_search, and load_skill", () => { +test("declares skills_list, skills_search, and skills_load", () => { const bundle = skillsTools(testEnv()); expect(bundle.definitions.map((d) => d.name)).toEqual([ SKILLS_LIST_TOOL, SKILLS_SEARCH_TOOL, - LOAD_SKILL_TOOL, + SKILLS_LOAD_TOOL, ]); }); @@ -100,7 +100,7 @@ test("skills_search requires a query", async () => { expect(String(result.content)).toContain("requires a query"); }); -test("load_skill returns the skill body", async () => { +test("skills_load returns the skill body", async () => { stubFetch( () => new Response( @@ -114,32 +114,41 @@ test("load_skill returns the skill body", async () => { ), ); const result = await skillsTools(testEnv()).run( - callFor(LOAD_SKILL_TOOL, { name: "triage" }), + callFor(SKILLS_LOAD_TOOL, { name: "triage" }), new AbortController().signal, ); expect(result.isError).toBeFalsy(); expect(String(result.content)).toContain("Pick one label."); }); -test("load_skill surfaces a missing skill as an error rather than inventing one", async () => { +test("skills_load surfaces a missing skill as an error rather than inventing one", async () => { stubFetch(() => new Response("nope", { status: 404 })); const result = await skillsTools(testEnv()).run( - callFor(LOAD_SKILL_TOOL, { name: "triage" }), + callFor(SKILLS_LOAD_TOOL, { name: "triage" }), new AbortController().signal, ); expect(result.isError).toBe(true); expect(String(result.content)).toContain("404"); }); -test("load_skill requires a name", async () => { +test("skills_load requires a name", async () => { const result = await skillsTools(testEnv()).run( - callFor(LOAD_SKILL_TOOL), + callFor(SKILLS_LOAD_TOOL), new AbortController().signal, ); expect(result.isError).toBe(true); expect(String(result.content)).toContain("requires a skill name"); }); +test("load_skill is no longer a recognized tool name", async () => { + const result = await skillsTools(testEnv()).run( + callFor("load_skill", { name: "triage" }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain("unknown tool"); +}); + test("an unknown tool name is an error result, not a throw", async () => { const result = await skillsTools(testEnv()).run( callFor("skills_delete"), diff --git a/packages/tools-skills/src/tool.ts b/packages/tools-skills/src/tool.ts index 339d2c16d..6c94ad5e1 100644 --- a/packages/tools-skills/src/tool.ts +++ b/packages/tools-skills/src/tool.ts @@ -1,8 +1,8 @@ // The `@corbits/tools-skills` bundle: `skills_list`, `skills_search`, -// and `load_skill` — the agent-facing half of the skill registry. A +// and `skills_load` — the agent-facing half of the skill registry. A // definition's pinned skills are already named in its system prompt's // `` index (`@corbits/skills`' `withAvailableSkills`); -// `load_skill` is how the model turns one of those names into the actual +// `skills_load` is how the model turns one of those names into the actual // instructions, so the index stays cheap and only the skills a turn // truly needs are paid for in context. // @@ -19,7 +19,7 @@ import { listSkills, loadSkill, searchSkills } from "./client"; export const SKILLS_LIST_TOOL = "skills_list"; export const SKILLS_SEARCH_TOOL = "skills_search"; -export const LOAD_SKILL_TOOL = "load_skill"; +export const SKILLS_LOAD_TOOL = "skills_load"; /** Env this bundle needs beyond `BaseEnv`: the run's hub-reach credential. */ export interface WorkflowSkillsToolEnv extends BaseEnv { @@ -80,13 +80,13 @@ async function runSkillsSearch( } } -async function runLoadSkill( +async function runSkillsLoad( env: WorkflowSkillsToolEnv, call: ToolCall, ): Promise { const name = call.arguments["name"]; if (typeof name !== "string" || name === "") { - return errorResult(call.id, new Error("load_skill requires a skill name")); + return errorResult(call.id, new Error("skills_load requires a skill name")); } try { const skill = await loadSkill(clientConfig(env), name); @@ -102,7 +102,7 @@ export const skillsTools = defineTool({ definitions: [ { name: SKILLS_LIST_TOOL }, { name: SKILLS_SEARCH_TOOL }, - { name: LOAD_SKILL_TOOL }, + { name: SKILLS_LOAD_TOOL }, ], factory: (env) => ({ definitions: [ @@ -130,7 +130,7 @@ export const skillsTools = defineTool({ }, }, { - name: LOAD_SKILL_TOOL, + name: SKILLS_LOAD_TOOL, description: "Reads one skill's full instructions by name. Call this " + "before following a skill listed in — the " + @@ -154,8 +154,8 @@ export const skillsTools = defineTool({ return runSkillsList(env, call); case SKILLS_SEARCH_TOOL: return runSkillsSearch(env, call); - case LOAD_SKILL_TOOL: - return runLoadSkill(env, call); + case SKILLS_LOAD_TOOL: + return runSkillsLoad(env, call); default: return Promise.resolve( errorResult(