feat: add extraWorkspaceFiles config option for custom bootstrap files
Allows users to specify additional workspace files to auto-inject into
the system prompt alongside the defaults (AGENTS.md, SOUL.md, etc.).
Configuration example:
```yaml
agents:
defaults:
extraWorkspaceFiles:
- PANTHEON.md
- protocols/SHARED.md
```
Features:
- Paths are relative to workspace directory
- Files that don't exist are silently skipped (no [MISSING] markers)
- Deduplication against default files
- Extra files are included for subagent sessions (alongside AGENTS.md/TOOLS.md)
- Respects bootstrapMaxChars truncation like other workspace files
Closes: users wanting custom protocol files auto-loaded
This commit is contained in:
parent
9688454a30
commit
f5ff4363b3
@ -24,8 +24,9 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
agentId?: string;
|
||||
}): Promise<WorkspaceBootstrapFile[]> {
|
||||
const sessionKey = params.sessionKey ?? params.sessionId;
|
||||
const extraFiles = params.config?.agents?.defaults?.extraWorkspaceFiles;
|
||||
const bootstrapFiles = filterBootstrapFilesForSession(
|
||||
await loadWorkspaceBootstrapFiles(params.workspaceDir),
|
||||
await loadWorkspaceBootstrapFiles(params.workspaceDir, { extraFiles }),
|
||||
sessionKey,
|
||||
);
|
||||
return applyBootstrapHookOverrides({
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_MEMORY_ALT_FILENAME,
|
||||
DEFAULT_MEMORY_FILENAME,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
filterBootstrapFilesForSession,
|
||||
loadWorkspaceBootstrapFiles,
|
||||
} from "./workspace.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
|
||||
@ -46,4 +49,72 @@ describe("loadWorkspaceBootstrapFiles", () => {
|
||||
|
||||
expect(memoryEntries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes extraWorkspaceFiles when present", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-workspace-");
|
||||
await writeWorkspaceFile({ dir: tempDir, name: "PANTHEON.md", content: "shared protocols" });
|
||||
|
||||
const files = await loadWorkspaceBootstrapFiles(tempDir, {
|
||||
extraFiles: ["PANTHEON.md"],
|
||||
});
|
||||
|
||||
const extra = files.find((f) => f.name === "PANTHEON.md");
|
||||
expect(extra).toBeDefined();
|
||||
expect(extra?.missing).toBe(false);
|
||||
expect(extra?.content).toBe("shared protocols");
|
||||
expect(extra?.isExtra).toBe(true);
|
||||
});
|
||||
|
||||
it("skips extraWorkspaceFiles when file does not exist", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-workspace-");
|
||||
|
||||
const files = await loadWorkspaceBootstrapFiles(tempDir, {
|
||||
extraFiles: ["MISSING.md"],
|
||||
});
|
||||
|
||||
// Extra files that don't exist are silently skipped (not marked missing)
|
||||
const extra = files.find((f) => f.name === "MISSING.md");
|
||||
expect(extra).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dedupes extraWorkspaceFiles against defaults", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-workspace-");
|
||||
await writeWorkspaceFile({ dir: tempDir, name: "AGENTS.md", content: "default" });
|
||||
|
||||
const files = await loadWorkspaceBootstrapFiles(tempDir, {
|
||||
extraFiles: ["AGENTS.md"], // duplicate of default
|
||||
});
|
||||
|
||||
// Should only have one AGENTS.md entry (the default)
|
||||
const agentsEntries = files.filter((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsEntries).toHaveLength(1);
|
||||
expect(agentsEntries[0]?.isExtra).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterBootstrapFilesForSession", () => {
|
||||
it("returns all files for main session", () => {
|
||||
const files = [
|
||||
{ name: DEFAULT_AGENTS_FILENAME, path: "/a/AGENTS.md", missing: false },
|
||||
{ name: DEFAULT_TOOLS_FILENAME, path: "/a/TOOLS.md", missing: false },
|
||||
{ name: "IDENTITY.md", path: "/a/IDENTITY.md", missing: false },
|
||||
{ name: "PANTHEON.md", path: "/a/PANTHEON.md", missing: false, isExtra: true },
|
||||
];
|
||||
|
||||
const filtered = filterBootstrapFilesForSession(files, "agent:main:main");
|
||||
expect(filtered).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("filters to allowlist + extras for subagent session", () => {
|
||||
const files = [
|
||||
{ name: DEFAULT_AGENTS_FILENAME, path: "/a/AGENTS.md", missing: false },
|
||||
{ name: DEFAULT_TOOLS_FILENAME, path: "/a/TOOLS.md", missing: false },
|
||||
{ name: "IDENTITY.md", path: "/a/IDENTITY.md", missing: false },
|
||||
{ name: "PANTHEON.md", path: "/a/PANTHEON.md", missing: false, isExtra: true },
|
||||
];
|
||||
|
||||
const filtered = filterBootstrapFilesForSession(files, "agent:main:subagent:abc123");
|
||||
expect(filtered).toHaveLength(3); // AGENTS.md, TOOLS.md, PANTHEON.md (extra)
|
||||
expect(filtered.map((f) => f.name).sort()).toEqual(["AGENTS.md", "PANTHEON.md", "TOOLS.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -68,10 +68,13 @@ export type WorkspaceBootstrapFileName =
|
||||
| typeof DEFAULT_MEMORY_ALT_FILENAME;
|
||||
|
||||
export type WorkspaceBootstrapFile = {
|
||||
name: WorkspaceBootstrapFileName;
|
||||
/** Filename (e.g., "AGENTS.md") or custom name for extra files */
|
||||
name: WorkspaceBootstrapFileName | string;
|
||||
path: string;
|
||||
content?: string;
|
||||
missing: boolean;
|
||||
/** True for user-configured extraWorkspaceFiles */
|
||||
isExtra?: boolean;
|
||||
};
|
||||
|
||||
async function writeFileIfMissing(filePath: string, content: string) {
|
||||
@ -221,12 +224,16 @@ async function resolveMemoryBootstrapEntries(
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function loadWorkspaceBootstrapFiles(dir: string): Promise<WorkspaceBootstrapFile[]> {
|
||||
export async function loadWorkspaceBootstrapFiles(
|
||||
dir: string,
|
||||
options?: { extraFiles?: string[] },
|
||||
): Promise<WorkspaceBootstrapFile[]> {
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
|
||||
const entries: Array<{
|
||||
name: WorkspaceBootstrapFileName;
|
||||
name: WorkspaceBootstrapFileName | string;
|
||||
filePath: string;
|
||||
isExtra?: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
@ -260,6 +267,23 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
|
||||
|
||||
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
|
||||
|
||||
// Add extra workspace files from config
|
||||
if (options?.extraFiles) {
|
||||
const seenPaths = new Set(entries.map((e) => e.filePath.toLowerCase()));
|
||||
for (const extraPath of options.extraFiles) {
|
||||
const trimmed = extraPath.trim();
|
||||
if (!trimmed) continue;
|
||||
// Resolve path relative to workspace dir
|
||||
const filePath = path.isAbsolute(trimmed) ? trimmed : path.join(resolvedDir, trimmed);
|
||||
// Dedupe: skip if already in the default list
|
||||
if (seenPaths.has(filePath.toLowerCase())) continue;
|
||||
seenPaths.add(filePath.toLowerCase());
|
||||
// Use the basename as the display name
|
||||
const name = path.basename(trimmed);
|
||||
entries.push({ name, filePath, isExtra: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result: WorkspaceBootstrapFile[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
@ -269,8 +293,11 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
|
||||
path: entry.filePath,
|
||||
content,
|
||||
missing: false,
|
||||
isExtra: entry.isExtra,
|
||||
});
|
||||
} catch {
|
||||
// For extra files, only include if they exist (don't mark as missing)
|
||||
if (entry.isExtra) continue;
|
||||
result.push({ name: entry.name, path: entry.filePath, missing: true });
|
||||
}
|
||||
}
|
||||
@ -284,5 +311,8 @@ export function filterBootstrapFilesForSession(
|
||||
sessionKey?: string,
|
||||
): WorkspaceBootstrapFile[] {
|
||||
if (!sessionKey || !isSubagentSessionKey(sessionKey)) return files;
|
||||
return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name));
|
||||
// For subagents: include allowlisted defaults + all extra workspace files
|
||||
return files.filter(
|
||||
(file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name) || file.isExtra === true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -216,6 +216,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"agents.defaults.repoRoot": "Repo Root",
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.extraWorkspaceFiles": "Extra Workspace Files",
|
||||
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||
"agents.defaults.envelopeElapsed": "Envelope Elapsed",
|
||||
@ -487,6 +488,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||
"agents.defaults.extraWorkspaceFiles":
|
||||
"Additional workspace files to auto-inject into the system prompt alongside the defaults (AGENTS.md, SOUL.md, etc.). Paths are relative to the workspace directory (e.g., ['PANTHEON.md', 'protocols/SHARED.md']).",
|
||||
"agents.defaults.repoRoot":
|
||||
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
"agents.defaults.envelopeTimezone":
|
||||
|
||||
@ -106,6 +106,8 @@ export type AgentDefaultsConfig = {
|
||||
skipBootstrap?: boolean;
|
||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||
bootstrapMaxChars?: number;
|
||||
/** Extra workspace files to inject alongside the default bootstrap files (paths relative to workspace). */
|
||||
extraWorkspaceFiles?: string[];
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
|
||||
|
||||
@ -45,6 +45,7 @@ export const AgentDefaultsSchema = z
|
||||
repoRoot: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
extraWorkspaceFiles: z.array(z.string()).optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
|
||||
envelopeTimezone: z.string().optional(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user