fix(advisor): fail a refusal over to the model fallback chain
A classifier refusal returned before the `onTurnError` hook that owns model fallback, so `AdvisorRuntime` treated one provider's policy verdict as terminal: `Refusal (cyber)` on the advisor model disabled the advisor outright even with a fallback chain configured. Its only recovery was stripping echoed primary reasoning and resending once, which does nothing for a refusal about the content itself. Route a refusal that outlives the strip through the same hook the generic failure path uses, mirroring its epoch guard, session-transition requeue, and requeue-on-recovery. The cascade walks the chain to exhaustion and only reports the advisor unavailable once the host runs out of candidates, matching what turn-recovery already allows for the primary. Each cascade visits a model at most once. A switch re-arms `#includeThinking` through `#syncModelIdentity`, so chain keys that point back at each other (A to B, B to A) would otherwise strip-and-resend against the same pair forever. A successful turn or a reset starts a fresh walk. Also stop `/advisor status` throwing when a live advisor has no roster entry: `formatAdvisorStatus` guarded only the inactive case before dereferencing `stats.advisors[0]`, and `#ensureAdvisors` clears `#advisorStatuses` before repopulating it, so a status call landing in that window hit `undefined.contextWindow`.
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an advisor refusal skipping the model fallback chain. `AdvisorRuntime` treated a classifier refusal as terminal once its one stripped-reasoning resend failed, returning before the `onTurnError` hook that owns fallback (`#recoverAdvisorTurn`), so a `Refusal (cyber)` on one model disabled the advisor even with a configured chain. The refusal path now takes the same fallback pass the primary turn-recovery path already allows, and only reports the advisor unavailable when the host declines to switch.
|
||||
- Bounded advisor refusal recovery to one attempt per model. The cascade walks the fallback chain to exhaustion, but a model switch re-arms `#includeThinking` via `#syncModelIdentity`, so a chain whose keys point back at each other (A→B, B→A) would strip-and-resend against the same pair forever. Each cascade now visits a model at most once; a successful turn or a reset starts a fresh walk.
|
||||
- Fixed `/advisor status` throwing when the roster is empty but an advisor is live. `formatAdvisorStatus` dereferenced `stats.advisors[0]` after a guard that only covered the inactive case, so a status call landing in the window where `#advisorStatuses` is cleared for a rebuild hit `undefined.contextWindow`. Reporting status now never throws.
|
||||
|
||||
## [17.2.8] - 2026-08-04
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -296,6 +296,14 @@ export class AdvisorRuntime {
|
||||
#failureNotified = false;
|
||||
/** Consecutive quarantined turns since the last success/reset (issue #6661). */
|
||||
#consecutiveQuarantines = 0;
|
||||
/**
|
||||
* Model identities this refusal cascade has already tried. The cascade walks
|
||||
* the fallback chain to exhaustion — that is what the chain is for — but
|
||||
* visits each model at most once, so a chain whose keys point back at each
|
||||
* other (A→B, B→A) cannot ping-pong forever. Cleared by a successful turn or
|
||||
* a reset, so a later refusal starts a fresh walk.
|
||||
*/
|
||||
readonly #refusalModelsTried = new Set<string>();
|
||||
/** Whether primary reasoning is included in advisor deltas for the current model. */
|
||||
#includeThinking = true;
|
||||
#modelIdentity: string | undefined;
|
||||
@@ -541,6 +549,7 @@ export class AdvisorRuntime {
|
||||
this.#failing = false;
|
||||
this.#droppedBacklogs = 0;
|
||||
this.#consecutiveQuarantines = 0;
|
||||
this.#refusalModelsTried.clear();
|
||||
this.#failureNotified = false;
|
||||
this.#resetAdvisorContext(true, true);
|
||||
}
|
||||
@@ -936,6 +945,7 @@ export class AdvisorRuntime {
|
||||
this.#failureNotified = false;
|
||||
this.#droppedBacklogs = 0;
|
||||
this.#consecutiveQuarantines = 0;
|
||||
this.#refusalModelsTried.clear();
|
||||
if (this.host.onTurnSuccess) {
|
||||
try {
|
||||
await raceWithSignal(Promise.resolve(this.host.onTurnSuccess()), iterationAbort.signal);
|
||||
@@ -995,6 +1005,47 @@ export class AdvisorRuntime {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A refusal that outlives the strip is this model's policy call, not
|
||||
// a malformed request, so hand it to the host's model fallback before
|
||||
// declaring the advisor dead. The cascade walks the chain to
|
||||
// exhaustion (the host returns false once candidates run out); the
|
||||
// tried-set only stops a cyclic chain from revisiting a model. The
|
||||
// primary turn-recovery path already allows fallback on refusals.
|
||||
const refusalModel = this.host.getModelIdentity?.() ?? this.#modelIdentity ?? "";
|
||||
let refusalRecovered = false;
|
||||
try {
|
||||
if (!this.#refusalModelsTried.has(refusalModel)) {
|
||||
this.#refusalModelsTried.add(refusalModel);
|
||||
refusalRecovered =
|
||||
(await raceWithSignal(
|
||||
Promise.resolve(this.host.onTurnError?.(err, failedMessages, iterationAbort.signal)),
|
||||
iterationAbort.signal,
|
||||
)) === true;
|
||||
} else {
|
||||
logger.debug("advisor refusal chain exhausted", { model: refusalModel });
|
||||
}
|
||||
} catch (hookErr) {
|
||||
logger.debug("advisor onTurnError hook failed after refusal", { err: String(hookErr) });
|
||||
}
|
||||
if (this.#epoch !== epoch) continue;
|
||||
if (this.#sessionTransitionPaused) {
|
||||
this.#pending.unshift(...popped);
|
||||
continue;
|
||||
}
|
||||
if (refusalRecovered) {
|
||||
this.#consecutiveFailures = 0;
|
||||
this.#failureNotified = false;
|
||||
this.#pending.unshift({
|
||||
text: batch,
|
||||
rawMessages,
|
||||
renderRevision: this.#renderRevision,
|
||||
turns: finalTurns,
|
||||
wip,
|
||||
overflowRecovery: recoveringOverflow || undefined,
|
||||
});
|
||||
logger.debug("advisor refusal recovered by model fallback");
|
||||
continue;
|
||||
}
|
||||
this.#notifyFailureOnce(err);
|
||||
this.#clearSeenContext();
|
||||
this.#backlog = Math.max(0, this.#backlog - finalTurns);
|
||||
|
||||
@@ -1790,7 +1790,14 @@ export class SessionAdvisors {
|
||||
}
|
||||
if (stats.advisors.length <= 1) {
|
||||
const s = stats.advisors[0];
|
||||
if (s && s.status === "no_model") {
|
||||
if (!s) {
|
||||
// A rebuild clears #advisorStatuses before repopulating it, so a status
|
||||
// call landing in that window sees live advisors with an empty roster.
|
||||
// Reporting a status must never throw — this used to fall through to
|
||||
// `s.contextWindow`.
|
||||
return stats.active ? "Advisor is starting up." : "Advisor is disabled.";
|
||||
}
|
||||
if (s.status === "no_model") {
|
||||
return stats.configured
|
||||
? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
|
||||
: "Advisor is disabled.";
|
||||
|
||||
@@ -3617,6 +3617,261 @@ describe("advisor", () => {
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("asks the host to switch models when a refusal outlives the stripped resend", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const failures: unknown[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
// The first model refuses every time; the host's fallback hook swaps in a
|
||||
// model that answers, exactly as `#recoverAdvisorTurn` does for a session.
|
||||
let modelRefuses = true;
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptInputs.push(input);
|
||||
if (modelRefuses) {
|
||||
state.error = "Refusal (cyber): blocked under Anthropic's Usage Policy";
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
stopDetails: { type: "refusal", category: "cyber" },
|
||||
errorMessage: state.error,
|
||||
timestamp: promptInputs.length + 1,
|
||||
} as unknown as AgentMessage);
|
||||
return;
|
||||
}
|
||||
state.error = undefined;
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
timestamp: promptInputs.length + 1,
|
||||
} as unknown as AgentMessage);
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
rollbackTo: count => {
|
||||
state.messages.length = count;
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
timestamp: 1,
|
||||
} as AgentMessage,
|
||||
];
|
||||
let fallbackCalls = 0;
|
||||
const runtime = new AdvisorRuntime(
|
||||
agent,
|
||||
{
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
notifyFailure: error => failures.push(error),
|
||||
onTurnError: async () => {
|
||||
fallbackCalls++;
|
||||
modelRefuses = false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await settleUntil(() => runtime.backlog === 0);
|
||||
|
||||
// Refuse, strip-and-resend, refuse again, then the swapped model answers.
|
||||
expect(fallbackCalls).toBe(1);
|
||||
expect(promptInputs).toHaveLength(3);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces the refusal when the host declines to switch models", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const failures: unknown[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptInputs.push(input);
|
||||
state.error = "Refusal (cyber): blocked under Anthropic's Usage Policy";
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
stopDetails: { type: "refusal", category: "cyber" },
|
||||
errorMessage: state.error,
|
||||
timestamp: promptInputs.length + 1,
|
||||
} as unknown as AgentMessage);
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
rollbackTo: count => {
|
||||
state.messages.length = count;
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
timestamp: 1,
|
||||
} as AgentMessage,
|
||||
];
|
||||
const runtime = new AdvisorRuntime(
|
||||
agent,
|
||||
{
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
notifyFailure: error => failures.push(error),
|
||||
onTurnError: async () => false,
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await settleUntil(() => failures.length === 1 && runtime.backlog === 0);
|
||||
|
||||
expect(promptInputs).toHaveLength(2);
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("walks the whole fallback chain before reporting a refusal", async () => {
|
||||
// Every model refuses. The cascade must reach the last chain entry, then
|
||||
// stop once the host runs out of candidates.
|
||||
const promptInputs: string[] = [];
|
||||
const failures: unknown[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
let identity = "anthropic/claude-fable-5";
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptInputs.push(input);
|
||||
state.error = "Refusal (cyber): blocked under Anthropic's Usage Policy";
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
stopDetails: { type: "refusal", category: "cyber" },
|
||||
errorMessage: state.error,
|
||||
timestamp: promptInputs.length + 1,
|
||||
} as unknown as AgentMessage);
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
rollbackTo: count => {
|
||||
state.messages.length = count;
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
timestamp: 1,
|
||||
} as AgentMessage,
|
||||
];
|
||||
const chain = ["openai-codex/gpt-5.6-sol", "synthetic/hf:moonshotai/Kimi-K3", "fireworks/kimi-k3"];
|
||||
const switched: string[] = [];
|
||||
const runtime = new AdvisorRuntime(
|
||||
agent,
|
||||
{
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
notifyFailure: error => failures.push(error),
|
||||
getModelIdentity: () => identity,
|
||||
onTurnError: async () => {
|
||||
const next = chain[switched.length];
|
||||
if (!next) return false;
|
||||
switched.push(next);
|
||||
identity = next;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await settleUntil(() => failures.length === 1 && runtime.backlog === 0);
|
||||
|
||||
expect(switched).toEqual(chain);
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops a refusal walk that a cyclic chain would loop forever", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const failures: unknown[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
// A chain configured A -> B -> A: the host never runs out of candidates,
|
||||
// so only the per-cascade tried-set can end the walk.
|
||||
const cycle = ["model/b", "model/a"];
|
||||
let identity = "model/a";
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptInputs.push(input);
|
||||
state.error = "Refusal (cyber): blocked under Anthropic's Usage Policy";
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
stopDetails: { type: "refusal", category: "cyber" },
|
||||
errorMessage: state.error,
|
||||
timestamp: promptInputs.length + 1,
|
||||
} as unknown as AgentMessage);
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
rollbackTo: count => {
|
||||
state.messages.length = count;
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
timestamp: 1,
|
||||
} as AgentMessage,
|
||||
];
|
||||
let hops = 0;
|
||||
const runtime = new AdvisorRuntime(
|
||||
agent,
|
||||
{
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
notifyFailure: error => failures.push(error),
|
||||
getModelIdentity: () => identity,
|
||||
onTurnError: async () => {
|
||||
identity = cycle[hops % cycle.length] ?? "model/a";
|
||||
hops++;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await settleUntil(() => failures.length === 1 && runtime.backlog === 0);
|
||||
|
||||
// a (refuse, strip) -> b (refuse, strip) -> back to a, already tried: stop.
|
||||
expect(hops).toBe(2);
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("degrades on a category-less refusal", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const failures: unknown[] = [];
|
||||
|
||||
Reference in New Issue
Block a user