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:
+6
-1
@@ -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<providerId, ProviderOverride>
|
||||
providers: Record<providerId, ProviderOverride>,
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -203,6 +203,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers,
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user