fix(FN-XXX): unify codex auth and chat fallback
This commit is contained in:
@@ -5,6 +5,18 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "utf-8").toString("base64url");
|
||||
}
|
||||
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
return [
|
||||
encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })),
|
||||
encodeBase64Url(JSON.stringify(payload)),
|
||||
"signature",
|
||||
].join(".");
|
||||
}
|
||||
|
||||
describe("createFusionAuthStorage", () => {
|
||||
// HOME override required — createFusionAuthStorage() has no dir parameter
|
||||
const originalHome = process.env.HOME;
|
||||
@@ -95,6 +107,88 @@ describe("createFusionAuthStorage", () => {
|
||||
expect(existsSync(join(homeDir, ".pi", "auth.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reads valid Codex CLI OAuth credentials from ~/.codex/auth.json", async () => {
|
||||
const codexDir = join(homeDir, ".codex");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
const accessToken = createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_codex",
|
||||
},
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(codexDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "codex-refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("openai-codex")).toBe(accessToken);
|
||||
expect(authStorage.get("openai-codex")).toEqual({
|
||||
type: "oauth",
|
||||
access: accessToken,
|
||||
refresh: "codex-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
accountId: "acct_codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => {
|
||||
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||
const codexDir = join(homeDir, ".codex");
|
||||
mkdirSync(fusionAgentDir, { recursive: true });
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
const olderAccessToken = createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 900,
|
||||
});
|
||||
writeFileSync(
|
||||
getFusionAuthPath(homeDir),
|
||||
JSON.stringify({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: olderAccessToken,
|
||||
refresh: "old-refresh-token",
|
||||
expires: Date.now() + 900_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const newerAccessToken = createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_newer",
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
join(codexDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: newerAccessToken,
|
||||
refresh_token: "new-refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
authStorage.reload();
|
||||
|
||||
expect(await authStorage.getApiKey("openai-codex")).toBe(newerAccessToken);
|
||||
expect(authStorage.get("openai-codex")).toEqual({
|
||||
type: "oauth",
|
||||
access: newerAccessToken,
|
||||
refresh: "new-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
accountId: "acct_newer",
|
||||
});
|
||||
});
|
||||
|
||||
describe("models.json API key fallback", () => {
|
||||
it("returns API key from models.json when not in auth.json", async () => {
|
||||
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { createFallbackModelObserver } from "../fallback-model-observer.js";
|
||||
import { notifyFallbackUsed } from "../notifier.js";
|
||||
|
||||
vi.mock("../notifier.js", () => ({
|
||||
notifyFallbackUsed: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("createFallbackModelObserver", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("logs fallback activity, appends agent log, and dispatches a notification", async () => {
|
||||
const store = {
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const observer = createFallbackModelObserver({
|
||||
agent: "Executor Agent",
|
||||
label: "executor",
|
||||
store,
|
||||
taskId: "FN-123",
|
||||
taskTitle: "Fix Codex auth",
|
||||
});
|
||||
|
||||
await observer({
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "prompt-time",
|
||||
timestamp: "2026-05-03T22:00:00.000Z",
|
||||
});
|
||||
|
||||
const expectedMessage =
|
||||
"[fallback] executor switched from openai-codex/gpt-5.3-codex to zai/glm-5.1 (prompt-time)";
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-123", expectedMessage);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-123",
|
||||
expectedMessage,
|
||||
"text",
|
||||
undefined,
|
||||
"Executor Agent",
|
||||
);
|
||||
expect(notifyFallbackUsed).toHaveBeenCalledWith({
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "prompt-time",
|
||||
taskId: "FN-123",
|
||||
taskTitle: "Fix Codex auth",
|
||||
timestamp: "2026-05-03T22:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("swallows logging failures and still dispatches a notification", async () => {
|
||||
const store = {
|
||||
logEntry: vi.fn().mockRejectedValue(new Error("log failed")),
|
||||
appendAgentLog: vi.fn().mockRejectedValue(new Error("append failed")),
|
||||
};
|
||||
|
||||
const observer = createFallbackModelObserver({
|
||||
agent: "Merger Agent",
|
||||
label: "merge verification",
|
||||
store,
|
||||
});
|
||||
|
||||
await expect(observer({
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-456",
|
||||
taskTitle: "Merge verification",
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(notifyFallbackUsed).toHaveBeenCalledWith({
|
||||
primaryModel: "openai-codex/gpt-5.3-codex",
|
||||
fallbackModel: "zai/glm-5.1",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-456",
|
||||
taskTitle: "Merge verification",
|
||||
timestamp: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,19 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
choosePreferredStoredCredential,
|
||||
getCodexCliAuthPath,
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
type StoredAuthCredential,
|
||||
} from "@fusion/core";
|
||||
import { AuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import type { AuthCredential } from "@mariozechner/pi-coding-agent";
|
||||
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
|
||||
|
||||
type StoredCredential = {
|
||||
type?: string;
|
||||
key?: string;
|
||||
access?: string;
|
||||
refresh?: string;
|
||||
expires?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type StoredCredential = StoredAuthCredential;
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
@@ -33,6 +34,13 @@ function getLegacyAuthPaths(home = getHomeDir()): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function getSupplementalAuthPaths(home = getHomeDir()): string[] {
|
||||
return [
|
||||
...getLegacyAuthPaths(home),
|
||||
getCodexCliAuthPath(home),
|
||||
];
|
||||
}
|
||||
|
||||
function getLegacyModelsPaths(home = getHomeDir()): string[] {
|
||||
return [
|
||||
join(home, ".pi", "agent", "models.json"),
|
||||
@@ -49,20 +57,13 @@ export function getModelRegistryModelsPath(home = getHomeDir()): string {
|
||||
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
|
||||
}
|
||||
|
||||
function readLegacyCredentials(authPaths = getLegacyAuthPaths()): Record<string, StoredCredential> {
|
||||
function readSupplementalCredentials(authPaths = getSupplementalAuthPaths()): Record<string, StoredCredential> {
|
||||
const credentials: Record<string, StoredCredential> = {};
|
||||
|
||||
for (const authPath of authPaths) {
|
||||
if (!existsSync(authPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, StoredCredential>;
|
||||
for (const [provider, credential] of Object.entries(parsed)) {
|
||||
credentials[provider] ??= credential;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid legacy auth files and continue with other candidates.
|
||||
const parsed = readStoredCredentialsFromAuthFile(authPath);
|
||||
for (const [provider, credential] of Object.entries(parsed)) {
|
||||
credentials[provider] = choosePreferredStoredCredential(credentials[provider], credential) ?? credential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +137,24 @@ function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> {
|
||||
|
||||
export function createFusionAuthStorage(): AuthStorage {
|
||||
const primary = AuthStorage.create(getFusionAuthPath());
|
||||
let legacyCredentials = readLegacyCredentials();
|
||||
// models.json provider API keys — third fallback after primary auth and legacy auth.json
|
||||
let supplementalCredentials = readSupplementalCredentials();
|
||||
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
|
||||
let modelsJsonApiKeys = readModelsJsonApiKeys();
|
||||
|
||||
const syncSupplementalOauthCredentials = () => {
|
||||
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
|
||||
const current = primary.get(provider) as StoredCredential | undefined;
|
||||
if (!shouldHydrateStoredCredential(current, credential)) {
|
||||
continue;
|
||||
}
|
||||
if (credential.type === "oauth" || credential.type === "api_key") {
|
||||
primary.set(provider, credential as AuthCredential);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncSupplementalOauthCredentials();
|
||||
|
||||
return new Proxy(primary, {
|
||||
// Forward property writes to the target so that methods like
|
||||
// `setFallbackResolver` (called by ModelRegistry) correctly update the
|
||||
@@ -154,29 +169,50 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
if (prop === "reload") {
|
||||
return () => {
|
||||
target.reload();
|
||||
legacyCredentials = readLegacyCredentials();
|
||||
supplementalCredentials = readSupplementalCredentials();
|
||||
syncSupplementalOauthCredentials();
|
||||
modelsJsonApiKeys = readModelsJsonApiKeys();
|
||||
};
|
||||
}
|
||||
|
||||
if (prop === "get") {
|
||||
return (provider: string) => target.get(provider) ?? legacyCredentials[provider];
|
||||
return (provider: string) =>
|
||||
choosePreferredStoredCredential(
|
||||
target.get(provider) as StoredCredential | undefined,
|
||||
supplementalCredentials[provider],
|
||||
);
|
||||
}
|
||||
|
||||
if (prop === "has") {
|
||||
return (provider: string) => target.has(provider) || provider in legacyCredentials || modelsJsonApiKeys.has(provider);
|
||||
return (provider: string) => target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider);
|
||||
}
|
||||
|
||||
if (prop === "hasAuth") {
|
||||
return (provider: string) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]) || modelsJsonApiKeys.has(provider);
|
||||
return (provider: string) => target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider);
|
||||
}
|
||||
|
||||
if (prop === "getAll") {
|
||||
return () => ({ ...legacyCredentials, ...target.getAll() });
|
||||
return () => {
|
||||
const providerIds = new Set([
|
||||
...Object.keys(supplementalCredentials),
|
||||
...Object.keys(target.getAll() as Record<string, StoredCredential>),
|
||||
]);
|
||||
const merged: Record<string, StoredCredential> = {};
|
||||
for (const providerId of providerIds) {
|
||||
const credential = choosePreferredStoredCredential(
|
||||
(target.get(providerId) as StoredCredential | undefined),
|
||||
supplementalCredentials[providerId],
|
||||
);
|
||||
if (credential) {
|
||||
merged[providerId] = credential;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
}
|
||||
|
||||
if (prop === "list") {
|
||||
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list(), ...modelsJsonApiKeys.keys()]));
|
||||
return () => Array.from(new Set([...Object.keys(supplementalCredentials), ...target.list(), ...modelsJsonApiKeys.keys()]));
|
||||
}
|
||||
|
||||
if (prop === "getApiKey") {
|
||||
@@ -185,9 +221,9 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
const primaryKey = await target.getApiKey(provider);
|
||||
if (primaryKey) return primaryKey;
|
||||
|
||||
// 2. Legacy auth.json credentials
|
||||
const legacyKey = resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
|
||||
if (legacyKey) return legacyKey;
|
||||
// 2. Supplemental auth.json credentials (.pi + .codex)
|
||||
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
|
||||
if (supplementalKey) return supplementalKey;
|
||||
|
||||
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
|
||||
return modelsJsonApiKeys.get(provider);
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
|
||||
import { createRunVerificationTool } from "./run-verification-tool.js";
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
|
||||
// Re-export for backward compatibility (tests import from executor.ts)
|
||||
export { summarizeToolArgs } from "./agent-logger.js";
|
||||
@@ -2839,7 +2839,13 @@ export class TaskExecutor {
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: detail.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "executor",
|
||||
label: "executor",
|
||||
store: this.store,
|
||||
taskId: task.id,
|
||||
taskTitle: detail.title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (isResuming) {
|
||||
@@ -4731,36 +4737,36 @@ ${failureFeedback}
|
||||
* Uses git diff against the stored baseCommitSha to determine what changed.
|
||||
* Returns an empty array if no changes or if git commands fail.
|
||||
*/
|
||||
private async resolveDiffBaseRef(worktreePath: string, baseCommitSha?: string): Promise<string | undefined> {
|
||||
if (baseCommitSha) return baseCommitSha;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
|
||||
{ cwd: worktreePath, encoding: "utf-8" },
|
||||
);
|
||||
const ref = stdout.trim();
|
||||
if (ref) return ref;
|
||||
} catch (mergeBaseErr: unknown) {
|
||||
const mergeBaseMsg = mergeBaseErr instanceof Error ? mergeBaseErr.message : String(mergeBaseErr);
|
||||
executorLog.warn(`Failed merge-base lookup for diff base in ${worktreePath}, trying HEAD~1 fallback: ${mergeBaseMsg}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD~1", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return stdout.trim() || undefined;
|
||||
} catch {
|
||||
executorLog.log(`Could not determine base commit for diff in ${worktreePath}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async captureModifiedFiles(worktreePath: string, baseCommitSha?: string): Promise<string[]> {
|
||||
try {
|
||||
// Determine the base reference for diff
|
||||
// If baseCommitSha is stored, use it; otherwise fall back to merge-base with HEAD
|
||||
let baseRef = baseCommitSha;
|
||||
if (!baseRef) {
|
||||
// Try to find merge-base with main/master as fallback
|
||||
try {
|
||||
const { stdout } = await execAsync("git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
baseRef = stdout.trim();
|
||||
} catch (mergeBaseErr: unknown) {
|
||||
const mergeBaseMsg = mergeBaseErr instanceof Error ? mergeBaseErr.message : String(mergeBaseErr);
|
||||
executorLog.warn(`Failed merge-base lookup for diff base in ${worktreePath}, trying HEAD~1 fallback: ${mergeBaseMsg}`);
|
||||
// If merge-base fails, use HEAD~1 as last resort
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD~1", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
baseRef = stdout.trim();
|
||||
} catch {
|
||||
executorLog.log(`Could not determine base commit for diff in ${worktreePath}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseRef = await this.resolveDiffBaseRef(worktreePath, baseCommitSha);
|
||||
if (!baseRef) {
|
||||
return [];
|
||||
}
|
||||
@@ -5050,6 +5056,42 @@ ${failureFeedback}
|
||||
settings: Settings,
|
||||
): Promise<WorkflowStepOutcome> {
|
||||
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
|
||||
|
||||
// Compute the diff scope so the workflow step agent reviews only what THIS
|
||||
// task changed — not unrelated files it might wander into. Without this,
|
||||
// open-ended review prompts (e.g. "verify visual polish") have been
|
||||
// observed to spend the entire timeout budget reading pre-existing files
|
||||
// that match the task description's keywords. See FN-3327 post-mortem.
|
||||
const scopedFiles = await this.captureModifiedFiles(worktreePath, task.baseCommitSha);
|
||||
let diffShortstat: string | undefined;
|
||||
try {
|
||||
const baseRef = await this.resolveDiffBaseRef(worktreePath, task.baseCommitSha);
|
||||
if (baseRef) {
|
||||
const { stdout } = await execAsync(`git diff --shortstat ${baseRef}..HEAD`, {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
diffShortstat = stdout.trim() || undefined;
|
||||
}
|
||||
} catch {
|
||||
// best-effort — fall through with no shortstat
|
||||
}
|
||||
|
||||
const MAX_SCOPE_FILES = 100;
|
||||
const scopeFileBlock = scopedFiles.length === 0
|
||||
? "(no modified files detected for this task — review the worktree directly, but do NOT browse unrelated files)"
|
||||
: scopedFiles.length > MAX_SCOPE_FILES
|
||||
? `${scopedFiles.slice(0, MAX_SCOPE_FILES).map((f) => `- ${f}`).join("\n")}\n- ... (${scopedFiles.length - MAX_SCOPE_FILES} more files truncated)`
|
||||
: scopedFiles.map((f) => `- ${f}`).join("\n");
|
||||
|
||||
const scopeBlock = `Diff Scope (files changed by THIS task vs base):
|
||||
${scopeFileBlock}${diffShortstat ? `\nDiff stat: ${diffShortstat}` : ""}
|
||||
|
||||
CRITICAL SCOPING RULES — read before doing anything else:
|
||||
- Review ONLY the files listed above. Do NOT analyze unmodified files or unrelated parts of the codebase.
|
||||
- If NONE of the files in the diff scope are relevant to your review category (e.g. a UX/design reviewer with no UI/CSS/component files in scope, a security reviewer with no auth/network code in scope, an a11y reviewer with no markup changes), respond IMMEDIATELY with a single short approval line such as "No relevant changes in scope — approved." and STOP. Do not start exploring the codebase.
|
||||
- Your wall-clock budget is short. Spending it browsing unmodified files will cause this step to time out and block merge.`;
|
||||
|
||||
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
|
||||
|
||||
Task Context:
|
||||
@@ -5057,6 +5099,8 @@ Task Context:
|
||||
- Task Description: ${task.description}
|
||||
- Worktree: ${worktreePath}
|
||||
|
||||
${scopeBlock}
|
||||
|
||||
Your role:
|
||||
- Execute this workflow step exactly as scoped.
|
||||
- Prioritize high-impact correctness/risk findings over stylistic nits.
|
||||
|
||||
52
packages/engine/src/fallback-model-observer.ts
Normal file
52
packages/engine/src/fallback-model-observer.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import type { FallbackModelUsedPayload } from "./pi.js";
|
||||
|
||||
type FallbackLogStore = {
|
||||
logEntry?(taskId: string, action: string): Promise<unknown>;
|
||||
appendAgentLog?(
|
||||
taskId: string,
|
||||
text: string,
|
||||
type: "text" | "thinking" | "tool" | "tool_result" | "tool_error",
|
||||
detail?: string,
|
||||
agent?: string,
|
||||
): Promise<unknown>;
|
||||
};
|
||||
|
||||
type FallbackModelObserverOptions = {
|
||||
agent: string;
|
||||
label: string;
|
||||
store?: FallbackLogStore;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
};
|
||||
|
||||
function buildFallbackLogMessage(
|
||||
label: string,
|
||||
payload: FallbackModelUsedPayload,
|
||||
): string {
|
||||
return `[fallback] ${label} switched from ${payload.primaryModel} to ${payload.fallbackModel} (${payload.triggerPoint})`;
|
||||
}
|
||||
|
||||
export function createFallbackModelObserver(options: FallbackModelObserverOptions) {
|
||||
return async (payload: FallbackModelUsedPayload): Promise<void> => {
|
||||
const taskId = options.taskId ?? payload.taskId;
|
||||
const taskTitle = options.taskTitle ?? payload.taskTitle;
|
||||
const message = buildFallbackLogMessage(options.label, payload);
|
||||
|
||||
if (taskId && options.store?.logEntry) {
|
||||
await options.store.logEntry(taskId, message).catch(() => undefined);
|
||||
}
|
||||
if (taskId && options.store?.appendAgentLog) {
|
||||
await options.store.appendAgentLog(taskId, message, "text", undefined, options.agent).catch(() => undefined);
|
||||
}
|
||||
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: payload.primaryModel,
|
||||
fallbackModel: payload.fallbackModel,
|
||||
triggerPoint: payload.triggerPoint,
|
||||
taskId,
|
||||
taskTitle,
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -139,6 +139,8 @@ export {
|
||||
createResolvedAgentSession,
|
||||
promptWithAutoRetry,
|
||||
describeAgentModel,
|
||||
extractRuntimeHint,
|
||||
extractRuntimeModel,
|
||||
type ResolvedSessionOptions,
|
||||
type ResolvedSessionResult,
|
||||
} from "./agent-session-helpers.js";
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { describeModel, promptWithFallback } from "./pi.js";
|
||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
@@ -570,9 +570,20 @@ Do not refactor, rename broadly, or make opportunistic improvements.
|
||||
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
|
||||
? settings.defaultModelIdOverride
|
||||
: settings.defaultModelId,
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
taskTitle: taskForSkillContext?.title,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "merger",
|
||||
label: "merge verification fix agent",
|
||||
store,
|
||||
taskId,
|
||||
taskTitle: taskForSkillContext?.title,
|
||||
}),
|
||||
});
|
||||
|
||||
const runId = mergeRunContext?.runId;
|
||||
@@ -1945,9 +1956,16 @@ You are assisting with a paused \`git pull --rebase\`.
|
||||
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
|
||||
? settings.defaultModelIdOverride
|
||||
: settings.defaultModelId,
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
taskId,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "merger",
|
||||
label: "rebase conflict resolver",
|
||||
store,
|
||||
taskId,
|
||||
}),
|
||||
});
|
||||
|
||||
const prompt = [
|
||||
@@ -4453,9 +4471,20 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride
|
||||
? settings.defaultModelIdOverride
|
||||
: settings.defaultModelId,
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
taskTitle: taskForSkillContext?.title,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "merger",
|
||||
label: "merge agent",
|
||||
store,
|
||||
taskId,
|
||||
taskTitle: taskForSkillContext?.title,
|
||||
}),
|
||||
});
|
||||
|
||||
options.onSession?.(session);
|
||||
@@ -5041,6 +5070,13 @@ If issues are found that need attention, describe them clearly and include concr
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(postMergeSkillContext?.skillSelectionContext ? { skillSelection: postMergeSkillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "merger",
|
||||
label: `post-merge workflow step '${workflowStep.name}'`,
|
||||
store,
|
||||
taskId,
|
||||
}),
|
||||
});
|
||||
|
||||
mergerLog.log(`${taskId}: [post-merge] workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
|
||||
/** Logger for the mission execution loop subsystem. */
|
||||
export const loopLog = createLogger("mission-loop");
|
||||
@@ -360,7 +360,13 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
},
|
||||
taskId: task?.id,
|
||||
taskTitle: task?.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "reviewer",
|
||||
label: "mission validator",
|
||||
store: this.taskStore,
|
||||
taskId: task?.id,
|
||||
taskTitle: task?.title,
|
||||
}),
|
||||
});
|
||||
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { AgentLogger } from "./agent-logger.js";
|
||||
import { reviewerLog } from "./logger.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./agent-tools.js";
|
||||
|
||||
export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
|
||||
@@ -493,7 +493,13 @@ export async function reviewStep(
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: options.taskId,
|
||||
taskTitle: options.taskTitle,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "reviewer",
|
||||
label: "reviewer",
|
||||
store: options.store,
|
||||
taskId: options.taskId,
|
||||
taskTitle: options.taskTitle,
|
||||
}),
|
||||
beforeSpawnSession: async () => {
|
||||
if (!options.store) return;
|
||||
let finalSettings: Settings | undefined;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { AgentSemaphore } from "./concurrency.js";
|
||||
import { StuckTaskDetector } from "./stuck-task-detector.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
import {
|
||||
@@ -1015,7 +1015,13 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
|
||||
taskId: taskDetail.id,
|
||||
taskTitle: taskDetail.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "executor",
|
||||
label: "workflow step agent",
|
||||
store: this.store,
|
||||
taskId: taskDetail.id,
|
||||
taskTitle: taskDetail.title,
|
||||
}),
|
||||
});
|
||||
session = createResult.session;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
import { notifyFallbackUsed } from "./notifier.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { planLog, reviewerLog, formatError } from "./logger.js";
|
||||
import {
|
||||
isUsageLimitError,
|
||||
@@ -1032,7 +1032,13 @@ export class TriageProcessor {
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "triage",
|
||||
label: "triage",
|
||||
store: this.store,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
}),
|
||||
});
|
||||
|
||||
const modelDesc = describeModel(session);
|
||||
@@ -1232,7 +1238,13 @@ export class TriageProcessor {
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "triage",
|
||||
label: "triage",
|
||||
store: this.store,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
}),
|
||||
});
|
||||
|
||||
session = fallbackResult.session;
|
||||
|
||||
Reference in New Issue
Block a user