refactor: unify structured generation pipeline
This commit is contained in:
@@ -4,8 +4,10 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.js";
|
||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||
import {
|
||||
@@ -14,12 +16,8 @@ import {
|
||||
type CheckoutStatusResult,
|
||||
} from "../../utils/checkout-git.js";
|
||||
|
||||
const AUTO_GEN_PROVIDER = "codex" as const;
|
||||
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
|
||||
const AUTO_GEN_REASONING_EFFORT = "low";
|
||||
|
||||
export type AgentMetadataGeneratorDeps = {
|
||||
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
};
|
||||
@@ -148,7 +146,9 @@ export async function generateAndApplyAgentMetadata(
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||
|
||||
@@ -157,21 +157,22 @@ export async function generateAndApplyAgentMetadata(
|
||||
try {
|
||||
result = await generator({
|
||||
manager: options.agentManager,
|
||||
agentConfig: {
|
||||
provider: AUTO_GEN_PROVIDER,
|
||||
model: AUTO_GEN_MODEL,
|
||||
thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
|
||||
cwd: options.cwd,
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(needs),
|
||||
schema,
|
||||
schemaName: "AgentMetadata",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Structured metadata generation failed"
|
||||
|
||||
@@ -2,9 +2,12 @@ import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
type AgentCaller,
|
||||
} from "./agent-response-loop.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
|
||||
function createScriptedCaller(responses: string[]) {
|
||||
const prompts: string[] = [];
|
||||
@@ -142,3 +145,134 @@ describe("getStructuredAgentResponse", () => {
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
const schema = z.object({ summary: z.string() });
|
||||
|
||||
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
|
||||
return {
|
||||
listProviderAvailability: async () => availability,
|
||||
} as unknown as AgentManager;
|
||||
}
|
||||
|
||||
it("uses the first available provider in the waterfall", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
});
|
||||
|
||||
it("skips unavailable providers and uses the next available one", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
|
||||
});
|
||||
|
||||
it("falls back when an available provider fails", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
if (options.agentConfig.provider === "claude") {
|
||||
throw new Error("claude failed");
|
||||
}
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: false, error: "not installed" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
],
|
||||
runner: async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
})
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
|
||||
@@ -21,6 +21,43 @@ export class StructuredAgentResponseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type StructuredGenerationProvider = {
|
||||
provider: AgentProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
};
|
||||
|
||||
export type StructuredGenerationAttempt = {
|
||||
provider: AgentProvider;
|
||||
model: string | null;
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export class StructuredAgentFallbackError extends Error {
|
||||
readonly attempts: StructuredGenerationAttempt[];
|
||||
|
||||
constructor(attempts: StructuredGenerationAttempt[]) {
|
||||
const summary = attempts
|
||||
.map((attempt) => {
|
||||
const modelSuffix = attempt.model ? ` (${attempt.model})` : "";
|
||||
if (!attempt.available) {
|
||||
return `${attempt.provider}${modelSuffix}: unavailable${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||
}
|
||||
return `${attempt.provider}${modelSuffix}: failed${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||
})
|
||||
.join("; ");
|
||||
|
||||
super(
|
||||
summary.length > 0
|
||||
? `Structured generation failed for all providers: ${summary}`
|
||||
: "Structured generation failed for all providers"
|
||||
);
|
||||
this.name = "StructuredAgentFallbackError";
|
||||
this.attempts = attempts;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StructuredAgentResponseOptions<T> {
|
||||
caller: AgentCaller;
|
||||
prompt: string;
|
||||
@@ -39,6 +76,27 @@ export interface StructuredAgentGenerationOptions<T> {
|
||||
schemaName?: string;
|
||||
}
|
||||
|
||||
export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
manager: AgentManager;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T> | JsonSchema;
|
||||
providers: readonly StructuredGenerationProvider[];
|
||||
agentConfigOverrides?: Omit<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
maxRetries?: number;
|
||||
schemaName?: string;
|
||||
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||
}
|
||||
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
] as const;
|
||||
|
||||
interface SchemaValidator<T> {
|
||||
jsonSchema: JsonSchema;
|
||||
validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] };
|
||||
@@ -293,3 +351,82 @@ export async function generateStructuredAgentResponse<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
options: StructuredAgentGenerationWithFallbackOptions<T>
|
||||
): Promise<T> {
|
||||
const {
|
||||
manager,
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
providers,
|
||||
agentConfigOverrides,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
runner,
|
||||
} = options;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
}
|
||||
|
||||
const runStructured =
|
||||
runner ??
|
||||
((input: StructuredAgentGenerationOptions<T>) =>
|
||||
generateStructuredAgentResponse<T>(input));
|
||||
const availability = await manager.listProviderAvailability();
|
||||
const availabilityByProvider = new Map(
|
||||
availability.map((entry) => [entry.provider, entry])
|
||||
);
|
||||
const attempts: StructuredGenerationAttempt[] = [];
|
||||
|
||||
for (const candidate of providers) {
|
||||
const availabilityEntry = availabilityByProvider.get(candidate.provider);
|
||||
if (availabilityEntry && !availabilityEntry.available) {
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model ?? null,
|
||||
available: false,
|
||||
error: availabilityEntry.error ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runStructured({
|
||||
manager,
|
||||
prompt,
|
||||
schema,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
agentConfig: {
|
||||
...agentConfigOverrides,
|
||||
provider: candidate.provider,
|
||||
cwd,
|
||||
...(candidate.model ? { model: candidate.model } : {}),
|
||||
...(candidate.thinkingOptionId
|
||||
? { thinkingOptionId: candidate.thinkingOptionId }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model ?? null,
|
||||
available: true,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new StructuredAgentFallbackError(attempts);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,20 @@ export type {
|
||||
|
||||
// Agent activity curator for CLI logs
|
||||
export { curateAgentActivity } from "./agent/activity-curator.js";
|
||||
export {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
StructuredAgentFallbackError,
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
type AgentCaller,
|
||||
type JsonSchema,
|
||||
type StructuredGenerationAttempt,
|
||||
type StructuredGenerationProvider,
|
||||
type StructuredAgentGenerationOptions,
|
||||
type StructuredAgentGenerationWithFallbackOptions,
|
||||
type StructuredAgentResponseOptions,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
|
||||
// WebSocket message types for CLI streaming
|
||||
export type {
|
||||
|
||||
@@ -81,8 +81,10 @@ import {
|
||||
} from "./agent/timeline-append.js";
|
||||
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentPermissionResponse,
|
||||
@@ -166,12 +168,6 @@ const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
|
||||
|
||||
/**
|
||||
* Default model used for auto-generating commit messages and PR descriptions.
|
||||
* Uses Claude Haiku for speed and cost efficiency.
|
||||
*/
|
||||
const AUTO_GEN_MODEL = "haiku";
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -3042,23 +3038,25 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
try {
|
||||
const result = await generateStructuredAgentResponse({
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: AUTO_GEN_MODEL,
|
||||
cwd,
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return "Update files";
|
||||
}
|
||||
throw error;
|
||||
@@ -3107,22 +3105,24 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
try {
|
||||
return await generateStructuredAgentResponse({
|
||||
return await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: AUTO_GEN_MODEL,
|
||||
cwd,
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return {
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
|
||||
Reference in New Issue
Block a user