diff --git a/app/lib/workflows/runAgentStep.ts b/app/lib/workflows/runAgentStep.ts index f9a894195..704035c64 100644 --- a/app/lib/workflows/runAgentStep.ts +++ b/app/lib/workflows/runAgentStep.ts @@ -42,7 +42,7 @@ export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishRe }); const modelMessages = convertToModelMessages(input.messages); - const tools = buildAgentTools(); + const tools = buildAgentTools({ skills: input.agentContext.skills }); const result = streamText({ model: gateway(input.modelId), system: agentCustomInstructions, diff --git a/lib/agent/__tests__/buildAgentTools.test.ts b/lib/agent/__tests__/buildAgentTools.test.ts index 5478c59ca..fb5d99a5a 100644 --- a/lib/agent/__tests__/buildAgentTools.test.ts +++ b/lib/agent/__tests__/buildAgentTools.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildAgentTools } from "@/lib/agent/buildAgentTools"; -const EXPECTED_TOOL_NAMES = [ +const BASE_TOOLS = [ "bash", "read", "write", @@ -13,19 +13,50 @@ const EXPECTED_TOOL_NAMES = [ ] as const; describe("buildAgentTools", () => { - it("returns a tools record with all 8 leaf tools registered", () => { + it("returns the 8 leaf tools by default (no skill registered when skills list is empty)", () => { const tools = buildAgentTools(); - for (const name of EXPECTED_TOOL_NAMES) { + for (const name of BASE_TOOLS) { expect(tools).toHaveProperty(name); } + expect(tools).not.toHaveProperty("skill"); + }); + + it("registers the skill tool when a non-empty skill catalog is provided", () => { + const tools = buildAgentTools({ + skills: [ + { + name: "commit", + description: "Make a commit", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + options: {}, + }, + ], + }); + expect(tools).toHaveProperty("skill"); + for (const name of BASE_TOOLS) { + expect(tools).toHaveProperty(name); + } + }); + + it("omits the skill tool when an empty array is passed", () => { + const tools = buildAgentTools({ skills: [] }); + expect(tools).not.toHaveProperty("skill"); }); it("each tool exposes the AI SDK shape (description + inputSchema + execute)", () => { - const tools = buildAgentTools() as Record< - string, - { description?: unknown; inputSchema?: unknown; execute?: unknown } - >; - for (const name of EXPECTED_TOOL_NAMES) { + const tools = buildAgentTools({ + skills: [ + { + name: "foo", + description: "x", + path: "/p", + filename: "SKILL.md", + options: {}, + }, + ], + }) as Record; + for (const name of [...BASE_TOOLS, "skill"]) { const t = tools[name]!; expect(typeof t.description).toBe("string"); expect(t.inputSchema).toBeDefined(); diff --git a/lib/agent/buildAgentTools.ts b/lib/agent/buildAgentTools.ts index f9cbc2b39..393b32889 100644 --- a/lib/agent/buildAgentTools.ts +++ b/lib/agent/buildAgentTools.ts @@ -6,24 +6,27 @@ import { grepTool } from "@/lib/agent/tools/grepTool"; import { globTool } from "@/lib/agent/tools/globTool"; import { todoWriteTool } from "@/lib/agent/tools/todoWriteTool"; import { webFetchTool } from "@/lib/agent/tools/webFetchTool"; +import { skillTool } from "@/lib/agent/tools/skillTool"; +import type { SkillMetadata } from "@/lib/skills/skillTypes"; /** * Factory for the full agent tool set passed into `streamText({ tools })`. - * Each tool reads its sandbox handle + recoup creds from `experimental_context` - * at execute time — the factory takes no arguments because the tools are - * stateless modulo that context. + * Each tool reads its sandbox handle + per-prompt context from + * `experimental_context` at execute time — the factory is otherwise stateless. * - * Currently ships 8 leaf tools: - * - bash, read, write, edit, grep, glob (sandbox / file ops) + * Currently ships 9 tools: + * - 6 file/shell: bash, read, write, edit, grep, glob * - todo_write (planning surface; stateless, echoes the list back) * - web_fetch (HTTP via curl inside the sandbox) + * - skill (load a project-level skill's SKILL.md; only registered when the + * sandbox has skills available, so models without any skill catalog + * don't see the tool at all and never call it speculatively) * - * Composite tools (`task` subagent, `ask_user_question` UI part, - * `skill` skill discovery) port in a follow-up PR — they require - * subagent context plumbing / UI rendering / skill discovery infra - * that isn't in api today. + * @param options.skills - Discovered skill catalog. When empty / undefined, + * `skill` is omitted from the tool record so the model doesn't see it. */ -export function buildAgentTools() { +export function buildAgentTools(options: { skills?: SkillMetadata[] } = {}) { + const hasSkills = (options.skills?.length ?? 0) > 0; return { bash: bashTool, read: readFileTool, @@ -33,6 +36,7 @@ export function buildAgentTools() { glob: globTool, todo_write: todoWriteTool, web_fetch: webFetchTool, + ...(hasSkills ? { skill: skillTool } : {}), }; } diff --git a/lib/agent/tools/AgentContext.ts b/lib/agent/tools/AgentContext.ts index 63d2a1b7e..acb455164 100644 --- a/lib/agent/tools/AgentContext.ts +++ b/lib/agent/tools/AgentContext.ts @@ -1,4 +1,5 @@ import type { VercelState } from "@/lib/sandbox/vercel/state"; +import type { SkillMetadata } from "@/lib/skills/skillTypes"; /** * Per-tool-call context threaded into the agent via `streamText`'s @@ -31,4 +32,14 @@ export type AgentContext = { * Public information — no security risk in exposing. */ recoupOrgId?: string; + /** + * Skills discovered in the sandbox before workflow start (handler + * calls `discoverSkills(sandbox, getSandboxSkillDirectories(sandbox))`). + * The `skillTool` reads this list to: + * - resolve names → SKILL.md paths + * - filter out skills with `disable-model-invocation` + * - surface "Available skills" hints when a model picks an unknown name + * Empty / undefined when the sandbox has no `skills/` directory. + */ + skills?: SkillMetadata[]; }; diff --git a/lib/agent/tools/__tests__/skillTool.test.ts b/lib/agent/tools/__tests__/skillTool.test.ts new file mode 100644 index 000000000..0b3196dbc --- /dev/null +++ b/lib/agent/tools/__tests__/skillTool.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { skillTool } from "@/lib/agent/tools/skillTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const baseCtx = { + sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" }, +}; + +function makeSandbox(readFile: ReturnType) { + return { workingDirectory: "/sandbox/mono", readFile }; +} + +function skillMd(body: string) { + return `---\nname: commit\ndescription: Make a commit\n---\n\n${body}`; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("skillTool", () => { + it("returns success:false with available skills when the requested skill isn't in context", async () => { + vi.mocked(connectVercel).mockResolvedValue(makeSandbox(vi.fn()) as never); + const result = (await skillTool.execute!({ skill: "unknown" }, { + experimental_context: { + ...baseCtx, + skills: [ + { + name: "commit", + description: "Make a commit", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + options: {}, + }, + { + name: "deploy", + description: "Deploy", + path: "/sandbox/mono/skills/deploy", + filename: "SKILL.md", + options: {}, + }, + ], + }, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/Available skills: commit, deploy/); + }); + + it("returns success:false when no skills are loaded", async () => { + vi.mocked(connectVercel).mockResolvedValue(makeSandbox(vi.fn()) as never); + const result = (await skillTool.execute!({ skill: "commit" }, { + experimental_context: { ...baseCtx, skills: [] }, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/Available skills: none/); + }); + + it("matches the skill name case-insensitively (slash-command behavior)", async () => { + const sb = makeSandbox(vi.fn().mockResolvedValue(skillMd("body content"))); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await skillTool.execute!( + { skill: "COMMIT" }, // model typed it loud + { + experimental_context: { + ...baseCtx, + skills: [ + { + name: "commit", + description: "x", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + options: {}, + }, + ], + }, + } as never, + )) as { success: boolean; skillName: string }; + expect(result.success).toBe(true); + expect(result.skillName).toBe("COMMIT"); + }); + + it("returns the SKILL.md body with skill directory injected", async () => { + const sb = makeSandbox(vi.fn().mockResolvedValue(skillMd("Run git commit -m ..."))); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await skillTool.execute!({ skill: "commit" }, { + experimental_context: { + ...baseCtx, + skills: [ + { + name: "commit", + description: "x", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + options: {}, + }, + ], + }, + } as never)) as { success: boolean; content: string; skillPath: string }; + expect(result.success).toBe(true); + expect(result.skillPath).toBe("/sandbox/mono/skills/commit"); + expect(result.content).toContain("Skill directory: /sandbox/mono/skills/commit"); + expect(result.content).toContain("Run git commit -m ..."); + expect(sb.readFile).toHaveBeenCalledWith("/sandbox/mono/skills/commit/SKILL.md", "utf-8"); + }); + + it("substitutes $ARGUMENTS in the skill body when args are provided", async () => { + const sb = makeSandbox(vi.fn().mockResolvedValue(skillMd('git commit -m "$ARGUMENTS"'))); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await skillTool.execute!({ skill: "commit", args: "fix bug" }, { + experimental_context: { + ...baseCtx, + skills: [ + { + name: "commit", + description: "x", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + options: {}, + }, + ], + }, + } as never)) as { content: string }; + expect(result.content).toContain('git commit -m "fix bug"'); + expect(result.content).not.toContain("$ARGUMENTS"); + }); + + it("rejects skills with disable-model-invocation set", async () => { + vi.mocked(connectVercel).mockResolvedValue(makeSandbox(vi.fn()) as never); + const result = (await skillTool.execute!({ skill: "internal" }, { + experimental_context: { + ...baseCtx, + skills: [ + { + name: "internal", + description: "x", + path: "/sandbox/mono/skills/internal", + filename: "SKILL.md", + options: { disableModelInvocation: true }, + }, + ], + }, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/cannot be invoked/); + }); + + it("returns success:false when the SKILL.md read fails", async () => { + const sb = makeSandbox(vi.fn().mockRejectedValue(new Error("ENOENT"))); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await skillTool.execute!({ skill: "commit" }, { + experimental_context: { + ...baseCtx, + skills: [ + { + name: "commit", + description: "x", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + options: {}, + }, + ], + }, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/ENOENT/); + }); +}); diff --git a/lib/agent/tools/skillTool.ts b/lib/agent/tools/skillTool.ts new file mode 100644 index 000000000..8c74f35d1 --- /dev/null +++ b/lib/agent/tools/skillTool.ts @@ -0,0 +1,87 @@ +import * as path from "path"; +import { tool } from "ai"; +import { z } from "zod"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { extractSkillBody } from "@/lib/skills/extractSkillBody"; +import { getSkills } from "@/lib/skills/getSkills"; +import { injectSkillDirectory } from "@/lib/skills/injectSkillDirectory"; +import { substituteArguments } from "@/lib/skills/substituteArguments"; + +const skillInputSchema = z.object({ + skill: z.string().describe("The skill name to invoke"), + args: z.string().optional().describe("Optional arguments for the skill"), +}); + +/** + * `skill` — load a project-level skill's SKILL.md body and return it + * to the model. The model then follows the loaded instructions in + * subsequent turns (using `bash`, `read`, `write`, etc. to actually + * carry them out). The skill catalog itself is discovered in the + * handler before workflow start and threaded via `AgentContext.skills`. + * + * Matching is case-insensitive so the model can resolve a slash command + * like `/Commit` against a skill named `commit`. Skills marked with + * `disable-model-invocation` in their frontmatter are filtered out at + * the gate — only the user (via a server-side dispatcher) can run them. + */ +export const skillTool = tool({ + description: `Execute a skill within the main conversation. + +When users ask you to perform tasks, check if any of the available skills can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +When users ask you to run a "slash command" or reference "/" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke the corresponding skill. + +How to invoke: +- Use this tool with the skill name and optional arguments +- Examples: + - skill: "pdf" — invoke the pdf skill + - skill: "commit", args: "-m 'Fix bug'" — invoke with arguments + +Important: +- When a skill is relevant, invoke this tool IMMEDIATELY as your first action +- When the user's message starts with "/", they are invoking a skill — call this tool FIRST before any other tool +- NEVER just announce or mention a skill without actually calling this tool +- Only use skills listed in "Available skills" in your system prompt`, + inputSchema: skillInputSchema, + execute: async ({ skill, args }, { experimental_context }) => { + const sandbox = await getSandbox(experimental_context, "skill"); + const skills = getSkills(experimental_context); + + const normalized = skill.toLowerCase(); + const found = skills.find(s => s.name.toLowerCase() === normalized); + if (!found) { + const available = skills.map(s => s.name).join(", "); + return { + success: false, + error: `Skill '${skill}' not found. Available skills: ${available || "none"}`, + }; + } + + if (found.options.disableModelInvocation) { + return { + success: false, + error: `Skill '${skill}' cannot be invoked by the model (disable-model-invocation is set)`, + }; + } + + const skillFilePath = path.join(found.path, found.filename); + let fileContent: string; + try { + fileContent = await sandbox.readFile(skillFilePath, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to read skill file: ${message}` }; + } + + const body = extractSkillBody(fileContent); + const bodyWithDir = injectSkillDirectory(body, found.path); + const content = substituteArguments(bodyWithDir, args); + + return { + success: true, + skillName: skill, + skillPath: found.path, + content, + }; + }, +}); diff --git a/lib/chat/__tests__/handleChatWorkflowStream.test.ts b/lib/chat/__tests__/handleChatWorkflowStream.test.ts index fb3b434f1..702edb918 100644 --- a/lib/chat/__tests__/handleChatWorkflowStream.test.ts +++ b/lib/chat/__tests__/handleChatWorkflowStream.test.ts @@ -39,6 +39,19 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); vi.mock("@/lib/uuid/generateUUID", () => ({ default: vi.fn(() => "deterministic-uuid") })); +// Stub sandbox connection + skill discovery so handler tests don't actually +// try to talk to Vercel Sandbox / parse SKILL.md files. The handler treats +// discovery failures as non-fatal (empty catalog), but we mock to keep tests fast. +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(async () => ({ workingDirectory: "/sandbox/mono" })), +})); +vi.mock("@/lib/skills/discoverSkills", () => ({ + discoverSkills: vi.fn(async () => []), +})); +vi.mock("@/lib/skills/getSandboxSkillDirectories", () => ({ + getSandboxSkillDirectories: vi.fn(() => ["/sandbox/mono/skills"]), +})); + const ACCOUNT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; const OTHER_ACCOUNT_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; const SESSION_ID = "22222222-2222-2222-2222-222222222222"; diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 6ceb0c867..818c70f8c 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -15,7 +15,10 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; import { extractOrgId } from "@/lib/recoupable/extractOrgId"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; import type { VercelState } from "@/lib/sandbox/vercel/state"; +import { discoverSkills } from "@/lib/skills/discoverSkills"; +import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; import generateUUID from "@/lib/uuid/generateUUID"; const DEFAULT_MODEL_ID = "anthropic/claude-haiku-4.5"; @@ -90,6 +93,23 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise> = []; + try { + const sandbox = await connectVercel(session.sandbox_state as VercelState); + const dirs = await getSandboxSkillDirectories(sandbox); + skills = await discoverSkills(sandbox, dirs); + } catch (error) { + console.error( + "[handleChatWorkflowStream] skill discovery failed; continuing with empty catalog:", + error, + ); + } + const run = await start(runAgentWorkflow, [ { messages: validated.messages, @@ -105,6 +125,7 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise isDir, isFile: () => !isDir, size: 0, mtimeMs: 0 }; +} + +function makeDirent(name: string, isDir: boolean) { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isSymbolicLink: () => false, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + }; +} + +function frontmatter(name: string, description: string, extra = "") { + return `---\nname: ${name}\ndescription: ${description}\n${extra}---\n\nBody for ${name}`; +} + +function makeSandbox() { + const files = new Map(); + return { + files, + workingDirectory: "/sandbox/mono", + stat: vi.fn(async (path: string) => { + if (path.endsWith("/skills")) return makeStat(true); + if (path.startsWith("/sandbox/mono/skills/") && !path.endsWith(".md")) return makeStat(true); + throw new Error(`ENOENT: ${path}`); + }), + readdir: vi.fn(), + access: vi.fn(async (path: string) => { + if (!files.has(path)) throw new Error(`ENOENT: ${path}`); + }), + readFile: vi.fn(async (path: string) => { + const content = files.get(path); + if (content === undefined) throw new Error(`ENOENT: ${path}`); + return content; + }), + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("discoverSkills", () => { + it("discovers a single skill with name + description + path", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([makeDirent("commit", true)]); + sb.files.set("/sandbox/mono/skills/commit/SKILL.md", frontmatter("commit", "Make a commit")); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + name: "commit", + description: "Make a commit", + path: "/sandbox/mono/skills/commit", + filename: "SKILL.md", + }); + }); + + it("falls back to lowercase skill.md when SKILL.md is missing", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([makeDirent("lowercase", true)]); + sb.files.set("/sandbox/mono/skills/lowercase/skill.md", frontmatter("lowercase", "lc")); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills).toHaveLength(1); + expect(skills[0]?.filename).toBe("skill.md"); + }); + + it("returns [] when the directory does not exist", async () => { + const sb = makeSandbox(); + sb.stat.mockRejectedValue(new Error("ENOENT")); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills).toEqual([]); + }); + + it("skips entries that aren't directories", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([makeDirent("README.md", false), makeDirent("good", true)]); + sb.files.set("/sandbox/mono/skills/good/SKILL.md", frontmatter("good", "yes")); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe("good"); + }); + + it("skips subdirs without SKILL.md / skill.md", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([makeDirent("empty", true), makeDirent("real", true)]); + sb.files.set("/sandbox/mono/skills/real/SKILL.md", frontmatter("real", "yes")); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe("real"); + }); + + it("skips skills with invalid frontmatter (missing required fields)", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([makeDirent("broken", true), makeDirent("ok", true)]); + sb.files.set("/sandbox/mono/skills/broken/SKILL.md", "---\nname: broken\n---\nno desc"); + sb.files.set("/sandbox/mono/skills/ok/SKILL.md", frontmatter("ok", "yes")); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe("ok"); + }); + + it("skips skills whose names shadow built-in commands (model / resume / new)", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([ + makeDirent("model", true), + makeDirent("resume", true), + makeDirent("new", true), + makeDirent("kept", true), + ]); + for (const name of ["model", "resume", "new", "kept"]) { + sb.files.set(`/sandbox/mono/skills/${name}/SKILL.md`, frontmatter(name, "x")); + } + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills.map(s => s.name)).toEqual(["kept"]); + }); + + it("dedupes by name across multiple directories (first wins, case-insensitive)", async () => { + const sb = makeSandbox(); + sb.readdir.mockImplementation(async (dir: string) => { + if (dir === "/sandbox/mono/skills") return [makeDirent("Foo", true)] as never; + if (dir === "/global/.skills") return [makeDirent("foo", true)] as never; + return []; + }); + sb.files.set("/sandbox/mono/skills/Foo/SKILL.md", frontmatter("Foo", "project")); + sb.files.set("/global/.skills/foo/SKILL.md", frontmatter("foo", "global")); + sb.stat.mockImplementation(async (p: string) => { + if (p === "/sandbox/mono/skills" || p === "/global/.skills") return makeStat(true); + throw new Error("ENOENT"); + }); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills", "/global/.skills"]); + expect(skills).toHaveLength(1); + expect(skills[0]?.description).toBe("project"); // first dir wins + }); + + it("populates options from frontmatter (camelCase + split lists)", async () => { + const sb = makeSandbox(); + sb.readdir.mockResolvedValue([makeDirent("scoped", true)]); + sb.files.set( + "/sandbox/mono/skills/scoped/SKILL.md", + frontmatter( + "scoped", + "limited", + "allowed-tools: bash, read\ndisable-model-invocation: true\n", + ), + ); + const skills = await discoverSkills(sb as never, ["/sandbox/mono/skills"]); + expect(skills[0]?.options).toEqual({ + disableModelInvocation: true, + allowedTools: ["bash", "read"], + }); + }); +}); diff --git a/lib/skills/__tests__/extractSkillBody.test.ts b/lib/skills/__tests__/extractSkillBody.test.ts new file mode 100644 index 000000000..b8f62bbc8 --- /dev/null +++ b/lib/skills/__tests__/extractSkillBody.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { extractSkillBody } from "@/lib/skills/extractSkillBody"; + +describe("extractSkillBody", () => { + it("strips YAML frontmatter and returns the body", () => { + const md = "---\nname: foo\ndescription: bar\n---\n# Heading\n\nBody."; + expect(extractSkillBody(md)).toBe("# Heading\n\nBody."); + }); + + it("returns the full content when no frontmatter is present", () => { + expect(extractSkillBody("# Just a heading")).toBe("# Just a heading"); + }); + + it("trims surrounding whitespace", () => { + expect(extractSkillBody("---\nname: x\ndescription: y\n---\n\n\nbody\n\n")).toBe("body"); + }); + + it("tolerates Windows-style CRLF line endings", () => { + const md = "---\r\nname: foo\r\ndescription: bar\r\n---\r\nbody"; + expect(extractSkillBody(md)).toBe("body"); + }); +}); diff --git a/lib/skills/__tests__/findSkillFile.test.ts b/lib/skills/__tests__/findSkillFile.test.ts new file mode 100644 index 000000000..2d15de6fa --- /dev/null +++ b/lib/skills/__tests__/findSkillFile.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { findSkillFile } from "@/lib/skills/findSkillFile"; + +beforeEach(() => vi.clearAllMocks()); + +function makeSandbox(existing: string[]) { + const set = new Set(existing); + return { + access: vi.fn(async (p: string) => { + if (!set.has(p)) throw new Error(`ENOENT: ${p}`); + }), + }; +} + +describe("findSkillFile", () => { + it("prefers uppercase SKILL.md when both casings exist", async () => { + const sb = makeSandbox(["/skills/foo/SKILL.md", "/skills/foo/skill.md"]); + const result = await findSkillFile(sb as never, "/skills/foo"); + expect(result).toBe("/skills/foo/SKILL.md"); + expect(sb.access).toHaveBeenCalledWith("/skills/foo/SKILL.md"); + }); + + it("falls back to lowercase skill.md when SKILL.md is missing", async () => { + const sb = makeSandbox(["/skills/foo/skill.md"]); + const result = await findSkillFile(sb as never, "/skills/foo"); + expect(result).toBe("/skills/foo/skill.md"); + }); + + it("returns null when neither casing exists", async () => { + const sb = makeSandbox([]); + const result = await findSkillFile(sb as never, "/skills/foo"); + expect(result).toBeNull(); + }); +}); diff --git a/lib/skills/__tests__/getGlobalSkillsDirectory.test.ts b/lib/skills/__tests__/getGlobalSkillsDirectory.test.ts new file mode 100644 index 000000000..7833f2450 --- /dev/null +++ b/lib/skills/__tests__/getGlobalSkillsDirectory.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { getGlobalSkillsDirectory } from "@/lib/skills/getGlobalSkillsDirectory"; + +describe("getGlobalSkillsDirectory", () => { + it("returns /.agents/skills", () => { + expect(getGlobalSkillsDirectory("/root")).toBe("/root/.agents/skills"); + expect(getGlobalSkillsDirectory("/home/vercel-sandbox")).toBe( + "/home/vercel-sandbox/.agents/skills", + ); + }); + + it("handles trailing slash on input", () => { + expect(getGlobalSkillsDirectory("/root/")).toBe("/root/.agents/skills"); + }); +}); diff --git a/lib/skills/__tests__/getSandboxSkillDirectories.test.ts b/lib/skills/__tests__/getSandboxSkillDirectories.test.ts new file mode 100644 index 000000000..5762ccea1 --- /dev/null +++ b/lib/skills/__tests__/getSandboxSkillDirectories.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; +import { resolveSandboxHomeDirectory } from "@/lib/sandbox/resolveSandboxHomeDirectory"; + +vi.mock("@/lib/sandbox/resolveSandboxHomeDirectory", () => ({ + resolveSandboxHomeDirectory: vi.fn(), +})); + +beforeEach(() => vi.clearAllMocks()); + +describe("getSandboxSkillDirectories", () => { + it("returns just the global skill dir under the resolved $HOME", async () => { + vi.mocked(resolveSandboxHomeDirectory).mockResolvedValue("/home/vercel-sandbox"); + const dirs = await getSandboxSkillDirectories({ workingDirectory: "/sandbox/mono" } as never); + expect(dirs).toEqual(["/home/vercel-sandbox/.agents/skills"]); + }); + + it("works with the /root fallback (open-agents base image)", async () => { + vi.mocked(resolveSandboxHomeDirectory).mockResolvedValue("/root"); + const dirs = await getSandboxSkillDirectories({ workingDirectory: "/x" } as never); + expect(dirs).toEqual(["/root/.agents/skills"]); + }); +}); diff --git a/lib/skills/__tests__/getSkills.test.ts b/lib/skills/__tests__/getSkills.test.ts new file mode 100644 index 000000000..8ffd47e24 --- /dev/null +++ b/lib/skills/__tests__/getSkills.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { getSkills } from "@/lib/skills/getSkills"; + +const validCtx = { + sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" }, +}; + +const sample = { + name: "recoup-api", + description: "Recoupable API skill", + path: "/home/vercel-sandbox/.agents/skills/recoup-api", + filename: "SKILL.md", + options: {}, +}; + +describe("getSkills", () => { + it("returns the skills array when present in a valid AgentContext", () => { + expect(getSkills({ ...validCtx, skills: [sample] })).toEqual([sample]); + }); + + it("returns [] when no skills field is set", () => { + expect(getSkills(validCtx)).toEqual([]); + }); + + it("returns [] for malformed contexts (non-AgentContext shape)", () => { + expect(getSkills(undefined)).toEqual([]); + expect(getSkills(null)).toEqual([]); + expect(getSkills({ noSandbox: true })).toEqual([]); + expect(getSkills({ sandbox: null })).toEqual([]); + }); +}); diff --git a/lib/skills/__tests__/injectSkillDirectory.test.ts b/lib/skills/__tests__/injectSkillDirectory.test.ts new file mode 100644 index 000000000..ac6d646bb --- /dev/null +++ b/lib/skills/__tests__/injectSkillDirectory.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { injectSkillDirectory } from "@/lib/skills/injectSkillDirectory"; + +describe("injectSkillDirectory", () => { + it("prepends a `Skill directory: ` header followed by a blank line", () => { + expect(injectSkillDirectory("body content", "/skills/foo")).toBe( + "Skill directory: /skills/foo\n\nbody content", + ); + }); + + it("works with empty body", () => { + expect(injectSkillDirectory("", "/skills/foo")).toBe("Skill directory: /skills/foo\n\n"); + }); +}); diff --git a/lib/skills/__tests__/parseSkillFrontmatter.test.ts b/lib/skills/__tests__/parseSkillFrontmatter.test.ts new file mode 100644 index 000000000..91dfcf7c1 --- /dev/null +++ b/lib/skills/__tests__/parseSkillFrontmatter.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { parseSkillFrontmatter } from "@/lib/skills/parseSkillFrontmatter"; + +describe("parseSkillFrontmatter", () => { + it("parses a minimal frontmatter (name + description)", () => { + const md = `---\nname: commit\ndescription: Make a git commit\n---\n\nBody.`; + const result = parseSkillFrontmatter(md); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.name).toBe("commit"); + expect(result.data.description).toBe("Make a git commit"); + }); + + it("unwraps double-quoted values (including escaped quotes)", () => { + const md = `---\nname: foo\ndescription: "Has \\"quotes\\" inside"\n---\nbody`; + const result = parseSkillFrontmatter(md); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.description).toBe('Has "quotes" inside'); + }); + + it("parses booleans for unquoted true/false", () => { + const md = `---\nname: foo\ndescription: bar\ndisable-model-invocation: true\nuser-invocable: false\n---\nbody`; + const result = parseSkillFrontmatter(md); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data["disable-model-invocation"]).toBe(true); + expect(result.data["user-invocable"]).toBe(false); + }); + + it("treats `true`/`false` inside quotes as strings (not booleans)", () => { + const md = `---\nname: foo\ndescription: "true"\n---\nbody`; + const result = parseSkillFrontmatter(md); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.description).toBe("true"); + }); + + it("returns success:false when frontmatter is missing", () => { + const result = parseSkillFrontmatter("just markdown, no frontmatter"); + expect(result.success).toBe(false); + }); + + it("returns success:false when required fields are absent", () => { + const result = parseSkillFrontmatter(`---\nname: only-name\n---\nbody`); + expect(result.success).toBe(false); + }); + + it("preserves colons in values (e.g. URLs)", () => { + const md = `---\nname: foo\ndescription: see https://example.com\n---\nbody`; + const result = parseSkillFrontmatter(md); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.description).toBe("see https://example.com"); + }); +}); diff --git a/lib/skills/__tests__/substituteArguments.test.ts b/lib/skills/__tests__/substituteArguments.test.ts new file mode 100644 index 000000000..db4fb0aa9 --- /dev/null +++ b/lib/skills/__tests__/substituteArguments.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { substituteArguments } from "@/lib/skills/substituteArguments"; + +describe("substituteArguments", () => { + it("replaces $ARGUMENTS with the provided args", () => { + expect(substituteArguments("run with $ARGUMENTS", "--flag value")).toBe( + "run with --flag value", + ); + }); + + it("replaces all occurrences", () => { + expect(substituteArguments("$ARGUMENTS / $ARGUMENTS", "x")).toBe("x / x"); + }); + + it("substitutes empty string when args are undefined", () => { + expect(substituteArguments("run with $ARGUMENTS", undefined)).toBe("run with "); + }); + + it("leaves text unchanged when $ARGUMENTS is absent", () => { + expect(substituteArguments("no placeholder here", "ignored")).toBe("no placeholder here"); + }); +}); diff --git a/lib/skills/discoverSkills.ts b/lib/skills/discoverSkills.ts new file mode 100644 index 000000000..9ae0ced67 --- /dev/null +++ b/lib/skills/discoverSkills.ts @@ -0,0 +1,89 @@ +import * as path from "path"; +import type { Sandbox } from "@/lib/sandbox/interface"; +import { findSkillFile } from "@/lib/skills/findSkillFile"; +import { parseSkillFrontmatter } from "@/lib/skills/parseSkillFrontmatter"; +import { frontmatterToOptions, type SkillMetadata } from "@/lib/skills/skillTypes"; + +/** + * Built-in commands that skills cannot shadow. Skills with these names + * would be unreachable via slash command, so we drop them at discovery. + */ +const BUILTIN_COMMANDS = ["model", "resume", "new"]; + +/** + * Scan a list of directories for skills. Each directory is expected to + * contain one subdirectory per skill, with a SKILL.md (or skill.md) + * inside. Returns metadata for everything discoverable; silently skips + * non-directories, missing files, malformed frontmatter, and names that + * shadow built-in slash commands. + * + * Dedupes by name (case-insensitive); first-wins across directories so + * callers can list project skills before global skills and have project + * shadow global. + * + * @param sandbox - Connected sandbox for file ops. + * @param directories - Absolute paths to scan. + */ +export async function discoverSkills( + sandbox: Sandbox, + directories: string[], +): Promise { + const skills: SkillMetadata[] = []; + const seen = new Set(); + + for (const dir of directories) { + try { + const stat = await sandbox.stat(dir); + if (!stat.isDirectory()) continue; + } catch { + continue; // directory doesn't exist + } + + let entries; + try { + entries = await sandbox.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillDir = path.join(dir, entry.name); + const skillFile = await findSkillFile(sandbox, skillDir); + if (!skillFile) continue; + + let content: string; + try { + content = await sandbox.readFile(skillFile, "utf-8"); + } catch { + continue; + } + + const parsed = parseSkillFrontmatter(content); + if (!parsed.success) continue; + const frontmatter = parsed.data; + + if (BUILTIN_COMMANDS.includes(frontmatter.name.toLowerCase())) { + console.warn( + `[discoverSkills] Skipping "${frontmatter.name}" in ${skillDir} — name shadows built-in /${frontmatter.name}`, + ); + continue; + } + + const normalized = frontmatter.name.toLowerCase(); + if (seen.has(normalized)) continue; + seen.add(normalized); + + skills.push({ + name: frontmatter.name, + description: frontmatter.description, + path: skillDir, + filename: path.basename(skillFile), + options: frontmatterToOptions(frontmatter), + }); + } + } + + return skills; +} diff --git a/lib/skills/extractSkillBody.ts b/lib/skills/extractSkillBody.ts new file mode 100644 index 000000000..d1dcb3f5e --- /dev/null +++ b/lib/skills/extractSkillBody.ts @@ -0,0 +1,14 @@ +/** + * Strip the YAML frontmatter from a SKILL.md file and return just the + * markdown body. Returns the entire content (trimmed) when no + * frontmatter is present. + * + * @param fileContent - Full file content read from sandbox. + */ +export function extractSkillBody(fileContent: string): string { + const match = fileContent.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); + if (match) { + return fileContent.slice(match[0].length).trim(); + } + return fileContent.trim(); +} diff --git a/lib/skills/findSkillFile.ts b/lib/skills/findSkillFile.ts new file mode 100644 index 000000000..a81b9e415 --- /dev/null +++ b/lib/skills/findSkillFile.ts @@ -0,0 +1,33 @@ +import * as path from "path"; +import type { Sandbox } from "@/lib/sandbox/interface"; + +/** + * Locate the SKILL.md file inside a candidate skill directory. Prefers + * uppercase `SKILL.md` (the project convention) but falls back to + * lowercase `skill.md` for skills that ship the lowercase name. Returns + * `null` when neither file exists so callers can skip the entry. + * + * Probes via `sandbox.access` (which throws on missing) rather than + * `readdir` so we don't pay the cost of listing a directory whose + * contents we don't otherwise need. + * + * @param sandbox - Connected sandbox handle. + * @param skillDir - Absolute path to the candidate skill directory. + */ +export async function findSkillFile(sandbox: Sandbox, skillDir: string): Promise { + const uppercase = path.join(skillDir, "SKILL.md"); + const lowercase = path.join(skillDir, "skill.md"); + + try { + await sandbox.access(uppercase); + return uppercase; + } catch { + // try lowercase + } + try { + await sandbox.access(lowercase); + return lowercase; + } catch { + return null; + } +} diff --git a/lib/skills/getGlobalSkillsDirectory.ts b/lib/skills/getGlobalSkillsDirectory.ts new file mode 100644 index 000000000..788a6dfc7 --- /dev/null +++ b/lib/skills/getGlobalSkillsDirectory.ts @@ -0,0 +1,14 @@ +import * as path from "path"; + +/** + * Resolve the absolute path to the global skills directory under a + * given `$HOME`. This is where `installSessionGlobalSkills` lays down + * skills at sandbox provisioning time via `npx skills add ... -g` + * (today: `recoup-api`, `artist-workspace`). + * + * @param homeDirectory - The sandbox's resolved $HOME (e.g. + * `/home/vercel-sandbox`, or `/root` on the open-agents base image). + */ +export function getGlobalSkillsDirectory(homeDirectory: string): string { + return path.posix.join(homeDirectory, ".agents", "skills"); +} diff --git a/lib/skills/getSandboxSkillDirectories.ts b/lib/skills/getSandboxSkillDirectories.ts new file mode 100644 index 000000000..81645ea46 --- /dev/null +++ b/lib/skills/getSandboxSkillDirectories.ts @@ -0,0 +1,16 @@ +import type { Sandbox } from "@/lib/sandbox/interface"; +import { resolveSandboxHomeDirectory } from "@/lib/sandbox/resolveSandboxHomeDirectory"; +import { getGlobalSkillsDirectory } from "@/lib/skills/getGlobalSkillsDirectory"; + +/** + * Resolve the directory list to scan when discovering skills for a + * sandbox. Currently just one path — `${HOME}/.agents/skills/` — + * because all skills are provisioned globally at sandbox startup via + * `installSessionGlobalSkills` rather than bundled into the cloned repo. + * + * @param sandbox - Connected sandbox handle. + */ +export async function getSandboxSkillDirectories(sandbox: Sandbox): Promise { + const homeDirectory = await resolveSandboxHomeDirectory(sandbox); + return [getGlobalSkillsDirectory(homeDirectory)]; +} diff --git a/lib/skills/getSkills.ts b/lib/skills/getSkills.ts new file mode 100644 index 000000000..d2d29ed7d --- /dev/null +++ b/lib/skills/getSkills.ts @@ -0,0 +1,22 @@ +import { isAgentContext } from "@/lib/agent/tools/isAgentContext"; +import type { SkillMetadata } from "@/lib/skills/skillTypes"; + +/** + * Read the discovered skill catalog out of the agent's + * `experimental_context`. The catalog is populated by the chat handler + * via `discoverSkills(sandbox, getSandboxSkillDirectories(sandbox))` + * before workflow start, then threaded through as + * `AgentContext.skills`. Returns `[]` when the context shape is wrong + * or no skills were discovered. + * + * Lives in its own file so consumers (the `skill` tool today, future + * skill-aware system prompts tomorrow) share one accessor instead of + * each reimplementing the context-cast. + * + * @param experimental_context - Opaque context object passed by AI SDK to tool execute. + */ +export function getSkills(experimental_context: unknown): SkillMetadata[] { + if (!isAgentContext(experimental_context)) return []; + const ctx = experimental_context as { skills?: SkillMetadata[] }; + return ctx.skills ?? []; +} diff --git a/lib/skills/injectSkillDirectory.ts b/lib/skills/injectSkillDirectory.ts new file mode 100644 index 000000000..cf4bf58d5 --- /dev/null +++ b/lib/skills/injectSkillDirectory.ts @@ -0,0 +1,11 @@ +/** + * Prepend a `Skill directory: ` header to a skill body + * so the model can construct full paths to scripts and resources living + * alongside SKILL.md (e.g. `${skillDir}/scripts/check.sh`). + * + * @param body - Skill body (after frontmatter strip). + * @param skillDir - Absolute sandbox path to the skill directory. + */ +export function injectSkillDirectory(body: string, skillDir: string): string { + return `Skill directory: ${skillDir}\n\n${body}`; +} diff --git a/lib/skills/parseSkillFrontmatter.ts b/lib/skills/parseSkillFrontmatter.ts new file mode 100644 index 000000000..3d2888d76 --- /dev/null +++ b/lib/skills/parseSkillFrontmatter.ts @@ -0,0 +1,52 @@ +import { skillFrontmatterSchema } from "@/lib/skills/skillTypes"; + +/** + * Parse YAML frontmatter from SKILL.md content. Returns the Zod + * `safeParse` shape so callers can branch cleanly on success. + * + * Intentionally a hand-rolled subset of YAML (one-line `key: value` + * with `"…"` / `'…'` quoting + unquoted `true`/`false`) so we don't + * pull a YAML dep just to read a 3-line block. + * + * @param content - Full SKILL.md content (including the leading `---`). + */ +export function parseSkillFrontmatter( + content: string, +): ReturnType { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match?.[1]) { + return { + success: false, + error: new Error("No frontmatter found") as never, + }; + } + + const yaml = match[1]; + const parsed: Record = {}; + + for (const line of yaml.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const colonIndex = trimmed.indexOf(":"); + if (colonIndex === -1) continue; + + const key = trimmed.slice(0, colonIndex).trim(); + // Only split on the first colon so values like URLs stay intact. + let value: string | boolean = trimmed.slice(colonIndex + 1).trim(); + + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1).replace(/\\"/g, '"'); + } else if (value.startsWith("'") && value.endsWith("'")) { + value = value.slice(1, -1).replace(/\\'/g, "'"); + } else if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } + + parsed[key] = value; + } + + return skillFrontmatterSchema.safeParse(parsed); +} diff --git a/lib/skills/skillTypes.ts b/lib/skills/skillTypes.ts new file mode 100644 index 000000000..77fffd055 --- /dev/null +++ b/lib/skills/skillTypes.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +/** + * Zod schema for skill frontmatter YAML validation. Defines the + * expected structure at the top of SKILL.md files. + */ +export const skillFrontmatterSchema = z.object({ + name: z.string().min(1, "Skill name cannot be empty").describe("Unique name of the skill"), + description: z + .string() + .min(1, "Skill description cannot be empty") + .describe("Short description for the agent"), + version: z.string().optional().describe("Skill version"), + "disable-model-invocation": z + .boolean() + .optional() + .describe("If true, the model cannot invoke this skill automatically"), + "user-invocable": z + .boolean() + .optional() + .describe("If false, users cannot invoke this skill via slash command"), + "allowed-tools": z + .string() + .optional() + .describe("Comma-separated list of allowed tools when skill is active"), + context: z.enum(["fork"]).optional().describe("Execution context for the skill"), + agent: z.string().optional().describe("Agent type to use for execution"), +}); + +export type SkillFrontmatter = z.infer; + +/** + * Normalized skill options derived from frontmatter — camelCase fields, + * comma-separated lists pre-split. + */ +export interface SkillOptions { + disableModelInvocation?: boolean; + userInvocable?: boolean; + allowedTools?: string[]; + context?: "fork"; + agent?: string; +} + +/** + * Skill metadata stored on `AgentContext.skills`. Contains only what + * `skillTool` needs at invocation time — the SKILL.md body is loaded + * lazily. + */ +export interface SkillMetadata { + /** Unique name of the skill. */ + name: string; + /** Short description for the agent. */ + description: string; + /** Absolute sandbox path to the skill directory. */ + path: string; + /** Filename of the skill file (`SKILL.md` or `skill.md`). */ + filename: string; + /** Skill options from frontmatter. */ + options: SkillOptions; +} + +/** + * Normalize parsed frontmatter to {@link SkillOptions}. + */ +export function frontmatterToOptions(frontmatter: SkillFrontmatter): SkillOptions { + return { + disableModelInvocation: frontmatter["disable-model-invocation"], + userInvocable: frontmatter["user-invocable"], + allowedTools: frontmatter["allowed-tools"] + ?.split(",") + .map(t => t.trim()) + .filter(Boolean), + context: frontmatter.context, + agent: frontmatter.agent, + }; +} diff --git a/lib/skills/substituteArguments.ts b/lib/skills/substituteArguments.ts new file mode 100644 index 000000000..44500bc58 --- /dev/null +++ b/lib/skills/substituteArguments.ts @@ -0,0 +1,14 @@ +/** + * Replace all occurrences of `$ARGUMENTS` in a skill body with the + * provided args string (or empty string when no args were passed). + * + * Used by `skillTool` after loading SKILL.md so slash-command-style + * invocations like `/commit -m "fix"` thread the arg suffix through to + * the skill's body text. + * + * @param body - Skill body (markdown after frontmatter). + * @param args - Optional arguments passed by the model. + */ +export function substituteArguments(body: string, args?: string): string { + return body.replace(/\$ARGUMENTS/g, args ?? ""); +}