From c1ebaae54ad13f02eba765b9f1b790283e385638 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 12:02:12 +0200 Subject: [PATCH] feat(appkit): add fromPlugin() for referencing plugin tools in code-defined agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `fromPlugin(factory, opts?)` — a spread-friendly symbol-keyed marker that references a plugin's tools inside an `AgentDefinition.tools` record without needing an intermediate `const analyticsP = analytics()` variable or a `.toolkit()` call. ```ts const support = createAgent({ instructions: "…", tools: { ...fromPlugin(analytics), ...fromPlugin(files, { only: ["uploads.read"] }), get_weather: tool({ … }), }, }); await createApp({ plugins: [server(), analytics(), files(), agents({ agents: { support } })], }); ``` At setup time `AgentsPlugin.buildToolIndex` walks `Object.getOwnPropertySymbols(def.tools)`, resolves each marker against `PluginContext.getToolProviders()`, and calls the provider's `.toolkit(opts)` method. For providers without `.toolkit()` (third-party ToolProviders or plugins built with plain `toPlugin`), the resolver falls back to walking `getAgentTools()` and synthesizing namespaced keys (`\${pluginName}.\${localName}`), respecting `only` / `except` / `rename` / `prefix` the same way. Missing plugins throw at setup with an `Available: …` listing so wiring errors surface on boot, not mid-request. `runAgent` gains an optional `plugins?: PluginData[]` argument so `fromPlugin` markers work in standalone mode too (plugin tools dispatch as the service principal since there is no HTTP request). Factory identity is carried via a new `pluginName` field stamped onto the returned function inside `toPlugin` / `toPluginWithInstance`, which `fromPlugin` reads synchronously — no throwaway instance construction. `.toolkit()` is not deprecated — it remains the power-user path for renaming or combining tools in ways `fromPlugin`'s options can't express. Docs lead with `fromPlugin` as the primary shape. Also: - Widens `AgentDefinition.tools` to `{ [key: string]: AgentTool } & { [key: symbol]: FromPluginMarker }` so spread compiles under strict TS while preserving string-key autocomplete. - `hasExplicitTools` now includes symbol keys, so a `tools: { ...fromPlugin(x) }` record correctly disables auto-inherit on code-defined agents. - Shared `resolveToolkitFromProvider` helper between auto-inherit and fromPlugin resolution paths. - Migrates apps/agent-app/server.ts to spread `fromPlugin` instead of hand-writing the support agent's tool list. - Adds a new code-defined `helper` agent to apps/dev-playground/server/index.ts showing the API alongside the markdown-driven `autocomplete` agent. - Docs updated: new "Scoping tools in code" section in agents.md; new fromPlugin section in the migration guide with before/after. Signed-off-by: MarioCadenas --- apps/agent-app/server.ts | 5 +- apps/dev-playground/server/index.ts | 23 +- .../docs/guides/migrating-to-agents-plugin.md | 63 +++++- docs/docs/plugins/agents.md | 55 ++++- packages/appkit/src/core/run-agent.ts | 180 ++++++++++++++- packages/appkit/src/index.ts | 4 + packages/appkit/src/plugin/index.ts | 6 +- packages/appkit/src/plugin/to-plugin.ts | 39 +++- packages/appkit/src/plugins/agents/agents.ts | 136 +++++++++--- .../appkit/src/plugins/agents/from-plugin.ts | 93 ++++++++ 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 | 94 ++++++++ packages/appkit/src/plugins/agents/types.ts | 13 +- 15 files changed, 948 insertions(+), 57 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 diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts index 3c853d43..29756079 100644 --- a/apps/agent-app/server.ts +++ b/apps/agent-app/server.ts @@ -4,6 +4,7 @@ import { createAgent, createApp, files, + fromPlugin, mcpServer, server, tool, @@ -25,12 +26,14 @@ const get_weather = tool({ // Code-defined agent. Overrides config/agents/support.md if a file with that // name exists. Tools here are explicit; defaults are strict (no auto-inherit -// for code-defined agents). +// for code-defined agents), so we pull analytics + files in via fromPlugin. const support = createAgent({ instructions: "You help customers with data analysis, file browsing, and general questions. " + "Use the available tools as needed and summarize results concisely.", tools: { + ...fromPlugin(analytics), + ...fromPlugin(files), get_weather, "mcp.vector-search": mcpServer( "vector-search", diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 1795321b..b782dcbb 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -2,12 +2,16 @@ import "reflect-metadata"; import { agents, analytics, + createAgent, createApp, files, + fromPlugin, genie, server, + tool, } from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; +import { z } from "zod"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -22,6 +26,23 @@ function createMockClient() { return client; } +// Code-defined demo agent showing the fromPlugin() API alongside the +// markdown-driven agents in config/agents/. +const helper = createAgent({ + instructions: + "You are a demo helper. Use analytics tools to answer data questions, " + + "or get_weather for light small-talk.", + tools: { + ...fromPlugin(analytics), + get_weather: tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ city: z.string().describe("City name") }), + execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, + }), + }, +}); + createApp({ plugins: [ server({ autoStart: false }), @@ -33,7 +54,7 @@ createApp({ }), lakebaseExamples(), files(), - agents(), + agents({ agents: { helper } }), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), }).then((appkit) => { diff --git a/docs/docs/guides/migrating-to-agents-plugin.md b/docs/docs/guides/migrating-to-agents-plugin.md index d2ca374a..da1fd091 100644 --- a/docs/docs/guides/migrating-to-agents-plugin.md +++ b/docs/docs/guides/migrating-to-agents-plugin.md @@ -136,6 +136,50 @@ You are a read-only data assistant. Engineers declare tools in code; prompt authors pick from a menu in frontmatter. No YAML-as-code ceremony required. +## Scoping tools in code with `fromPlugin` + +Earlier alphas of the new API required a three-touch dance for every plugin whose tools you wanted on a code-defined agent: + +```ts +// Before: intermediate variable + .toolkit() + a second entry in plugins[] +const analyticsP = analytics(); +const filesP = files(); + +const support = createAgent({ + instructions: "…", + tools: { + ...analyticsP.toolkit(), + ...filesP.toolkit({ only: ["uploads.read"] }), + }, +}); + +await createApp({ + plugins: [server(), analyticsP, filesP, agents({ agents: { support } })], +}); +``` + +`fromPlugin(factory, opts?)` collapses this to a single reference per plugin: + +```ts +import { agents, analytics, createAgent, createApp, files, fromPlugin, server } from "@databricks/appkit"; + +const support = createAgent({ + instructions: "…", + tools: { + ...fromPlugin(analytics), + ...fromPlugin(files, { only: ["uploads.read"] }), + }, +}); + +await createApp({ + plugins: [server(), analytics(), files(), agents({ agents: { support } })], +}); +``` + +`fromPlugin` returns a spread-friendly, symbol-keyed marker. The agents plugin resolves it at setup against registered `ToolProvider`s and throws a clear `Available: …` error if the referenced plugin is missing from `plugins: [...]`. + +`.toolkit()` is **not deprecated** — use it when you need to rename individual tools or combine fine-grained scoping that `fromPlugin`'s options can't express. For the 90% case where you want "all tools from this plugin", prefer `fromPlugin`. + ## Standalone runs The old `createAgent` returned a running HTTP app. Sometimes you want to run an agent in a script, cron, or test without HTTP. Use `runAgent`: @@ -153,7 +197,24 @@ const result = await runAgent(classifier, { messages: "Billing issue please help console.log(result.text); ``` -Plugin toolkits (`ToolkitEntry` from `.toolkit()`) require `createApp`; `runAgent` throws a clear error if invoked with one. +To use plugin tools in standalone mode, pass the plugin factories through `plugins: [...]`. `runAgent` resolves any `fromPlugin` markers in the def against that list and dispatches tool calls as the service principal: + +```ts +import { analytics, createAgent, fromPlugin, runAgent } from "@databricks/appkit"; + +const classifier = createAgent({ + instructions: "Classify tickets. Use analytics.query for historical data.", + model: "databricks-claude-sonnet-4-5", + tools: { ...fromPlugin(analytics) }, +}); + +await runAgent(classifier, { + messages: "is ticket 42 a duplicate?", + plugins: [analytics()], +}); +``` + +Hosted/MCP tools are still `agents()`-only (they need the live MCP client). Raw `ToolkitEntry` spreads from `.toolkit()` can't be dispatched standalone — `runAgent` throws a clear error pointing you at `fromPlugin`. ## Gradual migration diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index 45742bbb..b1c47878 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -75,34 +75,54 @@ import { createAgent, createApp, files, + fromPlugin, server, tool, } from "@databricks/appkit"; import { z } from "zod"; -const analyticsP = analytics(); -const filesP = files(); - const support = createAgent({ instructions: "You help customers with data and files.", - model: "databricks-claude-sonnet-4-5", // string sugar + model: "databricks-claude-sonnet-4-5", // string sugar tools: { + ...fromPlugin(analytics), // all analytics tools + ...fromPlugin(files, { only: ["uploads.read"] }), // filtered subset get_weather: tool({ + name: "get_weather", description: "Weather", schema: z.object({ city: z.string() }), execute: async ({ city }) => `Sunny in ${city}`, }), - ...analyticsP.toolkit(), // spread plugin tools - ...filesP.toolkit({ only: ["uploads.read"] }), // filtered }, }); await createApp({ - plugins: [server(), analyticsP, filesP, agents({ agents: { support } })], + plugins: [server(), analytics(), files(), agents({ agents: { support } })], }); ``` -Code-defined agents start with no tools by default. Spread `.toolkit()` outputs into `tools: { ... }` explicitly. The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. +Code-defined agents start with no tools by default. `fromPlugin(factory)` is the primary way to pull in a plugin's tools — it returns a spread-friendly marker that the agents plugin resolves against registered `ToolProvider`s at setup time. No intermediate variable, no duplicate `plugins: [analyticsP, filesP, ...]` dance: you write the factory reference once inside `fromPlugin` and again in `plugins: [...]`. + +The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. + +### Scoping tools in code + +`fromPlugin(factory, opts?)` accepts the same `ToolkitOptions` as markdown frontmatter: + +| Option | Example | Meaning | +|---|---|---| +| `only` | `{ only: ["query"] }` | Allowlist of local tool names | +| `except` | `{ except: ["legacy"] }` | Denylist of local tool names | +| `prefix` | `{ prefix: "" }` | Drop the `${pluginName}.` prefix | +| `rename` | `{ rename: { query: "q" } }` | Remap specific local names | + +For plugins that don't expose a `.toolkit()` method (e.g., third-party `ToolProvider` plugins authored with plain `toPlugin`), `fromPlugin` falls back to walking `getAgentTools()` and synthesizing namespaced keys (`${pluginName}.${localName}`). The fallback respects `only` / `except` / `rename` / `prefix` the same way. + +If a referenced plugin is not registered in `createApp({ plugins })`, the agents plugin throws at setup with an `Available: …` listing so you can fix the wiring before the first request. + +### Using `.toolkit()` directly (advanced) + +`.toolkit()` is still available on `analytics()`, `files()`, `genie()`, and `lakebase()` handles. Use it when you need to rename tools individually or bind them under a custom record key — anything `fromPlugin` can't express. In the common case, prefer `fromPlugin`. ## Level 4: sub-agents @@ -156,7 +176,24 @@ for (const ticket of tickets) { } ``` -`runAgent` drives the adapter without `createApp` or HTTP. Limitation: plugin toolkits (`ToolkitEntry`) require a live `PluginContext`, so they only work when invoked through `agents()` + `createApp`. Inline `tool()` and `mcpServer()` both work standalone. +`runAgent` drives the adapter without `createApp` or HTTP. Inline `tool()` calls work standalone as shown above. To use plugin tools in standalone mode, pass the plugin factories through `RunAgentInput.plugins` — `runAgent` will resolve any `fromPlugin` markers in the def against that list: + +```ts +import { analytics, createAgent, fromPlugin, runAgent } from "@databricks/appkit"; + +const classifier = createAgent({ + instructions: "Classify tickets. Use analytics.query for historical data.", + model: "databricks-claude-sonnet-4-5", + tools: { ...fromPlugin(analytics) }, +}); + +const result = await runAgent(classifier, { + messages: "is ticket 42 a duplicate?", + plugins: [analytics()], +}); +``` + +Hosted tools (MCP) are still `agents()`-only since they require the live MCP client. Plugin tool dispatch in standalone mode runs as the service principal (no OBO) since there is no HTTP request. ## Configuration reference diff --git a/packages/appkit/src/core/run-agent.ts b/packages/appkit/src/core/run-agent.ts index e83c2c9c..491a8ea2 100644 --- a/packages/appkit/src/core/run-agent.ts +++ b/packages/appkit/src/core/run-agent.ts @@ -3,8 +3,13 @@ import type { AgentAdapter, AgentEvent, AgentToolDefinition, + BasePlugin, Message, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; +import { isFromPluginMarker } from "../plugins/agents/from-plugin"; import { type FunctionTool, functionToolToDefinition, @@ -15,6 +20,7 @@ import type { AgentDefinition, AgentTool, ToolkitEntry, + ToolkitOptions, } from "../plugins/agents/types"; import { isToolkitEntry } from "../plugins/agents/types"; @@ -23,6 +29,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` reuses eagerly-constructed instances + * (from `toPluginWithInstance`) and constructs fresh ones for plain + * `toPlugin` factories. + */ + plugins?: PluginData[]; } export interface RunAgentResult { @@ -39,11 +53,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 +66,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 +77,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 +93,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 +181,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; + + 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; - for (const [key, tool] of Object.entries(def.tools ?? {})) { - index.set(key, classifyTool(key, tool)); + const provider = resolveStandaloneProvider( + marker.pluginName, + plugins, + providerCache, + ); + const entries = synthesizeToolkit( + 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 +267,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 +288,101 @@ 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 preBuilt = (match as { instance?: BasePlugin }).instance; + const instance = + preBuilt ?? 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; +} + +function synthesizeToolkit( + pluginName: string, + provider: ToolProvider, + opts?: ToolkitOptions, +): Record { + const withToolkit = provider as ToolProvider & { + toolkit?: (opts?: ToolkitOptions) => Record; + }; + 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/index.ts b/packages/appkit/src/index.ts index 654e3fbc..f78df631 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -92,8 +92,12 @@ export { type AgentDefinition, type AgentsPluginConfig, type AgentTool, + type AgentTools, agents, type BaseSystemPromptOption, + type FromPluginMarker, + fromPlugin, + isFromPluginMarker, isToolkitEntry, loadAgentFromFile, loadAgentsFromDir, diff --git a/packages/appkit/src/plugin/index.ts b/packages/appkit/src/plugin/index.ts index 3ea1f553..59a87b36 100644 --- a/packages/appkit/src/plugin/index.ts +++ b/packages/appkit/src/plugin/index.ts @@ -1,4 +1,8 @@ export type { ToPlugin } from "shared"; export type { ExecutionResult } from "./execution-result"; export { Plugin } from "./plugin"; -export { toPlugin, toPluginWithInstance } from "./to-plugin"; +export { + type NamedPluginFactory, + toPlugin, + toPluginWithInstance, +} from "./to-plugin"; diff --git a/packages/appkit/src/plugin/to-plugin.ts b/packages/appkit/src/plugin/to-plugin.ts index 0add3f4d..56ea3e03 100644 --- a/packages/appkit/src/plugin/to-plugin.ts +++ b/packages/appkit/src/plugin/to-plugin.ts @@ -5,6 +5,15 @@ import type { ToPlugin, } from "shared"; +/** + * Factory function produced by `toPlugin` / `toPluginWithInstance`. Carries a + * static `pluginName` field so tooling (e.g. `fromPlugin`) can identify which + * plugin a factory references without constructing an instance. + */ +export type NamedPluginFactory = { + readonly pluginName: Name; +}; + /** * Wraps a plugin class so it can be passed to createApp with optional config. * Infers config type from the constructor and plugin name from the static `name` property. @@ -13,14 +22,22 @@ import type { */ export function toPlugin( plugin: T, -): ToPlugin[0], T["manifest"]["name"]> { +): ToPlugin[0], T["manifest"]["name"]> & + NamedPluginFactory { type Config = ConstructorParameters[0]; type Name = T["manifest"]["name"]; - return (config: Config = {} as Config): PluginData => ({ + const pluginName = plugin.manifest.name as Name; + const factory = (config: Config = {} as Config): PluginData => ({ plugin: plugin as T, config: config as Config, - name: plugin.manifest.name as Name, + name: pluginName, + }); + Object.defineProperty(factory, "pluginName", { + value: pluginName, + writable: false, + enumerable: true, }); + return factory as ToPlugin & NamedPluginFactory; } /** @@ -41,13 +58,14 @@ export function toPluginWithInstance< type Instance = InstanceType; type Exposed = Pick; - return ( + const pluginName = plugin.manifest.name as Name; + + const factory = ( config: Config = {} as Config, ): PluginData & { instance: BasePlugin; } & Exposed => { - const name = plugin.manifest.name as Name; - const instance = new plugin({ ...(config ?? {}), name }) as Instance; + const instance = new plugin({ ...(config ?? {}), name: pluginName }) as Instance; const exposed: Record = {}; for (const methodName of expose) { @@ -62,9 +80,16 @@ export function toPluginWithInstance< return { plugin: plugin as T, config: config as Config, - name, + name: pluginName, instance: instance as unknown as BasePlugin, ...(exposed as Exposed), }; }; + + Object.defineProperty(factory, "pluginName", { + value: pluginName, + writable: false, + enumerable: true, + }); + return factory as typeof factory & NamedPluginFactory; } diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 03b9257c..076fe9c7 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -19,6 +19,7 @@ import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; import { agentStreamDefaults } from "./defaults"; import { AgentEventTranslator } from "./event-translator"; +import { isFromPluginMarker } from "./from-plugin"; import { loadAgentsFromDir } from "./load-agents"; import manifest from "./manifest.json"; import { chatRequestSchema, invocationsRequestSchema } from "./schemas"; @@ -39,6 +40,8 @@ import type { PromptContext, RegisteredAgent, ResolvedToolEntry, + ToolkitEntry, + ToolkitOptions, } from "./types"; import { isToolkitEntry } from "./types"; @@ -253,7 +256,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; @@ -292,9 +299,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", @@ -338,31 +349,13 @@ 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; - index.set(key, { - source: "toolkit", - pluginName: maybeEntry.pluginName, - localName: maybeEntry.localName, - def: { ...maybeEntry.def, name: key }, - }); - } - continue; - } - // Fallback: providers without a toolkit() still expose getAgentTools(); - // dispatch goes through PluginContext.executeTool by plugin name. - for (const tool of provider.getAgentTools()) { - const qualifiedName = `${pluginName}.${tool.name}`; - index.set(qualifiedName, { + const entries = resolveToolkitFromProvider(pluginName, provider); + for (const [key, entry] of Object.entries(entries)) { + index.set(key, { source: "toolkit", - pluginName, - localName: tool.name, - def: { ...tool, name: qualifiedName }, + pluginName: entry.pluginName, + localName: entry.localName, + def: { ...entry.def, name: key }, }); } } @@ -376,6 +369,52 @@ 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, @@ -952,6 +991,49 @@ function normalizeAutoInherit(value: AgentsPluginConfig["autoInheritTools"]): { return { file: value.file ?? true, code: value.code ?? false }; } +/** + * Extract a plugin's toolkit as a keyed record of `ToolkitEntry`s. Prefers the + * plugin's own `.toolkit(opts)` method; falls back to walking `getAgentTools()` + * and synthesizing namespaced keys (`${pluginName}.${localName}`) with + * `only` / `except` filtering. Shared between `applyAutoInherit` and + * `resolveFromPluginMarkers` so both paths treat third-party ToolProviders + * without `.toolkit()` the same way. + */ +function resolveToolkitFromProvider( + pluginName: string, + provider: ToolProvider, + opts?: ToolkitOptions, +): Record { + const withToolkit = provider as ToolProvider & { + toolkit?: (opts?: ToolkitOptions) => Record; + }; + 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; +} + function composePromptForAgent( registered: RegisteredAgent, pluginLevel: BaseSystemPromptOption | undefined, 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..041a9fa4 --- /dev/null +++ b/packages/appkit/src/plugins/agents/from-plugin.ts @@ -0,0 +1,93 @@ +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` / `toPluginWithInstance` (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` or + * `toPluginWithInstance`. 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() or toPluginWithInstance().", + ); + } + 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 1adc41c1..7adc49ff 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 { type LoadContext, type LoadResult, @@ -11,6 +18,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 8116551e..b2152b61 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 }>; @@ -286,4 +294,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..213dd386 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,95 @@ 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; + + 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/types.ts b/packages/appkit/src/plugins/agents/types.ts index 4963a52a..37c322e6 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"; @@ -54,6 +55,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; @@ -66,7 +77,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. */