Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 62 additions & 17 deletions apps/desktop/electron/main/ipc/agent-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest } from "@pi-desktop/shared";
import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, type ThinkingLevel } from "@pi-desktop/shared";
import type { FinishTurn } from "../runtime/plans";
import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime";
import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth";
Expand All @@ -12,6 +12,7 @@ import type { Logger } from "../logger";
import type { PersistenceOutbox } from "../persistence-outbox";
import type { ComposerCommandService } from "./composer-ipc";
import type { IpcRegistrar } from "./types";
import { withPromptEnhancementTimeout } from "../prompt-enhancement-timeout";

export type AgentIpcDependencies = {
registrar: IpcRegistrar;
Expand Down Expand Up @@ -122,29 +123,73 @@ export function registerAgentIpc({
}
const settings = await host.call<any>("settings.get");
const launchSessionId = sessionId || `prompt-enhancement:${crypto.randomUUID()}`;
const launch = await resolveAgentRuntimeLaunch(
launchSessionId,
session ?? {},
settings,
{
// A pinned enhancement model is a preference, not a hard requirement: a
// pin whose provider was disabled, whose account was signed out, or whose
// binding no longer exists must not take the action down. Try the pin,
// fall back to the Composer's current model, and record why (ADR 0121).
const pinnedProviderId =
typeof settings?.promptEnhancementProviderId === "string"
? settings.promptEnhancementProviderId.trim()
: "";
const pinnedModelId =
typeof settings?.promptEnhancementModelId === "string"
? settings.promptEnhancementModelId.trim()
: "";
const composerProviderId =
typeof req.providerId === "string" ? req.providerId.trim() : undefined;
const composerModelId =
typeof req.modelId === "string" ? req.modelId.trim() : undefined;
// The enhancement carries its own reasoning level and never follows the
// conversation's: an unset value means "off", because a rewrite rarely
// benefits from reasoning and reasoning is the slow path.
const enhancementThinkingLevel =
typeof settings?.promptEnhancementThinkingLevel === "string"
? settings.promptEnhancementThinkingLevel.trim()
: "";
const launchFor = (providerId?: string, modelId?: string) =>
resolveAgentRuntimeLaunch(launchSessionId, session ?? {}, settings, {
mode: "agent",
providerId:
typeof req.providerId === "string" ? req.providerId.trim() : undefined,
modelId: typeof req.modelId === "string" ? req.modelId.trim() : undefined,
thinkingLevel: req.thinkingLevel,
},
);
providerId,
modelId,
thinkingLevel: (enhancementThinkingLevel || "off") as ThinkingLevel,
});
let launch: Awaited<ReturnType<typeof launchFor>>;
if (pinnedProviderId) {
try {
launch = await launchFor(pinnedProviderId, pinnedModelId || undefined);
} catch (error) {
logger.app("session", "warn", "prompt enhancement model unavailable", {
data: {
pinnedProviderId,
pinnedModelId: pinnedModelId || undefined,
error: error instanceof Error ? error.message : String(error),
},
});
launch = await launchFor(composerProviderId, composerModelId);
}
} else {
launch = await launchFor(composerProviderId, composerModelId);
}
const runtimeProvider = {
...launch.sidecarParams.provider,
...(launch.sidecarParams.provider.authKind === OAUTH_AUTH_KIND
? { resolveAuth: () => vendorOAuth.resolveAuth(launch.providerId) }
: {}),
} as RuntimeProviderConfig;
const enhancedDraft = await enhancePromptDraft(
runtimeProvider,
draft,
launch.sidecarParams.thinkingLevel,
{ sessionId: launchSessionId },
// A pin, a slow gateway, or a stalled connection would otherwise hold this
// promise open indefinitely. Aborting is best-effort (the transport only
// consults the signal between provider retries); racing the promise is what
// actually guarantees the caller is released on time.
const enhancedDraft = await withPromptEnhancementTimeout((signal) =>
enhancePromptDraft(runtimeProvider, draft, launch.sidecarParams.thinkingLevel, {
signal,
sessionId: launchSessionId,
customTemplate: settings?.promptEnhancementCustomTemplate === true,
userTemplate:
typeof settings?.promptEnhancementUserTemplate === "string"
? settings.promptEnhancementUserTemplate
: undefined,
}),
);
logger.app("session", "info", "prompt enhanced", {
sessionId: sessionId || undefined,
Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/electron/main/prompt-enhancement-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Bound one prompt-enhancement request.
*
* The transport only consults its abort signal between provider retries, so
* aborting is best-effort cancellation. Racing the promise is what actually
* guarantees the caller is released: without it a stalled connection or a slow
* gateway holds the renderer's promise open indefinitely, and the provider
* retry budget alone can already spend about a minute before giving up.
*
* The helper still aborts: a timeout that only races leaves the provider call
* running, which burns tokens and can overlap the user's next click.
*
* Kept in its own module (no Electron or pi-ai imports) so the behavior is
* directly testable.
*/

/** Hard ceiling for one enhancement request. */
export const PROMPT_ENHANCEMENT_TIMEOUT_MS = 60_000;

/** Classified timeout failure the renderer renders as a dismissible error. */
export type PromptEnhancementTimeoutError = Error & {
errorCode: "TIMEOUT";
};

export function promptEnhancementTimeoutError(
timeoutMs: number,
): PromptEnhancementTimeoutError {
return Object.assign(
new Error(
`Prompt enhancement timed out after ${Math.round(timeoutMs / 1000)}s. Try again, or pick a faster enhancement model in Settings.`,
),
{ errorCode: "TIMEOUT" as const },
);
}

/**
* Run `start` with an abort signal, or reject with `TIMEOUT` after `timeoutMs`.
*
* A failure from `start` itself passes through unchanged; only an unanswered
* request becomes a timeout. Abort after timeout is best-effort; the race is
* what frees the caller. The rejection is never retried on another model:
* the user chose this one, and a hidden second attempt would double the wait.
*/
export function withPromptEnhancementTimeout<T>(
start: (signal: AbortSignal) => Promise<T>,
timeoutMs: number = PROMPT_ENHANCEMENT_TIMEOUT_MS,
): Promise<T> {
const controller = new AbortController();
const work = start(controller.signal);
return new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (action: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
action();
};
const timer = setTimeout(() => {
controller.abort();
finish(() => reject(promptEnhancementTimeoutError(timeoutMs)));
}, timeoutMs);
work.then(
(value) => finish(() => resolve(value)),
(error) => {
if (controller.signal.aborted) return;
finish(() => reject(error));
},
);
});
}
Loading
Loading