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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user