feat(ai,coding-agent): added Anthropic fast mode with auto-fallback

Wires `speed: "fast"` and the `fast-mode-2026-02-01` beta into the
Anthropic provider, plumbs a matching `speed` option through
`SimpleStreamOptions` and the Agent, and teaches `/fast` to dispatch
on the active model's API (Anthropic -> speed=fast, OpenAI -> existing
serviceTier=priority path). Server is the authority on which models
support fast mode.

When the server rejects an unsupported model, the provider mirrors the
strict-tools fallback: drops the field, retries the same turn
transparently, persists the disable via `providerSessionState`, and
surfaces the action through the new `AssistantMessage.disabledFeatures`
marker so the session can sync the toggle off and warn the user.
This commit is contained in:
Lucas Szwarcberg
2026-05-18 11:48:40 -07:00
parent 12e92262e8
commit d0efcea5ad
11 changed files with 247 additions and 2 deletions
@@ -1578,6 +1578,14 @@ export class AgentSession {
if (event.message.role === "assistant") {
this.#lastAssistantMessage = event.message;
const assistantMsg = event.message as AssistantMessage;
if (assistantMsg.disabledFeatures?.includes("anthropic.fast_mode") && this.speed === "fast") {
this.setSpeed(undefined);
this.emitNotice(
"warning",
"Fast mode rejected for this model; retried without it. Fast mode is now off.",
"anthropic.fast_mode",
);
}
// Resolve TTSR resume gate before checking for new deferred injections.
// Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming
// edit) also produces stopReason === "aborted" but has no continuation coming.
@@ -2761,6 +2769,10 @@ export class AgentSession {
return this.agent.serviceTier;
}
get speed(): "fast" | "standard" | undefined {
return this.agent.speed;
}
/** Whether agent is currently streaming a response */
get isStreaming(): boolean {
return this.agent.state.isStreaming || this.#promptInFlightCount > 0;
@@ -5111,6 +5123,9 @@ export class AgentSession {
}
isFastModeEnabled(): boolean {
if (this.model?.api === "anthropic-messages") {
return this.speed === "fast";
}
return this.serviceTier === "priority";
}
@@ -5120,7 +5135,17 @@ export class AgentSession {
this.sessionManager.appendServiceTierChange(serviceTier ?? null);
}
setSpeed(speed: "fast" | "standard" | undefined): void {
if (this.speed === speed) return;
this.agent.speed = speed;
this.sessionManager.appendSpeedChange(speed ?? null);
}
setFastMode(enabled: boolean): void {
if (this.model?.api === "anthropic-messages") {
this.setSpeed(enabled ? "fast" : undefined);
return;
}
this.setServiceTier(enabled ? "priority" : undefined);
}
@@ -7582,6 +7607,7 @@ export class AgentSession {
reasoning: toReasoningEffort(this.thinkingLevel),
hideThinkingSummary: this.agent.hideThinkingSummary,
serviceTier: this.serviceTier,
speed: this.speed,
signal: args.signal,
toolChoice: "none",
},
@@ -7821,6 +7847,7 @@ export class AgentSession {
const hasServiceTierEntry = this.sessionManager
.getBranch()
.some(entry => entry.type === "service_tier_change");
const hasSpeedEntry = this.sessionManager.getBranch().some(entry => entry.type === "speed_change");
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
const configuredServiceTier = this.settings.get("serviceTier");
const nextThinkingLevel = resolveThinkingLevelForModel(
@@ -7834,6 +7861,7 @@ export class AgentSession {
: configuredServiceTier === "none"
? undefined
: configuredServiceTier;
this.agent.speed = hasSpeedEntry ? sessionContext.speed : undefined;
if (switchingToDifferentSession) {
this.#resetHindsightConversationTrackingIfHindsight();
@@ -103,6 +103,11 @@ export interface ServiceTierChangeEntry extends SessionEntryBase {
serviceTier: ServiceTier | null;
}
export interface SpeedChangeEntry extends SessionEntryBase {
type: "speed_change";
speed: "fast" | "standard" | null;
}
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string;
@@ -214,6 +219,7 @@ export type SessionEntry =
| ThinkingLevelChangeEntry
| ModelChangeEntry
| ServiceTierChangeEntry
| SpeedChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
@@ -239,6 +245,7 @@ export interface SessionContext {
messages: AgentMessage[];
thinkingLevel?: string;
serviceTier?: ServiceTier;
speed?: "fast" | "standard";
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
models: Record<string, string>;
/** Names of TTSR rules that have been injected this session */
@@ -524,6 +531,7 @@ export function buildSessionContext(
messages: [],
thinkingLevel: "off",
serviceTier: undefined,
speed: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
@@ -544,6 +552,7 @@ export function buildSessionContext(
messages: [],
thinkingLevel: "off",
serviceTier: undefined,
speed: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
@@ -563,6 +572,7 @@ export function buildSessionContext(
// Extract settings and find compaction
let thinkingLevel: string | undefined = "off";
let serviceTier: ServiceTier | undefined;
let speed: "fast" | "standard" | undefined;
const models: Record<string, string> = {};
let compaction: CompactionEntry | null = null;
const injectedTtsrRulesSet = new Set<string>();
@@ -593,6 +603,8 @@ export function buildSessionContext(
}
} else if (entry.type === "service_tier_change") {
serviceTier = entry.serviceTier ?? undefined;
} else if (entry.type === "speed_change") {
speed = entry.speed ?? undefined;
} else if (entry.type === "message" && entry.message.role === "assistant") {
// Legacy fallback: infer default model from assistant messages only
// when no explicit `model_change` (role=default) entry has been
@@ -705,6 +717,7 @@ export function buildSessionContext(
messages,
thinkingLevel,
serviceTier,
speed,
models,
injectedTtsrRules,
selectedMCPToolNames,
@@ -2564,6 +2577,18 @@ export class SessionManager {
return entry.id;
}
appendSpeedChange(speed: "fast" | "standard" | null): string {
const entry: SpeedChangeEntry = {
type: "speed_change",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
speed,
};
this.#appendEntry(entry);
return entry.id;
}
/** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */
appendModeChange(mode: string, data?: Record<string, unknown>): string {
const entry: ModeChangeEntry = {