diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts new file mode 100644 index 000000000..0e7ed6f12 --- /dev/null +++ b/src/agents/bootstrap-files.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + resolveBootstrapContextForRun, + resolveBootstrapFilesForRun, +} from "./bootstrap-files.js"; +import { + clearInternalHooks, + registerInternalHook, + type AgentBootstrapHookContext, +} from "../hooks/internal-hooks.js"; + +describe("resolveBootstrapFilesForRun", () => { + beforeEach(() => clearInternalHooks()); + afterEach(() => clearInternalHooks()); + + it("applies bootstrap hook overrides", async () => { + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { + name: "EXTRA.md", + path: path.join(context.workspaceDir, "EXTRA.md"), + content: "extra", + missing: false, + }, + ]; + }); + + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-")); + const files = await resolveBootstrapFilesForRun({ workspaceDir }); + + expect(files.some((file) => file.name === "EXTRA.md")).toBe(true); + }); +}); + +describe("resolveBootstrapContextForRun", () => { + beforeEach(() => clearInternalHooks()); + afterEach(() => clearInternalHooks()); + + it("returns context files for hook-adjusted bootstrap files", async () => { + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { + name: "EXTRA.md", + path: path.join(context.workspaceDir, "EXTRA.md"), + content: "extra", + missing: false, + }, + ]; + }); + + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-")); + const result = await resolveBootstrapContextForRun({ workspaceDir }); + const extra = result.contextFiles.find((file) => file.path === "EXTRA.md"); + + expect(extra?.content).toBe("extra"); + }); +}); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 59371853c..326737e3d 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -5,6 +5,8 @@ import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile, } from "./workspace.js"; +import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; export async function resolveBootstrapFilesForRun(params: { workspaceDir: string; @@ -27,3 +29,22 @@ export async function resolveBootstrapFilesForRun(params: { agentId: params.agentId, }); } + +export async function resolveBootstrapContextForRun(params: { + workspaceDir: string; + config?: ClawdbotConfig; + sessionKey?: string; + sessionId?: string; + agentId?: string; + warn?: (message: string) => void; +}): Promise<{ + bootstrapFiles: WorkspaceBootstrapFile[]; + contextFiles: EmbeddedContextFile[]; +}> { + const bootstrapFiles = await resolveBootstrapFilesForRun(params); + const contextFiles = buildBootstrapContextFiles(bootstrapFiles, { + maxChars: resolveBootstrapMaxChars(params.config), + warn: params.warn, + }); + return { bootstrapFiles, contextFiles }; +} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index b128bbecd..ddffda56a 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -7,7 +7,7 @@ import { createSubsystemLogger } from "../logging.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; -import { resolveBootstrapFilesForRun } from "./bootstrap-files.js"; +import { resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; import { appendImagePathsToPrompt, @@ -25,12 +25,7 @@ import { writeCliImages, } from "./cli-runner/helpers.js"; import { FailoverError, resolveFailoverStatus } from "./failover-error.js"; -import { - buildBootstrapContextFiles, - classifyFailoverReason, - isFailoverErrorMessage, - resolveBootstrapMaxChars, -} from "./pi-embedded-helpers.js"; +import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js"; import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; const log = createSubsystemLogger("agent/claude-cli"); @@ -72,15 +67,12 @@ export async function runCliAgent(params: { .filter(Boolean) .join("\n"); - const hookAdjustedBootstrapFiles = await resolveBootstrapFilesForRun({ + const sessionLabel = params.sessionKey ?? params.sessionId; + const { contextFiles } = await resolveBootstrapContextForRun({ workspaceDir, config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, - }); - const sessionLabel = params.sessionKey ?? params.sessionId; - const contextFiles = buildBootstrapContextFiles(hookAdjustedBootstrapFiles, { - maxChars: resolveBootstrapMaxChars(params.config), warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), }); const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index f5d9ef25f..d5d25a1e3 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -16,14 +16,12 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveUserPath } from "../../utils.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; -import { resolveBootstrapFilesForRun } from "../bootstrap-files.js"; +import { resolveBootstrapContextForRun } from "../bootstrap-files.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; import { ensureClawdbotModelsJson } from "../models-config.js"; import { - buildBootstrapContextFiles, - type EmbeddedContextFile, ensureSessionHeader, resolveBootstrapMaxChars, validateAnthropicTurns, @@ -178,20 +176,14 @@ export async function compactEmbeddedPiSession(params: { workspaceDir: effectiveWorkspace, }); - const hookAdjustedBootstrapFiles = await resolveBootstrapFilesForRun({ + const sessionLabel = params.sessionKey ?? params.sessionId; + const { contextFiles } = await resolveBootstrapContextForRun({ workspaceDir: effectiveWorkspace, config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, + warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), }); - const sessionLabel = params.sessionKey ?? params.sessionId; - const contextFiles: EmbeddedContextFile[] = buildBootstrapContextFiles( - hookAdjustedBootstrapFiles, - { - maxChars: resolveBootstrapMaxChars(params.config), - warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), - }, - ); const runAbortController = new AbortController(); const toolsRaw = createClawdbotCodingTools({ exec: { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 83c0edcef..b5e31a262 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -17,10 +17,9 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveUserPath } from "../../../utils.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; -import { resolveBootstrapFilesForRun } from "../../bootstrap-files.js"; +import { resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { - buildBootstrapContextFiles, isCloudCodeAssistFormatError, resolveBootstrapMaxChars, validateAnthropicTurns, @@ -120,17 +119,15 @@ export async function runEmbeddedAttempt( workspaceDir: effectiveWorkspace, }); - const hookAdjustedBootstrapFiles = await resolveBootstrapFilesForRun({ - workspaceDir: effectiveWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - }); const sessionLabel = params.sessionKey ?? params.sessionId; - const contextFiles = buildBootstrapContextFiles(hookAdjustedBootstrapFiles, { - maxChars: resolveBootstrapMaxChars(params.config), - warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), - }); + const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = + await resolveBootstrapContextForRun({ + workspaceDir: effectiveWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), + }); const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 2c3718389..1063405b9 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -1,7 +1,4 @@ -import { - buildBootstrapContextFiles, - resolveBootstrapMaxChars, -} from "../../agents/pi-embedded-helpers.js"; +import { resolveBootstrapMaxChars } from "../../agents/pi-embedded-helpers.js"; import { createClawdbotCodingTools } from "../../agents/pi-tools.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; @@ -9,7 +6,7 @@ import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; import { buildSystemPromptReport } from "../../agents/system-prompt-report.js"; import { buildToolSummaryMap } from "../../agents/tool-summaries.js"; -import { resolveBootstrapFilesForRun } from "../../agents/bootstrap-files.js"; +import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import type { ReplyPayload } from "../types.js"; @@ -52,15 +49,12 @@ async function resolveContextReport( const workspaceDir = params.workspaceDir; const bootstrapMaxChars = resolveBootstrapMaxChars(params.cfg); - const hookAdjustedBootstrapFiles = await resolveBootstrapFilesForRun({ + const { bootstrapFiles, contextFiles: injectedFiles } = await resolveBootstrapContextForRun({ workspaceDir, config: params.cfg, sessionKey: params.sessionKey, sessionId: params.sessionEntry?.sessionId, }); - const injectedFiles = buildBootstrapContextFiles(hookAdjustedBootstrapFiles, { - maxChars: bootstrapMaxChars, - }); const skillsSnapshot = (() => { try { return buildWorkspaceSkillSnapshot(workspaceDir, { diff --git a/src/hooks/bundled/soul-evil/handler.test.ts b/src/hooks/bundled/soul-evil/handler.test.ts new file mode 100644 index 000000000..1e4674517 --- /dev/null +++ b/src/hooks/bundled/soul-evil/handler.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import handler from "./handler.js"; +import { createHookEvent } from "../../hooks.js"; +import type { AgentBootstrapHookContext } from "../../hooks.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; + +describe("soul-evil hook", () => { + it("skips subagent sessions", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); + await fs.writeFile(path.join(tempDir, "SOUL_EVIL.md"), "chaotic", "utf-8"); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + entries: { + "soul-evil": { enabled: true, chance: 1 }, + }, + }, + }, + }; + const context: AgentBootstrapHookContext = { + workspaceDir: tempDir, + bootstrapFiles: [ + { + name: "SOUL.md", + path: path.join(tempDir, "SOUL.md"), + content: "friendly", + missing: false, + }, + ], + cfg, + sessionKey: "agent:main:subagent:abc", + }; + + const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context); + await handler(event); + + expect(context.bootstrapFiles[0]?.content).toBe("friendly"); + }); +}); diff --git a/src/hooks/soul-evil.test.ts b/src/hooks/soul-evil.test.ts index 9d58f5c6d..f89ecdd23 100644 --- a/src/hooks/soul-evil.test.ts +++ b/src/hooks/soul-evil.test.ts @@ -128,4 +128,29 @@ describe("applySoulEvilOverride", () => { const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); expect(soul?.content).toBe("friendly"); }); + + it("leaves files untouched when SOUL.md is not in bootstrap files", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); + const evilPath = path.join(tempDir, DEFAULT_SOUL_EVIL_FILENAME); + await fs.writeFile(evilPath, "chaotic", "utf-8"); + + const files: WorkspaceBootstrapFile[] = [ + { + name: "AGENTS.md", + path: path.join(tempDir, "AGENTS.md"), + content: "agents", + missing: false, + }, + ]; + + const updated = await applySoulEvilOverride({ + files, + workspaceDir: tempDir, + config: { chance: 1 }, + userTimezone: "UTC", + random: () => 0, + }); + + expect(updated).toEqual(files); + }); });