1484 lines
49 KiB
TypeScript
1484 lines
49 KiB
TypeScript
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 } 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<Api>,
|
|
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<Api>,
|
|
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<Api>,
|
|
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<string, unknown>,
|
|
): 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<string, unknown>): 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.]";
|
|
|
|
export function isToolResultError(result: unknown): boolean {
|
|
if (!result || typeof result !== "object") return false;
|
|
if ("error" in result && (result as { error?: unknown }).error) return true;
|
|
if (
|
|
"details" in result &&
|
|
result.details &&
|
|
typeof result.details === "object" &&
|
|
"error" in result.details &&
|
|
(result.details as { error?: unknown }).error
|
|
) {
|
|
return true;
|
|
}
|
|
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<string, ToolCallBudget>();
|
|
|
|
/** 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: 16_384,
|
|
maxTokens: 4_096,
|
|
};
|
|
}
|
|
|
|
export function modelsForRequest(
|
|
request: Pick<AgentRunRequest, "model">,
|
|
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<Api> | 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<string, unknown>) {
|
|
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;
|
|
}
|
|
|
|
/** Discard cancelled tool results and tool calls from conversational history context */
|
|
export function pruneCancelledToolResultsContext(messages: AgentMessage[]): AgentMessage[] {
|
|
const cancelledIds = new Set<string>();
|
|
for (const message of messages) {
|
|
if (message.role === "toolResult") {
|
|
const details =
|
|
"details" in message ? (message.details as Record<string, unknown> | undefined) : undefined;
|
|
if (details?.cancelled === true) {
|
|
cancelledIds.add(message.toolCallId);
|
|
}
|
|
}
|
|
}
|
|
if (cancelledIds.size === 0) 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(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<AgentMessage, { role: "toolResult" }> {
|
|
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<string, unknown>,
|
|
): ReturnType<typeof Type.Object> {
|
|
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<string, unknown>)),
|
|
) as unknown as ReturnType<typeof Type.Object>;
|
|
}
|
|
const properties = (schema.properties ?? {}) as Record<string, unknown>;
|
|
const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
|
|
const fields: Record<string, ReturnType<typeof Type.Optional>> = {};
|
|
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<typeof Type.String> {
|
|
const definition = spec && typeof spec === "object" ? (spec as Record<string, unknown>) : {};
|
|
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<T>(result: AgentToolResult<T>): AgentToolResult<T> {
|
|
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<AgentRuntimeEvent>;
|
|
}
|
|
|
|
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<void>((resolve) => {
|
|
wake = resolve;
|
|
});
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createGate(max: number) {
|
|
let active = 0;
|
|
const waiters: Array<() => void> = [];
|
|
return {
|
|
async acquire() {
|
|
if (active >= max) {
|
|
await new Promise<void>((resolve) => {
|
|
waiters.push(resolve);
|
|
});
|
|
}
|
|
active += 1;
|
|
},
|
|
release() {
|
|
active = Math.max(0, active - 1);
|
|
waiters.shift()?.();
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Model completion ---
|
|
|
|
export function modelForCompletion(
|
|
model: Model<Api>,
|
|
configuredMaxTokens?: number,
|
|
configuredContextWindow?: number,
|
|
): Model<Api> {
|
|
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 = 2;
|
|
const 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<Message, { role: "system" }>;
|
|
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));
|
|
}
|
|
|
|
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<T>(
|
|
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<string, unknown>,
|
|
maxStringChars: number,
|
|
): Record<string, unknown> {
|
|
let changed = false;
|
|
const next: Record<string, unknown> = {};
|
|
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, { role: "assistant" }>,
|
|
): 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<string, unknown>,
|
|
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.
|
|
*/
|
|
export function pruneContextToWindow<T extends AgentMessage>(
|
|
messages: T[],
|
|
options: { contextWindow?: number; maxTokens?: number } = {},
|
|
): T[] {
|
|
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 < 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,
|
|
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<Message, { role: "system" }>;
|
|
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<Models, "streamSimple">;
|
|
model: Model<Api>;
|
|
context: TranscriptContext;
|
|
options?: SimpleStreamOptions;
|
|
configuredMaxTokens?: number;
|
|
getEffectiveContextWindow: () => number;
|
|
setEffectiveContextWindow: (nextWindow: number) => void;
|
|
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<ReturnType<typeof inner.result>> 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,
|
|
});
|
|
params.onPrunedMessages?.(currentMessages);
|
|
continue;
|
|
}
|
|
|
|
outer.end();
|
|
return;
|
|
}
|
|
})();
|
|
|
|
return outer;
|
|
}
|
|
|
|
// --- Reliable streaming ---
|
|
|
|
export function reliableStreamOptions(
|
|
model: Pick<Model<Api>, "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<Api>;
|
|
apiKey: string | undefined;
|
|
nestedAgents: Set<Agent>;
|
|
subagentGate: { acquire(): Promise<void>; 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<string, AbortController>;
|
|
/** 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;
|
|
}
|