Skip to content

Commit e581b0b

Browse files
committed
🤖 feat: add agent skills tools and prompt index
Change-Id: Ic6403e04df7db28a075d2b4084fb9f3f330e9425 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 086aea2 commit e581b0b

File tree

16 files changed

+967
-2
lines changed

16 files changed

+967
-2
lines changed

bun.lock

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"lockfileVersion": 1,
3-
"configVersion": 0,
43
"workspaces": {
54
"": {
65
"name": "mux",
@@ -75,6 +74,7 @@
7574
"write-file-atomic": "^6.0.0",
7675
"ws": "^8.18.3",
7776
"xxhash-wasm": "^1.1.0",
77+
"yaml": "^2.8.2",
7878
"zod": "^4.1.11",
7979
"zod-to-json-schema": "^3.24.6",
8080
},
@@ -3674,6 +3674,8 @@
36743674

36753675
"yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
36763676

3677+
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
3678+
36773679
"yargs": ["[email protected]", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
36783680

36793681
"yargs-parser": ["[email protected]", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],

docs/system-prompt.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,41 @@ You are in a git worktree at ${workspacePath}
9696
* Only included when at least one MCP server is configured.
9797
* Note: We only expose server names, not commands, to avoid leaking secrets.
9898
*/
99+
100+
async function buildAgentSkillsContext(projectPath: string): Promise<string> {
101+
try {
102+
const skills = await discoverAgentSkills(projectPath);
103+
if (skills.length === 0) return "";
104+
105+
const MAX_SKILLS = 50;
106+
const shown = skills.slice(0, MAX_SKILLS);
107+
const omitted = skills.length - shown.length;
108+
109+
const lines: string[] = [];
110+
lines.push("Available agent skills (call tools to load):");
111+
for (const skill of shown) {
112+
lines.push(`- ${skill.name}: ${skill.description} (scope: ${skill.scope})`);
113+
}
114+
if (omitted > 0) {
115+
lines.push(`(+${omitted} more not shown)`);
116+
}
117+
118+
lines.push("");
119+
lines.push("To load a skill:");
120+
lines.push('- agent_skill_read({ name: "<skill-name>" })');
121+
122+
lines.push("");
123+
lines.push("To read referenced files inside a skill directory:");
124+
lines.push(
125+
'- agent_skill_read_file({ name: "<skill-name>", filePath: "references/whatever.txt" })'
126+
);
127+
128+
return `\n\n<agent-skills>\n${lines.join("\n")}\n</agent-skills>`;
129+
} catch (error) {
130+
log.warn("Failed to build agent skills context", { projectPath, error });
131+
return "";
132+
}
133+
}
99134
function buildMCPContext(mcpServers: MCPServerMap): string {
100135
const names = Object.keys(mcpServers);
101136
if (names.length === 0) return "";

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@
102102
"parse-duration": "^2.1.4",
103103
"posthog-node": "^5.17.0",
104104
"quickjs-emscripten": "^0.31.0",
105-
"typescript": "^5.1.3",
106105
"quickjs-emscripten-core": "^0.31.0",
107106
"rehype-harden": "^1.1.5",
108107
"rehype-sanitize": "^6.0.0",
@@ -111,10 +110,12 @@
111110
"streamdown": "1.6.10",
112111
"trpc-cli": "^0.12.1",
113112
"turndown": "^7.2.2",
113+
"typescript": "^5.1.3",
114114
"undici": "^7.16.0",
115115
"write-file-atomic": "^6.0.0",
116116
"ws": "^8.18.3",
117117
"xxhash-wasm": "^1.1.0",
118+
"yaml": "^2.8.2",
118119
"zod": "^4.1.11",
119120
"zod-to-json-schema": "^3.24.6"
120121
},

src/common/orpc/schemas.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ export {
3939
TokenConsumerSchema,
4040
} from "./schemas/chatStats";
4141

42+
// Agent Skill schemas
43+
export {
44+
AgentSkillDescriptorSchema,
45+
AgentSkillFrontmatterSchema,
46+
AgentSkillPackageSchema,
47+
AgentSkillScopeSchema,
48+
SkillNameSchema,
49+
} from "./schemas/agentSkill";
50+
4251
// Error schemas
4352
export { SendMessageErrorSchema, StreamErrorTypeSchema } from "./schemas/errors";
4453

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { z } from "zod";
2+
3+
export const AgentSkillScopeSchema = z.enum(["project", "global"]);
4+
5+
/**
6+
* Skill name per agentskills.io
7+
* - 1–64 chars
8+
* - lowercase letters/numbers/hyphens
9+
* - no leading/trailing hyphen
10+
* - no consecutive hyphens
11+
*/
12+
export const SkillNameSchema = z
13+
.string()
14+
.min(1)
15+
.max(64)
16+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
17+
18+
export const AgentSkillFrontmatterSchema = z.object({
19+
name: SkillNameSchema,
20+
description: z.string().min(1).max(1024),
21+
license: z.string().optional(),
22+
compatibility: z.string().min(1).max(500).optional(),
23+
metadata: z.record(z.string(), z.string()).optional(),
24+
});
25+
26+
export const AgentSkillDescriptorSchema = z.object({
27+
name: SkillNameSchema,
28+
description: z.string().min(1).max(1024),
29+
scope: AgentSkillScopeSchema,
30+
});
31+
32+
export const AgentSkillPackageSchema = z
33+
.object({
34+
scope: AgentSkillScopeSchema,
35+
directoryName: SkillNameSchema,
36+
frontmatter: AgentSkillFrontmatterSchema,
37+
body: z.string(),
38+
})
39+
.refine((value) => value.directoryName === value.frontmatter.name, {
40+
message: "SKILL.md frontmatter.name must match the parent directory name",
41+
path: ["frontmatter", "name"],
42+
});

src/common/types/agentSkill.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { z } from "zod";
2+
import type {
3+
AgentSkillDescriptorSchema,
4+
AgentSkillFrontmatterSchema,
5+
AgentSkillPackageSchema,
6+
AgentSkillScopeSchema,
7+
SkillNameSchema,
8+
} from "@/common/orpc/schemas";
9+
10+
export type SkillName = z.infer<typeof SkillNameSchema>;
11+
12+
export type AgentSkillScope = z.infer<typeof AgentSkillScopeSchema>;
13+
14+
export type AgentSkillFrontmatter = z.infer<typeof AgentSkillFrontmatterSchema>;
15+
16+
export type AgentSkillDescriptor = z.infer<typeof AgentSkillDescriptorSchema>;
17+
18+
export type AgentSkillPackage = z.infer<typeof AgentSkillPackageSchema>;

src/common/types/tools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import type { z } from "zod";
77
import type {
88
AgentReportToolResultSchema,
9+
AgentSkillReadFileToolResultSchema,
10+
AgentSkillReadToolResultSchema,
911
AskUserQuestionOptionSchema,
1012
AskUserQuestionQuestionSchema,
1113
AskUserQuestionToolResultSchema,
@@ -42,6 +44,16 @@ export interface FileReadToolArgs {
4244
limit?: number; // number of lines to return from offset (optional)
4345
}
4446

47+
// Agent Skill Tool Types
48+
// Args derived from schema (avoid drift)
49+
export type AgentSkillReadToolArgs = z.infer<typeof TOOL_DEFINITIONS.agent_skill_read.schema>;
50+
export type AgentSkillReadToolResult = z.infer<typeof AgentSkillReadToolResultSchema>;
51+
52+
export type AgentSkillReadFileToolArgs = z.infer<
53+
typeof TOOL_DEFINITIONS.agent_skill_read_file.schema
54+
>;
55+
export type AgentSkillReadFileToolResult = z.infer<typeof AgentSkillReadFileToolResultSchema>;
56+
4557
// FileReadToolResult derived from Zod schema (single source of truth)
4658
export type FileReadToolResult = z.infer<typeof FileReadToolResultSchema>;
4759

src/common/utils/tools/toolDefinitions.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { z } from "zod";
9+
import { AgentSkillPackageSchema, SkillNameSchema } from "@/common/orpc/schemas";
910
import {
1011
BASH_HARD_MAX_LINES,
1112
BASH_MAX_LINE_BYTES,
@@ -388,6 +389,46 @@ export const TOOL_DEFINITIONS = {
388389
.describe("Number of lines to return from offset (optional, returns all if not specified)"),
389390
}),
390391
},
392+
agent_skill_read: {
393+
description:
394+
"Load an Agent Skill's SKILL.md (YAML frontmatter + markdown body) by name. " +
395+
"Skills are discovered from <projectRoot>/.mux/skills/<name>/SKILL.md and ~/.mux/skills/<name>/SKILL.md.",
396+
schema: z
397+
.object({
398+
name: SkillNameSchema.describe("Skill name (directory name under the skills root)"),
399+
})
400+
.strict(),
401+
},
402+
agent_skill_read_file: {
403+
description:
404+
"Read a file within an Agent Skill directory. " +
405+
"filePath must be relative to the skill directory (no absolute paths, no ~, no .. traversal). " +
406+
"Supports offset/limit like file_read.",
407+
schema: z
408+
.object({
409+
name: SkillNameSchema.describe("Skill name (directory name under the skills root)"),
410+
filePath: z
411+
.string()
412+
.min(1)
413+
.describe("Path to the file within the skill directory (relative)"),
414+
offset: z
415+
.number()
416+
.int()
417+
.positive()
418+
.optional()
419+
.describe("1-based starting line number (optional, defaults to 1)"),
420+
limit: z
421+
.number()
422+
.int()
423+
.positive()
424+
.optional()
425+
.describe(
426+
"Number of lines to return from offset (optional, returns all if not specified)"
427+
),
428+
})
429+
.strict(),
430+
},
431+
391432
file_edit_replace_string: {
392433
description:
393434
"⚠️ CRITICAL: Always check tool results - edits WILL fail if old_string is not found or unique. Do not proceed with dependent operations (commits, pushes, builds) until confirming success.\n\n" +
@@ -777,6 +818,26 @@ export const FileReadToolResultSchema = z.union([
777818
}),
778819
]);
779820

821+
/**
822+
* Agent Skill read tool result - full SKILL.md package or error.
823+
*/
824+
export const AgentSkillReadToolResultSchema = z.union([
825+
z.object({
826+
success: z.literal(true),
827+
skill: AgentSkillPackageSchema,
828+
}),
829+
z.object({
830+
success: z.literal(false),
831+
error: z.string(),
832+
}),
833+
]);
834+
835+
/**
836+
* Agent Skill read_file tool result.
837+
* Uses the same shape/limits as file_read.
838+
*/
839+
export const AgentSkillReadFileToolResultSchema = FileReadToolResultSchema;
840+
780841
/**
781842
* File edit insert tool result - diff or error.
782843
*/
@@ -839,6 +900,8 @@ export type BridgeableToolName =
839900
| "bash_background_list"
840901
| "bash_background_terminate"
841902
| "file_read"
903+
| "agent_skill_read"
904+
| "agent_skill_read_file"
842905
| "file_edit_insert"
843906
| "file_edit_replace_string"
844907
| "web_fetch";
@@ -855,6 +918,8 @@ export const RESULT_SCHEMAS: Record<BridgeableToolName, z.ZodType> = {
855918
bash_background_list: BashBackgroundListResultSchema,
856919
bash_background_terminate: BashBackgroundTerminateResultSchema,
857920
file_read: FileReadToolResultSchema,
921+
agent_skill_read: AgentSkillReadToolResultSchema,
922+
agent_skill_read_file: AgentSkillReadFileToolResultSchema,
858923
file_edit_insert: FileEditInsertToolResultSchema,
859924
file_edit_replace_string: FileEditReplaceStringToolResultSchema,
860925
web_fetch: WebFetchToolResultSchema,
@@ -901,6 +966,8 @@ export function getAvailableTools(
901966
"bash_background_list",
902967
"bash_background_terminate",
903968
"file_read",
969+
"agent_skill_read",
970+
"agent_skill_read_file",
904971
"file_edit_replace_string",
905972
// "file_edit_replace_lines", // DISABLED: causes models to break repo state
906973
"file_edit_insert",

src/common/utils/tools/tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { createTaskTool } from "@/node/services/tools/task";
1515
import { createTaskAwaitTool } from "@/node/services/tools/task_await";
1616
import { createTaskTerminateTool } from "@/node/services/tools/task_terminate";
1717
import { createTaskListTool } from "@/node/services/tools/task_list";
18+
import { createAgentSkillReadTool } from "@/node/services/tools/agent_skill_read";
19+
import { createAgentSkillReadFileTool } from "@/node/services/tools/agent_skill_read_file";
1820
import { createAgentReportTool } from "@/node/services/tools/agent_report";
1921
import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait";
2022
import { log } from "@/node/services/log";
@@ -145,6 +147,8 @@ export async function getToolsForModel(
145147
// Non-runtime tools execute immediately (no init wait needed)
146148
const nonRuntimeTools: Record<string, Tool> = {
147149
...(config.mode === "plan" ? { ask_user_question: createAskUserQuestionTool(config) } : {}),
150+
agent_skill_read: createAgentSkillReadTool(config),
151+
agent_skill_read_file: createAgentSkillReadFileTool(config),
148152
propose_plan: createProposePlanTool(config),
149153
task: createTaskTool(config),
150154
task_await: createTaskAwaitTool(config),
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as fs from "node:fs/promises";
2+
import * as path from "node:path";
3+
4+
import { describe, expect, test } from "bun:test";
5+
6+
import { SkillNameSchema } from "@/common/orpc/schemas";
7+
import { DisposableTempDir } from "@/node/services/tempDir";
8+
import { discoverAgentSkills, readAgentSkill } from "./agentSkillsService";
9+
10+
async function writeSkill(root: string, name: string, description: string): Promise<void> {
11+
const skillDir = path.join(root, name);
12+
await fs.mkdir(skillDir, { recursive: true });
13+
const content = `---
14+
name: ${name}
15+
description: ${description}
16+
---
17+
Body
18+
`;
19+
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf-8");
20+
}
21+
22+
describe("agentSkillsService", () => {
23+
test("project skills override global skills", async () => {
24+
using project = new DisposableTempDir("agent-skills-project");
25+
using global = new DisposableTempDir("agent-skills-global");
26+
27+
const projectSkillsRoot = path.join(project.path, ".mux", "skills");
28+
const globalSkillsRoot = global.path;
29+
30+
await writeSkill(globalSkillsRoot, "foo", "from global");
31+
await writeSkill(projectSkillsRoot, "foo", "from project");
32+
await writeSkill(globalSkillsRoot, "bar", "global only");
33+
34+
const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot };
35+
36+
const skills = await discoverAgentSkills(project.path, { roots });
37+
38+
expect(skills.map((s) => s.name)).toEqual(["bar", "foo"]);
39+
40+
const foo = skills.find((s) => s.name === "foo");
41+
expect(foo).toBeDefined();
42+
expect(foo!.scope).toBe("project");
43+
expect(foo!.description).toBe("from project");
44+
45+
const bar = skills.find((s) => s.name === "bar");
46+
expect(bar).toBeDefined();
47+
expect(bar!.scope).toBe("global");
48+
});
49+
50+
test("readAgentSkill resolves project before global", async () => {
51+
using project = new DisposableTempDir("agent-skills-project");
52+
using global = new DisposableTempDir("agent-skills-global");
53+
54+
const projectSkillsRoot = path.join(project.path, ".mux", "skills");
55+
const globalSkillsRoot = global.path;
56+
57+
await writeSkill(globalSkillsRoot, "foo", "from global");
58+
await writeSkill(projectSkillsRoot, "foo", "from project");
59+
60+
const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot };
61+
62+
const name = SkillNameSchema.parse("foo");
63+
const resolved = await readAgentSkill(project.path, name, { roots });
64+
65+
expect(resolved.package.scope).toBe("project");
66+
expect(resolved.package.frontmatter.description).toBe("from project");
67+
});
68+
});

0 commit comments

Comments
 (0)