diff --git a/.playwright/scripts/__tests__/agentInput.e2e.mjs b/.playwright/scripts/__tests__/agentInput.e2e.mjs index 20cac9d7..9f452c88 100644 --- a/.playwright/scripts/__tests__/agentInput.e2e.mjs +++ b/.playwright/scripts/__tests__/agentInput.e2e.mjs @@ -127,6 +127,135 @@ for (const dimensions of [[2, 1], [2400, 1200]]) { }) } +test('model retries retain their run budget, expose progress and allow cancelling unlimited backoff', async ({ buddy }) => { + const requests = [] + const finishThinking = Promise.withResolvers() + let recover = true + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { + response.writeHead(404).end() + return + } + const chunks = [] + for await (const chunk of request) + chunks.push(chunk) + requests.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + if (!recover || requests.length < 3) { + response.writeHead(503, { 'content-type': 'application/json' }).end(JSON.stringify({ error: { message: 'Service temporarily unavailable', type: 'server_error' } })) + return + } + const common = { id: 'recovered', model: 'retry-fixture', object: 'chat.completion.chunk', created: 1 } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: 'Inspecting the recovered request.' }, finish_reason: null }] })}\n\n`) + await finishThinking.promise + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', content: 'Recovered after retry.' }, finish_reason: null }] })}\n\n`) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 32, completion_tokens: 8, total_tokens: 40 } })}\n\n`) + response.end('data: [DONE]\n\n') + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const instance = await buddy.createInstance('model-retry-progress') + try { + const application = await instance.launch() + await useSyntheticCredentialStorage(application) + const { page } = application + await page.evaluate(async (baseUrl) => { + await window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 2 } }) + const providers = window.lexoraDesktop.localChat.providers + await providers.upsertCustom({ id: 'retry-fixture', displayName: 'Retry fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'retry-fixture', name: 'Retry fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] }) + const stop = providers.onAuthChallenge((challenge) => { + if (challenge.providerId === 'retry-fixture' && challenge.type === 'secret') + void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key') + }) + try { + await providers.login('retry-fixture', 'api_key') + } + finally { + stop() + } + await providers.setDefaultModel({ providerId: 'retry-fixture', modelId: 'retry-fixture', reasoning: null }) + }, `http://127.0.0.1:${server.address().port}/v1`) + await page.reload() + const editor = page.locator('.desktop-chat-composer__prosemirror:visible') + const activity = page.locator('.buddy-chat-run-activity') + await editor.fill('Recover from the transient error.') + await page.getByRole('button', { name: '发送消息', exact: true }).click() + await expect(activity).toContainText(/重试 1\s*\/\s*2/) + await page.evaluate(() => window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 0 } })) + await expect(activity).toContainText(/重试 2\s*\/\s*2/) + await expect(activity).toContainText('秒后重试') + await expect(activity).toContainText('已用') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-finite.png'), animations: 'disabled' }) + await expect(activity).toContainText('正在思考') + await expect(activity).not.toContainText('重试') + await activity.getByRole('button', { name: '查看思考详情', exact: true }).click() + await expect(activity.locator('.buddy-chat-run-activity__reasoning')).toContainText('Inspecting the recovered request.') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-recovered-reasoning.png'), animations: 'disabled' }) + finishThinking.resolve() + await expect.poll(() => completedRuns(instance.home)).toBe(1) + expect(requests).toHaveLength(3) + for (const body of requests) + expect(body.messages.filter(message => message.role === 'user')).toHaveLength(1) + await expect(activity).toHaveCount(0) + + recover = false + await editor.fill('This next run has automatic retry disabled.') + await page.getByRole('button', { name: '发送消息', exact: true }).click() + await expect.poll(() => readDatabase(instance.home, db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = \'failed\'').get().count)).toBe(1) + expect(requests).toHaveLength(4) + await expect(activity).toHaveCount(0) + + await page.evaluate(() => window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 'unlimited' } })) + await editor.fill('Keep retrying until I stop this run.') + await page.getByRole('button', { name: '发送消息', exact: true }).click() + await expect.poll(() => readDatabase(instance.home, (db) => { + const row = db.prepare('SELECT payload_json FROM run_events JOIN runs ON runs.id = run_events.run_id WHERE runs.status = ? AND event_type = ? ORDER BY sequence DESC LIMIT 1').get('running', 'run.progress') + return row ? JSON.parse(row.payload_json) : null + })).toMatchObject({ retry: { attempt: 1, maxAttempts: 'unlimited' } }) + const retryRun = readDatabase(instance.home, db => db.prepare('SELECT id FROM runs WHERE status = ?').get('running')) + const publicProgress = await page.evaluate(async runId => (await window.lexoraDesktop.localChat.runs.listEvents({ runId })).filter(event => event.type === 'run.progress'), retryRun.id) + expect(publicProgress.at(-1).payload.retry).toMatchObject({ maxAttempts: 'unlimited' }) + await expect(activity).toContainText(/重试 1\s*\//) + await expect(activity.locator('.buddy-chat-run-activity__unlimited svg')).toBeVisible() + await page.reload() + await expect(activity.locator('.buddy-chat-run-activity__unlimited svg')).toBeVisible() + const typography = await activity.evaluate((element) => { + return ['.buddy-chat-activity-status__label', '.buddy-chat-run-activity__retry-attempt', '.buddy-chat-run-activity__duration'].map((selector) => { + const node = element.querySelector(selector) + const style = getComputedStyle(node) + const bounds = node.getBoundingClientRect() + return { fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, centerY: bounds.y + bounds.height / 2 } + }) + }) + expect(new Set(typography.map(item => `${item.fontFamily}:${item.fontSize}:${item.lineHeight}`)).size).toBe(1) + expect(Math.max(...typography.map(item => item.centerY)) - Math.min(...typography.map(item => item.centerY))).toBeLessThan(1) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-unlimited-light.png'), animations: 'disabled' }) + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } })) + await expect(page.locator('.buddy-app')).toHaveClass(/is-dark/) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-unlimited-dark.png'), animations: 'disabled' }) + await activity.evaluate(element => element.style.maxWidth = '320px') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-unlimited-narrow.png'), animations: 'disabled' }) + const narrow = await activity.evaluate((element) => { + const label = element.querySelector('.buddy-chat-activity-status__label').getBoundingClientRect() + const metadata = element.querySelector('.buddy-chat-run-activity__retry-meta').getBoundingClientRect() + return { labelLeft: label.x, metadataLeft: metadata.x, labelBottom: label.bottom, metadataTop: metadata.y, width: element.clientWidth, scrollWidth: element.scrollWidth } + }) + expect(narrow.metadataLeft).toBe(narrow.labelLeft) + expect(narrow.metadataTop).toBeGreaterThanOrEqual(narrow.labelBottom) + expect(narrow.scrollWidth).toBeLessThanOrEqual(narrow.width) + await page.getByRole('button', { name: '停止运行', exact: true }).click() + await expect.poll(() => readDatabase(instance.home, db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = \'cancelled\'').get().count)).toBe(1) + await expect(activity).toHaveCount(0) + expect(application.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + } + finally { + finishThinking.resolve() + await instance.stop() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +}) + async function useSyntheticCredentialStorage({ app, page }) { await app.evaluate(({ app, safeStorage }) => { if (app.getName() !== 'Lexora Buddy Test') diff --git a/.playwright/scripts/__tests__/settings.e2e.mjs b/.playwright/scripts/__tests__/settings.e2e.mjs index 10fe59f5..79a23a1d 100644 --- a/.playwright/scripts/__tests__/settings.e2e.mjs +++ b/.playwright/scripts/__tests__/settings.e2e.mjs @@ -391,6 +391,145 @@ export function render(context, container) { expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) }) +test('global model retry settings preserve finite, unlimited and disabled values across restart', async ({ buddy }) => { + const instance = await buddy.createInstance('model-retry-settings') + let desktop = await instance.launch() + await openSettings(desktop.page) + await desktop.page.locator('.desktop-settings-sidebar').getByRole('link', { name: '运行时', exact: true }).click() + const row = () => desktop.page.getByTestId('model-retry-limit-setting') + const countRow = () => desktop.page.getByTestId('model-retry-count-setting') + const input = () => countRow().locator('input.n-input__input-el') + const limit = () => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime.modelRetryLimit) + const mode = label => row().getByRole('button', { name: label, exact: true }) + const select = label => mode(label).click() + const expectCapsuleAligned = () => expect.poll(() => row().evaluate((element) => { + const capsule = element.querySelector('.n-tabs-capsule')?.getBoundingClientRect() + const selected = element.querySelector('[aria-pressed="true"]')?.getBoundingClientRect() + return !!capsule && !!selected && Math.abs(capsule.x - selected.x) < 1 && Math.abs(capsule.width - selected.width) < 1 + })).toBe(true) + await expect(row().getByRole('button')).toHaveCount(3) + await expect(mode('次数')).toHaveAttribute('aria-pressed', 'true') + await expect(row().locator('small')).toHaveText('遇到临时错误时重试模型请求。') + await expect(desktop.page.locator('.runtime-settings__group > .runtime-settings__row')).toHaveCount(2) + await expect(input()).toHaveValue('3') + await input().fill('12') + expect(await limit()).toBe(3) + await input().press('Enter') + await expect.poll(limit).toBe(12) + await expect(input()).toBeEnabled() + await select('次数') + expect(await limit()).toBe(12) + const modeBox = await row().boundingBox() + const countBox = await countRow().boundingBox() + expect(countBox.y).toBeCloseTo(modeBox.y + modeBox.height, 0) + expect(countBox.x).toBe(modeBox.x) + expect(countBox.width).toBe(modeBox.width) + expect((await row().locator('.runtime-settings__retry-modes').boundingBox()).width).toBeLessThan(200) + await expectCapsuleAligned() + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-finite-light.png'), animations: 'disabled' }) + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { language: 'en-US' } })) + await expect(mode('Limited')).toHaveAttribute('aria-pressed', 'true') + await expectCapsuleAligned() + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-finite-english.png'), animations: 'disabled' }) + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { language: 'zh-CN' } })) + await expect(mode('次数')).toHaveAttribute('aria-pressed', 'true') + await expectCapsuleAligned() + await row().locator('.n-tabs-capsule').evaluate((element) => { + const from = element.getBoundingClientRect().x + window.retrySegmentMotion = null + element.addEventListener('transitionrun', () => { + const animation = element.getAnimations().find(item => item.transitionProperty === 'transform') + if (!animation) + return + const duration = Number(animation.effect.getTiming().duration) + animation.pause() + animation.currentTime = duration / 2 + window.retrySegmentMotion = { + from, + middle: element.getBoundingClientRect().x, + to: element.closest('.n-tabs').querySelector('[aria-pressed="true"]').getBoundingClientRect().x, + duration, + } + animation.play() + }, { once: true }) + }) + await mode('无限').press('Space') + await expect.poll(limit).toBe('unlimited') + await expect(mode('无限')).toHaveAttribute('aria-pressed', 'true') + await expect.poll(() => desktop.page.evaluate(() => window.retrySegmentMotion)).not.toBeNull() + const motion = await desktop.page.evaluate(() => window.retrySegmentMotion) + expect(motion.duration).toBeGreaterThan(0) + expect(motion.middle).toBeGreaterThan(motion.from) + expect(motion.middle).toBeLessThan(motion.to) + await expectCapsuleAligned() + await expect(countRow()).toHaveCount(0) + await expect(row().locator('small')).toHaveText('遇到临时错误时重试模型请求。') + await desktop.page.emulateMedia({ reducedMotion: 'reduce' }) + await expect(row().locator('.n-tabs-capsule')).toHaveCSS('transition-duration', '0s') + await select('禁用') + await expect.poll(limit).toBe(0) + await expectCapsuleAligned() + await select('无限') + await expect.poll(limit).toBe('unlimited') + await expectCapsuleAligned() + await desktop.page.emulateMedia({ reducedMotion: 'no-preference' }) + await desktop.page.getByTestId('cache-warming-setting').locator('.n-select').click() + await desktop.page.locator('.n-base-select-menu').getByText('任务执行期间', { exact: true }).click() + await expect.poll(() => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime)).toEqual({ cacheWarming: 'streaming', modelRetryLimit: 'unlimited' }) + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-light.png'), animations: 'disabled' }) + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } })) + await expect(desktop.page.locator('.buddy-app')).toHaveClass(/is-dark/) + await desktop.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find(window => window.isVisible()).setSize(980, 680)) + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-dark.png'), animations: 'disabled' }) + await instance.stop() + desktop = await instance.launch() + await openSettings(desktop.page) + await desktop.page.locator('.desktop-settings-sidebar').getByRole('link', { name: '运行时', exact: true }).click() + await expect(mode('无限')).toHaveAttribute('aria-pressed', 'true') + await expect(countRow()).toHaveCount(0) + expect(await limit()).toBe('unlimited') + await select('次数') + await expect(input()).toHaveValue('3') + await input().fill('5') + await input().press('Tab') + await expect.poll(limit).toBe(5) + await expect(input()).toBeEnabled() + await desktop.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find(window => window.isVisible()).setSize(980, 680)) + await expect.poll(() => desktop.page.evaluate(() => window.innerWidth)).toBe(980) + await expectCapsuleAligned() + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-finite-dark-narrow.png'), animations: 'disabled' }) + await select('禁用') + await expect.poll(limit).toBe(0) + await expect(countRow()).toHaveCount(0) + expect(await fs.readFile(path.join(instance.home, 'config.toml'), 'utf8')).toContain('model_retry_limit = 0') + await desktop.app.evaluate(({ ipcMain }) => { + const gate = new Promise(resolve => globalThis.releaseRetrySettingFailure = resolve) + ipcMain.removeHandler('lexora:settings:update') + ipcMain.handle('lexora:settings:update', async () => { + await gate + throw new Error('Isolated retry-setting save failure') + }) + }) + await select('次数') + try { + await expect(mode('次数')).toHaveAttribute('aria-pressed', 'true') + await expect(mode('无限')).toBeDisabled() + await expect(input()).toHaveValue('3') + await expect(input()).toBeDisabled() + expect(await limit()).toBe(0) + } + finally { + await desktop.app.evaluate(() => globalThis.releaseRetrySettingFailure()) + } + await expect(desktop.page.getByText('保存失败,已恢复原设置', { exact: true })).toBeVisible() + await expect(mode('禁用')).toHaveAttribute('aria-pressed', 'true') + await expect(mode('次数')).toBeEnabled() + await expectCapsuleAligned() + await expect(countRow()).toHaveCount(0) + expect(await limit()).toBe(0) + expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + async function openSettings(page) { await page.locator('.desktop-app-sidebar').getByRole('button', { name: '设置', exact: true }).click() await page.locator('.desktop-settings-sidebar').getByRole('link', { name: '常规', exact: true }).click() diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index c95d74aa..c2fa36ca 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -224,7 +224,7 @@ export class DesktopRuntimeHost { await this.#network?.apply(config.proxy) const previous = this.#config?.runtime this.#config = config - if (previous?.cacheWarming !== config.runtime.cacheWarming) + if (previous?.cacheWarming !== config.runtime.cacheWarming || previous?.modelRetryLimit !== config.runtime.modelRetryLimit) this.#service?.notify(runtimePreferencesRpc.changed, config.runtime) await Promise.all(this.#features.map(feature => feature.applyConfig(config))) if (app.isPackaged && this.#environment.paths.profile === 'stable') diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index 4edddcaf..be2802d5 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -107,6 +107,7 @@ const petConfigSchema = z.object({ const lexoraConfigFileSchema = z.object({ runtime: z.object({ cache_warming: runtimePreferencesSchema.shape.cacheWarming.default(DEFAULT_RUNTIME_PREFERENCES.cacheWarming), + model_retry_limit: runtimePreferencesSchema.shape.modelRetryLimit.default(DEFAULT_RUNTIME_PREFERENCES.modelRetryLimit), }).passthrough().prefault({}), browser: z.object({ screenshot_destination: browserPreferencesSchema.shape.screenshotDestination.default(DEFAULT_BROWSER_PREFERENCES.screenshotDestination), @@ -277,7 +278,7 @@ function decodeConfig(value: unknown): LexoraConfig { } return { - runtime: { cacheWarming: config.runtime.cache_warming }, + runtime: { cacheWarming: config.runtime.cache_warming, modelRetryLimit: config.runtime.model_retry_limit }, browser: { screenshotDestination: config.browser.screenshot_destination, defaultZoomFactor: config.browser.default_zoom_factor, @@ -327,7 +328,7 @@ function decodeConfig(value: unknown): LexoraConfig { function encodeConfig(config: LexoraConfig) { return { - runtime: { cache_warming: config.runtime.cacheWarming }, + runtime: { cache_warming: config.runtime.cacheWarming, model_retry_limit: config.runtime.modelRetryLimit }, browser: { screenshot_destination: config.browser.screenshotDestination, default_zoom_factor: config.browser.defaultZoomFactor, diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index 558af4b4..81e658c6 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -18,6 +18,27 @@ async function createConfigStore() { } describe('lexoraConfigStore', () => { + it('defaults existing profiles to three retries and round-trips disabled, finite and unlimited limits', async () => { + const { store, configPath } = await createConfigStore() + await mkdir(dirname(configPath), { recursive: true }) + await writeFile(configPath, '[runtime]\ncache_warming = "streaming"\nfuture = true\n') + expect((await store.read()).runtime).toEqual({ cacheWarming: 'streaming', modelRetryLimit: 3 }) + for (const modelRetryLimit of [0, 7, 'unlimited'] as const) { + await store.update({ runtime: { modelRetryLimit } }) + await store.update({ desktop: { language: 'en-US' } }) + expect((await new LexoraConfigStore({ configPath }).read()).runtime).toEqual({ cacheWarming: 'streaming', modelRetryLimit }) + expect(await readFile(configPath, 'utf8')).toContain('future = true') + } + const saved = await readFile(configPath, 'utf8') + expect(saved).toContain('model_retry_limit = "unlimited"') + for (const modelRetryLimit of [-1, 1.5, Infinity, Number.NaN, Number.MAX_SAFE_INTEGER + 1, 'invalid'] as const) + await expect(store.update({ runtime: { modelRetryLimit: modelRetryLimit as never } })).rejects.toThrow() + await expect(store.update({ runtime: { modelRetryLimit: 0 } }, () => { + throw new Error('Apply failed') + })).rejects.toThrow() + expect(await readFile(configPath, 'utf8')).toBe(saved) + }) + it('defaults warming off and preserves runtime preferences across saves and restart', async () => { const { store, configPath } = await createConfigStore() expect((await store.read()).runtime.cacheWarming).toBe('off') diff --git a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts index 999512bb..87378732 100644 --- a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts +++ b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts @@ -1370,7 +1370,7 @@ function createRuntimeRpcHarness( value?: unknown } if (method === 'host.runtimePreferences.get') - return Promise.resolve({ cacheWarming: 'off' }) + return Promise.resolve({ cacheWarming: 'off', modelRetryLimit: 3 }) if (method === 'host.credentials.list') { return Promise.resolve({ ok: true, diff --git a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts index e031d3d0..d0ec1de3 100644 --- a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts +++ b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts @@ -307,7 +307,8 @@ describe('composer input at the Buddy session boundary', () => { ? terminalStream(model, { stopReason: 'error', errorMessage: '429 rate limit exceeded' }) : terminalStream(model), }) - fixture.session.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 1, baseDelayMs: 1 } }) + fixture.reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 1 }) + fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 1 } }) await fixture.send(plan()) expect(fixture.contexts).toHaveLength(2) @@ -782,6 +783,8 @@ async function createFixture(options: { shutdown: created.shutdown, }) + reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 0 }) + async function send(input: InputPlan, runId = `run-${input.messageId}`) { const reference = toBuddyInputReference(input) const existing = session.sessionManager.getBranch().find(entry => ( diff --git a/apps/buddy/service/src/agent/events/PiEventBridge.ts b/apps/buddy/service/src/agent/events/PiEventBridge.ts index a0e9f7b4..c172a086 100644 --- a/apps/buddy/service/src/agent/events/PiEventBridge.ts +++ b/apps/buddy/service/src/agent/events/PiEventBridge.ts @@ -221,7 +221,8 @@ class ActivePiEventChannel implements PiCompactionEventChannel, PiTurnEventChann } #enqueueEvent(event: AgentSessionEvent): void { - this.#eventTail = this.#eventTail.then(() => this.#handleEvent(event)) + const observedAt = Date.now() + this.#eventTail = this.#eventTail.then(() => this.#handleEvent(event, observedAt)) } #flushPendingTerminalToolUpdates(): void { @@ -249,8 +250,8 @@ class ActivePiEventChannel implements PiCompactionEventChannel, PiTurnEventChann this.#terminalToolUpdateTimer.unref() } - async #handleEvent(event: AgentSessionEvent): Promise { - const projected = projectPiEvent(event, this.#projectionState) + async #handleEvent(event: AgentSessionEvent, observedAt: number): Promise { + const projected = projectPiEvent(event, this.#projectionState, observedAt) if (projected.failureCode) { this.#failureCode = projected.failureCode this.#failureMessage = projected.failureMessage diff --git a/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts b/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts index ed78c000..9fbbad72 100644 --- a/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts +++ b/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts @@ -1,6 +1,7 @@ import type { AssistantMessage, Usage } from '@earendil-works/pi-ai' import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent' import { describe, expect, it } from 'vitest' +import { publicRunEventSchema, toPublicRunEvent } from '../../../../../shared/runs/publicRunEvent' import { createPiEventProjectionState, @@ -9,6 +10,70 @@ import { projectToolExecutionDenied, } from '../projectPiEvent' +describe('projectPiEvent retries', () => { + it.each([3, Infinity])('preserves the %s budget and deadline through public JSON and clears it on settlement', (maxAttempts) => { + const state = createPiEventProjectionState() + const now = Date.parse('2026-09-21T00:00:00Z') + const projection = projectPiEvent({ type: 'auto_retry_start', attempt: 1, maxAttempts, delayMs: 2000, errorMessage: 'private upstream error' }, state, now) + const retry = { attempt: 1, maxAttempts: maxAttempts === Infinity ? 'unlimited' : maxAttempts, retryAt: '2026-09-21T00:00:02.000Z' } + expect(projection.events).toEqual([{ type: 'run.progress', payload: { phase: 'model_requesting', toolName: null, retry } }]) + const wire = JSON.parse(JSON.stringify(toPublicRunEvent({ ...projection.events[0]!, createdAt: new Date(now).toISOString(), runId: 'retry-run', sequence: 1 }))) + expect(publicRunEventSchema.parse(wire).payload).toEqual(projection.events[0]!.payload) + expect(JSON.stringify(wire)).not.toContain('private upstream') + projectPiEvent({ type: 'agent_start' }, state) + expect(projectPiEvent({ type: 'turn_start' }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'model_requesting', toolName: null, retry: { ...retry, retryAt: null } } }]) + expect(projectPiEvent({ type: 'auto_retry_end', success: true, attempt: 1 }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'model_requesting', toolName: null } }]) + projectPiEvent({ type: 'auto_retry_start', attempt: 2, maxAttempts, delayMs: 4000, errorMessage: 'private' }, state, now) + expect(projectPiEvent({ type: 'agent_settled' }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'idle', toolName: null } }]) + }) + + it.each(['thinking_delta', 'text_delta', 'toolcall_delta'] as const)('restores live activity on the first nonempty %s before the retried response finishes', (type) => { + const state = createPiEventProjectionState() + const partial: AssistantMessage = { + api: 'openai-completions', + content: [], + model: 'retry-fixture', + provider: 'retry-fixture', + role: 'assistant', + stopReason: 'pending', + timestamp: 1, + usage: emptyUsage(), + } + projectPiEvent({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000, errorMessage: '503 transient' }, state) + projectPiEvent({ type: 'agent_start' }, state) + projectPiEvent({ type: 'turn_start' }, state) + projectPiEvent({ type: 'message_start', message: partial }, state) + const update = (delta: string) => projectPiEvent({ + type: 'message_update', + message: partial, + assistantMessageEvent: { type, contentIndex: 0, delta, partial }, + }, state) + update('') + expect(state.progress?.retry).toEqual({ attempt: 1, maxAttempts: 3, retryAt: null }) + const recovered = update('Recovered content') + expect(recovered.events.at(-1)).toEqual({ + type: 'run.progress', + payload: { phase: state.progress!.phase, toolName: null }, + }) + expect(state.progress?.retry).toBeUndefined() + expect(update('More content').events.some(event => event.type === 'run.progress')).toBe(false) + projectPiEvent({ type: 'auto_retry_start', attempt: 2, maxAttempts: 3, delayMs: 4000, errorMessage: '503 transient' }, state) + expect(state.progress?.retry).toMatchObject({ attempt: 2, maxAttempts: 3 }) + }) + + it('projects summary retries and clears the progress after exhaustion or cancellation', () => { + const state = createPiEventProjectionState() + projectPiEvent({ type: 'summarization_retry_scheduled', attempt: 2, maxAttempts: 3, delayMs: 4000, errorMessage: 'private' }, state) + projectPiEvent({ type: 'summarization_retry_attempt_start', source: 'compaction', reason: 'overflow' }, state) + expect(state.progress?.retry).toEqual({ attempt: 2, maxAttempts: 3, retryAt: null }) + projectPiEvent({ type: 'summarization_retry_finished' }, state) + expect(state.progress?.retry).toBeUndefined() + projectPiEvent({ type: 'auto_retry_start', attempt: 1, maxAttempts: 1, delayMs: 2000, errorMessage: 'private' }, state) + projectPiEvent({ type: 'auto_retry_end', attempt: 1, success: false, finalError: 'private' }, state) + expect(state.progress?.retry).toBeUndefined() + }) +}) + describe('projectPiEvent compaction', () => { it('projects manual and automatic compaction without exposing Pi session details', () => { const state = createPiEventProjectionState() diff --git a/apps/buddy/service/src/agent/events/projectPiEvent.ts b/apps/buddy/service/src/agent/events/projectPiEvent.ts index 720bdabd..5cf25116 100644 --- a/apps/buddy/service/src/agent/events/projectPiEvent.ts +++ b/apps/buddy/service/src/agent/events/projectPiEvent.ts @@ -7,7 +7,7 @@ import type { import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent' import type { BuddyAssistantTextPhase } from '../../../../shared/runs/assistantTextPhase' import type { BuddyToolPresentation } from '../../../../shared/runs/runEventPresentation' -import type { BuddyRunProgress } from '../../../../shared/runs/runProgress' +import type { BuddyRunProgress, BuddyRunRetry } from '../../../../shared/runs/runProgress' import { randomUUID } from 'node:crypto' import { MAX_BUDDY_MESSAGE_TEXT_LENGTH } from '../../../../shared/conversation/buddyMessageContent' @@ -112,15 +112,26 @@ export function createPiEventProjectionState( export function projectPiEvent( event: AgentSessionEvent, state: PiEventProjectionState, + now = Date.now(), ): PiEventProjection { switch (event.type) { case 'agent_start': return progressProjection(state, 'preparing') case 'agent_settled': - return progressProjection(state, 'idle') + return progressProjection(state, 'idle', null, null) case 'turn_start': + case 'summarization_retry_attempt_start': + return progressProjection(state, 'model_requesting', null, state.progress?.retry ? { ...state.progress.retry, retryAt: null } : null) case 'auto_retry_start': - return progressProjection(state, 'model_requesting') + case 'summarization_retry_scheduled': + return progressProjection(state, 'model_requesting', null, { + attempt: event.attempt, + maxAttempts: event.maxAttempts === Number.POSITIVE_INFINITY ? 'unlimited' : event.maxAttempts, + retryAt: new Date(now + event.delayMs).toISOString(), + }) + case 'auto_retry_end': + case 'summarization_retry_finished': + return progressProjection(state, state.progress?.phase ?? 'model_requesting', state.progress?.toolName ?? null, null) case 'compaction_start': return { events: [{ @@ -132,8 +143,21 @@ export function projectPiEvent( return projectCompactionEnd(event) case 'message_start': return projectMessageStart(event.message, state) - case 'message_update': - return projectMessageUpdate(event.assistantMessageEvent, state) + case 'message_update': { + const update = event.assistantMessageEvent + const projected = projectMessageUpdate(update, state) + if ( + !state.progress?.retry + || (update.type !== 'thinking_delta' && update.type !== 'text_delta' && update.type !== 'toolcall_delta') + || !update.delta + ) { + return projected + } + return { + ...projected, + events: [...projected.events, ...progressProjection(state, state.progress.phase, null, null).events], + } + } case 'message_end': return projectMessageEnd(event.message, state) case 'tool_execution_start': { @@ -382,14 +406,19 @@ function progressProjection( state: PiEventProjectionState, phase: BuddyRunProgress['phase'], toolName: string | null = null, + retry: BuddyRunRetry | null = state.progress?.retry ?? null, ): PiEventProjection { const progress: BuddyRunProgress = { phase, toolName: toolName?.slice(0, 256) || null, + ...(retry ? { retry } : {}), } if ( state.progress?.phase === progress.phase && state.progress.toolName === progress.toolName + && state.progress.retry?.attempt === progress.retry?.attempt + && state.progress.retry?.maxAttempts === progress.retry?.maxAttempts + && state.progress.retry?.retryAt === progress.retry?.retryAt ) { return { events: [] } } diff --git a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts index cf2d096d..6e04cec2 100644 --- a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts +++ b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts @@ -12,6 +12,7 @@ import { DefaultResourceLoader, SettingsManager as PiSettingsManager, } from '@earendil-works/pi-coding-agent' +import { DEFAULT_MODEL_RETRY_LIMIT } from '../../../../shared/runtime/runtimePreferences' import { SHELL_SANDBOX_EXTENSION } from '../../sandbox/shellCapability' import { getPiShellToolName, PI_BUILTIN_TOOL_NAME_SET } from '../extensions/piBuiltinTools' import { createReadFileExtension, READ_FILE_EXTENSION } from '../extensions/readFileExtension' @@ -35,6 +36,13 @@ export interface CreateBuddyResourceLoaderOptions { export function createBuddySettingsManager(): SettingsManager { return PiSettingsManager.inMemory({ cacheWarming: 'off', + retry: { + enabled: true, + maxRetries: DEFAULT_MODEL_RETRY_LIMIT, + baseDelayMs: 2_000, + maxAgentDelayMs: 60_000, + provider: { maxRetries: 0 }, + }, enableAnalytics: false, enableInstallTelemetry: false, extensions: [], diff --git a/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts index e1eee9fc..f47eccbd 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts @@ -108,7 +108,7 @@ describe('buddy cache warming', () => { const unsubscribe = channel.subscribe() let task: Promise | undefined try { - reusable.applyPreferences({ cacheWarming: 'streaming' }) + reusable.applyPreferences({ cacheWarming: 'streaming', modelRetryLimit: 3 }) expect(created.session.settingsManager.getCacheWarmingMode()).toBe('off') expect(reusable.getCacheWarmingStatus?.()).toBe(stop === 'unsupported' ? 'unsupported' : 'idle') const release = await reusable.activateTurn({ @@ -155,7 +155,7 @@ describe('buddy cache warming', () => { expect(warmRequests).toBe(1) expect(reusable.getCacheWarmingStatus?.()).toBe('refreshing') if (stop === 'inflight-disable') - reusable.applyPreferences({ cacheWarming: 'off' }) + reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 3 }) if (stop === 'inflight-abort') await reusable.abort() if (stop === 'inflight-shutdown') @@ -186,7 +186,7 @@ describe('buddy cache warming', () => { expect(await usage.record({ createdAt: records[0]!.createdAt, model: model.id, provider: model.provider, purpose: 'cache_warm', runId: 'run-1', sourceEntryId: entry.id, usage: responseUsage(true) })).toBeNull() expect(repository.listForRun('run-1').filter(record => record.purpose === 'cache_warm')).toHaveLength(1) if (stop === 'disable') - reusable.applyPreferences({ cacheWarming: 'off' }) + reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 3 }) if (stop === 'abort') await reusable.abort() if (stop === 'model') diff --git a/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts new file mode 100644 index 00000000..efe70202 --- /dev/null +++ b/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts @@ -0,0 +1,156 @@ +import type { Api, AssistantMessage, Model } from '@earendil-works/pi-ai' +import type { ModelRetryLimit } from '../../../../../shared/runtime/runtimePreferences' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createAssistantMessageEventStream, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { ModelRuntime } from '@earendil-works/pi-coding-agent' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createReusableBuddySession } from '../createReusableBuddySession' +import { createIsolatedBuddySession } from './isolatedBuddySession' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) + await cleanup() +}) + +describe('model retry policy', () => { + it.each([0, 2, 'unlimited'] as const)('executes the %s budget without duplicating the user input', async (limit) => { + let requests = 0 + const fixture = await createFixture(() => ++requests <= 5 ? '503 service unavailable' : undefined) + const release = await fixture.activate(limit) + await fixture.reusable.prompt('Retry the model request') + expect(requests).toBe(limit === 'unlimited' ? 6 : limit + 1) + expect(fixture.session.messages.at(-1)).toMatchObject({ stopReason: limit === 'unlimited' ? 'stop' : 'error' }) + expect(fixture.session.sessionManager.getEntries().filter(entry => entry.type === 'message' && entry.message.role === 'user')).toHaveLength(1) + release() + }) + + it.each(['401 invalid api key', '429 insufficient_quota', '403 forbidden'])('does not retry %s even with an unlimited budget', async (failure) => { + let requests = 0 + const fixture = await createFixture(() => { + requests++ + return failure + }) + const release = await fixture.activate('unlimited') + await fixture.reusable.prompt('Do not repeat a permanent failure') + expect(requests).toBe(1) + expect(fixture.session.messages.at(-1)).toMatchObject({ stopReason: 'error' }) + release() + }) + + it('cancels unlimited backoff without sending another request', async () => { + let requests = 0 + const fixture = await createFixture(() => { + requests++ + return '429 rate limit' + }) + const release = await fixture.activate('unlimited') + const waiting = Promise.withResolvers() + fixture.session.subscribe((event) => { + if (event.type === 'auto_retry_start') + waiting.resolve() + }) + fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 60_000 } }) + const prompt = fixture.reusable.prompt('Cancel this retry') + await waiting.promise + await fixture.reusable.abort() + await prompt + expect(requests).toBe(1) + expect(fixture.session.isRetrying).toBe(false) + release() + }) + + it('freezes the running budget through cache warming changes and applies the new limit on reuse', async () => { + let requests = 0 + const fixture = await createFixture(() => { + requests++ + return '503 service unavailable' + }) + let release = await fixture.activate(2) + fixture.reusable.applyPreferences({ cacheWarming: 'streaming', modelRetryLimit: 0 }) + expect(fixture.session.settingsManager.getRetrySettings()).toMatchObject({ enabled: true, maxRetries: 2 }) + fixture.reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 0 }) + fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 1 } }) + await fixture.reusable.prompt('First run') + expect(requests).toBe(3) + release() + release = await fixture.activate() + expect(fixture.session.settingsManager.getRetrySettings()).toMatchObject({ enabled: false, maxRetries: 0 }) + await fixture.reusable.prompt('Second run') + expect(requests).toBe(4) + release() + }) +}) + +async function createFixture(failure: () => string | undefined) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'buddy-model-retry-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false }) + const model = runtime.getModels().find(model => model.provider === 'anthropic')! + await runtime.setRuntimeApiKey(model.provider, 'offline-fixture') + vi.spyOn(runtime, 'streamSimple').mockImplementation(target => response(target, failure())) + const created = await createIsolatedBuddySession({ + agentDir: join(root, 'agent'), + canonicalRoot: root, + cwd: root, + conversationsDirectory: join(root, 'conversations'), + conversationId: 'retry-conversation', + branchId: 'retry-branch', + approvalPolicy: 'policy', + executionProfile: 'read_only', + model, + modelRuntime: runtime, + thinkingLevel: 'off', + resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, + inProcessExtensions: [], + }) + cleanups.push(() => created.shutdown('quit')) + const { session } = created + session.settingsManager.setCompactionEnabled(false) + const reusable = createReusableBuddySession({ + session, + shutdown: created.shutdown, + assertModelAccess: async () => model, + runContext: { current: null }, + inputReferences: { pending: null }, + materializeInput: async input => input.prompt, + }) + let run = 0 + async function activate(limit?: ModelRetryLimit) { + if (limit !== undefined) + reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: limit }) + const release = await reusable.activateTurn({ + runId: `retry-run-${++run}`, + provider: model.provider, + model: model.id, + contextWindow: null, + maxTokens: null, + signal: new AbortController().signal, + flushProjectedEvents: async () => {}, + onToolExecutionAuthorized: async () => {}, + onToolExecutionDenied: async () => {}, + }) + session.settingsManager.applyOverrides({ retry: { baseDelayMs: 1 } }) + return release + } + return { session, reusable, activate } +} + +function response(model: Model, failure?: string) { + const message: AssistantMessage = { + api: model.api, + provider: model.provider, + model: model.id, + role: 'assistant', + timestamp: Date.now(), + content: failure ? [] : [{ type: 'text', text: 'Recovered' }], + stopReason: failure ? 'error' : 'stop', + errorMessage: failure, + usage: { input: 0, output: 0, totalTokens: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + } + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.push(failure ? { type: 'error', reason: 'error', error: message } : { type: 'done', reason: 'stop', message })) + return stream +} diff --git a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts index 7daef662..179ca957 100644 --- a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts @@ -57,9 +57,18 @@ export function createReusableBuddySession( ): ReusableBuddySession & { applyPreferences: (preferences: RuntimePreferences) => void } { const { session } = options let preferences = DEFAULT_RUNTIME_PREFERENCES - function applyCacheWarming(mode: RuntimePreferences['cacheWarming']) { + let turnRetryLimit: RuntimePreferences['modelRetryLimit'] | undefined + function applySessionSettings(mode: RuntimePreferences['cacheWarming']) { if (session.settingsManager.getCacheWarmingMode() !== mode) session.setCacheWarmingMode(mode) + if (turnRetryLimit !== undefined) { + session.settingsManager.applyOverrides({ + retry: { + enabled: turnRetryLimit !== 0, + maxRetries: turnRetryLimit === 'unlimited' ? Number.POSITIVE_INFINITY : turnRetryLimit, + }, + }) + } } const convertToLlm = session.agent.convertToLlm const streamFunction = session.agent.streamFunction @@ -206,7 +215,7 @@ export function createReusableBuddySession( }), applyPreferences: (next) => { preferences = next - applyCacheWarming(options.runContext.current && !options.runContext.current.signal.aborted ? preferences.cacheWarming : 'off') + applySessionSettings(options.runContext.current && !options.runContext.current.signal.aborted ? preferences.cacheWarming : 'off') }, getModelUsage: () => session.model ? { providerId: session.model.provider, modelId: session.model.id } : null, getInputContext: () => { @@ -222,7 +231,7 @@ export function createReusableBuddySession( steer: (prepare, skills) => enqueueInput('steer', prepare, skills), followUp: (prepare, skills) => enqueueInput('followUp', prepare, skills), abort: () => { - applyCacheWarming('off') + applySessionSettings('off') pendingInputs.clear() session.agent.clearSteeringQueue() session.agent.clearFollowUpQueue() @@ -235,6 +244,7 @@ export function createReusableBuddySession( ), async activateTurn(input) { input.signal.throwIfAborted() + turnRetryLimit = preferences.modelRetryLimit options.runContext.current = { flushProjectedEvents: input.flushProjectedEvents, onToolExecutionAuthorized: input.onToolExecutionAuthorized, @@ -250,18 +260,21 @@ export function createReusableBuddySession( await options.tree?.begin(session, input.runId) await applyModelSelection(session, options.assertModelAccess, input) input.signal.throwIfAborted() - applyCacheWarming(preferences.cacheWarming) + applySessionSettings(preferences.cacheWarming) }, ) } catch (error) { + turnRetryLimit = undefined options.runContext.current = null throw error } return () => { - applyCacheWarming('off') - if (options.runContext.current?.runId === input.runId) + applySessionSettings('off') + if (options.runContext.current?.runId === input.runId) { + turnRetryLimit = undefined options.runContext.current = null + } } }, shutdown: options.shutdown, diff --git a/apps/buddy/shared/runs/publicRunEvent.ts b/apps/buddy/shared/runs/publicRunEvent.ts index 721bb8d5..ab884cb0 100644 --- a/apps/buddy/shared/runs/publicRunEvent.ts +++ b/apps/buddy/shared/runs/publicRunEvent.ts @@ -182,6 +182,7 @@ function publicRunProgress(source: Record): Record + export const buddyRunProgressSchema = z.object({ phase: z.enum([ 'idle', @@ -10,6 +18,7 @@ export const buddyRunProgressSchema = z.object({ 'tool_executing', ]), toolName: z.string().min(1).max(256).nullable(), + retry: buddyRunRetrySchema.optional(), }).strict() export type BuddyRunProgress = z.infer diff --git a/apps/buddy/shared/runtime/runtimePreferences.ts b/apps/buddy/shared/runtime/runtimePreferences.ts index c3094031..ef4d0f8d 100644 --- a/apps/buddy/shared/runtime/runtimePreferences.ts +++ b/apps/buddy/shared/runtime/runtimePreferences.ts @@ -1,13 +1,20 @@ import { z } from 'zod' +export const DEFAULT_MODEL_RETRY_LIMIT = 3 + +export const modelRetryLimitSchema = z.union([z.number().int().nonnegative(), z.literal('unlimited')]) +export type ModelRetryLimit = z.infer + export const runtimePreferencesSchema = z.object({ cacheWarming: z.enum(['off', 'streaming']), + modelRetryLimit: modelRetryLimitSchema, }).strict() export type RuntimePreferences = z.infer export const DEFAULT_RUNTIME_PREFERENCES: RuntimePreferences = { cacheWarming: 'off', + modelRetryLimit: DEFAULT_MODEL_RETRY_LIMIT, } export const runtimePreferencesRpc = { diff --git a/apps/buddy/src/i18n/locales/en-US/chat.ts b/apps/buddy/src/i18n/locales/en-US/chat.ts index ce0cd140..87fdf464 100644 --- a/apps/buddy/src/i18n/locales/en-US/chat.ts +++ b/apps/buddy/src/i18n/locales/en-US/chat.ts @@ -498,6 +498,11 @@ export default { 'desktop.chat.messageInterruptedTruncated': 'The response was interrupted by a local runtime restart. Only part of the generated content was kept.', 'desktop.chat.recoveryAttachmentsMissing': 'Some historical attachments ({count}) were missing while restoring context. The remaining content was kept.', 'desktop.chat.providerRequired': 'Provider connection required', + 'desktop.chat.retryWaiting': 'Retrying in {seconds}s', + 'desktop.chat.retryRequesting': 'Retrying', + 'desktop.chat.retryAttempt': 'Retry {attempt}', + 'desktop.chat.retryUnlimited': 'Unlimited retries', + 'desktop.chat.retryElapsed': 'Elapsed {duration}', 'desktop.chat.progressModelRequesting': 'Waiting for model', 'desktop.chat.progressModelStreaming': 'Generating response', 'desktop.chat.progressPreparing': 'Preparing', diff --git a/apps/buddy/src/i18n/locales/en-US/settings.ts b/apps/buddy/src/i18n/locales/en-US/settings.ts index 16d547c0..a749b5b3 100644 --- a/apps/buddy/src/i18n/locales/en-US/settings.ts +++ b/apps/buddy/src/i18n/locales/en-US/settings.ts @@ -3,6 +3,15 @@ import type zhCN from '../zh-CN/settings' export default { 'desktop.settings.category.runtime': 'Runtime', 'desktop.settings.categoryDescription.runtime': 'Manage permissions for new tasks, context, and model request behavior.', + 'desktop.settings.runtime.modelRequests': 'Model requests', + 'desktop.settings.runtime.modelRetry': 'Automatic retries', + 'desktop.settings.runtime.modelRetryLimit': 'Retry limit', + 'desktop.settings.runtime.modelRetryDescription': 'Retry model requests after temporary errors.', + 'desktop.settings.runtime.retryOff': 'Disabled', + 'desktop.settings.runtime.retryLimited': 'Limited', + 'desktop.settings.runtime.retryUnlimited': 'Unlimited', + 'desktop.settings.runtime.retryCountDescription': 'Excludes the initial request. Stop retrying when this limit is reached.', + 'desktop.settings.runtime.retryCountUnit': 'retries', 'desktop.settings.runtime.taskPermissions': 'Task permissions', 'desktop.settings.runtime.defaultPermissionMode': 'New task permissions', 'desktop.settings.runtime.defaultPermissionModeDescription': 'New tasks start with the selected permissions. Existing tasks, including unsent drafts, keep their current settings.', diff --git a/apps/buddy/src/i18n/locales/zh-CN/chat.ts b/apps/buddy/src/i18n/locales/zh-CN/chat.ts index a4de5b61..e65ddd5b 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts @@ -496,6 +496,11 @@ export default { 'desktop.chat.messageInterruptedTruncated': '回答因本地运行时重启而中断,仅保留部分生成内容', 'desktop.chat.recoveryAttachmentsMissing': '恢复上下文时有 {count} 个历史附件已缺失,其余内容已继续使用', 'desktop.chat.providerRequired': '需要连接 Provider', + 'desktop.chat.retryWaiting': '{seconds} 秒后重试', + 'desktop.chat.retryRequesting': '正在重试', + 'desktop.chat.retryAttempt': '重试 {attempt}', + 'desktop.chat.retryUnlimited': '不限次数', + 'desktop.chat.retryElapsed': '已用 {duration}', 'desktop.chat.progressModelRequesting': '等待模型响应', 'desktop.chat.progressModelStreaming': '生成回复中', 'desktop.chat.progressPreparing': '准备中', diff --git a/apps/buddy/src/i18n/locales/zh-CN/settings.ts b/apps/buddy/src/i18n/locales/zh-CN/settings.ts index 3baeea11..7763e61e 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/settings.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/settings.ts @@ -1,6 +1,15 @@ export default { 'desktop.settings.category.runtime': '运行时', 'desktop.settings.categoryDescription.runtime': '管理新任务权限、上下文与模型调用策略。', + 'desktop.settings.runtime.modelRequests': '模型请求', + 'desktop.settings.runtime.modelRetry': '自动重试', + 'desktop.settings.runtime.modelRetryLimit': '重试次数', + 'desktop.settings.runtime.modelRetryDescription': '遇到临时错误时重试模型请求。', + 'desktop.settings.runtime.retryOff': '禁用', + 'desktop.settings.runtime.retryLimited': '次数', + 'desktop.settings.runtime.retryUnlimited': '无限', + 'desktop.settings.runtime.retryCountDescription': '不含首次请求,达到次数后停止重试。', + 'desktop.settings.runtime.retryCountUnit': '次', 'desktop.settings.runtime.taskPermissions': '任务权限', 'desktop.settings.runtime.defaultPermissionMode': '新任务权限', 'desktop.settings.runtime.defaultPermissionModeDescription': '新建任务时使用所选权限;已有任务(包括未发送的草稿)保持当前设置。', diff --git a/apps/buddy/src/modules/extensions/layouts/DesktopExtensionWorkbench.vue b/apps/buddy/src/modules/extensions/layouts/DesktopExtensionWorkbench.vue index 2f9bfa3b..1185cb5e 100644 --- a/apps/buddy/src/modules/extensions/layouts/DesktopExtensionWorkbench.vue +++ b/apps/buddy/src/modules/extensions/layouts/DesktopExtensionWorkbench.vue @@ -1,6 +1,7 @@