feat(appkit): fromPlugin() DX, runAgent plugins arg, shared toolkit-resolver#305
feat(appkit): fromPlugin() DX, runAgent plugins arg, shared toolkit-resolver#305MarioCadenas wants to merge 7 commits intomainfrom
Conversation
3c7c35e to
cb7fe2b
Compare
162e970 to
29e3534
Compare
cb7fe2b to
0afea5e
Compare
29e3534 to
b462716
Compare
0afea5e to
983461c
Compare
539487e to
dac73b5
Compare
a7b0444 to
623792d
Compare
624f2a0 to
0dd07a4
Compare
623792d to
2f752a0
Compare
a6567bc to
5d0fae2
Compare
8ace826 to
f41317f
Compare
af9b6ee to
e4b1322
Compare
f41317f to
d42173c
Compare
caa6286 to
c2b0f28
Compare
41fa8b0 to
8b0c28e
Compare
c2b0f28 to
fd73087
Compare
22393bb to
fdfd568
Compare
269d1a9 to
c038a77
Compare
fdfd568 to
65178cf
Compare
c038a77 to
ab5b485
Compare
65178cf to
03da825
Compare
ab5b485 to
6378638
Compare
03da825 to
c16fa29
Compare
6378638 to
2640ea4
Compare
c16fa29 to
a8cedbd
Compare
| * @param opts Optional toolkit scoping — `prefix`, `only`, `except`, `rename`. | ||
| * Same shape as the `.toolkit()` method. | ||
| */ | ||
| export function fromPlugin<F extends NamedPluginFactory>( |
There was a problem hiding this comment.
Honestly, I still don't like the fromPlugin API. Asked an agent if that's just me and looks like the community usually takes a similar approach to the one I suggest 😄 See the report at the end of the comment.
My suggestion is to: instead of
const support = createAgent({
instructions: "You help customers with data and files.",
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}`,
}),
},
});
await createApp({
plugins: [server(), analytics(), files(), agents({ agents: { support } })],
});
go with this:
const analyticsPlugin = analytics();
const filesPlugin = files();
const support = createAgent({
instructions: "You help customers with data and files.",
model: "databricks-claude-sonnet-4-5",
tools: {
...analyticsPlugin.toolkit(),
...filesPlugin.toolkit({ only: ["uploads.read"] }),
get_weather: tool({
name: "get_weather",
description: "Weather",
schema: z.object({ city: z.string() }),
execute: async ({ city }) => `Sunny in ${city}`,
}),
},
});
await createApp({
plugins: [server(), analyticsPlugin, filesPlugin, agents({ agents: { support } })],
});What do you think?
The chicken-and-egg problem
The tension: agent definitions need plugin tools, but plugins need AppKit context (WorkspaceClient, PluginContext, OBO) to be functional. So at definition time, nothing is "ready."
Common JS patterns for this
1. Two-phase init (your preference)
Create a handle, wire references, then initialize. This is how Express, Fastify, and Koa work — you instantiate middleware/plugins, compose them, then call listen():
const auth = passport(); // create
const app = express();
app.use(auth); // wire
app.listen(3000); // initializeIn AppKit terms, your preferred API would look like:
const analyticsPlugin = analytics({});
const filesPlugin = files({ volumes: { uploads: {} } });
const agent = createAgent({
tools: {
...analyticsPlugin.toolkit(),
...filesPlugin.toolkit({ only: ["uploads.read"] }),
},
});
createApp({ plugins: [server(), analyticsPlugin, filesPlugin, agents({ agents: { agent } })] });This is totally achievable — analytics() already returns a data bag. It could also expose .toolkit() that returns the same lazy markers fromPlugin creates internally. Same resolution mechanism, more explicit surface.
2. DI container / token-based (Angular, NestJS, InversifyJS)
Register by class or token, resolve lazily:
@Injectable()
class MyAgent {
constructor(@Inject(AnalyticsPlugin) private analytics: AnalyticsPlugin) {}
}This is basically what fromPlugin does — reference-by-name, resolve-at-startup — but dressed up with decorators. Heavy for a non-decorator codebase.
3. Builder / fluent API (Hono, tRPC)
Collect everything, resolve at .build():
const app = AppKit.builder()
.plugin("analytics", analytics({}))
.plugin("files", files({ ... }))
.agent("support", { tools: ["analytics", "files.uploads.read"] })
.build();Clean, but loses the "agents are plain objects" composability.
4. String references (Terraform, Spring, Django settings)
Just use names, resolve later. This is what markdown agents already do with toolkits: [analytics] in frontmatter — it's the YAML equivalent of fromPlugin(analytics).
My honest assessment
fromPlugin is pattern #2 disguised as a spread operator. It works, but has two DX friction points:
-
It's an unusual pattern —
...fromPlugin(analytics)looks like it's doing something, but it's just creating a{ [Symbol]: { pluginName: "analytics" } }marker. The magic is hidden. -
It introduces a separate concept — you need to learn
fromPluginas a standalone primitive. In your preferred approach (chore: rework TelemetryManager to use Node SDK #1),.toolkit()is a method on the thing you already have — no new import, no new concept.
The good news: these aren't mutually exclusive. The factory data bag returned by analytics() could grow a .toolkit() method that produces the same markers. fromPlugin could remain as a lower-level escape hatch (e.g., for dynamic plugin names). This is actually a very small surface change — toPlugin just needs to stamp a .toolkit() method alongside the existing .pluginName.
What the broader JS community leans toward
Explicit instance references (your preference) is the dominant pattern. Express, Fastify, Hono, tRPC, Drizzle, Prisma — almost nobody uses magic marker/symbol indirection. The standard expectation is: "I created it, I hold a reference to it, I pass that reference where it's needed."
The fromPlugin pattern is closer to how Terraform or Pulumi work (reference by logical name, resolve in a plan phase), which makes sense for infrastructure-as-code but feels foreign in a JS application SDK.
There was a problem hiding this comment.
Agentic review:
Fix Plan: Code Review Findings for agent/v2/5-fromplugin-runagent
Context
Code review of branch agent/v2/5-fromplugin-runagent vs agent/v2/4-agents-plugin identified 16 findings across 12 parallel reviewers (correctness, testing, maintainability, project-standards, agent-native, learnings-researcher, security, api-contract, reliability, adversarial, kieran-typescript, performance). This plan addresses all actionable findings in priority order.
P1 — High Priority Fixes
Fix 1–2: Wrap threadStore calls in route handlers with try-catch
Files:
packages/appkit/src/plugins/agents/agents.ts—_handleChat(~line 700) and_handleInvocations(~line 740)
Problem: If threadStore.get(), .create(), or .addMessage() throws (e.g., DB unavailable), the async Express handler propagates uncaught and the client connection hangs with no HTTP response.
Fix:
- In
_handleChat: wrap lines 702–718 (threadStore.get → threadStore.create → threadStore.addMessage) in a try-catch that returnsres.status(500).json({ error: "Thread operation failed" }) - In
_handleInvocations: wrap lines 740–764 (threadStore.create → for-loop of addMessage) in a try-catch that returnsres.status(500).json({ error: "Thread operation failed" })
Fix 3: Early hosted-tool rejection in runAgent()
File: packages/appkit/src/core/agent/run-agent.ts — buildStandaloneToolIndex() (~line 144)
Problem: Hosted/MCP tools are included in the standalone tool index but error only at dispatch time. The adapter sees these tools, may try to use them, and gets a confusing mid-conversation error.
Fix: After the tool classification loop (after line 184), scan the index for kind: "hosted" entries. If any exist AND they weren't produced by toolkitEntryToStandalone (which already warns in the description), collect their names and throw:
runAgent: agent definition includes hosted/MCP tools [${names}] which are not supported in standalone mode. Use createApp({ plugins: [agents(...)] }) for MCP support.
Fix 4: Replace countUserStreams() O(n) scan with per-user counter
File: packages/appkit/src/plugins/agents/agents.ts
Problem: countUserStreams() (line 143) iterates all active streams on every chat request. At scale this is O(n) where n = total concurrent streams across all users.
Fix:
- Add a private field:
private userStreamCounts = new Map<string, number>() - In
_streamAgentwhereactiveStreams.set(requestId, ...)is called (line 779): increment the counter foruserId - In the
finallyblock whereactiveStreams.delete(requestId)(line 895) and in_handleCancelwhereactiveStreams.delete(streamId)(line 1081): decrement the counter foruserId - Replace
countUserStreams(userId)body with:return this.userStreamCounts.get(userId) ?? 0
P2 — Moderate Fixes
Fix 5: Extract shared toolkit filtering helper
Files:
packages/appkit/src/core/agent/toolkit-resolver.ts(lines 41–61)packages/appkit/src/core/agent/build-toolkit.ts(lines 25–62)
Problem: Both files independently implement identical prefix/only/except/rename filtering for ToolkitOptions. Bug fixes must be applied in two places.
Fix: Extract a shared helper function (e.g., filterToolkitKeys) that takes (localName: string, opts: ToolkitOptions, pluginName: string) => string | null (returns the final key or null if filtered out). Use it in both buildToolkitEntries() and the fallback path of resolveToolkitFromProvider().
Fix 6: Use isToolProvider type guard in resolveStandaloneProvider
File: packages/appkit/src/core/agent/run-agent.ts — resolveStandaloneProvider() (line 280–298)
Problem: instance as unknown as ToolProvider bypasses TypeScript entirely. A proper type guard exists at core/plugin-context.ts:308.
Fix: Import isToolProvider from ../plugin-context (or extract it to a shared location if circular imports are a concern). Replace the manual guard (lines 285–296) with:
if (!isToolProvider(instance)) {
throw new Error(`runAgent: plugin '${pluginName}' is not a ToolProvider ...`);
}
// instance is now narrowed to ToolProviderPlugin
cache.set(pluginName, instance);
return instance;Note: isToolProvider checks for asUser which standalone providers won't use. May need a lighter guard (isToolProviderLike) that only checks getAgentTools + executeAgentTool. Evaluate at implementation time.
Fix 7–8: Add missing tests
File: packages/appkit/src/core/agent/tests/run-agent.test.ts
Test 7: Plugin without ToolProvider methods
it("throws when plugin lacks getAgentTools/executeAgentTool", async () => {
// Create a PluginData with a class that doesn't implement ToolProvider
// Call runAgent with fromPlugin referencing it
// Assert throws with "not a ToolProvider" message
});Test 8: Sub-agent recursive execution
it("executes sub-agent tools via recursive runAgent", async () => {
// Define parent agent with agents: { helper: childDef }
// Mock adapter to call "agent-helper" tool
// Verify child agent runs and returns text
});P2 — Advisory (No Code Changes)
- (9)
fromPluginmarker usesSymbol.for— forgeable in theory. Agent defs come from developer code; acceptable risk. - (10) Sub-agent runner uses global agent registry — names come from static config, not runtime input.
- (11) New
approval_pendingevent type in unions — downstream consumers with exhaustive switches need updating. Document in changelog. - (12) AgentsPlugin 1200+ lines — consider future extraction. Not blocking.
P3 — Low Priority (Deferred)
- (13) New optional
effectfield — backward compatible, no action needed. - (14) Cache
getPluginNames()at setup time — minor optimization. - (15)
defineTool()trivial wrapper — DX decision, defer.
Verification
- Build:
pnpm build - Lint + typecheck:
pnpm check:fix && pnpm -r typecheck - Tests:
pnpm test— ensure all existing tests pass and new tests (7, 8) pass - Manual smoke test: Run
pnpm devand verify agents plugin loads, chat endpoint works, and sub-agent delegation works
…esolver
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 <MarioCadenas@users.noreply.github.com>
…on 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.
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.
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.
…te manifest) Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…ebase Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
PR #305 agentic-review fixes (P1 + cheap P2): - _handleChat / _handleInvocations now wrap threadStore calls in try/catch and surface failures as a 500. Without this an unreachable store hung the SSE client until the upstream proxy timeout because the async Express handler propagated the rejection without a response. - runAgent rejects hosted/MCP tools at index-build time with a clear pointer to createApp({ plugins: [..., agents()] }). Previously the adapter saw the tool list and the failure surfaced mid-conversation. - Extract applyToolkitOptions: single source of truth for the prefix/only/except/rename filtering shared by build-toolkit (registry path) and toolkit-resolver (getAgentTools fallback). Bug fixes here apply to both paths instead of drifting between them. - resolveStandaloneProvider now uses an isStandaloneToolProvider type guard instead of `instance as unknown as ToolProvider`. Distinct from core/plugin-context.isToolProvider which also requires asUser (request-scoped, only meaningful inside createApp). Tests added: - threadStore failure paths on /chat (get/create/addMessage rejections) and /invocations (create + addMessage mid-loop) — assert 500 responses with the canonical error body. - runAgent rejects hosted tools at standalone resolution. - runAgent surfaces a clear error when fromPlugin references a plugin lacking ToolProvider methods. - runAgent recursively executes sub-agents declared on def.agents. #4 (countUserStreams O(n)) deferred — n bounded by 5 × users in the default config; not a hot path. #11 (printRegistry console.log) and Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com> #9-#15 advisory items left as-is per the PR-comment thread.
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 markerpackages/appkit/src/plugins/agents/from-plugin.ts. Returns a spread-friendly
{ [Symbol()]: FromPluginMarker }record. The symbol key isfreshly 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 acrossmodule boundaries.
resolveToolkitFromProvider(pluginName, provider, opts?)packages/appkit/src/plugins/agents/toolkit-resolver.ts. Single sourceof truth for "turn a ToolProvider into a keyed record of
ToolkitEntrymarkers". Prefers
provider.toolkit(opts)when available (core pluginsimplement it), falls back to walking
getAgentTools()and synthesizingnamespaced keys (
${pluginName}.${localName}) for third-partyproviders, honoring
only/except/rename/prefixthe sameway.
Used by three call sites, previously all copy-pasted:
AgentsPlugin.buildToolIndex— fromPlugin marker resolution passAgentsPlugin.applyAutoInherit— markdown auto-inherit pathrunAgent— standalone-mode plugin tool dispatchAgentsPlugin.buildToolIndex— symbol-key resolution passBefore the existing string-key iteration,
buildToolIndexnow walksObject.getOwnPropertySymbols(def.tools). For eachFromPluginMarker,it looks up the plugin by name in
PluginContext.getToolProviders(),calls
resolveToolkitFromProvider, and merges the resulting entriesinto the per-agent index. Missing plugins throw at setup time with a
clear
Available: ...listing — wiring errors surface on boot, notmid-request.
hasExplicitToolsnow counts symbol keys too, so atools: { ...fromPlugin(x) }record correctly disables auto-inheriton code-defined agents.
Type plumbing
AgentToolstype:{ [key: string]: AgentTool } & { [key: symbol]: FromPluginMarker }. Preserves string-key autocomplete whileaccepting marker spreads under strict TS.
AgentDefinition.toolsswitched toAgentTools.runAgentgainsplugins?: PluginData[]packages/appkit/src/core/run-agent.ts. When an agent def containsfromPluginmarkers, the caller passes plugins viaRunAgentInput.plugins. A local provider cache constructs each pluginand dispatches tool calls via
provider.executeAgentTool(). Runs asservice principal (no OBO — there's no HTTP request). If a def
contains markers but
pluginsis absent, throws with guidance.Exports
fromPlugin,FromPluginMarker,isFromPluginMarker,AgentToolsadded to the main barrel.
Test plan
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
pluginsarg, guidance error whenmissing.
Signed-off-by: MarioCadenas MarioCadenas@users.noreply.github.com
PR Stack
agents()plugin +createAgent(def)+ markdown-driven agents — feat(appkit): agents() plugin, createAgent(def), and markdown-driven agents #304fromPlugin()DX +runAgentplugins arg + toolkit-resolver (this PR)Demo
agent-demo.mp4