From 3f1fb01f1016f5c5a3f4721a3de3e1d07335a9ae Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 11:41:33 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20agent=20skills?= =?UTF-8?q?=20tools=20and=20prompt=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ic6403e04df7db28a075d2b4084fb9f3f330e9425 Signed-off-by: Thomas Kosiewski --- bun.lock | 4 +- docs/system-prompt.mdx | 35 +++ package.json | 3 +- src/common/orpc/schemas.ts | 9 + src/common/orpc/schemas/agentSkill.ts | 42 +++ src/common/types/agentSkill.ts | 18 ++ src/common/types/tools.ts | 12 + src/common/utils/tools/toolDefinitions.ts | 67 +++++ src/common/utils/tools/tools.ts | 4 + .../agentSkills/agentSkillsService.test.ts | 68 +++++ .../agentSkills/agentSkillsService.ts | 248 ++++++++++++++++++ .../agentSkills/parseSkillMarkdown.test.ts | 78 ++++++ .../agentSkills/parseSkillMarkdown.ts | 108 ++++++++ src/node/services/systemMessage.ts | 40 +++ src/node/services/tools/agent_skill_read.ts | 53 ++++ .../services/tools/agent_skill_read_file.ts | 180 +++++++++++++ 16 files changed, 967 insertions(+), 2 deletions(-) create mode 100644 src/common/orpc/schemas/agentSkill.ts create mode 100644 src/common/types/agentSkill.ts create mode 100644 src/node/services/agentSkills/agentSkillsService.test.ts create mode 100644 src/node/services/agentSkills/agentSkillsService.ts create mode 100644 src/node/services/agentSkills/parseSkillMarkdown.test.ts create mode 100644 src/node/services/agentSkills/parseSkillMarkdown.ts create mode 100644 src/node/services/tools/agent_skill_read.ts create mode 100644 src/node/services/tools/agent_skill_read_file.ts diff --git a/bun.lock b/bun.lock index 441ac53a35..591fc47011 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -75,6 +74,7 @@ "write-file-atomic": "^6.0.0", "ws": "^8.18.3", "xxhash-wasm": "^1.1.0", + "yaml": "^2.8.2", "zod": "^4.1.11", "zod-to-json-schema": "^3.24.6", }, @@ -3674,6 +3674,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "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=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/docs/system-prompt.mdx b/docs/system-prompt.mdx index f515a2a571..dd8386251f 100644 --- a/docs/system-prompt.mdx +++ b/docs/system-prompt.mdx @@ -96,6 +96,41 @@ You are in a git worktree at ${workspacePath} * Only included when at least one MCP server is configured. * Note: We only expose server names, not commands, to avoid leaking secrets. */ + +async function buildAgentSkillsContext(projectPath: string): Promise { + try { + const skills = await discoverAgentSkills(projectPath); + if (skills.length === 0) return ""; + + const MAX_SKILLS = 50; + const shown = skills.slice(0, MAX_SKILLS); + const omitted = skills.length - shown.length; + + const lines: string[] = []; + lines.push("Available agent skills (call tools to load):"); + for (const skill of shown) { + lines.push(`- ${skill.name}: ${skill.description} (scope: ${skill.scope})`); + } + if (omitted > 0) { + lines.push(`(+${omitted} more not shown)`); + } + + lines.push(""); + lines.push("To load a skill:"); + lines.push('- agent_skill_read({ name: "" })'); + + lines.push(""); + lines.push("To read referenced files inside a skill directory:"); + lines.push( + '- agent_skill_read_file({ name: "", filePath: "references/whatever.txt" })' + ); + + return `\n\n\n${lines.join("\n")}\n`; + } catch (error) { + log.warn("Failed to build agent skills context", { projectPath, error }); + return ""; + } +} function buildMCPContext(mcpServers: MCPServerMap): string { const names = Object.keys(mcpServers); if (names.length === 0) return ""; diff --git a/package.json b/package.json index 305c41303d..8842d50374 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,6 @@ "parse-duration": "^2.1.4", "posthog-node": "^5.17.0", "quickjs-emscripten": "^0.31.0", - "typescript": "^5.1.3", "quickjs-emscripten-core": "^0.31.0", "rehype-harden": "^1.1.5", "rehype-sanitize": "^6.0.0", @@ -111,10 +110,12 @@ "streamdown": "1.6.10", "trpc-cli": "^0.12.1", "turndown": "^7.2.2", + "typescript": "^5.1.3", "undici": "^7.16.0", "write-file-atomic": "^6.0.0", "ws": "^8.18.3", "xxhash-wasm": "^1.1.0", + "yaml": "^2.8.2", "zod": "^4.1.11", "zod-to-json-schema": "^3.24.6" }, diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 2ebcf2ceca..991afbf871 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -39,6 +39,15 @@ export { TokenConsumerSchema, } from "./schemas/chatStats"; +// Agent Skill schemas +export { + AgentSkillDescriptorSchema, + AgentSkillFrontmatterSchema, + AgentSkillPackageSchema, + AgentSkillScopeSchema, + SkillNameSchema, +} from "./schemas/agentSkill"; + // Error schemas export { SendMessageErrorSchema, StreamErrorTypeSchema } from "./schemas/errors"; diff --git a/src/common/orpc/schemas/agentSkill.ts b/src/common/orpc/schemas/agentSkill.ts new file mode 100644 index 0000000000..81c629d049 --- /dev/null +++ b/src/common/orpc/schemas/agentSkill.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +export const AgentSkillScopeSchema = z.enum(["project", "global"]); + +/** + * Skill name per agentskills.io + * - 1–64 chars + * - lowercase letters/numbers/hyphens + * - no leading/trailing hyphen + * - no consecutive hyphens + */ +export const SkillNameSchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +export const AgentSkillFrontmatterSchema = z.object({ + name: SkillNameSchema, + description: z.string().min(1).max(1024), + license: z.string().optional(), + compatibility: z.string().min(1).max(500).optional(), + metadata: z.record(z.string(), z.string()).optional(), +}); + +export const AgentSkillDescriptorSchema = z.object({ + name: SkillNameSchema, + description: z.string().min(1).max(1024), + scope: AgentSkillScopeSchema, +}); + +export const AgentSkillPackageSchema = z + .object({ + scope: AgentSkillScopeSchema, + directoryName: SkillNameSchema, + frontmatter: AgentSkillFrontmatterSchema, + body: z.string(), + }) + .refine((value) => value.directoryName === value.frontmatter.name, { + message: "SKILL.md frontmatter.name must match the parent directory name", + path: ["frontmatter", "name"], + }); diff --git a/src/common/types/agentSkill.ts b/src/common/types/agentSkill.ts new file mode 100644 index 0000000000..77f369662d --- /dev/null +++ b/src/common/types/agentSkill.ts @@ -0,0 +1,18 @@ +import type { z } from "zod"; +import type { + AgentSkillDescriptorSchema, + AgentSkillFrontmatterSchema, + AgentSkillPackageSchema, + AgentSkillScopeSchema, + SkillNameSchema, +} from "@/common/orpc/schemas"; + +export type SkillName = z.infer; + +export type AgentSkillScope = z.infer; + +export type AgentSkillFrontmatter = z.infer; + +export type AgentSkillDescriptor = z.infer; + +export type AgentSkillPackage = z.infer; diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index e89c230b17..e9804f72a6 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -6,6 +6,8 @@ import type { z } from "zod"; import type { AgentReportToolResultSchema, + AgentSkillReadFileToolResultSchema, + AgentSkillReadToolResultSchema, AskUserQuestionOptionSchema, AskUserQuestionQuestionSchema, AskUserQuestionToolResultSchema, @@ -42,6 +44,16 @@ export interface FileReadToolArgs { limit?: number; // number of lines to return from offset (optional) } +// Agent Skill Tool Types +// Args derived from schema (avoid drift) +export type AgentSkillReadToolArgs = z.infer; +export type AgentSkillReadToolResult = z.infer; + +export type AgentSkillReadFileToolArgs = z.infer< + typeof TOOL_DEFINITIONS.agent_skill_read_file.schema +>; +export type AgentSkillReadFileToolResult = z.infer; + // FileReadToolResult derived from Zod schema (single source of truth) export type FileReadToolResult = z.infer; diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index c52ddeaa93..f84854f291 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -6,6 +6,7 @@ */ import { z } from "zod"; +import { AgentSkillPackageSchema, SkillNameSchema } from "@/common/orpc/schemas"; import { BASH_HARD_MAX_LINES, BASH_MAX_LINE_BYTES, @@ -388,6 +389,46 @@ export const TOOL_DEFINITIONS = { .describe("Number of lines to return from offset (optional, returns all if not specified)"), }), }, + agent_skill_read: { + description: + "Load an Agent Skill's SKILL.md (YAML frontmatter + markdown body) by name. " + + "Skills are discovered from /.mux/skills//SKILL.md and ~/.mux/skills//SKILL.md.", + schema: z + .object({ + name: SkillNameSchema.describe("Skill name (directory name under the skills root)"), + }) + .strict(), + }, + agent_skill_read_file: { + description: + "Read a file within an Agent Skill directory. " + + "filePath must be relative to the skill directory (no absolute paths, no ~, no .. traversal). " + + "Supports offset/limit like file_read.", + schema: z + .object({ + name: SkillNameSchema.describe("Skill name (directory name under the skills root)"), + filePath: z + .string() + .min(1) + .describe("Path to the file within the skill directory (relative)"), + offset: z + .number() + .int() + .positive() + .optional() + .describe("1-based starting line number (optional, defaults to 1)"), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + "Number of lines to return from offset (optional, returns all if not specified)" + ), + }) + .strict(), + }, + file_edit_replace_string: { description: "⚠️ 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([ }), ]); +/** + * Agent Skill read tool result - full SKILL.md package or error. + */ +export const AgentSkillReadToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + skill: AgentSkillPackageSchema, + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +/** + * Agent Skill read_file tool result. + * Uses the same shape/limits as file_read. + */ +export const AgentSkillReadFileToolResultSchema = FileReadToolResultSchema; + /** * File edit insert tool result - diff or error. */ @@ -839,6 +900,8 @@ export type BridgeableToolName = | "bash_background_list" | "bash_background_terminate" | "file_read" + | "agent_skill_read" + | "agent_skill_read_file" | "file_edit_insert" | "file_edit_replace_string" | "web_fetch"; @@ -855,6 +918,8 @@ export const RESULT_SCHEMAS: Record = { bash_background_list: BashBackgroundListResultSchema, bash_background_terminate: BashBackgroundTerminateResultSchema, file_read: FileReadToolResultSchema, + agent_skill_read: AgentSkillReadToolResultSchema, + agent_skill_read_file: AgentSkillReadFileToolResultSchema, file_edit_insert: FileEditInsertToolResultSchema, file_edit_replace_string: FileEditReplaceStringToolResultSchema, web_fetch: WebFetchToolResultSchema, @@ -901,6 +966,8 @@ export function getAvailableTools( "bash_background_list", "bash_background_terminate", "file_read", + "agent_skill_read", + "agent_skill_read_file", "file_edit_replace_string", // "file_edit_replace_lines", // DISABLED: causes models to break repo state "file_edit_insert", diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 9f2ab48090..63d1613aa0 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -15,6 +15,8 @@ import { createTaskTool } from "@/node/services/tools/task"; import { createTaskAwaitTool } from "@/node/services/tools/task_await"; import { createTaskTerminateTool } from "@/node/services/tools/task_terminate"; import { createTaskListTool } from "@/node/services/tools/task_list"; +import { createAgentSkillReadTool } from "@/node/services/tools/agent_skill_read"; +import { createAgentSkillReadFileTool } from "@/node/services/tools/agent_skill_read_file"; import { createAgentReportTool } from "@/node/services/tools/agent_report"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { log } from "@/node/services/log"; @@ -145,6 +147,8 @@ export async function getToolsForModel( // Non-runtime tools execute immediately (no init wait needed) const nonRuntimeTools: Record = { ...(config.mode === "plan" ? { ask_user_question: createAskUserQuestionTool(config) } : {}), + agent_skill_read: createAgentSkillReadTool(config), + agent_skill_read_file: createAgentSkillReadFileTool(config), propose_plan: createProposePlanTool(config), task: createTaskTool(config), task_await: createTaskAwaitTool(config), diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts new file mode 100644 index 0000000000..abe8576061 --- /dev/null +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -0,0 +1,68 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { SkillNameSchema } from "@/common/orpc/schemas"; +import { DisposableTempDir } from "@/node/services/tempDir"; +import { discoverAgentSkills, readAgentSkill } from "./agentSkillsService"; + +async function writeSkill(root: string, name: string, description: string): Promise { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + const content = `--- +name: ${name} +description: ${description} +--- +Body +`; + await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf-8"); +} + +describe("agentSkillsService", () => { + test("project skills override global skills", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + + const projectSkillsRoot = path.join(project.path, ".mux", "skills"); + const globalSkillsRoot = global.path; + + await writeSkill(globalSkillsRoot, "foo", "from global"); + await writeSkill(projectSkillsRoot, "foo", "from project"); + await writeSkill(globalSkillsRoot, "bar", "global only"); + + const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + + const skills = await discoverAgentSkills(project.path, { roots }); + + expect(skills.map((s) => s.name)).toEqual(["bar", "foo"]); + + const foo = skills.find((s) => s.name === "foo"); + expect(foo).toBeDefined(); + expect(foo!.scope).toBe("project"); + expect(foo!.description).toBe("from project"); + + const bar = skills.find((s) => s.name === "bar"); + expect(bar).toBeDefined(); + expect(bar!.scope).toBe("global"); + }); + + test("readAgentSkill resolves project before global", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + + const projectSkillsRoot = path.join(project.path, ".mux", "skills"); + const globalSkillsRoot = global.path; + + await writeSkill(globalSkillsRoot, "foo", "from global"); + await writeSkill(projectSkillsRoot, "foo", "from project"); + + const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + + const name = SkillNameSchema.parse("foo"); + const resolved = await readAgentSkill(project.path, name, { roots }); + + expect(resolved.package.scope).toBe("project"); + expect(resolved.package.frontmatter.description).toBe("from project"); + }); +}); diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts new file mode 100644 index 0000000000..75fe648600 --- /dev/null +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -0,0 +1,248 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + AgentSkillDescriptorSchema, + AgentSkillPackageSchema, + SkillNameSchema, +} from "@/common/orpc/schemas"; +import type { + AgentSkillDescriptor, + AgentSkillPackage, + AgentSkillScope, + SkillName, +} from "@/common/types/agentSkill"; +import { log } from "@/node/services/log"; +import { PlatformPaths } from "@/node/utils/paths.main"; +import { AgentSkillParseError, parseSkillMarkdown } from "./parseSkillMarkdown"; + +const GLOBAL_SKILLS_ROOT = "~/.mux/skills"; + +export interface AgentSkillsRoots { + projectRoot: string; + globalRoot: string; +} + +export function getDefaultAgentSkillsRoots(projectPath: string): AgentSkillsRoots { + return { + projectRoot: path.join(projectPath, ".mux", "skills"), + globalRoot: PlatformPaths.expandHome(GLOBAL_SKILLS_ROOT), + }; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function dirExists(dirPath: string): Promise { + try { + const stat = await fs.stat(dirPath); + return stat.isDirectory(); + } catch { + return false; + } +} + +async function readSkillDescriptorFromDir( + skillDir: string, + directoryName: SkillName, + scope: AgentSkillScope +): Promise { + const skillFilePath = path.join(skillDir, "SKILL.md"); + + let stat; + try { + stat = await fs.stat(skillFilePath); + } catch { + return null; + } + if (!stat.isFile()) { + return null; + } + + let content: string; + try { + content = await fs.readFile(skillFilePath, "utf-8"); + } catch (err) { + log.warn(`Failed to read SKILL.md for ${directoryName}: ${formatError(err)}`); + return null; + } + + try { + const parsed = parseSkillMarkdown({ + content, + byteSize: stat.size, + directoryName, + }); + + const descriptor: AgentSkillDescriptor = { + name: parsed.frontmatter.name, + description: parsed.frontmatter.description, + scope, + }; + + const validated = AgentSkillDescriptorSchema.safeParse(descriptor); + if (!validated.success) { + log.warn(`Invalid agent skill descriptor for ${directoryName}: ${validated.error.message}`); + return null; + } + + return validated.data; + } catch (err) { + const message = err instanceof AgentSkillParseError ? err.message : formatError(err); + log.warn(`Skipping invalid skill '${directoryName}' (${scope}): ${message}`); + return null; + } +} + +export async function discoverAgentSkills( + projectPath: string, + options?: { roots?: AgentSkillsRoots } +): Promise { + const roots = options?.roots ?? getDefaultAgentSkillsRoots(projectPath); + + const byName = new Map(); + + // Project skills take precedence over global. + const scans: Array<{ scope: AgentSkillScope; root: string }> = [ + { scope: "project", root: roots.projectRoot }, + { scope: "global", root: roots.globalRoot }, + ]; + + for (const scan of scans) { + const exists = await dirExists(scan.root); + if (!exists) continue; + + let entries; + try { + entries = await fs.readdir(scan.root, { withFileTypes: true }); + } catch (err) { + log.warn(`Failed to read skills directory ${scan.root}: ${formatError(err)}`); + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const directoryNameRaw = entry.name; + const nameParsed = SkillNameSchema.safeParse(directoryNameRaw); + if (!nameParsed.success) { + log.warn(`Skipping invalid skill directory name '${directoryNameRaw}' in ${scan.root}`); + continue; + } + + const directoryName = nameParsed.data; + + if (scan.scope === "global" && byName.has(directoryName)) { + continue; + } + + const skillDir = path.join(scan.root, directoryName); + const descriptor = await readSkillDescriptorFromDir(skillDir, directoryName, scan.scope); + if (!descriptor) continue; + + // Precedence: project overwrites global. + byName.set(descriptor.name, descriptor); + } + } + + return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface ResolvedAgentSkill { + package: AgentSkillPackage; + skillDir: string; +} + +async function readAgentSkillFromDir( + skillDir: string, + directoryName: SkillName, + scope: AgentSkillScope +): Promise { + const skillFilePath = path.join(skillDir, "SKILL.md"); + + const stat = await fs.stat(skillFilePath); + if (!stat.isFile()) { + throw new Error(`SKILL.md is not a file: ${skillFilePath}`); + } + + const content = await fs.readFile(skillFilePath, "utf-8"); + const parsed = parseSkillMarkdown({ + content, + byteSize: stat.size, + directoryName, + }); + + const pkg: AgentSkillPackage = { + scope, + directoryName, + frontmatter: parsed.frontmatter, + body: parsed.body, + }; + + const validated = AgentSkillPackageSchema.safeParse(pkg); + if (!validated.success) { + throw new Error( + `Invalid agent skill package for '${directoryName}': ${validated.error.message}` + ); + } + + return { + package: validated.data, + skillDir, + }; +} + +export async function readAgentSkill( + projectPath: string, + name: SkillName, + options?: { roots?: AgentSkillsRoots } +): Promise { + const roots = options?.roots ?? getDefaultAgentSkillsRoots(projectPath); + + // Project overrides global. + const candidates: Array<{ scope: AgentSkillScope; root: string }> = [ + { scope: "project", root: roots.projectRoot }, + { scope: "global", root: roots.globalRoot }, + ]; + + for (const candidate of candidates) { + const skillDir = path.join(candidate.root, name); + try { + const stat = await fs.stat(skillDir); + if (!stat.isDirectory()) continue; + + return await readAgentSkillFromDir(skillDir, name, candidate.scope); + } catch { + continue; + } + } + + throw new Error(`Agent skill not found: ${name}`); +} + +export function resolveAgentSkillFilePath(skillDir: string, filePath: string): string { + if (!filePath) { + throw new Error("filePath is required"); + } + + // Disallow absolute paths and home-relative paths. + if (path.isAbsolute(filePath) || filePath.startsWith("~")) { + throw new Error(`Invalid filePath (must be relative to the skill directory): ${filePath}`); + } + + // Resolve relative to skillDir and ensure it stays within skillDir. + const resolved = path.resolve(skillDir, filePath); + const relative = path.relative(skillDir, resolved); + + if (relative === "" || relative === ".") { + // Allow reading the skill directory itself? No. + throw new Error(`Invalid filePath (expected a file, got directory): ${filePath}`); + } + + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Invalid filePath (path traversal): ${filePath}`); + } + + return resolved; +} diff --git a/src/node/services/agentSkills/parseSkillMarkdown.test.ts b/src/node/services/agentSkills/parseSkillMarkdown.test.ts new file mode 100644 index 0000000000..f7a5310e09 --- /dev/null +++ b/src/node/services/agentSkills/parseSkillMarkdown.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; + +import { SkillNameSchema } from "@/common/orpc/schemas"; +import { AgentSkillParseError, parseSkillMarkdown } from "./parseSkillMarkdown"; + +describe("parseSkillMarkdown", () => { + test("parses valid YAML frontmatter and body", () => { + const content = `--- +name: pdf-processing +description: Extract text from PDFs +--- +# Instructions +Do the thing. +`; + + const directoryName = SkillNameSchema.parse("pdf-processing"); + + const result = parseSkillMarkdown({ + content, + byteSize: Buffer.byteLength(content, "utf-8"), + directoryName, + }); + + expect(result.frontmatter.name).toBe("pdf-processing"); + expect(result.frontmatter.description).toBe("Extract text from PDFs"); + expect(result.body).toContain("# Instructions"); + }); + + test("tolerates unknown frontmatter keys (e.g., allowed-tools)", () => { + const content = `--- +name: foo +description: Hello +allowed-tools: file_read +--- +Body +`; + + const directoryName = SkillNameSchema.parse("foo"); + + const result = parseSkillMarkdown({ + content, + byteSize: Buffer.byteLength(content, "utf-8"), + directoryName, + }); + + expect(result.frontmatter.name).toBe("foo"); + expect(result.frontmatter.description).toBe("Hello"); + }); + + test("throws on missing frontmatter", () => { + const content = "# No frontmatter\n"; + expect(() => + parseSkillMarkdown({ + content, + byteSize: Buffer.byteLength(content, "utf-8"), + }) + ).toThrow(AgentSkillParseError); + }); + + test("throws when frontmatter name does not match directory name", () => { + const content = `--- +name: bar +description: Hello +--- +Body +`; + + const directoryName = SkillNameSchema.parse("foo"); + + expect(() => + parseSkillMarkdown({ + content, + byteSize: Buffer.byteLength(content, "utf-8"), + directoryName, + }) + ).toThrow(AgentSkillParseError); + }); +}); diff --git a/src/node/services/agentSkills/parseSkillMarkdown.ts b/src/node/services/agentSkills/parseSkillMarkdown.ts new file mode 100644 index 0000000000..bab6357883 --- /dev/null +++ b/src/node/services/agentSkills/parseSkillMarkdown.ts @@ -0,0 +1,108 @@ +import { AgentSkillFrontmatterSchema } from "@/common/orpc/schemas"; +import type { AgentSkillFrontmatter, SkillName } from "@/common/types/agentSkill"; +import { MAX_FILE_SIZE } from "@/node/services/tools/fileCommon"; +import YAML from "yaml"; + +export class AgentSkillParseError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentSkillParseError"; + } +} + +export interface ParsedSkillMarkdown { + frontmatter: AgentSkillFrontmatter; + body: string; +} + +function normalizeNewlines(input: string): string { + return input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function stripUtf8Bom(input: string): string { + return input.startsWith("\uFEFF") ? input.slice(1) : input; +} + +function assertObject(value: unknown, message: string): asserts value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AgentSkillParseError(message); + } +} + +function formatZodIssues( + issues: ReadonlyArray<{ path: readonly PropertyKey[]; message: string }> +): string { + return issues + .map((issue) => { + const issuePath = + issue.path.length > 0 ? issue.path.map((part) => String(part)).join(".") : ""; + return `${issuePath}: ${issue.message}`; + }) + .join("; "); +} + +/** + * Parse a SKILL.md file into validated frontmatter + markdown body. + * + * Defensive constraints: + * - Enforces a 1MB max file size (consistent with existing file tools) + * - Requires YAML frontmatter delimited by `---` on its own line at the top + */ +export function parseSkillMarkdown(input: { + content: string; + byteSize: number; + directoryName?: SkillName; +}): ParsedSkillMarkdown { + if (input.byteSize > MAX_FILE_SIZE) { + const sizeMB = (input.byteSize / (1024 * 1024)).toFixed(2); + const maxMB = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2); + throw new AgentSkillParseError( + `SKILL.md is too large (${sizeMB}MB). Maximum supported size is ${maxMB}MB.` + ); + } + + const content = normalizeNewlines(stripUtf8Bom(input.content)); + + // Frontmatter must start at byte 0. + if (!content.startsWith("---")) { + throw new AgentSkillParseError("SKILL.md must start with YAML frontmatter delimited by '---'."); + } + + const lines = content.split("\n"); + if ((lines[0] ?? "").trim() !== "---") { + throw new AgentSkillParseError("SKILL.md frontmatter start delimiter must be exactly '---'."); + } + + const endIndex = lines.findIndex((line, idx) => idx > 0 && line.trim() === "---"); + if (endIndex === -1) { + throw new AgentSkillParseError("SKILL.md frontmatter is missing the closing '---' delimiter."); + } + + const yamlText = lines.slice(1, endIndex).join("\n"); + const body = lines.slice(endIndex + 1).join("\n"); + + let raw: unknown; + try { + raw = YAML.parse(yamlText); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AgentSkillParseError(`Failed to parse SKILL.md YAML frontmatter: ${message}`); + } + + assertObject(raw, "SKILL.md YAML frontmatter must be a mapping/object."); + + const parsed = AgentSkillFrontmatterSchema.safeParse(raw); + if (!parsed.success) { + throw new AgentSkillParseError( + `Invalid SKILL.md frontmatter: ${formatZodIssues(parsed.error.issues)}` + ); + } + + if (input.directoryName && parsed.data.name !== input.directoryName) { + throw new AgentSkillParseError( + `SKILL.md frontmatter.name '${parsed.data.name}' must match directory name '${input.directoryName}'.` + ); + } + + return { frontmatter: parsed.data, body }; +} diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index f8c4db4c8e..fd55945638 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -12,6 +12,8 @@ import { } from "@/node/utils/main/markdown"; import type { Runtime } from "@/node/runtime/Runtime"; import { getMuxHome } from "@/common/constants/paths"; +import { discoverAgentSkills } from "@/node/services/agentSkills/agentSkillsService"; +import { log } from "@/node/services/log"; import { getAvailableTools } from "@/common/utils/tools/toolDefinitions"; // NOTE: keep this in sync with the docs/models.md file @@ -115,6 +117,41 @@ You are in a git worktree at ${workspacePath} * Only included when at least one MCP server is configured. * Note: We only expose server names, not commands, to avoid leaking secrets. */ + +async function buildAgentSkillsContext(projectPath: string): Promise { + try { + const skills = await discoverAgentSkills(projectPath); + if (skills.length === 0) return ""; + + const MAX_SKILLS = 50; + const shown = skills.slice(0, MAX_SKILLS); + const omitted = skills.length - shown.length; + + const lines: string[] = []; + lines.push("Available agent skills (call tools to load):"); + for (const skill of shown) { + lines.push(`- ${skill.name}: ${skill.description} (scope: ${skill.scope})`); + } + if (omitted > 0) { + lines.push(`(+${omitted} more not shown)`); + } + + lines.push(""); + lines.push("To load a skill:"); + lines.push('- agent_skill_read({ name: "" })'); + + lines.push(""); + lines.push("To read referenced files inside a skill directory:"); + lines.push( + '- agent_skill_read_file({ name: "", filePath: "references/whatever.txt" })' + ); + + return `\n\n\n${lines.join("\n")}\n`; + } catch (error) { + log.warn("Failed to build agent skills context", { projectPath, error }); + return ""; + } +} function buildMCPContext(mcpServers: MCPServerMap): string { const names = Object.keys(mcpServers); if (names.length === 0) return ""; @@ -275,6 +312,9 @@ export async function buildSystemMessage( systemMessage += buildMCPContext(mcpServers); } + // Add agent skills context (if any) + systemMessage += await buildAgentSkillsContext(metadata.projectPath); + if (options?.variant === "agent") { const agentPrompt = options.agentSystemPrompt?.trim(); if (agentPrompt) { diff --git a/src/node/services/tools/agent_skill_read.ts b/src/node/services/tools/agent_skill_read.ts new file mode 100644 index 0000000000..e1cfe5c907 --- /dev/null +++ b/src/node/services/tools/agent_skill_read.ts @@ -0,0 +1,53 @@ +import { tool } from "ai"; + +import type { AgentSkillReadToolResult } from "@/common/types/tools"; +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { SkillNameSchema } from "@/common/orpc/schemas"; +import { readAgentSkill } from "@/node/services/agentSkills/agentSkillsService"; + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Agent Skill read tool factory. + * Reads and validates a skill's SKILL.md from project-local or global skills roots. + */ +export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.agent_skill_read.description, + inputSchema: TOOL_DEFINITIONS.agent_skill_read.schema, + execute: async ({ name }): Promise => { + const projectPath = config.muxEnv?.MUX_PROJECT_PATH; + if (!projectPath) { + return { + success: false, + error: "MUX_PROJECT_PATH is not available; cannot resolve agent skills roots.", + }; + } + + // Defensive: validate again even though inputSchema should guarantee shape. + const parsedName = SkillNameSchema.safeParse(name); + if (!parsedName.success) { + return { + success: false, + error: parsedName.error.message, + }; + } + + try { + const resolved = await readAgentSkill(projectPath, parsedName.data); + return { + success: true, + skill: resolved.package, + }; + } catch (error) { + return { + success: false, + error: formatError(error), + }; + } + }, + }); +}; diff --git a/src/node/services/tools/agent_skill_read_file.ts b/src/node/services/tools/agent_skill_read_file.ts new file mode 100644 index 0000000000..49a036e526 --- /dev/null +++ b/src/node/services/tools/agent_skill_read_file.ts @@ -0,0 +1,180 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { tool } from "ai"; + +import type { AgentSkillReadFileToolResult } from "@/common/types/tools"; +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { SkillNameSchema } from "@/common/orpc/schemas"; +import { + readAgentSkill, + resolveAgentSkillFilePath, +} from "@/node/services/agentSkills/agentSkillsService"; +import { validateFileSize } from "@/node/services/tools/fileCommon"; + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isPathTraversal(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel.startsWith("..") || path.isAbsolute(rel); +} + +/** + * Agent Skill read_file tool factory. + * Reads a file within a skill directory with the same output limits as file_read. + */ +export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.agent_skill_read_file.description, + inputSchema: TOOL_DEFINITIONS.agent_skill_read_file.schema, + execute: async ({ name, filePath, offset, limit }): Promise => { + try { + const projectPath = config.muxEnv?.MUX_PROJECT_PATH; + if (!projectPath) { + return { + success: false, + error: "MUX_PROJECT_PATH is not available; cannot resolve agent skills roots.", + }; + } + + // Defensive: validate again even though inputSchema should guarantee shape. + const parsedName = SkillNameSchema.safeParse(name); + if (!parsedName.success) { + return { + success: false, + error: parsedName.error.message, + }; + } + + const resolvedSkill = await readAgentSkill(projectPath, parsedName.data); + const unsafeTargetPath = resolveAgentSkillFilePath(resolvedSkill.skillDir, filePath); + + // Resolve symlinks and ensure the final target stays inside the skill directory. + const [realSkillDir, realTargetPath] = await Promise.all([ + fs.realpath(resolvedSkill.skillDir), + fs.realpath(unsafeTargetPath), + ]); + + if (isPathTraversal(realSkillDir, realTargetPath)) { + return { + success: false, + error: `Invalid filePath (path traversal): ${filePath}`, + }; + } + + if (offset !== undefined && offset < 1) { + return { + success: false, + error: `Offset must be positive (got ${offset})`, + }; + } + + const stat = await fs.stat(realTargetPath); + if (stat.isDirectory()) { + return { + success: false, + error: `Path is a directory, not a file: ${filePath}`, + }; + } + + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: false, + }); + if (sizeValidation) { + return { + success: false, + error: sizeValidation.error, + }; + } + + const fullContent = await fs.readFile(realTargetPath, "utf-8"); + const lines = fullContent === "" ? [] : fullContent.split("\n"); + + if (offset !== undefined && offset > lines.length) { + return { + success: false, + error: `Offset ${offset} is beyond file length`, + }; + } + + const startLineNumber = offset ?? 1; + const startIdx = startLineNumber - 1; + const endIdx = limit !== undefined ? startIdx + limit : lines.length; + + const numberedLines: string[] = []; + let totalBytesAccumulated = 0; + const MAX_LINE_BYTES = 1024; + const MAX_LINES = 1000; + const MAX_TOTAL_BYTES = 16 * 1024; // 16KB + + for (let i = startIdx; i < Math.min(endIdx, lines.length); i++) { + const line = lines[i]; + const lineNumber = i + 1; + + let processedLine = line; + const lineBytes = Buffer.byteLength(line, "utf-8"); + if (lineBytes > MAX_LINE_BYTES) { + processedLine = Buffer.from(line, "utf-8") + .subarray(0, MAX_LINE_BYTES) + .toString("utf-8"); + processedLine += "... [truncated]"; + } + + const numberedLine = `${lineNumber}\t${processedLine}`; + const numberedLineBytes = Buffer.byteLength(numberedLine, "utf-8"); + + if (totalBytesAccumulated + numberedLineBytes > MAX_TOTAL_BYTES) { + return { + success: false, + error: `Output would exceed ${MAX_TOTAL_BYTES} bytes. Please read less at a time using offset and limit parameters.`, + }; + } + + numberedLines.push(numberedLine); + totalBytesAccumulated += numberedLineBytes + 1; + + if (numberedLines.length > MAX_LINES) { + return { + success: false, + error: `Output would exceed ${MAX_LINES} lines. Please read less at a time using offset and limit parameters.`, + }; + } + } + + return { + success: true, + file_size: stat.size, + modifiedTime: stat.mtime.toISOString(), + lines_read: numberedLines.length, + content: numberedLines.join("\n"), + }; + } catch (error) { + if (error && typeof error === "object" && "code" in error) { + const code = (error as { code?: string }).code; + if (code === "ENOENT") { + return { + success: false, + error: `File not found: ${filePath}`, + }; + } + if (code === "EACCES") { + return { + success: false, + error: `Permission denied: ${filePath}`, + }; + } + } + + return { + success: false, + error: `Failed to read file: ${formatError(error)}`, + }; + } + }, + }); +}; From 8ef7ec6140674114961f2163891f7d87282784b5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 11:49:31 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20docs:=20document=20agent=20s?= =?UTF-8?q?kills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I94e8cfc58b4220045018e6f2d751560ffc4b39b9 Signed-off-by: Thomas Kosiewski --- docs/agent-skills.mdx | 102 ++++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 1 + 2 files changed, 103 insertions(+) create mode 100644 docs/agent-skills.mdx diff --git a/docs/agent-skills.mdx b/docs/agent-skills.mdx new file mode 100644 index 0000000000..5242d154fb --- /dev/null +++ b/docs/agent-skills.mdx @@ -0,0 +1,102 @@ +--- +title: Agent Skills +description: Share reusable workflows and references with skills +--- + +## Overview + +Agent Skills are reusable, file-based “playbooks” that you can share across projects or keep project-local. + +Mux follows the Agent Skills specification and exposes skills to models in two steps: + +1. **Index in the system prompt**: mux lists available skills (name + description). +2. **Tool-based loading**: the agent calls tools to load a full skill when needed. + +This keeps the system prompt small while still making skills discoverable. + +## Where skills live + +Mux discovers skills from two roots: + +- **Project-local**: `/.mux/skills//SKILL.md` +- **Global**: `~/.mux/skills//SKILL.md` + +If a skill exists in both locations, **project-local overrides global**. + +Mux reads skills from the machine running mux (even if the workspace runtime is SSH). + +## Skill layout + +A skill is a directory named after the skill: + +```text +.mux/skills/ + my-skill/ + SKILL.md + references/ + ... +``` + +Skill directory names must match `^[a-z0-9]+(?:-[a-z0-9]+)*$` (1–64 chars). + +## `SKILL.md` format + +`SKILL.md` must start with YAML frontmatter delimited by `---` on its own line. +Mux enforces a 1MB maximum file size for `SKILL.md`. + +Required fields: + +- `name`: must match the directory name +- `description`: short summary shown in mux’s skills index + +Optional fields: + +- `license` +- `compatibility` +- `metadata` (string key/value map) + +Mux ignores unknown frontmatter keys (for example `allowed-tools`). + +Example: + +```md +--- +name: my-skill +description: Build and validate a release branch. +license: MIT +metadata: + owner: platform +--- + +# My Skill + +1. Do the thing... +``` + +## Using skills in mux + +Mux injects an `` block into the system prompt listing the available skills. + +To load a skill, the agent calls: + +```ts +agent_skill_read({ name: "my-skill" }); +``` + +If your skill references additional files (cheatsheets, templates, etc.), the agent can read them with: + +```ts +agent_skill_read_file({ name: "my-skill", filePath: "references/template.md" }); +``` + +`agent_skill_read_file` supports `offset` / `limit` (like `file_read`) and rejects absolute paths and `..` traversal. + + + `agent_skill_read_file` uses the same output limits as `file_read` (roughly 16KB per call, + numbered lines). Files are limited to 1MB. Read large files in chunks with `offset`/`limit`. + + +## Current limitations + +- There is no `/skill` command or UI activation flow yet; skills are loaded on-demand via tools. +- `allowed-tools` is not enforced by mux (it is tolerated in frontmatter, but ignored). diff --git a/docs/docs.json b/docs/docs.json index 155381e307..a283c62e78 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -55,6 +55,7 @@ }, "context-management", "instruction-files", + "agent-skills", "mcp-servers", { "group": "Project Secrets", From 524e1c9a9580ebbcb9fb67265e9d780130643b88 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 12:04:45 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20docs:=20rerequest=20Codex=20?= =?UTF-8?q?review=20after=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I78abef7d37ac2e0a06a03dfad8e577a5becf2437 Signed-off-by: Thomas Kosiewski --- docs/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 2444eb6bc4..621479b480 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -29,6 +29,7 @@ gh pr view --json mergeable,mergeStateStatus | jq '.' ./scripts/wait_pr_checks.sh ``` +- If Codex left review comments and you addressed them, push your fixes and then comment `@codex review` to re-request review. After that, re-run `./scripts/wait_pr_checks.sh ` and `./scripts/check_codex_comments.sh `. - Generally run `wait_pr_checks` after submitting a PR to ensure CI passes. - Status decoding: `mergeable=MERGEABLE` clean; `CONFLICTING` needs resolution. `mergeStateStatus=CLEAN` ready, `BLOCKED` waiting for CI, `BEHIND` rebase, `DIRTY` conflicts. - If behind: `git fetch origin && git rebase origin/main && git push --force-with-lease`. From f114344ea5727b57d83ae5aff46cea977b2c3d42 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:00:12 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20load=20agent=20skills?= =?UTF-8?q?=20via=20runtime=20for=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Id5fbd1107aa2b323e7ce065f0bf0b780dfc0b19a Signed-off-by: Thomas Kosiewski --- docs/agent-skills.mdx | 5 +- docs/system-prompt.mdx | 6 +- src/common/utils/tools/tools.ts | 4 +- .../agentSkills/agentSkillsService.test.ts | 7 +- .../agentSkills/agentSkillsService.ts | 171 +++++++++++++----- src/node/services/systemMessage.ts | 8 +- src/node/services/tools/agent_skill_read.ts | 8 +- .../services/tools/agent_skill_read_file.ts | 117 ++++++------ 8 files changed, 204 insertions(+), 122 deletions(-) diff --git a/docs/agent-skills.mdx b/docs/agent-skills.mdx index 5242d154fb..4377f5beca 100644 --- a/docs/agent-skills.mdx +++ b/docs/agent-skills.mdx @@ -23,7 +23,10 @@ Mux discovers skills from two roots: If a skill exists in both locations, **project-local overrides global**. -Mux reads skills from the machine running mux (even if the workspace runtime is SSH). + + Mux reads skills using the active workspace runtime. For SSH workspaces, skills are read from the + remote host. + ## Skill layout diff --git a/docs/system-prompt.mdx b/docs/system-prompt.mdx index dd8386251f..e4e378d52c 100644 --- a/docs/system-prompt.mdx +++ b/docs/system-prompt.mdx @@ -97,9 +97,9 @@ You are in a git worktree at ${workspacePath} * Note: We only expose server names, not commands, to avoid leaking secrets. */ -async function buildAgentSkillsContext(projectPath: string): Promise { +async function buildAgentSkillsContext(runtime: Runtime, workspacePath: string): Promise { try { - const skills = await discoverAgentSkills(projectPath); + const skills = await discoverAgentSkills(runtime, workspacePath); if (skills.length === 0) return ""; const MAX_SKILLS = 50; @@ -127,7 +127,7 @@ async function buildAgentSkillsContext(projectPath: string): Promise { return `\n\n\n${lines.join("\n")}\n`; } catch (error) { - log.warn("Failed to build agent skills context", { projectPath, error }); + log.warn("Failed to build agent skills context", { workspacePath, error }); return ""; } } diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 63d1613aa0..e31c485c34 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -131,6 +131,8 @@ export async function getToolsForModel( // Wrap them to handle init waiting centrally instead of in each tool const runtimeTools: Record = { file_read: wrap(createFileReadTool(config)), + agent_skill_read: wrap(createAgentSkillReadTool(config)), + agent_skill_read_file: wrap(createAgentSkillReadFileTool(config)), file_edit_replace_string: wrap(createFileEditReplaceStringTool(config)), file_edit_insert: wrap(createFileEditInsertTool(config)), // DISABLED: file_edit_replace_lines - causes models (particularly GPT-5-Codex) @@ -147,8 +149,6 @@ export async function getToolsForModel( // Non-runtime tools execute immediately (no init wait needed) const nonRuntimeTools: Record = { ...(config.mode === "plan" ? { ask_user_question: createAskUserQuestionTool(config) } : {}), - agent_skill_read: createAgentSkillReadTool(config), - agent_skill_read_file: createAgentSkillReadFileTool(config), propose_plan: createProposePlanTool(config), task: createTaskTool(config), task_await: createTaskAwaitTool(config), diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index abe8576061..90e03384ef 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { describe, expect, test } from "bun:test"; import { SkillNameSchema } from "@/common/orpc/schemas"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { DisposableTempDir } from "@/node/services/tempDir"; import { discoverAgentSkills, readAgentSkill } from "./agentSkillsService"; @@ -32,8 +33,9 @@ describe("agentSkillsService", () => { await writeSkill(globalSkillsRoot, "bar", "global only"); const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + const runtime = new LocalRuntime(project.path); - const skills = await discoverAgentSkills(project.path, { roots }); + const skills = await discoverAgentSkills(runtime, project.path, { roots }); expect(skills.map((s) => s.name)).toEqual(["bar", "foo"]); @@ -58,9 +60,10 @@ describe("agentSkillsService", () => { await writeSkill(projectSkillsRoot, "foo", "from project"); const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + const runtime = new LocalRuntime(project.path); const name = SkillNameSchema.parse("foo"); - const resolved = await readAgentSkill(project.path, name, { roots }); + const resolved = await readAgentSkill(runtime, project.path, name, { roots }); expect(resolved.package.scope).toBe("project"); expect(resolved.package.frontmatter.description).toBe("from project"); diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 75fe648600..662562f07f 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -1,6 +1,11 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import type { Runtime } from "@/node/runtime/Runtime"; +import { SSHRuntime } from "@/node/runtime/SSHRuntime"; +import { shellQuote } from "@/node/runtime/backgroundCommands"; +import { execBuffered, readFileString } from "@/node/utils/runtime/helpers"; + import { AgentSkillDescriptorSchema, AgentSkillPackageSchema, @@ -13,7 +18,7 @@ import type { SkillName, } from "@/common/types/agentSkill"; import { log } from "@/node/services/log"; -import { PlatformPaths } from "@/node/utils/paths.main"; +import { validateFileSize } from "@/node/services/tools/fileCommon"; import { AgentSkillParseError, parseSkillMarkdown } from "./parseSkillMarkdown"; const GLOBAL_SKILLS_ROOT = "~/.mux/skills"; @@ -23,10 +28,17 @@ export interface AgentSkillsRoots { globalRoot: string; } -export function getDefaultAgentSkillsRoots(projectPath: string): AgentSkillsRoots { +export function getDefaultAgentSkillsRoots( + runtime: Runtime, + workspacePath: string +): AgentSkillsRoots { + if (!workspacePath) { + throw new Error("getDefaultAgentSkillsRoots: workspacePath is required"); + } + return { - projectRoot: path.join(projectPath, ".mux", "skills"), - globalRoot: PlatformPaths.expandHome(GLOBAL_SKILLS_ROOT), + projectRoot: runtime.normalizePath(".mux/skills", workspacePath), + globalRoot: GLOBAL_SKILLS_ROOT, }; } @@ -34,35 +46,71 @@ function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -async function dirExists(dirPath: string): Promise { +async function listSkillDirectoriesFromLocalFs(root: string): Promise { try { - const stat = await fs.stat(dirPath); - return stat.isDirectory(); + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); } catch { - return false; + return []; + } +} + +async function listSkillDirectoriesFromRuntime( + runtime: Runtime, + root: string, + options: { cwd: string } +): Promise { + if (!options.cwd) { + throw new Error("listSkillDirectoriesFromRuntime: options.cwd is required"); + } + + const quotedRoot = shellQuote(root); + const command = + `if [ -d ${quotedRoot} ]; then ` + + `find ${quotedRoot} -mindepth 1 -maxdepth 1 -type d -exec basename {} \\; ; ` + + `fi`; + + const result = await execBuffered(runtime, command, { cwd: options.cwd, timeout: 10 }); + if (result.exitCode !== 0) { + log.warn(`Failed to read skills directory ${root}: ${result.stderr || result.stdout}`); + return []; } + + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); } async function readSkillDescriptorFromDir( + runtime: Runtime, skillDir: string, directoryName: SkillName, scope: AgentSkillScope ): Promise { - const skillFilePath = path.join(skillDir, "SKILL.md"); + const skillFilePath = runtime.normalizePath("SKILL.md", skillDir); let stat; try { - stat = await fs.stat(skillFilePath); + stat = await runtime.stat(skillFilePath); } catch { return null; } - if (!stat.isFile()) { + + if (stat.isDirectory) { + return null; + } + + // Avoid reading very large files into memory (parseSkillMarkdown enforces the same limit). + const sizeValidation = validateFileSize(stat); + if (sizeValidation) { + log.warn(`Skipping skill '${directoryName}' (${scope}): ${sizeValidation.error}`); return null; } let content: string; try { - content = await fs.readFile(skillFilePath, "utf-8"); + content = await readFileString(runtime, skillFilePath); } catch (err) { log.warn(`Failed to read SKILL.md for ${directoryName}: ${formatError(err)}`); return null; @@ -96,10 +144,15 @@ async function readSkillDescriptorFromDir( } export async function discoverAgentSkills( - projectPath: string, + runtime: Runtime, + workspacePath: string, options?: { roots?: AgentSkillsRoots } ): Promise { - const roots = options?.roots ?? getDefaultAgentSkillsRoots(projectPath); + if (!workspacePath) { + throw new Error("discoverAgentSkills: workspacePath is required"); + } + + const roots = options?.roots ?? getDefaultAgentSkillsRoots(runtime, workspacePath); const byName = new Map(); @@ -110,24 +163,23 @@ export async function discoverAgentSkills( ]; for (const scan of scans) { - const exists = await dirExists(scan.root); - if (!exists) continue; - - let entries; + let resolvedRoot: string; try { - entries = await fs.readdir(scan.root, { withFileTypes: true }); + resolvedRoot = await runtime.resolvePath(scan.root); } catch (err) { - log.warn(`Failed to read skills directory ${scan.root}: ${formatError(err)}`); + log.warn(`Failed to resolve skills root ${scan.root}: ${formatError(err)}`); continue; } - for (const entry of entries) { - if (!entry.isDirectory()) continue; + const directoryNames = + runtime instanceof SSHRuntime + ? await listSkillDirectoriesFromRuntime(runtime, resolvedRoot, { cwd: workspacePath }) + : await listSkillDirectoriesFromLocalFs(resolvedRoot); - const directoryNameRaw = entry.name; + for (const directoryNameRaw of directoryNames) { const nameParsed = SkillNameSchema.safeParse(directoryNameRaw); if (!nameParsed.success) { - log.warn(`Skipping invalid skill directory name '${directoryNameRaw}' in ${scan.root}`); + log.warn(`Skipping invalid skill directory name '${directoryNameRaw}' in ${resolvedRoot}`); continue; } @@ -137,8 +189,13 @@ export async function discoverAgentSkills( continue; } - const skillDir = path.join(scan.root, directoryName); - const descriptor = await readSkillDescriptorFromDir(skillDir, directoryName, scan.scope); + const skillDir = runtime.normalizePath(directoryName, resolvedRoot); + const descriptor = await readSkillDescriptorFromDir( + runtime, + skillDir, + directoryName, + scan.scope + ); if (!descriptor) continue; // Precedence: project overwrites global. @@ -155,18 +212,24 @@ export interface ResolvedAgentSkill { } async function readAgentSkillFromDir( + runtime: Runtime, skillDir: string, directoryName: SkillName, scope: AgentSkillScope ): Promise { - const skillFilePath = path.join(skillDir, "SKILL.md"); + const skillFilePath = runtime.normalizePath("SKILL.md", skillDir); - const stat = await fs.stat(skillFilePath); - if (!stat.isFile()) { + const stat = await runtime.stat(skillFilePath); + if (stat.isDirectory) { throw new Error(`SKILL.md is not a file: ${skillFilePath}`); } - const content = await fs.readFile(skillFilePath, "utf-8"); + const sizeValidation = validateFileSize(stat); + if (sizeValidation) { + throw new Error(sizeValidation.error); + } + + const content = await readFileString(runtime, skillFilePath); const parsed = parseSkillMarkdown({ content, byteSize: stat.size, @@ -194,11 +257,16 @@ async function readAgentSkillFromDir( } export async function readAgentSkill( - projectPath: string, + runtime: Runtime, + workspacePath: string, name: SkillName, options?: { roots?: AgentSkillsRoots } ): Promise { - const roots = options?.roots ?? getDefaultAgentSkillsRoots(projectPath); + if (!workspacePath) { + throw new Error("readAgentSkill: workspacePath is required"); + } + + const roots = options?.roots ?? getDefaultAgentSkillsRoots(runtime, workspacePath); // Project overrides global. const candidates: Array<{ scope: AgentSkillScope; root: string }> = [ @@ -207,12 +275,20 @@ export async function readAgentSkill( ]; for (const candidate of candidates) { - const skillDir = path.join(candidate.root, name); + let resolvedRoot: string; try { - const stat = await fs.stat(skillDir); - if (!stat.isDirectory()) continue; + resolvedRoot = await runtime.resolvePath(candidate.root); + } catch { + continue; + } - return await readAgentSkillFromDir(skillDir, name, candidate.scope); + const skillDir = runtime.normalizePath(name, resolvedRoot); + + try { + const stat = await runtime.stat(skillDir); + if (!stat.isDirectory) continue; + + return await readAgentSkillFromDir(runtime, skillDir, name, candidate.scope); } catch { continue; } @@ -221,26 +297,37 @@ export async function readAgentSkill( throw new Error(`Agent skill not found: ${name}`); } -export function resolveAgentSkillFilePath(skillDir: string, filePath: string): string { +function isAbsolutePathAny(filePath: string): boolean { + if (filePath.startsWith("/") || filePath.startsWith("\\")) return true; + // Windows drive letter paths (e.g., C:\foo or C:/foo) + return /^[A-Za-z]:[\\/]/.test(filePath); +} + +export function resolveAgentSkillFilePath( + runtime: Runtime, + skillDir: string, + filePath: string +): string { if (!filePath) { throw new Error("filePath is required"); } // Disallow absolute paths and home-relative paths. - if (path.isAbsolute(filePath) || filePath.startsWith("~")) { + if (isAbsolutePathAny(filePath) || filePath.startsWith("~")) { throw new Error(`Invalid filePath (must be relative to the skill directory): ${filePath}`); } + const pathModule = runtime instanceof SSHRuntime ? path.posix : path; + // Resolve relative to skillDir and ensure it stays within skillDir. - const resolved = path.resolve(skillDir, filePath); - const relative = path.relative(skillDir, resolved); + const resolved = pathModule.resolve(skillDir, filePath); + const relative = pathModule.relative(skillDir, resolved); if (relative === "" || relative === ".") { - // Allow reading the skill directory itself? No. throw new Error(`Invalid filePath (expected a file, got directory): ${filePath}`); } - if (relative.startsWith("..") || path.isAbsolute(relative)) { + if (relative.startsWith("..") || pathModule.isAbsolute(relative)) { throw new Error(`Invalid filePath (path traversal): ${filePath}`); } diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index fd55945638..562e5bab8c 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -118,9 +118,9 @@ You are in a git worktree at ${workspacePath} * Note: We only expose server names, not commands, to avoid leaking secrets. */ -async function buildAgentSkillsContext(projectPath: string): Promise { +async function buildAgentSkillsContext(runtime: Runtime, workspacePath: string): Promise { try { - const skills = await discoverAgentSkills(projectPath); + const skills = await discoverAgentSkills(runtime, workspacePath); if (skills.length === 0) return ""; const MAX_SKILLS = 50; @@ -148,7 +148,7 @@ async function buildAgentSkillsContext(projectPath: string): Promise { return `\n\n\n${lines.join("\n")}\n`; } catch (error) { - log.warn("Failed to build agent skills context", { projectPath, error }); + log.warn("Failed to build agent skills context", { workspacePath, error }); return ""; } } @@ -313,7 +313,7 @@ export async function buildSystemMessage( } // Add agent skills context (if any) - systemMessage += await buildAgentSkillsContext(metadata.projectPath); + systemMessage += await buildAgentSkillsContext(runtime, workspacePath); if (options?.variant === "agent") { const agentPrompt = options.agentSystemPrompt?.trim(); diff --git a/src/node/services/tools/agent_skill_read.ts b/src/node/services/tools/agent_skill_read.ts index e1cfe5c907..ac24cd5e80 100644 --- a/src/node/services/tools/agent_skill_read.ts +++ b/src/node/services/tools/agent_skill_read.ts @@ -19,11 +19,11 @@ export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) description: TOOL_DEFINITIONS.agent_skill_read.description, inputSchema: TOOL_DEFINITIONS.agent_skill_read.schema, execute: async ({ name }): Promise => { - const projectPath = config.muxEnv?.MUX_PROJECT_PATH; - if (!projectPath) { + const workspacePath = config.cwd; + if (!workspacePath) { return { success: false, - error: "MUX_PROJECT_PATH is not available; cannot resolve agent skills roots.", + error: "Tool misconfigured: cwd is required.", }; } @@ -37,7 +37,7 @@ export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) } try { - const resolved = await readAgentSkill(projectPath, parsedName.data); + const resolved = await readAgentSkill(config.runtime, workspacePath, parsedName.data); return { success: true, skill: resolved.package, diff --git a/src/node/services/tools/agent_skill_read_file.ts b/src/node/services/tools/agent_skill_read_file.ts index 49a036e526..ac9fdbaeec 100644 --- a/src/node/services/tools/agent_skill_read_file.ts +++ b/src/node/services/tools/agent_skill_read_file.ts @@ -1,6 +1,3 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - import { tool } from "ai"; import type { AgentSkillReadFileToolResult } from "@/common/types/tools"; @@ -12,16 +9,13 @@ import { resolveAgentSkillFilePath, } from "@/node/services/agentSkills/agentSkillsService"; import { validateFileSize } from "@/node/services/tools/fileCommon"; +import { RuntimeError } from "@/node/runtime/Runtime"; +import { readFileString } from "@/node/utils/runtime/helpers"; function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isPathTraversal(root: string, candidate: string): boolean { - const rel = path.relative(root, candidate); - return rel.startsWith("..") || path.isAbsolute(rel); -} - /** * Agent Skill read_file tool factory. * Reads a file within a skill directory with the same output limits as file_read. @@ -31,39 +25,30 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat description: TOOL_DEFINITIONS.agent_skill_read_file.description, inputSchema: TOOL_DEFINITIONS.agent_skill_read_file.schema, execute: async ({ name, filePath, offset, limit }): Promise => { - try { - const projectPath = config.muxEnv?.MUX_PROJECT_PATH; - if (!projectPath) { - return { - success: false, - error: "MUX_PROJECT_PATH is not available; cannot resolve agent skills roots.", - }; - } - - // Defensive: validate again even though inputSchema should guarantee shape. - const parsedName = SkillNameSchema.safeParse(name); - if (!parsedName.success) { - return { - success: false, - error: parsedName.error.message, - }; - } - - const resolvedSkill = await readAgentSkill(projectPath, parsedName.data); - const unsafeTargetPath = resolveAgentSkillFilePath(resolvedSkill.skillDir, filePath); + const workspacePath = config.cwd; + if (!workspacePath) { + return { + success: false, + error: "Tool misconfigured: cwd is required.", + }; + } - // Resolve symlinks and ensure the final target stays inside the skill directory. - const [realSkillDir, realTargetPath] = await Promise.all([ - fs.realpath(resolvedSkill.skillDir), - fs.realpath(unsafeTargetPath), - ]); + // Defensive: validate again even though inputSchema should guarantee shape. + const parsedName = SkillNameSchema.safeParse(name); + if (!parsedName.success) { + return { + success: false, + error: parsedName.error.message, + }; + } - if (isPathTraversal(realSkillDir, realTargetPath)) { - return { - success: false, - error: `Invalid filePath (path traversal): ${filePath}`, - }; - } + try { + const resolvedSkill = await readAgentSkill(config.runtime, workspacePath, parsedName.data); + const targetPath = resolveAgentSkillFilePath( + config.runtime, + resolvedSkill.skillDir, + filePath + ); if (offset !== undefined && offset < 1) { return { @@ -72,19 +57,27 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat }; } - const stat = await fs.stat(realTargetPath); - if (stat.isDirectory()) { + let stat; + try { + stat = await config.runtime.stat(targetPath); + } catch (err) { + if (err instanceof RuntimeError) { + return { + success: false, + error: err.message, + }; + } + throw err; + } + + if (stat.isDirectory) { return { success: false, error: `Path is a directory, not a file: ${filePath}`, }; } - const sizeValidation = validateFileSize({ - size: stat.size, - modifiedTime: stat.mtime, - isDirectory: false, - }); + const sizeValidation = validateFileSize(stat); if (sizeValidation) { return { success: false, @@ -92,7 +85,19 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat }; } - const fullContent = await fs.readFile(realTargetPath, "utf-8"); + let fullContent: string; + try { + fullContent = await readFileString(config.runtime, targetPath); + } catch (err) { + if (err instanceof RuntimeError) { + return { + success: false, + error: err.message, + }; + } + throw err; + } + const lines = fullContent === "" ? [] : fullContent.split("\n"); if (offset !== undefined && offset > lines.length) { @@ -149,27 +154,11 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat return { success: true, file_size: stat.size, - modifiedTime: stat.mtime.toISOString(), + modifiedTime: stat.modifiedTime.toISOString(), lines_read: numberedLines.length, content: numberedLines.join("\n"), }; } catch (error) { - if (error && typeof error === "object" && "code" in error) { - const code = (error as { code?: string }).code; - if (code === "ENOENT") { - return { - success: false, - error: `File not found: ${filePath}`, - }; - } - if (code === "EACCES") { - return { - success: false, - error: `Permission denied: ${filePath}`, - }; - } - } - return { success: false, error: `Failed to read file: ${formatError(error)}`, From 463aab136f0338403c4971bca59048099ac791ab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:13:32 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20docs:=20avoid=20literal=20\n?= =?UTF-8?q?=20in=20gh=20pr=20comment=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Iaa402bb87f8a76413e38f6e4ab94b1c3b6d3eb11 Signed-off-by: Thomas Kosiewski --- docs/AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 621479b480..5722a66158 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -29,6 +29,15 @@ gh pr view --json mergeable,mergeStateStatus | jq '.' ./scripts/wait_pr_checks.sh ``` +- When posting multi-line comments with `gh` (e.g., `@codex review`), **do not** rely on `\n` escapes inside quoted `--body` strings (they will be sent as literal text). Prefer `--body-file -` with a heredoc to preserve real newlines: + +```bash +gh pr comment --body-file - <<'EOF' +@codex review + + +EOF +``` - If Codex left review comments and you addressed them, push your fixes and then comment `@codex review` to re-request review. After that, re-run `./scripts/wait_pr_checks.sh ` and `./scripts/check_codex_comments.sh `. - Generally run `wait_pr_checks` after submitting a PR to ensure CI passes. - Status decoding: `mergeable=MERGEABLE` clean; `CONFLICTING` needs resolution. `mergeStateStatus=CLEAN` ready, `BLOCKED` waiting for CI, `BEHIND` rebase, `DIRTY` conflicts. From 584845ecae48cb28e6e14a242eecae6ab67e9166 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 15:34:41 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20docs:=20add=20Agent=20Skills?= =?UTF-8?q?=20further=20reading=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ie701433b134eac7cdf13730c7467bdcd72c3eb62 Signed-off-by: Thomas Kosiewski --- docs/agent-skills.mdx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/agent-skills.mdx b/docs/agent-skills.mdx index 4377f5beca..b614c171b3 100644 --- a/docs/agent-skills.mdx +++ b/docs/agent-skills.mdx @@ -103,3 +103,17 @@ agent_skill_read_file({ name: "my-skill", filePath: "references/template.md" }); - There is no `/skill` command or UI activation flow yet; skills are loaded on-demand via tools. - `allowed-tools` is not enforced by mux (it is tolerated in frontmatter, but ignored). + +## Further reading + +- [Agent Skills overview](https://agentskills.io/home) +- [What are skills?](https://agentskills.io/what-are-skills) (progressive disclosure) +- [Agent Skills specification](https://agentskills.io/specification) + - [Directory structure](https://agentskills.io/specification#directory-structure) + - [`SKILL.md` format](https://agentskills.io/specification#skill-md-format) + - [Frontmatter fields](https://agentskills.io/specification#frontmatter-required) + - [Optional directories](https://agentskills.io/specification#optional-directories) + - [Progressive disclosure](https://agentskills.io/specification#progressive-disclosure) +- [Integrate skills into your agent](https://agentskills.io/integrate-skills) (tool-based vs filesystem-based) +- [Example skills (GitHub)](https://github.com/anthropics/skills) +- [skills-ref validation library (GitHub)](https://github.com/agentskills/agentskills/tree/main/skills-ref)