maestro/src/engine/agent-loop/context-control.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

236 lines
9.9 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 { EventLogger } from '../../progress/event-log.js';
import type { SafetyConfig } from '../../config.js';
import { logger } from '../../logger.js';
import type { Message, ToolDef } from '../../llm/openai-compat.js';
import { ContextManager, type ContextAction } from '../context-manager.js';
import { summarizeForceTransition } from '../context/history-compactor.js';
import { guardPromptBeforeSend, parsePromptSafeLimitTokens } from '../context/prompt-guard.js';
import type { AgentLoopCallbacks, Movement, MovementResult } from './types.js';
/**
* Codex follow-up #2: the single terminal-default policy for context-overflow
* forced exits. Terminal defaults (COMPLETE/ASK) become a false success/ask on
* context loss and skip the worker retry path, so they are normalized to ABORT.
* Mid-piece movement names (verify, aggregate, …) are honored so the piece can
* still progress. Used by every context-overflow exit so both paths agree.
*/
export function resolveContextOverflowNext(defaultNext: string | undefined): string {
if (!defaultNext || defaultNext === 'COMPLETE' || defaultNext === 'ASK') return 'ABORT';
return defaultNext;
}
/**
* Build the result returned when prompt-guard cannot recover by other means.
* Prefers force-transition to movement.defaultNext (with a last-resort LLM
* summary handed off as the next movement's input), falling back to ABORT
* only when defaultNext is absent or terminal.
*/
export async function buildContextOverflowResult(
movement: Movement,
guardMessage: string,
messages: Message[],
toolsUsed: string[],
runIsolatedLlm?: (messages: Message[]) => Promise<string>,
): Promise<MovementResult> {
const fallbackNext = resolveContextOverflowNext(movement.defaultNext);
if (fallbackNext === 'ABORT') {
return { next: 'ABORT', output: guardMessage, toolsUsed, abortCode: 'context_overflow' };
}
let handoffSummary: string | null = null;
if (runIsolatedLlm) {
try {
handoffSummary = await summarizeForceTransition(messages, runIsolatedLlm);
} catch {
handoffSummary = null;
}
}
const output = handoffSummary
? [
'[Context overflow — forced handoff]',
`Reason: ${guardMessage}`,
'',
'## Carried-over summary for the next step',
handoffSummary,
].join('\n')
: [
'[Context overflow — forced handoff without summary]',
`Reason: ${guardMessage}`,
'The agent ran out of context budget before producing an organic transition. The next movement should re-verify state before assuming progress.',
].join('\n');
return {
next: fallbackNext,
output,
toolsUsed,
lessons: 'Context overflow forced this transition. Downstream movements should re-verify file state and progress before assuming this step finished cleanly.',
};
}
const USAGE_FALLBACK_AFTER_ITERATIONS = 3;
/**
* After each LLM iteration, update the ContextManager with the freshly
* reported `usage.prompt_tokens` (when present) and react to whatever
* threshold action it returns.
*/
export function applyContextManagerUpdate(
contextManager: ContextManager,
lastUsage: { prompt_tokens: number; completion_tokens: number } | undefined,
iteration: number,
movement: Movement,
toolsUsed: string[],
messages: Message[],
callbacks: AgentLoopCallbacks | undefined,
eventLogger?: EventLogger,
): MovementResult | null {
const buildForceTransitionResult = (reason: string): MovementResult => {
// Codex #2: same terminal-default policy as buildContextOverflowResult —
// a terminal default would be a false success/ask on context loss.
const forceNext = resolveContextOverflowNext(movement.defaultNext);
return {
next: forceNext,
output: `Context limit reached (${reason}). Forced transition to ${forceNext}.`,
toolsUsed,
...(forceNext === 'ABORT' ? { abortCode: 'context_overflow' } : {}),
};
};
const handleAction = (action: ContextAction, fallbackReason: string): MovementResult | null => {
callbacks?.onContextAction?.(action);
eventLogger?.emit('context_action', {
type: action.type,
ratio: contextManager.getRatio(),
tokens: contextManager.getPromptTokens(),
limit: contextManager.getContextLimit(),
reason: fallbackReason,
});
if (action.type === 'prompt') {
messages.push({ role: 'user', content: action.message });
return null;
}
if (action.type === 'force_transition') {
logger.warn(`[agent-loop] context force_transition triggered at ratio=${contextManager.getRatio().toFixed(3)}`);
return buildForceTransitionResult(fallbackReason);
}
return null;
};
const emitContextUpdate = (): void => {
callbacks?.onContextUpdate?.({
promptTokens: contextManager.getPromptTokens(),
limitTokens: contextManager.getContextLimit(),
});
};
if (lastUsage) {
const action = contextManager.update(lastUsage);
emitContextUpdate();
if (!action) return null;
return handleAction(action, `${(contextManager.getRatio() * 100).toFixed(0)}%`);
}
if (!contextManager.hasUsageData() && iteration >= USAGE_FALLBACK_AFTER_ITERATIONS) {
let totalChars = 0;
for (const msg of messages) {
totalChars += typeof msg.content === 'string' ? msg.content.length : 0;
for (const tc of msg.tool_calls ?? []) {
totalChars += tc.function.arguments.length;
}
}
logger.info(`[agent-loop] no usage data after ${iteration} iterations, falling back to char-based estimation (${totalChars} chars)`);
const action = contextManager.updateFromChars(totalChars);
emitContextUpdate();
if (!action) return null;
return handleAction(action, 'char-based fallback');
}
return null;
}
interface LLMErrorContext {
movement: Movement;
messages: Message[];
tools: ToolDef[];
toolsUsed: string[];
contextManager?: ContextManager;
promptGuardRatio: number;
safetyConfig?: SafetyConfig;
runIsolatedLlm: (messages: Message[]) => Promise<string>;
}
const NO_TOOLS_SUPPORT_RE = /does not support tools|tool.*not.*support|tool_use.*not.*support/i;
const NO_TOOLS_MODEL_NAME_RE = /library\/([^\s"]+)|model[`'" ]+([^\s"'`]+)/i;
/**
* Translate an LLM stream error into either a recovery (return null, caller
* continues the loop) or a terminal MovementResult.
*/
export async function handleLLMError(
errorMessage: string,
ctx: LLMErrorContext,
): Promise<MovementResult | null> {
if (errorMessage.startsWith('LLM request blocked before send:')) {
const parsedSafeLimit = parsePromptSafeLimitTokens(errorMessage);
const limitTokens = ctx.contextManager?.getContextLimit();
const impliedRatio = parsedSafeLimit && limitTokens
? parsedSafeLimit / limitTokens
: ctx.promptGuardRatio;
// Recovery must target the safe limit the client actually enforced. The
// guard normally re-derives its threshold as `limit - min(ratioReserve,
// reserveCap)`, which on large-context models is LOOSER than the client's
// limit (e.g. 230,144 vs 209,715 on a 262k model) — the guard would then
// see the blocked prompt as "within limits" and shrink nothing. Pinning
// reserveCapTokens to `limit - parsedSafeLimit` makes the re-derived
// threshold equal the client's limit exactly.
const historySummarization = parsedSafeLimit && limitTokens
? { ...ctx.safetyConfig?.historySummarization, reserveCapTokens: limitTokens - parsedSafeLimit }
: ctx.safetyConfig?.historySummarization;
const recoveredGuard = await guardPromptBeforeSend(ctx.messages, ctx.tools, ctx.contextManager, {
promptGuardRatio: impliedRatio,
historySummarization,
runIsolatedLlm: ctx.runIsolatedLlm,
});
if (recoveredGuard.ok) {
const changedAnything = recoveredGuard.deduped || recoveredGuard.compacted || recoveredGuard.summarized;
if (changedAnything) {
logger.warn(`[agent-loop] movement=${ctx.movement.name} recovered from client prompt preflight block (deduped=${recoveredGuard.deduped} compacted=${recoveredGuard.compacted} summarized=${recoveredGuard.summarized}) estimated=${recoveredGuard.estimatedTokens}`);
return null;
}
// The client blocked the request but the local guard sees the prompt as
// within limits AND changed nothing — the estimators disagree. Resending
// the byte-identical request would be blocked again on every iteration
// (observed in production: 195 consecutive ~14ms failures straight to
// maxIterations). A recovery that changed nothing is not a recovery.
logger.warn(`[agent-loop] movement=${ctx.movement.name} client preflight blocked but local guard found nothing to shrink (estimated=${recoveredGuard.estimatedTokens}) — ending movement instead of resending an identical request`);
return await buildContextOverflowResult(
ctx.movement,
`${errorMessage}\n\nThe local prompt guard found nothing further to shrink; resending the identical request would spin until max iterations.`,
ctx.messages,
ctx.toolsUsed,
ctx.runIsolatedLlm,
);
}
return await buildContextOverflowResult(
ctx.movement,
`${errorMessage}\n\nRecovery via dedup, compaction, and summarization could not bring the prompt under the safe limit.`,
ctx.messages,
ctx.toolsUsed,
ctx.runIsolatedLlm,
);
}
if (errorMessage && NO_TOOLS_SUPPORT_RE.test(errorMessage)) {
const modelMatch = errorMessage.match(NO_TOOLS_MODEL_NAME_RE);
const modelName = modelMatch?.[1] ?? modelMatch?.[2] ?? '使用中のモデル';
return {
next: 'ABORT',
output: `モデル "${modelName}" はツール使用に対応していません。config.yaml の model 設定をツール対応モデル(例: qwen2.5:7b、llama3.1:8bに変更してください。`,
toolsUsed: ctx.toolsUsed,
abortCode: 'llm_unsupported_tools',
};
}
return { next: 'ABORT', output: `LLM error: ${errorMessage}`, toolsUsed: ctx.toolsUsed, abortCode: 'llm_error' };
}