feat(FN-3008): notify when model settings fall back to defaults
Merges FN-3008 to add a "fallback-used" notification system: the engine now emits events when AI model fallbacks are triggered, dispatches notifications via ntfy/webhook providers, surfaces a session banner in the dashboard, and exposes a settings toggle to enable or disable these alerts. Fusion-Task-Id: FN-3008
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
buildNtfyClickUrl,
|
||||
isNtfyEventEnabled,
|
||||
resolveNtfyEvents,
|
||||
notifyFallbackUsed,
|
||||
} from "../notifier.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
|
||||
@@ -68,6 +69,7 @@ describe("Ntfy notifier helpers", () => {
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
||||
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
||||
});
|
||||
|
||||
it("checks planning-awaiting-input event enablement", () => {
|
||||
@@ -1123,6 +1125,35 @@ describe("NtfyNotifier", () => {
|
||||
await sharedService.stop();
|
||||
});
|
||||
|
||||
it("dispatches and deduplicates fallback-used notifications", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows notifications for different tasks independently", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"],
|
||||
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
||||
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
||||
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
||||
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
||||
await provider.sendNotification(event as any, { taskId: "FN-1", taskTitle: "T", event: event as any });
|
||||
|
||||
@@ -62,6 +63,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -681,6 +681,93 @@ describe("piLog structured diagnostics", () => {
|
||||
expect(hasModelLog).toBe(true);
|
||||
});
|
||||
|
||||
it("fires fallback hook on session-creation fallback", async () => {
|
||||
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||
const onFallbackModelUsed = vi.fn();
|
||||
createAgentSessionMock.mockReset();
|
||||
createAgentSessionMock
|
||||
.mockRejectedValueOnce(new Error("429 Too Many Requests"))
|
||||
.mockResolvedValueOnce({
|
||||
session: {
|
||||
model: { provider: "test", id: "fallback-model" },
|
||||
prompt: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
setThinkingLevel: vi.fn(),
|
||||
sessionFile: undefined,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await createFnAgent({
|
||||
cwd: "/test/project",
|
||||
systemPrompt: "Test",
|
||||
defaultProvider: "test",
|
||||
defaultModelId: "primary-model",
|
||||
fallbackProvider: "test",
|
||||
fallbackModelId: "fallback-model",
|
||||
taskId: "FN-1",
|
||||
taskTitle: "My Task",
|
||||
onFallbackModelUsed,
|
||||
});
|
||||
|
||||
expect(onFallbackModelUsed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerPoint: "session-creation",
|
||||
primaryModel: "test/test-model",
|
||||
fallbackModel: "test/test-model",
|
||||
taskId: "FN-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fires fallback hook on prompt-time fallback", async () => {
|
||||
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||
const onFallbackModelUsed = vi.fn();
|
||||
|
||||
const primarySession = {
|
||||
model: { provider: "test", id: "primary-model" },
|
||||
prompt: vi.fn().mockRejectedValue(new Error("429 Too Many Requests")),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
setThinkingLevel: vi.fn(),
|
||||
sessionFile: undefined,
|
||||
} as unknown as AgentSession;
|
||||
|
||||
const fallbackSession = {
|
||||
model: { provider: "test", id: "fallback-model" },
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
setThinkingLevel: vi.fn(),
|
||||
sessionFile: undefined,
|
||||
} as unknown as AgentSession;
|
||||
|
||||
createAgentSessionMock.mockReset();
|
||||
createAgentSessionMock
|
||||
.mockResolvedValueOnce({ session: primarySession } as any)
|
||||
.mockResolvedValueOnce({ session: fallbackSession } as any);
|
||||
|
||||
const { session } = await createFnAgent({
|
||||
cwd: "/test/project",
|
||||
systemPrompt: "Test",
|
||||
defaultProvider: "test",
|
||||
defaultModelId: "primary-model",
|
||||
fallbackProvider: "test",
|
||||
fallbackModelId: "fallback-model",
|
||||
taskId: "FN-2",
|
||||
onFallbackModelUsed,
|
||||
});
|
||||
|
||||
await (session as any).promptWithFallback("prompt text");
|
||||
|
||||
expect(onFallbackModelUsed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerPoint: "prompt-time",
|
||||
taskId: "FN-2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs warning on primary model failure and fallback attempt", async () => {
|
||||
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||
createAgentSessionMock.mockReset();
|
||||
|
||||
@@ -137,6 +137,7 @@ describe("WebhookNotificationProvider", () => {
|
||||
["awaiting-user-review", "needs human review before it can proceed"],
|
||||
["planning-awaiting-input", "is awaiting your input during planning"],
|
||||
["gridlock", "Pipeline gridlocked"],
|
||||
["fallback-used", "Fusion recovered by switching from"],
|
||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||
])("message formatting for %s", async (event, expectedPart) => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import type { FallbackModelUsedPayload } from "./pi.js";
|
||||
|
||||
/**
|
||||
* Options for creating an agent session.
|
||||
@@ -69,6 +70,11 @@ export interface AgentRuntimeOptions {
|
||||
* the runtime's internal setup latency rather than unbounded.
|
||||
*/
|
||||
beforeSpawnSession?: () => Promise<void> | void;
|
||||
/** Callback fired when runtime falls back from primary model to fallback model. */
|
||||
onFallbackModelUsed?: (payload: FallbackModelUsedPayload) => Promise<void> | void;
|
||||
/** Optional task context for fallback notifications. */
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,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";
|
||||
|
||||
// Re-export for backward compatibility (tests import from executor.ts)
|
||||
export { summarizeToolArgs } from "./agent-logger.js";
|
||||
@@ -2746,6 +2747,9 @@ export class TaskExecutor {
|
||||
sessionManager,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: detail.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
});
|
||||
|
||||
if (isResuming) {
|
||||
|
||||
@@ -155,6 +155,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 { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
@@ -2336,6 +2337,8 @@ You are assisting with a paused \`git pull --rebase\`.
|
||||
? settings.defaultModelIdOverride
|
||||
: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
taskId,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
});
|
||||
|
||||
const prompt = [
|
||||
|
||||
@@ -23,6 +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";
|
||||
|
||||
/** Logger for the mission execution loop subsystem. */
|
||||
export const loopLog = createLogger("mission-loop");
|
||||
@@ -357,6 +358,9 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
onText: (_delta) => {
|
||||
// Could stream this to a log entry if needed
|
||||
},
|
||||
taskId: task?.id,
|
||||
taskTitle: task?.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
});
|
||||
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };
|
||||
|
||||
|
||||
@@ -228,6 +228,15 @@ export class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
async dispatch(eventType: NotificationEvent, payload: NotificationPayload): Promise<void> {
|
||||
if (!this.notificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupTaskId = payload.taskId ?? "global";
|
||||
this.maybeNotify(dedupTaskId, eventType, payload);
|
||||
}
|
||||
|
||||
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
|
||||
return {
|
||||
taskId: task.id,
|
||||
|
||||
@@ -33,7 +33,8 @@ type SupportedNtfyEvent =
|
||||
| "failed"
|
||||
| "awaiting-approval"
|
||||
| "awaiting-user-review"
|
||||
| "planning-awaiting-input";
|
||||
| "planning-awaiting-input"
|
||||
| "fallback-used";
|
||||
|
||||
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||
"in-review",
|
||||
@@ -42,6 +43,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||
"awaiting-approval",
|
||||
"awaiting-user-review",
|
||||
"planning-awaiting-input",
|
||||
"fallback-used",
|
||||
]);
|
||||
|
||||
export class NtfyNotificationProvider implements NotificationProvider {
|
||||
@@ -92,8 +94,9 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
};
|
||||
}
|
||||
|
||||
const taskId = payload.taskId ?? "unknown-task";
|
||||
const taskLike = {
|
||||
id: payload.taskId,
|
||||
id: taskId,
|
||||
title: payload.taskTitle,
|
||||
description: payload.taskDescription ?? "",
|
||||
} as Pick<Task, "id" | "title" | "description"> as Task;
|
||||
@@ -107,35 +110,40 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
|
||||
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
||||
"in-review": {
|
||||
title: `Task ${payload.taskId} completed`,
|
||||
title: `Task ${taskId} completed`,
|
||||
message: `Task "${identifier}" is ready for review`,
|
||||
priority: "default",
|
||||
},
|
||||
merged: {
|
||||
title: `Task ${payload.taskId} merged`,
|
||||
title: `Task ${taskId} merged`,
|
||||
message: `Task "${identifier}" has been merged to main`,
|
||||
priority: "default",
|
||||
},
|
||||
failed: {
|
||||
title: `Task ${payload.taskId} failed`,
|
||||
title: `Task ${taskId} failed`,
|
||||
message: `Task "${identifier}" has failed and needs attention`,
|
||||
priority: "high",
|
||||
},
|
||||
"awaiting-approval": {
|
||||
title: `Plan needs approval for ${payload.taskId}`,
|
||||
title: `Plan needs approval for ${taskId}`,
|
||||
message: `Task "${identifier}" needs your approval before it can proceed`,
|
||||
priority: "high",
|
||||
},
|
||||
"awaiting-user-review": {
|
||||
title: `User review needed for ${payload.taskId}`,
|
||||
title: `User review needed for ${taskId}`,
|
||||
message: `Task "${identifier}" needs human review before it can proceed`,
|
||||
priority: "high",
|
||||
},
|
||||
"planning-awaiting-input": {
|
||||
title: `Planning input needed for ${payload.taskId}`,
|
||||
title: `Planning input needed for ${taskId}`,
|
||||
message: `Task "${identifier}" is awaiting your input during planning`,
|
||||
priority: "high",
|
||||
},
|
||||
"fallback-used": {
|
||||
title: `Fallback model used${payload.taskId ? ` for ${payload.taskId}` : ""}`,
|
||||
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
|
||||
priority: "high",
|
||||
},
|
||||
};
|
||||
|
||||
const content = contentByEvent[event as SupportedNtfyEvent];
|
||||
|
||||
@@ -133,6 +133,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
return `Task "${identifier}" is awaiting your input during planning`;
|
||||
case "gridlock":
|
||||
return "Pipeline gridlocked";
|
||||
case "fallback-used":
|
||||
return `Fusion recovered by switching from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} (${String(payload.metadata?.triggerPoint ?? "unknown trigger")})`;
|
||||
default:
|
||||
return `Event "${event}" for task ${identifier}`;
|
||||
}
|
||||
@@ -145,7 +147,7 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
|
||||
const description = payload.taskDescription ?? "";
|
||||
const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
|
||||
return `${payload.taskId}: ${snippet}`;
|
||||
return `${payload.taskId ?? "unknown-task"}: ${snippet}`;
|
||||
}
|
||||
|
||||
private formatPayload(payload: NotificationPayload, message: string): Record<string, unknown> {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||
"awaiting-user-review",
|
||||
"planning-awaiting-input",
|
||||
"gridlock",
|
||||
"fallback-used",
|
||||
] as const;
|
||||
|
||||
export interface NtfyNotificationConfigInput {
|
||||
@@ -53,7 +54,7 @@ interface NtfyConfig {
|
||||
|
||||
/** Event types for task notification deduplication */
|
||||
type TaskNotificationEvent = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
|
||||
type AnyNotificationEvent = TaskNotificationEvent | "gridlock";
|
||||
type AnyNotificationEvent = TaskNotificationEvent | "gridlock" | "fallback-used";
|
||||
|
||||
/**
|
||||
* Format a task identifier for notifications.
|
||||
@@ -166,6 +167,35 @@ export async function sendNtfyNotification({
|
||||
* It keeps legacy APIs (getConfig, notifyGridlock) while delegating task event
|
||||
* notifications to the pluggable provider-based notification module.
|
||||
*/
|
||||
let activeNotificationService: NotificationService | undefined;
|
||||
|
||||
export interface FallbackNotificationInput {
|
||||
primaryModel: string;
|
||||
fallbackModel: string;
|
||||
triggerPoint: "session-creation" | "prompt-time";
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export async function notifyFallbackUsed(input: FallbackNotificationInput): Promise<void> {
|
||||
if (!activeNotificationService) {
|
||||
return;
|
||||
}
|
||||
|
||||
await activeNotificationService.dispatch("fallback-used", {
|
||||
taskId: input.taskId,
|
||||
taskTitle: input.taskTitle,
|
||||
event: "fallback-used",
|
||||
timestamp: input.timestamp,
|
||||
metadata: {
|
||||
primaryModel: input.primaryModel,
|
||||
fallbackModel: input.fallbackModel,
|
||||
triggerPoint: input.triggerPoint,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = {
|
||||
enabled: false,
|
||||
@@ -192,6 +222,7 @@ export class NtfyNotifier {
|
||||
projectId: this.projectId,
|
||||
ntfyBaseUrl: options.ntfyBaseUrl,
|
||||
});
|
||||
activeNotificationService = this.notificationService;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
|
||||
@@ -393,6 +393,15 @@ export async function compactSessionContext(
|
||||
}
|
||||
}
|
||||
|
||||
export interface FallbackModelUsedPayload {
|
||||
primaryModel: string;
|
||||
fallbackModel: string;
|
||||
triggerPoint: "session-creation" | "prompt-time";
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface AgentOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
@@ -429,6 +438,11 @@ export interface AgentOptions {
|
||||
/** Last-chance abort hook fired immediately before `createAgentSession`.
|
||||
* See `AgentRuntimeOptions.beforeSpawnSession`. */
|
||||
beforeSpawnSession?: () => Promise<void> | void;
|
||||
/** Callback fired when runtime falls back from primary model to fallback model. */
|
||||
onFallbackModelUsed?: (payload: FallbackModelUsedPayload) => Promise<void> | void;
|
||||
/** Optional task context for fallback notifications. */
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
}
|
||||
|
||||
function resolveConfiguredModel(
|
||||
@@ -1204,6 +1218,20 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
});
|
||||
};
|
||||
|
||||
const emitFallbackUsed = async (triggerPoint: "session-creation" | "prompt-time"): Promise<void> => {
|
||||
if (!options.onFallbackModelUsed || !selectedModel || !fallbackModel) {
|
||||
return;
|
||||
}
|
||||
await options.onFallbackModelUsed({
|
||||
primaryModel: `${selectedModel.provider}/${selectedModel.id}`,
|
||||
fallbackModel: `${fallbackModel.provider}/${fallbackModel.id}`,
|
||||
triggerPoint,
|
||||
taskId: options.taskId,
|
||||
taskTitle: options.taskTitle,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
let sessionResult;
|
||||
let usingFallback = false;
|
||||
try {
|
||||
@@ -1217,6 +1245,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
piLog.warn(`Primary model failed (${err.message}), trying fallback`);
|
||||
usingFallback = true;
|
||||
sessionResult = await createSessionWithModel(fallbackModel);
|
||||
await emitFallbackUsed("session-creation");
|
||||
piLog.log("Fallback session created successfully");
|
||||
}
|
||||
|
||||
@@ -1278,6 +1307,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
}
|
||||
|
||||
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
|
||||
await emitFallbackUsed("prompt-time");
|
||||
const fallbackSession = fallbackSessionResult.session as PromptableSession;
|
||||
installToolResultContentGuard(fallbackSession as unknown as AgentToolHookSession);
|
||||
installMessageContentGuard(
|
||||
|
||||
@@ -17,6 +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 { createMemoryGetTool, createMemorySearchTool } from "./agent-tools.js";
|
||||
|
||||
export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
|
||||
@@ -254,6 +255,8 @@ export interface ReviewOptions {
|
||||
store?: TaskStore;
|
||||
/** Task ID for agent log persistence. Required alongside `store`. */
|
||||
taskId?: string;
|
||||
/** Optional task title for fallback-used notification context. */
|
||||
taskTitle?: string;
|
||||
/** Task with optional assignedAgentId for skill selection. */
|
||||
task?: { assignedAgentId?: string | null };
|
||||
/** User comments on the task (author === "user"). For spec reviews, the reviewer explicitly checks that every comment is addressed. */
|
||||
@@ -488,6 +491,9 @@ export async function reviewStep(
|
||||
defaultThinkingLevel: options.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: options.taskId,
|
||||
taskTitle: options.taskTitle,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
beforeSpawnSession: async () => {
|
||||
if (!options.store) return;
|
||||
let finalSettings: Settings | undefined;
|
||||
|
||||
@@ -30,6 +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 { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
import {
|
||||
@@ -1012,6 +1013,9 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
},
|
||||
// Skill selection from step-session executor options
|
||||
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
|
||||
taskId: taskDetail.id,
|
||||
taskTitle: taskDetail.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
});
|
||||
session = createResult.session;
|
||||
|
||||
|
||||
@@ -24,6 +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 { planLog, reviewerLog, formatError } from "./logger.js";
|
||||
import {
|
||||
isUsageLimitError,
|
||||
@@ -1029,6 +1030,9 @@ export class TriageProcessor {
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
});
|
||||
|
||||
const modelDesc = describeModel(session);
|
||||
@@ -1226,6 +1230,9 @@ export class TriageProcessor {
|
||||
defaultModelId: planningFallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
onFallbackModelUsed: notifyFallbackUsed,
|
||||
});
|
||||
|
||||
session = fallbackResult.session;
|
||||
|
||||
Reference in New Issue
Block a user