import { randomUUID } from "node:crypto"; import type { Agent, AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Api, AssistantMessage, AssistantMessageEventStream, Message, Model, Models, ModelThinkingLevel, SimpleStreamOptions, TranscriptContext, } from "@earendil-works/pi-ai"; import { clampThinkingLevel, createAssistantMessageEventStream, getSystemMessageText, isContextOverflow, normalizeContext, Type, } from "@earendil-works/pi-ai"; import { builtinModels } from "@earendil-works/pi-ai/providers/all"; import type { AgentRunRequest, AgentRuntimeEvent, AgentSteeringMessage, AgentToolExecutionResult, } from "@rakazo/adapter-kit"; import { DEFAULT_MODEL_CONTEXT_WINDOW, DEFAULT_MODEL_MAX_TOKENS } from "@rakazo/contracts"; import { DEFAULT_OPENROUTER_MODEL_ID } from "./deployment-model.js"; import { PiRuntimeCredentialStore, toOAuthCredential } from "./pi-credentials.js"; import { registerLocalProvider } from "./pi-local-provider.js"; import { OPENAI_COMPATIBLE_PROVIDER_ID, registerOpenAiCompatibleCatalog, registerOpenAiCompatibleRuntime, } from "./pi-openai-compatible-provider.js"; import { clipToolResultContent, clipToolResultText, resolveCompletionMaxTokens, resolveModelStreamMaxRetries, resolveModelStreamTimeoutMs, } from "./pi-runtime-limits.js"; // --- Model catalog --- const REASONING_MODEL_THINKING_LEVEL: ModelThinkingLevel = "medium"; let catalogModelsCache: Models | undefined; export function catalogModels(): Models { catalogModelsCache ??= registerOpenAiCompatibleCatalog(registerLocalProvider(builtinModels())); return catalogModelsCache; } export function thinkingLevelFor( model: Model, preferred?: ModelThinkingLevel | null, ): ModelThinkingLevel { if (!model.reasoning) return "off"; if (preferred) return clampThinkingLevel(model, preferred); return clampThinkingLevel(model, REASONING_MODEL_THINKING_LEVEL); } /** * Selects an initial thinking level that minimizes Time-to-First-Token on short, * conversational prompts while keeping full reasoning depth on complex tasks or * explicit user preferences. Once a run enters a multi-step tool loop, callers * can escalate to `escalatedThinkingLevelFor`. */ export function initialThinkingLevelFor( model: Model, preferred?: ModelThinkingLevel | null, prompt?: string, ): ModelThinkingLevel { if (!model.reasoning) return "off"; if (preferred) return clampThinkingLevel(model, preferred); const trimmed = (prompt ?? "").trim(); const isShortConversational = trimmed.length > 0 && trimmed.length <= 160 && !trimmed.includes("```") && !/\b(plan|refactor|architect|debug|investigate|implement|optimize|analyze|migration|bug|test|fix)\b/i.test( trimmed, ); if (isShortConversational) { return clampThinkingLevel(model, "low"); } return clampThinkingLevel(model, REASONING_MODEL_THINKING_LEVEL); } /** Escalates reasoning depth once a run enters a multi-step tool or code-mutation loop. */ export function escalatedThinkingLevelFor( model: Model, preferred?: ModelThinkingLevel | null, ): ModelThinkingLevel { if (!model.reasoning) return "off"; if (preferred === "off" || preferred === "minimal" || preferred === "low") { return clampThinkingLevel(model, preferred); } return clampThinkingLevel(model, preferred ?? "high"); } const CODE_FILE_EXTENSION_PATTERN = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|c|cpp|h|hpp|cs|rb|php|sh|sql|prisma|json|yaml|yml|toml)$/i; export function isCodeFileMutationToolCall( toolName: string, args: Record, ): boolean { if (toolName !== "write_file" && toolName !== "edit_file") return false; const path = String(args.path ?? args.file_path ?? args.filePath ?? "").trim(); return CODE_FILE_EXTENSION_PATTERN.test(path); } export function isVerificationToolCall(toolName: string, args: Record): boolean { if (toolName === "read_file") return true; if (toolName !== "shell") return false; const command = String(args.command ?? args.cmd ?? "").trim(); return /\b(?:test|vitest|jest|pytest|cargo\s+test|go\s+test|tsc|typecheck|lint|biome|eslint|check|build|verify)\b/i.test( command, ); } export const CONSECUTIVE_TOOL_FAILURE_DIAGNOSTIC_HINT = "\n\n[Diagnostic hint: This tool failed twice in a row. Do not retry the same command or arguments blindly — inspect the error details, check the current file or environment state first, or switch to an alternative approach.]"; function isErrorPayload(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; if ("error" in payload && (payload as { error?: unknown }).error) return true; if ( "exitCode" in payload && typeof (payload as { exitCode?: unknown }).exitCode === "number" && (payload as { exitCode: number }).exitCode !== 0 ) { return true; } if ( "pages" in payload && Array.isArray((payload as { pages?: unknown }).pages) && (payload as { pages: unknown[] }).pages.length > 0 && (payload as { pages: unknown[] }).pages.every( (page) => Boolean(page) && typeof page === "object" && "error" in (page as Record) && Boolean((page as { error?: unknown }).error), ) ) { return true; } return false; } export function isToolResultError(result: unknown): boolean { if (isErrorPayload(result)) return true; if (result && typeof result === "object" && "details" in result) { return isErrorPayload((result as { details?: unknown }).details); } return false; } // --- Tool call budget --- export interface ToolCallBudget { count: number; exceeded: boolean; limit: number; inFlight: number; } // Optional fuse is process-local. continueRun on another worker starts at zero. const toolCallBudgetsByRun = new Map(); /** Optional self-host fuse. Unset, empty, or 0 means unlimited (default). */ export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number { const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim(); if (!raw) return 0; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return 0; return Math.floor(parsed); } export function toolCallBudgetFor(runId: string): ToolCallBudget { const existing = toolCallBudgetsByRun.get(runId); if (existing) { existing.inFlight = 0; existing.limit = maxToolCallsPerTurn(); return existing; } const budget: ToolCallBudget = { count: 0, exceeded: false, limit: maxToolCallsPerTurn(), inFlight: 0, }; if (budget.limit > 0) toolCallBudgetsByRun.set(runId, budget); return budget; } export function releaseToolCallBudget(runId: string, keepForResume: boolean) { if (!keepForResume) toolCallBudgetsByRun.delete(runId); } export function maybeAbortToolCallBudget(host: { toolCallBudget: ToolCallBudget; abortTurn(): void; }): void { if (host.toolCallBudget.exceeded && host.toolCallBudget.inFlight === 0) { host.abortTurn(); } } export function beginToolCall(host: { toolCallBudget: ToolCallBudget; abortTurn(): void; queue: { push(event: AgentRuntimeEvent): void }; }): boolean { const budget = host.toolCallBudget; if (budget.limit <= 0) { budget.count += 1; return true; } if (budget.exceeded) { maybeAbortToolCallBudget(host); return false; } budget.count += 1; if (budget.count <= budget.limit) { budget.inFlight += 1; return true; } if (!budget.exceeded) { budget.exceeded = true; host.queue.push({ type: "progress", text: `Stopped: more than ${budget.limit} tool calls in one turn.`, }); } maybeAbortToolCallBudget(host); return false; } export function endToolCall(host: { toolCallBudget: ToolCallBudget; abortTurn(): void }): void { host.toolCallBudget.inFlight = Math.max(0, host.toolCallBudget.inFlight - 1); maybeAbortToolCallBudget(host); } export function toolCallBudgetExceededMessage(limit: number): string { return `I stopped after reaching the limit of ${limit} tool calls in this turn. Send another message to continue.`; } // --- Model resolution --- export function configuredOpenRouterModel(id: string): Model<"openai-completions"> { return { id, name: id, api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: DEFAULT_MODEL_CONTEXT_WINDOW, maxTokens: DEFAULT_MODEL_MAX_TOKENS, }; } export function modelsForRequest( request: Pick, provider: string, ): Models { const oauth = request.model.oauth; if (oauth) { const persist = oauth.persist; return registerOpenAiCompatibleCatalog( registerLocalProvider( builtinModels({ credentials: new PiRuntimeCredentialStore( provider, toOAuthCredential(oauth.credential), persist ? (next) => persist(next) : undefined, ), }), ), ); } if ( provider === OPENAI_COMPATIBLE_PROVIDER_ID && request.model.baseUrl && request.model.id.trim() ) { const models = registerOpenAiCompatibleCatalog(registerLocalProvider(builtinModels())); return registerOpenAiCompatibleRuntime(models, { modelId: request.model.id, baseUrl: request.model.baseUrl, reasoning: request.model.reasoning, acceptsImages: request.model.acceptsImages, maxTokens: request.model.maxTokens, contextWindow: request.model.contextWindow, }); } return catalogModels(); } export function resolveRuntimeModel(modelConfig: AgentRunRequest["model"]): { provider: string; modelId: string; models: Models; model: Model | undefined; apiKey: string | undefined; } { const provider = modelConfig.provider === "scripted" ? "openrouter" : modelConfig.provider; const envDefaultModel = process.env.PI_DEFAULT_MODEL?.trim(); const envDefaultProvider = process.env.PI_DEFAULT_PROVIDER?.trim() || "openrouter"; const modelId = modelConfig.id === "scripted" ? envDefaultModel || DEFAULT_OPENROUTER_MODEL_ID : modelConfig.id.trim(); const models = modelsForRequest({ model: modelConfig }, provider); let model = models.getModel(provider, modelId); if (!model && provider !== "openrouter" && provider !== OPENAI_COMPATIBLE_PROVIDER_ID) { model = models.getModel("openrouter", modelId); } if ( !model && provider === "openrouter" && envDefaultProvider === "openrouter" && modelId === envDefaultModel ) { model = configuredOpenRouterModel(modelId); } const apiKey = modelConfig.oauth ? undefined : modelConfig.provider === OPENAI_COMPATIBLE_PROVIDER_ID ? modelConfig.apiKey || "local" : (modelConfig.apiKey ?? (provider === "openrouter" ? process.env.OPENROUTER_API_KEY : undefined)); return { provider, modelId, models, model, apiKey }; } // --- Image helpers --- export function toPiImages(images: AgentRunRequest["currentTurnImages"]) { return (images ?? []).map((image) => ({ type: "image" as const, data: Buffer.from(image.data).toString("base64"), mimeType: image.mimeType, })); } // --- Steering message removal --- export function withoutSteeringMessages( history: AgentRunRequest["history"], steering: AgentSteeringMessage[], ): AgentRunRequest["history"] { if (steering.length === 0) return history; const result = [...history]; let beforeIndex = result.length - 1; for (let steeringIndex = steering.length - 1; steeringIndex >= 0; steeringIndex -= 1) { const steeringMessage = steering[steeringIndex]; for (let index = beforeIndex; index >= 0; index -= 1) { const message = result[index]; if ( message?.role !== "user" || (message.id ? message.id !== steeringMessage?.messageId : message.content !== (steeringMessage?.historyText ?? steeringMessage?.text)) ) { continue; } result.splice(index, 1); beforeIndex = index - 1; break; } } return result; } // --- Request secret arguments --- export function prepareRequestSecretArguments(raw: Record) { return { label: String(raw.label ?? "Code"), purpose: String(raw.purpose ?? "otp"), ...(raw.connectionId ? { connectionId: String(raw.connectionId) } : {}), ...(raw.credential ? { credential: raw.credential } : {}), ...(raw.replace === true ? { replace: true } : {}), }; } // --- Screenshot context pruning --- const DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP = 2; export function pruneComputerScreenshotContext( messages: AgentMessage[], maxImagesPerPrompt?: number, ): AgentMessage[] { const imageLimit = maxImagesPerPrompt === undefined ? undefined : Number.isFinite(maxImagesPerPrompt) ? Math.max(0, Math.floor(maxImagesPerPrompt)) : DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP; let remaining = imageLimit ?? DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP; if (imageLimit !== undefined) { const nonScreenshotImages = messages.reduce( (count, message) => isComputerScreenshotMessage(message) ? count : count + imagePartCount(message), 0, ); if (nonScreenshotImages > imageLimit) { throw new Error( `The configured model image limit is ${imageLimit}, but the prompt contains ${nonScreenshotImages} non-screenshot images.`, ); } remaining = imageLimit - nonScreenshotImages; } let transformed: AgentMessage[] | undefined; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (!isComputerScreenshotMessage(message)) continue; const images = imagePartCount(message); if (remaining >= images) { remaining -= images; continue; } transformed ??= [...messages]; transformed[index] = { ...message, content: message.content.filter((part) => part.type !== "image"), }; } return transformed ?? messages; } const TRUNCATED_TOOL_REISSUE_SUFFIX = "so its arguments may be truncated. Re-issue the tool call with complete arguments."; const TRUNCATED_TOOL_RECOVERY_GUIDANCE = "so its arguments were truncated. Do NOT re-issue the same oversized tool call. Prefer edit_file for targeted changes to existing files, or split large writes into smaller incremental steps."; function rewriteTruncatedToolResultMessage(message: AgentMessage): AgentMessage { if (message.role !== "toolResult" || !Array.isArray(message.content)) return message; let changed = false; const nextContent = message.content.map((part) => { if (part.type === "text" && part.text.includes(TRUNCATED_TOOL_REISSUE_SUFFIX)) { changed = true; return { ...part, text: part.text.replace(TRUNCATED_TOOL_REISSUE_SUFFIX, TRUNCATED_TOOL_RECOVERY_GUIDANCE), }; } return part; }); return changed ? { ...message, content: nextContent } : message; } /** Discard cancelled tool results and tool calls from conversational history context */ export function pruneCancelledToolResultsContext(messages: AgentMessage[]): AgentMessage[] { const cancelledIds = new Set(); let hasTruncatedToolMessage = false; for (const message of messages) { if (message.role === "toolResult") { const details = "details" in message ? (message.details as Record | undefined) : undefined; if (details?.cancelled === true) { cancelledIds.add(message.toolCallId); } if ( Array.isArray(message.content) && message.content.some( (part) => part.type === "text" && part.text.includes(TRUNCATED_TOOL_REISSUE_SUFFIX), ) ) { hasTruncatedToolMessage = true; } } } if (cancelledIds.size === 0 && !hasTruncatedToolMessage) return messages; const transformed: AgentMessage[] = []; for (const message of messages) { if (message.role === "toolResult" && cancelledIds.has(message.toolCallId)) { continue; } if (message.role === "assistant" && Array.isArray(message.content)) { const hasCancelledCall = message.content.some( (part) => part.type === "toolCall" && cancelledIds.has(part.id), ); if (hasCancelledCall) { const filteredContent = message.content.filter( (part) => !(part.type === "toolCall" && cancelledIds.has(part.id)), ); if (filteredContent.length === 0) { transformed.push({ ...message, content: [{ type: "text", text: "[Cancelled tool step]" }], }); continue; } transformed.push({ ...message, content: filteredContent }); continue; } } transformed.push(rewriteTruncatedToolResultMessage(message)); } return transformed; } export function imagePartCount(message: AgentMessage): number { if (!("content" in message) || !Array.isArray(message.content)) return 0; return message.content.filter( (part: unknown) => typeof part === "object" && part !== null && "type" in part && (part as { type?: string }).type === "image", ).length; } export function isComputerScreenshotMessage( message: AgentMessage | undefined, ): message is Extract { if ( message?.role !== "toolResult" || !message.content.some((part) => (part as { type?: string }).type === "image") ) { return false; } const details = message.details; return Boolean( details && typeof details === "object" && "frameId" in details && typeof (details as { frameId?: unknown }).frameId === "string", ); } // --- Type guards --- export function isAgentToolExecutionResult(result: unknown): result is AgentToolExecutionResult { if ( !result || typeof result !== "object" || (result as { kind?: unknown }).kind !== "agent_tool_result" || !("content" in result) ) { return false; } const content = (result as { content?: unknown }).content; return ( Array.isArray(content) && content.every( (item) => item && typeof item === "object" && ((item as { type?: unknown }).type === "text" || (item as { type?: unknown }).type === "image"), ) ); } // --- JSON Schema helpers --- export function jsonSchemaParameters( schema: Record, ): ReturnType { const alternatives = Array.isArray(schema.oneOf) ? schema.oneOf : Array.isArray(schema.anyOf) ? schema.anyOf : undefined; if (alternatives && alternatives.length > 0 && schema.properties == null) { return Type.Union( alternatives.map((variant) => jsonSchemaParameters(variant as Record)), ) as unknown as ReturnType; } const properties = (schema.properties ?? {}) as Record; const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []); const fields: Record> = {}; for (const [key, spec] of Object.entries(properties)) { const field = jsonField(spec); fields[key] = (required.has(key) ? field : Type.Optional(field)) as unknown as ReturnType< typeof Type.Optional >; } return schema.additionalProperties === false ? Type.Object(fields, { additionalProperties: false }) : schema.additionalProperties === true || (schema.properties == null && Object.keys(fields).length === 0) ? Type.Object(fields, { additionalProperties: true }) : Type.Object(fields); } export function enumUnion(values: readonly unknown[]) { const members = values.map((value) => value === null ? Type.Null() : typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? Type.Literal(value) : undefined, ); return members.every((member) => member !== undefined) ? Type.Union(members) : undefined; } export function jsonField(spec: unknown): ReturnType { const definition = spec && typeof spec === "object" ? (spec as Record) : {}; if (Array.isArray(definition.enum) && definition.enum.length > 0) { const union = enumUnion(definition.enum); if (union) return union as never; } if ("const" in definition) { const literal = enumUnion([definition.const]); if (literal) return literal as never; } const variants = Array.isArray(definition.oneOf) ? definition.oneOf : Array.isArray(definition.anyOf) ? definition.anyOf : undefined; if (variants && variants.length > 0) { return Type.Union(variants.map((variant) => jsonField(variant))) as never; } if (Array.isArray(definition.type) && definition.type.length > 0) { return Type.Union(definition.type.map((type) => jsonField({ ...definition, type }))) as never; } const type = "type" in definition ? String(definition.type) : "string"; if (type === "null") return Type.Null() as never; if (type === "number" || type === "integer") return Type.Number() as never; if (type === "boolean") return Type.Boolean() as never; if (type === "array") { const options: { minItems?: number; maxItems?: number; uniqueItems?: boolean; } = {}; if (typeof definition.minItems === "number") options.minItems = definition.minItems; if (typeof definition.maxItems === "number") options.maxItems = definition.maxItems; if (definition.uniqueItems === true) options.uniqueItems = true; return Type.Array(jsonField(definition.items), options) as never; } if (type === "object") return jsonSchemaParameters(definition) as never; return Type.String(); } // --- Result helpers --- export function summarizeToolResult(result: unknown) { try { const text = JSON.stringify(result); if (!text) return "ok"; return clipToolResultText(text); } catch { return "ok"; } } export function boundAgentToolResult(result: AgentToolResult): AgentToolResult { if (!result || !Array.isArray(result.content)) return result; return { ...result, content: clipToolResultContent(result.content), }; } export function assistantText(message: unknown): string { if (!message || typeof message !== "object" || !("content" in message)) return ""; const content = (message as { content?: unknown }).content; if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part ? String(part.text) : "", ) .join(""); } // --- Sanitization --- export function sanitizeSensitiveText(message: string) { return message .replace(/sk-or-v1-[a-zA-Z0-9]+/g, "[redacted]") .replace(/sk-[a-zA-Z0-9-]+/g, "[redacted]") .replace(/Bearer\s+[^\s"',;&]+/gi, "Bearer [redacted]") .replace(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, "[redacted]") .replace(/COMPOSIO_API_KEY[=:]?\s*\S+/gi, "COMPOSIO_API_KEY=[redacted]") .replace( /((?:api[_-]?key|access[_-]?token|password|secret)\s*[=:]\s*)[^\s"',;&]+/gi, "$1[redacted]", ) .replace(/((?:auth|authorization)\s*[=:]\s*)(?!Bearer\b)[^\s"',;&]+/gi, "$1[redacted]"); } export function redactActivityUrl(value: unknown): string { const raw = String(value ?? "").trim(); if (!raw) return raw; try { const url = new URL(raw); url.username = ""; url.password = ""; url.search = ""; url.hash = ""; return url.toString(); } catch { return "[invalid URL]"; } } export function sanitizeError(message: string) { return sanitizeSensitiveText(message); } // --- Session ID & provider helpers --- export function conversationSessionId(threadId: string, botId: string, agentId?: string): string { return agentId ? `${threadId}:${botId}:${agentId}` : `${threadId}:${botId}`; } export function isOpenCodeProvider(provider: string): boolean { return provider === "opencode" || provider === "opencode-go"; } const OPENCODE_SESSION_ERROR = "OpenCode rejected this chat session. Send the message again."; export function looksLikeOpenCodeSessionError(message: string): boolean { return ( /x-opencode-session/i.test(message) || /session\s*(id|header|required|missing|invalid|expired|stale)/i.test(message) || /model is unavailable/i.test(message) ); } export function sanitizeProviderError(provider: string, message: string): string { const sanitized = sanitizeError(message); if (isOpenCodeProvider(provider) && looksLikeOpenCodeSessionError(sanitized)) { return OPENCODE_SESSION_ERROR; } return sanitized; } // --- Queue & gate --- export interface EventQueue { push(event: AgentRuntimeEvent): void; fail(error: Error): void; close(): void; iterate(): AsyncIterable; } export function createQueue(): EventQueue { const items: AgentRuntimeEvent[] = []; let wake: (() => void) | undefined; let closed = false; let failure: Error | undefined; return { push(event) { items.push(event); wake?.(); }, fail(error) { failure = error; closed = true; wake?.(); }, close() { closed = true; wake?.(); }, async *iterate() { while (true) { if (items.length) { yield items.shift()!; continue; } if (failure) throw failure; if (closed) return; await new Promise((resolve) => { wake = resolve; }); } }, }; } export function createGate(max: number) { let active = 0; const waiters: Array<() => void> = []; return { async acquire() { if (active >= max) { await new Promise((resolve) => { waiters.push(resolve); }); } active += 1; }, release() { active = Math.max(0, active - 1); waiters.shift()?.(); }, }; } // --- Model completion --- export function modelForCompletion( model: Model, configuredMaxTokens?: number, configuredContextWindow?: number, ): Model { const maxTokens = resolveCompletionMaxTokens(model.maxTokens, configuredMaxTokens); const contextWindow = typeof configuredContextWindow === "number" && configuredContextWindow >= 1 ? configuredContextWindow : model.contextWindow; if (maxTokens === model.maxTokens && contextWindow === model.contextWindow) return model; return { ...model, maxTokens, contextWindow }; } // --- Context window budgeting & overflow recovery --- /** * Code, JSON tool schemas/arguments, and multilingual text tokenize closer to * ~2.8-3.2 chars/token than 4 chars/token. Using 3 chars/token plus a safety * margin prevents prompts (e.g. 133,699 tokens against a 131,072 n_ctx) from * slipping past pre-flight estimation or starving completion output tokens. */ const CONSERVATIVE_CHARS_PER_TOKEN = 3; const MESSAGE_TOKEN_OVERHEAD = 12; const ESTIMATED_IMAGE_CHARS = 4_800; const RECENT_FULL_TOOL_RESULTS = 6; const COMPACTED_TOOL_RESULT_CHARS = 2_400; const EMERGENCY_COMPACTED_TOOL_RESULT_CHARS = 800; const COMPACTED_TOOL_CALL_STRING_CHARS = 400; const MAX_CONTEXT_OVERFLOW_RETRIES = 3; function safeJsonLength(value: unknown): number { if (value === undefined || value === null) return 0; try { return (JSON.stringify(value) ?? "").length; } catch { return 64; } } function estimateContentChars(content: unknown): number { if (typeof content === "string") return content.length; if (!Array.isArray(content)) return 0; let chars = 0; for (const part of content) { if (!part || typeof part !== "object") continue; const typed = part as { type?: unknown; text?: unknown }; if (typed.type === "text" && typeof typed.text === "string") { chars += typed.text.length; } else if (typed.type === "image") { chars += ESTIMATED_IMAGE_CHARS; } } return chars; } export function estimateAgentMessageTokens(message: AgentMessage): number { if (message.role === "system") { const sys = message as Extract; const chars = getSystemMessageText(sys).length + safeJsonLength(sys.toolsAdded) + safeJsonLength(sys.toolsRemoved); return Math.ceil(chars / CONSERVATIVE_CHARS_PER_TOKEN) + MESSAGE_TOKEN_OVERHEAD; } if (message.role === "user" || message.role === "toolResult") { const chars = estimateContentChars("content" in message ? message.content : ""); return Math.ceil(chars / CONSERVATIVE_CHARS_PER_TOKEN) + MESSAGE_TOKEN_OVERHEAD; } if (message.role === "assistant" && Array.isArray(message.content)) { let chars = 0; for (const block of message.content) { if (block.type === "text") { chars += block.text.length; } else if (block.type === "thinking") { chars += block.thinking.length; } else if (block.type === "toolCall") { chars += block.name.length + safeJsonLength(block.arguments); } } return Math.ceil(chars / CONSERVATIVE_CHARS_PER_TOKEN) + MESSAGE_TOKEN_OVERHEAD; } return MESSAGE_TOKEN_OVERHEAD; } function rawCharTokenSum(messages: readonly AgentMessage[]): number { let total = 0; for (const message of messages) { total += estimateAgentMessageTokens(message); } return total; } function usageTotalTokens(message: AgentMessage): number { if (message.role !== "assistant" || !("usage" in message) || !message.usage) return 0; if (message.stopReason === "error" || message.stopReason === "aborted") return 0; const u = message.usage; return ( u.totalTokens || (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0) ); } /** * Calibrates the character-to-token scale factor against the last provider-reported * `usage` in `messages` when available, so dense code/JSON prompts cannot exceed * `contextWindow` even when BPE token density is higher than 1 token per 3 chars. */ function calibratedUsageScale(messages: readonly AgentMessage[]): number { for (let i = messages.length - 1; i >= 0; i -= 1) { const reported = usageTotalTokens(messages[i]!); if (reported > 0) { const prefixCharTokens = rawCharTokenSum(messages.slice(0, i + 1)); if (prefixCharTokens > 0) { return Math.min(2.5, Math.max(1, reported / prefixCharTokens)); } break; } } return 1; } export function estimateAgentContextTokens(messages: readonly AgentMessage[]): number { const scale = calibratedUsageScale(messages); return Math.ceil(rawCharTokenSum(messages) * scale); } export function maxInputTokensForWindow(contextWindow?: number, maxTokens?: number): number { const effectiveWindow = typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : DEFAULT_MODEL_CONTEXT_WINDOW; const completionCap = typeof maxTokens === "number" && maxTokens > 0 ? maxTokens : 4_096; const reservedOutput = Math.min( Math.floor(effectiveWindow * 0.3), Math.max(completionCap, 4_096) + 4_096, ); return Math.max(512, Math.floor((effectiveWindow - reservedOutput) * 0.88)); } /** Share of a model's input budget that verbatim conversation history may fill. */ const HISTORY_SHARE_OF_INPUT = 0.4; export function estimateTextTokens(text: string): number { return Math.ceil(text.length / CONSERVATIVE_CHARS_PER_TOKEN) + MESSAGE_TOKEN_OVERHEAD; } /** * Tokens the verbatim history of a run may use with this model, leaving room for instructions, * tool declarations, the current turn and the tool rounds that follow it. */ export function historyBudgetTokens(modelConfig: AgentRunRequest["model"]): number { let contextWindow = modelConfig.contextWindow; let maxTokens = modelConfig.maxTokens; try { const { model } = resolveRuntimeModel(modelConfig); if (model) { const completion = modelForCompletion(model, maxTokens, contextWindow); contextWindow = completion.contextWindow; maxTokens = completion.maxTokens; } } catch { // An unknown model keeps the configured or default window. } return Math.floor(maxInputTokensForWindow(contextWindow, maxTokens) * HISTORY_SHARE_OF_INPUT); } function compactHeadTailText(text: string, maxChars: number, marker: string): string { if (text.length <= maxChars) return text; if (maxChars <= marker.length + 32) { return `${text.slice(0, Math.max(1, maxChars - 1))}…`; } const remaining = maxChars - marker.length; const headChars = Math.max(1, Math.ceil(remaining * 0.7)); const tailChars = Math.max(0, remaining - headChars); return tailChars > 0 ? `${text.slice(0, headChars)}${marker}${text.slice(text.length - tailChars)}` : `${text.slice(0, headChars)}${marker}`; } function compactContentParts( content: T, maxChars: number, marker = "\n…[truncated to fit context window]…\n", ): T { if (typeof content === "string") { return compactHeadTailText(content, maxChars, marker) as unknown as T; } if (!Array.isArray(content)) return content; let remaining = maxChars; const next: unknown[] = []; for (const part of content) { if (!part || typeof part !== "object") { next.push(part); continue; } const typed = part as { type?: unknown; text?: unknown }; if (typed.type !== "text" || typeof typed.text !== "string") { next.push(part); continue; } if (remaining <= 0) continue; if (typed.text.length <= remaining) { next.push(part); remaining -= typed.text.length; } else { next.push({ ...part, text: compactHeadTailText(typed.text, remaining, marker), }); remaining = 0; } } return next as unknown as T; } function compactToolCallArguments( args: Record, maxStringChars: number, ): Record { let changed = false; const next: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string" && value.length > maxStringChars) { next[key] = compactHeadTailText(value, maxStringChars, "…[truncated for context window]…"); changed = true; } else { next[key] = value; } } return changed ? next : args; } function compactOlderAssistantMessage( message: Extract, ): AgentMessage { if (!Array.isArray(message.content)) return message; let changed = false; const nextContent = message.content .filter((block) => { if (block.type === "thinking" && !block.redacted && !block.thinkingSignature) { changed = true; return false; } return true; }) .map((block) => { if (block.type === "toolCall" && block.arguments && typeof block.arguments === "object") { const compactedArgs = compactToolCallArguments( block.arguments as Record, COMPACTED_TOOL_CALL_STRING_CHARS, ); if (compactedArgs !== block.arguments) { changed = true; return { ...block, arguments: compactedArgs as typeof block.arguments }; } } return block; }); if (!changed) return message; return { ...message, content: nextContent.length > 0 ? nextContent : [{ type: "text", text: "" }], }; } /** * Progressively prunes an agent transcript so its estimated token count fits within * the model's context window while preserving system instructions, tool declarations, * the current turn's prompt, and assistant toolCall ↔ toolResult pairing invariants. * The first `pinnedPrefix` messages after the leading system message (compacted summary, * recalled memory) are never dropped. */ export function pruneContextToWindow( messages: T[], options: { contextWindow?: number; maxTokens?: number; pinnedPrefix?: number } = {}, ): T[] { const leadingSystem = messages.findIndex((message) => message.role !== "system"); const pinnedEnd = (leadingSystem === -1 ? messages.length : leadingSystem) + (options.pinnedPrefix ?? 0); if (messages.length === 0) return messages; const budget = maxInputTokensForWindow(options.contextWindow, options.maxTokens); const scale = calibratedUsageScale(messages); const currentTokens = (list: readonly AgentMessage[]) => Math.ceil(rawCharTokenSum(list) * scale); if (currentTokens(messages) <= budget) return messages; const working: AgentMessage[] = [...messages]; // Stage 1: Compact older toolResult payloads and older assistant thinking/toolCall args in-place. const toolResultIndices: number[] = []; const assistantIndices: number[] = []; for (let i = 0; i < working.length; i += 1) { if (working[i]?.role === "toolResult") toolResultIndices.push(i); else if (working[i]?.role === "assistant") assistantIndices.push(i); } const compactableToolResults = new Set( toolResultIndices.slice(0, Math.max(0, toolResultIndices.length - RECENT_FULL_TOOL_RESULTS)), ); const compactableAssistants = new Set( assistantIndices.slice(0, Math.max(0, assistantIndices.length - 1)), ); for (let i = 0; i < working.length && currentTokens(working) > budget; i += 1) { const msg = working[i]!; if (msg.role === "toolResult" && compactableToolResults.has(i)) { working[i] = { ...msg, content: compactContentParts( msg.content, COMPACTED_TOOL_RESULT_CHARS, "\n…[older tool output truncated to fit context window]…\n", ), }; } else if (msg.role === "assistant" && compactableAssistants.has(i)) { working[i] = compactOlderAssistantMessage(msg); } } if (currentTokens(working) <= budget) return working as T[]; // Stage 2: Drop oldest prior conversation history messages (before the current turn's user prompt). const findFirstAssistantIndex = (list: readonly AgentMessage[]) => list.findIndex((m) => m.role === "assistant"); const findCurrentPromptIndex = (list: readonly AgentMessage[]) => { const firstAssistant = findFirstAssistantIndex(list); const limit = firstAssistant === -1 ? list.length : firstAssistant; for (let i = limit - 1; i >= 0; i -= 1) { if (list[i]?.role === "user") return i; } return -1; }; while (currentTokens(working) > budget) { const currentPromptIdx = findCurrentPromptIndex(working); const oldestHistoryIdx = working.findIndex( (m, idx) => idx >= pinnedEnd && idx < currentPromptIdx && m.role === "user", ); if (oldestHistoryIdx === -1) break; working.splice(oldestHistoryIdx, 1); } if (currentTokens(working) <= budget) return working as T[]; // Stage 3: Drop oldest completed in-turn (assistant + toolResult[]) groups atomically. while (currentTokens(working) > budget) { const currentPromptIdx = findCurrentPromptIndex(working); const startSearch = currentPromptIdx === -1 ? 0 : currentPromptIdx + 1; const groupRanges: Array<{ start: number; end: number }> = []; let idx = startSearch; while (idx < working.length) { const msg = working[idx]!; if (msg.role === "assistant") { let end = idx + 1; while (end < working.length && working[end]?.role === "toolResult") { end += 1; } groupRanges.push({ start: idx, end }); idx = end; } else { idx += 1; } } // Keep at least the latest tool round unless the transcript ends with a newer user message. const endsWithUser = working.at(-1)?.role === "user"; const minGroupsToKeep = endsWithUser ? 0 : 1; if (groupRanges.length <= minGroupsToKeep) break; const oldest = groupRanges[0]!; working.splice(oldest.start, oldest.end - oldest.start); } if (currentTokens(working) <= budget) return working as T[]; // Stage 4: Emergency truncation of remaining recent toolResults, current user prompt, or system prompt. for (let i = working.length - 1; i >= 0 && currentTokens(working) > budget; i -= 1) { const msg = working[i]!; if (msg.role === "toolResult") { working[i] = { ...msg, content: compactContentParts( msg.content, EMERGENCY_COMPACTED_TOOL_RESULT_CHARS, "\n…[tool output truncated to fit context window]…\n", ), }; } else if (msg.role === "assistant") { working[i] = compactOlderAssistantMessage(msg); } } if (currentTokens(working) <= budget) return working as T[]; for (let i = working.length - 1; i >= 0 && currentTokens(working) > budget; i -= 1) { const msg = working[i]!; if (msg.role !== "user") continue; const excessTokens = currentTokens(working) - budget; const currentMsgTokens = Math.ceil(estimateAgentMessageTokens(msg) * scale); const targetMsgTokens = Math.max(64, currentMsgTokens - excessTokens - 32); const targetChars = Math.max( 128, Math.floor((targetMsgTokens / scale) * CONSERVATIVE_CHARS_PER_TOKEN), ); working[i] = { ...msg, content: compactContentParts( msg.content, targetChars, "\n…[prompt truncated to fit model context window]…\n", ), }; } if (currentTokens(working) <= budget) return working as T[]; for (let i = 0; i < working.length && currentTokens(working) > budget; i += 1) { const msg = working[i]!; if (msg.role !== "system") continue; const sys = msg as Extract; const excessTokens = currentTokens(working) - budget; const currentSysText = getSystemMessageText(sys); const currentTextTokens = Math.ceil( (currentSysText.length / CONSERVATIVE_CHARS_PER_TOKEN) * scale, ); const targetTextTokens = Math.max(64, currentTextTokens - excessTokens - 32); const targetChars = Math.max( 128, Math.floor((targetTextTokens / scale) * CONSERVATIVE_CHARS_PER_TOKEN), ); working[i] = { ...sys, content: compactHeadTailText( currentSysText, targetChars, "\n…[system instructions truncated to fit context window]…\n", ), sections: undefined, }; } return working as T[]; } export interface ContextOverflowDetails { limitTokens?: number; requestedTokens?: number; } /** * Extracts the server's reported context window and requested prompt token count * from a provider context-overflow error message when present (e.g. llama.cpp's * `request (133699 tokens) exceeds the available context size (131072 tokens)` * or `"n_prompt_tokens":133699,"n_ctx":131072`). */ export function parseContextOverflowDetails( errorMessage: string | undefined, contextWindow?: number, ): ContextOverflowDetails | null { if (!errorMessage) return null; const syntheticAssistant = { role: "assistant" as const, content: [], api: "openai-completions" as const, provider: "openai", model: "unknown", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "error" as const, errorMessage, timestamp: 0, }; const isOverflow = isContextOverflow(syntheticAssistant, contextWindow) || /exceed_context_size_error/i.test(errorMessage); if (!isOverflow) return null; const parsePositiveInt = (raw: string | undefined): number | undefined => { if (!raw) return undefined; const num = Number(raw.replaceAll(",", "")); return Number.isSafeInteger(num) && num > 0 ? num : undefined; }; const nCtxMatch = errorMessage.match(/"n_ctx"\s*:\s*(\d+)/); const nPromptMatch = errorMessage.match(/"n_prompt_tokens"\s*:\s*(\d+)/); if (nCtxMatch || nPromptMatch) { return { limitTokens: parsePositiveInt(nCtxMatch?.[1]), requestedTokens: parsePositiveInt(nPromptMatch?.[1]), }; } // Matches: "request (133699 tokens) exceeds the available context size (131072 tokens)" // or "prompt is too long: 213462 tokens > 200000 maximum" // or "Input length (265330) exceeds model's maximum context length (262144)" const requestedFirstPatterns = [ /request\s*\(([\d,]+)\s*tokens?\)\s*exceeds\s*the\s*available\s*context\s*size\s*\(([\d,]+)\s*tokens?\)/i, /prompt is too long:\s*([\d,]+)\s*tokens?\s*>\s*([\d,]+)/i, /input(?: token count| length)?\s*\(?([\d,]+)(?:\s*tokens?)?\)?\s*(?:exceeds|is longer than)[^0-9]+([\d,]+)/i, /prompt has\s*([\d,]+)\s*tokens?,\s*but the configured context size is\s*([\d,]+)/i, ]; for (const pattern of requestedFirstPatterns) { const match = errorMessage.match(pattern); if (match) { return { requestedTokens: parsePositiveInt(match[1]), limitTokens: parsePositiveInt(match[2]), }; } } // Matches: "maximum context length is 131072 tokens. However, you requested about 133699 tokens" // or "maximum prompt length is 131072 but the request contains 537812 tokens" const limitFirstPatterns = [ /maximum (?:context|prompt) length (?:of |is )?([\d,]+)(?:\s*tokens?)?.*?(?:requested|resulted in|contains)\s*(?:about\s*)?([\d,]+)/i, /exceeded model token limit:\s*([\d,]+)\s*\(requested:\s*([\d,]+)\)/i, ]; for (const pattern of limitFirstPatterns) { const match = errorMessage.match(pattern); if (match) { return { limitTokens: parsePositiveInt(match[1]), requestedTokens: parsePositiveInt(match[2]), }; } } return {}; } export function nextContextWindowAfterOverflow( currentWindow: number, details: ContextOverflowDetails, ): number { const baseWindow = details.limitTokens && details.limitTokens > 0 ? Math.min(currentWindow, details.limitTokens) : currentWindow; if ( details.limitTokens && details.requestedTokens && details.requestedTokens > details.limitTokens ) { const ratio = details.limitTokens / details.requestedTokens; return Math.max(1_024, Math.floor(baseWindow * ratio * 0.82)); } return Math.max(1_024, Math.floor(baseWindow * 0.72)); } /** * Wraps `models.streamSimple` so that pre-generation context-overflow errors * (HTTP 400/413 `exceed_context_size_error`, `context_length_exceeded`, etc.) * automatically calibrate the effective context window, prune the transcript, * and retry transparently without failing the run. */ export function streamWithContextOverflowRecovery(params: { models: Pick; model: Model; context: TranscriptContext; options?: SimpleStreamOptions; configuredMaxTokens?: number; getEffectiveContextWindow: () => number; setEffectiveContextWindow: (nextWindow: number) => void; pinnedPrefix?: number; onPrunedMessages?: (messages: Message[]) => void; }): AssistantMessageEventStream { const outer = createAssistantMessageEventStream(); (async () => { let currentMessages = params.context.messages; for (let attempt = 0; attempt <= MAX_CONTEXT_OVERFLOW_RETRIES; attempt += 1) { const effectiveWindow = params.getEffectiveContextWindow(); const effectiveModel = effectiveWindow > 0 && effectiveWindow !== params.model.contextWindow ? { ...params.model, contextWindow: effectiveWindow } : params.model; const streamOptions = reliableStreamOptions( effectiveModel, params.options, params.configuredMaxTokens, ); const inner = params.models.streamSimple( effectiveModel, normalizeContext({ messages: currentMessages }), streamOptions, ); let pendingStart: Extract< Awaited> extends AssistantMessage ? { type: "start"; partial: AssistantMessage } : never, { type: "start" } > | null = null; let emittedContent = false; let retryWithWindow: number | null = null; try { for await (const event of inner) { if (event.type === "start" && !emittedContent) { pendingStart = event; continue; } if ( event.type === "error" && !emittedContent && attempt < MAX_CONTEXT_OVERFLOW_RETRIES && !params.options?.signal?.aborted ) { const overflow = parseContextOverflowDetails(event.error.errorMessage, effectiveWindow); if (overflow) { retryWithWindow = nextContextWindowAfterOverflow(effectiveWindow, overflow); break; } } if ( event.type === "done" && !emittedContent && attempt < MAX_CONTEXT_OVERFLOW_RETRIES && !params.options?.signal?.aborted && isContextOverflow(event.message, effectiveWindow) ) { retryWithWindow = nextContextWindowAfterOverflow(effectiveWindow, {}); break; } if (pendingStart) { outer.push(pendingStart); pendingStart = null; } emittedContent = true; outer.push(event); } } catch (error) { const errorText = error instanceof Error ? error.message : String(error); if ( !emittedContent && attempt < MAX_CONTEXT_OVERFLOW_RETRIES && !params.options?.signal?.aborted ) { const overflow = parseContextOverflowDetails(errorText, effectiveWindow); if (overflow) { retryWithWindow = nextContextWindowAfterOverflow(effectiveWindow, overflow); } } if (retryWithWindow === null) { if (pendingStart) { outer.push(pendingStart); pendingStart = null; } const fallbackError: AssistantMessage = { role: "assistant", content: [], api: effectiveModel.api, provider: effectiveModel.provider, model: effectiveModel.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: params.options?.signal?.aborted ? "aborted" : "error", errorMessage: errorText, timestamp: Date.now(), }; outer.push({ type: "error", reason: fallbackError.stopReason === "aborted" ? "aborted" : "error", error: fallbackError, }); outer.end(); return; } } if (retryWithWindow !== null) { params.setEffectiveContextWindow(retryWithWindow); currentMessages = pruneContextToWindow(currentMessages, { contextWindow: retryWithWindow, maxTokens: effectiveModel.maxTokens, pinnedPrefix: params.pinnedPrefix, }); params.onPrunedMessages?.(currentMessages); continue; } outer.end(); return; } })(); return outer; } // --- Reliable streaming --- export function reliableStreamOptions( model: Pick, "api" | "provider" | "maxTokens">, options?: SimpleStreamOptions, configuredMaxTokens?: number, ): SimpleStreamOptions { let next: SimpleStreamOptions = { ...options, timeoutMs: resolveModelStreamTimeoutMs(model.provider, options?.timeoutMs), maxRetries: resolveModelStreamMaxRetries(model.provider, options?.maxRetries), maxTokens: resolveCompletionMaxTokens(model.maxTokens, configuredMaxTokens, options?.maxTokens), }; if (model.provider === "openai-codex" || model.api === "openai-codex-responses") { next = { ...next, transport: "sse" }; } if (isOpenCodeProvider(model.provider)) { const sessionId = next.sessionId?.trim() || randomUUID(); next = { ...next, sessionId, headers: { "x-opencode-session": sessionId, "x-opencode-client": "rakazo", ...next.headers, }, }; } return next; } // --- Tool host interface --- export interface ToolHost { queue: EventQueue; request: AgentRunRequest; models: Models; model: Model; apiKey: string | undefined; nestedAgents: Set; subagentGate: { acquire(): Promise; release(): void }; toolCallBudget: ToolCallBudget; toolCallSeq: { value: number }; abortTurn(): void; signal: AbortSignal; depth: number; pausePending: boolean; steerAgent?: (message: AgentMessage) => void; seenSteeringIds?: string[]; /** Active in-flight tool controllers keyed by executionId */ activeToolControllers: Map; /** Abort all currently in-flight tools in this host */ abortActiveTools(reason?: unknown): void; /** Backwards compatibility alias for abortActiveTools */ activeToolAbort?: (reason?: unknown) => void; /** Tracks consecutive tool errors by tool name to inject diagnostic guidance */ lastFailedToolName?: string; consecutiveToolFailures?: number; /** Tracks whether a code file mutation occurred without subsequent verification */ unverifiedCodeMutation?: boolean; /** Shared mid-step steering poller state across concurrent tools in this host */ steeringPoller?: { timer?: NodeJS.Timeout; inFlight: boolean; }; }