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
5 changes: 5 additions & 0 deletions .changeset/skills-tool-executor-only.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/core/execution/src/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions packages/core/execution/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<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: "<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");
16 changes: 16 additions & 0 deletions packages/hosts/mcp/src/tool-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();
});
Expand Down
19 changes: 15 additions & 4 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
Expand Down Expand Up @@ -1505,15 +1513,18 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
"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 }) =>
Expand Down
Loading