fix(FN-XXX): unify codex auth and chat fallback

This commit is contained in:
gsxdsm
2026-05-03 23:08:05 -07:00
parent 6666a90b6e
commit a3ce8e1f8e
41 changed files with 2340 additions and 325 deletions

View File

@@ -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");

View File

@@ -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,
});
});
});

View File

@@ -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);

View File

@@ -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.

View 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,
});
};
}

View File

@@ -139,6 +139,8 @@ export {
createResolvedAgentSession,
promptWithAutoRetry,
describeAgentModel,
extractRuntimeHint,
extractRuntimeModel,
type ResolvedSessionOptions,
type ResolvedSessionResult,
} from "./agent-session-helpers.js";

View File

@@ -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)" : ""}`);

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;