Prefer configured provider fallbacks for metadata generation (#1219)

* Prefer configured provider fallbacks for metadata generation

* Update client config test for metadata defaults
This commit is contained in:
Matt Cowger
2026-05-29 06:38:22 +00:00
committed by GitHub
parent 14d176a4e2
commit 25d6fc8515
19 changed files with 935 additions and 56 deletions
@@ -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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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: {
@@ -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<T> {
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
}
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<T> {
jsonSchema: JsonSchema;
@@ -350,13 +345,7 @@ export async function generateStructuredAgentResponse<T>(
): Promise<T> {
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);
@@ -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,
@@ -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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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,
@@ -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" },
]);
});
});
@@ -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<ProviderSnapshotManager, "listProviders">;
daemonConfig?: StructuredGenerationDaemonConfig | null;
currentSelection?: {
provider?: AgentProvider | null;
model?: string | null;
thinkingOptionId?: string | null;
};
}
export async function resolveStructuredGenerationProviders(
options: ResolveStructuredGenerationProvidersOptions,
): Promise<StructuredGenerationProvider[]> {
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<AgentProvider, ProviderSnapshotEntry>,
): 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<AgentProvider, ProviderSnapshotEntry>,
): 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<string>();
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;
}
+10
View File
@@ -258,6 +258,13 @@ export interface PaseoDaemonConfig {
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
metadataGeneration?: {
providers?: Array<{
provider: string;
model?: string;
thinkingOptionId?: string;
}>;
};
providerOverrides?: Record<string, ProviderOverride>;
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 ?? "",
},
+1
View File
@@ -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),
};
@@ -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,
);
@@ -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<string, ProviderOverride> | undefined,
mutable.providers,
);
const persistedAgents = persisted.agents as
| ({ providers?: Record<string, ProviderOverride> } & Record<string, unknown>)
| undefined;
const persistedAgents = persisted.agents as Record<string, unknown> | 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"] }
: {}),
},
];
});
}
@@ -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)", () => {
@@ -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(),
+55 -3
View File
@@ -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",
@@ -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<unknown>[] = [];
async function generateStructured<T>(
options: StructuredAgentGenerationWithFallbackOptions<T>,
): Promise<T> {
calls.push(options as StructuredAgentGenerationWithFallbackOptions<unknown>);
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),
};
}
@@ -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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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: {