From f5ff4363b3c5e0ef5a54115ad05a3d851bdae64c Mon Sep 17 00:00:00 2001 From: SPANISH FLU Date: Tue, 27 Jan 2026 10:20:54 +0100 Subject: [PATCH 1/3] 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 --- src/agents/bootstrap-files.ts | 3 +- src/agents/workspace.test.ts | 71 +++++++++++++++++++++++++ src/agents/workspace.ts | 38 +++++++++++-- src/config/schema.ts | 3 ++ src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + 6 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 477db8e2a..a63846832 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -24,8 +24,9 @@ export async function resolveBootstrapFilesForRun(params: { agentId?: string; }): Promise { 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({ diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 507788f3c..2f5db6b43 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -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"]); + }); }); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 0cef8e5f0..3d8ad1ef3 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -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 { +export async function loadWorkspaceBootstrapFiles( + dir: string, + options?: { extraFiles?: string[] }, +): Promise { 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 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 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, + ); } diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..d474be44e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -216,6 +216,7 @@ const FIELD_LABELS: Record = { "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 = { "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": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..9a4a5fbb8 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a849078ed..ace660f81 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -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(), From ca09994c095f9ededdbab07358c1dfa55a1034c3 Mon Sep 17 00:00:00 2001 From: SPANISH FLU Date: Tue, 27 Jan 2026 10:41:36 +0100 Subject: [PATCH 2/3] feat: support per-agent extraWorkspaceFiles config --- src/agents/agent-scope.ts | 4 ++ src/agents/bootstrap-files.test.ts | 72 +++++++++++++++++++++++++- src/agents/bootstrap-files.ts | 7 ++- src/config/schema.ts | 3 ++ src/config/types.agents.ts | 2 + src/config/zod-schema.agent-runtime.ts | 2 + 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index aae983040..aed9d88e6 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -20,6 +20,7 @@ type ResolvedAgentConfig = { workspace?: string; agentDir?: string; model?: AgentEntry["model"]; + extraWorkspaceFiles?: string[]; memorySearch?: AgentEntry["memorySearch"]; humanDelay?: AgentEntry["humanDelay"]; heartbeat?: AgentEntry["heartbeat"]; @@ -103,6 +104,9 @@ export function resolveAgentConfig( typeof entry.model === "string" || (entry.model && typeof entry.model === "object") ? entry.model : undefined, + extraWorkspaceFiles: Array.isArray(entry.extraWorkspaceFiles) + ? entry.extraWorkspaceFiles + : undefined, memorySearch: entry.memorySearch, humanDelay: entry.humanDelay, heartbeat: entry.heartbeat, diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 43ef5e4a5..670700591 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -3,12 +3,13 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js"; -import { makeTempWorkspace } from "../test-helpers/workspace.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; import { clearInternalHooks, registerInternalHook, type AgentBootstrapHookContext, } from "../hooks/internal-hooks.js"; +import type { ClawdbotConfig } from "../config/config.js"; describe("resolveBootstrapFilesForRun", () => { beforeEach(() => clearInternalHooks()); @@ -60,3 +61,72 @@ describe("resolveBootstrapContextForRun", () => { expect(extra?.content).toBe("extra"); }); }); + +describe("extraWorkspaceFiles per-agent config", () => { + beforeEach(() => clearInternalHooks()); + afterEach(() => clearInternalHooks()); + + it("uses per-agent extraWorkspaceFiles over defaults", async () => { + const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-"); + await writeWorkspaceFile({ dir: workspaceDir, name: "AGENT_SPECIFIC.md", content: "agent" }); + await writeWorkspaceFile({ dir: workspaceDir, name: "DEFAULT.md", content: "default" }); + + const config: ClawdbotConfig = { + agents: { + defaults: { extraWorkspaceFiles: ["DEFAULT.md"] }, + list: [{ id: "test-agent", extraWorkspaceFiles: ["AGENT_SPECIFIC.md"] }], + }, + }; + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + config, + agentId: "test-agent", + }); + + // Per-agent config should be used, not defaults + expect(files.some((f) => f.name === "AGENT_SPECIFIC.md")).toBe(true); + expect(files.some((f) => f.name === "DEFAULT.md")).toBe(false); + }); + + it("falls back to defaults when agent has no extraWorkspaceFiles", async () => { + const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-"); + await writeWorkspaceFile({ dir: workspaceDir, name: "DEFAULT.md", content: "default" }); + + const config: ClawdbotConfig = { + agents: { + defaults: { extraWorkspaceFiles: ["DEFAULT.md"] }, + list: [{ id: "other-agent" }], + }, + }; + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + config, + agentId: "other-agent", + }); + + expect(files.some((f) => f.name === "DEFAULT.md")).toBe(true); + }); + + it("allows per-agent to disable extras with empty array", async () => { + const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-"); + await writeWorkspaceFile({ dir: workspaceDir, name: "DEFAULT.md", content: "default" }); + + const config: ClawdbotConfig = { + agents: { + defaults: { extraWorkspaceFiles: ["DEFAULT.md"] }, + list: [{ id: "no-extras", extraWorkspaceFiles: [] }], + }, + }; + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + config, + agentId: "no-extras", + }); + + // Empty array should override defaults - no extra files + expect(files.some((f) => f.name === "DEFAULT.md")).toBe(false); + }); +}); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index a63846832..2a805ac7a 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -1,4 +1,5 @@ import type { MoltbotConfig } from "../config/config.js"; +import { resolveAgentConfig } from "./agent-scope.js"; import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; import { filterBootstrapFilesForSession, @@ -24,7 +25,11 @@ export async function resolveBootstrapFilesForRun(params: { agentId?: string; }): Promise { const sessionKey = params.sessionKey ?? params.sessionId; - const extraFiles = params.config?.agents?.defaults?.extraWorkspaceFiles; + // Per-agent extraWorkspaceFiles takes precedence over global defaults + const agentConfig = + params.agentId && params.config ? resolveAgentConfig(params.config, params.agentId) : undefined; + const extraFiles = + agentConfig?.extraWorkspaceFiles ?? params.config?.agents?.defaults?.extraWorkspaceFiles; const bootstrapFiles = filterBootstrapFilesForSession( await loadWorkspaceBootstrapFiles(params.workspaceDir, { extraFiles }), sessionKey, diff --git a/src/config/schema.ts b/src/config/schema.ts index d474be44e..1ed58c7c3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -124,6 +124,7 @@ const FIELD_LABELS: Record = { "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.extraWorkspaceFiles": "Extra Workspace Files", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -570,6 +571,8 @@ const FIELD_HELP: Record = { "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "agents.list.*.identity.avatar": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", + "agents.list.*.extraWorkspaceFiles": + "Additional workspace files to auto-inject for this agent (overrides agents.defaults.extraWorkspaceFiles). Paths are relative to the workspace directory.", "agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index f083c1897..355621c89 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -24,6 +24,8 @@ export type AgentConfig = { workspace?: string; agentDir?: string; model?: AgentModelConfig; + /** Extra workspace files to inject alongside the default bootstrap files (paths relative to workspace). */ + extraWorkspaceFiles?: string[]; memorySearch?: MemorySearchConfig; /** Human-like delay between block replies for this agent. */ humanDelay?: HumanDelayConfig; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a63e307d..24f86a44b 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -421,6 +421,8 @@ export const AgentEntrySchema = z workspace: z.string().optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), + /** Extra workspace files to inject alongside the default bootstrap files (paths relative to workspace). */ + extraWorkspaceFiles: z.array(z.string()).optional(), memorySearch: MemorySearchSchema, humanDelay: HumanDelaySchema.optional(), heartbeat: HeartbeatSchema, From 59347115dbe646e975cab526f8261372635e6c53 Mon Sep 17 00:00:00 2001 From: SPANISH FLU Date: Tue, 27 Jan 2026 10:45:19 +0100 Subject: [PATCH 3/3] docs: add extraWorkspaceFiles documentation, remove redundant JSDoc --- docs/concepts/agent-workspace.md | 38 ++++++++++++++++++++++++++ src/config/types.agent-defaults.ts | 1 - src/config/types.agents.ts | 1 - src/config/zod-schema.agent-runtime.ts | 1 - 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index ace2ad4eb..f86497e0d 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -118,6 +118,44 @@ adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000). `moltbot setup` can recreate missing defaults without overwriting existing files. +## Extra workspace files + +You can inject additional workspace files into the system prompt alongside the +defaults using `extraWorkspaceFiles`: + +```json5 +{ + agents: { + defaults: { + extraWorkspaceFiles: ["PANTHEON.md", "protocols/SHARED.md"] + } + } +} +``` + +Paths are relative to the workspace directory. Files that do not exist are +silently skipped (no "missing file" marker). Duplicates of default files are +ignored. + +Per-agent overrides are supported: + +```json5 +{ + agents: { + defaults: { + extraWorkspaceFiles: ["PANTHEON.md"] + }, + list: [ + { id: "researcher", extraWorkspaceFiles: ["RESEARCH_PROTOCOL.md"] }, + { id: "minimal", extraWorkspaceFiles: [] } // disable extras + ] + } +} +``` + +Extra files are included for subagent sessions alongside the default allowlist +(AGENTS.md, TOOLS.md). + ## What is NOT in the workspace These live under `~/.clawdbot/` and should NOT be committed to the workspace repo: diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9a4a5fbb8..7a74dce95 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -106,7 +106,6 @@ 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; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 355621c89..1fc2d2728 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -24,7 +24,6 @@ export type AgentConfig = { workspace?: string; agentDir?: string; model?: AgentModelConfig; - /** Extra workspace files to inject alongside the default bootstrap files (paths relative to workspace). */ extraWorkspaceFiles?: string[]; memorySearch?: MemorySearchConfig; /** Human-like delay between block replies for this agent. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 24f86a44b..c0126a86e 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -421,7 +421,6 @@ export const AgentEntrySchema = z workspace: z.string().optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), - /** Extra workspace files to inject alongside the default bootstrap files (paths relative to workspace). */ extraWorkspaceFiles: z.array(z.string()).optional(), memorySearch: MemorySearchSchema, humanDelay: HumanDelaySchema.optional(),