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, ): Promise { 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; } 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 { 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' }; }