Browse Source

fix: propagate model arrays to subagents and infer fallback model

Two fixes for subagent model fallback:

1. applyOverrides() now sets config.model to the first entry in the
   model array instead of undefined. This gives subagents a valid model
   at launch time while preserving _modelArray for ForegroundFallbackManager
   runtime failover.

2. ForegroundFallbackManager.tryFallback() now infers the current model
   from the chain's first entry when agent name is known but no model was
   captured via message.updated. Without this, error events for subagents
   that fire before message.updated would incorrectly re-select the
   primary model as the fallback target.

Previously, subagent model arrays silently fell back to the global session
model, and rate-limit errors in background subagents received no fallback.
Robin Gutzen 1 month ago
parent
commit
9100e59d0f

+ 2 - 2
src/agents/index.test.ts

@@ -236,7 +236,7 @@ describe('orchestrator agent', () => {
       { id: 'github-copilot/claude-3.5-haiku' },
       { id: 'openai/gpt-4' },
     ]);
-    expect(orchestrator?.config.model).toBeUndefined();
+    expect(orchestrator?.config.model).toBe('google/gemini-3-pro');
   });
 });
 
@@ -258,7 +258,7 @@ describe('per-model variant in array config', () => {
       { id: 'google/gemini-3-flash', variant: 'low' },
       { id: 'openai/gpt-4o-mini' },
     ]);
-    expect(explorer?.config.model).toBeUndefined();
+    expect(explorer?.config.model).toBe('google/gemini-3-flash');
   });
 
   test('top-level variant preserved alongside per-model variants', () => {

+ 4 - 1
src/agents/index.ts

@@ -160,7 +160,10 @@ function applyOverrides(
       agent._modelArray = override.model.map((m) =>
         typeof m === 'string' ? { id: m } : m,
       );
-      agent.config.model = undefined; // cleared; runtime hook resolves from _modelArray
+      // Set config.model to the primary entry so the subagent has a valid
+      // model at launch time. ForegroundFallbackManager handles runtime
+      // failover to the remaining entries in _modelArray.
+      agent.config.model = agent._modelArray[0].id;
     } else {
       agent.config.model = override.model;
     }

+ 5 - 4
src/hooks/foreground-fallback/index.test.ts

@@ -579,7 +579,7 @@ describe('ForegroundFallbackManager deduplication', () => {
 // ---------------------------------------------------------------------------
 
 describe('ForegroundFallbackManager subagent.session.created', () => {
-  test('records agent name from subagent.session.created when agentName provided', async () => {
+  test('records agent name from subagent.session.created and falls back correctly', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(client, makeChains(), true);
 
@@ -602,9 +602,10 @@ describe('ForegroundFallbackManager subagent.session.created', () => {
       },
     ];
     // explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
-    // no current model tracked → first untried = openai/gpt-4o-mini
-    expect(call[0].body.model.providerID).toBe('openai');
-    expect(call[0].body.model.modelID).toBe('gpt-4o-mini');
+    // agentName known → currentModel inferred as chain[0] (primary)
+    // primary is tried → fallback picks claude-haiku
+    expect(call[0].body.model.providerID).toBe('anthropic');
+    expect(call[0].body.model.modelID).toBe('claude-haiku');
   });
 });
 

+ 10 - 1
src/hooks/foreground-fallback/index.ts

@@ -226,7 +226,7 @@ export class ForegroundFallbackManager {
 
     this.inProgress.add(sessionID);
     try {
-      const currentModel = this.sessionModel.get(sessionID);
+      let currentModel = this.sessionModel.get(sessionID);
       const agentName = this.sessionAgent.get(sessionID);
       const chain = this.resolveChain(agentName, currentModel);
       if (!chain.length) {
@@ -237,6 +237,15 @@ export class ForegroundFallbackManager {
         return;
       }
 
+      // When the agent is known but no model was captured (common for
+      // subagent error events that fire before message.updated), infer
+      // the current model as the chain's first entry. Without this, the
+      // fallback would incorrectly re-select the primary model as the
+      // "next" fallback target.
+      if (!currentModel && agentName && chain.length > 0) {
+        currentModel = chain[0];
+      }
+
       if (!this.sessionTried.has(sessionID)) {
         this.sessionTried.set(sessionID, new Set());
       }