This commit is contained in:
SPANISH FLU 2026-01-29 14:15:24 -05:00 committed by GitHub
commit 9ddddfc83e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 235 additions and 6 deletions

View File

@ -118,6 +118,44 @@ adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
`moltbot setup` can recreate missing defaults without overwriting existing `moltbot setup` can recreate missing defaults without overwriting existing
files. 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 ## What is NOT in the workspace
These live under `~/.clawdbot/` and should NOT be committed to the workspace repo: These live under `~/.clawdbot/` and should NOT be committed to the workspace repo:

View File

@ -20,6 +20,7 @@ type ResolvedAgentConfig = {
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: AgentEntry["model"]; model?: AgentEntry["model"];
extraWorkspaceFiles?: string[];
memorySearch?: AgentEntry["memorySearch"]; memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"]; humanDelay?: AgentEntry["humanDelay"];
heartbeat?: AgentEntry["heartbeat"]; heartbeat?: AgentEntry["heartbeat"];
@ -103,6 +104,9 @@ export function resolveAgentConfig(
typeof entry.model === "string" || (entry.model && typeof entry.model === "object") typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
? entry.model ? entry.model
: undefined, : undefined,
extraWorkspaceFiles: Array.isArray(entry.extraWorkspaceFiles)
? entry.extraWorkspaceFiles
: undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
heartbeat: entry.heartbeat, heartbeat: entry.heartbeat,

View File

@ -3,12 +3,13 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js"; import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
import { import {
clearInternalHooks, clearInternalHooks,
registerInternalHook, registerInternalHook,
type AgentBootstrapHookContext, type AgentBootstrapHookContext,
} from "../hooks/internal-hooks.js"; } from "../hooks/internal-hooks.js";
import type { ClawdbotConfig } from "../config/config.js";
describe("resolveBootstrapFilesForRun", () => { describe("resolveBootstrapFilesForRun", () => {
beforeEach(() => clearInternalHooks()); beforeEach(() => clearInternalHooks());
@ -60,3 +61,72 @@ describe("resolveBootstrapContextForRun", () => {
expect(extra?.content).toBe("extra"); 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);
});
});

View File

@ -1,4 +1,5 @@
import type { MoltbotConfig } from "../config/config.js"; import type { MoltbotConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
import { import {
filterBootstrapFilesForSession, filterBootstrapFilesForSession,
@ -24,8 +25,13 @@ export async function resolveBootstrapFilesForRun(params: {
agentId?: string; agentId?: string;
}): Promise<WorkspaceBootstrapFile[]> { }): Promise<WorkspaceBootstrapFile[]> {
const sessionKey = params.sessionKey ?? params.sessionId; const sessionKey = params.sessionKey ?? params.sessionId;
// 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( const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(params.workspaceDir), await loadWorkspaceBootstrapFiles(params.workspaceDir, { extraFiles }),
sessionKey, sessionKey,
); );
return applyBootstrapHookOverrides({ return applyBootstrapHookOverrides({

View File

@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME, DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_FILENAME,
DEFAULT_TOOLS_FILENAME,
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles, loadWorkspaceBootstrapFiles,
} from "./workspace.js"; } from "./workspace.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
@ -46,4 +49,72 @@ describe("loadWorkspaceBootstrapFiles", () => {
expect(memoryEntries).toHaveLength(0); 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; | typeof DEFAULT_MEMORY_ALT_FILENAME;
export type WorkspaceBootstrapFile = { export type WorkspaceBootstrapFile = {
name: WorkspaceBootstrapFileName; /** Filename (e.g., "AGENTS.md") or custom name for extra files */
name: WorkspaceBootstrapFileName | string;
path: string; path: string;
content?: string; content?: string;
missing: boolean; missing: boolean;
/** True for user-configured extraWorkspaceFiles */
isExtra?: boolean;
}; };
async function writeFileIfMissing(filePath: string, content: string) { async function writeFileIfMissing(filePath: string, content: string) {
@ -221,12 +224,16 @@ async function resolveMemoryBootstrapEntries(
return deduped; 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 resolvedDir = resolveUserPath(dir);
const entries: Array<{ const entries: Array<{
name: WorkspaceBootstrapFileName; name: WorkspaceBootstrapFileName | string;
filePath: string; filePath: string;
isExtra?: boolean;
}> = [ }> = [
{ {
name: DEFAULT_AGENTS_FILENAME, name: DEFAULT_AGENTS_FILENAME,
@ -260,6 +267,23 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir))); 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[] = []; const result: WorkspaceBootstrapFile[] = [];
for (const entry of entries) { for (const entry of entries) {
try { try {
@ -269,8 +293,11 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
path: entry.filePath, path: entry.filePath,
content, content,
missing: false, missing: false,
isExtra: entry.isExtra,
}); });
} catch { } 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 }); result.push({ name: entry.name, path: entry.filePath, missing: true });
} }
} }
@ -284,5 +311,8 @@ export function filterBootstrapFilesForSession(
sessionKey?: string, sessionKey?: string,
): WorkspaceBootstrapFile[] { ): WorkspaceBootstrapFile[] {
if (!sessionKey || !isSubagentSessionKey(sessionKey)) return files; 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

@ -124,6 +124,7 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.identity.avatar": "Identity Avatar",
"agents.list.*.extraWorkspaceFiles": "Extra Workspace Files",
"gateway.remote.url": "Remote Gateway URL", "gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
@ -216,6 +217,7 @@ const FIELD_LABELS: Record<string, string> = {
"agents.defaults.workspace": "Workspace", "agents.defaults.workspace": "Workspace",
"agents.defaults.repoRoot": "Repo Root", "agents.defaults.repoRoot": "Repo Root",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.extraWorkspaceFiles": "Extra Workspace Files",
"agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimezone": "Envelope Timezone",
"agents.defaults.envelopeTimestamp": "Envelope Timestamp", "agents.defaults.envelopeTimestamp": "Envelope Timestamp",
"agents.defaults.envelopeElapsed": "Envelope Elapsed", "agents.defaults.envelopeElapsed": "Envelope Elapsed",
@ -488,6 +490,8 @@ const FIELD_HELP: Record<string, string> = {
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars": "agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "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": "agents.defaults.repoRoot":
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
"agents.defaults.envelopeTimezone": "agents.defaults.envelopeTimezone":
@ -570,6 +574,8 @@ const FIELD_HELP: Record<string, string> = {
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
"agents.list.*.identity.avatar": "agents.list.*.identity.avatar":
"Agent avatar (workspace-relative path, http(s) URL, or data URI).", "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.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks": "agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.", "Ordered fallback models (provider/model). Used when the primary model fails.",

View File

@ -106,6 +106,7 @@ export type AgentDefaultsConfig = {
skipBootstrap?: boolean; skipBootstrap?: boolean;
/** Max chars for injected bootstrap files before truncation (default: 20000). */ /** Max chars for injected bootstrap files before truncation (default: 20000). */
bootstrapMaxChars?: number; bootstrapMaxChars?: number;
extraWorkspaceFiles?: string[];
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string; userTimezone?: string;
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */

View File

@ -24,6 +24,7 @@ export type AgentConfig = {
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: AgentModelConfig; model?: AgentModelConfig;
extraWorkspaceFiles?: string[];
memorySearch?: MemorySearchConfig; memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */ /** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig; humanDelay?: HumanDelayConfig;

View File

@ -45,6 +45,7 @@ export const AgentDefaultsSchema = z
repoRoot: z.string().optional(), repoRoot: z.string().optional(),
skipBootstrap: z.boolean().optional(), skipBootstrap: z.boolean().optional(),
bootstrapMaxChars: z.number().int().positive().optional(), bootstrapMaxChars: z.number().int().positive().optional(),
extraWorkspaceFiles: z.array(z.string()).optional(),
userTimezone: z.string().optional(), userTimezone: z.string().optional(),
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(), timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
envelopeTimezone: z.string().optional(), envelopeTimezone: z.string().optional(),

View File

@ -422,6 +422,7 @@ export const AgentEntrySchema = z
workspace: z.string().optional(), workspace: z.string().optional(),
agentDir: z.string().optional(), agentDir: z.string().optional(),
model: AgentModelSchema.optional(), model: AgentModelSchema.optional(),
extraWorkspaceFiles: z.array(z.string()).optional(),
memorySearch: MemorySearchSchema, memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(), humanDelay: HumanDelaySchema.optional(),
heartbeat: HeartbeatSchema, heartbeat: HeartbeatSchema,