Skip to content

Commit b462716

Browse files
committed
feat(appkit): fromPlugin() DX, runAgent plugins arg, shared toolkit-resolver
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. ### `fromPlugin(factory, opts?)` — the marker `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. ### `resolveToolkitFromProvider(pluginName, provider, opts?)` `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 ### `AgentsPlugin.buildToolIndex` — symbol-key resolution pass 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. ### Type plumbing - `AgentTools` type: `{ [key: string]: AgentTool } & { [key: symbol]: FromPluginMarker }`. Preserves string-key autocomplete while accepting marker spreads under strict TS. - `AgentDefinition.tools` switched to `AgentTools`. ### `runAgent` gains `plugins?: PluginData[]` `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. ### Exports `fromPlugin`, `FromPluginMarker`, `isFromPluginMarker`, `AgentTools` added to the main barrel. ### Test plan - 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 <[email protected]>
1 parent 0afea5e commit b462716

10 files changed

Lines changed: 765 additions & 37 deletions

File tree

packages/appkit/src/core/run-agent.ts

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import type {
44
AgentEvent,
55
AgentToolDefinition,
66
Message,
7+
PluginConstructor,
8+
PluginData,
9+
ToolProvider,
710
} from "shared";
11+
import { isFromPluginMarker } from "../plugins/agents/from-plugin";
12+
import { resolveToolkitFromProvider } from "../plugins/agents/toolkit-resolver";
813
import {
914
type FunctionTool,
1015
functionToolToDefinition,
@@ -23,6 +28,14 @@ export interface RunAgentInput {
2328
messages: string | Message[];
2429
/** Abort signal for cancellation. */
2530
signal?: AbortSignal;
31+
/**
32+
* Optional plugin list used to resolve `fromPlugin` markers in `def.tools`.
33+
* Required when the def contains any `...fromPlugin(factory)` spreads;
34+
* ignored otherwise. `runAgent` constructs a fresh instance per plugin
35+
* and dispatches tool calls against it as the service principal (no
36+
* OBO — there is no HTTP request in standalone mode).
37+
*/
38+
plugins?: PluginData<PluginConstructor, unknown, string>[];
2639
}
2740

2841
export interface RunAgentResult {
@@ -39,19 +52,20 @@ export interface RunAgentResult {
3952
* Limitations vs. running through the agents() plugin:
4053
* - No OBO: there is no HTTP request, so plugin tools run as the service
4154
* principal (when they work at all).
42-
* - Plugin tools (`ToolkitEntry`) are not supported — they require a live
43-
* `PluginContext` that only exists when registered in a `createApp`
44-
* instance. This function throws a clear error if encountered.
55+
* - Hosted tools (MCP) are not supported — they require a live MCP client
56+
* that only exists inside the agents plugin.
4557
* - Sub-agents (`agents: { ... }` on the def) are executed as nested
4658
* `runAgent` calls with no shared thread state.
59+
* - Plugin tools (`fromPlugin` markers or `ToolkitEntry` spreads) require
60+
* passing `plugins: [...]` via `RunAgentInput`.
4761
*/
4862
export async function runAgent(
4963
def: AgentDefinition,
5064
input: RunAgentInput,
5165
): Promise<RunAgentResult> {
5266
const adapter = await resolveAdapter(def);
5367
const messages = normalizeMessages(input.messages, def.instructions);
54-
const toolIndex = buildStandaloneToolIndex(def);
68+
const toolIndex = buildStandaloneToolIndex(def, input.plugins ?? []);
5569
const tools = Array.from(toolIndex.values()).map((e) => e.def);
5670

5771
const signal = input.signal;
@@ -62,6 +76,13 @@ export async function runAgent(
6276
if (entry.kind === "function") {
6377
return entry.tool.execute(args as Record<string, unknown>);
6478
}
79+
if (entry.kind === "toolkit") {
80+
return entry.provider.executeAgentTool(
81+
entry.localName,
82+
args as Record<string, unknown>,
83+
signal,
84+
);
85+
}
6586
if (entry.kind === "subagent") {
6687
const subInput: RunAgentInput = {
6788
messages:
@@ -71,13 +92,14 @@ export async function runAgent(
7192
? (args as { input: string }).input
7293
: JSON.stringify(args),
7394
signal,
95+
plugins: input.plugins,
7496
};
7597
const res = await runAgent(entry.agentDef, subInput);
7698
return res.text;
7799
}
78100
throw new Error(
79101
`runAgent: tool "${name}" is a ${entry.kind} tool. ` +
80-
"Plugin toolkits and MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).",
102+
"Hosted/MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).",
81103
);
82104
};
83105

@@ -158,20 +180,61 @@ type StandaloneEntry =
158180
| {
159181
kind: "toolkit";
160182
def: AgentToolDefinition;
161-
entry: ToolkitEntry;
183+
provider: ToolProvider;
184+
pluginName: string;
185+
localName: string;
162186
}
163187
| {
164188
kind: "hosted";
165189
def: AgentToolDefinition;
166190
};
167191

192+
/**
193+
* Resolves `def.tools` (string-keyed entries + symbol-keyed `fromPlugin`
194+
* markers) and `def.agents` (sub-agents) into a flat dispatch index.
195+
* Symbol-keyed markers are resolved against `plugins`; missing references
196+
* throw with an `Available: …` listing.
197+
*/
168198
function buildStandaloneToolIndex(
169199
def: AgentDefinition,
200+
plugins: PluginData<PluginConstructor, unknown, string>[],
170201
): Map<string, StandaloneEntry> {
171202
const index = new Map<string, StandaloneEntry>();
203+
const tools = def.tools;
172204

173-
for (const [key, tool] of Object.entries(def.tools ?? {})) {
174-
index.set(key, classifyTool(key, tool));
205+
const symbolKeys = tools ? Object.getOwnPropertySymbols(tools) : [];
206+
if (symbolKeys.length > 0) {
207+
const providerCache = new Map<string, ToolProvider>();
208+
for (const sym of symbolKeys) {
209+
const marker = (tools as Record<symbol, unknown>)[sym];
210+
if (!isFromPluginMarker(marker)) continue;
211+
212+
const provider = resolveStandaloneProvider(
213+
marker.pluginName,
214+
plugins,
215+
providerCache,
216+
);
217+
const entries = resolveToolkitFromProvider(
218+
marker.pluginName,
219+
provider,
220+
marker.opts,
221+
);
222+
for (const [key, entry] of Object.entries(entries)) {
223+
index.set(key, {
224+
kind: "toolkit",
225+
provider,
226+
pluginName: entry.pluginName,
227+
localName: entry.localName,
228+
def: { ...entry.def, name: key },
229+
});
230+
}
231+
}
232+
}
233+
234+
if (tools) {
235+
for (const [key, tool] of Object.entries(tools)) {
236+
index.set(key, classifyTool(key, tool));
237+
}
175238
}
176239

177240
for (const [childKey, child] of Object.entries(def.agents ?? {})) {
@@ -203,7 +266,7 @@ function buildStandaloneToolIndex(
203266

204267
function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
205268
if (isToolkitEntry(tool)) {
206-
return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool };
269+
return toolkitEntryToStandalone(key, tool);
207270
}
208271
if (isFunctionTool(tool)) {
209272
return {
@@ -224,3 +287,67 @@ function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
224287
}
225288
throw new Error(`runAgent: unrecognized tool shape at key "${key}"`);
226289
}
290+
291+
/**
292+
* Pre-`fromPlugin` code could reach a `ToolkitEntry` by calling
293+
* `.toolkit()` at module scope (which requires an instance). Those entries
294+
* still flow through `def.tools` but without a provider we can dispatch
295+
* against — runAgent cannot execute them and errors clearly.
296+
*/
297+
function toolkitEntryToStandalone(
298+
key: string,
299+
entry: ToolkitEntry,
300+
): StandaloneEntry {
301+
const def: AgentToolDefinition = { ...entry.def, name: key };
302+
return {
303+
kind: "hosted",
304+
def: {
305+
...def,
306+
description:
307+
`${def.description ?? ""} ` +
308+
`[runAgent: this ToolkitEntry refers to plugin '${entry.pluginName}' but ` +
309+
"runAgent cannot dispatch it without the plugin instance. Pass the " +
310+
"plugin via plugins: [...] and use fromPlugin(factory) instead of " +
311+
".toolkit() spreads.]".trim(),
312+
},
313+
};
314+
}
315+
316+
function resolveStandaloneProvider(
317+
pluginName: string,
318+
plugins: PluginData<PluginConstructor, unknown, string>[],
319+
cache: Map<string, ToolProvider>,
320+
): ToolProvider {
321+
const cached = cache.get(pluginName);
322+
if (cached) return cached;
323+
324+
const match = plugins.find((p) => p.name === pluginName);
325+
if (!match) {
326+
const available = plugins.map((p) => p.name).join(", ") || "(none)";
327+
throw new Error(
328+
`runAgent: agent references plugin '${pluginName}' via fromPlugin(), but ` +
329+
"that plugin is missing from RunAgentInput.plugins. " +
330+
`Available: ${available}.`,
331+
);
332+
}
333+
334+
const instance = new match.plugin({
335+
...(match.config ?? {}),
336+
name: pluginName,
337+
});
338+
const provider = instance as unknown as ToolProvider;
339+
if (
340+
typeof (provider as { getAgentTools?: unknown }).getAgentTools !==
341+
"function" ||
342+
typeof (provider as { executeAgentTool?: unknown }).executeAgentTool !==
343+
"function"
344+
) {
345+
throw new Error(
346+
`runAgent: plugin '${pluginName}' is not a ToolProvider ` +
347+
"(missing getAgentTools/executeAgentTool). Only ToolProvider plugins " +
348+
"are supported via fromPlugin() in runAgent.",
349+
);
350+
}
351+
cache.set(pluginName, provider);
352+
return provider;
353+
}

packages/appkit/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,12 @@ export {
7373
type AgentDefinition,
7474
type AgentsPluginConfig,
7575
type AgentTool,
76+
type AgentTools,
7677
agents,
7778
type BaseSystemPromptOption,
79+
type FromPluginMarker,
80+
fromPlugin,
81+
isFromPluginMarker,
7882
isToolkitEntry,
7983
loadAgentFromFile,
8084
loadAgentsFromDir,

packages/appkit/src/plugins/agents/agents.ts

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import { Plugin, toPlugin } from "../../plugin";
1919
import type { PluginManifest } from "../../registry";
2020
import { agentStreamDefaults } from "./defaults";
2121
import { AgentEventTranslator } from "./event-translator";
22+
import { isFromPluginMarker } from "./from-plugin";
2223
import { loadAgentsFromDir } from "./load-agents";
2324
import manifest from "./manifest.json";
2425
import { chatRequestSchema, invocationsRequestSchema } from "./schemas";
2526
import { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt";
2627
import { InMemoryThreadStore } from "./thread-store";
28+
import { resolveToolkitFromProvider } from "./toolkit-resolver";
2729
import {
2830
AppKitMcpClient,
2931
type FunctionTool,
@@ -254,7 +256,11 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
254256
src: AgentSource,
255257
): Promise<Map<string, ResolvedToolEntry>> {
256258
const index = new Map<string, ResolvedToolEntry>();
257-
const hasExplicitTools = def.tools && Object.keys(def.tools).length > 0;
259+
const toolsRecord = def.tools ?? {};
260+
const hasExplicitTools =
261+
def.tools !== undefined &&
262+
(Object.keys(toolsRecord).length > 0 ||
263+
Object.getOwnPropertySymbols(toolsRecord).length > 0);
258264
const hasExplicitSubAgents =
259265
def.agents && Object.keys(def.agents).length > 0;
260266

@@ -293,9 +299,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
293299
});
294300
}
295301

296-
// 2. Explicit tools (toolkit entries, function tools, hosted tools)
302+
// 2. fromPlugin markers — resolve against registered ToolProviders first so
303+
// explicit string-keyed tools can still overwrite on the same key.
304+
this.resolveFromPluginMarkers(agentName, toolsRecord, index);
305+
306+
// 3. Explicit tools (toolkit entries, function tools, hosted tools)
297307
const hostedToCollect: import("./tools/hosted-tools").HostedTool[] = [];
298-
for (const [key, tool] of Object.entries(def.tools ?? {})) {
308+
for (const [key, tool] of Object.entries(toolsRecord)) {
299309
if (isToolkitEntry(tool)) {
300310
index.set(key, {
301311
source: "toolkit",
@@ -339,31 +349,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
339349
provider,
340350
} of this.context.getToolProviders()) {
341351
if (pluginName === this.name) continue;
342-
const withToolkit = provider as ToolProvider & {
343-
toolkit?: (opts?: unknown) => Record<string, unknown>;
344-
};
345-
if (typeof withToolkit.toolkit === "function") {
346-
const entries = withToolkit.toolkit() as Record<string, unknown>;
347-
for (const [key, maybeEntry] of Object.entries(entries)) {
348-
if (!isToolkitEntry(maybeEntry)) continue;
349-
index.set(key, {
350-
source: "toolkit",
351-
pluginName: maybeEntry.pluginName,
352-
localName: maybeEntry.localName,
353-
def: { ...maybeEntry.def, name: key },
354-
});
355-
}
356-
continue;
357-
}
358-
// Fallback: providers without a toolkit() still expose getAgentTools();
359-
// dispatch goes through PluginContext.executeTool by plugin name.
360-
for (const tool of provider.getAgentTools()) {
361-
const qualifiedName = `${pluginName}.${tool.name}`;
362-
index.set(qualifiedName, {
352+
const entries = resolveToolkitFromProvider(pluginName, provider);
353+
for (const [key, entry] of Object.entries(entries)) {
354+
index.set(key, {
363355
source: "toolkit",
364-
pluginName,
365-
localName: tool.name,
366-
def: { ...tool, name: qualifiedName },
356+
pluginName: entry.pluginName,
357+
localName: entry.localName,
358+
def: { ...entry.def, name: key },
367359
});
368360
}
369361
}
@@ -377,6 +369,51 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
377369
}
378370
}
379371

372+
/**
373+
* Walks the symbol-keyed `fromPlugin` markers in an agent's `tools` record
374+
* and resolves each one against a registered `ToolProvider`. Throws with a
375+
* helpful `Available: …` listing if a referenced plugin isn't registered.
376+
*/
377+
private resolveFromPluginMarkers(
378+
agentName: string,
379+
toolsRecord: Record<string | symbol, unknown>,
380+
index: Map<string, ResolvedToolEntry>,
381+
): void {
382+
const symbolKeys = Object.getOwnPropertySymbols(toolsRecord);
383+
if (symbolKeys.length === 0) return;
384+
385+
const providers = this.context?.getToolProviders() ?? [];
386+
387+
for (const sym of symbolKeys) {
388+
const marker = (toolsRecord as Record<symbol, unknown>)[sym];
389+
if (!isFromPluginMarker(marker)) continue;
390+
391+
const providerEntry = providers.find((p) => p.name === marker.pluginName);
392+
if (!providerEntry) {
393+
const available = providers.map((p) => p.name).join(", ") || "(none)";
394+
throw new Error(
395+
`Agent '${agentName}' references plugin '${marker.pluginName}' via ` +
396+
`fromPlugin(), but that plugin is not registered in createApp. ` +
397+
`Available: ${available}.`,
398+
);
399+
}
400+
401+
const entries = resolveToolkitFromProvider(
402+
marker.pluginName,
403+
providerEntry.provider,
404+
marker.opts,
405+
);
406+
for (const [key, entry] of Object.entries(entries)) {
407+
index.set(key, {
408+
source: "toolkit",
409+
pluginName: entry.pluginName,
410+
localName: entry.localName,
411+
def: { ...entry.def, name: key },
412+
});
413+
}
414+
}
415+
}
416+
380417
private async connectHostedTools(
381418
hostedTools: import("./tools/hosted-tools").HostedTool[],
382419
index: Map<string, ResolvedToolEntry>,

0 commit comments

Comments
 (0)