maestro/src/engine/context/prompt-guard.ts
oss-sync 77ee3bc426
Some checks are pending
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

268 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Message, ToolDef } from '../../llm/openai-compat.js';
import type { ContextManager } from '../context-manager.js';
import type { HistorySummarizationConfig } from '../../config.js';
import { dedupeFileReads } from './file-read-dedup.js';
import { summarizeHistory } from './history-compactor.js';
import {
estimateTokensFromText,
estimateMessagesTokens,
estimateToolsTokens,
PROMPT_BASE_OVERHEAD_TOKENS,
} from './token-estimate.js';
import { logger } from '../../logger.js';
export const PROMPT_GUARD_RATIO_DEFAULT = 0.8;
/**
* Fallback used only when the LLM client's preflight error message can't be
* parsed for a safe-limit value. Conservative on purpose: kicks in only in
* degraded paths.
*/
export const PROMPT_GUARD_FALLBACK_TOKENS = 24_000;
/** Tool result messages above this size become candidates for compaction. */
export const LARGE_TOOL_RESULT_TOKENS = 8_000;
/**
* Absolute cap on the headroom reserved above the prompt when deciding the
* compact-and-continue trigger. Opencode/Crush compact at `limit - reserve`
* rather than a flat percentage; a pure ratio wastes hundreds of K of usable
* context on large-context models (a 1M model at 0.8 throws away 200K it could
* have kept). We keep the ratio as the reserve for small/medium models but cap
* the absolute reserve here so large-context models retain raw context longer.
*/
export const RESERVE_CAP_TOKENS_DEFAULT = 32_000;
/**
* Compute the prompt-size ceiling that triggers compact-and-continue.
*
* reserve = min(limit * (1 - ratio), reserveCapTokens)
* maxPromptTokens = limit - reserve
*
* - Small/medium limits (≤ reserveCapTokens / (1 - ratio)): the ratio reserve
* is below the cap, so this is identical to the old `floor(limit * ratio)`.
* - Large limits: the absolute cap binds, so the trigger moves up and the model
* uses its headroom instead of compacting early.
*/
export function computeMaxPromptTokens(
limitTokens: number,
promptGuardRatio: number,
reserveCapTokens: number = RESERVE_CAP_TOKENS_DEFAULT,
): number {
const ratioReserve = limitTokens * (1 - promptGuardRatio);
const reserve = Math.min(ratioReserve, reserveCapTokens);
return Math.max(1, Math.floor(limitTokens - reserve));
}
export type GuardResult =
| {
ok: true;
estimatedTokens: number;
compacted: boolean;
deduped: boolean;
summarized: boolean;
feedback?: string;
}
| {
ok: false;
estimatedTokens: number;
limitTokens: number;
message: string;
};
export interface GuardOptions {
promptGuardRatio?: number;
historySummarization?: HistorySummarizationConfig;
runIsolatedLlm?: (messages: Message[]) => Promise<string>;
}
export function looksLikeLargeEncodedPayload(text: string): boolean {
if (text.length < 8_000) return false;
if (/data:[^;,\s]+;base64,[A-Za-z0-9+/=\s]{2000,}/.test(text)) return true;
if (/base64[,:"'\s]+[A-Za-z0-9+/=\s]{2000,}/i.test(text)) return true;
return false;
}
export function buildPromptLimitAgentInstruction(estimatedTokens: number, maxPromptTokens: number): string {
return [
'前回のツール結果または会話履歴が大きすぎるため、一部の内容は LLM コンテキストに入れられませんでした。',
`推定 prompt サイズ: ${estimatedTokens.toLocaleString()} tokens / 安全上限: ${maxPromptTokens.toLocaleString()} tokens。`,
'全文を再読込しようとせず、必要な箇所を絞って調査を続けてください。',
'推奨行動: Read(offset/limit), Read(byte_offset/byte_length), Grep, または対象を絞った Bash で必要範囲だけ確認してください。',
'ユーザーに確認する前に、まず自分で範囲指定や検索に切り替えて続行してください。',
].join('\n');
}
export function parsePromptSafeLimitTokens(errorMessage: string): number | null {
const match = /safe limit ([\d,]+) tokens/i.exec(errorMessage);
if (!match) return null;
const parsed = Number.parseInt(match[1]!.replace(/,/g, ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
/**
* Replace `role: 'tool'` messages whose content exceeds LARGE_TOOL_RESULT_TOKENS
* with a short placeholder, in descending size order, until the prompt fits or
* candidates are exhausted. Mutates `messages` in place.
*
* Tracks the size delta directly instead of re-walking messages each iteration —
* O(candidates) vs the previous O(messages × candidates).
*/
export function compactOversizedToolResults(
messages: Message[],
fixedTokens: number, // non-message tokens: tool definitions + request base overhead
maxPromptTokens: number,
): { changed: boolean; estimatedTokens: number; omittedCount: number } {
let estimatedTokens = estimateMessagesTokens(messages) + fixedTokens;
let changed = false;
let omittedCount = 0;
if (estimatedTokens <= maxPromptTokens) return { changed, estimatedTokens, omittedCount };
const candidates = messages
.map((message, index) => ({
message,
index,
tokens: typeof message.content === 'string' ? estimateTokensFromText(message.content) : 0,
}))
.filter(({ message, tokens }) => message.role === 'tool' && tokens >= LARGE_TOOL_RESULT_TOKENS)
.sort((a, b) => b.tokens - a.tokens);
for (const candidate of candidates) {
if (estimatedTokens <= maxPromptTokens) break;
const content = typeof candidate.message.content === 'string' ? candidate.message.content : '';
const encodedHint = looksLikeLargeEncodedPayload(content)
? ' The omitted content appears to contain base64/data URLs.'
: '';
const placeholder = [
'[Tool result omitted before LLM request]',
`The previous tool result was too large to fit safely in the model context.${encodedHint}`,
'Use a narrower Read(offset/limit), Read(byte_offset/byte_length), Grep, or a targeted Bash command to inspect only the needed range.',
].join('\n');
const placeholderTokens = estimateTokensFromText(placeholder);
estimatedTokens = estimatedTokens - candidate.tokens + placeholderTokens;
candidate.message.content = placeholder;
changed = true;
omittedCount++;
}
return { changed, estimatedTokens, omittedCount };
}
/**
* Three-stage prompt-overflow defense, run before every LLM request.
*
* Stage 1 — dedupe duplicate file Reads (cheap, no LLM call, no info loss
* since the latest Read of each file is preserved).
* Stage 2 — compact oversized tool results (prune large tool messages).
* Stage 3 — anchored Markdown history summarization via runIsolatedLlm
* (Opencode-style); skipped if disabled in config or no LLM hook.
*
* Returns ok:false only when all three stages fail to bring the prompt under
* `promptGuardRatio` of the model context limit. The caller (executeMovement)
* decides whether to ABORT or force-transition.
*/
export async function guardPromptBeforeSend(
messages: Message[],
tools: ToolDef[],
contextManager?: ContextManager,
options: GuardOptions = {},
): Promise<GuardResult> {
const promptGuardRatio = options.promptGuardRatio ?? PROMPT_GUARD_RATIO_DEFAULT;
// tools is built once per movement and never mutated, so JSON.stringify it
// exactly once instead of recomputing inside every estimate call.
// The fixed part of every estimate is tools + the client's per-request base
// overhead — the guard must count exactly what the client preflight counts
// (see estimateRequestTokens), or prompts in the last-overhead-band pass
// here but are blocked at send time forever.
const fixedTokens = estimateToolsTokens(tools) + PROMPT_BASE_OVERHEAD_TOKENS;
if (!contextManager) {
return {
ok: true,
estimatedTokens: estimateMessagesTokens(messages) + fixedTokens,
compacted: false,
deduped: false,
summarized: false,
};
}
const limitTokens = contextManager.getContextLimit();
const reserveCapTokens = options.historySummarization?.reserveCapTokens ?? RESERVE_CAP_TOKENS_DEFAULT;
const maxPromptTokens = computeMaxPromptTokens(limitTokens, promptGuardRatio, reserveCapTokens);
let estimated = estimateMessagesTokens(messages) + fixedTokens;
if (estimated <= maxPromptTokens) {
return { ok: true, estimatedTokens: estimated, compacted: false, deduped: false, summarized: false };
}
const dedup = dedupeFileReads(messages);
if (dedup.changed) {
estimated = estimateMessagesTokens(messages) + fixedTokens;
logger.info(`[prompt-guard] file-read dedup replaced=${dedup.replacedCount} freedChars=${dedup.freedChars} estimated=${estimated}`);
}
if (estimated <= maxPromptTokens) {
return { ok: true, estimatedTokens: estimated, compacted: false, deduped: dedup.changed, summarized: false };
}
const compacted = compactOversizedToolResults(messages, fixedTokens, maxPromptTokens);
let summarized = false;
if (compacted.changed) {
const feedback = buildPromptLimitAgentInstruction(compacted.estimatedTokens, maxPromptTokens);
// Pop on overshoot: the feedback instruction is only valuable when it
// actually fits — otherwise we'd carry redundant guidance into stage 3.
messages.push({ role: 'user', content: feedback });
const estimatedWithFeedback = compacted.estimatedTokens + estimateTokensFromText(feedback);
if (estimatedWithFeedback <= maxPromptTokens) {
return {
ok: true,
estimatedTokens: estimatedWithFeedback,
compacted: true,
deduped: dedup.changed,
summarized: false,
feedback,
};
}
messages.pop();
}
estimated = compacted.estimatedTokens;
if (estimated <= maxPromptTokens) {
return {
ok: true,
estimatedTokens: estimated,
compacted: compacted.changed,
deduped: dedup.changed,
summarized: false,
};
}
const summarizationEnabled = options.historySummarization?.enabled !== false;
if (summarizationEnabled && options.runIsolatedLlm) {
const tailTurns = options.historySummarization?.tailTurns ?? 2;
const preserveRecentBudget = options.historySummarization?.preserveRecentBudget
?? Math.min(Math.floor(limitTokens * 0.25), 8_000);
const summary = await summarizeHistory(messages, {
tailTurns,
preserveRecentBudget,
runIsolatedLlm: options.runIsolatedLlm,
});
if (summary.summarized) {
summarized = true;
estimated = estimateMessagesTokens(messages) + fixedTokens;
logger.info(`[prompt-guard] history summarization complete freedChars=${summary.freedChars} estimated=${estimated}`);
if (estimated <= maxPromptTokens) {
return {
ok: true,
estimatedTokens: estimated,
compacted: compacted.changed,
deduped: dedup.changed,
summarized: true,
};
}
} else {
logger.warn(`[prompt-guard] history summarization skipped: ${summary.reason}`);
}
}
return {
ok: false,
estimatedTokens: estimated,
limitTokens,
message: `LLM request blocked before send: estimated prompt size ${estimated.toLocaleString()} tokens exceeds safe limit ${maxPromptTokens.toLocaleString()} tokens (${Math.round(promptGuardRatio * 100)}% of context ${limitTokens.toLocaleString()})${summarized ? ' (after dedup, compaction, and history summarization)' : ''}. Narrow the requested content with Read(offset/limit), Read(byte_offset/byte_length), Grep, or targeted Bash before continuing.`,
};
}