Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"ollama-ai-provider-v2": "^1.5.4",
"openai": "^6.9.1",
"parse-duration": "^2.1.4",
"picomatch": "^4.0.3",
"posthog-node": "^5.17.0",
"quickjs-emscripten": "^0.31.0",
"quickjs-emscripten-core": "^0.31.0",
Expand Down Expand Up @@ -107,6 +108,7 @@
"@types/katex": "^0.16.7",
"@types/markdown-it": "^14.1.2",
"@types/minimist": "^1.2.5",
"@types/picomatch": "^4.0.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/turndown": "^5.0.6",
Expand Down Expand Up @@ -1395,6 +1397,8 @@

"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],

"@types/picomatch": ["@types/[email protected]", "", {}, "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA=="],

"@types/plist": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],

"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
Expand Down
3 changes: 3 additions & 0 deletions docs/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Optional fields:
- `license`
- `compatibility`
- `metadata` (string key/value map)
- `include_files` (glob patterns, relative to the skill directory, for files to inline when the skill is loaded)

Mux ignores unknown frontmatter keys (for example `allowed-tools`).

Expand All @@ -69,6 +70,8 @@ description: Build and validate a release branch.
license: MIT
metadata:
owner: platform
include_files:
- references/**/*.md
---

# My Skill
Expand Down
5 changes: 5 additions & 0 deletions docs/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ name: My Agent # Display name in UI

# Optional
description: What this agent does # Shown in tooltips

# Auto-include file context (workspace-root relative globs)
include_files:
- docs/**/*.md
- src/**/*.ts
base: exec # Inherit from another agent (exec, plan, or custom agent id)

# UI settings
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"ollama-ai-provider-v2": "^1.5.4",
"openai": "^6.9.1",
"parse-duration": "^2.1.4",
"picomatch": "^4.0.3",
"posthog-node": "^5.17.0",
"quickjs-emscripten": "^0.31.0",
"quickjs-emscripten-core": "^0.31.0",
Expand Down Expand Up @@ -147,6 +148,7 @@
"@types/katex": "^0.16.7",
"@types/markdown-it": "^14.1.2",
"@types/minimist": "^1.2.5",
"@types/picomatch": "^4.0.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/turndown": "^5.0.6",
Expand Down
3 changes: 3 additions & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export {
AgentSkillFrontmatterSchema,
AgentSkillPackageSchema,
AgentSkillScopeSchema,
IncludeFileGlobSchema,
IncludedFileSchema,
IncludeFilesContextSchema,
SkillNameSchema,
} from "./schemas/agentSkill";

Expand Down
10 changes: 9 additions & 1 deletion src/common/orpc/schemas/agentDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from "zod";

import { IncludeFileGlobSchema } from "./agentSkill";

export const AgentDefinitionScopeSchema = z.enum(["built-in", "project", "global"]);

// Agent IDs come from filenames (<agentId>.md).
Expand Down Expand Up @@ -46,7 +48,7 @@ const AgentDefinitionAiDefaultsSchema = z

const AgentDefinitionPromptSchema = z
.object({
// When true, append this agent's body to the base agent's body (default: false = replace)
// When true, append this agent's body to the base agent's body (default: true); set false to replace.
append: z.boolean().optional(),
})
.strip();
Expand All @@ -67,6 +69,12 @@ export const AgentDefinitionFrontmatterSchema = z
name: z.string().min(1).max(128),
description: z.string().min(1).max(1024).optional(),

/**
* Glob patterns for files to automatically include in context when this agent runs.
* Patterns are relative to the workspace root (where tools run).
* Example: ["docs/**", "src/**"]
*/
include_files: z.array(IncludeFileGlobSchema).max(20).optional(),
// Inheritance: reference a built-in or custom agent ID
base: AgentIdSchema.optional(),

Expand Down
50 changes: 50 additions & 0 deletions src/common/orpc/schemas/agentSkill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,35 @@ export const SkillNameSchema = z
.max(64)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);

/**
* Glob pattern for include_files.
* Patterns must be relative (no absolute paths, no ~, no ..).
*/
export const IncludeFileGlobSchema = z
.string()
.min(1)
.max(256)
.refine(
(pattern) =>
!pattern.startsWith("/") &&
!pattern.startsWith("~") &&
!pattern.includes("..") &&
!/^[A-Za-z]:[\\/]/.test(pattern),
{ message: "Pattern must be relative (no absolute paths, ~, or ..)" }
);

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(),
/**
* Glob patterns for files to automatically include in context when the skill is read.
* Patterns are relative to the skill directory (where SKILL.md lives).
* Example: ["examples/*.ts", "schemas/**\/*.json"]
*/
include_files: z.array(IncludeFileGlobSchema).max(20).optional(),
});

