diff --git a/.changeset/skills-tool-executor-only.md b/.changeset/skills-tool-executor-only.md new file mode 100644 index 000000000..04a8e3a40 --- /dev/null +++ b/.changeset/skills-tool-executor-only.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Scope the `skills` tool to Executor's own documentation. Its description, argument, index, and unknown-name error now state that it serves a fixed catalog of how-to docs for this server's tools, so an agent on a host without a skill tool of its own no longer reads it as a general reader for the harness's or the user's skills. diff --git a/packages/core/execution/src/skills.test.ts b/packages/core/execution/src/skills.test.ts index 54a1c049b..97e76e106 100644 --- a/packages/core/execution/src/skills.test.ts +++ b/packages/core/execution/src/skills.test.ts @@ -29,6 +29,15 @@ describe("skills registry", () => { } }); + // A host with no skill tool of its own reads `executor_skills` as the general + // one it is missing, so the index has to say what the catalog covers and that + // it is closed — otherwise the model asks it for the user's skills next. + it("frames the index as Executor's own closed catalog", () => { + const index = renderSkillsIndex(); + expect(index).toContain("Executor's own tools"); + expect(index).toContain("complete list"); + }); + // A connection that did not opt in to artifacts — the default — has no tool // the artifact skills apply to, so they leave its catalog entirely rather // than documenting a surface it cannot reach. diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 1036ae697..f503b2698 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -639,10 +639,17 @@ export const findSkill = (name: string, catalog: readonly Skill[] = SKILLS): Ski /** The index the `skills` tool returns when called without a name (or with an * unknown one): every skill's name and one-line summary, plus how to fetch - * the body. */ + * the body. + * + * It leads with what the catalog IS. A host without a skill tool of its own + * (pi, for one) reads `skills` as the general skill reader it is missing and + * asks it for the user's or the harness's skills; the index is where that + * model lands, so it has to say plainly that this list is the whole of it and + * covers only Executor's own tools. */ export const renderSkillsIndex = (catalog: readonly Skill[] = SKILLS): string => [ - 'Available skills. Fetch one with `skills({ name: "" })`.', + "How-to docs for Executor's own tools — this is the complete list, and there is nothing else to fetch.", + 'Fetch one with `skills({ name: "" })`. Names outside this list, file paths, and skills belonging to your harness or the user are not served here.', "", ...catalog.map((skill) => `- \`${skill.name}\` — ${skill.summary}`), ].join("\n"); diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 80118f37a..813faeb48 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -1807,6 +1807,19 @@ describe("MCP host server — skills tool", () => { }); }); + // pi and other hosts that ship no skill tool of their own read + // `executor_skills` as the general skill reader they are missing, so the + // description has to scope itself to this server before a model tries to + // read a SKILL.md through it. + it("scopes the skills tool description to this server's own docs", async () => { + await withClient(makeStubEngine({}), NO_CAPS, async (client) => { + const { tools } = await client.listTools(); + const description = tools.find((t) => t.name === "skills")?.description ?? ""; + expect(description).toContain("Not a general skill reader"); + expect(description).toContain("SKILL.md"); + }); + }); + it("returns the execute skill body by name", async () => { await withClient(makeStubEngine({}), NO_CAPS, async (client) => { const result = await client.callTool({ @@ -1866,6 +1879,9 @@ describe("MCP host server — skills tool", () => { }); expect(result.isError).toBe(true); expect(textOf(result)).toContain('No skill named "nope"'); + // The miss is where a model that asked for an outside skill lands, so the + // note names the boundary instead of only reporting the bad name. + expect(textOf(result)).toContain("only Executor's own docs"); expect(textOf(result)).toContain("`execute`"); expect(result.structuredContent).toBeUndefined(); }); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 41c61bcd0..26014e563 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -771,6 +771,11 @@ const fallbackOutcomeResult = ( // skill's body; an unknown name -> the index plus a not-found note so the model // retries with a listed name instead of the same miss. // +// The miss is also where a model that mistook this for a general skill reader +// arrives — a host with no skill tool of its own reads `executor_skills` as the +// one it is missing and asks it for the harness's or the user's skills — so the +// note names the boundary rather than only reporting the bad name. +// // The skill body IS the payload, returned as plain text content. We do NOT // attach `structuredContent`: a client that prefers structured output (Claude // Code does) will surface only that and drop the text, so the long-form guide @@ -797,7 +802,10 @@ const skillsResult = ( if (!skill) { return { content: [ - { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` }, + { + type: "text", + text: `No skill named "${trimmed}". This tool serves only Executor's own docs, listed below — a skill from your harness or the user's project is not reachable from here.\n\n${renderSkillsIndex(catalog)}`, + }, ], isError: true, }; @@ -1505,15 +1513,18 @@ export const createExecutorMcpServer = ( "skills", { description: [ - "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", + "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", + "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', - "Call with no name to list the available skills.", + "Call with no name to list the few docs available.", ].join("\n"), inputSchema: { name: z .string() .optional() - .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'), + .describe( + 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.', + ), }, }, ({ name }) =>