fix: limit subagent bootstrap context

This commit is contained in:
Peter Steinberger
2026-01-10 00:01:16 +00:00
parent e311dc82e0
commit 21eebb6d3b
8 changed files with 158 additions and 14 deletions

View File

@@ -29,6 +29,7 @@
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete - 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: 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: `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 - 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 - 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 - Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete

View File

@@ -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-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. - 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. - `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`).

View File

@@ -18,7 +18,10 @@ import {
} from "./pi-embedded-helpers.js"; } from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.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 log = createSubsystemLogger("agent/claude-cli");
const CLAUDE_CLI_QUEUE_KEY = "global"; const CLAUDE_CLI_QUEUE_KEY = "global";
@@ -366,7 +369,10 @@ export async function runClaudeCliAgent(params: {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
const bootstrapFiles = await loadWorkspaceBootstrapFiles(workspaceDir); const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(workspaceDir),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
workspaceDir, workspaceDir,

View File

@@ -96,10 +96,14 @@ import {
} from "./skills.js"; } from "./skills.js";
import { buildAgentSystemPrompt } from "./system-prompt.js"; import { buildAgentSystemPrompt } from "./system-prompt.js";
import { normalizeUsage, type UsageLike } from "./usage.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. // Optional features can be implemented as Pi extensions that run in the same Node process.
/** /**
* Resolve provider-specific extraParams from model config. * Resolve provider-specific extraParams from model config.
* Auto-enables thinking mode for GLM-4.x models unless explicitly disabled. * Auto-enables thinking mode for GLM-4.x models unless explicitly disabled.
@@ -855,8 +859,10 @@ export async function compactEmbeddedPiSession(params: {
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
}); });
const bootstrapFiles = const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(effectiveWorkspace); await loadWorkspaceBootstrapFiles(effectiveWorkspace),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
bash: { bash: {
@@ -1194,8 +1200,10 @@ export async function runEmbeddedPiAgent(params: {
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
}); });
const bootstrapFiles = const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(effectiveWorkspace); await loadWorkspaceBootstrapFiles(effectiveWorkspace),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`). // Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged. // `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.

View File

@@ -142,20 +142,54 @@ export function buildSubagentSystemPrompt(params: {
requesterProvider?: string; requesterProvider?: string;
childSessionKey: string; childSessionKey: string;
label?: string; label?: string;
task?: string;
}) { }) {
const taskText =
typeof params.task === "string" && params.task.trim()
? params.task.replace(/\s+/g, " ").trim()
: "{{TASK_DESCRIPTION}}";
const lines = [ const lines = [
"Sub-agent context:", "# Subagent Context",
params.label ? `Label: ${params.label}` : undefined, "",
"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 params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.` ? `- Requester session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterProvider params.requesterProvider
? `Requester provider: ${params.requesterProvider}.` ? `- Requester provider: ${params.requesterProvider}.`
: undefined, : undefined,
`Your session: ${params.childSessionKey}.`, `- Your session: ${params.childSessionKey}.`,
"",
"Run the task. Provide a clear final answer (plain text).", "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.', '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"); return lines.join("\n");
} }

View File

@@ -162,6 +162,7 @@ export function createSessionsSpawnTool(opts?: {
requesterProvider: opts?.agentProvider, requesterProvider: opts?.agentProvider,
childSessionKey, childSessionKey,
label: label || undefined, label: label || undefined,
task,
}); });
const childIdem = crypto.randomUUID(); const childIdem = crypto.randomUUID();

View File

@@ -2,7 +2,18 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; 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", () => { describe("ensureAgentWorkspace", () => {
it("creates directory and bootstrap files when missing", async () => { it("creates directory and bootstrap files when missing", async () => {
@@ -52,3 +63,71 @@ describe("ensureAgentWorkspace", () => {
await expect(fs.stat(bootstrapPath)).rejects.toBeDefined(); 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,
]);
});
});

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
export function resolveDefaultAgentWorkspaceDir( export function resolveDefaultAgentWorkspaceDir(
@@ -362,3 +363,16 @@ export async function loadWorkspaceBootstrapFiles(
} }
return result; 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));
}