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:
SPANISH FLU 2026-01-27 10:20:54 +01:00
parent 9688454a30
commit f5ff4363b3
6 changed files with 113 additions and 5 deletions

View File

@ -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({

View File

@ -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"]);
});
});

View File

@ -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,
);
}

View File

@ -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":

View File

@ -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. */

View File

@ -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(),