Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions devlog/2026-07-01_principle-driven-prompts/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Principle-Driven Compression Prompts

## Problem
System prompt had 72 lines of detailed compression rules (7-item priority list, 3 pressure levels, hardcoded thresholds). Models mechanically followed rules regardless of context level — compressing at 6% context, losing critical task details.

## Requirements
- R1: Simplify system prompt to high-level principles (~15 lines)
- R2: Per-message shows only context number (no compression guidance)
- R3: Every 10 percentage points (from 15%): show Tips with tool names (not commands)
- R4: Below 15%: no compression prompts at all
- R5: At 65%+: stronger tone about overflow risk
- R6: Add "BE FRUGAL" section with examples of obvious waste
- R7: Config: minNudgeContextPercent=15, growthPercent=10pp

## Design Philosophy
Minimal intervention. Give smart models principles, not rules. Let them decide when/what to compress.
48 changes: 48 additions & 0 deletions devlog/2026-07-01_principle-driven-prompts/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Worklog: Principle-Driven Compression Prompts

## Changes (commit 77098fe, 11 files, +158/-118)

### system.ts (72→27 lines)
- Removed: CONTEXT PRESSURE LEVELS, WHAT TO COMPRESS FIRST (7-item list), DO NOT RE-COMPRESS, WHAT TO COMPRESS CAREFULLY, BEFORE/AFTER COMPRESSING
- Added: 2 failure modes principle (over-compress loses detail, under-compress causes overflow)
- Added: BE FRUGAL section with 5 examples (command output, sub-agent results, training logs, duplicate reads, failed explorations)
- Fixed: Empty backtick tags → `<dcp-message-id>` and `<dcp-system-reminder>`

### utils.ts (buildContextUsageGuidance)
- Removed: All guidance text ("Be frugal", "Extract and keep what matters", pressure level descriptions)
- Now returns: Just "Context usage: XK / 1000K tokens (X%)" — no suggestions

### inject.ts (shouldInjectPerMessageNudge)
- Added: minNudgeContextPercent check (default 15%) — below 15%, no nudge
- Changed: Growth from relative (%) to absolute (percentage points)
- Changed: Default growth threshold 3→10pp
- Tips: Only tool names, no compression commands

### nudge.ts (buildCompressedBlockGuidance)
- Removed: Consolidation suggestion
- Added: Block token counts in list — `b50 (76t), b51 (88t), ...`

### range.ts + message.ts (compress tools)
- Removed: Dual-tier soft/hard limit
- Added: Single 3000-char limit
- Added: `summaryMaxChars` optional parameter for override
- Error: "Add summaryMaxChars parameter to allow longer summaries."

### decompress.ts
- Added: `toFile` optional parameter — writes content to file without inflating context
- Block stays compressed when toFile is used

### config.ts
- Added: minNudgeContextPercent (default 15)

### tests/nudge-text.test.ts
- Updated: All assertions for simplified output

## Config Updates (not in commit)
- acp.jsonc: nudgeFrequency=6, perMessageNudgeGrowthPercent=10
- dcp.jsonc: nudgeFrequency=6, perMessageNudgeGrowthPercent=10

## Verification
- typecheck: 0 errors
- tests: 494 pass, 0 fail
- build: 330KB
31 changes: 31 additions & 0 deletions lib/compress/decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ function buildSchema() {
blockId: tool.schema
.string()
.describe('Block reference to decompress (e.g., "b0", "b2")'),
toFile: tool.schema
.string()
.optional()
.describe("If provided, writes restored content to this file path instead of inflating context. Block stays compressed. Use read tool to access specific parts. Example: '/tmp/block52.txt'"),
}
}

Expand Down Expand Up @@ -121,6 +125,33 @@ export function createDecompressTool(ctx: ToolContext): ReturnType<typeof tool>
return `Error: Block ${target.displayId} is not active. It may have already been decompressed.`
}

if (args.toFile) {
const block = activeBlocks[0]
const msgIds = new Set(block.effectiveMessageIds ?? [])
const blockMessages = rawMessages.filter((m) => {
const id = (m as { id?: string }).id ?? (m as { messageId?: string }).messageId ?? ""
return msgIds.has(id)
})
const lines = blockMessages.map((m) => {
const msg = m as { role?: string; type?: string; content?: unknown; text?: string }
const role = msg.role || msg.type || "unknown"
const content =
typeof msg.content === "string"
? msg.content
: typeof msg.text === "string"
? msg.text
: JSON.stringify(msg.content || msg.text || "")
return `[${role}]\n${content}`
})
const { writeFile } = await import("fs/promises")
const fileContent =
lines.length > 0
? lines.join("\n\n---\n\n")
: (block.summary ?? "(no content available)")
await writeFile(args.toFile as string, fileContent, "utf-8")
return `Block b${target.displayId} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${args.toFile}. Block stays compressed — context unchanged. Use read tool to access specific parts.`
}

