From 8f65c7881163708c50f05bb24e47b2eb400c7a96 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:52:50 +0200 Subject: [PATCH] feat(appkit): fromPlugin() DX, runAgent plugins arg, shared toolkit-resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DX centerpiece. Introduces the symbol-marker pattern that collapses plugin tool references in code-defined agents from a three-touch dance to a single line, and extracts the shared resolver that the agents plugin, auto-inherit, and standalone runAgent all now go through. `packages/appkit/src/plugins/agents/from-plugin.ts`. Returns a spread- friendly `{ [Symbol()]: FromPluginMarker }` record. The symbol key is freshly generated per call, so multiple spreads of the same plugin coexist safely. The marker's brand is a globally-interned `Symbol.for("@databricks/appkit.fromPluginMarker")` — stable across module boundaries. `packages/appkit/src/plugins/agents/toolkit-resolver.ts`. Single source of truth for "turn a ToolProvider into a keyed record of `ToolkitEntry` markers". Prefers `provider.toolkit(opts)` when available (core plugins implement it), falls back to walking `getAgentTools()` and synthesizing namespaced keys (`${pluginName}.${localName}`) for third-party providers, honoring `only` / `except` / `rename` / `prefix` the same way. Used by three call sites, previously all copy-pasted: 1. `AgentsPlugin.buildToolIndex` — fromPlugin marker resolution pass 2. `AgentsPlugin.applyAutoInherit` — markdown auto-inherit path 3. `runAgent` — standalone-mode plugin tool dispatch Before the existing string-key iteration, `buildToolIndex` now walks `Object.getOwnPropertySymbols(def.tools)`. For each `FromPluginMarker`, it looks up the plugin by name in `PluginContext.getToolProviders()`, calls `resolveToolkitFromProvider`, and merges the resulting entries into the per-agent index. Missing plugins throw at setup time with a clear `Available: ...` listing — wiring errors surface on boot, not mid-request. `hasExplicitTools` now counts symbol keys too, so a `tools: { ...fromPlugin(x) }` record correctly disables auto-inherit on code-defined agents. - `AgentTools` type: `{ [key: string]: AgentTool } & { [key: symbol]: FromPluginMarker }`. Preserves string-key autocomplete while accepting marker spreads under strict TS. - `AgentDefinition.tools` switched to `AgentTools`. `packages/appkit/src/core/run-agent.ts`. When an agent def contains `fromPlugin` markers, the caller passes plugins via `RunAgentInput.plugins`. A local provider cache constructs each plugin and dispatches tool calls via `provider.executeAgentTool()`. Runs as service principal (no OBO — there's no HTTP request). If a def contains markers but `plugins` is absent, throws with guidance. `fromPlugin`, `FromPluginMarker`, `isFromPluginMarker`, `AgentTools` added to the main barrel. - 14 new tests: marker shape, symbol uniqueness, type guard, factory-without-pluginName error, fromPlugin marker resolution in AgentsPlugin, fallback to getAgentTools for providers without .toolkit(), symbol-only tools disables auto-inherit, runAgent standalone marker resolution via `plugins` arg, guidance error when missing. - Full appkit vitest suite: 1311 tests passing. - Typecheck clean. Signed-off-by: MarioCadenas --- packages/appkit/src/core/run-agent.ts | 145 +++++++++++- packages/appkit/src/index.ts | 4 + packages/appkit/src/plugins/agents/agents.ts | 98 ++++++--- .../appkit/src/plugins/agents/from-plugin.ts | 97 +++++++++ packages/appkit/src/plugins/agents/index.ts | 8 + .../agents/tests/agents-plugin.test.ts | 206 ++++++++++++++++++ .../plugins/agents/tests/from-plugin.test.ts | 80 +++++++ .../plugins/agents/tests/run-agent.test.ts | 96 ++++++++ .../src/plugins/agents/toolkit-resolver.ts | 62 ++++++ packages/appkit/src/plugins/agents/types.ts | 13 +- 10 files changed, 771 insertions(+), 38 deletions(-) create mode 100644 packages/appkit/src/plugins/agents/from-plugin.ts create mode 100644 packages/appkit/src/plugins/agents/tests/from-plugin.test.ts create mode 100644 packages/appkit/src/plugins/agents/toolkit-resolver.ts diff --git a/packages/appkit/src/core/run-agent.ts b/packages/appkit/src/core/run-agent.ts index e83c2c9c..6bbed55f 100644 --- a/packages/appkit/src/core/run-agent.ts +++ b/packages/appkit/src/core/run-agent.ts @@ -4,7 +4,12 @@ import type { AgentEvent, AgentToolDefinition, Message, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; +import { isFromPluginMarker } from "../plugins/agents/from-plugin"; +import { resolveToolkitFromProvider } from "../plugins/agents/toolkit-resolver"; import { type FunctionTool, functionToolToDefinition, @@ -23,6 +28,14 @@ export interface RunAgentInput { messages: string | Message[]; /** Abort signal for cancellation. */ signal?: AbortSignal; + /** + * Optional plugin list used to resolve `fromPlugin` markers in `def.tools`. + * Required when the def contains any `...fromPlugin(factory)` spreads; + * ignored otherwise. `runAgent` constructs a fresh instance per plugin + * and dispatches tool calls against it as the service principal (no + * OBO — there is no HTTP request in standalone mode). + */ + plugins?: PluginData[]; } export interface RunAgentResult { @@ -39,11 +52,12 @@ export interface RunAgentResult { * Limitations vs. running through the agents() plugin: * - No OBO: there is no HTTP request, so plugin tools run as the service * principal (when they work at all). - * - Plugin tools (`ToolkitEntry`) are not supported — they require a live - * `PluginContext` that only exists when registered in a `createApp` - * instance. This function throws a clear error if encountered. + * - Hosted tools (MCP) are not supported — they require a live MCP client + * that only exists inside the agents plugin. * - Sub-agents (`agents: { ... }` on the def) are executed as nested * `runAgent` calls with no shared thread state. + * - Plugin tools (`fromPlugin` markers or `ToolkitEntry` spreads) require + * passing `plugins: [...]` via `RunAgentInput`. */ export async function runAgent( def: AgentDefinition, @@ -51,7 +65,7 @@ export async function runAgent( ): Promise { const adapter = await resolveAdapter(def); const messages = normalizeMessages(input.messages, def.instructions); - const toolIndex = buildStandaloneToolIndex(def); + const toolIndex = buildStandaloneToolIndex(def, input.plugins ?? []); const tools = Array.from(toolIndex.values()).map((e) => e.def); const signal = input.signal; @@ -62,6 +76,13 @@ export async function runAgent( if (entry.kind === "function") { return entry.tool.execute(args as Record); } + if (entry.kind === "toolkit") { + return entry.provider.executeAgentTool( + entry.localName, + args as Record, + signal, + ); + } if (entry.kind === "subagent") { const subInput: RunAgentInput = { messages: @@ -71,13 +92,14 @@ export async function runAgent( ? (args as { input: string }).input : JSON.stringify(args), signal, + plugins: input.plugins, }; const res = await runAgent(entry.agentDef, subInput); return res.text; } throw new Error( `runAgent: tool "${name}" is a ${entry.kind} tool. ` + - "Plugin toolkits and MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).", + "Hosted/MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).", ); }; @@ -158,20 +180,61 @@ type StandaloneEntry = | { kind: "toolkit"; def: AgentToolDefinition; - entry: ToolkitEntry; + provider: ToolProvider; + pluginName: string; + localName: string; } | { kind: "hosted"; def: AgentToolDefinition; }; +/** + * Resolves `def.tools` (string-keyed entries + symbol-keyed `fromPlugin` + * markers) and `def.agents` (sub-agents) into a flat dispatch index. + * Symbol-keyed markers are resolved against `plugins`; missing references + * throw with an `Available: …` listing. + */ function buildStandaloneToolIndex( def: AgentDefinition, + plugins: PluginData[], ): Map { const index = new Map(); + const tools = def.tools; - for (const [key, tool] of Object.entries(def.tools ?? {})) { - index.set(key, classifyTool(key, tool)); + const symbolKeys = tools ? Object.getOwnPropertySymbols(tools) : []; + if (symbolKeys.length > 0) { + const providerCache = new Map(); + for (const sym of symbolKeys) { + const marker = (tools as Record)[sym]; + if (!isFromPluginMarker(marker)) continue; + + const provider = resolveStandaloneProvider( + marker.pluginName, + plugins, + providerCache, + ); + const entries = resolveToolkitFromProvider( + marker.pluginName, + provider, + marker.opts, + ); + for (const [key, entry] of Object.entries(entries)) { + index.set(key, { + kind: "toolkit", + provider, + pluginName: entry.pluginName, + localName: entry.localName, + def: { ...entry.def, name: key }, + }); + } + } + } + + if (tools) { + for (const [key, tool] of Object.entries(tools)) { + index.set(key, classifyTool(key, tool)); + } } for (const [childKey, child] of Object.entries(def.agents ?? {})) { @@ -203,7 +266,7 @@ function buildStandaloneToolIndex( function classifyTool(key: string, tool: AgentTool): StandaloneEntry { if (isToolkitEntry(tool)) { - return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool }; + return toolkitEntryToStandalone(key, tool); } if (isFunctionTool(tool)) { return { @@ -224,3 +287,67 @@ function classifyTool(key: string, tool: AgentTool): StandaloneEntry { } throw new Error(`runAgent: unrecognized tool shape at key "${key}"`); } + +/** + * Pre-`fromPlugin` code could reach a `ToolkitEntry` by calling + * `.toolkit()` at module scope (which requires an instance). Those entries + * still flow through `def.tools` but without a provider we can dispatch + * against — runAgent cannot execute them and errors clearly. + */ +function toolkitEntryToStandalone( + key: string, + entry: ToolkitEntry, +): StandaloneEntry { + const def: AgentToolDefinition = { ...entry.def, name: key }; + return { + kind: "hosted", + def: { + ...def, + description: + `${def.description ?? ""} ` + + `[runAgent: this ToolkitEntry refers to plugin '${entry.pluginName}' but ` + + "runAgent cannot dispatch it without the plugin instance. Pass the " + + "plugin via plugins: [...] and use fromPlugin(factory) instead of " + + ".toolkit() spreads.]".trim(), + }, + }; +} + +function resolveStandaloneProvider( + pluginName: string, + plugins: PluginData[], + cache: Map, +): ToolProvider { + const cached = cache.get(pluginName); + if (cached) return cached; + + const match = plugins.find((p) => p.name === pluginName); + if (!match) { + const available = plugins.map((p) => p.name).join(", ") || "(none)"; + throw new Error( + `runAgent: agent references plugin '${pluginName}' via fromPlugin(), but ` + + "that plugin is missing from RunAgentInput.plugins. " + + `Available: ${available}.`, + ); + } + + const instance = new match.plugin({ + ...(match.config ?? {}), + name: pluginName, + }); + const provider = instance as unknown as ToolProvider; + if ( + typeof (provider as { getAgentTools?: unknown }).getAgentTools !== + "function" || + typeof (provider as { executeAgentTool?: unknown }).executeAgentTool !== + "function" + ) { + throw new Error( + `runAgent: plugin '${pluginName}' is not a ToolProvider ` + + "(missing getAgentTools/executeAgentTool). Only ToolProvider plugins " + + "are supported via fromPlugin() in runAgent.", + ); + } + cache.set(pluginName, provider); + return provider; +} diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index ba4110b3..2843e3a3 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -73,9 +73,13 @@ export { type AgentDefinition, type AgentsPluginConfig, type AgentTool, + type AgentTools, agentIdFromMarkdownPath, agents, type BaseSystemPromptOption, + type FromPluginMarker, + fromPlugin, + isFromPluginMarker, isToolkitEntry, loadAgentFromFile, loadAgentsFromDir, diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index f1938ef2..94122925 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -20,6 +20,7 @@ import type { PluginManifest } from "../../registry"; import { agentStreamDefaults } from "./defaults"; import { EventChannel } from "./event-channel"; import { AgentEventTranslator } from "./event-translator"; +import { isFromPluginMarker } from "./from-plugin"; import { loadAgentsFromDir } from "./load-agents"; import manifest from "./manifest.json"; import { @@ -30,6 +31,7 @@ import { import { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt"; import { InMemoryThreadStore } from "./thread-store"; import { ToolApprovalGate } from "./tool-approval-gate"; +import { resolveToolkitFromProvider } from "./toolkit-resolver"; import { AppKitMcpClient, functionToolToDefinition, @@ -325,7 +327,11 @@ export class AgentsPlugin extends Plugin implements ToolProvider { src: AgentSource, ): Promise> { const index = new Map(); - const hasExplicitTools = def.tools && Object.keys(def.tools).length > 0; + const toolsRecord = def.tools ?? {}; + const hasExplicitTools = + def.tools !== undefined && + (Object.keys(toolsRecord).length > 0 || + Object.getOwnPropertySymbols(toolsRecord).length > 0); const hasExplicitSubAgents = def.agents && Object.keys(def.agents).length > 0; @@ -364,9 +370,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider { }); } - // 2. Explicit tools (toolkit entries, function tools, hosted tools) + // 2. fromPlugin markers — resolve against registered ToolProviders first so + // explicit string-keyed tools can still overwrite on the same key. + this.resolveFromPluginMarkers(agentName, toolsRecord, index); + + // 3. Explicit tools (toolkit entries, function tools, hosted tools) const hostedToCollect: import("./tools/hosted-tools").HostedTool[] = []; - for (const [key, tool] of Object.entries(def.tools ?? {})) { + for (const [key, tool] of Object.entries(toolsRecord)) { if (isToolkitEntry(tool)) { index.set(key, { source: "toolkit", @@ -418,32 +428,19 @@ export class AgentsPlugin extends Plugin implements ToolProvider { provider, } of this.context.getToolProviders()) { if (pluginName === this.name) continue; - const withToolkit = provider as ToolProvider & { - toolkit?: (opts?: unknown) => Record; - }; - if (typeof withToolkit.toolkit === "function") { - const entries = withToolkit.toolkit() as Record; - for (const [key, maybeEntry] of Object.entries(entries)) { - if (!isToolkitEntry(maybeEntry)) continue; - if (maybeEntry.autoInheritable !== true) { - recordSkip(maybeEntry.pluginName, maybeEntry.localName); - continue; - } - index.set(key, { - source: "toolkit", - pluginName: maybeEntry.pluginName, - localName: maybeEntry.localName, - def: { ...maybeEntry.def, name: key }, - }); - inherited.push(key); + const entries = resolveToolkitFromProvider(pluginName, provider); + for (const [key, entry] of Object.entries(entries)) { + if (entry.autoInheritable !== true) { + recordSkip(entry.pluginName, entry.localName); + continue; } - continue; - } - // Fallback: providers without a toolkit() still expose getAgentTools(). - // These cannot be selectively opted in per tool, so we conservatively - // skip them during auto-inherit and require explicit `tools:` wiring. - for (const tool of provider.getAgentTools()) { - recordSkip(pluginName, tool.name); + index.set(key, { + source: "toolkit", + pluginName: entry.pluginName, + localName: entry.localName, + def: { ...entry.def, name: key }, + }); + inherited.push(key); } } @@ -471,6 +468,51 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } } + /** + * Walks the symbol-keyed `fromPlugin` markers in an agent's `tools` record + * and resolves each one against a registered `ToolProvider`. Throws with a + * helpful `Available: …` listing if a referenced plugin isn't registered. + */ + private resolveFromPluginMarkers( + agentName: string, + toolsRecord: Record, + index: Map, + ): void { + const symbolKeys = Object.getOwnPropertySymbols(toolsRecord); + if (symbolKeys.length === 0) return; + + const providers = this.context?.getToolProviders() ?? []; + + for (const sym of symbolKeys) { + const marker = (toolsRecord as Record)[sym]; + if (!isFromPluginMarker(marker)) continue; + + const providerEntry = providers.find((p) => p.name === marker.pluginName); + if (!providerEntry) { + const available = providers.map((p) => p.name).join(", ") || "(none)"; + throw new Error( + `Agent '${agentName}' references plugin '${marker.pluginName}' via ` + + `fromPlugin(), but that plugin is not registered in createApp. ` + + `Available: ${available}.`, + ); + } + + const entries = resolveToolkitFromProvider( + marker.pluginName, + providerEntry.provider, + marker.opts, + ); + for (const [key, entry] of Object.entries(entries)) { + index.set(key, { + source: "toolkit", + pluginName: entry.pluginName, + localName: entry.localName, + def: { ...entry.def, name: key }, + }); + } + } + } + private async connectHostedTools( hostedTools: import("./tools/hosted-tools").HostedTool[], index: Map, diff --git a/packages/appkit/src/plugins/agents/from-plugin.ts b/packages/appkit/src/plugins/agents/from-plugin.ts new file mode 100644 index 00000000..b1128594 --- /dev/null +++ b/packages/appkit/src/plugins/agents/from-plugin.ts @@ -0,0 +1,97 @@ +import type { NamedPluginFactory } from "../../plugin/to-plugin"; +import type { ToolkitOptions } from "./types"; + +/** + * Symbol brand for the `fromPlugin` marker. Using a globally-interned symbol + * (`Symbol.for`) keeps the brand stable across module boundaries / bundle + * duplicates so `isFromPluginMarker` stays reliable. + */ +export const FROM_PLUGIN_MARKER = Symbol.for( + "@databricks/appkit.fromPluginMarker", +); + +/** + * A lazy reference to a plugin's tools, produced by {@link fromPlugin} and + * resolved to concrete `ToolkitEntry`s at `AgentsPlugin.setup()` time. + * + * The marker is spread under a unique symbol key so multiple calls to + * `fromPlugin` (even for the same plugin) coexist in an `AgentDefinition.tools` + * record without colliding. + */ +export interface FromPluginMarker { + readonly [FROM_PLUGIN_MARKER]: true; + readonly pluginName: string; + readonly opts: ToolkitOptions | undefined; +} + +/** + * Record shape returned by {@link fromPlugin} — a single symbol-keyed entry + * suitable for spreading into `AgentDefinition.tools`. + */ +export type FromPluginSpread = { readonly [key: symbol]: FromPluginMarker }; + +/** + * Reference a plugin's tools inside an `AgentDefinition.tools` record without + * naming the plugin instance. The returned spread-friendly object carries a + * symbol-keyed marker that the agents plugin resolves against registered + * `ToolProvider`s at setup time. + * + * The factory argument must come from `toPlugin` (or any function that + * carries a `pluginName` field). `fromPlugin` reads `factory.pluginName` + * synchronously — it does not construct an instance. + * + * If the referenced plugin is also registered in `createApp({ plugins })`, the + * same runtime instance is used for dispatch. If the plugin is missing, + * `AgentsPlugin.setup()` throws with a clear `Available: …` listing. + * + * @example + * ```ts + * import { analytics, createAgent, files, fromPlugin, tool } from "@databricks/appkit"; + * + * const support = createAgent({ + * instructions: "You help customers.", + * tools: { + * ...fromPlugin(analytics), + * ...fromPlugin(files, { only: ["uploads.read"] }), + * get_weather: tool({ ... }), + * }, + * }); + * ``` + * + * @param factory A plugin factory produced by `toPlugin`. Must expose a + * `pluginName` field. + * @param opts Optional toolkit scoping — `prefix`, `only`, `except`, `rename`. + * Same shape as the `.toolkit()` method. + */ +export function fromPlugin( + factory: F, + opts?: ToolkitOptions, +): FromPluginSpread { + if ( + !factory || + typeof factory.pluginName !== "string" || + !factory.pluginName + ) { + throw new Error( + "fromPlugin(): factory is missing pluginName. Pass a factory created by toPlugin().", + ); + } + const pluginName = factory.pluginName; + const marker: FromPluginMarker = { + [FROM_PLUGIN_MARKER]: true, + pluginName, + opts, + }; + return { [Symbol(`fromPlugin:${pluginName}`)]: marker }; +} + +/** + * Type guard for {@link FromPluginMarker}. + */ +export function isFromPluginMarker(value: unknown): value is FromPluginMarker { + return ( + typeof value === "object" && + value !== null && + (value as Record)[FROM_PLUGIN_MARKER] === true + ); +} diff --git a/packages/appkit/src/plugins/agents/index.ts b/packages/appkit/src/plugins/agents/index.ts index 377a8776..c076ead0 100644 --- a/packages/appkit/src/plugins/agents/index.ts +++ b/packages/appkit/src/plugins/agents/index.ts @@ -1,5 +1,12 @@ export { AgentsPlugin, agents } from "./agents"; export { buildToolkitEntries } from "./build-toolkit"; +export { + FROM_PLUGIN_MARKER, + type FromPluginMarker, + type FromPluginSpread, + fromPlugin, + isFromPluginMarker, +} from "./from-plugin"; export { agentIdFromMarkdownPath, type LoadContext, @@ -12,6 +19,7 @@ export { type AgentDefinition, type AgentsPluginConfig, type AgentTool, + type AgentTools, type AutoInheritToolsConfig, type BaseSystemPromptOption, isToolkitEntry, diff --git a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts index 747ada48..aaf97742 100644 --- a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -14,10 +14,18 @@ import { CacheManager } from "../../../cache"; // Import the class directly so we can construct it without a createApp import { AgentsPlugin } from "../agents"; import { buildToolkitEntries } from "../build-toolkit"; +import { fromPlugin } from "../from-plugin"; import { defineTool, type ToolRegistry } from "../tools/define-tool"; +import { tool } from "../tools/tool"; import type { AgentsPluginConfig, ToolkitEntry } from "../types"; import { isToolkitEntry } from "../types"; +function namedFactory(name: string) { + const f = () => ({ name }); + Object.defineProperty(f, "pluginName", { value: name, enumerable: true }); + return f as typeof f & { readonly pluginName: string }; +} + interface FakeContext { providers: Array<{ name: string; provider: ToolProvider }>; getToolProviders(): Array<{ name: string; provider: ToolProvider }>; @@ -364,4 +372,202 @@ describe("AgentsPlugin", () => { expect(isToolkitEntry({ foo: 1 })).toBe(false); expect(isToolkitEntry(null)).toBe(false); }); + + describe("fromPlugin markers", () => { + test("spreading fromPlugin registers all tools from the referenced plugin", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const ctx = fakeContext([ + { + name: "analytics", + provider: makeToolProvider("analytics", registry), + }, + ]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("analytics")) }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + expect(agent?.toolIndex.has("analytics.query")).toBe(true); + }); + + test("mixed inline + fromPlugin tools coexist", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const ctx = fakeContext([ + { + name: "analytics", + provider: makeToolProvider("analytics", registry), + }, + ]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { + ...fromPlugin(namedFactory("analytics")), + get_weather: tool({ + name: "get_weather", + description: "Weather", + schema: z.object({ city: z.string() }), + execute: async ({ city }) => `Sunny in ${city}`, + }), + }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + expect(agent?.toolIndex.has("analytics.query")).toBe(true); + expect(agent?.toolIndex.has("get_weather")).toBe(true); + }); + + test("missing plugin throws at setup with Available: listing", async () => { + const ctx = fakeContext([ + { + name: "files", + provider: makeToolProvider("files", {}), + }, + ]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("analytics")) }, + }, + }, + }, + ctx, + ); + await expect(plugin.setup()).rejects.toThrow(/analytics/); + await expect(plugin.setup()).rejects.toThrow(/Available:/); + await expect(plugin.setup()).rejects.toThrow(/files/); + }); + + test("symbol-only tools record disables auto-inherit", async () => { + const analyticsReg: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const filesReg: ToolRegistry = { + list: defineTool({ + description: "l", + schema: z.object({}), + handler: () => [], + }), + }; + const ctx = fakeContext([ + { + name: "analytics", + provider: makeToolProvider("analytics", analyticsReg), + }, + { + name: "files", + provider: makeToolProvider("files", filesReg), + }, + ]); + + const plugin = instantiate( + { + dir: false, + autoInheritTools: { code: true }, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("analytics")) }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + const toolNames = Array.from(agent?.toolIndex.keys() ?? []); + expect(toolNames.some((n) => n.startsWith("analytics."))).toBe(true); + expect(toolNames.some((n) => n.startsWith("files."))).toBe(false); + }); + + test("falls back to getAgentTools() for providers without toolkit()", async () => { + // Provider lacks .toolkit() — only getAgentTools/executeAgentTool. + const bareProvider: ToolProvider = { + getAgentTools: () => [ + { + name: "ping", + description: "ping", + parameters: { type: "object", properties: {} }, + }, + ], + executeAgentTool: vi.fn(async () => "pong"), + }; + const ctx = fakeContext([{ name: "bare", provider: bareProvider }]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("bare")) }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + expect(agent?.toolIndex.has("bare.ping")).toBe(true); + }); + }); }); diff --git a/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts new file mode 100644 index 00000000..cd8a12b4 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { + FROM_PLUGIN_MARKER, + fromPlugin, + isFromPluginMarker, +} from "../from-plugin"; + +function fakeFactory(name: string) { + const f = () => ({ name }); + Object.defineProperty(f, "pluginName", { value: name, enumerable: true }); + return f as typeof f & { readonly pluginName: string }; +} + +describe("fromPlugin", () => { + test("returns a spread-friendly object with a single symbol-keyed marker", () => { + const spread = fromPlugin(fakeFactory("analytics")); + + expect(Object.keys(spread)).toHaveLength(0); + const syms = Object.getOwnPropertySymbols(spread); + expect(syms).toHaveLength(1); + + const marker = (spread as Record)[syms[0]!]; + expect(isFromPluginMarker(marker)).toBe(true); + expect((marker as { pluginName: string }).pluginName).toBe("analytics"); + }); + + test("multiple calls produce distinct symbol keys (spreads coexist)", () => { + const spread = { + ...fromPlugin(fakeFactory("analytics")), + ...fromPlugin(fakeFactory("analytics")), + ...fromPlugin(fakeFactory("files")), + }; + + const syms = Object.getOwnPropertySymbols(spread); + expect(syms).toHaveLength(3); + }); + + test("passes opts through to the marker", () => { + const spread = fromPlugin(fakeFactory("analytics"), { + only: ["query"], + prefix: "q_", + }); + const sym = Object.getOwnPropertySymbols(spread)[0]!; + const marker = (spread as Record)[sym] as { + opts: { only: string[]; prefix: string }; + }; + expect(marker.opts.only).toEqual(["query"]); + expect(marker.opts.prefix).toBe("q_"); + }); + + test("throws when factory has no pluginName", () => { + const missing = () => ({ name: "nope" }); + expect(() => + fromPlugin(missing as unknown as { readonly pluginName: string }), + ).toThrow(/missing pluginName/); + }); + + test("FROM_PLUGIN_MARKER is a globally-interned symbol", () => { + expect(FROM_PLUGIN_MARKER).toBe( + Symbol.for("@databricks/appkit.fromPluginMarker"), + ); + }); +}); + +describe("isFromPluginMarker", () => { + test("returns true for real markers", () => { + const spread = fromPlugin(fakeFactory("analytics")); + const sym = Object.getOwnPropertySymbols(spread)[0]!; + expect(isFromPluginMarker((spread as Record)[sym])).toBe( + true, + ); + }); + + test("returns false for objects without the brand", () => { + expect(isFromPluginMarker({ pluginName: "x" })).toBe(false); + expect(isFromPluginMarker(null)).toBe(false); + expect(isFromPluginMarker(undefined)).toBe(false); + expect(isFromPluginMarker("string")).toBe(false); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/run-agent.test.ts b/packages/appkit/src/plugins/agents/tests/run-agent.test.ts index 1a974811..da626f49 100644 --- a/packages/appkit/src/plugins/agents/tests/run-agent.test.ts +++ b/packages/appkit/src/plugins/agents/tests/run-agent.test.ts @@ -3,11 +3,16 @@ import type { AgentEvent, AgentInput, AgentRunContext, + AgentToolDefinition, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { createAgent } from "../../../core/create-agent-def"; import { runAgent } from "../../../core/run-agent"; +import { fromPlugin } from "../from-plugin"; import { tool } from "../tools/tool"; import type { ToolkitEntry } from "../types"; @@ -84,6 +89,97 @@ describe("runAgent", () => { expect(weatherFn).toHaveBeenCalledWith({ city: "NYC" }); }); + test("resolves fromPlugin markers against RunAgentInput.plugins", async () => { + const pingExec = vi.fn(async () => "pong"); + class FakePlugin implements ToolProvider { + static manifest = { name: "ping" }; + static DEFAULT_CONFIG = {}; + name = "ping"; + constructor(public config: unknown) {} + async setup() {} + injectRoutes() {} + getEndpoints() { + return {}; + } + getAgentTools(): AgentToolDefinition[] { + return [ + { + name: "ping", + description: "ping", + parameters: { type: "object", properties: {} }, + }, + ]; + } + executeAgentTool = pingExec; + } + + const factory = () => ({ + plugin: FakePlugin as unknown as PluginConstructor, + config: {}, + name: "ping" as const, + }); + Object.defineProperty(factory, "pluginName", { + value: "ping", + enumerable: true, + }); + + let capturedCtx: AgentRunContext | null = null; + const adapter: AgentAdapter = { + async *run(_input, context) { + capturedCtx = context; + yield { type: "message_delta", content: "" }; + }, + }; + + const def = createAgent({ + instructions: "x", + model: adapter, + tools: { + ...fromPlugin(factory as unknown as { readonly pluginName: string }), + }, + }); + + const pluginData = factory() as PluginData< + PluginConstructor, + unknown, + string + >; + + await runAgent(def, { messages: "hi", plugins: [pluginData] }); + expect(capturedCtx).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: asserted above + const result = await capturedCtx!.executeTool("ping.ping", {}); + expect(result).toBe("pong"); + expect(pingExec).toHaveBeenCalled(); + }); + + test("throws with guidance when fromPlugin marker has no matching plugin", async () => { + const factory = () => ({ name: "absent" as const }); + Object.defineProperty(factory, "pluginName", { + value: "absent", + enumerable: true, + }); + + const adapter: AgentAdapter = { + async *run(_input, _context) { + yield { type: "message_delta", content: "" }; + }, + }; + + const def = createAgent({ + instructions: "x", + model: adapter, + tools: { + ...fromPlugin(factory as unknown as { readonly pluginName: string }), + }, + }); + + await expect(runAgent(def, { messages: "hi" })).rejects.toThrow(/absent/); + await expect(runAgent(def, { messages: "hi" })).rejects.toThrow( + /Available:/, + ); + }); + test("throws a clear error when a ToolkitEntry is invoked", async () => { const toolkitEntry: ToolkitEntry = { __toolkitRef: true, diff --git a/packages/appkit/src/plugins/agents/toolkit-resolver.ts b/packages/appkit/src/plugins/agents/toolkit-resolver.ts new file mode 100644 index 00000000..8ec8cf1f --- /dev/null +++ b/packages/appkit/src/plugins/agents/toolkit-resolver.ts @@ -0,0 +1,62 @@ +import type { ToolProvider } from "shared"; +import type { ToolkitEntry, ToolkitOptions } from "./types"; + +/** + * Internal interface: a `ToolProvider` that optionally exposes a typed + * `.toolkit(opts)` method. Core plugins (analytics, files, genie, lakebase) + * implement this; third-party `ToolProvider`s may not. + */ +type MaybeToolkitProvider = ToolProvider & { + toolkit?: (opts?: ToolkitOptions) => Record; +}; + +/** + * Resolve a plugin's tools into a keyed record of {@link ToolkitEntry} markers + * ready to be merged into an agent's tool index. + * + * Preferred path: call the plugin's own `.toolkit(opts)` method, which + * typically delegates to `buildToolkitEntries` with full `ToolkitOptions` + * support (prefix, only, except, rename). + * + * Fallback path: when the plugin doesn't expose `.toolkit()` (e.g. a + * third-party `ToolProvider` built with plain `toPlugin`), walk + * `getAgentTools()` and synthesize namespaced keys (`${pluginName}.${name}`) + * while still honoring `only` / `except` / `rename` / `prefix`. + * + * This helper is the single source of truth for "turn a provider into a + * toolkit entry record" and is used by `AgentsPlugin.buildToolIndex` + * (both the `fromPlugin` resolution pass and auto-inherit) and by the + * standalone `runAgent` executor. + */ +export function resolveToolkitFromProvider( + pluginName: string, + provider: ToolProvider, + opts?: ToolkitOptions, +): Record { + const withToolkit = provider as MaybeToolkitProvider; + if (typeof withToolkit.toolkit === "function") { + return withToolkit.toolkit(opts); + } + + const only = opts?.only ? new Set(opts.only) : null; + const except = opts?.except ? new Set(opts.except) : null; + const rename = opts?.rename ?? {}; + const prefix = opts?.prefix ?? `${pluginName}.`; + + const out: Record = {}; + for (const tool of provider.getAgentTools()) { + if (only && !only.has(tool.name)) continue; + if (except?.has(tool.name)) continue; + + const keyAfterPrefix = `${prefix}${tool.name}`; + const key = rename[tool.name] ?? keyAfterPrefix; + + out[key] = { + __toolkitRef: true, + pluginName, + localName: tool.name, + def: { ...tool, name: key }, + }; + } + return out; +} diff --git a/packages/appkit/src/plugins/agents/types.ts b/packages/appkit/src/plugins/agents/types.ts index 8d52d278..3deb04b9 100644 --- a/packages/appkit/src/plugins/agents/types.ts +++ b/packages/appkit/src/plugins/agents/types.ts @@ -5,6 +5,7 @@ import type { ThreadStore, ToolAnnotations, } from "shared"; +import type { FromPluginMarker } from "./from-plugin"; import type { FunctionTool } from "./tools/function-tool"; import type { HostedTool } from "./tools/hosted-tools"; import type { McpHostPolicyConfig } from "./tools/mcp-host-policy"; @@ -62,6 +63,16 @@ export type BaseSystemPromptOption = | string | ((ctx: PromptContext) => string); +/** + * Per-agent tool record. String keys map to inline tools, toolkit entries, + * hosted tools, etc. Symbol keys hold `FromPluginMarker` references produced + * by `fromPlugin(factory)` spreads — these are resolved at + * `AgentsPlugin.setup()` time against registered `ToolProvider` plugins. + */ +export type AgentTools = { [key: string]: AgentTool } & { + [key: symbol]: FromPluginMarker; +}; + export interface AgentDefinition { /** Filled in from the enclosing key when used in `agents: { foo: def }`. */ name?: string; @@ -74,7 +85,7 @@ export interface AgentDefinition { */ model?: AgentAdapter | Promise | string; /** Per-agent tool record. Key is the LLM-visible tool-call name. */ - tools?: Record; + tools?: AgentTools; /** Sub-agents, exposed as `agent-` tools on this agent. */ agents?: Record; /** Override the plugin's baseSystemPrompt for this agent only. */