Merge remote-tracking branch 'upstream/main' into feat/secret-friendly-names

This commit is contained in:
Mathews-Tom
2026-07-11 20:42:16 +05:30
530 changed files with 43935 additions and 14782 deletions
@@ -38,6 +38,7 @@ import {
createToolScopedAbortReason,
resolveTelemetry,
type StreamFn,
TERMINAL_TOOL_RESULT_ABORT_REASON,
ThinkingLevel,
type ToolChoiceDirective,
} from "@oh-my-pi/pi-agent-core";
@@ -84,6 +85,7 @@ import type {
AssistantMessageEvent,
AssistantRetryRecovery,
AssistantRetryRecoveryKind,
CodexCompactionContext,
Context,
ImageContent,
Message,
@@ -117,6 +119,7 @@ import {
streamSimple,
} from "@oh-my-pi/pi-ai";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { resetOpenAICodexHistoryAfterCompaction } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
@@ -154,6 +157,7 @@ import {
AdvisorTranscriptRecorder,
advisorTranscriptFilename,
formatAdvisorBatchContent,
getOrCreateAdvisorProviderSessionId,
isAdvisorInterruptImmuneTurnActive,
isInterruptingSeverity,
resolveAdvisorDeliveryChannel,
@@ -273,6 +277,7 @@ import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redir
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
import vibeModeActivePrompt from "../prompts/system/vibe-mode-active.md" with { type: "text" };
import { AgentRegistry } from "../registry/agent-registry";
import {
deobfuscateAssistantContent,
@@ -283,6 +288,7 @@ import {
stripPendingSecretPlaceholderSuffix,
} from "../secrets/obfuscator";
import { invalidateHostMetadata } from "../ssh/connection-manager";
import { usesCodexTaskPrompt } from "../task/prompt-policy";
import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
@@ -294,7 +300,7 @@ import {
shouldDisableReasoning,
toReasoningEffort,
} from "../thinking";
import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/text";
import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/message-preproc";
import { shutdownTinyTitleClient } from "../tiny/title-client";
import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
import {
@@ -326,6 +332,7 @@ import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallba
import { formatLocalCalendarDate } from "../utils/local-date";
import { generateSessionTitle } from "../utils/title-generator";
import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
import type { VibeModeState } from "../vibe/state";
import type { AuthStorage } from "./auth-storage";
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
import {
@@ -603,6 +610,20 @@ function compactionDeadEndWarning(remedies: string): string {
);
}
function createCodexCompactionContext(options: {
trigger: CodexCompactionContext["trigger"];
reason: CodexCompactionContext["reason"];
phase: CodexCompactionContext["phase"];
}): CodexCompactionContext {
return {
operationId: crypto.randomUUID(),
trigger: options.trigger,
reason: options.reason,
phase: options.phase,
strategy: "memento",
};
}
/**
* Per-turn prune cache window. A tool result whose all-message suffix exceeds
* this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
@@ -694,6 +715,8 @@ export interface AgentSessionConfig {
modelRegistry: ModelRegistry;
/** Tool registry for LSP and settings */
toolRegistry?: Map<string, AgentTool>;
/** Creates the tools registered only while `/vibe` mode is active. */
createVibeTools?: () => AgentTool[];
/** Tool names whose current registry entry is still the built-in implementation. */
builtInToolNames?: Iterable<string>;
/** Update tool-session predicates that render guidance from the live active tool set. */
@@ -794,6 +817,8 @@ export interface AgentSessionConfig {
* so that credential sticky selection is consistent with the session's streaming calls.
*/
providerSessionId?: string;
/** Marks `agent.promptCacheKey` as fork-inherited so incompatible route changes can clear it. */
providerPromptCacheKeySource?: "explicit" | "fork";
/**
* Full advisor toolset, pre-built in `createAgentSession` against a distinct,
* advisor-scoped `ToolSession` (its own `-advisor` session/agent id) so the
@@ -1062,7 +1087,7 @@ function parseRetryFallbackSelector(
const trimmed = selector.trim();
if (!trimmed) return undefined;
const parsed = parseModelString(trimmed, {
allowMaxAlias: true,
allowMaxSuffix: true,
allowAutoAlias: true,
isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined,
});
@@ -1181,6 +1206,7 @@ const noOpUIContext: ExtensionUIContext = {
pasteToEditor: () => {},
getEditorText: () => "",
editor: async () => undefined,
addAutocompleteProvider: () => {},
get theme() {
return theme;
},
@@ -1587,6 +1613,7 @@ export class AgentSession {
#advisorPrimaryTurnsCompleted = 0;
#advisorInterruptImmuneTurnStart: number | undefined;
#planModeState: PlanModeState | undefined;
#vibeModeState: VibeModeState | undefined;
#goalModeState: GoalModeState | undefined;
#goalRuntime: GoalRuntime;
#advisorEnabled = false;
@@ -1599,6 +1626,8 @@ export class AgentSession {
#advisors: ActiveAdvisor[] = [];
/** Configured advisor roster from WATCHDOG.yml; undefined/empty → single legacy advisor. */
#advisorConfigs?: AdvisorConfig[];
/** Provider-facing UUIDv7 identities keyed by primary provider session and advisor slug. */
#advisorProviderSessionIds = new Map<string, string>();
/** Aggregate of the most recent stop's recorder closes; awaited by dispose() and
* used as the open barrier for the next build so two writers never share a file. */
#advisorRecorderClosed: Promise<void> = Promise.resolve();
@@ -1697,6 +1726,7 @@ export class AgentSession {
#agentKind: "main" | "sub" = "main";
#providerSessionId: string | undefined;
#freshProviderSessionId: string | undefined;
#inheritedProviderPromptCacheKey: string | undefined;
#isDisposed = false;
// Extension system
#extensionRunner: ExtensionRunner | undefined = undefined;
@@ -1720,6 +1750,8 @@ export class AgentSession {
// Tool registry and prompt builder for extensions
#toolRegistry: Map<string, AgentTool>;
#createVibeTools: (() => AgentTool[]) | undefined;
#installedVibeToolNames = new Set<string>();
#transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
#onPayload: SimpleStreamOptions["onPayload"] | undefined;
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
@@ -1846,6 +1878,7 @@ export class AgentSession {
* Cleared before every new prompt turn so the next turn evaluates cleanly.
*/
#yieldTerminationPending = false;
#synchronouslyTerminatedYieldToolCallIds = new Set<string>();
#providerSessionState = new Map<string, ProviderSessionState>();
#hindsightSessionState: HindsightSessionState | undefined = undefined;
readonly rawSseDebugBuffer: RawSseDebugBuffer;
@@ -2082,6 +2115,7 @@ export class AgentSession {
this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
this.#validateRetryFallbackChains();
this.#toolRegistry = config.toolRegistry ?? new Map();
this.#createVibeTools = config.createVibeTools;
this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
this.#requestedToolNames = config.requestedToolNames;
this.#transformContext = config.transformContext ?? (messages => messages);
@@ -2212,6 +2246,8 @@ export class AgentSession {
this.#agentId = config.agentId;
this.#agentKind = config.agentKind ?? "main";
this.#providerSessionId = config.providerSessionId;
this.#inheritedProviderPromptCacheKey =
config.providerPromptCacheKeySource === "fork" ? this.agent.promptCacheKey : undefined;
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
const event: AgentEvent = {
type: "message_update",
@@ -2222,8 +2258,8 @@ export class AgentSession {
this.#maybeAbortStreamingEdit(event);
this.#maybeInterruptGeminiHeaderRunaway(message, assistantMessageEvent);
});
// Per-tool TTSR reminders are folded into the matched tool's result via this hook.
this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
// Tool-result hook owns synchronous post-tool actions that must affect the current loop.
this.agent.afterToolCall = ctx => this.#afterToolCall(ctx);
this.agent.providerSessionState = this.#providerSessionState;
this.#syncAgentSessionId();
this.#syncTodoPhasesFromBranch();
@@ -2474,21 +2510,29 @@ export class AgentSession {
if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
const names = config.tools?.length ? new Set(config.tools) : ADVISOR_DEFAULT_TOOL_NAMES;
const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
const advisorSessionId = this.#advisorSessionId(slug);
const primaryProviderSessionId = this.sessionId;
const advisorSessionLabel = slug
? `${primaryProviderSessionId}-advisor-${slug}`
: `${primaryProviderSessionId}-advisor`;
const advisorProviderSessionId = getOrCreateAdvisorProviderSessionId(
this.#advisorProviderSessionIds,
primaryProviderSessionId,
slug,
);
const appendOnlyContext = new AppendOnlyContextManager();
// Thread the primary's telemetry into the advisor loop so the advisor
// model's GenAI spans + usage/cost hooks fire stamped with the advisor's
// own identity. `conversationId` is cleared so the advisor loop falls back
// to its own session id; undefined telemetry stays undefined.
// model's GenAI spans + usage/cost hooks fire stamped with the local advisor
// identity. `conversationId` is cleared so provider telemetry falls back to
// the UUIDv7 provider session id, not the local `-advisor` label.
const advisorTelemetry = this.agent.telemetry
? {
...this.agent.telemetry,
agent: {
id: advisorSessionId,
id: advisorSessionLabel,
name: slug ? `${MODEL_ROLES.advisor.name}: ${advisorName}` : MODEL_ROLES.advisor.name,
description: formatModelString(advisorModel),
},
@@ -2500,10 +2544,10 @@ export class AgentSession {
// advisor's requests cache, route, and obfuscate like the main turn.
// `promptCacheKey` preserves an explicitly pinned provider cache key
// unchanged so tan/shared-session advisor calls read the exact shard the
// parent turn populated, while keeping only `sessionId` advisor-scoped;
// sessions without a pinned key fall back to the advisor session id for
// stable advisor-local caching (see can1357/oh-my-pi#3639).
const advisorPromptCacheKey = this.agent.promptCacheKey ?? advisorSessionId;
// parent turn populated. Otherwise the advisor uses its provider UUIDv7 so
// Codex request identity remains UUID-shaped while local labels keep the
// `-advisor` suffix.
const advisorPromptCacheKey = this.agent.promptCacheKey ?? advisorProviderSessionId;
const advisorAgent = new Agent({
initialState: {
systemPrompt,
@@ -2512,11 +2556,11 @@ export class AgentSession {
tools: [adviseTool, ...tools],
},
appendOnlyContext,
sessionId: advisorSessionId,
sessionId: advisorProviderSessionId,
promptCacheKey: advisorPromptCacheKey,
providerSessionState: this.#providerSessionState,
preferWebsockets: this.#preferWebsockets,
getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorProviderSessionId),
streamFn: this.#advisorStreamFn,
onPayload: this.#onPayload,
onResponse: this.#onResponse,
@@ -2575,11 +2619,15 @@ export class AgentSession {
// suspect-mark a credential on a transient advisor error).
const message = error instanceof Error ? error.message : String(error);
if (!isUsageLimitOutcome(extractHttpStatusFromError(error), message)) return;
await this.#modelRegistry.authStorage.markUsageLimitReached(advisorModel.provider, advisorSessionId, {
retryAfterMs: extractRetryHint(undefined, message),
baseUrl: advisorModel.baseUrl,
modelId: advisorModel.id,
});
await this.#modelRegistry.authStorage.markUsageLimitReached(
advisorModel.provider,
advisorProviderSessionId,
{
retryAfterMs: extractRetryHint(undefined, message),
baseUrl: advisorModel.baseUrl,
modelId: advisorModel.id,
},
);
},
notifyFailure: error => {
const message = error instanceof Error ? error.message : String(error);
@@ -2633,13 +2681,6 @@ export class AgentSession {
return this.#advisors.length > 0;
}
/** Provider/session id for an advisor's loop. The slug suffix MUST match the
* advisor's transcript filename so stats/telemetry attribute the same advisor. */
#advisorSessionId(slug: string): string | undefined {
if (!this.sessionId) return undefined;
return slug ? `${this.sessionId}-advisor-${slug}` : `${this.sessionId}-advisor`;
}
/**
* Route one accepted advice note from `advisor` to the primary. Concern and
* blocker interrupt the running agent through the steering channel; once the
@@ -2845,11 +2886,15 @@ export class AgentSession {
// No compaction candidates, fallback to re-prime
return true;
}
const advisorSessionId = this.#advisorSessionId(advisor.slug);
const advisorProviderSessionId = getOrCreateAdvisorProviderSessionId(
this.#advisorProviderSessionIds,
this.sessionId,
advisor.slug,
);
const preparation = prepareCompaction(
pathEntries,
compactionSettings,
await this.#runnableCompactionCandidates(candidates, advisorSessionId),
await this.#runnableCompactionCandidates(candidates, advisorProviderSessionId),
);
if (!preparation) {
// Cannot prepare compaction, fallback to re-prime
@@ -2869,17 +2914,23 @@ export class AgentSession {
let lastError: unknown;
// Instrument the advisor's overflow-compaction one-shot like the primary
// compaction path so the advisor model's maintenance call also emits spans.
const telemetry = resolveTelemetry(agent.telemetry, advisorSessionId);
const telemetry = resolveTelemetry(agent.telemetry, advisorProviderSessionId);
const codexCompaction = createCodexCompactionContext({
trigger: "auto",
reason: "context_limit",
phase: "pre_turn",
});
for (const candidate of candidates) {
const apiKey = await this.#modelRegistry.getApiKey(candidate, advisorSessionId);
const apiKey = await this.#modelRegistry.getApiKey(candidate, advisorProviderSessionId);
if (!apiKey) continue;
try {
compactResult = await compact(
preparation,
candidate,
this.#modelRegistry.resolver(candidate, advisorSessionId),
this.#modelRegistry.resolver(candidate, advisorProviderSessionId),
undefined,
undefined,
{
@@ -2887,8 +2938,10 @@ export class AgentSession {
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
telemetry,
tools: agent.state.tools,
sessionId: advisorSessionId,
promptCacheKey: advisorSessionId,
sessionId: advisorProviderSessionId,
promptCacheKey: advisorProviderSessionId,
providerSessionState: this.#providerSessionState,
codexCompaction,
},
);
break;
@@ -3671,9 +3724,12 @@ export class AgentSession {
this.#planModeReminderAwaitingProgress = false;
}
}
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
this.#lastSuccessfulYieldToolCallId = event.toolCallId;
this.#yieldTerminationPending = true;
if (event.type === "tool_execution_end" && this.#isTerminalYieldToolResult(event)) {
const alreadyTerminated = this.#synchronouslyTerminatedYieldToolCallIds.delete(event.toolCallId);
if (!alreadyTerminated) {
this.#markTerminalYieldToolCall(event.toolCallId);
this.agent.abort(TERMINAL_TOOL_RESULT_ABORT_REASON);
}
}
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
@@ -4418,6 +4474,21 @@ export class AgentSession {
}
}
#afterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
if (
this.#isTerminalYieldToolResult({
toolName: ctx.toolCall.name,
isError: ctx.isError,
result: ctx.result,
})
) {
this.#markTerminalYieldToolCall(ctx.toolCall.id);
this.#synchronouslyTerminatedYieldToolCallIds.add(ctx.toolCall.id);
this.agent.abort(TERMINAL_TOOL_RESULT_ABORT_REASON);
}
return this.#ttsrAfterToolCall(ctx);
}
/** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */
#ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id);
@@ -5570,6 +5641,23 @@ export class AgentSession {
return this.#freshProviderSessionId ?? this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
}
#adoptInheritedProviderPromptCacheKey(): void {
const key = this.sessionManager.getHeader()?.providerPromptCacheKey;
if (!key) return;
if (this.#inheritedProviderPromptCacheKey !== undefined || this.agent.promptCacheKey === undefined) {
this.agent.promptCacheKey = key;
this.#inheritedProviderPromptCacheKey = key;
}
}
#clearInheritedProviderPromptCacheKey(): void {
const key = this.#inheritedProviderPromptCacheKey;
this.#inheritedProviderPromptCacheKey = undefined;
if (key !== undefined && this.agent.promptCacheKey === key) {
this.agent.promptCacheKey = undefined;
}
}
/**
* Set agent.sessionId from the session manager and install a dynamic
* metadata resolver so every Anthropic API request carries
@@ -6034,6 +6122,49 @@ export class AgentSession {
return Array.from(this.#toolRegistry.keys());
}
#wrapRuntimeTool(tool: AgentTool): AgentTool {
const wrapped = wrapToolWithMetaNotice(tool);
return this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped;
}
/**
* Registers the ephemeral vibe tools and activates them alongside `baseToolNames`.
*
* @throws When this session cannot create vibe tools or the factory returns duplicate names.
*/
async activateVibeTools(baseToolNames: string[]): Promise<void> {
const createVibeTools = this.#createVibeTools;
if (!createVibeTools) {
throw new Error("Vibe tools are unavailable in this session.");
}
const tools = createVibeTools();
const vibeToolNames = tools.map(tool => tool.name);
if (new Set(vibeToolNames).size !== vibeToolNames.length) {
throw new Error("Vibe tool names must be unique.");
}
for (const tool of tools) {
if (this.#toolRegistry.has(tool.name)) continue;
this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool));
this.#builtInToolNames.add(tool.name);
this.#installedVibeToolNames.add(tool.name);
}
await this.#applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]);
}
/** Removes tools installed by {@link activateVibeTools} and activates `nextToolNames`. */
async deactivateVibeTools(nextToolNames: string[]): Promise<void> {
for (const name of this.#installedVibeToolNames) {
this.#toolRegistry.delete(name);
this.#builtInToolNames.delete(name);
this.#selectedDiscoveredToolNames.delete(name);
}
this.#installedVibeToolNames.clear();
await this.#applyActiveToolsByName(nextToolNames);
}
#getEditModeSession() {
return {
settings: this.settings,
@@ -6045,19 +6176,17 @@ export class AgentSession {
return resolveEditMode(this.#getEditModeSession());
}
/**
* Model key (`provider/id`) currently surfaced in the system prompt, or
* undefined when the model is unset or `includeModelInPrompt` is disabled.
*/
/** Cache key for model-dependent prompt content: displayed id or hidden-policy cohort. */
#currentPromptModelKey(): string | undefined {
if (!this.settings.get("includeModelInPrompt")) return undefined;
return this.model ? formatModelString(this.model) : undefined;
const model = this.model ? formatModelString(this.model) : undefined;
if (!model || this.settings.get("includeModelInPrompt")) return model;
return usesCodexTaskPrompt(model) ? "task-policy:gpt-5.6" : "task-policy:default";
}
async #syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
const currentEditMode = this.#resolveActiveEditMode();
const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
// The system prompt may surface the active model; a switch makes the cached prompt stale.
// The system prompt selects model-specific policy even when it does not display the model id.
const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
if (editModeChanged || modelChanged) {
await this.refreshBaseSystemPrompt();
@@ -6375,6 +6504,9 @@ export class AgentSession {
if (this.#rebuildSystemPrompt) {
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
if (signature !== this.#lastAppliedToolSignature) {
if (this.#lastAppliedToolSignature !== undefined) {
this.#clearInheritedProviderPromptCacheKey();
}
const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
this.#baseSystemPrompt = built.systemPrompt;
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
@@ -6461,9 +6593,16 @@ export class AgentSession {
if (!this.#rebuildSystemPrompt) return;
const activeToolNames = this.getActiveToolNames();
this.#setActiveToolNames?.(activeToolNames);
const previousBaseSystemPrompt = this.#baseSystemPrompt;
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
this.#baseSystemPrompt = built.systemPrompt;
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
if (
previousBaseSystemPrompt.length !== this.#baseSystemPrompt.length ||
previousBaseSystemPrompt.some((part, index) => part !== this.#baseSystemPrompt[index])
) {
this.#clearInheritedProviderPromptCacheKey();
}
this.agent.setSystemPrompt(this.#baseSystemPrompt);
this.#promptModelKey = this.#currentPromptModelKey();
// Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
@@ -6593,8 +6732,8 @@ export class AgentSession {
*
* @param mcpTools The new MCP tools to register.
* @param options.activateAll When true, force-activates every newly registered MCP tool
* regardless of prior selection state. Used when an ACP client provisions MCP servers
* for a session where MCP discovery is disabled.
* regardless of prior selection state. Used when MCP discovery is disabled and tools
* arrive after initial session activation.
*/
async refreshMCPTools(mcpTools: CustomTool[], options?: { activateAll?: boolean }): Promise<void> {
const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
@@ -6639,10 +6778,10 @@ export class AgentSession {
if (options?.activateAll) {
// Force-activate every newly registered MCP tool. This path is used
// when an ACP client provisions MCP servers for a session where MCP
// discovery is disabled — without it, getSelectedMCPToolNames()
// returns only already-active tools (circular deadlock: tools can
// only become active if they're already active).
// when MCP discovery is disabled and tools arrive after initial
// activation — without it, getSelectedMCPToolNames() returns only
// already-active tools (circular deadlock: tools can only become
// active if they're already active).
const newMcpNames = mcpTools.map(t => t.name);
const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...newMcpNames])];
await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
@@ -6986,6 +7125,14 @@ export class AgentSession {
this.#goalModeState = state;
}
getVibeModeState(): VibeModeState | undefined {
return this.#vibeModeState;
}
setVibeModeState(state: VibeModeState | undefined): void {
this.#vibeModeState = state;
}
get goalRuntime(): GoalRuntime {
return this.#goalRuntime;
}
@@ -7118,6 +7265,21 @@ export class AgentSession {
);
}
async sendVibeModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
const message = this.#buildVibeModeMessage();
if (!message) return;
await this.sendCustomMessage(
{
customType: message.customType,
content: message.content,
display: message.display,
details: message.details,
attribution: message.attribution,
},
options ? { deliverAs: options.deliverAs } : undefined,
);
}
resolveRoleModel(role: string): Model | undefined {
return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
}
@@ -7246,6 +7408,18 @@ export class AgentSession {
};
}
#buildVibeModeMessage(): CustomMessage | null {
if (!this.#vibeModeState?.enabled) return null;
return {
role: "custom",
customType: "vibe-mode-context",
content: prompt.render(vibeModeActivePrompt),
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
#sanitizeGoalTodoText(text: string): string {
return escapeXmlText(text)
.replace(/\r\n/g, "\\n")
@@ -7683,6 +7857,10 @@ export class AgentSession {
if (goalModeMessage) {
messages.push(goalModeMessage);
}
const vibeModeMessage = this.#buildVibeModeMessage();
if (vibeModeMessage) {
messages.push(vibeModeMessage);
}
if (options?.prependMessages) {
messages.push(...options.prependMessages);
}
@@ -8312,11 +8490,10 @@ export class AgentSession {
}
/**
* Send a user message to the agent.
* When deliverAs is set, queue the message instead of starting a new turn.
* Send a user message through the prompt flow.
*
* @param content User message content (string or content array)
* @param options.deliverAs Delivery mode: "steer" or "followUp"
* Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
* Explicit `deliverAs` queues without starting a turn in either state.
*/
async sendUserMessage(
content: string | (TextContent | ImageContent)[],
@@ -8351,10 +8528,13 @@ export class AgentSession {
return;
}
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion.
// `streamingBehavior: "steer"` preserves prompt-flow side effects during streaming while
// covering the narrow race where a stream starts before prompt() acquires the turn.
await this.prompt(text, {
expandPromptTemplates: false,
images,
streamingBehavior: "steer",
});
}
@@ -8704,6 +8884,7 @@ export class AgentSession {
this.#clearCheckpointRuntimeState();
this.setTodoPhases([]);
this.#freshProviderSessionId = undefined;
this.#clearInheritedProviderPromptCacheKey();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -8804,6 +8985,7 @@ export class AgentSession {
// Update agent session ID
this.#freshProviderSessionId = undefined;
this.#adoptInheritedProviderPromptCacheKey();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -9125,6 +9307,9 @@ export class AgentSession {
this.#autoThinking = true;
this.#autoResolvedLevel = undefined;
this.#thinkingLevel = provisional;
if (!wasAuto) {
this.#clearInheritedProviderPromptCacheKey();
}
this.#applyThinkingLevelToAgent(provisional);
if (persist) {
this.settings.set("defaultThinkingLevel", AUTO_THINKING);
@@ -9148,6 +9333,7 @@ export class AgentSession {
this.#applyThinkingLevelToAgent(effectiveLevel);
if (isChanging) {
this.#clearInheritedProviderPromptCacheKey();
this.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveLevel);
if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
this.settings.set("defaultThinkingLevel", effectiveLevel);
@@ -9166,7 +9352,7 @@ export class AgentSession {
}
/**
* Cycle to next thinking level: off → auto → minimal..xhigh → off.
* Cycle to next thinking level: off → auto → minimal..max → off.
* @returns New selector, or undefined if model doesn't support thinking
*/
cycleThinkingLevel(): ConfiguredThinkingLevel | undefined {
@@ -9208,8 +9394,9 @@ export class AgentSession {
let resolved: Effort | undefined;
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
// The user explicitly asked for maximum thinking; bypass the classifier
// and jump straight to the highest auto-supported level for this model.
resolved = clampAutoThinkingEffort(model, Effort.XHigh);
// (and its xhigh auto ceiling) and jump straight to the highest
// supported level for this model.
resolved = clampAutoThinkingEffort(model, Effort.Max);
} else {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), AgentSession.#AUTO_THINKING_TIMEOUT_MS);
@@ -9751,6 +9938,7 @@ export class AgentSession {
let firstKeptEntryId: string;
let tokensBefore: number;
let details: unknown;
let codexCompaction: CodexCompactionContext | undefined;
// Snapcompact runs locally first. The frame cap is sized from the live
// model window via #computeSnapcompactMaxFrames so the post-render context
@@ -9830,6 +10018,11 @@ export class AgentSession {
details = snapcompactResult.details;
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
} else {
codexCompaction = createCodexCompactionContext({
trigger: "manual",
reason: "user_requested",
phase: "standalone_turn",
});
// Generate compaction result. Only convert known abort-shaped
// rejections (AbortError raised while the abort signal is set,
// or an already-typed sentinel) into `CompactionCancelledError`
@@ -9851,6 +10044,7 @@ export class AgentSession {
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
codexCompaction,
},
compactionCandidates,
);
@@ -9893,7 +10087,11 @@ export class AgentSession {
this.#planReferenceSent = false;
this.#resetAllAdvisorRuntimes();
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
if (codexCompaction) {
this.#resetCodexProviderAfterCompaction(codexCompaction);
} else {
this.#closeCodexProviderSessionsForHistoryRewrite();
}
// Get the saved compaction entry for the hook
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
@@ -10259,6 +10457,7 @@ export class AgentSession {
await this.#runAutoCompaction("threshold", false, false, false, {
autoContinue: false,
triggerContextTokens: contextTokens,
phase: "pre_turn",
});
}
@@ -10333,6 +10532,7 @@ export class AgentSession {
suppressContinuation: true,
suppressHandoff: true,
triggerContextTokens: contextTokens,
phase: "mid_turn",
});
if (signal?.aborted) return;
@@ -10579,6 +10779,7 @@ export class AgentSession {
return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
autoContinue,
triggerContextTokens: postMaintenanceContextTokens,
phase: "pre_turn",
});
}
logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
@@ -10589,6 +10790,24 @@ export class AgentSession {
}
return COMPACTION_CHECK_NONE;
}
#isTerminalYieldToolResult(event: { toolName: string; isError?: boolean; result?: { details?: unknown } }): boolean {
if (event.toolName !== "yield" || event.isError) return false;
const details = event.result?.details;
if (!details || typeof details !== "object") return true;
const record = details as Record<string, unknown>;
return !(
record.status === "success" &&
Array.isArray(record.type) &&
record.type.length > 0 &&
record.type.every(item => typeof item === "string")
);
}
#markTerminalYieldToolCall(toolCallId: string): void {
this.#lastSuccessfulYieldToolCallId = toolCallId;
this.#yieldTerminationPending = true;
}
#assistantMessageHasSuccessfulYieldToolCall(assistantMessage: AssistantMessage, toolCallId: string): boolean {
const lastToolCall = assistantMessage.content
.slice()
@@ -10748,21 +10967,21 @@ export class AgentSession {
this.#emptyStopRetryCount++;
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
logger.warn("Assistant returned empty stop after retry cap", {
attempts: this.#emptyStopRetryCount - 1,
const attempts = this.#emptyStopRetryCount - 1;
const finalError = "Assistant returned empty stop after retry cap";
logger.warn(finalError, {
attempts,
model: assistantMessage.model,
provider: assistantMessage.provider,
});
if (this.#retryAttempt > 0) {
await this.#emitSessionEvent({
type: "auto_retry_end",
success: false,
attempt: this.#retryAttempt,
finalError: "Assistant returned empty stop after retry cap",
});
this.#clearPendingRecoveredRetryErrors();
this.#retryAttempt = 0;
}
await this.#emitSessionEvent({
type: "auto_retry_end",
success: false,
attempt: this.#retryAttempt > 0 ? this.#retryAttempt : attempts,
finalError,
});
this.#clearPendingRecoveredRetryErrors();
this.#retryAttempt = 0;
this.#resolveRetry();
// Tool-use orphans corrupt Anthropic message history (tool_result without
// matching tool_use). Always remove them even when the retry cap is hit.
@@ -10939,7 +11158,11 @@ export class AgentSession {
): Promise<CompactionCheckResult> {
const compactionEntryBefore = getLatestCompactionEntry(this.sessionManager.getBranch());
await this.#dropPersistedAssistantTurn(assistantMessage);
const result = await this.#runAutoCompaction(reason, true, false, allowDefer, options);
const result = await this.#runAutoCompaction(reason, true, false, allowDefer, {
autoContinue: options.autoContinue,
triggerContextTokens: options.triggerContextTokens,
phase: "mid_turn",
});
const compactionEntryAfter = getLatestCompactionEntry(this.sessionManager.getBranch());
if (result.historyRewritten !== true && compactionEntryAfter === compactionEntryBefore) {
this.#restoreFailedAssistantTurn(assistantMessage);
@@ -11538,6 +11761,9 @@ export class AgentSession {
const currentModel = this.model;
if (currentModel) {
this.#closeProviderSessionsForModelSwitch(currentModel, model);
if (!modelsAreEqual(currentModel, model)) {
this.#clearInheritedProviderPromptCacheKey();
}
}
this.agent.setModel(model);
@@ -11551,6 +11777,14 @@ export class AgentSession {
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
}
#resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void {
resetOpenAICodexHistoryAfterCompaction({
providerSessionState: this.#providerSessionState,
sessionId: this.sessionId,
compaction,
});
}
#resetCurrentResponsesProviderSession(reason: string): void {
const currentModel = this.model;
if (currentModel?.api !== "openai-responses" && currentModel?.api !== "openai-codex-responses") {
@@ -11826,7 +12060,7 @@ export class AgentSession {
if (!trimmedTarget) return undefined;
const parsed = parseModelString(trimmedTarget, {
allowMaxAlias: true,
allowMaxSuffix: true,
allowAutoAlias: true,
isLiteralModelId: (provider, id) =>
availableModels.some(model => model.provider === provider && model.id === id),
@@ -11921,18 +12155,6 @@ export class AgentSession {
return candidates;
}
#isCompactionAuthFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
// Real provider 401/403 — surfaced as `.status` by the compaction layer
// (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts).
// Without this branch, an expired/revoked Anthropic key would bypass the
// authenticated-fallback path and dump the raw HTTP body into the UI.
const status = (error as Error & { status?: number }).status;
if (status === 401 || status === 403) return true;
// pi-native gateway synthetic for "no credential configured" (issue #986).
// Carries no HTTP status, so the legacy message regex stays.
return /auth_unavailable|no auth available/i.test(error.message);
}
#buildCompactionAuthError(): Error {
const currentModel = this.model;
@@ -11982,6 +12204,7 @@ export class AgentSession {
tools: this.agent.state.tools,
sessionId: this.sessionId,
promptCacheKey: this.sessionId,
providerSessionState: this.#providerSessionState,
// Route every summarization HTTP request through the
// session's side-stream transport so the provider
// concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
@@ -11997,7 +12220,7 @@ export class AgentSession {
},
);
} catch (error) {
if (!this.#isCompactionAuthFailure(error)) {
if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) {
throw error;
}
}
@@ -12320,6 +12543,7 @@ export class AgentSession {
triggerContextTokens?: number;
suppressContinuation?: boolean;
suppressHandoff?: boolean;
phase?: CodexCompactionContext["phase"];
} = {},
): Promise<CompactionCheckResult> {
const compactionSettings = this.settings.getGroup("compaction");
@@ -12362,7 +12586,7 @@ export class AgentSession {
async signal => {
await Promise.resolve();
if (signal.aborted) return;
await this.#runAutoCompaction(reason, willRetry, true);
await this.#runAutoCompaction(reason, willRetry, true, true, { phase: options.phase });
},
{ generation },
);
@@ -12509,6 +12733,7 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let preserveData: Record<string, unknown> | undefined;
let codexCompaction: CodexCompactionContext | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
const hookResult = (await this.#extensionRunner.emit({
@@ -12647,6 +12872,13 @@ export class AgentSession {
const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
let compactResult: CompactionResult | undefined;
let lastError: unknown;
codexCompaction = createCodexCompactionContext({
trigger: "auto",
reason: "context_limit",
phase:
options.phase ??
(reason === "threshold" ? "pre_turn" : reason === "idle" ? "standalone_turn" : "mid_turn"),
});
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
const candidate = candidates[candidateIndex];
@@ -12679,6 +12911,8 @@ export class AgentSession {
tools: this.agent.state.tools,
sessionId: this.sessionId,
promptCacheKey: this.sessionId,
providerSessionState: this.#providerSessionState,
codexCompaction,
},
);
break;
@@ -12689,7 +12923,7 @@ export class AgentSession {
const message = error instanceof Error ? error.message : String(error);
const id = AIError.classify(error, candidate.api);
if (this.#isCompactionAuthFailure(error)) {
if (AIError.is(id, AIError.Flag.AuthFailed)) {
lastError = this.#buildCompactionAuthError();
break;
}
@@ -12797,7 +13031,11 @@ export class AgentSession {
this.#planReferenceSent = false;
this.#resetAllAdvisorRuntimes();
this.#syncTodoPhasesFromBranch();
this.#closeCodexProviderSessionsForHistoryRewrite();
if (codexCompaction) {
this.#resetCodexProviderAfterCompaction(codexCompaction);
} else {
this.#closeCodexProviderSessionsForHistoryRewrite();
}
// Get the saved compaction entry for the hook
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
@@ -14679,6 +14917,7 @@ export class AgentSession {
const previousSystemPrompt = this.agent.state.systemPrompt;
const previousBaseSystemPromptBeforeMemoryPromotion = this.#baseSystemPromptBeforeMemoryPromotion;
const previousFreshProviderSessionId = this.#freshProviderSessionId;
const previousInheritedProviderPromptCacheKey = this.#inheritedProviderPromptCacheKey;
const previousFallbackSelectedMCPToolNames = previousSessionFile
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
: undefined;
@@ -14700,6 +14939,8 @@ export class AgentSession {
await this.sessionManager.setSessionFile(sessionPath);
if (switchingToDifferentSession) {
this.#freshProviderSessionId = undefined;
this.#clearInheritedProviderPromptCacheKey();
this.#adoptInheritedProviderPromptCacheKey();
}
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -14853,6 +15094,7 @@ export class AgentSession {
this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages);
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey;
this.#checkpointState = previousCheckpointState;
this.#pendingRewindReport = previousPendingRewindReport;
this.#lastCompletedRewind = previousLastCompletedRewind;
@@ -14928,6 +15170,7 @@ export class AgentSession {
this.#rehydrateCheckpointRewindState();
this.#syncTodoPhasesFromBranch();
this.#freshProviderSessionId = undefined;
this.#clearInheritedProviderPromptCacheKey();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -0,0 +1,83 @@
import { describe, expect, it } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import * as snapcompact from "@oh-my-pi/snapcompact";
import type { CompactionSummaryMessage } from "./messages";
import { buildSessionContext } from "./session-context";
import type { SessionEntry } from "./session-entries";
const timestamp = "2026-07-09T00:00:00.000Z";
const compactedEntries = [
{
type: "message",
id: "m1",
parentId: null,
timestamp,
message: { role: "user", content: [{ type: "text", text: "before compaction" }], timestamp: 1 },
},
{
type: "compaction",
id: "c1",
parentId: "m1",
timestamp,
summary: "summary",
firstKeptEntryId: "m1",
tokensBefore: 123,
preserveData: {
[snapcompact.PRESERVE_KEY]: {
frames: [{ data: "base64-frame", mimeType: "image/png", cols: 10, rows: 10, chars: 100 }],
totalChars: 100,
truncatedChars: 0,
textHead: "head",
textTail: "tail",
},
},
},
{
type: "message",
id: "m2",
parentId: "c1",
timestamp,
message: { role: "user", content: [{ type: "text", text: "after compaction" }], timestamp: 2 },
},
] satisfies SessionEntry[];
function compactionSummary(messages: AgentMessage[]): CompactionSummaryMessage {
const summary = messages.find(
(message): message is CompactionSummaryMessage => message.role === "compactionSummary",
);
if (!summary) throw new Error("Expected a compaction summary message");
return summary;
}
describe("buildSessionContext snapcompact archives", () => {
it("omits snapcompact archive blocks from collapsed transcript summaries", () => {
const context = buildSessionContext(compactedEntries, undefined, undefined, {
transcript: true,
collapseCompactedHistory: true,
});
const summary = compactionSummary(context.messages);
expect(summary.images).toBeUndefined();
expect(summary.blocks).toBeUndefined();
});
it("keeps snapcompact archive blocks in full transcript summaries", () => {
const context = buildSessionContext(compactedEntries, undefined, undefined, { transcript: true });
const summary = compactionSummary(context.messages);
expect(summary.images?.map(image => image.data)).toEqual(["base64-frame"]);
expect(summary.blocks?.map(block => block.type)).toEqual(["text", "image", "text"]);
});
it("keeps snapcompact archive blocks in provider context summaries", () => {
const context = buildSessionContext(compactedEntries);
const summary = compactionSummary(context.messages);
expect(summary.images?.map(image => image.data)).toEqual(["base64-frame"]);
expect(summary.blocks?.map(block => block.type)).toEqual(["text", "image", "text"]);
});
});
@@ -132,6 +132,7 @@ function snapcompactHistoryBlocksForContext(
options: BuildSessionContextOptions | undefined,
) {
if (!archive) return undefined;
if (options?.transcript && options.collapseCompactedHistory) return undefined;
return snapcompact.historyBlocks(archive, snapcompactHistoryBlockOptions(archive, options));
}
@@ -32,10 +32,14 @@ export interface SessionHeader {
timestamp: string;
cwd: string;
parentSession?: string;
/** Provider prompt-cache identity inherited by exact-route full forks. */
providerPromptCacheKey?: string;
}
export interface NewSessionOptions {
parentSession?: string;
/** Provider prompt-cache identity to seed on the new session header. */
providerPromptCacheKey?: string;
/** Skip flushing the current session and delete it instead of saving. */
drop?: boolean;
}
@@ -15,6 +15,7 @@ import {
getSessionsDir,
isEnoent,
logger,
stringifyJson,
toError,
} from "@oh-my-pi/pi-utils";
import { ArtifactManager } from "./artifacts";
@@ -539,7 +540,7 @@ export class SessionManager {
}
#lineFor(entry: FileEntry): string {
return `${JSON.stringify(prepareEntryForPersistence(entry, this.#blobs))}\n`;
return `${stringifyJson(prepareEntryForPersistence(entry, this.#blobs)) ?? "null"}\n`;
}
#titleSlotLine(): string {
@@ -783,6 +784,7 @@ export class SessionManager {
timestamp,
cwd: this.#cwd,
parentSession: options?.parentSession,
providerPromptCacheKey: options?.providerPromptCacheKey,
};
this.#titleUpdatedAt = timestamp;
@@ -1050,6 +1052,7 @@ export class SessionManager {
timestamp,
cwd: this.#cwd,
parentSession: parentSessionId,
providerPromptCacheKey: this.#header.providerPromptCacheKey ?? parentSessionId,
};
this.#sessionName = this.#header.title;
this.#titleSource = this.#header.titleSource;
@@ -1888,7 +1891,13 @@ export class SessionManager {
const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[];
manager.#resetToNewSession({ parentSession: sourceHeader?.id }, options?.sessionFile);
manager.#resetToNewSession(
{
parentSession: sourceHeader?.id,
providerPromptCacheKey: sourceHeader?.providerPromptCacheKey ?? sourceHeader?.id,
},
options?.sessionFile,
);
manager.#header.title = sourceHeader?.title;
manager.#header.titleSource = sourceHeader?.titleSource;
manager.#sessionName = manager.#header.title;
@@ -16,7 +16,6 @@
import { countTokens } from "@oh-my-pi/pi-agent-core";
import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
import { isPersonalGitHubCopilotBaseUrl } from "@oh-my-pi/pi-catalog/wire/github-copilot";
import * as snapcompact from "@oh-my-pi/snapcompact";
import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -78,19 +77,6 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
}
/**
* The model is vision-capable for the endpoint we're actually about to hit.
* GitHub Copilot business and enterprise hosts respond `400 vision is not
* supported` on image inputs (issue #3387), so even if a stale cached spec
* still advertises `["text","image"]` we MUST not rasterize transcripts when
* the resolved `baseUrl` is non-personal.
*/
function canSendImages(model: Model): boolean {
if (!model.input.includes("image")) return false;
if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
return true;
}
interface SystemPromptImageTarget {
scope: Exclude<SnapcompactSystemPromptMode, "none">;
text: string;
@@ -291,7 +277,7 @@ export function estimateInlineSavings(input: {
messages: readonly InlineMessageView[];
}): SnapcompactSavingsEstimate {
const { options, model } = input;
if (!model || !canSendImages(model)) {
if (!model?.input.includes("image")) {
return { visionCapable: false, savedTokens: 0 };
}
@@ -430,10 +416,8 @@ export class SnapcompactInlineTransformer {
async transform(context: Context, model: Model): Promise<Context> {
// Vision gate: providers silently DROP images on text-only models —
// rendering would lose the content entirely. Also short-circuits when the
// resolved endpoint rejects vision regardless of the model's input list
// (issue #3387: Copilot business endpoint).
if (!canSendImages(model)) return context;
// rendering would lose the content entirely.
if (!model.input.includes("image")) return context;
const shape = snapcompact.resolveShape(model, this.options.shape);
const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);