diff --git a/docs/data-model.md b/docs/data-model.md index 1b578bb6d8..8bed344aa1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -317,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a session is created, resumed, imported, or reloaded, so configuration changes affect the next session rather than an already-running one. -`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. +`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen). ### Git process limits @@ -353,8 +353,6 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. - Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. Set these to select OpenAI instead of local speech: diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 643bd44691..c44c7f8ffc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2253,7 +2253,7 @@ export const ar: TranslationResources = { automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا", preferredHint: "اختر النموذج الذي يستخدمه Paseo", model: "النموذج", - fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا", + modelHint: "يُستخدم هذا النموذج فقط. إذا فشل، لا يجرّب Paseo نموذجًا آخر", docs: "الوثائق", saveError: "تعذر تحديث إنشاء البيانات الوصفية", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6224b848f1..d752c89372 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2342,7 +2342,7 @@ export const en = { automaticHint: "Paseo picks a fast available model", preferredHint: "Choose the model Paseo uses", model: "Model", - fallbackHint: "If it is unavailable, Paseo falls back to another available model", + modelHint: "Only this model is used. If it fails, Paseo does not try another model", docs: "Docs", saveError: "Unable to update metadata generation", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b2718cd5a4..f5eda9f6b9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2265,7 +2265,7 @@ export const es: TranslationResources = { automaticHint: "Paseo elige un modelo rápido disponible", preferredHint: "Elige el modelo que usa Paseo", model: "Modelo", - fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible", + modelHint: "Solo se usa este modelo. Si falla, Paseo no prueba otro modelo", docs: "Documentación", saveError: "No se pudo actualizar la generación de metadatos", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c750712bdd..7ffeaf8d59 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2265,7 +2265,7 @@ export const fr: TranslationResources = { automaticHint: "Paseo choisit un modèle rapide disponible", preferredHint: "Choisissez le modèle utilisé par Paseo", model: "Modèle", - fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible", + modelHint: "Seul ce modèle est utilisé. S’il échoue, Paseo n’essaie pas d’autre modèle", docs: "Documentation", saveError: "Impossible de mettre à jour la génération de métadonnées", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 89ae1c5151..1ba8dac613 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2238,7 +2238,7 @@ export const ja: TranslationResources = { automaticHint: "Paseo が利用可能な高速モデルを選択します", preferredHint: "Paseo が使用するモデルを選択します", model: "モデル", - fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します", + modelHint: "このモデルのみを使用します。失敗しても、Paseo は別のモデルを試しません", docs: "ドキュメント", saveError: "メタデータ生成を更新できません", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6e96723260..f59d03a8e9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2227,7 +2227,7 @@ export const ko: TranslationResources = { automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다", preferredHint: "Paseo에서 사용할 모델을 선택하세요", model: "모델", - fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다", + modelHint: "이 모델만 사용합니다. 실패해도 Paseo는 다른 모델을 시도하지 않습니다", docs: "문서", saveError: "메타데이터 생성을 업데이트할 수 없습니다", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0e09fd91dc..8a98a32487 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2250,7 +2250,7 @@ export const ptBR: TranslationResources = { automaticHint: "O Paseo escolhe um modelo rápido disponível", preferredHint: "Escolha o modelo que o Paseo usa", model: "Modelo", - fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível", + modelHint: "Somente este modelo é usado. Se ele falhar, o Paseo não tenta outro modelo", docs: "Documentação", saveError: "Não foi possível atualizar a geração de metadados", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 95f9718313..b5e519baad 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2250,7 +2250,8 @@ export const ru: TranslationResources = { automaticHint: "Paseo выбирает быструю доступную модель", preferredHint: "Выберите модель, которую использует Paseo", model: "Модель", - fallbackHint: "Если она недоступна, Paseo использует другую доступную модель", + modelHint: + "Используется только эта модель. Если она не сработает, Paseo не будет пробовать другую модель", docs: "Документация", saveError: "Не удалось обновить настройки генерации метаданных", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index aa7f64b2ef..2d5fbca085 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2194,7 +2194,7 @@ export const zhCN: TranslationResources = { automaticHint: "Paseo 会选择一个可用的快速模型", preferredHint: "选择 Paseo 使用的模型", model: "模型", - fallbackHint: "如果不可用,Paseo 会改用其他可用模型", + modelHint: "只使用此模型。如果失败,Paseo 不会尝试其他模型", docs: "文档", saveError: "无法更新元数据生成设置", }, diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx index be137726ba..c9a04e9b3e 100644 --- a/packages/app/src/screens/settings/metadata-generation-page.tsx +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -139,7 +139,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) { {t("settings.metadataGeneration.model")} - {t("settings.metadataGeneration.fallbackHint")} + {t("settings.metadataGeneration.modelHint")} { expect(manager.checkedProviders).toEqual(["claude", "codex"]); }); + it("logs the provider, model and error of every failed attempt", async () => { + const warnings: Array<{ obj: object; msg?: string }> = []; + const runnerError = new Error("connection refused"); + const manager = createManager([ + { provider: "local-llm", available: true, error: null }, + { provider: "opencode", available: false, error: "not installed" }, + ]); + + await expect( + generateStructuredAgentResponseWithFallback({ + manager, + cwd: "/tmp/project", + prompt: "Return JSON", + schema, + schemaName: "BranchName", + providers: [ + { provider: "local-llm", model: "qwen-local" }, + { provider: "opencode", model: "minimax-m3" }, + ], + logger: { + info: () => undefined, + warn: (obj, msg) => warnings.push({ obj, msg }), + }, + runner: async () => { + throw runnerError; + }, + }), + ).rejects.toBeInstanceOf(StructuredAgentFallbackError); + + expect(warnings).toEqual([ + { + obj: { + err: runnerError, + provider: "local-llm", + model: "qwen-local", + schemaName: "BranchName", + }, + msg: "Structured generation: provider failed", + }, + { + obj: { + provider: "opencode", + model: "minimax-m3", + schemaName: "BranchName", + reason: "not installed", + }, + msg: "Structured generation: skipping unavailable provider", + }, + ]); + }); + it("throws a fallback error when all providers are unavailable or fail", async () => { const manager = createManager([ { provider: "claude", available: false, error: "missing auth" }, diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index a76f82fdaa..79f625b025 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -475,7 +475,7 @@ export async function generateStructuredAgentResponseWithFallback( }); logger?.warn( { err: error, provider: candidate.provider, model: candidate.model, schemaName }, - "Structured generation: provider failed, trying next", + "Structured generation: provider failed", ); } } diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts index d34aff9640..0216fae643 100644 --- a/packages/server/src/server/agent/structured-generation-providers.test.ts +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -18,7 +18,7 @@ class ProviderSnapshots { } describe("resolveStructuredGenerationProviders", () => { - test("tries the configured model before dynamically discovered fallbacks", async () => { + test("tries only the configured models when metadata generation is manual", async () => { const snapshots = new ProviderSnapshots([ { provider: "work-claude", @@ -28,6 +28,12 @@ describe("resolveStructuredGenerationProviders", () => { { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true }, ], }, + { + provider: "opencode", + status: READY, + enabled: true, + models: [{ provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true }], + }, ]); const providers = await resolveStructuredGenerationProviders({ @@ -35,14 +41,18 @@ describe("resolveStructuredGenerationProviders", () => { providerSnapshotManager: snapshots, daemonConfig: { metadataGeneration: { - providers: [{ provider: "mock", model: "ten-second-stream" }], + providers: [ + { provider: "local-llm", model: "qwen-local" }, + { provider: "mock", model: "ten-second-stream" }, + ], }, }, + currentSelection: { provider: "opencode", model: "minimax-m3" }, }); expect(providers).toEqual([ + { provider: "local-llm", model: "qwen-local" }, { provider: "mock", model: "ten-second-stream" }, - { provider: "work-claude", model: "claude-haiku-2026" }, ]); expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); @@ -227,10 +237,7 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([ - { provider: "current-provider", model: "configured-model" }, - { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, - ]); + expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]); expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); }); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts index 2a09ee1844..bf1af4f8fb 100644 --- a/packages/server/src/server/agent/structured-generation-providers.ts +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -40,6 +40,10 @@ export interface ResolveStructuredGenerationProvidersOptions { }; } +/** + * A non-empty configured list means Manual: only those models are tried, in order. + * An empty list means Automatic: built-in defaults, then the current selection. + */ export async function resolveStructuredGenerationProviders( options: ResolveStructuredGenerationProvidersOptions, ): Promise { @@ -53,16 +57,18 @@ export async function resolveStructuredGenerationProviders( const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); const providers: StructuredGenerationProvider[] = []; - for (const configured of configuredProviders) { - const resolvedConfigured = resolveConfiguredCandidate( - configured, - modelEntries, - entriesByProvider, - ); - if (!resolvedConfigured) { - continue; + if (configuredProviders.length > 0) { + for (const configured of configuredProviders) { + const resolvedConfigured = resolveConfiguredCandidate( + configured, + modelEntries, + entriesByProvider, + ); + if (resolvedConfigured) { + providers.push(resolvedConfigured); + } } - providers.push(resolvedConfigured); + return dedupeProviders(providers); } for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f49191e59e..221d3148c8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -973,7 +973,9 @@ export class Session { providerSnapshotManager, readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(), getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd), + logger: this.sessionLogger, }), + logger: this.sessionLogger, }), paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, diff --git a/packages/server/src/server/session/checkout/git-metadata-generator.test.ts b/packages/server/src/server/session/checkout/git-metadata-generator.test.ts index 9c30200402..a09f08f2fd 100644 --- a/packages/server/src/server/session/checkout/git-metadata-generator.test.ts +++ b/packages/server/src/server/session/checkout/git-metadata-generator.test.ts @@ -7,6 +7,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../../../utils/che import type { WorkspaceGitService } from "../../workspace-git-service.js"; import { createGitMetadataGenerator, + type GitMetadataGenerator, type StructuredTextGeneration, type StructuredTextGenerationRequest, } from "./git-metadata-generator.js"; @@ -38,6 +39,18 @@ function createGeneration(handler: (request: StructuredTextGenerationRequest = []; + return { + errors, + logger: { error: (obj: object, msg?: string) => errors.push({ obj, msg }) }, + }; +} + +const MANUAL_MODEL_FAILURE = new StructuredAgentFallbackError([ + { provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" }, +]); + const DIFF_WITH_ONE_FILE: CheckoutDiffResult = { diff: "diff --git a/src/foo.ts b/src/foo.ts\n+added\n", structured: [ @@ -59,7 +72,11 @@ describe("createGitMetadataGenerator", () => { const { generation, generateCalls } = createGeneration(() => ({ message: "Fix the flaky retry test", })); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); const message = await generator.generateCommitMessage("/repo"); @@ -82,7 +99,11 @@ describe("createGitMetadataGenerator", () => { const { generation } = createGeneration(() => { throw new StructuredAgentFallbackError([]); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files"); }); @@ -95,7 +116,11 @@ describe("createGitMetadataGenerator", () => { validationErrors: ["message: required"], }); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files"); }); @@ -105,7 +130,11 @@ describe("createGitMetadataGenerator", () => { const { generation } = createGeneration(() => { throw new Error("network down"); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generateCommitMessage("/repo")).rejects.toThrow("network down"); }); @@ -116,7 +145,11 @@ describe("createGitMetadataGenerator", () => { title: "Add retry with backoff", body: "Retries transient failures up to twice.", })); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); const result = await generator.generatePullRequestText("/repo", "main"); @@ -140,11 +173,60 @@ describe("createGitMetadataGenerator", () => { const { generation } = createGeneration(() => { throw new StructuredAgentFallbackError([]); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generatePullRequestText("/repo")).resolves.toEqual({ title: "Update changes", body: "Automated PR generated by Paseo.", }); }); + + it.each([ + [ + "generateCommitMessage", + "CommitMessage", + (generator: GitMetadataGenerator) => generator.generateCommitMessage("/repo"), + ], + [ + "generatePullRequestText", + "PullRequest", + (generator: GitMetadataGenerator) => generator.generatePullRequestText("/repo", "main"), + ], + ] as const)("%s logs the failed provider, model and error", async (_name, schemaName, run) => { + const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE); + const { generation } = createGeneration(() => { + throw MANUAL_MODEL_FAILURE; + }); + const { logger, errors } = createLogger(); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger, + }); + + await run(generator); + + expect(errors).toEqual([ + { + obj: { + err: MANUAL_MODEL_FAILURE, + cwd: "/repo", + schemaName, + attempts: [ + { + provider: "local-llm", + model: "qwen-local", + available: true, + error: "connection refused", + }, + ], + }, + msg: "Structured git metadata generation failed", + }, + ]); + }); }); diff --git a/packages/server/src/server/session/checkout/git-metadata-generator.ts b/packages/server/src/server/session/checkout/git-metadata-generator.ts index 06f276f133..722309213a 100644 --- a/packages/server/src/server/session/checkout/git-metadata-generator.ts +++ b/packages/server/src/server/session/checkout/git-metadata-generator.ts @@ -3,6 +3,7 @@ import { StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, + type StructuredGenerationLogger, } from "../../agent/agent-response-loop.js"; import type { AgentManager } from "../../agent/agent-manager.js"; import type { ProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js"; @@ -37,7 +38,7 @@ export interface GitMetadataGenerator { * behaviour is unit-testable with a fake. Production wires * createAgentStructuredTextGeneration (resolve providers → run structured * generation); a failed generation throws StructuredAgent*Error, which the - * generator catches and turns into the product fallback text. + * generator logs and turns into the product fallback text. */ export interface StructuredTextGeneration { generate(request: StructuredTextGenerationRequest): Promise; @@ -51,6 +52,10 @@ export interface StructuredTextGenerationRequest { agentTitle: string; } +interface GitMetadataLogger { + error: (obj: object, msg?: string) => void; +} + type GitMetadataDiffSource = Pick; type CheckoutDiffOptions = Parameters[1]; type CheckoutDiff = Awaited>; @@ -90,8 +95,32 @@ interface PromptForDiffInput { export function createGitMetadataGenerator(deps: { workspaceGitService: GitMetadataDiffSource; generation: StructuredTextGeneration; + logger: GitMetadataLogger; }): GitMetadataGenerator { - const { workspaceGitService, generation } = deps; + const { workspaceGitService, generation, logger } = deps; + + async function generateOrFallback( + request: StructuredTextGenerationRequest, + fallback: T, + ): Promise { + try { + return await generation.generate(request); + } catch (error) { + if (!isStructuredGenerationFailure(error)) { + throw error; + } + logger.error( + { + err: error, + cwd: request.cwd, + schemaName: request.schemaName, + attempts: error instanceof StructuredAgentFallbackError ? error.attempts : undefined, + }, + "Structured git metadata generation failed", + ); + return fallback; + } + } async function buildPromptForDiff(input: PromptForDiffInput): Promise { const diff = await workspaceGitService.getCheckoutDiff(input.cwd, input.diffOptions); @@ -123,21 +152,17 @@ export function createGitMetadataGenerator(deps: { styleDefault: "Concise, imperative mood, no trailing period.", jsonFieldsHint: "Return JSON only with a single field 'message'.", }); - try { - const result = await generation.generate({ + const result = await generateOrFallback( + { cwd, prompt, schema: COMMIT_MESSAGE_SCHEMA, schemaName: "CommitMessage", agentTitle: "Commit generator", - }); - return result.message; - } catch (error) { - if (isStructuredGenerationFailure(error)) { - return COMMIT_MESSAGE_FALLBACK; - } - throw error; - } + }, + { message: COMMIT_MESSAGE_FALLBACK }, + ); + return result.message; }, async generatePullRequestText(cwd, baseRef) { @@ -150,20 +175,16 @@ export function createGitMetadataGenerator(deps: { styleDefault: "Clear, descriptive title; body explaining what changed and why.", jsonFieldsHint: "Return JSON only with fields 'title' and 'body'.", }); - try { - return await generation.generate({ + return generateOrFallback( + { cwd, prompt, schema: PULL_REQUEST_SCHEMA, schemaName: "PullRequest", agentTitle: "PR generator", - }); - } catch (error) { - if (isStructuredGenerationFailure(error)) { - return PULL_REQUEST_FALLBACK; - } - throw error; - } + }, + PULL_REQUEST_FALLBACK, + ); }, }; } @@ -180,6 +201,7 @@ export function createAgentStructuredTextGeneration(deps: { getFocusedSelection: ( cwd: string, ) => ResolveStructuredGenerationProvidersOptions["currentSelection"]; + logger: StructuredGenerationLogger; }): StructuredTextGeneration { return { async generate({ cwd, prompt, schema, schemaName, agentTitle }) { @@ -198,6 +220,7 @@ export function createAgentStructuredTextGeneration(deps: { maxRetries: 2, providers, persistSession: false, + logger: deps.logger, agentConfigOverrides: { title: agentTitle, internal: true, @@ -234,7 +257,9 @@ function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A" return "M"; } -function isStructuredGenerationFailure(error: unknown): boolean { +function isStructuredGenerationFailure( + error: unknown, +): error is StructuredAgentResponseError | StructuredAgentFallbackError { return ( error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError ); diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index caf0539880..8eb1625265 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { AgentManager } from "./agent/agent-manager.js"; -import type { StructuredAgentGenerationWithFallbackOptions } from "./agent/agent-response-loop.js"; +import { + StructuredAgentFallbackError, + type StructuredAgentGenerationWithFallbackOptions, +} from "./agent/agent-response-loop.js"; import { attemptFirstAgentBranchAutoName, type AttemptFirstAgentBranchAutoNameResult, @@ -227,6 +230,52 @@ describe("generateBranchNameFromFirstAgentContext", () => { ]); }); + test("tries only the manual model and logs its failure", async () => { + const logger = createLogger(); + const failure = new StructuredAgentFallbackError([ + { provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" }, + ]); + const calls: StructuredAgentGenerationWithFallbackOptions[] = []; + + const result = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "opencode", + status: "ready" as const, + enabled: true, + models: [ + { provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true }, + ], + }, + ]), + }, + daemonConfig: { + metadataGeneration: { providers: [{ provider: "local-llm", model: "qwen-local" }] }, + }, + currentSelection: { provider: "opencode", model: "minimax-m3" }, + firstAgentContext: { prompt: "Fix the login flow" }, + logger, + deps: { + generateStructuredAgentResponseWithFallback: async (options) => { + calls.push(options as StructuredAgentGenerationWithFallbackOptions); + throw failure; + }, + }, + }); + + expect(result).toBeNull(); + expect(calls.map((call) => call.providers)).toEqual([ + [{ provider: "local-llm", model: "qwen-local" }], + ]); + expect(logger.error).toHaveBeenCalledWith( + { err: failure, attempts: failure.attempts }, + "Structured branch name generation failed", + ); + }); + test.each([ ["paseo.json missing", undefined], ["paseo.json exists but invalid JSON", "{ nope"], diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md index 1d23ec661d..8941fa4c9b 100644 --- a/public-docs/metadata-generation.md +++ b/public-docs/metadata-generation.md @@ -21,29 +21,30 @@ A workspace title and its branch name are produced together from the same prompt ## How a model is chosen -You don't have to configure anything — Paseo picks a model automatically. It builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next. +Paseo has two modes. **Automatic** is the default and needs no configuration. **Manual** uses only the models you choose. -The candidate list is assembled in this order: +In Automatic mode, Paseo builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next: -1. **Providers you configured**, in the order you list them (see below). -2. **Built-in defaults**, matched against the models of the providers you have enabled: +1. **Built-in defaults**, matched against the models of the providers you have enabled: 1. a `haiku` model 2. `gpt-5.4-mini` (low reasoning) 3. `minimax-m3` 4. `nemotron-3-super` -3. **The model currently selected** for that agent or draft, as a last resort. +2. **The model currently selected** for that agent or draft, as a last resort. -Each default is a match on the model id or name, so the first enabled provider that ships a matching model wins. Anything that can't be resolved against an enabled provider is skipped. Duplicates are removed, then generation tries the list top to bottom. +Each default is a match on the model id or name, so the first enabled provider that ships a matching model wins. Defaults with no match are skipped. Duplicates are removed, then generation tries the list top to bottom. The default order prefers small, fast, cheap models for these short tasks before falling back to whatever you have selected. -The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected. +In Manual mode, Paseo tries only your configured models, in your order. It never adds the built-in defaults or the selected model, so your prompts and diffs only go to providers you chose. If every configured model fails, generation fails: workspace titles and branch names are not generated, and commits and pull requests get placeholder text. Pick a model that works for these tasks. + +Every failure is written to `$PASEO_HOME/daemon.log` (`~/.paseo/daemon.log` by default) with the provider, the model and the error. Look there when a manual model doesn't produce titles, branch names, commit messages or pull request text. ## Choose a model -Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. +Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, pick the model Paseo uses. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. Switching back to Automatic clears the list. ## Configure a custom fallback order -To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults. +To use more than one model in Manual mode, list them in `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Paseo tries your entries in order and nothing else. An empty list means Automatic. ```json { diff --git a/public-docs/metadata-generation.zh-CN.md b/public-docs/metadata-generation.zh-CN.md index 2b81411bb5..6669ab818c 100644 --- a/public-docs/metadata-generation.zh-CN.md +++ b/public-docs/metadata-generation.zh-CN.md @@ -19,29 +19,30 @@ Paseo 会生成以下几类元数据: ## 如何选择模型 {#how-a-model-is-chosen} -你无需进行任何配置,Paseo 会自动选择模型。它会构建一个有序的候选列表,逐个尝试,直到生成成功,因此慢或不可用的模型会让位给下一个。 +Paseo 有两种模式。**自动** 是默认模式,无需任何配置。**手动** 只使用你选择的模型。 -候选列表按以下顺序组成: +在自动模式下,Paseo 会构建一个有序的候选列表,逐个尝试,直到生成成功,因此慢或不可用的模型会让位给下一个: -1. **你配置的 Provider**,按你列出的顺序(见下文)。 -2. **内置默认值**,与你已启用的 Provider 的模型进行匹配: +1. **内置默认值**,与你已启用的 Provider 的模型进行匹配: 1. 一个 `haiku` 模型 2. `gpt-5.4-mini`(低推理) 3. `minimax-m3` 4. `nemotron-3-super` -3. 最后的备选:该 Agent 或草稿**当前选中的模型**。 +2. 最后的备选:该 Agent 或草稿**当前选中的模型**。 -每个默认值按模型 id 或名称匹配,因此第一个提供匹配模型的已启用 Provider 胜出。无法在已启用 Provider 中解析的条目会被跳过。去除重复项后,生成过程从上到下依次尝试。 +每个默认值按模型 id 或名称匹配,因此第一个提供匹配模型的已启用 Provider 胜出。没有匹配的默认值会被跳过。去除重复项后,生成过程从上到下依次尝试。默认顺序的意图是:对于这些简短任务,优先使用小、快、便宜的模型,最后才退回到你选中的模型。 -默认顺序的意图是:对于这些简短任务,优先使用小、快、便宜的模型,最后才退回到你选中的模型。 +在手动模式下,Paseo 只按你的顺序尝试你配置的模型。它不会加入内置默认值或当前选中的模型,因此你的提示词和 diff 只会发送给你选择的 Provider。如果所有配置的模型都失败,生成就会失败:不会生成工作区标题和分支名,提交和拉取请求会使用占位文本。请选择适合这些任务的模型。 + +每次失败都会写入 `$PASEO_HOME/daemon.log`(默认是 `~/.paseo/daemon.log`),包含 Provider、模型和错误。如果手动模型没有生成标题、分支名、提交信息或拉取请求文本,请查看这里。 ## 选择模型 {#choose-a-model} -打开 **设置 → 主机 → 元数据**,选择 **自动** 或 **手动**。在手动模式下,Paseo 会先尝试你选择的模型,然后退回到其余已配置的模型和内置候选。选择模型会替换 `agents.metadataGeneration.providers` 中的第一项,并保留列表的其余部分。 +打开 **设置 → 主机 → 元数据**,选择 **自动** 或 **手动**。在手动模式下,选择 Paseo 使用的模型。选择模型会替换 `agents.metadataGeneration.providers` 中的第一项,并保留列表的其余部分。切换回自动会清空该列表。 ## 配置自定义的回退顺序 {#configure-a-custom-fallback-order} -如果要配置多个首选模型或控制确切的顺序,请在 `~/.paseo/config.json` 中设置 `agents.metadataGeneration.providers`。你的条目会在内置默认值之前尝试。 +如果要在手动模式下使用多个模型,请在 `~/.paseo/config.json` 的 `agents.metadataGeneration.providers` 中列出它们。Paseo 按顺序尝试你的条目,不会尝试其他模型。空列表表示自动。 ```json {