236 lines
9.9 KiB
TypeScript
236 lines
9.9 KiB
TypeScript
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' };
|
||
}
|