From e8420bd047ac83596052cd0e16e8f7a1a248c9cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 10:03:50 +0000 Subject: [PATCH] fix: refine bootstrap injections --- CHANGELOG.md | 1 + docs/concepts/agent.md | 2 + src/agents/pi-embedded-helpers.test.ts | 103 +++++++++---------------- src/agents/pi-embedded-helpers.ts | 13 +++- src/auto-reply/reply.triggers.test.ts | 2 +- src/auto-reply/reply/groups.ts | 2 +- src/commands/status.ts | 5 +- src/infra/provider-summary.ts | 52 ++++++++++--- 8 files changed, 98 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 494a08cc8..8e4248a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 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: 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: 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. diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 6929a4e74..307aaa534 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -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. +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). `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. diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 124965014..c36664dba 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -1,77 +1,48 @@ -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; -import type { ThinkLevel } from "../auto-reply/thinking.js"; + +import { buildBootstrapContextFiles } from "./pi-embedded-helpers.js"; import { - isRateLimitAssistantError, - pickFallbackThinkingLevel, -} from "./pi-embedded-helpers.js"; + DEFAULT_AGENTS_FILENAME, + type WorkspaceBootstrapFile, +} from "./workspace.js"; -const asAssistant = (overrides: Partial) => - ({ - role: "assistant", - stopReason: "error", - ...overrides, - }) as AssistantMessage; - -describe("isRateLimitAssistantError", () => { - 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); - }); +const makeFile = ( + overrides: Partial, +): WorkspaceBootstrapFile => ({ + name: DEFAULT_AGENTS_FILENAME, + path: "/tmp/AGENTS.md", + content: "", + missing: false, + ...overrides, }); -describe("pickFallbackThinkingLevel", () => { - it("selects the first supported thinking level", () => { - const attempted = new Set(["low"]); - const next = pickFallbackThinkingLevel({ - message: - "Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.", - attempted, - }); - expect(next).toBe("medium"); +describe("buildBootstrapContextFiles", () => { + it("keeps missing markers", () => { + const files = [makeFile({ missing: true, content: undefined })]; + expect(buildBootstrapContextFiles(files)).toEqual([ + { + path: DEFAULT_AGENTS_FILENAME, + content: "[MISSING] Expected at: /tmp/AGENTS.md", + }, + ]); }); - it("skips already attempted levels", () => { - const attempted = new Set(["low", "medium"]); - const next = pickFallbackThinkingLevel({ - message: "Supported values are: 'medium', 'high', and 'xhigh'.", - attempted, - }); - expect(next).toBe("high"); + it("skips empty or whitespace-only content", () => { + const files = [makeFile({ content: " \n " })]; + expect(buildBootstrapContextFiles(files)).toEqual([]); }); - it("returns undefined when no supported values are found", () => { - const attempted = new Set(["low"]); - const next = pickFallbackThinkingLevel({ - message: "Request failed.", - attempted, - }); - expect(next).toBeUndefined(); + it("truncates large bootstrap content", () => { + const head = `HEAD-${"a".repeat(6000)}`; + const tail = `${"b".repeat(3000)}-TAIL`; + const long = `${head}${tail}`; + const files = [makeFile({ content: long })]; + const [result] = buildBootstrapContextFiles(files); + 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); }); }); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 82c552130..750c9504b 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -109,13 +109,18 @@ export function buildBootstrapContextFiles( ): EmbeddedContextFile[] { const result: EmbeddedContextFile[] = []; for (const file of files) { - if (file.missing) continue; - const content = file.content ?? ""; - const trimmed = content.trimEnd(); + if (file.missing) { + result.push({ + path: file.name, + content: `[MISSING] Expected at: ${file.path}`, + }); + continue; + } + const trimmed = trimBootstrapContent(file.content ?? "", file.name); if (!trimmed) continue; result.push({ path: file.name, - content: trimBootstrapContent(trimmed, file.name), + content: trimmed, }); } return result; diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index c1cdc0647..cc642196d 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -1006,7 +1006,7 @@ describe("trigger handling", () => { describe("group intro prompts", () => { 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 () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 3d5b60ca1..eccb7674c 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -164,7 +164,7 @@ export function buildGroupIntro(params: { ? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent." : undefined; 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 [ subjectLine, membersLine, diff --git a/src/commands/status.ts b/src/commands/status.ts index 2d5db34c1..911d5f438 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -66,7 +66,10 @@ export async function getStatusSummary(): Promise { const linked = await webAuthExists(account.authDir); const authAgeMs = getWebAuthAgeMs(account.authDir); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); - const providerSummary = await buildProviderSummary(cfg); + const providerSummary = await buildProviderSummary(cfg, { + colorize: true, + includeAllowFrom: true, + }); const queuedSystemEvents = peekSystemEvents(); const resolved = resolveConfiguredModelRef({ diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 02ce6bc09..93baf56c8 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -1,20 +1,36 @@ +import chalk from "chalk"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { normalizeE164 } from "../utils.js"; import { getWebAuthAgeMs, readWebSelfId, webAuthExists, } from "../web/session.js"; +export type ProviderSummaryOptions = { + colorize?: boolean; + includeAllowFrom?: boolean; +}; + +const DEFAULT_OPTIONS: Required = { + colorize: false, + includeAllowFrom: false, +}; + export async function buildProviderSummary( cfg?: ClawdbotConfig, + options?: ProviderSummaryOptions, ): Promise { const effective = cfg ?? loadConfig(); 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; if (!webEnabled) { - lines.push("WhatsApp: disabled"); + lines.push(tint("WhatsApp: disabled", chalk.cyan)); } else { const webLinked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); @@ -22,25 +38,30 @@ export async function buildProviderSummary( const { e164 } = readWebSelfId(); lines.push( webLinked - ? `WhatsApp: linked${e164 ? ` ${e164}` : ""}${authAge}` - : "WhatsApp: not linked", + ? tint( + `WhatsApp: linked${e164 ? ` ${e164}` : ""}${authAge}`, + chalk.green, + ) + : tint("WhatsApp: not linked", chalk.red), ); } const telegramEnabled = effective.telegram?.enabled !== false; if (!telegramEnabled) { - lines.push("Telegram: disabled"); + lines.push(tint("Telegram: disabled", chalk.cyan)); } else { const { token: telegramToken } = resolveTelegramToken(effective); const telegramConfigured = Boolean(telegramToken?.trim()); 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; if (!signalEnabled) { - lines.push("Signal: disabled"); + lines.push(tint("Signal: disabled", chalk.cyan)); } else { const signalConfigured = Boolean(effective.signal) && @@ -53,20 +74,33 @@ export async function buildProviderSummary( typeof effective.signal?.autoStart === "boolean", ); 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; if (!imessageEnabled) { - lines.push("iMessage: disabled"); + lines.push(tint("iMessage: disabled", chalk.cyan)); } else { const imessageConfigured = Boolean(effective.imessage); 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; }