evOo-local/packages/adapters/src/pi-model-config.ts
cynarAI ea7892baa3
Compact chat history automatically before it outgrows the model (#225)
* Push agents to act when they end a turn announcing work

A local model often ends a turn with "let me set that up now" and no tool
call. Nothing runs after a turn ends, so the user waits on work that never
happens. A text-only turn that announces unfinished work now gets up to two
follow-ups that ask the agent to do it or report what is open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE

* Summarize project conversations and retry contended handoffs

Group threads, which hold every project's main conversation, were never
compacted. Runs there saw only the last 200 raw messages, so the original
brief and earlier decisions fell out of context. The lead bot now
summarizes them like a bot thread; the summary stays local.

A group handoff now retries when its transaction hits a deadlock, so a
specialist that finished its work no longer fails at the hand-back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE

* Compact chat history automatically before it outgrows the model

Long chats lost their brief and open work. The Pi runtime passed only the
last 25 history entries, and the compacted summary and recalled memory sat
in front of them, so both were cut whenever a chat had more than 24
uncompacted messages. Context pruning then dropped the summary first,
because it was the oldest history entry. Compaction itself only started
after 100 uncompacted messages and ignored tokens.

- History entries can be pinned. The executor pins the summary and recalled
  memory, the runtime always keeps them ahead of the recent window, and
  context pruning never drops them.
- Compaction is driven by messages and tokens: it keeps the newest ten
  messages verbatim and folds the rest into the summary once 20 messages
  are uncompacted or the history exceeds 40% of the model's input budget.
- Before a run whose history would not reach the model in full, the
  executor compacts inline for up to 90 seconds, then runs with what it has.
- The summarizer writes a structured summary: goal and brief, decisions,
  done, open, preferences.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

* Record runtime failures again instead of leaving the run running

A runtime error closes the stream through withRuntimeCleanup, which aborts
the run's own controller. The catch treated that as an outside stop and
returned without finalizing, so a failed run stayed running and the web
never showed its error. Same fix as on the main-green branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 04:18:21 +02:00

1574 lines
52 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, 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<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.]";
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<string, unknown>) &&
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<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: DEFAULT_MODEL_CONTEXT_WINDOW,
maxTokens: DEFAULT_MODEL_MAX_TOKENS,
};
}
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;
}
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<string>();
let hasTruncatedToolMessage = false;
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 (
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<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 = 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<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));
}
/** 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<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.
* The first `pinnedPrefix` messages after the leading system message (compacted summary,
* recalled memory) are never dropped.
*/
export function pruneContextToWindow<T extends AgentMessage>(
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<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;
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<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,
pinnedPrefix: params.pinnedPrefix,
});
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;
/** Shared mid-step steering poller state across concurrent tools in this host */
steeringPoller?: {
timer?: NodeJS.Timeout;
inFlight: boolean;
};
}