Skip to content

Commit 5ddb7fa

Browse files
committed
fix(agents): inherit default agent model catalog for secondary agents
Model discovery only runs for the default/main agent at gateway startup, so generated plugin model catalogs (e.g. plugins/google/catalog.json) are only populated under the default agent dir. A secondary agent created with `openclaw agents add` gets an apiKey-only google catalog with no models, so a Google/Gemini model authenticated via GEMINI_API_KEY fails at runtime with `FailoverError: Unknown model: google/gemini-2.5-flash`. The default agent works. Add read-through inheritance for generated plugin model catalogs, mirroring the existing auth-profile read-through (`inheritedAuthDir`). Implementation is local-first with a per-provider gap-fill gate: - The agent's OWN catalogs (root models.json + local plugin catalogs) load first and establish its full local state. - Inheritance is gated per provider: a provider the local agent already has any model for is excluded from the inherited load entirely — it contributes no provider request config, no per-model headers, and no models for that provider. So "local always wins" never depends on write order, and an inherited catalog can neither read nor overwrite the local agent's request config/headers. - Only providers the secondary agent does not configure at all are inherited (config + headers + models), which is exactly the agent that is living on inheritance. apiKey values in catalogs are env refs (e.g. GEMINI_API_KEY) resolved per-agent, so no secret material crosses the agent boundary. - A broken/invalid inherited catalog is skipped, never breaking the local agent. `discoverModels` threads `inheritedAgentDir` through to the registry; `discoverCachedAgentStores` passes the resolved default agent dir and folds the inherited catalog files into the discovery-cache fingerprint so changes to the default agent's catalog invalidate the secondary agent's cached snapshot. The default agent never inherits from itself (path-equality guard).
1 parent b8adc11 commit 5ddb7fa

4 files changed

Lines changed: 354 additions & 6 deletions

File tree

src/agents/agent-model-discovery.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type DiscoverModelsOptions = {
3838
pluginMetadataSnapshot?: PluginModelCatalogMetadataSnapshot;
3939
workspaceDir?: string;
4040
normalizeModels?: boolean;
41+
/**
42+
* Default/main agent directory used as a read-through fallback for a secondary agent's
43+
* generated plugin model catalogs. See `ModelRegistry` `inheritedAgentDir`.
44+
*/
45+
inheritedAgentDir?: string;
4146
};
4247

4348
/** Applies plugin model normalization and transport hooks to discovered agent models. */
@@ -102,7 +107,10 @@ function createOpenClawModelRegistry(
102107
allowWorkspaceScopedCurrent: options?.workspaceDir === undefined,
103108
useRuntimeConfig: options?.config === undefined,
104109
});
105-
const registryOptions = pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {};
110+
const registryOptions = {
111+
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
112+
...(options?.inheritedAgentDir ? { inheritedAgentDir: options.inheritedAgentDir } : {}),
113+
};
106114
const registry = ModelRegistry.create(authStorage, modelsJsonPath, registryOptions);
107115
const getAll = registry.getAll.bind(registry);
108116
const getAvailable = registry.getAvailable.bind(registry);

src/agents/embedded-agent-runner/model-discovery-cache.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ function discoveryFingerprint(
9191
modelsJson: fileFingerprint(path.join(params.agentDir, "models.json")),
9292
pluginMetadata: pluginMetadataFingerprint(params.pluginMetadataSnapshot),
9393
pluginModelCatalogs: pluginModelCatalogFingerprint(params.agentDir),
94+
// Secondary agents read through to the default agent's plugin catalogs, so changes to
95+
// those inherited catalogs must invalidate this agent's cached discovery snapshot.
96+
inheritedPluginModelCatalogs: inheritedAuthDir
97+
? pluginModelCatalogFingerprint(inheritedAuthDir)
98+
: undefined,
9499
});
95100
}
96101

