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
- 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

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

View File

@@ -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,

View File

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

View File

@@ -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");
}

View File

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

View File

@@ -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,
]);
});
});

View File

@@ -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));
}