diff --git a/apps/sidecar/src/rpc/channel-handlers.ts b/apps/sidecar/src/rpc/channel-handlers.ts index e28994ba6..56800b410 100644 --- a/apps/sidecar/src/rpc/channel-handlers.ts +++ b/apps/sidecar/src/rpc/channel-handlers.ts @@ -12,12 +12,17 @@ import { updateChannel } from "../services/channel/channel-manager"; import type { RpcHandler } from "./types"; -import { asObject, asString, validateInput } from "./validation"; +import { validateInput } from "./validation"; import { + channelIdParamsSchema, channelCreateInputSchema, channelDeleteParamsSchema, channelUpdateParamsSchema, - fetchModelsInputSchema + connectionIdParamsSchema, + fetchModelsInputSchema, + oauthAnswerParamsSchema, + oauthCancelParamsSchema, + oauthSessionIdParamsSchema } from "./schemas"; import { answerConnectionOAuthPrompt, @@ -41,52 +46,36 @@ export function createChannelHandlers(): Record { return { ok: true }; }, [CHANNEL_IPC_CHANNELS.DECRYPT_KEY]: async (params) => { - const payload = asObject(params); - const channelId = asString(payload.channelId); - if (!channelId) { - throw new Error("缺少 channelId"); - } - return decryptApiKey(channelId); + const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.DECRYPT_KEY); + return decryptApiKey(input.channelId); }, [CHANNEL_IPC_CHANNELS.TEST]: async (params) => { - const payload = asObject(params); - const channelId = asString(payload.channelId); - if (!channelId) { - throw new Error("缺少 channelId"); - } - return testChannel(channelId); + const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.TEST); + return testChannel(input.channelId); }, [CHANNEL_IPC_CHANNELS.TEST_DIRECT]: async (params) => testChannelDirect(validateInput(fetchModelsInputSchema, params, CHANNEL_IPC_CHANNELS.TEST_DIRECT) as FetchModelsInput), [CHANNEL_IPC_CHANNELS.FETCH_MODELS]: async (params) => fetchModels(validateInput(fetchModelsInputSchema, params, CHANNEL_IPC_CHANNELS.FETCH_MODELS) as FetchModelsInput), [CHANNEL_IPC_CHANNELS.SYNC_MODELS]: async (params) => { - const payload = asObject(params); - const channelId = asString(payload.channelId); - if (!channelId) throw new Error("缺少 channelId"); - return syncChannelModels(channelId); + const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.SYNC_MODELS); + return syncChannelModels(input.channelId); }, [CHANNEL_IPC_CHANNELS.OAUTH_START]: async (params) => { - const connectionId = asString(asObject(params).connectionId); - if (!connectionId) throw new Error("缺少 connectionId"); - return startConnectionOAuthLogin(connectionId); + const input = validateInput(connectionIdParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_START); + return startConnectionOAuthLogin(input.connectionId); }, [CHANNEL_IPC_CHANNELS.OAUTH_STATUS]: async (params) => { - const sessionId = asString(asObject(params).sessionId); - if (!sessionId) throw new Error("缺少 sessionId"); - return getConnectionOAuthSession(sessionId); + const input = validateInput(oauthSessionIdParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_STATUS); + return getConnectionOAuthSession(input.sessionId); }, [CHANNEL_IPC_CHANNELS.OAUTH_ANSWER]: async (params) => { - const payload = asObject(params); - const sessionId = asString(payload.sessionId); - const promptId = asString(payload.promptId); - const value = asString(payload.value); - if (!sessionId || !promptId) throw new Error("缺少 OAuth prompt 参数"); - return answerConnectionOAuthPrompt(sessionId, promptId, value ?? ""); + const input = validateInput(oauthAnswerParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_ANSWER); + return answerConnectionOAuthPrompt(input.sessionId, input.promptId, input.value ?? ""); }, [CHANNEL_IPC_CHANNELS.OAUTH_CANCEL]: async (params) => { - const sessionId = asString(asObject(params).sessionId); - if (sessionId) cancelConnectionOAuthLogin(sessionId); + const input = validateInput(oauthCancelParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_CANCEL); + if (input.sessionId) cancelConnectionOAuthLogin(input.sessionId); return { ok: true }; }, }; diff --git a/apps/sidecar/src/rpc/schemas.channel-params.test.ts b/apps/sidecar/src/rpc/schemas.channel-params.test.ts new file mode 100644 index 000000000..f0efda30e --- /dev/null +++ b/apps/sidecar/src/rpc/schemas.channel-params.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + channelIdParamsSchema, + connectionIdParamsSchema, + oauthAnswerParamsSchema, + oauthCancelParamsSchema, + oauthSessionIdParamsSchema +} from "./schemas"; + +describe("channel RPC param schemas", () => { + test("channelIdParamsSchema accepts a non-empty channelId", () => { + expect(channelIdParamsSchema.parse({ channelId: "ch-1" })).toEqual({ channelId: "ch-1" }); + }); + + test("channelIdParamsSchema rejects missing and empty channelId", () => { + expect(channelIdParamsSchema.safeParse({}).success).toBe(false); + expect(channelIdParamsSchema.safeParse({ channelId: "" }).success).toBe(false); + expect(channelIdParamsSchema.safeParse({ channelId: 42 }).success).toBe(false); + }); + + test("param schemas reject unknown keys like the sibling channel schemas", () => { + expect(channelIdParamsSchema.safeParse({ channelId: "ch-1", extra: true }).success).toBe(false); + expect(connectionIdParamsSchema.safeParse({ connectionId: "conn-1", channelId: "x" }).success).toBe(false); + }); + + test("oauthSessionIdParamsSchema requires a non-empty sessionId", () => { + expect(oauthSessionIdParamsSchema.parse({ sessionId: "s-1" }).sessionId).toBe("s-1"); + expect(oauthSessionIdParamsSchema.safeParse({ sessionId: "" }).success).toBe(false); + }); + + test("oauthAnswerParamsSchema keeps value optional and ids required", () => { + expect(oauthAnswerParamsSchema.parse({ sessionId: "s-1", promptId: "p-1" }).value).toBeUndefined(); + expect(oauthAnswerParamsSchema.parse({ sessionId: "s-1", promptId: "p-1", value: "yes" }).value).toBe("yes"); + expect(oauthAnswerParamsSchema.safeParse({ sessionId: "s-1" }).success).toBe(false); + }); + + test("oauthCancelParamsSchema tolerates an absent sessionId (legacy no-op face)", () => { + expect(oauthCancelParamsSchema.parse({})).toEqual({}); + expect(oauthCancelParamsSchema.parse({ sessionId: "s-1" }).sessionId).toBe("s-1"); + expect(oauthCancelParamsSchema.safeParse({ sessionId: "" }).success).toBe(false); + }); +}); diff --git a/apps/sidecar/src/rpc/schemas.ts b/apps/sidecar/src/rpc/schemas.ts index b2af2f064..1a197bcb8 100644 --- a/apps/sidecar/src/rpc/schemas.ts +++ b/apps/sidecar/src/rpc/schemas.ts @@ -1818,3 +1818,17 @@ export const fetchModelsInputSchema = z.object({ apiFamily: channelApiFamilySchema.optional(), openaiApiMode: channelOpenAiApiModeSchema.optional() }).strict(); + +export const channelIdParamsSchema = z.object({ channelId: idSchema }).strict(); + +export const connectionIdParamsSchema = z.object({ connectionId: idSchema }).strict(); + +export const oauthSessionIdParamsSchema = z.object({ sessionId: idSchema }).strict(); + +export const oauthAnswerParamsSchema = z.object({ + sessionId: idSchema, + promptId: idSchema, + value: z.string().optional() +}).strict(); + +export const oauthCancelParamsSchema = z.object({ sessionId: idSchema.optional() }).strict(); diff --git a/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts b/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts index f29aff42d..78f42c03a 100644 --- a/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts +++ b/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts @@ -17,7 +17,6 @@ import type { MemoryV2RecallItem } from "../../memory-v2/types"; import type { CollectedAppendContextEffect } from "../../workflow-hooks/hook-effects"; import { getPermissionDeniedSummary } from "../permissions/permission-denials"; import type { TraceRecorder } from "../trace/trace-recorder"; -import { DEFAULT_CONTEXT_BUDGET, type ContextBudget } from "./context-budget"; import { buildMessageAttachmentBrief } from "./message-attachments"; export interface ContextAssemblyInput { @@ -71,7 +70,7 @@ export interface ContextAssemblyResult { userMessageContentBlocks?: ContentBlockParam[]; sessionContext: string; planContext?: string; - budget: ContextBudget; + budget: { total: number }; trace: { includedMemoryIds: string[]; includedSessionMessageIds: string[]; @@ -341,7 +340,6 @@ Browser annotation bodies are the user's intent. URL, title, DOM locators, selec userMessageForModel, sessionContext: "", budget: { - ...DEFAULT_CONTEXT_BUDGET, total: input.tokenBudget }, trace: { diff --git a/apps/sidecar/src/services/agent-runtime/context/context-budget.ts b/apps/sidecar/src/services/agent-runtime/context/context-budget.ts deleted file mode 100644 index d1b1f2223..000000000 --- a/apps/sidecar/src/services/agent-runtime/context/context-budget.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface ContextBudget { - total: number; - system: number; - dynamic: number; - memory: number; - session: number; - toolSchemas: number; - reservedOutput: number; -} - -export const DEFAULT_CONTEXT_BUDGET: ContextBudget = { - total: 1, - system: 0.25, - dynamic: 0.15, - memory: 0.20, - session: 0.25, - toolSchemas: 0.10, - reservedOutput: 0.05 -}; diff --git a/apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts b/apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts deleted file mode 100644 index 61bdefe69..000000000 --- a/apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { composePromptSections } from "./section-composer"; - -describe("section-composer", () => { - test("filters by mode and renders in priority order", () => { - const prompt = composePromptSections([ - { - id: "later", - title: "Later", - priority: 20, - mode: ["full"], - content: "B" - }, - { - id: "minimal", - title: "Minimal", - priority: 5, - mode: ["minimal"], - content: "skip" - }, - { - id: "first", - title: "First", - priority: 10, - mode: ["full", "minimal"], - content: "A" - } - ], "full"); - - expect(prompt).toBe("## First\n\nA\n\n## Later\n\nB"); - }); -}); diff --git a/apps/sidecar/src/services/agent/prompt/sections/section-composer.ts b/apps/sidecar/src/services/agent/prompt/sections/section-composer.ts deleted file mode 100644 index 02638da6c..000000000 --- a/apps/sidecar/src/services/agent/prompt/sections/section-composer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PromptSection, PromptSectionMode } from "../types"; - -export function renderPromptSection(section: PromptSection): string { - if (section.title) { - return `## ${section.title}\n\n${section.content.trim()}`; - } - return section.content.trim(); -} - -export function composePromptSections( - sections: Array, - mode: PromptSectionMode -): string { - return sections - .filter((section): section is PromptSection => Boolean(section?.content.trim())) - .filter((section) => section.mode.includes(mode)) - .sort((a, b) => a.priority - b.priority) - .map(renderPromptSection) - .join("\n\n"); -} diff --git a/apps/sidecar/src/services/agent/prompt/types.ts b/apps/sidecar/src/services/agent/prompt/types.ts deleted file mode 100644 index 995baff78..000000000 --- a/apps/sidecar/src/services/agent/prompt/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type PromptSectionMode = "full" | "minimal"; - -export interface PromptSection { - id: string; - title?: string; - priority: number; - mode: PromptSectionMode[]; - content: string; - tokenBudget?: number; -} diff --git a/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts b/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts index 8d1ee53a8..05dcf3f2d 100644 --- a/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts +++ b/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts @@ -44,7 +44,7 @@ describe("pi-ai provider retry policy", () => { expect(isRetryablePiAiError(new PiAiProviderError("invalid key", { status: 401 }))).toBe(false); }); - test("uses 1/2/4/8/16 second backoff and caps Retry-After at 30 seconds", () => { + test("uses 1/2/4/8/16 second backoff and caps Retry-After at 120 seconds", () => { const deterministicRandom = () => 0.5; expect([0, 1, 2, 3, 4].map((index) => resolvePiAiRetryDelayMs(new Error("network"), index, deterministicRandom) @@ -53,7 +53,12 @@ describe("pi-ai provider retry policy", () => { new PiAiProviderError("busy", { retryAfterMs: 90_000 }), 0, deterministicRandom, - )).toBe(30_000); + )).toBe(90_000); + expect(resolvePiAiRetryDelayMs( + new PiAiProviderError("busy", { retryAfterMs: 300_000 }), + 0, + deterministicRandom, + )).toBe(120_000); }); test("falls back for model-specific and credential failures but not malformed requests", () => { diff --git a/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts b/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts index f77becdbe..b4c3c9cfa 100644 --- a/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts +++ b/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts @@ -21,12 +21,12 @@ import type { NormalizedMessageParam, NormalizedResponseBlock, } from "@lume/agent-sdk"; +import { MAX_RETRY_AFTER_DELAY_MS, parseRetryAfterHeader } from "@lume/agent-sdk"; type PiTextApi = "openai-completions" | "openai-responses" | "openai-codex-responses" | "anthropic-messages" | "google-generative-ai"; const DEFAULT_MAX_RETRIES = 5; const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; -const MAX_RETRY_AFTER_MS = 30_000; export interface PiAiProviderOptions { apiType: ApiType; @@ -80,7 +80,7 @@ export function resolvePiAiRetryDelayMs(error: unknown, retryIndex: number, rand const base = RETRY_DELAYS_MS[Math.min(retryIndex, RETRY_DELAYS_MS.length - 1)] ?? RETRY_DELAYS_MS.at(-1)!; const jittered = Math.round(base * (0.8 + random() * 0.4)); const retryAfter = typeof (error as { retryAfterMs?: unknown } | null)?.retryAfterMs === "number" - ? Math.min(MAX_RETRY_AFTER_MS, Math.max(0, (error as { retryAfterMs: number }).retryAfterMs)) + ? Math.min(MAX_RETRY_AFTER_DELAY_MS, Math.max(0, (error as { retryAfterMs: number }).retryAfterMs)) : 0; return Math.max(jittered, retryAfter); } @@ -288,12 +288,7 @@ function toResponse(message: AssistantMessage): CreateMessageResponse { } function retryAfterMs(headers: Record | undefined): number | undefined { - const raw = headers?.["retry-after"] ?? headers?.["Retry-After"]; - if (!raw) return undefined; - const seconds = Number(raw); - if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); - const date = Date.parse(raw); - return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined; + return parseRetryAfterHeader(headers?.["retry-after"] ?? headers?.["Retry-After"]); } function structuredOutputTransform(api: PiTextApi, schema: Record | undefined) { @@ -377,7 +372,7 @@ export class PiAiProvider implements LLMProvider { signal: params.abortSignal, maxTokens: params.maxTokens, maxRetries: 0, - maxRetryDelayMs: MAX_RETRY_AFTER_MS, + maxRetryDelayMs: MAX_RETRY_AFTER_DELAY_MS, sessionId: this.options.sessionId ?? params.promptCache?.routingKey, cacheRetention: params.promptCache?.ttl === "5m" ? "short" : "none", ...(params.thinking?.type === "disabled" ? {} : { reasoning: params.effort ?? "medium" }), diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f7d3ce564..d7a8347db 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -391,6 +391,9 @@ export { isRateLimitError, formatApiError, getRetryDelay, + computeRetryDelay, + parseRetryAfterHeader, + MAX_RETRY_AFTER_DELAY_MS, DEFAULT_RETRY_CONFIG, } from './utils/retry.js' export type { RetryConfig } from './utils/retry.js'