Skip to content

feat(appkit): tools(plugins) DX, runAgent plugins arg, shared toolkit-resolver#305

Open
MarioCadenas wants to merge 12 commits intomainfrom
agent/v2/5-fromplugin-runagent
Open

feat(appkit): tools(plugins) DX, runAgent plugins arg, shared toolkit-resolver#305
MarioCadenas wants to merge 12 commits intomainfrom
agent/v2/5-fromplugin-runagent

Conversation

@MarioCadenas
Copy link
Copy Markdown
Collaborator

@MarioCadenas MarioCadenas commented Apr 21, 2026

DX centerpiece. Introduces a function-form tools field on
AgentDefinition that receives a plugins map, eliminating the need
to mention each plugin twice in user code. Extracts the shared
toolkit resolver that the agents plugin, auto-inherit, and standalone
runAgent all now go through. Refactors the agent runtime into
core/agent/ so peer plugins can depend on it without crossing the
sibling boundary.

tools(plugins) — the function form

AgentDefinition.tools accepts either a plain record (for agents
that only use inline tools) or a function (plugins) => Record<string, AgentTool> that receives a plugins map and returns a
tool record. The function runs once at agent setup; the result is
cached as the agent's resolved tool record.

const support = createAgent({
  instructions: "You help customers.",
  model: "databricks-claude-sonnet-4-5",
  tools(plugins) {
    return {
      ...plugins.analytics.toolkit(),
      ...plugins.files.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(), analytics(), files(), agents({ agents: { support } })],
});

Each plugin is mentioned exactly once, in createApp({ plugins: [...] }). No held variables, no spread markers, no fromPlugin
indirection.

resolveToolkitFromProvider(pluginName, provider, opts?)

packages/appkit/src/core/agent/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 — function-form resolution pass
  2. AgentsPlugin.applyAutoInherit — markdown auto-inherit path
  3. runAgent — standalone-mode plugin tool dispatch

The underlying filtering primitive applyToolkitOptions (in
core/agent/toolkit-options.ts) is also used by
buildToolkitEntries in core/agent/build-toolkit.ts.

runAgent gains plugins?: PluginData[]

packages/appkit/src/core/agent/run-agent.ts. When an agent def's
tools is the function form, runAgent builds a plugins map lazily
from RunAgentInput.plugins. Plugin instances are constructed on
the first plugins.<name>.toolkit(...) call and cached, so the same
instance handles both spread-time resolution and dispatch-time tool
execution. Hosted/MCP tools are rejected up front with a clear error
rather than failing mid-conversation when the adapter tries to
dispatch them — they require a live MCP client that only exists
inside the agents plugin's lifecycle.

Agent runtime relocated to core/agent/

The framework-level agent primitives (createAgent, runAgent,
tool helpers, types, system-prompt composition, markdown agent
loader) moved from plugins/agents/ to core/agent/. The
HTTP-facing agents() plugin in plugins/agents/ consumes these
but no longer owns them — peer plugins (analytics, files, genie,
lakebase) can depend on the runtime without reaching across the
sibling boundary.

Type plumbing

  • AgentTools simplified to Record<string, AgentTool> (no symbol
    keys, no FromPluginMarker).
  • AgentToolsFn = (plugins: Plugins) => AgentTools.
  • AgentDefinition.tools?: AgentTools | AgentToolsFn.
  • Plugins = Record<string, PluginToolkitProvider> — plain
    string-keyed map; users refer to plugins by the name they pass to
    createApp({ plugins: [...] }). Per-plugin keyed autocomplete on
    the plugins parameter is a separate, larger design problem
    tracked for follow-up.

DoS-limit hardening

  • Concurrent-stream rate limit (maxConcurrentStreamsPerUser)
    enforced on both /chat and /invocations (previously only on
    /chat).
  • countUserStreams now O(1) via a per-user counter map kept in
    sync with activeStreams through trackStream / untrackStream
    helpers.
  • Tool-call budget and approval gate now travel through a shared
    RunState so sub-agent dispatches enforce the same limits as
    top-level calls.
  • Per-tool-call timeout (limits.toolCallTimeoutMs, default 5min)
    combined with parent abort signal via AbortSignal.any.
  • threadStore.{get,create,addMessage} failures in _handleChat
    and _handleInvocations return HTTP 500 instead of hanging the
    client connection.

Reload safety

AgentsPlugin.reload() builds a fresh registry first and only swaps
on success, so a malformed markdown file or missing tool reference
no longer leaves the live registry in a half-rebuilt state. Markdown
agent loader fs.* calls are now async to avoid blocking the event
loop during reload.

Exports

Function-form types (AgentToolsFn, Plugins,
PluginToolkitProvider) exported from @databricks/appkit/beta.
fromPlugin, FromPluginMarker, isFromPluginMarker,
FROM_PLUGIN_MARKER, FromPluginSpread, and the
NamedPluginFactory.pluginName field (its only consumer) are
removed — files deleted, callers migrated.

Test plan

  • 2273 tests passing across the full vitest workspace.
  • New tests cover: function-form spread of
    plugins.<name>.toolkit(), function-form opt-out from
    auto-inherit (object and empty-object forms), function form with
    provider lacking .toolkit(), function form invoked exactly once
    at setup, sub-agent recursive execution in standalone runAgent,
    hosted-tool rejection at index-build time, threadStore failure
    paths returning HTTP 500 (5 paths in route-handler-errors.test.ts),
    /invocations honouring maxConcurrentStreamsPerUser, and the
    userStreamCounts invariant across track/untrack lifecycles.
  • Typecheck clean across all 7 workspace projects.
  • Build + docs:build clean.

PR Stack

  1. Shared agent types + LLM adapters — feat(appkit): shared agent types and LLM adapter implementations #301
  2. Tool primitives + ToolProvider surfaces — feat(appkit): tool primitives and ToolProvider surfaces on core plugins #302
  3. Plugin infrastructure (attachContext + PluginContext) — feat(appkit): plugin infrastructure — attachContext + PluginContext mediator #303
  4. agents() plugin + createAgent(def) + markdown-driven agents — feat(appkit): agents() plugin, createAgent(def), and markdown-driven agents #304
  5. tools(plugins) DX + runAgent plugins arg + toolkit-resolver (this PR)
  6. Reference app + dev-playground + docs — feat(appkit): reference agent-app, dev-playground chat UI, docs, and template #306

Demo

agent-demo.mp4

This was referenced Apr 21, 2026
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from 3c7c35e to cb7fe2b Compare April 21, 2026 20:41
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from 162e970 to 29e3534 Compare April 21, 2026 20:41
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from cb7fe2b to 0afea5e Compare April 22, 2026 08:45
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from 29e3534 to b462716 Compare April 22, 2026 08:45
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from 0afea5e to 983461c Compare April 22, 2026 09:24
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch 2 times, most recently from 539487e to dac73b5 Compare April 22, 2026 09:46
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch 2 times, most recently from a7b0444 to 623792d Compare April 22, 2026 09:59
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch 2 times, most recently from 624f2a0 to 0dd07a4 Compare April 22, 2026 10:21
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from 623792d to 2f752a0 Compare April 22, 2026 10:21
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from caa6286 to c2b0f28 Compare May 4, 2026 11:19
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from 41fa8b0 to 8b0c28e Compare May 4, 2026 11:19
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from c2b0f28 to fd73087 Compare May 4, 2026 12:59
@MarioCadenas MarioCadenas requested a review from a team as a code owner May 4, 2026 12:59
@MarioCadenas MarioCadenas requested review from calvarjorge and removed request for a team May 4, 2026 12:59
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch 2 times, most recently from 22393bb to fdfd568 Compare May 4, 2026 13:12
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch 2 times, most recently from 269d1a9 to c038a77 Compare May 4, 2026 13:15
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from fdfd568 to 65178cf Compare May 4, 2026 13:15
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from c038a77 to ab5b485 Compare May 4, 2026 16:36
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from 65178cf to 03da825 Compare May 4, 2026 16:36
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from ab5b485 to 6378638 Compare May 4, 2026 17:19
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from 03da825 to c16fa29 Compare May 4, 2026 17:19
@MarioCadenas MarioCadenas force-pushed the agent/v2/4-agents-plugin branch from 6378638 to 2640ea4 Compare May 4, 2026 17:32
@MarioCadenas MarioCadenas force-pushed the agent/v2/5-fromplugin-runagent branch from c16fa29 to a8cedbd Compare May 4, 2026 17:32
Comment thread packages/appkit/src/core/agent/from-plugin.ts Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 returns res.status(500).json({ error: "Thread operation failed" })
  • In _handleInvocations: wrap lines 740–764 (threadStore.create → for-loop of addMessage) in a try-catch that returns res.status(500).json({ error: "Thread operation failed" })

Fix 3: Early hosted-tool rejection in runAgent()

File: packages/appkit/src/core/agent/run-agent.tsbuildStandaloneToolIndex() (~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 _streamAgent where activeStreams.set(requestId, ...) is called (line 779): increment the counter for userId
  • In the finally block where activeStreams.delete(requestId) (line 895) and in _handleCancel where activeStreams.delete(streamId) (line 1081): decrement the counter for userId
  • 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.tsresolveStandaloneProvider() (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) fromPlugin marker uses Symbol.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_pending event 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 effect field — backward compatible, no action needed.
  • (14) Cache getPluginNames() at setup time — minor optimization.
  • (15) defineTool() trivial wrapper — DX decision, defer.

Verification

  1. Build: pnpm build
  2. Lint + typecheck: pnpm check:fix && pnpm -r typecheck
  3. Tests: pnpm test — ensure all existing tests pass and new tests (7, 8) pass
  4. Manual smoke test: Run pnpm dev and 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.
Drop the symbol-keyed fromPlugin(factory) API in favor of a function
form on AgentDefinition.tools that receives a typed plugins map. The
key win: each plugin appears exactly once in user code (only inside
createApp({ plugins: [...] })), and plugins.<name>.toolkit() is fully
autocompleted in the IDE.

Before:

  import { analytics, fromPlugin } from "@databricks/appkit";

  const support = createAgent({
    tools: { ...fromPlugin(analytics) },
  });
  await createApp({ plugins: [analytics(), agents({ agents: { support } })] });

After:

  const support = createAgent({
    tools(plugins) {
      return { ...plugins.analytics.toolkit() };
    },
  });
  await createApp({ plugins: [analytics(), agents({ agents: { support } })] });

The dual tools: AgentTools | AgentToolsFn shape means simple agents
keep the plain object form. The function runs once at agent setup; its
return record replaces def.tools for the rest of the registered
agent's lifetime.

Typing relies on a RegisteredPlugins module-augmentation interface that
core plugins (analytics, files, genie, lakebase) extend at their
declaration sites. Third-party plugins fall back to a
PluginToolkitProvider shape via the index signature; they still work at
runtime, just without keyed autocomplete.

Standalone runAgent invokes the function form against a Plugins map
built lazily from RunAgentInput.plugins. Plugin instances are
constructed on first plugins.<name>.toolkit() call and cached, so the
same instance handles both spread-time and dispatch-time work.

Auto-inherit semantics unchanged: declaring tools (object or function,
even an empty record) opts out, mirroring the prior rule.

Markdown agents are untouched. YAML cannot carry a function reference,
so toolkits: [analytics] and ambient agents({ tools: {...} }) keep
working as the markdown surface.

fromPlugin, FromPluginMarker, FROM_PLUGIN_MARKER, FromPluginSpread,
isFromPluginMarker, and the NamedPluginFactory.pluginName field
(its only consumer) are deleted from beta exports and from-plugin.ts.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…ugin augmentation

Two follow-ups on the tools(plugins) function form:

1. Each entry in the Plugins map is now typed as PluginToolkitProvider
   (just the .toolkit() method) instead of the full plugin class. Inside
   tools(plugins), plugins.analytics.<TAB> shows only .toolkit() — the
   contract for tool composition — instead of leaking query, name,
   setup, injectRoutes, and every other instance method.

2. RegisteredPlugins ships pre-populated with the four core plugin keys
   (analytics, files, genie, lakebase) directly in core/agent/types.ts.
   The per-plugin "declare module ../../core/agent/types" blocks at the
   bottom of each core plugin file are gone. Adding a new core plugin
   now means adding one line to RegisteredPlugins, not duplicating an
   augmentation block.

Third-party plugins still augment RegisteredPlugins from user code if
they want their key in autocomplete; the index-signature fallback
keeps unaugmented names working at runtime.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…ed record

Hardcoding the registered plugin names in core/agent/types.ts was the
wrong shape — AppKit cannot statically know which plugins the
surrounding createApp call will register, so a curated list there was
guaranteed to drift.

Drop the RegisteredPlugins augmentation interface entirely. Plugins is
now Record<string, PluginToolkitProvider>: every entry exposes
.toolkit() at runtime, but plugin names do not autocomplete inside
tools(plugins). Users refer to plugins by the same name they pass to
createApp({ plugins: [...] }).

Plugin-name autocomplete (so tools(plugins) sees plugins.<TAB> ->
analytics | files | ...) is a separate, larger design problem with
real tradeoffs across inline-in-createApp, factory pattern, codegen,
and user augmentation. Tracked separately; this commit unblocks the
v5 stack with a runtime-correct, honest type that does not lie about
what AppKit knows.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
countUserStreams() previously walked every entry in activeStreams on
every /chat and /invocations request — O(n) over total concurrent
streams across all users on the hot path. At scale that scan dominates
chat-request latency under load.

Add userStreamCounts: Map<string, number> kept in sync with
activeStreams via two new helpers:

- trackStream(requestId, userId, controller) — sole writer that
  registers a stream and increments the user's counter.
- untrackStream(requestId) — sole writer that removes a stream and
  decrements the counter; deletes the user's entry on the last stream
  to keep the map bounded across many distinct users; idempotent on
  unknown ids.

countUserStreams() is now an O(1) Map.get(). All three previous
mutation sites (_streamAgent setup, _streamAgent finally, _handleCancel)
go through the helpers, so the counter cannot drift from the map.

Tests: existing dos-limits seeders updated to use trackStream(); new
test in dos-limits.test.ts asserts the invariant across track/untrack
for multiple users plus idempotent untrack.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Address all four medium findings from the xavier review panel
(correctness + security + performance) on PR #305.

1. applyToolkitOptions no longer returns literal "undefined"

   The rename branch used Object.hasOwn(rename, name) followed by an
   unguarded rename[name] return. When a developer wrote
   `rename: { query: featureFlag ? "x" : undefined }`, the explicit
   undefined satisfied hasOwn() and propagated as the toolkit key,
   producing a tool keyed literally "undefined" downstream.

   Drop hasOwn, use nullish coalescing + empty-string guard so explicit
   undefined and "" both fall through to the prefix path. Adds a new
   toolkit-options.test.ts covering all four knobs (prefix, only,
   except, rename) plus the regression case.

2. runAgent shares providerCache across sub-agent recursion

   Sub-agent tool dispatch used to call runAgent recursively, which
   built a fresh providerCache inside buildStandaloneToolIndex on every
   nested call. Plugins were re-instantiated per sub-agent invocation,
   in-instance state diverged between parent and child, and any
   per-call setup cost (pool open, client construction) ran for every
   nested level.

   Hoist the cache to the top-level runAgent and thread it through a
   private runAgentInternal() helper so all nested calls share the
   same instance map.

3. Plugin lookup names the missing plugin (proxy)

   The Plugins type is Record<string, PluginToolkitProvider> without
   noUncheckedIndexedAccess workspace-wide, so unknown keys typecheck
   as present but resolve to undefined at runtime. Accessing
   .toolkit() on undefined produced a generic
   "Cannot read properties of undefined (reading 'toolkit')" with no
   plugin name and no list of available plugins.

   New core/agent/plugins-map.ts exports createPluginsProxy() — wraps
   the resolved record so unknown string-key access throws a named
   error: "<context> referenced plugin '<name>', but it is not
   registered. Available: ...". Used by both standalone runAgent and
   the agents plugin's buildPluginsMap. Symbol access and well-known
   probes (then, toJSON, toString, valueOf, constructor) pass through
   untouched so Promise/JSON tooling doesn't trip the guard.

4. Standalone runAgent eagerly initialises plugin lifecycle

   resolveStandaloneProvider used to construct plugin instances lazily
   on first plugins[name].toolkit() call and never invoke
   attachContext()/setup(). First-party plugins like AnalyticsPlugin
   that depend on createApp's runtime (WorkspaceClient,
   ServiceContext, PluginContext) crashed mid-stream when their tools
   dereferenced getWorkspaceClient(), with stack traces far from the
   cause. The previous JSDoc only hinted with "(when they work at
   all)".

   New initStandalonePlugins() helper runs at the top of runAgent:
   constructs every plugin in input.plugins, calls attachContext({}),
   awaits setup(), and populates the shared cache. Failures wrap with
   a clear message naming the plugin and pointing the caller at
   createApp({ plugins: [..., agents(...)] }). Plugins with the
   default no-op setup() initialise cleanly; plugins that need
   createApp-only runtime fail at startup, not mid-conversation.

   The runAgent JSDoc is rewritten to spell out the trust boundary
   (no OBO, no approval gate, treat as trusted-prompt environment)
   and the new init contract.

Tests: + 8 toolkit-options unit tests, + 3 run-agent tests covering
the new behaviours (proxy names missing plugin, setup failure
surfaces at entry not mid-stream, sub-agent recursion shares plugin
instance with parent — verified by counting constructor calls and
asserting same instance id from both parent and child tools). 2284
tests passing across the workspace.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants