fix: refine bootstrap injections

This commit is contained in:
Peter Steinberger
2026-01-07 10:03:50 +00:00
parent 412990a139
commit e8420bd047
8 changed files with 98 additions and 82 deletions

View File

@@ -28,6 +28,7 @@
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete. - CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341. - Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369! - Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298. - Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent. - Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior. - Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.

View File

@@ -31,6 +31,8 @@ Inside `agent.workspace`, CLAWDBOT expects these user-editable files:
On the first turn of a new session, CLAWDBOT injects the contents of these files directly into the agent context. On the first turn of a new session, CLAWDBOT injects the contents of these files directly into the agent context.
Blank files are skipped. Large files are trimmed and truncated with a marker so prompts stay lean (read the file for full content).
If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template). If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template).
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts. `BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts.

View File

@@ -1,77 +1,48 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import { buildBootstrapContextFiles } from "./pi-embedded-helpers.js";
import { import {
isRateLimitAssistantError, DEFAULT_AGENTS_FILENAME,
pickFallbackThinkingLevel, type WorkspaceBootstrapFile,
} from "./pi-embedded-helpers.js"; } from "./workspace.js";
const asAssistant = (overrides: Partial<AssistantMessage>) => const makeFile = (
({ overrides: Partial<WorkspaceBootstrapFile>,
role: "assistant", ): WorkspaceBootstrapFile => ({
stopReason: "error", name: DEFAULT_AGENTS_FILENAME,
...overrides, path: "/tmp/AGENTS.md",
}) as AssistantMessage; content: "",
missing: false,
describe("isRateLimitAssistantError", () => { ...overrides,
it("detects 429 rate limit payloads", () => {
const msg = asAssistant({
errorMessage:
'429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}',
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects human-readable rate limit messages", () => {
const msg = asAssistant({
errorMessage: "Too many requests. Rate limit exceeded.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects quota exceeded messages", () => {
const msg = asAssistant({
errorMessage:
"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("returns false for non-error messages", () => {
const msg = asAssistant({
stopReason: "end_turn",
errorMessage: "rate limit",
});
expect(isRateLimitAssistantError(msg)).toBe(false);
});
}); });
describe("pickFallbackThinkingLevel", () => { describe("buildBootstrapContextFiles", () => {
it("selects the first supported thinking level", () => { it("keeps missing markers", () => {
const attempted = new Set<ThinkLevel>(["low"]); const files = [makeFile({ missing: true, content: undefined })];
const next = pickFallbackThinkingLevel({ expect(buildBootstrapContextFiles(files)).toEqual([
message: {
"Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.", path: DEFAULT_AGENTS_FILENAME,
attempted, content: "[MISSING] Expected at: /tmp/AGENTS.md",
}); },
expect(next).toBe("medium"); ]);
}); });
it("skips already attempted levels", () => { it("skips empty or whitespace-only content", () => {
const attempted = new Set<ThinkLevel>(["low", "medium"]); const files = [makeFile({ content: " \n " })];
const next = pickFallbackThinkingLevel({ expect(buildBootstrapContextFiles(files)).toEqual([]);
message: "Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("high");
}); });
it("returns undefined when no supported values are found", () => { it("truncates large bootstrap content", () => {
const attempted = new Set<ThinkLevel>(["low"]); const head = `HEAD-${"a".repeat(6000)}`;
const next = pickFallbackThinkingLevel({ const tail = `${"b".repeat(3000)}-TAIL`;
message: "Request failed.", const long = `${head}${tail}`;
attempted, const files = [makeFile({ content: long })];
}); const [result] = buildBootstrapContextFiles(files);
expect(next).toBeUndefined(); expect(result?.content).toContain(
"[...truncated, read AGENTS.md for full content...]",
);
expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
}); });
}); });

View File

@@ -109,13 +109,18 @@ export function buildBootstrapContextFiles(
): EmbeddedContextFile[] { ): EmbeddedContextFile[] {
const result: EmbeddedContextFile[] = []; const result: EmbeddedContextFile[] = [];
for (const file of files) { for (const file of files) {
if (file.missing) continue; if (file.missing) {
const content = file.content ?? ""; result.push({
const trimmed = content.trimEnd(); path: file.name,
content: `[MISSING] Expected at: ${file.path}`,
});
continue;
}
const trimmed = trimBootstrapContent(file.content ?? "", file.name);
if (!trimmed) continue; if (!trimmed) continue;
result.push({ result.push({
path: file.name, path: file.name,
content: trimBootstrapContent(trimmed, file.name), content: trimmed,
}); });
} }
return result; return result;

View File

@@ -1006,7 +1006,7 @@ describe("trigger handling", () => {
describe("group intro prompts", () => { describe("group intro prompts", () => {
const groupParticipationNote = const groupParticipationNote =
"In groups, respond only when helpful; reactions are ok when available."; "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available.";
it("labels Discord groups using the surface metadata", async () => { it("labels Discord groups using the surface metadata", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -164,7 +164,7 @@ export function buildGroupIntro(params: {
? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent." ? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent."
: undefined; : undefined;
const lurkLine = const lurkLine =
"In groups, respond only when helpful; reactions are ok when available."; "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available.";
return [ return [
subjectLine, subjectLine,
membersLine, membersLine,

View File

@@ -66,7 +66,10 @@ export async function getStatusSummary(): Promise<StatusSummary> {
const linked = await webAuthExists(account.authDir); const linked = await webAuthExists(account.authDir);
const authAgeMs = getWebAuthAgeMs(account.authDir); const authAgeMs = getWebAuthAgeMs(account.authDir);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const providerSummary = await buildProviderSummary(cfg); const providerSummary = await buildProviderSummary(cfg, {
colorize: true,
includeAllowFrom: true,
});
const queuedSystemEvents = peekSystemEvents(); const queuedSystemEvents = peekSystemEvents();
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({

View File

@@ -1,20 +1,36 @@
import chalk from "chalk";
import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { resolveTelegramToken } from "../telegram/token.js"; import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
readWebSelfId, readWebSelfId,
webAuthExists, webAuthExists,
} from "../web/session.js"; } from "../web/session.js";
export type ProviderSummaryOptions = {
colorize?: boolean;
includeAllowFrom?: boolean;
};
const DEFAULT_OPTIONS: Required<ProviderSummaryOptions> = {
colorize: false,
includeAllowFrom: false,
};
export async function buildProviderSummary( export async function buildProviderSummary(
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
options?: ProviderSummaryOptions,
): Promise<string[]> { ): Promise<string[]> {
const effective = cfg ?? loadConfig(); const effective = cfg ?? loadConfig();
const lines: string[] = []; const lines: string[] = [];
const resolved = { ...DEFAULT_OPTIONS, ...options };
const tint = (value: string, color?: (input: string) => string) =>
resolved.colorize && color ? color(value) : value;
const webEnabled = effective.web?.enabled !== false; const webEnabled = effective.web?.enabled !== false;
if (!webEnabled) { if (!webEnabled) {
lines.push("WhatsApp: disabled"); lines.push(tint("WhatsApp: disabled", chalk.cyan));
} else { } else {
const webLinked = await webAuthExists(); const webLinked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs(); const authAgeMs = getWebAuthAgeMs();
@@ -22,25 +38,30 @@ export async function buildProviderSummary(
const { e164 } = readWebSelfId(); const { e164 } = readWebSelfId();
lines.push( lines.push(
webLinked webLinked
? `WhatsApp: linked${e164 ? ` ${e164}` : ""}${authAge}` ? tint(
: "WhatsApp: not linked", `WhatsApp: linked${e164 ? ` ${e164}` : ""}${authAge}`,
chalk.green,
)
: tint("WhatsApp: not linked", chalk.red),
); );
} }
const telegramEnabled = effective.telegram?.enabled !== false; const telegramEnabled = effective.telegram?.enabled !== false;
if (!telegramEnabled) { if (!telegramEnabled) {
lines.push("Telegram: disabled"); lines.push(tint("Telegram: disabled", chalk.cyan));
} else { } else {
const { token: telegramToken } = resolveTelegramToken(effective); const { token: telegramToken } = resolveTelegramToken(effective);
const telegramConfigured = Boolean(telegramToken?.trim()); const telegramConfigured = Boolean(telegramToken?.trim());
lines.push( lines.push(
telegramConfigured ? "Telegram: configured" : "Telegram: not configured", telegramConfigured
? tint("Telegram: configured", chalk.green)
: tint("Telegram: not configured", chalk.cyan),
); );
} }
const signalEnabled = effective.signal?.enabled !== false; const signalEnabled = effective.signal?.enabled !== false;
if (!signalEnabled) { if (!signalEnabled) {
lines.push("Signal: disabled"); lines.push(tint("Signal: disabled", chalk.cyan));
} else { } else {
const signalConfigured = const signalConfigured =
Boolean(effective.signal) && Boolean(effective.signal) &&
@@ -53,20 +74,33 @@ export async function buildProviderSummary(
typeof effective.signal?.autoStart === "boolean", typeof effective.signal?.autoStart === "boolean",
); );
lines.push( lines.push(
signalConfigured ? "Signal: configured" : "Signal: not configured", signalConfigured
? tint("Signal: configured", chalk.green)
: tint("Signal: not configured", chalk.cyan),
); );
} }
const imessageEnabled = effective.imessage?.enabled !== false; const imessageEnabled = effective.imessage?.enabled !== false;
if (!imessageEnabled) { if (!imessageEnabled) {
lines.push("iMessage: disabled"); lines.push(tint("iMessage: disabled", chalk.cyan));
} else { } else {
const imessageConfigured = Boolean(effective.imessage); const imessageConfigured = Boolean(effective.imessage);
lines.push( lines.push(
imessageConfigured ? "iMessage: configured" : "iMessage: not configured", imessageConfigured
? tint("iMessage: configured", chalk.green)
: tint("iMessage: not configured", chalk.cyan),
); );
} }
if (resolved.includeAllowFrom) {
const allowFrom = effective.whatsapp?.allowFrom?.length
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
: [];
if (allowFrom.length) {
lines.push(tint(`AllowFrom: ${allowFrom.join(", ")}`, chalk.cyan));
}
}
return lines; return lines;
} }