diff --git a/docs/data-model.md b/docs/data-model.md index 200413712..78c9a993f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -155,7 +155,10 @@ Single file, validated with `PersistedConfigSchema`. // ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the // current shape on load via `migrateProviderSettings`. Custom provider IDs must declare // `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`. - providers: Record + providers: Record, + metadataGeneration: { + providers: [{ provider, model?, thinkingOptionId? }] + } }, features: { dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, @@ -171,6 +174,8 @@ Single file, validated with `PersistedConfigSchema`. All fields are optional with sensible defaults. +`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. --- diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index f55df4344..eea768e10 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -203,6 +203,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa return { mcp: { injectIntoAgents: false }, providers, + metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, appendSystemPrompt: "", }; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index e6d4ad776..39d511234 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -673,6 +673,7 @@ test("config actions delegate to existing daemon config RPCs", async () => { config: { mcp: { injectIntoAgents: true }, providers: {}, + metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, appendSystemPrompt: "", }, @@ -725,6 +726,7 @@ test("config actions delegate to existing daemon config RPCs", async () => { enabled: false, }, }, + metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, appendSystemPrompt: "", }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6ac4b0eac..95ecf2a09 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -102,6 +102,20 @@ const MutableDaemonProviderConfigSchema = z }) .passthrough(); +const MutableStructuredGenerationProviderSchema = z + .object({ + provider: z.string().min(1), + model: z.string().min(1).optional(), + thinkingOptionId: z.string().min(1).optional(), + }) + .passthrough(); + +const MutableMetadataGenerationConfigSchema = z + .object({ + providers: z.array(MutableStructuredGenerationProviderSchema).default([]), + }) + .passthrough(); + export const MutableDaemonConfigSchema = z .object({ mcp: z @@ -110,6 +124,7 @@ export const MutableDaemonConfigSchema = z }) .passthrough(), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), + metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), autoArchiveAfterMerge: z.boolean().default(false), appendSystemPrompt: z.string().default(""), }) @@ -121,6 +136,7 @@ export const MutableDaemonConfigPatchSchema = z providers: z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .optional(), + metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), autoArchiveAfterMerge: z.boolean().optional(), appendSystemPrompt: z.string().optional(), }) diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 30c90af26..35035b3fc 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -3,14 +3,18 @@ import type { Logger } from "pino"; import type { AgentManager } from "./agent-manager.js"; import { - DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js"; +import { + resolveStructuredGenerationProviders, + type StructuredGenerationDaemonConfig, +} from "./structured-generation-providers.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits"; import { buildMetadataPrompt } from "../../utils/build-metadata-prompt.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; export interface AgentMetadataGeneratorDeps { generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; @@ -21,6 +25,13 @@ export interface AgentMetadataGenerationOptions { agentId: string; cwd: string; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; + currentSelection?: { + provider?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + }; initialPrompt?: string | null; explicitTitle?: string | null; paseoHome?: string; @@ -120,6 +131,14 @@ export async function generateAndApplyAgentMetadata( let result: { title?: string }; try { + const providers = options.providerSnapshotManager + ? await resolveStructuredGenerationProviders({ + cwd: options.cwd, + providerSnapshotManager: options.providerSnapshotManager, + daemonConfig: options.daemonConfig, + currentSelection: options.currentSelection, + }) + : []; result = await generator({ manager: options.agentManager, cwd: options.cwd, @@ -130,7 +149,7 @@ export async function generateAndApplyAgentMetadata( schema, schemaName: "AgentMetadata", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, logger: options.logger, agentConfigOverrides: { diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 66b46040d..ac72af9c4 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -3,7 +3,6 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv"; import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; import type { AgentManager } from "./agent-manager.js"; -import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; export interface StructuredGenerationLogger { info: (obj: object, msg?: string) => void; @@ -99,12 +98,8 @@ export interface StructuredAgentGenerationWithFallbackOptions { runner?: (options: StructuredAgentGenerationOptions) => Promise; } -export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [ - { provider: "claude", model: "haiku" }, - { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, - { provider: "opencode", model: "opencode/minimax-m2.5-free" }, - { provider: "opencode", model: "opencode/nemotron-3-super-free" }, -] as const; +// Re-export from the legacy module path so existing server consumers keep working. +export { DEFAULT_STRUCTURED_GENERATION_PROVIDERS } from "./structured-generation-providers.js"; interface SchemaValidator { jsonSchema: JsonSchema; @@ -350,13 +345,7 @@ export async function generateStructuredAgentResponse( ): Promise { const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } = options; - const modeId = - agentConfig.modeId ?? - getAgentProviderDefinition(agentConfig.provider).defaultModeId ?? - undefined; - const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId, { - persistSession, - }); + const agent = await manager.createAgent(agentConfig, agentId, { persistSession }); try { const caller: AgentCaller = async (nextPrompt) => { const result = await manager.runAgent(agent.id, nextPrompt); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 8da367a49..7d20edbbe 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -15,6 +15,7 @@ import type { import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; import type { AgentManager, ManagedAgent } from "../agent-manager.js"; import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js"; +import type { StructuredGenerationDaemonConfig } from "../structured-generation-providers.js"; import type { AgentPromptContentBlock, AgentPromptInput, @@ -51,6 +52,7 @@ interface CreateAgentCommandDependencies { >; terminalManager?: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; + daemonConfig?: StructuredGenerationDaemonConfig | null; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; } @@ -291,6 +293,14 @@ async function sendInitialPrompt( agentId: snapshot.id, cwd: snapshot.cwd, workspaceGitService: dependencies.workspaceGitService, + providerSnapshotManager: dependencies.providerSnapshotManager, + daemonConfig: dependencies.daemonConfig, + currentSelection: { + provider: snapshot.provider, + model: snapshot.runtimeInfo?.model ?? resolved.config.model, + thinkingOptionId: + snapshot.runtimeInfo?.thinkingOptionId ?? resolved.config.thinkingOptionId ?? null, + }, initialPrompt: resolved.metadataInitialPrompt, explicitTitle: resolved.explicitTitle, paseoHome: dependencies.paseoHome, diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 2606291eb..eba9172ca 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -11,6 +11,7 @@ import type { PersistedAgentDescriptor, } from "./agent-sdk-types.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; +import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import { unarchiveAgentState } from "./agent-prompt.js"; import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js"; @@ -62,6 +63,8 @@ export interface ImportProviderSessionInput { agentManager: AgentManager; agentStorage: AgentStorage; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; paseoHome?: string; logger: Logger; deps?: { @@ -180,6 +183,8 @@ export async function importProviderSession( snapshot, agentManager: input.agentManager, workspaceGitService: input.workspaceGitService, + providerSnapshotManager: input.providerSnapshotManager, + daemonConfig: input.daemonConfig, paseoHome: input.paseoHome, logger: input.logger, scheduleAgentMetadataGeneration: @@ -213,6 +218,8 @@ async function applyImportedAgentTitle(input: { snapshot: ManagedAgent; agentManager: AgentManager; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; paseoHome?: string; logger: Logger; scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration; @@ -235,6 +242,16 @@ async function applyImportedAgentTitle(input: { agentId: input.snapshot.id, cwd: input.snapshot.cwd, workspaceGitService: input.workspaceGitService, + providerSnapshotManager: input.providerSnapshotManager, + daemonConfig: input.daemonConfig, + currentSelection: { + provider: input.snapshot.provider, + model: input.snapshot.runtimeInfo?.model ?? input.snapshot.config.model, + thinkingOptionId: + input.snapshot.runtimeInfo?.thinkingOptionId ?? + input.snapshot.config.thinkingOptionId ?? + null, + }, initialPrompt, explicitTitle, paseoHome: input.paseoHome, diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts new file mode 100644 index 000000000..83ae26630 --- /dev/null +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test, vi } from "vitest"; + +import { resolveStructuredGenerationProviders } from "./structured-generation-providers.js"; + +const READY = "ready" as const; +const ERROR = "error" as const; + +describe("resolveStructuredGenerationProviders", () => { + test("prefers configured providers, resolves dynamic defaults, and dedupes duplicates", async () => { + const listProviders = vi.fn(async () => [ + { + provider: "work-claude", + status: READY, + enabled: true, + models: [ + { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true }, + ], + }, + { + provider: "work-codex", + status: READY, + enabled: true, + models: [ + { + provider: "work-codex", + id: "gpt-5.4-mini-2026", + label: "GPT 5.4 Mini", + isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + ], + defaultThinkingOptionId: "medium", + }, + ], + }, + { + provider: "router", + status: READY, + enabled: true, + models: [ + { provider: "router", id: "minimax-m2.5-free", label: "MiniMax M2.5", isDefault: true }, + { provider: "router", id: "nemotron-3-super-free", label: "Nemotron 3 Super" }, + ], + }, + ]); + + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { listProviders }, + daemonConfig: { + metadataGeneration: { + providers: [ + { provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" }, + { provider: "work-claude" }, + ], + }, + }, + currentSelection: { + provider: "focused-provider", + model: "focused-model", + thinkingOptionId: "high", + }, + }); + + expect(providers).toEqual([ + { provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" }, + { provider: "work-claude", model: "claude-haiku-2026" }, + { provider: "work-codex", model: "gpt-5.4-mini-2026", thinkingOptionId: "low" }, + { provider: "router", model: "minimax-m2.5-free" }, + { provider: "router", model: "nemotron-3-super-free" }, + { provider: "focused-provider", model: "focused-model", thinkingOptionId: "high" }, + ]); + expect(listProviders).toHaveBeenCalledWith({ cwd: "/tmp/repo", wait: true }); + }); + + test("falls back to the current selection when defaults do not match", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "current-provider", + status: READY, + enabled: true, + models: [ + { + provider: "current-provider", + id: "selected-model", + label: "Selected Model", + isDefault: true, + }, + ], + }, + ]), + }, + currentSelection: { + provider: "current-provider", + model: "selected-model", + thinkingOptionId: "medium", + }, + }); + + expect(providers).toEqual([ + { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); + }); + + test("resolves a provider-only current selection to that provider's default model", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "focused-provider", + status: READY, + enabled: true, + models: [ + { + provider: "focused-provider", + id: "focused-default", + label: "Focused Default", + isDefault: true, + defaultThinkingOptionId: "balanced", + }, + ], + }, + ]), + }, + currentSelection: { provider: "focused-provider" }, + }); + + expect(providers).toEqual([ + { provider: "focused-provider", model: "focused-default", thinkingOptionId: "balanced" }, + ]); + }); + + test("normalizes nested OpenCode provider entries to the top-level provider and full model id", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "opencode", + status: READY, + enabled: true, + models: [ + { + provider: "opencode", + id: "plexus/small-fast", + label: "Small Fast", + isDefault: true, + metadata: { + providerId: "plexus", + modelId: "small-fast", + }, + }, + ], + }, + ]), + }, + daemonConfig: { + metadataGeneration: { + providers: [{ provider: "plexus", model: "small-fast" }], + }, + }, + }); + + expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]); + }); + + test("keeps explicit candidates when provider snapshots are in error state", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "current-provider", + status: ERROR, + enabled: true, + error: "timed out", + }, + ]), + }, + daemonConfig: { + metadataGeneration: { + providers: [{ provider: "current-provider", model: "configured-model" }], + }, + }, + currentSelection: { + provider: "current-provider", + model: "selected-model", + thinkingOptionId: "medium", + }, + }); + + expect(providers).toEqual([ + { provider: "current-provider", model: "configured-model" }, + { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts new file mode 100644 index 000000000..9cf7ac461 --- /dev/null +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -0,0 +1,298 @@ +import type { + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { StructuredGenerationProvider } from "./agent-response-loop.js"; +import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; + +export interface StructuredGenerationDaemonConfig { + metadataGeneration?: { + providers?: Array<{ + provider: string; + model?: string; + thinkingOptionId?: string; + }>; + }; +} + +export interface StructuredGenerationProviderIdentifier { + modelSubstring: string; + thinkingOptionId?: string; +} + +export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProviderIdentifier[] = + [ + { modelSubstring: "haiku" }, + { modelSubstring: "gpt-5.4-mini", thinkingOptionId: "low" }, + { modelSubstring: "minimax-m2.5" }, + { modelSubstring: "nemotron-3-super" }, + ] as const; + +export interface ResolveStructuredGenerationProvidersOptions { + cwd: string; + providerSnapshotManager: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; + currentSelection?: { + provider?: AgentProvider | null; + model?: string | null; + thinkingOptionId?: string | null; + }; +} + +export async function resolveStructuredGenerationProviders( + options: ResolveStructuredGenerationProvidersOptions, +): Promise { + const providerEntries = await options.providerSnapshotManager.listProviders({ + cwd: options.cwd, + wait: true, + }); + const enabledEntries = providerEntries.filter((entry) => entry.enabled); + const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0); + const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); + const providers: StructuredGenerationProvider[] = []; + + for (const configured of readConfiguredProviders(options.daemonConfig)) { + const resolvedConfigured = resolveConfiguredCandidate( + configured, + modelEntries, + entriesByProvider, + ); + if (!resolvedConfigured) { + continue; + } + providers.push(resolvedConfigured); + } + + for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) { + const resolved = resolveByModelSubstring(modelEntries, identifier); + if (resolved) { + providers.push(resolved); + } + } + + const currentSelection = resolveCurrentSelection( + options.currentSelection, + modelEntries, + entriesByProvider, + ); + if (currentSelection) { + providers.push(currentSelection); + } + + return dedupeProviders(providers); +} + +function resolveCurrentSelection( + selection: ResolveStructuredGenerationProvidersOptions["currentSelection"], + readyEntries: readonly ProviderSnapshotEntry[], + entriesByProvider: ReadonlyMap, +): StructuredGenerationProvider | null { + if (!selection) { + return null; + } + + const provider = selection.provider?.trim(); + if (!provider) { + return null; + } + + const normalized = resolveConfiguredCandidate( + { + provider, + ...(selection.model ? { model: selection.model } : {}), + ...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}), + }, + readyEntries, + entriesByProvider, + ); + if (normalized) { + return normalized; + } + + const explicitModel = selection.model?.trim(); + if (explicitModel) { + return { + provider, + model: explicitModel, + ...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}), + }; + } + + const model = selectDefaultModel(entriesByProvider.get(provider)?.models ?? []); + if (!model) { + return { provider }; + } + + const thinkingOptionId = resolveThinkingOptionId(model, selection.thinkingOptionId); + return { + provider, + model: model.id, + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; +} + +function resolveConfiguredCandidate( + candidate: { provider: string; model?: string; thinkingOptionId?: string }, + readyEntries: readonly ProviderSnapshotEntry[], + entriesByProvider: ReadonlyMap, +): StructuredGenerationProvider | null { + const provider = candidate.provider.trim(); + if (!provider) { + return null; + } + + const topLevelEntry = entriesByProvider.get(provider); + const configuredModel = candidate.model?.trim(); + if (topLevelEntry) { + if (configuredModel) { + return { + provider, + model: configuredModel, + ...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}), + }; + } + + const model = selectDefaultModel(topLevelEntry.models ?? []); + const thinkingOptionId = resolveThinkingOptionId(model, candidate.thinkingOptionId); + return { + provider, + ...(model ? { model: model.id } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + } + + if (!configuredModel) { + return { + provider, + ...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}), + }; + } + + const nestedMatch = resolveNestedProviderModel(provider, configuredModel, readyEntries); + if (!nestedMatch) { + return { + provider, + model: configuredModel, + ...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}), + }; + } + + const thinkingOptionId = resolveThinkingOptionId(nestedMatch.model, candidate.thinkingOptionId); + return { + provider: nestedMatch.provider, + model: nestedMatch.model.id, + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; +} + +function resolveNestedProviderModel( + providerId: string, + modelId: string, + entries: readonly ProviderSnapshotEntry[], +): { provider: AgentProvider; model: AgentModelDefinition } | null { + const normalizedProviderId = providerId.trim().toLowerCase(); + const normalizedModelId = modelId.trim().toLowerCase(); + + for (const entry of entries) { + for (const model of entry.models ?? []) { + const modelProviderId = readModelMetadataString(model, "providerId")?.toLowerCase(); + const nestedModelId = readModelMetadataString(model, "modelId")?.toLowerCase(); + if (modelProviderId !== normalizedProviderId) { + continue; + } + if ( + normalizedModelId === model.id.toLowerCase() || + normalizedModelId === nestedModelId || + model.id.toLowerCase() === `${normalizedProviderId}/${normalizedModelId}` + ) { + return { provider: entry.provider, model }; + } + } + } + + return null; +} + +function resolveByModelSubstring( + entries: readonly ProviderSnapshotEntry[], + identifier: StructuredGenerationProviderIdentifier, +): StructuredGenerationProvider | null { + const needle = identifier.modelSubstring.trim().toLowerCase(); + if (!needle) { + return null; + } + + for (const entry of entries) { + for (const model of entry.models ?? []) { + const haystacks = [model.id, model.label].map((value) => value.toLowerCase()); + if (!haystacks.some((value) => value.includes(needle))) { + continue; + } + const thinkingOptionId = resolveThinkingOptionId(model, identifier.thinkingOptionId); + return { + provider: entry.provider, + model: model.id, + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + } + } + + return null; +} + +function readConfiguredProviders( + daemonConfig: ResolveStructuredGenerationProvidersOptions["daemonConfig"], +): Array<{ provider: string; model?: string; thinkingOptionId?: string }> { + const metadataGeneration = daemonConfig?.metadataGeneration; + if (!metadataGeneration || typeof metadataGeneration !== "object") { + return []; + } + const providers = "providers" in metadataGeneration ? metadataGeneration.providers : undefined; + return Array.isArray(providers) ? providers : []; +} + +function selectDefaultModel(models: readonly AgentModelDefinition[]): AgentModelDefinition | null { + return models.find((model) => model.isDefault) ?? models[0] ?? null; +} + +function resolveThinkingOptionId( + model: AgentModelDefinition | null | undefined, + preferredThinkingOptionId: string | null | undefined, +): string | undefined { + if (!model) { + return undefined; + } + if ( + preferredThinkingOptionId && + model.thinkingOptions?.some((option) => option.id === preferredThinkingOptionId) + ) { + return preferredThinkingOptionId; + } + return model.defaultThinkingOptionId; +} + +function dedupeProviders( + providers: readonly StructuredGenerationProvider[], +): StructuredGenerationProvider[] { + const seen = new Set(); + const deduped: StructuredGenerationProvider[] = []; + + for (const provider of providers) { + const key = [provider.provider, provider.model ?? "", provider.thinkingOptionId ?? ""].join( + "\0", + ); + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(provider); + } + + return deduped; +} + +function readModelMetadataString(model: AgentModelDefinition, key: string): string | undefined { + const value = model.metadata?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2e0205f00..68cd1174b 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -258,6 +258,13 @@ export interface PaseoDaemonConfig { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + metadataGeneration?: { + providers?: Array<{ + provider: string; + model?: string; + thinkingOptionId?: string; + }>; + }; providerOverrides?: Record; log?: PersistedConfig["log"]; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; @@ -297,6 +304,9 @@ export async function createPaseoDaemon( }, ]), ), + metadataGeneration: { + providers: config.metadataGeneration?.providers ?? [], + }, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, appendSystemPrompt: config.appendSystemPrompt ?? "", }, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 2a76bdf00..216652cae 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -345,6 +345,7 @@ export function loadConfig( voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, agentProviderSettings: extractAgentProviderSettings(providerOverrides), + metadataGeneration: persisted.agents?.metadataGeneration, providerOverrides, log: resolveLogConfigFromEnv(env, persisted), }; diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 8955b85e5..34f1f4ff2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -68,19 +68,26 @@ describe("DaemonConfigStore", () => { tempDirs.push(paseoHome); const initial = loadPersistedConfig(paseoHome); - initial.agents = { - providers: { - gemini: { - extends: "acp", - label: "Gemini", - command: ["gemini", "--acp"], - }, - }, - }; const configPath = path.join(paseoHome, "config.json"); // Reuse the validated serializer through the store path by seeding the file directly. // This keeps the test focused on the merge behavior. - const seeded = JSON.stringify(initial, null, 2) + "\n"; + const seeded = + JSON.stringify( + { + ...initial, + agents: { + providers: { + gemini: { + extends: "acp", + label: "Gemini", + command: ["gemini", "--acp"], + }, + }, + }, + }, + null, + 2, + ) + "\n"; writeFileSync(configPath, seeded); const store = new DaemonConfigStore( @@ -88,6 +95,9 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", }, undefined, ); @@ -116,6 +126,8 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, appendSystemPrompt: "", }, undefined, @@ -138,6 +150,9 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", }, undefined, ); @@ -175,6 +190,8 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, appendSystemPrompt: "", }, undefined, @@ -188,6 +205,79 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); + test("patch persists metadata generation providers into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + }, + undefined, + ); + + store.patch({ + metadataGeneration: { + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }, + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.metadataGeneration).toEqual({ + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }); + }); + + test("patch persists clearing metadata generation providers into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const configPath = path.join(paseoHome, "config.json"); + writeFileSync( + configPath, + `${JSON.stringify( + { + version: 1, + agents: { + metadataGeneration: { + providers: [{ provider: "claude", model: "haiku" }], + }, + }, + }, + null, + 2, + )}\n`, + ); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + metadataGeneration: { providers: [{ provider: "claude", model: "haiku" }] }, + }, + undefined, + ); + + store.patch({ metadataGeneration: { providers: [] } }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); + }); + test("patch persists custom ACP provider overrides into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -197,6 +287,9 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + metadataGeneration: { providers: [] }, }, undefined, ); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 49b3959fc..215871e1e 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -172,13 +172,33 @@ function mergeMutableConfigIntoPersistedConfig(params: { mutable: MutableDaemonConfig; }): PersistedConfig { const { persisted, mutable } = params; + const metadataGenerationProviders = readMetadataGenerationProviders(mutable); const providerOverrides = applyMutableProviderConfigToOverrides( persisted.agents?.providers as Record | undefined, mutable.providers, ); - const persistedAgents = persisted.agents as - | ({ providers?: Record } & Record) - | undefined; + const persistedAgents = persisted.agents as Record | undefined; + const persistedMetadataGeneration = { + providers: metadataGenerationProviders, + }; + const shouldPersistMetadataGeneration = + metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined; + + let nextAgents = persisted.agents as PersistedConfig["agents"]; + if (providerOverrides && Object.keys(providerOverrides).length > 0) { + nextAgents = { + ...persistedAgents, + providers: providerOverrides, + ...(shouldPersistMetadataGeneration + ? { metadataGeneration: persistedMetadataGeneration } + : {}), + } as PersistedConfig["agents"]; + } else if (shouldPersistMetadataGeneration) { + nextAgents = { + ...persistedAgents, + metadataGeneration: persistedMetadataGeneration, + } as PersistedConfig["agents"]; + } return { ...persisted, @@ -191,12 +211,33 @@ function mergeMutableConfigIntoPersistedConfig(params: { autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, appendSystemPrompt: mutable.appendSystemPrompt, }, - agents: - providerOverrides && Object.keys(providerOverrides).length > 0 - ? { - ...persistedAgents, - providers: providerOverrides, - } - : persisted.agents, + agents: nextAgents, } as PersistedConfig; } + +function readMetadataGenerationProviders( + mutable: MutableDaemonConfig, +): Array<{ provider: string; model?: string; thinkingOptionId?: string }> { + const metadataGeneration = mutable.metadataGeneration; + if (!isRecord(metadataGeneration)) { + return []; + } + const providers = metadataGeneration["providers"]; + if (!Array.isArray(providers)) { + return []; + } + return providers.flatMap((entry) => { + if (!isRecord(entry) || typeof entry["provider"] !== "string") { + return []; + } + return [ + { + provider: entry["provider"], + ...(typeof entry["model"] === "string" ? { model: entry["model"] } : {}), + ...(typeof entry["thinkingOptionId"] === "string" + ? { thinkingOptionId: entry["thinkingOptionId"] } + : {}), + }, + ]; + }); +} diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 998b2fbba..0361ab1da 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -133,6 +133,26 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { expect(result.success).toBe(false); }); + + test("accepts metadata generation provider fallbacks", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + metadataGeneration: { + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }, + }, + }); + + expect(parsed.agents?.metadataGeneration).toEqual({ + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }); + }); }); describe("provider overrides (new format)", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5ed59ba39..21c14390f 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -131,6 +131,20 @@ const FeatureVoiceModeSchema = z }) .strict(); +const StructuredGenerationProviderConfigSchema = z + .object({ + provider: z.string().min(1), + model: z.string().min(1).optional(), + thinkingOptionId: z.string().min(1).optional(), + }) + .strict(); + +const AgentMetadataGenerationSchema = z + .object({ + providers: z.array(StructuredGenerationProviderConfigSchema).optional(), + }) + .strict(); + const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const; function isLegacyProviderEntry(value: unknown): boolean { @@ -237,6 +251,7 @@ export const PersistedConfigSchema = z agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), + metadataGeneration: AgentMetadataGenerationSchema.optional(), }) .strict() .optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c149ac8aa..172aaebd5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -125,11 +125,14 @@ import { type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { - DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; +import { + resolveStructuredGenerationProviders, + type StructuredGenerationDaemonConfig, +} from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, type AgentPersistenceHandle, @@ -1057,6 +1060,37 @@ export class Session { return this.clientActivity; } + private getFocusedAgentSelectionForCwd(cwd: string): + | { + provider?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + } + | undefined { + const focusedAgentId = this.clientActivity?.focusedAgentId; + if (!focusedAgentId) { + return undefined; + } + + const agent = this.agentManager.getAgent(focusedAgentId); + if (!agent || agent.cwd !== cwd) { + return undefined; + } + + return { + provider: agent.provider, + model: agent.runtimeInfo?.model ?? agent.config.model ?? null, + thinkingOptionId: + agent.runtimeInfo?.thinkingOptionId ?? agent.config.thinkingOptionId ?? null, + }; + } + + private readStructuredGenerationDaemonConfig(): StructuredGenerationDaemonConfig { + return { + metadataGeneration: this.daemonConfigStore.get().metadataGeneration, + }; + } + public getRuntimeMetrics(): SessionRuntimeMetrics { const terminalMetrics = this.terminalController.getMetrics(); return { @@ -3066,6 +3100,7 @@ export class Session { paseoHome: this.paseoHome, workspaceGitService: this.workspaceGitService, providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), }, { kind: "session", @@ -3263,6 +3298,8 @@ export class Session { agentManager: this.agentManager, agentStorage: this.agentStorage, workspaceGitService: this.workspaceGitService, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), paseoHome: this.paseoHome, logger: this.sessionLogger, }); @@ -3501,6 +3538,9 @@ export class Session { agentManager: this.agentManager, cwd, workspaceGitService: this.workspaceGitService, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), + currentSelection: this.getFocusedAgentSelectionForCwd(cwd), firstAgentContext, logger: this.sessionLogger, }); @@ -3963,6 +4003,12 @@ export class Session { patch.length > 0 ? patch : "(No diff available)", ].join("\n"), }); + const providers = await resolveStructuredGenerationProviders({ + cwd, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), + currentSelection: this.getFocusedAgentSelectionForCwd(cwd), + }); try { const result = await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, @@ -3971,7 +4017,7 @@ export class Session { schema, schemaName: "CommitMessage", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, agentConfigOverrides: { title: "Commit generator", @@ -4035,6 +4081,12 @@ export class Session { patch.length > 0 ? patch : "(No diff available)", ].join("\n"), }); + const providers = await resolveStructuredGenerationProviders({ + cwd, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), + currentSelection: this.getFocusedAgentSelectionForCwd(cwd), + }); try { return await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, @@ -4043,7 +4095,7 @@ export class Session { schema, schemaName: "PullRequest", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, agentConfigOverrides: { title: "PR generator", 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 b874c6f45..84ac4074c 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -4,6 +4,7 @@ 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 { attemptFirstAgentBranchAutoName, type AttemptFirstAgentBranchAutoNameResult, @@ -32,26 +33,44 @@ afterEach(() => { function createLogger() { return { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; } +function createStructuredGenerator(result: { branch: string }) { + const calls: StructuredAgentGenerationWithFallbackOptions[] = []; + + async function generateStructured( + options: StructuredAgentGenerationWithFallbackOptions, + ): Promise { + calls.push(options as StructuredAgentGenerationWithFallbackOptions); + return result as T; + } + + return { generateStructured, calls }; +} + describe("generateBranchNameFromFirstAgentContext", () => { test("calls the structured generator with first-agent prompt text", async () => { - const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" })); + const structured = createStructuredGenerator({ branch: "fix-login-flow" }); const branch = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, cwd: "/tmp/repo", firstAgentContext: { prompt: "Fix the login flow" }, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); expect(branch).toBe("fix-login-flow"); - expect(generateStructured).toHaveBeenCalledTimes(1); - expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({ + expect(structured.calls).toHaveLength(1); + const firstCall = structured.calls[0]; + if (!firstCall) { + throw new Error("expected structured generation call"); + } + expect(firstCall).toMatchObject({ cwd: "/tmp/repo", schemaName: "BranchName", maxRetries: 2, @@ -60,11 +79,11 @@ describe("generateBranchNameFromFirstAgentContext", () => { internal: true, }, }); - expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow"); + expect(firstCall.prompt).toContain("Fix the login flow"); }); test("uses attachment-only context", async () => { - const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" })); + const structured = createStructuredGenerator({ branch: "review-flaky-checkout" }); const branch = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, @@ -81,11 +100,58 @@ describe("generateBranchNameFromFirstAgentContext", () => { ], }, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); expect(branch).toBe("review-flaky-checkout"); - expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout"); + const firstCall = structured.calls[0]; + if (!firstCall) { + throw new Error("expected structured generation call"); + } + expect(firstCall.prompt).toContain("Review flaky checkout"); + }); + + test("uses the current selection as the final provider fallback", async () => { + const structured = createStructuredGenerator({ branch: "focused-branch" }); + + const branch = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "focused-provider", + status: "ready" as const, + enabled: true, + models: [ + { + provider: "focused-provider", + id: "selected-model", + label: "Selected Model", + isDefault: true, + }, + ], + }, + ]), + }, + currentSelection: { + provider: "focused-provider", + model: "selected-model", + thinkingOptionId: "medium", + }, + firstAgentContext: { prompt: "Fix the login flow" }, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, + }); + + expect(branch).toBe("focused-branch"); + const firstCall = structured.calls[0]; + if (!firstCall) { + throw new Error("expected structured generation call"); + } + expect(firstCall.providers).toEqual([ + { provider: "focused-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); }); test.each([ @@ -152,8 +218,11 @@ describe("generateBranchNameFromFirstAgentContext", () => { }, }, }); - const generateStructured = vi.fn(async () => ({ branch: "Invalid Branch Name" })); - const renameCurrentBranch = vi.fn(async () => ({ currentBranch: "Invalid Branch Name" })); + const structured = createStructuredGenerator({ branch: "Invalid Branch Name" }); + const renameCurrentBranch = vi.fn(async () => ({ + previousBranch: "dazzling-yak", + currentBranch: "Invalid Branch Name", + })); const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({ cwd: worktreeRoot, @@ -167,7 +236,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { }), firstAgentContext, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }), getCurrentBranch: async () => "dazzling-yak", renameCurrentBranch, @@ -186,7 +255,7 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt writeConfig(repoRoot, config); } - const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" })); + const structured = createStructuredGenerator({ branch: "fix-login-flow" }); await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, @@ -196,11 +265,11 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt }), firstAgentContext: { prompt: "Fix the login flow" }, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); return { - prompt: String(generateStructured.mock.calls[0]?.[0].prompt), + prompt: String(structured.calls[0]?.prompt), }; } diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts index 946fab3b1..e4c3a5485 100644 --- a/packages/server/src/server/worktree-branch-name-generator.ts +++ b/packages/server/src/server/worktree-branch-name-generator.ts @@ -2,14 +2,18 @@ import { z } from "zod"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import type { AgentManager } from "./agent/agent-manager.js"; import { - DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; +import { + resolveStructuredGenerationProviders, + type StructuredGenerationDaemonConfig, +} from "./agent/structured-generation-providers.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; interface BranchNameGeneratorLogger { info: (obj: object, msg?: string) => void; @@ -21,6 +25,13 @@ export interface GenerateBranchNameFromFirstAgentContextOptions { agentManager: AgentManager; cwd: string; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; + currentSelection?: { + provider?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + }; firstAgentContext: FirstAgentContext | undefined; logger: BranchNameGeneratorLogger; deps?: { @@ -66,6 +77,14 @@ export async function generateBranchNameFromFirstAgentContext( generateStructuredAgentResponseWithFallback; try { + const providers = options.providerSnapshotManager + ? await resolveStructuredGenerationProviders({ + cwd: options.cwd, + providerSnapshotManager: options.providerSnapshotManager, + daemonConfig: options.daemonConfig, + currentSelection: options.currentSelection, + }) + : []; const result = await generator({ manager: options.agentManager, cwd: options.cwd, @@ -76,7 +95,7 @@ export async function generateBranchNameFromFirstAgentContext( schema: BranchNameSchema, schemaName: "BranchName", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, logger: options.logger, agentConfigOverrides: {