@@ -137,12 +142,20 @@ function discoverFreshAgentStores(
137142
agentDir: string,
138143
options: Pick<DiscoverCachedAgentStoresOptions, "config" | "workspaceDir">,
139144
pluginMetadataSnapshot: PluginMetadataSnapshot | undefined,
145+
inheritedAgentDir: string | undefined,
140146
): DiscoveryStores {
141147
const authStorage = discoverAuthStorage(agentDir);
148+
// Only inherit when the default agent dir is genuinely distinct, so the default agent
149+
// never reads through to itself.
150+
const inheritDir =
151+
inheritedAgentDir && path.resolve(inheritedAgentDir) !== path.resolve(agentDir)
152+
? inheritedAgentDir
153+
: undefined;
142154
const modelRegistry = discoverModels(authStorage, agentDir, {
143155
...(options.config ? { config: options.config } : {}),
144156
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
145157
...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
158+
...(inheritDir ? { inheritedAgentDir: inheritDir } : {}),
146159
});
147160
return { authStorage, modelRegistry };
148161
}
@@ -162,6 +175,7 @@ export function discoverCachedAgentStores(
162175
agentDir,
163176
options,
164177
resolvePluginMetadataSnapshotForDiscovery(options),
178+
inheritedAuthDir,
165179
);
166180
}
167181
const pluginMetadataSnapshot = resolvePluginMetadataSnapshotForDiscovery(options);
@@ -177,7 +191,12 @@ export function discoverCachedAgentStores(
177191
};
178192
}
179193

