feat(proxy): favourite-index tool presentation #86
@@ -52,35 +52,24 @@ function splitServer(canonical: string): { server: string; short: string } {
|
|||||||
: { server: canonical.slice(0, i), short: canonical.slice(i + 1) };
|
: { server: canonical.slice(0, i), short: canonical.slice(i + 1) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFavouriteIndexPlugin(config: FavouriteIndexConfig): ProxyModelPlugin {
|
interface Presentation {
|
||||||
const favourites = config.favourites ?? [];
|
tools: ToolDefinition[];
|
||||||
const cap = config.maxFavourites && config.maxFavourites > 0 ? config.maxFavourites : undefined;
|
/** presented name → canonical `server/tool` */
|
||||||
|
resolver: Map<string, string>;
|
||||||
return {
|
}
|
||||||
name: 'favourite-index',
|
|
||||||
description:
|
|
||||||
'Two-namespace tool presentation: curated favourite/<tool> + full all/<server>/<tool>, prefer favourites.',
|
|
||||||
|
|
||||||
async onInitialize() {
|
|
||||||
return { instructions: FAVOURITE_INDEX_INSTRUCTION };
|
|
||||||
},
|
|
||||||
|
|
||||||
async onToolsList(tools: ToolDefinition[], ctx: PluginSessionContext): Promise<ToolDefinition[]> {
|
|
||||||
// Distinguish upstream tools (in the discovered catalog) from virtual
|
|
||||||
// tools (gate/agent) that must pass through untouched.
|
|
||||||
const upstream = await ctx.discoverTools();
|
|
||||||
const upstreamNames = new Set(upstream.map((t) => t.name));
|
|
||||||
const upstreamTools = tools.filter((t) => upstreamNames.has(t.name));
|
|
||||||
const passthrough = tools.filter((t) => !upstreamNames.has(t.name));
|
|
||||||
|
|
||||||
// Gated / empty catalog → no-op (leave the list exactly as gate produced it).
|
|
||||||
if (upstreamTools.length === 0) {
|
|
||||||
ctx.state.delete(RESOLVER_KEY);
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reshape an upstream catalog into favourites-first + full all/ catalog, and
|
||||||
|
* the presented→canonical routing map. Pure — shared by onToolsList and the
|
||||||
|
* lazy rebuild in onToolCallBefore.
|
||||||
|
*/
|
||||||
|
function buildPresentation(
|
||||||
|
upstreamTools: ToolDefinition[],
|
||||||
|
favourites: string[],
|
||||||
|
cap?: number,
|
||||||
|
): Presentation {
|
||||||
const byName = new Map(upstreamTools.map((t) => [t.name, t]));
|
const byName = new Map(upstreamTools.map((t) => [t.name, t]));
|
||||||
const resolver = new Map<string, string>(); // presented → canonical
|
const resolver = new Map<string, string>();
|
||||||
const usedPresented = new Set<string>();
|
const usedPresented = new Set<string>();
|
||||||
const seenCanonical = new Set<string>();
|
const seenCanonical = new Set<string>();
|
||||||
|
|
||||||
@@ -118,15 +107,63 @@ export function createFavouriteIndexPlugin(config: FavouriteIndexConfig): ProxyM
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { tools: [...favTools, ...allTools], resolver };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFavouriteIndexPlugin(config: FavouriteIndexConfig): ProxyModelPlugin {
|
||||||
|
const favourites = config.favourites ?? [];
|
||||||
|
const cap = config.maxFavourites && config.maxFavourites > 0 ? config.maxFavourites : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'favourite-index',
|
||||||
|
description:
|
||||||
|
'Two-namespace tool presentation: curated favourite/<tool> + full all/<server>/<tool>, prefer favourites.',
|
||||||
|
|
||||||
|
async onInitialize() {
|
||||||
|
return { instructions: FAVOURITE_INDEX_INSTRUCTION };
|
||||||
|
},
|
||||||
|
|
||||||
|
async onToolsList(tools: ToolDefinition[], ctx: PluginSessionContext): Promise<ToolDefinition[]> {
|
||||||
|
// Distinguish upstream tools (in the discovered catalog) from virtual
|
||||||
|
// tools (gate/agent) that must pass through untouched.
|
||||||
|
const upstream = await ctx.discoverTools();
|
||||||
|
const upstreamNames = new Set(upstream.map((t) => t.name));
|
||||||
|
const upstreamTools = tools.filter((t) => upstreamNames.has(t.name));
|
||||||
|
const passthrough = tools.filter((t) => !upstreamNames.has(t.name));
|
||||||
|
|
||||||
|
// Gated / empty catalog → no-op (leave the list exactly as gate produced it).
|
||||||
|
if (upstreamTools.length === 0) {
|
||||||
|
ctx.state.delete(RESOLVER_KEY);
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tools: presented, resolver } = buildPresentation(upstreamTools, favourites, cap);
|
||||||
ctx.state.set(RESOLVER_KEY, resolver);
|
ctx.state.set(RESOLVER_KEY, resolver);
|
||||||
return [...favTools, ...allTools, ...passthrough];
|
return [...presented, ...passthrough];
|
||||||
},
|
},
|
||||||
|
|
||||||
async onToolCallBefore(toolName, _args, request, ctx) {
|
async onToolCallBefore(toolName, _args, request, ctx) {
|
||||||
const resolver = ctx.state.get(RESOLVER_KEY) as Map<string, string> | undefined;
|
if (!toolName.startsWith('favourite/') && !toolName.startsWith('all/')) return null;
|
||||||
const canonical =
|
|
||||||
resolver?.get(toolName) ??
|
let resolver = ctx.state.get(RESOLVER_KEY) as Map<string, string> | undefined;
|
||||||
(toolName.startsWith('all/') ? toolName.slice('all/'.length) : undefined);
|
let canonical = resolver?.get(toolName);
|
||||||
|
|
||||||
|
// Resolver missing (a call arrived before tools/list this session): rebuild
|
||||||
|
// it so favourite/ names — which, unlike all/, can't be prefix-stripped —
|
||||||
|
// still route.
|
||||||
|
if (canonical === undefined) {
|
||||||
|
const upstream = await ctx.discoverTools();
|
||||||
|
if (upstream.length > 0) {
|
||||||
|
resolver = buildPresentation(upstream, favourites, cap).resolver;
|
||||||
|
ctx.state.set(RESOLVER_KEY, resolver);
|
||||||
|
canonical = resolver.get(toolName);
|
||||||
|
}
|
||||||
|
// all/ is always derivable by stripping the prefix.
|
||||||
|
if (canonical === undefined && toolName.startsWith('all/')) {
|
||||||
|
canonical = toolName.slice('all/'.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (canonical && canonical !== toolName) {
|
if (canonical && canonical !== toolName) {
|
||||||
// Rewrite presented name → canonical `server/tool`, then continue the
|
// Rewrite presented name → canonical `server/tool`, then continue the
|
||||||
// chain (return null) so normal routing + content-pipeline still run.
|
// chain (return null) so normal routing + content-pipeline still run.
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ describe('favourite-index plugin', () => {
|
|||||||
expect(text).toBe('Called scale_deployment');
|
expect(text).toBe('Called scale_deployment');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes a favourite/ call even before tools/list (lazy resolver rebuild)', async () => {
|
||||||
|
const router = setup({ favourites: ['vault/read_secret'] });
|
||||||
|
router.addUpstream(mockUpstream('vault', CATALOG.vault));
|
||||||
|
// initialize only — deliberately NO tools/list, so the resolver is unset.
|
||||||
|
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||||
|
|
||||||
|
const res = await router.route(
|
||||||
|
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'favourite/read_secret', arguments: {} } },
|
||||||
|
{ sessionId: 's1' },
|
||||||
|
);
|
||||||
|
expect((res.result as { content: Array<{ text: string }> }).content[0]!.text).toBe('Called read_secret');
|
||||||
|
});
|
||||||
|
|
||||||
it('no-ops while the session is gated (only begin_session visible)', async () => {
|
it('no-ops while the session is gated (only begin_session visible)', async () => {
|
||||||
const router = setup({ gated: true, favourites: ['k8s/get_pods'] });
|
const router = setup({ gated: true, favourites: ['k8s/get_pods'] });
|
||||||
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
router.addUpstream(mockUpstream('k8s', CATALOG.k8s));
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ describe('Smoke: favourite-index presentation', () => {
|
|||||||
chat.section('favourite-index routing');
|
chat.section('favourite-index routing');
|
||||||
try {
|
try {
|
||||||
await chat.initialize();
|
await chat.initialize();
|
||||||
|
await chat.listTools(); // real clients list before calling — populates the resolver
|
||||||
|
|
||||||
// Calling with no args → the UPSTREAM tool's arg validation fires, proving
|
// Calling with no args → the UPSTREAM tool's arg validation fires, proving
|
||||||
// the presented name routed to the real server (not a -32601 unknown tool).
|
// the presented name routed to the real server (not a -32601 unknown tool).
|
||||||
|
|||||||
Reference in New Issue
Block a user