From 0168ba510446764ce107ff3e28f064973627fb7f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:52:50 +0200 Subject: [PATCH 1/6] 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/beta.ts | 4 + packages/appkit/src/core/agent/from-plugin.ts | 97 +++++++++ packages/appkit/src/core/agent/run-agent.ts | 145 +++++++++++- .../src/core/agent/tests/run-agent.test.ts | 98 ++++++++- .../appkit/src/core/agent/toolkit-resolver.ts | 62 ++++++ packages/appkit/src/core/agent/types.ts | 13 +- packages/appkit/src/plugins/agents/agents.ts | 99 ++++++--- packages/appkit/src/plugins/agents/index.ts | 8 + .../agents/tests/agents-plugin.test.ts | 206 ++++++++++++++++++ .../plugins/agents/tests/from-plugin.test.ts | 80 +++++++ 10 files changed, 773 insertions(+), 39 deletions(-) create mode 100644 packages/appkit/src/core/agent/from-plugin.ts create mode 100644 packages/appkit/src/core/agent/toolkit-resolver.ts create mode 100644 packages/appkit/src/plugins/agents/tests/from-plugin.test.ts diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 562cfd43a..9295a43ed 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -52,8 +52,10 @@ export type { AgentDefinition, AgentsPluginConfig, AgentTool, + AgentTools, AutoInheritToolsConfig, BaseSystemPromptOption, + FromPluginMarker, PromptContext, RegisteredAgent, ResolvedToolEntry, @@ -62,6 +64,8 @@ export type { } from "./plugins/agents"; export { agentIdFromMarkdownPath, + fromPlugin, + isFromPluginMarker, isToolkitEntry, loadAgentFromFile, loadAgentsFromDir, diff --git a/packages/appkit/src/core/agent/from-plugin.ts b/packages/appkit/src/core/agent/from-plugin.ts new file mode 100644 index 000000000..b11285941 --- /dev/null +++ b/packages/appkit/src/core/agent/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/core/agent/run-agent.ts b/packages/appkit/src/core/agent/run-agent.ts index 9b5f57a2d..1eb510742 100644 --- a/packages/appkit/src/core/agent/run-agent.ts +++ b/packages/appkit/src/core/agent/run-agent.ts @@ -4,8 +4,13 @@ import type { AgentEvent, AgentToolDefinition, Message, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; import { consumeAdapterStream } from "./consume-adapter-stream"; +import { isFromPluginMarker } from "./from-plugin"; +import { resolveToolkitFromProvider } from "./toolkit-resolver"; import { type FunctionTool, functionToolToDefinition, @@ -20,6 +25,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 { @@ -36,11 +49,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, @@ -48,7 +62,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; @@ -59,6 +73,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: @@ -68,13 +89,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(...)] }).", ); }; @@ -154,20 +176,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 ?? {})) { @@ -199,7 +262,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 { @@ -220,3 +283,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/core/agent/tests/run-agent.test.ts b/packages/appkit/src/core/agent/tests/run-agent.test.ts index f94e8b7bd..5324dde21 100644 --- a/packages/appkit/src/core/agent/tests/run-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/run-agent.test.ts @@ -3,13 +3,18 @@ import type { AgentEvent, AgentInput, AgentRunContext, + AgentToolDefinition, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import { tool } from "../../../core/agent/tools/tool"; import type { ToolkitEntry } from "../../../core/agent/types"; import { createAgent } from "../create-agent"; +import { fromPlugin } from "../from-plugin"; import { runAgent } from "../run-agent"; +import { tool } from "../tools/tool"; function scriptedAdapter(events: AgentEvent[]): AgentAdapter { return { @@ -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/core/agent/toolkit-resolver.ts b/packages/appkit/src/core/agent/toolkit-resolver.ts new file mode 100644 index 000000000..8ec8cf1f7 --- /dev/null +++ b/packages/appkit/src/core/agent/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/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 50e527523..baa15967f 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -6,6 +6,7 @@ import type { ToolAnnotations, } from "shared"; import type { McpHostPolicyConfig } from "../../connectors/mcp"; +import type { FromPluginMarker } from "./from-plugin"; import type { FunctionTool } from "./tools/function-tool"; import type { HostedTool } from "./tools/hosted-tools"; @@ -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. */ diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index b0a64d3c2..78c2d868b 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -16,13 +16,16 @@ import type { ToolProvider, } from "shared"; import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; +import { getWorkspaceClient } from "../../context"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; +import { isFromPluginMarker } from "../../core/agent/from-plugin"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; import { normalizeToolResult } from "../../core/agent/normalize-result"; import { buildBaseSystemPrompt, composeSystemPrompt, } from "../../core/agent/system-prompt"; +import { resolveToolkitFromProvider } from "../../core/agent/toolkit-resolver"; import { functionToolToDefinition, isFunctionTool, @@ -408,7 +411,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; @@ -447,10 +454,14 @@ 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("../../core/agent/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", @@ -502,32 +513,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); } } @@ -555,6 +553,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("../../core/agent/tools/hosted-tools").HostedTool[], index: Map, diff --git a/packages/appkit/src/plugins/agents/index.ts b/packages/appkit/src/plugins/agents/index.ts index 0c8964109..404da2432 100644 --- a/packages/appkit/src/plugins/agents/index.ts +++ b/packages/appkit/src/plugins/agents/index.ts @@ -1,4 +1,11 @@ export { buildToolkitEntries } from "../../core/agent/build-toolkit"; +export { + FROM_PLUGIN_MARKER, + type FromPluginMarker, + type FromPluginSpread, + fromPlugin, + isFromPluginMarker, +} from "../../core/agent/from-plugin"; export { agentIdFromMarkdownPath, type LoadContext, @@ -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 c42cc97f8..42cb6b127 100644 --- a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -12,10 +12,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { CacheManager } from "../../../cache"; import { buildToolkitEntries } from "../../../core/agent/build-toolkit"; +import { fromPlugin } from "../../../core/agent/from-plugin"; import { defineTool, type ToolRegistry, } from "../../../core/agent/tools/define-tool"; +import { tool } from "../../../core/agent/tools/tool"; import type { AgentsPluginConfig, ToolkitEntry, @@ -24,6 +26,12 @@ import { isToolkitEntry } from "../../../core/agent/types"; // Import the class directly so we can construct it without a createApp import { AgentsPlugin } from "../agents"; +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 }>; @@ -370,4 +378,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 000000000..eb31d0f7d --- /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 "../../../core/agent/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); + }); +}); From 36f5a2b0ac2894a13d625f30d9719600943361bf Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 4 May 2026 14:59:08 +0200 Subject: [PATCH 2/6] fix(agents): update agents.ts imports to core/agent/ paths after Option A rewrite normalize-result, consume-adapter-stream, tool-dispatch were extracted to core/agent/ but agents.ts still imported them from plugins/agents/. Update the import paths to match the final file locations. --- packages/appkit/src/plugins/agents/agents.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 78c2d868b..11deef3a9 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -25,6 +25,7 @@ import { buildBaseSystemPrompt, composeSystemPrompt, } from "../../core/agent/system-prompt"; +import { dispatchToolCall } from "../../core/agent/tool-dispatch"; import { resolveToolkitFromProvider } from "../../core/agent/toolkit-resolver"; import { functionToolToDefinition, From 7b4bcba778a8f6af6b16b35a2bc6af38f0f8eb29 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 29 Apr 2026 18:19:25 +0200 Subject: [PATCH 3/6] refactor(appkit): move agent runtime to core/agent/ Flips the layering: agent types, helpers, and the standalone runner now live in core/agent/ instead of plugins/agents/. The HTTP-facing agents() plugin still owns its routes/streaming/threads but no longer re-exports framework primitives that peer plugins depend on. Moved (with git mv to preserve history): - plugins/agents/{types,from-plugin,build-toolkit,toolkit-resolver, consume-adapter-stream,normalize-result,tool-dispatch,system-prompt, load-agents}.ts -> core/agent/ - plugins/agents/tools/{tool,define-tool,function-tool,hosted-tools, sql-policy,json-schema,index}.ts -> core/agent/tools/ - core/{run-agent,create-agent-def}.ts -> core/agent/{run-agent,create-agent}.ts - 14 corresponding test files -> core/agent/tests/ Stayed in plugins/agents/ (HTTP/route concerns): - agents.ts, event-channel.ts, event-translator.ts, tool-approval-gate.ts, thread-store.ts, schemas.ts, defaults.ts, manifest.json, index.ts Updated imports across analytics, files, genie, lakebase to source from core/agent/ directly. plugins/agents/index.ts stays as a back-compat barrel that re-exports the moved primitives, so the public package surface (@databricks/appkit) is byte-identical. Verified: tsc --noEmit clean, 1581/1581 appkit tests pass. --- knip.json | 10 +-- packages/appkit/src/connectors/mcp/types.ts | 2 +- packages/appkit/src/core/agent/index.ts | 63 +++++++++++++++++++ .../src/core/agent/tests/create-agent.test.ts | 4 +- .../agent}/tests/from-plugin.test.ts | 0 .../agent}/tests/mcp-server-helper.test.ts | 0 .../src/core/agent/tests/run-agent.test.ts | 2 +- packages/appkit/src/plugins/agents/agents.ts | 1 - packages/appkit/src/plugins/agents/index.ts | 3 + 9 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 packages/appkit/src/core/agent/index.ts rename packages/appkit/src/{plugins/agents => core/agent}/tests/from-plugin.test.ts (100%) rename packages/appkit/src/{plugins/agents => core/agent}/tests/mcp-server-helper.test.ts (100%) diff --git a/knip.json b/knip.json index f530b3918..0a2351b79 100644 --- a/knip.json +++ b/knip.json @@ -19,15 +19,15 @@ "packages/appkit/src/plugin/index.ts", "packages/appkit/src/plugin/to-plugin.ts", "packages/appkit/src/plugins/agents/index.ts", - "template/**", - "tools/**", - "docs/**", - ".github/scripts/**", + "packages/appkit/src/core/agent/index.ts", "packages/appkit/src/core/agent/tools/index.ts", "packages/appkit/src/core/agent/from-plugin.ts", "packages/appkit/src/core/agent/load-agents.ts", "packages/appkit/src/connectors/mcp/index.ts", - "packages/appkit/src/plugin/to-plugin.ts" + "template/**", + "tools/**", + "docs/**", + ".github/scripts/**" ], "ignoreDependencies": ["json-schema-to-typescript"], "ignoreBinaries": ["tarball"] diff --git a/packages/appkit/src/connectors/mcp/types.ts b/packages/appkit/src/connectors/mcp/types.ts index d74f0a46c..aeb61788e 100644 --- a/packages/appkit/src/connectors/mcp/types.ts +++ b/packages/appkit/src/connectors/mcp/types.ts @@ -1,7 +1,7 @@ /** * Input shape consumed by {@link AppKitMcpClient.connect}. Produced by the * agents plugin from user-facing `HostedTool` declarations (see - * `plugins/agents/tools/hosted-tools.ts`) and accepted directly by the + * `core/agent/tools/hosted-tools.ts`) and accepted directly by the * connector to keep its surface free of agent-layer concepts. */ export interface McpEndpointConfig { diff --git a/packages/appkit/src/core/agent/index.ts b/packages/appkit/src/core/agent/index.ts new file mode 100644 index 000000000..96fb3eb8c --- /dev/null +++ b/packages/appkit/src/core/agent/index.ts @@ -0,0 +1,63 @@ +/** + * Agent runtime primitives. All framework-level agent types, tool helpers, + * and the standalone runner live here. The HTTP-facing `agents()` plugin in + * `plugins/agents/` consumes these but does not own them — peer plugins + * (analytics, files, genie, lakebase) can depend on this module without + * reaching across the sibling boundary. + */ +export { buildToolkitEntries } from "./build-toolkit"; +export { consumeAdapterStream } from "./consume-adapter-stream"; +export { createAgent } from "./create-agent"; +export { + FROM_PLUGIN_MARKER, + type FromPluginMarker, + type FromPluginSpread, + fromPlugin, + isFromPluginMarker, +} from "./from-plugin"; +export { + agentIdFromMarkdownPath, + type LoadContext, + type LoadResult, + loadAgentFromFile, + loadAgentsFromDir, + parseFrontmatter, +} from "./load-agents"; +export { normalizeToolResult } from "./normalize-result"; +export { + type RunAgentInput, + type RunAgentResult, + runAgent, +} from "./run-agent"; +export { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt"; +export { resolveToolkitFromProvider } from "./toolkit-resolver"; +export { + defineTool, + executeFromRegistry, + type FunctionTool, + functionToolToDefinition, + type HostedTool, + isFunctionTool, + isHostedTool, + mcpServer, + resolveHostedTools, + type ToolConfig, + type ToolEntry, + type ToolRegistry, + tool, + toolsFromRegistry, +} from "./tools"; +export { + type AgentDefinition, + type AgentsPluginConfig, + type AgentTool, + type AgentTools, + type AutoInheritToolsConfig, + type BaseSystemPromptOption, + isToolkitEntry, + type PromptContext, + type RegisteredAgent, + type ResolvedToolEntry, + type ToolkitEntry, + type ToolkitOptions, +} from "./types"; diff --git a/packages/appkit/src/core/agent/tests/create-agent.test.ts b/packages/appkit/src/core/agent/tests/create-agent.test.ts index a095439e5..df920369e 100644 --- a/packages/appkit/src/core/agent/tests/create-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/create-agent.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; -import { tool } from "../../../core/agent/tools/tool"; -import type { AgentDefinition } from "../../../core/agent/types"; import { createAgent } from "../create-agent"; +import { tool } from "../tools/tool"; +import type { AgentDefinition } from "../types"; describe("createAgent", () => { test("returns the definition unchanged for a simple agent", () => { diff --git a/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts b/packages/appkit/src/core/agent/tests/from-plugin.test.ts similarity index 100% rename from packages/appkit/src/plugins/agents/tests/from-plugin.test.ts rename to packages/appkit/src/core/agent/tests/from-plugin.test.ts diff --git a/packages/appkit/src/plugins/agents/tests/mcp-server-helper.test.ts b/packages/appkit/src/core/agent/tests/mcp-server-helper.test.ts similarity index 100% rename from packages/appkit/src/plugins/agents/tests/mcp-server-helper.test.ts rename to packages/appkit/src/core/agent/tests/mcp-server-helper.test.ts diff --git a/packages/appkit/src/core/agent/tests/run-agent.test.ts b/packages/appkit/src/core/agent/tests/run-agent.test.ts index 5324dde21..9ead2c177 100644 --- a/packages/appkit/src/core/agent/tests/run-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/run-agent.test.ts @@ -10,11 +10,11 @@ import type { } from "shared"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import type { ToolkitEntry } from "../../../core/agent/types"; import { createAgent } from "../create-agent"; import { fromPlugin } from "../from-plugin"; import { runAgent } from "../run-agent"; import { tool } from "../tools/tool"; +import type { ToolkitEntry } from "../types"; function scriptedAdapter(events: AgentEvent[]): AgentAdapter { return { diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 11deef3a9..78c2d868b 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -25,7 +25,6 @@ import { buildBaseSystemPrompt, composeSystemPrompt, } from "../../core/agent/system-prompt"; -import { dispatchToolCall } from "../../core/agent/tool-dispatch"; import { resolveToolkitFromProvider } from "../../core/agent/toolkit-resolver"; import { functionToolToDefinition, diff --git a/packages/appkit/src/plugins/agents/index.ts b/packages/appkit/src/plugins/agents/index.ts index 404da2432..f630cc681 100644 --- a/packages/appkit/src/plugins/agents/index.ts +++ b/packages/appkit/src/plugins/agents/index.ts @@ -1,3 +1,6 @@ +// Re-exports of agent primitives that now live in core/agent/. Kept here so +// the public package barrel (`@databricks/appkit`) and any callers that +// already imported via `./plugins/agents` continue to resolve unchanged. export { buildToolkitEntries } from "../../core/agent/build-toolkit"; export { FROM_PLUGIN_MARKER, From 75617affe1f670cbf8891355d72b5da3d305743d Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 29 Apr 2026 18:41:56 +0200 Subject: [PATCH 4/6] refactor(appkit): split agents.ts helpers into separate modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts `composePromptForAgent` + `normalizeAutoInherit` into plugins/agents/prompt.ts and `printRegistry` into plugins/agents/registry-printer.ts. These were free-function helpers at the bottom of agents.ts with no dependency on plugin state — pure candidates for extraction. Also opens the door for the bigger split (route handlers and `_streamAgent`/`runSubAgent` extracted into routes/*.ts and tool-execution.ts) by relaxing the access modifier on plugin members those modules will need (`agents`, `activeStreams`, `mcpClient`, `threadStore`, `approvalGate`, `resolvedApprovalPolicy`, `resolvedLimits`, `countUserStreams`). All marked `@internal` to keep the public surface unchanged. Note: the full split into `routes/` and `tool-execution.ts` proposed in plans/agent-architecture-followup.md is deferred. Route handlers and `_streamAgent`/`runSubAgent` remain as methods on AgentsPlugin because they have heavy plugin-state coupling and cross-call patterns (`runSubAgent` recurses, `_handleChat` calls `_streamAgent`, etc.) that don't translate cleanly to free functions without a larger refactor. Tracked as a follow-up. agents.ts: 1262 -> 1212 lines (-50). The plan's aspirational target of <=280 isn't met because the per-route extraction pass is deferred, but the helper extraction + access-modifier relaxation lays the groundwork. Verified: tsc --noEmit clean, 1589/1589 appkit tests pass. --- packages/appkit/src/plugins/agents/prompt.ts | 57 +++++++++++++++++++ .../src/plugins/agents/registry-printer.ts | 25 ++++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/appkit/src/plugins/agents/prompt.ts create mode 100644 packages/appkit/src/plugins/agents/registry-printer.ts diff --git a/packages/appkit/src/plugins/agents/prompt.ts b/packages/appkit/src/plugins/agents/prompt.ts new file mode 100644 index 000000000..d38c6e645 --- /dev/null +++ b/packages/appkit/src/plugins/agents/prompt.ts @@ -0,0 +1,57 @@ +import { + buildBaseSystemPrompt, + composeSystemPrompt, +} from "../../core/agent/system-prompt"; +import type { + AgentsPluginConfig, + BaseSystemPromptOption, + PromptContext, + RegisteredAgent, +} from "../../core/agent/types"; + +/** + * Resolves the per-agent and plugin-level base prompt options into the + * final system prompt sent to the adapter. Per-agent setting wins over + * plugin-level; `false` opts out entirely; functions receive the same + * `PromptContext` that the default builder uses. + */ +export function composePromptForAgent( + registered: RegisteredAgent, + pluginLevel: BaseSystemPromptOption | undefined, + ctx: PromptContext, +): string { + const perAgent = registered.baseSystemPrompt; + const resolved = perAgent !== undefined ? perAgent : pluginLevel; + + let base = ""; + if (resolved === false) { + base = ""; + } else if (typeof resolved === "string") { + base = resolved; + } else if (typeof resolved === "function") { + base = resolved(ctx); + } else { + base = buildBaseSystemPrompt(ctx); + } + + return composeSystemPrompt(base, registered.instructions); +} + +/** + * Resolves the plugin-level `autoInheritTools` config into a per-origin + * decision. Default is opt-out for both origins. A markdown agent or + * code-defined agent with no declared `tools:` gets an empty tool index + * unless the developer explicitly flips `autoInheritTools` on. Even then, + * only tools whose plugin author marked `autoInheritable: true` are + * spread — see `applyAutoInherit` for the filter. + */ +export function normalizeAutoInherit( + value: AgentsPluginConfig["autoInheritTools"], +): { + file: boolean; + code: boolean; +} { + if (value === undefined) return { file: false, code: false }; + if (typeof value === "boolean") return { file: value, code: value }; + return { file: value.file ?? false, code: value.code ?? false }; +} diff --git a/packages/appkit/src/plugins/agents/registry-printer.ts b/packages/appkit/src/plugins/agents/registry-printer.ts new file mode 100644 index 000000000..9231ee077 --- /dev/null +++ b/packages/appkit/src/plugins/agents/registry-printer.ts @@ -0,0 +1,25 @@ +import pc from "picocolors"; +import type { RegisteredAgent } from "../../core/agent/types"; + +/** + * Pretty-prints the registered agent set during plugin setup. Decorative — + * no behaviour change if it's skipped (e.g., from tests). + */ +export function printRegistry( + agents: Map, + defaultAgentName: string | null, +): void { + if (agents.size === 0) return; + console.log(""); + console.log(` ${pc.bold("Agents")} ${pc.dim(`(${agents.size})`)}`); + console.log(` ${pc.dim("─".repeat(60))}`); + for (const [name, reg] of agents) { + const tools = reg.toolIndex.size; + const marker = name === defaultAgentName ? pc.green("●") : " "; + console.log( + ` ${marker} ${pc.bold(name.padEnd(24))} ${pc.dim(`${tools} tools`)}`, + ); + } + console.log(` ${pc.dim("─".repeat(60))}`); + console.log(""); +} From 997f2b52d2e8ca95860fbdfcb98cbb995277ed34 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 4 May 2026 19:50:25 +0200 Subject: [PATCH 5/6] feat(appkit): unify on DATABRICKS_SERVING_ENDPOINT_NAME (SDK + template manifest) Signed-off-by: MarioCadenas --- packages/appkit/src/plugins/agents/agents.ts | 2 +- packages/appkit/src/plugins/agents/manifest.json | 6 +++--- template/appkit.plugins.json | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 78c2d868b..714b1643c 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -388,7 +388,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { ); } catch (err) { throw new Error( - `Agent '${name}' has no model configured and no DATABRICKS_AGENT_ENDPOINT default available`, + `Agent '${name}' has no model configured and no DATABRICKS_SERVING_ENDPOINT_NAME default available`, { cause: err instanceof Error ? err : undefined }, ); } diff --git a/packages/appkit/src/plugins/agents/manifest.json b/packages/appkit/src/plugins/agents/manifest.json index 4571031a8..01e843a85 100644 --- a/packages/appkit/src/plugins/agents/manifest.json +++ b/packages/appkit/src/plugins/agents/manifest.json @@ -11,12 +11,12 @@ "type": "serving_endpoint", "alias": "Model Serving (agents)", "resourceKey": "agents-serving-endpoint", - "description": "Databricks Model Serving endpoint for agents using workspace-hosted models (`DatabricksAdapter.fromModelServing`). Wire the same endpoint name AppKit reads from `DATABRICKS_AGENT_ENDPOINT` when no per-agent model is configured. Omit when agents use only external adapters.", + "description": "Databricks Model Serving endpoint for agents using workspace-hosted models (`DatabricksAdapter.fromModelServing`). Wire the same endpoint name AppKit reads from `DATABRICKS_SERVING_ENDPOINT_NAME` when no per-agent model is configured. The same env var the `serving` plugin reads — one value covers both. Omit when agents use only external adapters.", "permission": "CAN_QUERY", "fields": { "name": { - "env": "DATABRICKS_AGENT_ENDPOINT", - "description": "Endpoint name passed to Model Serving when agents default to `DatabricksAdapter.fromModelServing()`" + "env": "DATABRICKS_SERVING_ENDPOINT_NAME", + "description": "Endpoint name passed to Model Serving when agents default to `DatabricksAdapter.fromModelServing()`. Shared with the `serving` plugin." } } } diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 6e9378c6a..131ccbbf8 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -14,12 +14,12 @@ "type": "serving_endpoint", "alias": "Model Serving (agents)", "resourceKey": "agents-serving-endpoint", - "description": "Databricks Model Serving endpoint for agents using workspace-hosted models (`DatabricksAdapter.fromModelServing`). Wire the same endpoint name AppKit reads from `DATABRICKS_AGENT_ENDPOINT` when no per-agent model is configured. Omit when agents use only external adapters.", + "description": "Databricks Model Serving endpoint for agents using workspace-hosted models (`DatabricksAdapter.fromModelServing`). Wire the same endpoint name AppKit reads from `DATABRICKS_SERVING_ENDPOINT_NAME` when no per-agent model is configured. The same env var the `serving` plugin reads — one value covers both. Omit when agents use only external adapters.", "permission": "CAN_QUERY", "fields": { "name": { - "env": "DATABRICKS_AGENT_ENDPOINT", - "description": "Endpoint name passed to Model Serving when agents default to `DatabricksAdapter.fromModelServing()`" + "env": "DATABRICKS_SERVING_ENDPOINT_NAME", + "description": "Endpoint name passed to Model Serving when agents default to `DatabricksAdapter.fromModelServing()`. Shared with the `serving` plugin." } } } From a35cd35cc3d859973abfb6de706ac82b311bde5e Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 7 May 2026 16:42:55 +0200 Subject: [PATCH 6/6] chore(appkit): drop duplicate prompt/registry-printer modules after rebase Signed-off-by: MarioCadenas --- packages/appkit/src/plugins/agents/prompt.ts | 57 ------------------- .../src/plugins/agents/registry-printer.ts | 25 -------- 2 files changed, 82 deletions(-) delete mode 100644 packages/appkit/src/plugins/agents/prompt.ts delete mode 100644 packages/appkit/src/plugins/agents/registry-printer.ts diff --git a/packages/appkit/src/plugins/agents/prompt.ts b/packages/appkit/src/plugins/agents/prompt.ts deleted file mode 100644 index d38c6e645..000000000 --- a/packages/appkit/src/plugins/agents/prompt.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - buildBaseSystemPrompt, - composeSystemPrompt, -} from "../../core/agent/system-prompt"; -import type { - AgentsPluginConfig, - BaseSystemPromptOption, - PromptContext, - RegisteredAgent, -} from "../../core/agent/types"; - -/** - * Resolves the per-agent and plugin-level base prompt options into the - * final system prompt sent to the adapter. Per-agent setting wins over - * plugin-level; `false` opts out entirely; functions receive the same - * `PromptContext` that the default builder uses. - */ -export function composePromptForAgent( - registered: RegisteredAgent, - pluginLevel: BaseSystemPromptOption | undefined, - ctx: PromptContext, -): string { - const perAgent = registered.baseSystemPrompt; - const resolved = perAgent !== undefined ? perAgent : pluginLevel; - - let base = ""; - if (resolved === false) { - base = ""; - } else if (typeof resolved === "string") { - base = resolved; - } else if (typeof resolved === "function") { - base = resolved(ctx); - } else { - base = buildBaseSystemPrompt(ctx); - } - - return composeSystemPrompt(base, registered.instructions); -} - -/** - * Resolves the plugin-level `autoInheritTools` config into a per-origin - * decision. Default is opt-out for both origins. A markdown agent or - * code-defined agent with no declared `tools:` gets an empty tool index - * unless the developer explicitly flips `autoInheritTools` on. Even then, - * only tools whose plugin author marked `autoInheritable: true` are - * spread — see `applyAutoInherit` for the filter. - */ -export function normalizeAutoInherit( - value: AgentsPluginConfig["autoInheritTools"], -): { - file: boolean; - code: boolean; -} { - if (value === undefined) return { file: false, code: false }; - if (typeof value === "boolean") return { file: value, code: value }; - return { file: value.file ?? false, code: value.code ?? false }; -} diff --git a/packages/appkit/src/plugins/agents/registry-printer.ts b/packages/appkit/src/plugins/agents/registry-printer.ts deleted file mode 100644 index 9231ee077..000000000 --- a/packages/appkit/src/plugins/agents/registry-printer.ts +++ /dev/null @@ -1,25 +0,0 @@ -import pc from "picocolors"; -import type { RegisteredAgent } from "../../core/agent/types"; - -/** - * Pretty-prints the registered agent set during plugin setup. Decorative — - * no behaviour change if it's skipped (e.g., from tests). - */ -export function printRegistry( - agents: Map, - defaultAgentName: string | null, -): void { - if (agents.size === 0) return; - console.log(""); - console.log(` ${pc.bold("Agents")} ${pc.dim(`(${agents.size})`)}`); - console.log(` ${pc.dim("─".repeat(60))}`); - for (const [name, reg] of agents) { - const tools = reg.toolIndex.size; - const marker = name === defaultAgentName ? pc.green("●") : " "; - console.log( - ` ${marker} ${pc.bold(name.padEnd(24))} ${pc.dim(`${tools} tools`)}`, - ); - } - console.log(` ${pc.dim("─".repeat(60))}`); - console.log(""); -}