const activeMessagesBefore = snapshotActiveMessages(messagesState)
const activeBlockIdsBefore = new Set(messagesState.activeBlockIds)

Expand Down
16 changes: 10 additions & 6 deletions lib/compress/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "./state"
import type { CompressMessageToolArgs } from "./types"

function buildSchema(maxSummaryLength: number) {
function buildSchema() {
return {
topic: tool.schema
.string()
Expand All @@ -32,11 +32,15 @@ function buildSchema(maxSummaryLength: number) {
summary: tool.schema
.string()
.describe(
`Complete technical summary replacing that one message. Aim for <=${maxSummaryLength} chars; exceed only when strictly necessary to preserve critical detail (file paths, decisions, signatures, exact values). Never pad.`,
"Complete technical summary replacing that one message. Max 3000 chars by default.",
),
}),
)
.describe("Batch of individual message summaries to create in one tool call"),
summaryMaxChars: tool.schema
.number()
.optional()
.describe("Override max summary length if default (3000) is too small."),
}
}

Expand All @@ -46,16 +50,16 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t

return tool({
description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
args: buildSchema(ctx.config.compress.maxSummaryLength),
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressMessageToolArgs
validateArgs(input)

const maxSummaryLengthHard = ctx.config.compress.maxSummaryLengthHard
const maxLen = (args as { summaryMaxChars?: number }).summaryMaxChars ?? 3000
for (const entry of input.content) {
if (entry.summary.length > maxSummaryLengthHard) {
if (entry.summary.length > maxLen) {
throw new Error(
`Summary too long (${entry.summary.length} chars; limit ${maxSummaryLengthHard}). Rewrite to under ${maxSummaryLengthHard} chars — keep only the most essential details (conclusions, file paths, decisions, exact values) and drop verbose narration or raw dumps.`,
`Summary too long (${entry.summary.length} chars, max ${maxLen}). Add summaryMaxChars parameter to allow longer summaries.`,
)
}
}
Expand Down
18 changes: 12 additions & 6 deletions lib/compress/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from "./state"
import type { CompressRangeToolArgs } from "./types"

function buildSchema(maxSummaryLength: number) {
function buildSchema() {
return {
topic: tool.schema
.string()
Expand All @@ -45,13 +45,19 @@ function buildSchema(maxSummaryLength: number) {
summary: tool.schema
.string()
.describe(
`Complete technical summary replacing all content in range. Aim for <=${maxSummaryLength} chars; exceed only when strictly necessary to preserve critical detail (file paths, decisions, signatures, exact values). Never pad.`,
"Complete technical summary replacing all content in range. Include key decisions, file paths, function signatures, and exact values. Max 3000 chars by default.",
),
}),
)
.describe(
"One or more ranges to compress, each with start/end boundaries and a summary",
),
summaryMaxChars: tool.schema
.number()
.optional()
.describe(
"Override max summary length if default (3000) is too small. Use only when the range contains critical detail requiring more space.",
),
}
}

Expand All @@ -61,16 +67,16 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too

return tool({
description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
args: buildSchema(ctx.config.compress.maxSummaryLength),
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressRangeToolArgs
validateArgs(input)

const maxSummaryLengthHard = ctx.config.compress.maxSummaryLengthHard
const maxLen = (args as { summaryMaxChars?: number }).summaryMaxChars ?? 3000
for (const entry of input.content) {
if (entry.summary.length > maxSummaryLengthHard) {
if (entry.summary.length > maxLen) {
throw new Error(
`Summary too long (${entry.summary.length} chars; limit ${maxSummaryLengthHard}). Rewrite to under ${maxSummaryLengthHard} chars — keep only the most essential details (conclusions, file paths, decisions, exact values) and drop verbose narration or raw dumps.`,
`Summary too long (${entry.summary.length} chars, max ${maxLen}). Add summaryMaxChars parameter to allow longer summaries.`,
)
}
}
Expand Down
3 changes: 3 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface CompressConfig {
modelMinLimits?: Record<string, number | `${number}%`>
nudgeFrequency: number
perMessageNudgeGrowthPercent: number
minNudgeContextPercent: number
iterationNudgeThreshold: number
nudgeForce: "strong" | "soft"
protectedTools: string[]
Expand Down Expand Up @@ -192,6 +193,7 @@ const defaultConfig: PluginConfig = {
minContextLimit: "45%",
nudgeFrequency: 5,
perMessageNudgeGrowthPercent: 3,
minNudgeContextPercent: 15,
iterationNudgeThreshold: 15,
nudgeForce: "soft",
protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
Expand Down Expand Up @@ -402,6 +404,7 @@ function mergeCompress(
modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
perMessageNudgeGrowthPercent: override.perMessageNudgeGrowthPercent ?? base.perMessageNudgeGrowthPercent,
minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent,
iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
nudgeForce: override.nudgeForce ?? base.nudgeForce,
protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])],
Expand Down
26 changes: 17 additions & 9 deletions lib/messages/inject/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,19 @@ function shouldInjectPerMessageNudge(
currentTokens?: number,
modelContextLimit?: number,
): boolean {
const turn = state.currentTurn ?? 0
const lastTurn = state.nudges.lastPerMessageNudgeTurn ?? 0
const turnsSinceLast = turn - lastTurn

const tokens = currentTokens ?? 0
const pct = modelContextLimit ? (tokens / modelContextLimit) * 100 : 0

// Below minimum threshold — no tips
const minPercent = config.compress.minNudgeContextPercent ?? 15
if (pct < minPercent) return false

// Fire every N percentage points (absolute, not relative)
const lastTokens = state.nudges.lastPerMessageNudgeTokens ?? 0
const tokenGrowth = tokens - lastTokens
const tokenGrowthPercent = modelContextLimit ? (tokenGrowth / modelContextLimit) * 100 : 0
const lastPct = modelContextLimit ? (lastTokens / modelContextLimit) * 100 : 0
const growthThreshold = config.compress.perMessageNudgeGrowthPercent ?? 10

const frequency = config.compress.nudgeFrequency ?? 5
const growthThreshold = config.compress.perMessageNudgeGrowthPercent ?? 3
return turnsSinceLast >= frequency || tokenGrowthPercent >= growthThreshold
return (pct - lastPct) >= growthThreshold
}

export const injectCompressNudges = (
Expand Down Expand Up @@ -205,6 +206,13 @@ export const injectCompressNudges = (
}

if (shouldNudge) {
const pct = modelContextLimit ? ((currentTokens ?? 0) / modelContextLimit) * 100 : 0
const tips = pct >= 65
? "\n\n⚠️ Context is high. Keep context tidy to reduce overflow risk. Tools: compress, decompress, search_context."
: "\n\n💡 Tools: compress, decompress, search_context."
if (suffixMessage) {
appendToLastTextPart(suffixMessage, tips)
}
state.nudges.lastPerMessageNudgeTurn = state.currentTurn ?? 0
state.nudges.lastPerMessageNudgeTokens = currentTokens ?? 0
}
Expand Down
20 changes: 1 addition & 19 deletions lib/messages/inject/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,25 +391,7 @@ export function buildContextUsageGuidance(
const percentage = pct.toFixed(1)
const formatK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n))

const minPct = resolveThresholdPercent(config.compress.minContextLimit, modelContextLimit) ?? 45
const maxPct = resolveThresholdPercent(config.compress.maxContextLimit, modelContextLimit) ?? 55

const base = `Context usage: ${formatK(currentTokens)} / ${formatK(modelContextLimit)} tokens (${percentage}%).`

if (minimal) {
return `\n\n${base}`
}

let guidance: string
if (pct < minPct) {
guidance = " 💡 Be frugal with context. If any visible tool output exceeds 5000 characters and you've finished reading it, compress it into a summary now — don't keep large outputs 'just in case'. You can decompress later if needed."
} else if (pct < maxPct) {
guidance = " ⚠️ Context is growing — compress completed sections and high-token waste now."
} else {
guidance = " 🔥 Context is high — compress aggressively, preserve only what is essential."
}

return `\n\n${base}${guidance}`
return `\n\nContext usage: ${formatK(currentTokens)} / ${formatK(modelContextLimit)} tokens (${percentage}%).`
}

export function applyAnchoredNudges(
Expand Down
8 changes: 5 additions & 3 deletions lib/prompts/extensions/nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export function buildCompressedBlockGuidance(
.filter((id) => Number.isInteger(id) && id > 0)
.sort((a, b) => a - b)

const refs = activeBlockIds.map((id) => `b${id}`)
const refs = activeBlockIds.map((id) => {
const block = state.prune.messages.blocksById.get(id)
const tokens = block?.summaryTokens ?? 0
return `b${id}${tokens > 0 ? ` (${tokens}t)` : ""}`
})
const blockCount = refs.length
let blockList: string
if (blockCount <= 20) {
Expand Down Expand Up @@ -90,8 +94,6 @@ export function buildCompressedBlockGuidance(
lines.push(...targets)
lines.push(` System auto-detects blocks in range — no need to manually list (bN) placeholders. Just write a short prose summary.`)
}
} else {
lines.push(`- 🔀 You have ${blockCount} blocks — use compress to consolidate adjacent same-topic blocks.`)
}
}

Expand Down
Loading
Loading