180-
const stores = discoverFreshAgentStores(agentDir, options, pluginMetadataSnapshot);
194+
const stores = discoverFreshAgentStores(
195+
agentDir,
196+
options,
197+
pluginMetadataSnapshot,
198+
inheritedAuthDir,
199+
);
181200
DISCOVERY_STORE_CACHE.set(cacheKey, {
182201
authStorage: stores.authStorage,
183202
fingerprint,

src/agents/sessions/model-registry.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,257 @@ describe("ModelRegistry models.json auth", () => {
226226
expect(registry.find("zai", "glm-5.1")).toBeUndefined();
227227
});
228228
});
229+
230+
describe("ModelRegistry default-agent catalog inheritance", () => {
231+
// Model discovery only runs for the default agent at gateway startup, so a freshly
232+
// created secondary agent has an apiKey-only google catalog with no models. Without
233+
// read-through inheritance it fails at runtime with "Unknown model: google/...".
234+
function writeGoogleCatalogAgentDir(params: { withModels: boolean; modelName?: string }): string {
235+
return writeModelsJsonWithPluginCatalog({
236+
root: { providers: {} },
237+
pluginRelativePath: join("plugins", "google", PLUGIN_MODEL_CATALOG_FILE),
238+
pluginCatalog: {
239+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
240+
providers: {
241+
google: params.withModels
242+
? {
243+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
244+
api: "openai-responses",
245+
apiKey: "GEMINI_API_KEY",
246+
models: [
247+
{ id: "gemini-2.5-flash", name: params.modelName ?? "Gemini 2.5 Flash" },
248+
],
249+
}
250+
: { apiKey: "GEMINI_API_KEY" },
251+
},
252+
},
253+
});
254+
}
255+
256+
it("inherits the default agent's generated catalog models for a secondary agent", () => {
257+
const defaultAgentDir = dirname(writeGoogleCatalogAgentDir({ withModels: true }));
258+
const secondaryModelsPath = writeGoogleCatalogAgentDir({ withModels: false });
259+
260+
const registry = ModelRegistry.create(
261+
AuthStorage.inMemory({ google: { type: "api_key", key: "sk-test" } }),
262+
secondaryModelsPath,
263+
{
264+
pluginMetadataSnapshot: pluginOwnerSnapshot("google", "google"),
265+
inheritedAgentDir: defaultAgentDir,
266+
},
267+
);
268+
269+
expect(registry.getError()).toBeUndefined();
270+
expect(registry.find("google", "gemini-2.5-flash")?.name).toBe("Gemini 2.5 Flash");
271+
});
272+
273+
it("does not inherit without an inheritedAgentDir (reproduces the bug)", () => {
274+
const secondaryModelsPath = writeGoogleCatalogAgentDir({ withModels: false });
275+
276+
const registry = ModelRegistry.create(
277+
AuthStorage.inMemory({ google: { type: "api_key", key: "sk-test" } }),
278+
secondaryModelsPath,
279+
{ pluginMetadataSnapshot: pluginOwnerSnapshot("google", "google") },
280+
);
281+
282+
expect(registry.getError()).toBeUndefined();
283+
expect(registry.find("google", "gemini-2.5-flash")).toBeUndefined();
284+
});
285+
286+
it("prefers the secondary agent's own catalog over inherited models", () => {
287+
const defaultAgentDir = dirname(
288+
writeGoogleCatalogAgentDir({ withModels: true, modelName: "Gemini 2.5 Flash (default)" }),
289+
);
290+
const secondaryModelsPath = writeGoogleCatalogAgentDir({
291+
withModels: true,
292+
modelName: "Gemini 2.5 Flash (local)",
293+
});
294+
295+
const registry = ModelRegistry.create(
296+
AuthStorage.inMemory({ google: { type: "api_key", key: "sk-test" } }),
297+
secondaryModelsPath,
298+
{
299+
pluginMetadataSnapshot: pluginOwnerSnapshot("google", "google"),
300+
inheritedAgentDir: defaultAgentDir,
301+
},
302+
);
303+
304+
expect(registry.getError()).toBeUndefined();
305+
expect(registry.find("google", "gemini-2.5-flash")?.name).toBe("Gemini 2.5 Flash (local)");
306+
});
307+
308+
it("does not let an inherited catalog override a local provider's config/headers or leak sibling models", async () => {
309+
// Regression: a provider the secondary agent defines in its own models.json must keep
310+
// its local request config (headers/auth) and must NOT gain sibling models from the
311+
// default agent's catalog — otherwise a local model would run with the default agent's
312+
// credentials/transport (a cross-agent boundary violation), and inheritance would no
313+
// longer be "local always wins".
314+
const defaultAgentDir = dirname(
315+
writeModelsJsonWithPluginCatalog({
316+
root: { providers: {} },
317+
pluginRelativePath: join("plugins", "google", PLUGIN_MODEL_CATALOG_FILE),
318+
pluginCatalog: {
319+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
320+
providers: {
321+
google: {
322+
baseUrl: "https://default.example/v1beta",
323+
api: "openai-responses",
324+
apiKey: "GEMINI_API_KEY",
325+
headers: { "x-source": "default" },
326+
models: [
327+
{ id: "gemini-2.5-flash", name: "Flash (default)" },
328+
{ id: "gemini-3-flash-preview", name: "3 Flash (default)" },
329+
],
330+
},
331+
},
332+
},
333+
}),
334+
);
335+
336+
// Secondary agent owns google via its OWN models.json (not a plugin catalog).
337+
const secondaryModelsPath = writeModelsJson({
338+
providers: {
339+
google: {
340+
baseUrl: "https://local.example/v1beta",
341+
api: "openai-responses",
342+
apiKey: "GEMINI_API_KEY",
343+
headers: { "x-source": "local" },
344+
models: [{ id: "gemini-2.5-flash", name: "Flash (local)" }],
345+
},
346+
},
347+
});
348+
349+
const registry = ModelRegistry.create(
350+
AuthStorage.inMemory({ google: { type: "api_key", key: "sk-test" } }),
351+
secondaryModelsPath,
352+
{
353+
pluginMetadataSnapshot: pluginOwnerSnapshot("google", "google"),
354+
inheritedAgentDir: defaultAgentDir,
355+
},
356+
);
357+
358+
expect(registry.getError()).toBeUndefined();
359+
360+
const local = registry.find("google", "gemini-2.5-flash");
361+
expect(local?.name).toBe("Flash (local)");
362+
// Per-provider gating: the default agent's sibling model must not leak in.
363+
expect(registry.find("google", "gemini-3-flash-preview")).toBeUndefined();
364+
365+
// Local provider request config (headers) must survive inherited loading.
366+
const resolved = await registry.getApiKeyAndHeaders(local!);
367+
if (!resolved.ok) {
368+
throw new Error(`expected resolved auth, got error: ${resolved.error}`);
369+
}
370+
expect(resolved.headers?.["x-source"]).toBe("local");
371+
});
372+
373+
it("resolves local request config when the local provider comes from a local plugin catalog", async () => {
374+
// Same guarantee as above, but the local provider is owned via a local plugin catalog
375+
// (not root models.json) — covers the other registration path end-to-end.
376+
const defaultAgentDir = dirname(
377+
writeModelsJsonWithPluginCatalog({
378+
root: { providers: {} },
379+
pluginRelativePath: join("plugins", "google", PLUGIN_MODEL_CATALOG_FILE),
380+
pluginCatalog: {
381+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
382+
providers: {
383+
google: {
384+
baseUrl: "https://default.example/v1beta",
385+
api: "openai-responses",
386+
apiKey: "GEMINI_API_KEY",
387+
headers: { "x-source": "default" },
388+
models: [
389+
{ id: "gemini-2.5-flash", name: "Flash (default)" },
390+
{ id: "gemini-3-flash-preview", name: "3 Flash (default)" },
391+
],
392+
},
393+
},
394+
},
395+
}),
396+
);
397+
398+
const secondaryModelsPath = writeModelsJsonWithPluginCatalog({
399+
root: { providers: {} },
400+
pluginRelativePath: join("plugins", "google", PLUGIN_MODEL_CATALOG_FILE),
401+
pluginCatalog: {
402+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
403+
providers: {
404+
google: {
405+
baseUrl: "https://local.example/v1beta",
406+
api: "openai-responses",
407+
apiKey: "GEMINI_API_KEY",
408+
headers: { "x-source": "local" },
409+
models: [{ id: "gemini-2.5-flash", name: "Flash (local)" }],
410+
},
411+
},
412+
},
413+
});
414+
415+
const registry = ModelRegistry.create(
416+
AuthStorage.inMemory({ google: { type: "api_key", key: "sk-test" } }),
417+
secondaryModelsPath,
418+
{
419+
pluginMetadataSnapshot: pluginOwnerSnapshot("google", "google"),
420+
inheritedAgentDir: defaultAgentDir,
421+
},
422+
);
423+
424+
expect(registry.getError()).toBeUndefined();
425+
426+
const local = registry.find("google", "gemini-2.5-flash");
427+
expect(local?.name).toBe("Flash (local)");
428+
expect(registry.find("google", "gemini-3-flash-preview")).toBeUndefined();
429+
430+
const resolved = await registry.getApiKeyAndHeaders(local!);
431+
if (!resolved.ok) {
432+
throw new Error(`expected resolved auth, got error: ${resolved.error}`);
433+
}
434+
expect(resolved.headers?.["x-source"]).toBe("local");
435+
});
436+
437+
it("resolves an inherited-only provider with the inherited baseUrl and headers", async () => {
438+
// For a provider the secondary agent does not configure at all, inheritance must supply
439+
// the default agent's baseUrl/api and request headers so the model is actually usable.
440+
const defaultAgentDir = dirname(
441+
writeModelsJsonWithPluginCatalog({
442+
root: { providers: {} },
443+
pluginRelativePath: join("plugins", "google", PLUGIN_MODEL_CATALOG_FILE),
444+
pluginCatalog: {
445+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
446+
providers: {
447+
google: {
448+
baseUrl: "https://inherited.example/v1beta",
449+
api: "openai-responses",
450+
apiKey: "GEMINI_API_KEY",
451+
headers: { "x-source": "inherited" },
452+
models: [{ id: "gemini-2.5-flash", name: "Flash (inherited)" }],
453+
},
454+
},
455+
},
456+
}),
457+
);
458+
459+
// Secondary agent configures no provider of its own (lives on inheritance).
460+
const secondaryModelsPath = writeModelsJson({ providers: {} });
461+
462+
const registry = ModelRegistry.create(
463+
AuthStorage.inMemory({ google: { type: "api_key", key: "sk-test" } }),
464+
secondaryModelsPath,
465+
{
466+
pluginMetadataSnapshot: pluginOwnerSnapshot("google", "google"),
467+
inheritedAgentDir: defaultAgentDir,
468+
},
469+
);
470+
471+
expect(registry.getError()).toBeUndefined();
472+
473+
const model = registry.find("google", "gemini-2.5-flash");
474+
expect(model?.baseUrl).toBe("https://inherited.example/v1beta");
475+
476+
const resolved = await registry.getApiKeyAndHeaders(model!);
477+
if (!resolved.ok) {
478+
throw new Error(`expected resolved auth, got error: ${resolved.error}`);
479+
}
480+
expect(resolved.headers?.["x-source"]).toBe("inherited");
481+
});
482+
});

0 commit comments

Comments
 (0)