export const AgentSkillDescriptorSchema = z.object({
Expand All @@ -29,12 +52,39 @@ export const AgentSkillDescriptorSchema = z.object({
scope: AgentSkillScopeSchema,
});

/**
* Resolved file from include_files expansion.
*/
export const IncludedFileSchema = z.object({
/** Path relative to skill directory */
path: z.string(),
/** File content (may be truncated) */
content: z.string(),
/** Whether content was truncated due to size/line limits */
truncated: z.boolean(),
});

/**
* Context representation for files included via include_files.
* Rendered as XML using the `<@path>` tag format.
*/
export const IncludeFilesContextSchema = z.object({
/** Successfully resolved files */
files: z.array(IncludedFileSchema),
/** Patterns/files that had errors during resolution */
errors: z.array(z.object({ pattern: z.string(), error: z.string() })),
/** Pre-rendered XML context (for direct injection) */
rendered: z.string(),
});

export const AgentSkillPackageSchema = z
.object({
scope: AgentSkillScopeSchema,
directoryName: SkillNameSchema,
frontmatter: AgentSkillFrontmatterSchema,
body: z.string(),
/** Resolved include_files context (present when frontmatter.include_files is set) */
includeFilesContext: IncludeFilesContextSchema.optional(),
})
.refine((value) => value.directoryName === value.frontmatter.name, {
message: "SKILL.md frontmatter.name must match the parent directory name",
Expand Down
65 changes: 65 additions & 0 deletions src/node/services/agentDefinitions/agentDefinitionsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
discoverAgentDefinitions,
readAgentDefinition,
resolveAgentBody,
resolveAgentIncludeFilesPatterns,
} from "./agentDefinitionsService";

async function writeAgent(root: string, id: string, name: string): Promise<void> {
Expand Down Expand Up @@ -229,4 +230,68 @@ Project body.
expect(skippedPkg.scope).toBe("global");
expect(skippedPkg.frontmatter.name).toBe("Test Global");
});

test("resolveAgentIncludeFilesPatterns inherits by default, stops when prompt.append is false", async () => {
using tempDir = new DisposableTempDir("agent-include-files-patterns");
const agentsRoot = path.join(tempDir.path, ".mux", "agents");
await fs.mkdir(agentsRoot, { recursive: true });

await fs.writeFile(
path.join(agentsRoot, "base.md"),
`---
name: Base
include_files:
- "README.md"
- "src/*.ts"
---
Base instructions.
`,
"utf-8"
);

await fs.writeFile(
path.join(agentsRoot, "child.md"),
`---
name: Child
base: base
include_files:
- "src/*.ts"
- "docs/**/*.md"
---
Child instructions.
`,
"utf-8"
);

await fs.writeFile(
path.join(agentsRoot, "replacer.md"),
`---
name: Replacer
base: base
prompt:
append: false
include_files:
- "only-child.txt"
---
Replaced body.
`,
"utf-8"
);

const roots = { projectRoot: agentsRoot, globalRoot: agentsRoot };
const runtime = new LocalRuntime(tempDir.path);

const childPatterns = await resolveAgentIncludeFilesPatterns(runtime, tempDir.path, "child", {
roots,
});
expect(childPatterns).toEqual(["README.md", "src/*.ts", "docs/**/*.md"]);

const replacerPatterns = await resolveAgentIncludeFilesPatterns(
runtime,
tempDir.path,
"replacer",
{ roots }
);
expect(replacerPatterns).toEqual(["only-child.txt"]);
});
});
65 changes: 65 additions & 0 deletions src/node/services/agentDefinitions/agentDefinitionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,68 @@ export async function resolveAgentBody(

return resolve(agentId, 0);
}

/**
* Resolve include_files patterns for an agent, including inherited patterns.
*
* Inheritance behavior mirrors resolveAgentBody:
* - If `prompt.append` is false, we do NOT inherit include_files from the base.
* - Otherwise, base patterns are prepended (base first, then child).
*/
export async function resolveAgentIncludeFilesPatterns(
runtime: Runtime,
workspacePath: string,
agentId: AgentId,
options?: { roots?: AgentDefinitionsRoots }
): Promise<string[]> {
const visited = new Set<string>();

async function resolve(
id: AgentId,
depth: number,
skipScopesAbove?: AgentDefinitionScope
): Promise<string[]> {
if (depth > MAX_INHERITANCE_DEPTH) {
throw new Error(
`Agent inheritance depth exceeded for '${id}' (max: ${MAX_INHERITANCE_DEPTH})`
);
}

const pkg = await readAgentDefinition(runtime, workspacePath, id, {
roots: options?.roots,
skipScopesAbove,
});

const visitKey = agentVisitKey(pkg.id, pkg.scope);
if (visited.has(visitKey)) {
throw new Error(`Circular agent inheritance detected: ${pkg.id} (${pkg.scope})`);
}
visited.add(visitKey);

const baseId = pkg.frontmatter.base;
const shouldAppend = pkg.frontmatter.prompt?.append !== false;

const patterns = pkg.frontmatter.include_files ?? [];

if (!baseId || !shouldAppend) {
return patterns;
}

const basePatterns = await resolve(
baseId,
depth + 1,
computeBaseSkipScope(baseId, id, pkg.scope)
);
return [...basePatterns, ...patterns];
}

const patterns = await resolve(agentId, 0);

// Preserve order while deduplicating.
const seen = new Set<string>();
return patterns.filter((pattern) => {
if (seen.has(pattern)) return false;
seen.add(pattern);
return true;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,58 @@ Body
).toThrow(AgentDefinitionParseError);
});

test("parses include_files patterns", () => {
const content = `---
name: With Files
include_files:
- "docs/**/*.md"
- "src/**/*.ts"
---
Body
`;

const result = parseAgentDefinitionMarkdown({
content,
byteSize: Buffer.byteLength(content, "utf-8"),
});

expect(result.frontmatter.include_files).toEqual(["docs/**/*.md", "src/**/*.ts"]);
});

test("rejects absolute paths in include_files", () => {
const content = `---
name: Bad Paths
include_files:
- "/etc/passwd"
---
Body
`;

expect(() =>
parseAgentDefinitionMarkdown({
content,
byteSize: Buffer.byteLength(content, "utf-8"),
})
).toThrow(AgentDefinitionParseError);
});

test("rejects path traversal in include_files", () => {
const content = `---
name: Traversal
include_files:
- "../secret.txt"
---
Body
`;

expect(() =>
parseAgentDefinitionMarkdown({
content,
byteSize: Buffer.byteLength(content, "utf-8"),
})
).toThrow(AgentDefinitionParseError);
});

test("parses tools as add/remove patterns", () => {
const content = `---
name: Regex Tools
Expand Down
Loading
Loading