From 21eebb6d3b7bf33d05876784e91623ae64ef0a60 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 00:01:16 +0000 Subject: [PATCH] fix: limit subagent bootstrap context --- CHANGELOG.md | 1 + docs/tools/subagents.md | 1 + src/agents/claude-cli-runner.ts | 10 ++- src/agents/pi-embedded-runner.ts | 18 ++++-- src/agents/subagent-announce.ts | 46 ++++++++++++-- src/agents/tools/sessions-spawn-tool.ts | 1 + src/agents/workspace.test.ts | 81 ++++++++++++++++++++++++- src/agents/workspace.ts | 14 +++++ 8 files changed, 158 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d6ee2aa..977022e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete - Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete - Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre +- Agents: sub-agent context now injects only AGENTS.md + TOOLS.md (omits identity/user/soul/heartbeat/bootstrap). — thanks @steipete - Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete - Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete - Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index f9288bf84..d7970d293 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -98,3 +98,4 @@ Sub-agents use a dedicated in-process queue lane: - Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost. - Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve. - `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately. +- Sub-agent context only injects `AGENTS.md` + `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`). diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts index 26ca8090b..5497ddb97 100644 --- a/src/agents/claude-cli-runner.ts +++ b/src/agents/claude-cli-runner.ts @@ -18,7 +18,10 @@ import { } from "./pi-embedded-helpers.js"; import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; -import { loadWorkspaceBootstrapFiles } from "./workspace.js"; +import { + filterBootstrapFilesForSession, + loadWorkspaceBootstrapFiles, +} from "./workspace.js"; const log = createSubsystemLogger("agent/claude-cli"); const CLAUDE_CLI_QUEUE_KEY = "global"; @@ -366,7 +369,10 @@ export async function runClaudeCliAgent(params: { .filter(Boolean) .join("\n"); - const bootstrapFiles = await loadWorkspaceBootstrapFiles(workspaceDir); + const bootstrapFiles = filterBootstrapFilesForSession( + await loadWorkspaceBootstrapFiles(workspaceDir), + params.sessionKey ?? params.sessionId, + ); const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const systemPrompt = buildSystemPrompt({ workspaceDir, diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index fa0278459..230c75b25 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -96,10 +96,14 @@ import { } from "./skills.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; import { normalizeUsage, type UsageLike } from "./usage.js"; -import { loadWorkspaceBootstrapFiles } from "./workspace.js"; +import { + filterBootstrapFilesForSession, + loadWorkspaceBootstrapFiles, +} from "./workspace.js"; // Optional features can be implemented as Pi extensions that run in the same Node process. + /** * Resolve provider-specific extraParams from model config. * Auto-enables thinking mode for GLM-4.x models unless explicitly disabled. @@ -855,8 +859,10 @@ export async function compactEmbeddedPiSession(params: { workspaceDir: effectiveWorkspace, }); - const bootstrapFiles = - await loadWorkspaceBootstrapFiles(effectiveWorkspace); + const bootstrapFiles = filterBootstrapFilesForSession( + await loadWorkspaceBootstrapFiles(effectiveWorkspace), + params.sessionKey ?? params.sessionId, + ); const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const tools = createClawdbotCodingTools({ bash: { @@ -1194,8 +1200,10 @@ export async function runEmbeddedPiAgent(params: { workspaceDir: effectiveWorkspace, }); - const bootstrapFiles = - await loadWorkspaceBootstrapFiles(effectiveWorkspace); + const bootstrapFiles = filterBootstrapFilesForSession( + await loadWorkspaceBootstrapFiles(effectiveWorkspace), + params.sessionKey ?? params.sessionId, + ); const contextFiles = buildBootstrapContextFiles(bootstrapFiles); // Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`). // `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged. diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 646d870f4..c48e6e9d6 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -142,20 +142,54 @@ export function buildSubagentSystemPrompt(params: { requesterProvider?: string; childSessionKey: string; label?: string; + task?: string; }) { + const taskText = + typeof params.task === "string" && params.task.trim() + ? params.task.replace(/\s+/g, " ").trim() + : "{{TASK_DESCRIPTION}}"; const lines = [ - "Sub-agent context:", - params.label ? `Label: ${params.label}` : undefined, + "# Subagent Context", + "", + "You are a **subagent** spawned by the main agent for a specific task.", + "", + "## Your Role", + `- You were created to handle: ${taskText}`, + "- Complete this task and report back. That's your entire purpose.", + "- You are NOT the main agent. Don't try to be.", + "", + "## Rules", + "1. **Stay focused** - Do your assigned task, nothing else", + "2. **Report completion** - When done, summarize results clearly", + "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests", + "4. **Ask the spawner** - If blocked or confused, report back rather than improvising", + "5. **Be ephemeral** - You may be terminated after task completion. That's fine.", + "", + "## What You DON'T Do", + "- NO user conversations (that's main agent's job)", + "- NO external messages (email, tweets, etc.) unless explicitly tasked", + "- NO cron jobs or persistent state", + "- NO pretending to be the main agent", + "", + "## Output Format", + "When complete, respond with:", + "- **Status:** success | failed | blocked", + "- **Result:** [what you accomplished]", + "- **Notes:** [anything the main agent should know] - discuss gimme options", + "", + "## Session Context", + params.label ? `- Label: ${params.label}` : undefined, params.requesterSessionKey - ? `Requester session: ${params.requesterSessionKey}.` + ? `- Requester session: ${params.requesterSessionKey}.` : undefined, params.requesterProvider - ? `Requester provider: ${params.requesterProvider}.` + ? `- Requester provider: ${params.requesterProvider}.` : undefined, - `Your session: ${params.childSessionKey}.`, + `- Your session: ${params.childSessionKey}.`, + "", "Run the task. Provide a clear final answer (plain text).", 'After you finish, you may be asked to produce an "announce" message to post back to the requester chat.', - ].filter(Boolean); + ].filter((line): line is string => line !== undefined); return lines.join("\n"); } diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index ab4ed9b9f..2806f22c3 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -162,6 +162,7 @@ export function createSessionsSpawnTool(opts?: { requesterProvider: opts?.agentProvider, childSessionKey, label: label || undefined, + task, }); const childIdem = crypto.randomUUID(); diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 89e8c5a25..feeb1b064 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -2,7 +2,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { ensureAgentWorkspace } from "./workspace.js"; +import type { WorkspaceBootstrapFile } from "./workspace.js"; +import { + DEFAULT_AGENTS_FILENAME, + DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_HEARTBEAT_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_USER_FILENAME, + ensureAgentWorkspace, + filterBootstrapFilesForSession, +} from "./workspace.js"; describe("ensureAgentWorkspace", () => { it("creates directory and bootstrap files when missing", async () => { @@ -52,3 +63,71 @@ describe("ensureAgentWorkspace", () => { await expect(fs.stat(bootstrapPath)).rejects.toBeDefined(); }); }); + +describe("filterBootstrapFilesForSession", () => { + const files: WorkspaceBootstrapFile[] = [ + { + name: DEFAULT_AGENTS_FILENAME, + path: "/tmp/AGENTS.md", + content: "agents", + missing: false, + }, + { + name: DEFAULT_SOUL_FILENAME, + path: "/tmp/SOUL.md", + content: "soul", + missing: false, + }, + { + name: DEFAULT_TOOLS_FILENAME, + path: "/tmp/TOOLS.md", + content: "tools", + missing: false, + }, + { + name: DEFAULT_IDENTITY_FILENAME, + path: "/tmp/IDENTITY.md", + content: "identity", + missing: false, + }, + { + name: DEFAULT_USER_FILENAME, + path: "/tmp/USER.md", + content: "user", + missing: false, + }, + { + name: DEFAULT_HEARTBEAT_FILENAME, + path: "/tmp/HEARTBEAT.md", + content: "heartbeat", + missing: false, + }, + { + name: DEFAULT_BOOTSTRAP_FILENAME, + path: "/tmp/BOOTSTRAP.md", + content: "bootstrap", + missing: false, + }, + ]; + + it("keeps full bootstrap set for non-subagent sessions", () => { + const result = filterBootstrapFilesForSession( + files, + "agent:main:session:abc", + ); + expect(result.map((file) => file.name)).toEqual( + files.map((file) => file.name), + ); + }); + + it("limits bootstrap files for subagent sessions", () => { + const result = filterBootstrapFilesForSession( + files, + "agent:main:subagent:abc", + ); + expect(result.map((file) => file.name)).toEqual([ + DEFAULT_AGENTS_FILENAME, + DEFAULT_TOOLS_FILENAME, + ]); + }); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index f29e97271..196576990 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isSubagentSessionKey } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; export function resolveDefaultAgentWorkspaceDir( @@ -362,3 +363,16 @@ export async function loadWorkspaceBootstrapFiles( } return result; } + +const SUBAGENT_BOOTSTRAP_ALLOWLIST = new Set([ + DEFAULT_AGENTS_FILENAME, + DEFAULT_TOOLS_FILENAME, +]); + +export function filterBootstrapFilesForSession( + files: WorkspaceBootstrapFile[], + sessionKey?: string, +): WorkspaceBootstrapFile[] { + if (!sessionKey || !isSubagentSessionKey(sessionKey)) return files; + return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name)); +}