feat(coding-agent): added advisor immune-turn window for concern/blocker interruptions

- Tracked completed primary turns in `AgentSession` and started an immune-turn window after each interrupting advisor steer.
- Routed follow-on `concern`/`blocker` notes to the aside channel while the immune window is active, while preserving prior auto-resume-suppressed handling.
- Added the `advisor.immuneTurns` setting and tests for immune-turn detection and delivery-channel decisions.
This commit is contained in:
can1357
2026-06-17 13:20:30 +02:00
parent e4a87fa3ce
commit 54d212ec33
7 changed files with 124 additions and 0 deletions
+2
View File
@@ -95,6 +95,8 @@ note text
When you deliberately interrupt the agent (Esc, or a cancel from collab, ACP, RPC, the SDK, or an extension), the advisor stops auto-resuming it. An interrupting `concern`/`blocker` raised while the run is stopped is recorded as a visible advisor card instead of restarting the turn, and a concern already in flight when you interrupt is preserved the same way rather than driving a surprise resume. The advice re-enters context the next time you resume — a new message, the `.`/`c` continue shortcut, or a steer/follow-up. A normal yield is unaffected: the advisor can still steer and resume a run the agent ended on its own.
`advisor.immuneTurns` limits interruption frequency. After the advisor successfully delivers a `concern` or `blocker` through the steering channel, later concerns/blockers are routed as non-interrupting asides until the configured number of primary turns has completed. The default is `1`. `nit` notes are unchanged, and advice raised while user-interrupt auto-resume suppression is active is still preserved instead of restarting a stopped run.
## Bounded catch-up with `advisor.syncBacklog`
`advisor.syncBacklog` is not lockstep turn execution. It is a bounded catch-up delay for the primary agent when the advisor falls behind.
+1
View File
@@ -321,6 +321,7 @@ See [Advisor and WATCHDOG.md](./advisor-watchdog.md) for runtime behavior, `WATC
| `advisor.enabled` | boolean | `false` | Enable the advisor runtime when `modelRoles.advisor` resolves to an available model. |
| `advisor.subagents` | boolean | `false` | Also enable advisor runtimes for spawned task/eval subagents. |
| `advisor.syncBacklog` | enum | `off` | Bounded advisor catch-up delay: `off`, `1`, `3`, or `5`. The primary waits up to 30 seconds only while advisor backlog is at or above the threshold. |
| `advisor.immuneTurns` | number | `1` | After a `concern`/`blocker` interrupts, route further concerns/blockers as non-interrupting asides for this many completed primary turns. |
### Thinking
+1
View File
@@ -8,6 +8,7 @@
- Added automatic endpoint-mode support for google-antigravity provider calls so users can force production-only or sandbox-only usage
- Added `images.describeForTextModels` option (default `true`) to control automatic image description for attachments sent to models without vision input
- Added automatic vision fallback prompts to describe images for text-only models
- Added `advisor.immuneTurns` setting (default `1`) to limit how often advisor `concern`/`blocker` notes can interrupt the primary agent.
### Changed
@@ -12,6 +12,7 @@ import {
AdvisorRuntime,
type AdvisorRuntimeHost,
formatAdvisorBatchContent,
isAdvisorInterruptImmuneTurnActive,
isInterruptingSeverity,
resolveAdvisorDeliveryChannel,
} from "..";
@@ -124,6 +125,44 @@ describe("advisor", () => {
expect(isInterruptingSeverity(undefined)).toBe(false);
});
it("keeps the interrupt-immune turn fence half-open for the configured window", () => {
expect(
isAdvisorInterruptImmuneTurnActive({
completedTurns: 4,
immuneTurnStart: undefined,
immuneTurns: 2,
}),
).toBe(false);
expect(
isAdvisorInterruptImmuneTurnActive({
completedTurns: 4,
immuneTurnStart: 5,
immuneTurns: 0,
}),
).toBe(false);
expect(
isAdvisorInterruptImmuneTurnActive({
completedTurns: 4,
immuneTurnStart: 5,
immuneTurns: 2,
}),
).toBe(true);
expect(
isAdvisorInterruptImmuneTurnActive({
completedTurns: 6,
immuneTurnStart: 5,
immuneTurns: 2,
}),
).toBe(true);
expect(
isAdvisorInterruptImmuneTurnActive({
completedTurns: 7,
immuneTurnStart: 5,
immuneTurns: 2,
}),
).toBe(false);
});
it("wraps each note in an advisory tag with severity as an attribute and escapes the body", () => {
const content = formatAdvisorBatchContent([
{ note: "first note" },
@@ -688,6 +727,26 @@ describe("advisor", () => {
}
});
it("routes interrupting notes to the aside queue during immune turns without overriding preservation", () => {
expect(
resolveAdvisorDeliveryChannel({
severity: "concern",
autoResumeSuppressed: false,
streaming: true,
aborting: false,
interruptImmuneTurnActive: true,
}),
).toBe("aside");
expect(
resolveAdvisorDeliveryChannel({
severity: "blocker",
autoResumeSuppressed: true,
streaming: false,
aborting: false,
interruptImmuneTurnActive: true,
}),
).toBe("preserve");
});
it("preserves an interrupting note while suppressed AND idle (no auto-resume of a stopped run)", () => {
for (const severity of ["concern", "blocker"] as const) {
expect(
@@ -68,6 +68,15 @@ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): b
/** How an advisor note is routed to the primary. */
export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
/** Half-open turn-count fence for the post-interrupt cooldown. */
export function isAdvisorInterruptImmuneTurnActive(opts: {
completedTurns: number;
immuneTurnStart: number | undefined;
immuneTurns: number;
}): boolean {
if (opts.immuneTurnStart === undefined || opts.immuneTurns <= 0) return false;
return opts.completedTurns < opts.immuneTurnStart + opts.immuneTurns;
}
/**
* Decide how one advisor note reaches the primary agent.
@@ -84,15 +93,19 @@ export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
* auto-resume anything, so it is delivered live. Parking it during an active
* run instead strands it (it never reaches the running agent) and the withheld
* notes dump as one burst at the next user prompt — the bug this guards.
* - During the post-interrupt immune-turn window, further `concern`/`blocker`
* notes are downgraded to asides; suppression preservation still wins.
*/
export function resolveAdvisorDeliveryChannel(opts: {
severity: AdvisorSeverity | undefined;
autoResumeSuppressed: boolean;
streaming: boolean;
aborting: boolean;
interruptImmuneTurnActive?: boolean;
}): AdvisorDeliveryChannel {
if (!isInterruptingSeverity(opts.severity)) return "aside";
if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
if (opts.interruptImmuneTurnActive) return "aside";
return "steer";
}
@@ -415,6 +415,25 @@ export const SETTINGS_SCHEMA = {
"Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays.",
},
},
"advisor.immuneTurns": {
type: "number",
default: 1,
ui: {
tab: "model",
group: "Advisor",
label: "Advisor Immune Turns",
description:
"After an advisor concern or blocker interrupts, route further concerns/blockers non-interruptingly for this many primary turns.",
options: [
{ value: "0", label: "0 turns", description: "Allow every concern/blocker to interrupt." },
{ value: "1", label: "1 turn", description: "Default." },
{ value: "2", label: "2 turns" },
{ value: "3", label: "3 turns" },
{ value: "4", label: "4 turns" },
{ value: "5", label: "5 turns" },
],
},
},
shellPath: { type: "string", default: undefined },
"git.enabled": {
type: "boolean",
@@ -127,6 +127,8 @@ import {
AdvisorRuntime,
type AdvisorSeverity,
formatAdvisorBatchContent,
isAdvisorInterruptImmuneTurnActive,
isInterruptingSeverity,
resolveAdvisorDeliveryChannel,
} from "../advisor";
import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
@@ -1082,6 +1084,8 @@ export class AgentSession {
* suppresses advisor concern/blocker auto-resume until the user next resumes.
* Advisor advice is still recorded into the transcript, just not auto-run. */
#advisorAutoResumeSuppressed = false;
#advisorPrimaryTurnsCompleted = 0;
#advisorInterruptImmuneTurnStart: number | undefined;
#planModeState: PlanModeState | undefined;
#goalModeState: GoalModeState | undefined;
#goalRuntime: GoalRuntime;
@@ -1519,6 +1523,7 @@ export class AgentSession {
this.agent.setRawSseEventInterceptor(this.#onSseEvent);
this.agent.setOnTurnEnd(async (messages, signal) => {
if (signal?.aborted) return;
this.#advisorPrimaryTurnsCompleted++;
if (this.#advisorRuntime && !this.#advisorRuntime.disposed) {
this.#advisorRuntime.onTurnEnd(messages);
const syncBacklog = this.settings.get("advisor.syncBacklog");
@@ -1651,6 +1656,27 @@ export class AgentSession {
// -------------------------------------------------------------------------
// Advisor runtime lifecycle
// -------------------------------------------------------------------------
#advisorImmuneTurnLimit(): number {
const immuneTurns = this.settings.get("advisor.immuneTurns") as number;
if (!Number.isFinite(immuneTurns) || immuneTurns <= 0) return 0;
return Math.trunc(immuneTurns);
}
#isAdvisorInterruptImmuneTurnActive(): boolean {
return isAdvisorInterruptImmuneTurnActive({
completedTurns: this.#advisorPrimaryTurnsCompleted,
immuneTurnStart: this.#advisorInterruptImmuneTurnStart,
immuneTurns: this.#advisorImmuneTurnLimit(),
});
}
// The next primary turn number starts the immune-turn window. While the
// interrupting steer is still in flight, completedTurns is lower than this
// start, so duplicate concern/blocker advice is also downgraded.
#recordAdvisorInterruptDelivered(): void {
this.#advisorInterruptImmuneTurnStart = this.#advisorPrimaryTurnsCompleted + 1;
}
#buildAdvisorRuntime(seedToCurrent = false): boolean {
if (this.#isDisposed) return false;
if (this.#advisorRuntime) return true;
@@ -1680,6 +1706,7 @@ export class AgentSession {
// strand the advice and dump the backlog as one burst at the next prompt. A
// plain nit always rides the non-interrupting YieldQueue aside.
const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
const interrupting = isInterruptingSeverity(severity);
const channel = resolveAdvisorDeliveryChannel({
severity,
autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
@@ -1690,6 +1717,7 @@ export class AgentSession {
// auto-resume it despite the user's interrupt.
streaming: this.agent.state.isStreaming,
aborting: this.#abortInProgress,
interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
});
if (channel === "aside") {
this.yieldQueue.enqueue("advisor", { note, severity });
@@ -1710,6 +1738,7 @@ export class AgentSession {
});
return;
}
this.#recordAdvisorInterruptDelivered();
void this.sendCustomMessage(
{ customType: "advisor", content, display: true, attribution: "agent", details },
{ deliverAs: "steer", triggerTurn: true },