From 5a7a4df431904c810c2007e857ce6ed35a3cb2f3 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:46:14 +0200 Subject: [PATCH 1/6] feat(appkit): tool primitives and ToolProvider surfaces on core plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second layer of the agents feature. Adds the primitives for defining agent tools and implements them on every core ToolProvider plugin. ### User-facing factories - `tool(config)` — inline function tools backed by a Zod schema. Auto- generates JSON Schema for the LLM via `z.toJSONSchema()` (stripping the top-level `$schema` annotation that Gemini rejects), runtime- validates tool-call arguments, returns an LLM-friendly error string on validation failure so the model can self-correct. - `mcpServer(name, url)` — tiny factory for hosted custom MCP server configs. Replaces the verbose `{ type: "custom_mcp_server", custom_mcp_server: { app_name, app_url } }` wrapper. - `FunctionTool` / `HostedTool` types + `isFunctionTool` / `isHostedTool` type guards. `HostedTool` is a union of Genie, VectorSearch, custom MCP, and external-connection configs. - `ToolkitEntry` + `ToolkitOptions` types + `isToolkitEntry` guard. `AgentTool = FunctionTool | HostedTool | ToolkitEntry` is the canonical union later PRs spread into agent definitions. ### Internal registry + JSON Schema helper - `defineTool(config)` + `ToolRegistry` — plugin authors' internal shape for declaring a keyed set of tools with Zod-typed handlers. - `toolsFromRegistry()` — produces the `AgentToolDefinition[]` exposed via `ToolProvider.getAgentTools()`. - `executeFromRegistry()` — validates args then dispatches to the handler. Returns LLM-friendly errors on bad args. - `toToolJSONSchema()` — shared helper at `packages/appkit/src/plugins/agents/tools/json-schema.ts` that wraps `toJSONSchema()` and strips `$schema`. Used by `tool()`, `toolsFromRegistry()`, and `buildToolkitEntries()`. - `buildToolkitEntries(pluginName, registry, opts?)` — converts a plugin's internal `ToolRegistry` into a keyed record of `ToolkitEntry` markers, honoring `prefix` / `only` / `except` / `rename`. ### MCP client - `AppKitMcpClient` — minimal JSON-RPC 2.0 client over SSE, zero deps. Handles auth refresh, per-server connection pooling, and tool definition aggregation. - `resolveHostedTools()` — maps `HostedTool` configs to Databricks MCP endpoint URLs. ### ToolProvider surfaces on core plugins - **analytics** — `query` tool (Zod-typed, asUser dispatch) - **files** — per-volume tool family: `${volumeKey}.{list,read,exists,metadata,upload,delete}` (dynamically named from the plugin's volume config) - **genie** — per-space tool family: `${alias}.{sendMessage,getConversation}` (dynamically named from the plugin's spaces config) - **lakebase** — `query` tool Each plugin gains `getAgentTools()` + `executeAgentTool()` satisfying the `ToolProvider` interface, plus a `.toolkit(opts?)` method that returns a record of `ToolkitEntry` markers for later spread into agent definitions. ### Test plan - 58 new tests across tool primitives + plugin ToolProvider surfaces - Full appkit vitest suite: 1212 tests passing - Typecheck clean - Build clean, publint clean Signed-off-by: MarioCadenas --- packages/appkit/src/index.ts | 24 ++ .../src/plugins/agents/build-toolkit.ts | 62 ++++ .../agents/tests/build-toolkit.test.ts | 75 +++++ .../plugins/agents/tests/define-tool.test.ts | 133 +++++++++ .../agents/tests/function-tool.test.ts | 110 +++++++ .../plugins/agents/tests/hosted-tools.test.ts | 131 +++++++++ .../agents/tests/mcp-server-helper.test.ts | 34 +++ .../src/plugins/agents/tests/tool.test.ts | 110 +++++++ .../src/plugins/agents/tools/define-tool.ts | 84 ++++++ .../src/plugins/agents/tools/function-tool.ts | 33 +++ .../src/plugins/agents/tools/hosted-tools.ts | 102 +++++++ .../appkit/src/plugins/agents/tools/index.ts | 20 ++ .../src/plugins/agents/tools/json-schema.ts | 22 ++ .../src/plugins/agents/tools/mcp-client.ts | 278 ++++++++++++++++++ .../appkit/src/plugins/agents/tools/tool.ts | 53 ++++ packages/appkit/src/plugins/agents/types.ts | 47 +++ .../appkit/src/plugins/analytics/analytics.ts | 50 +++- .../plugins/analytics/tests/analytics.test.ts | 18 ++ packages/appkit/src/plugins/files/plugin.ts | 108 ++++++- .../src/plugins/files/tests/plugin.test.ts | 56 ++++ packages/appkit/src/plugins/genie/genie.ts | 81 ++++- .../src/plugins/genie/tests/genie.test.ts | 24 ++ .../appkit/src/plugins/lakebase/lakebase.ts | 54 +++- 23 files changed, 1703 insertions(+), 6 deletions(-) create mode 100644 packages/appkit/src/plugins/agents/build-toolkit.ts create mode 100644 packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/define-tool.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/function-tool.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/hosted-tools.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/mcp-server-helper.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/tool.test.ts create mode 100644 packages/appkit/src/plugins/agents/tools/define-tool.ts create mode 100644 packages/appkit/src/plugins/agents/tools/function-tool.ts create mode 100644 packages/appkit/src/plugins/agents/tools/hosted-tools.ts create mode 100644 packages/appkit/src/plugins/agents/tools/index.ts create mode 100644 packages/appkit/src/plugins/agents/tools/json-schema.ts create mode 100644 packages/appkit/src/plugins/agents/tools/mcp-client.ts create mode 100644 packages/appkit/src/plugins/agents/tools/tool.ts create mode 100644 packages/appkit/src/plugins/agents/types.ts diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 955bfde6..8d236780 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -7,11 +7,20 @@ // Types from shared export type { + AgentAdapter, + AgentEvent, + AgentInput, + AgentRunContext, + AgentToolDefinition, BasePluginConfig, CacheConfig, IAppRouter, + Message, PluginData, StreamExecutionSettings, + Thread, + ThreadStore, + ToolProvider, } from "shared"; export { isSQLTypeMarker, sql } from "shared"; export { CacheManager } from "./cache"; @@ -54,6 +63,21 @@ export { toPlugin, } from "./plugin"; export { analytics, files, genie, lakebase, server, serving } from "./plugins"; +export { + type AgentTool, + isToolkitEntry, + type ToolkitEntry, + type ToolkitOptions, +} from "./plugins/agents/types"; +export { + type FunctionTool, + type HostedTool, + isFunctionTool, + isHostedTool, + mcpServer, + type ToolConfig, + tool, +} from "./plugins/agents/tools"; export type { EndpointConfig, ServingEndpointEntry, diff --git a/packages/appkit/src/plugins/agents/build-toolkit.ts b/packages/appkit/src/plugins/agents/build-toolkit.ts new file mode 100644 index 00000000..540fec25 --- /dev/null +++ b/packages/appkit/src/plugins/agents/build-toolkit.ts @@ -0,0 +1,62 @@ +import type { AgentToolDefinition } from "shared"; +import type { ToolRegistry } from "./tools/define-tool"; +import { toToolJSONSchema } from "./tools/json-schema"; +import type { ToolkitEntry, ToolkitOptions } from "./types"; + +/** + * Converts a plugin's internal `ToolRegistry` into a keyed record of + * `ToolkitEntry` markers suitable for spreading into an `AgentDefinition.tools` + * record. + * + * The `opts` record controls shape and filtering: + * - `prefix` — overrides the default `${pluginName}.` prefix; `""` drops it. + * - `only` — allowlist of local tool names to include (post-prefix). + * - `except` — denylist of local names. + * - `rename` — per-tool key remapping (applied after prefix/filter). + * + * Each entry carries `pluginName` + `localName` so the agents plugin can + * dispatch back through `PluginContext.executeTool` for OBO + telemetry. + */ +export function buildToolkitEntries( + pluginName: string, + registry: ToolRegistry, + opts: ToolkitOptions = {}, +): Record { + const prefix = opts.prefix ?? `${pluginName}.`; + const only = opts.only ? new Set(opts.only) : null; + const except = opts.except ? new Set(opts.except) : null; + const rename = opts.rename ?? {}; + + const out: Record = {}; + + for (const [localName, entry] of Object.entries(registry)) { + if (only && !only.has(localName)) continue; + if (except?.has(localName)) continue; + + const keyAfterPrefix = `${prefix}${localName}`; + const key = rename[localName] ?? keyAfterPrefix; + + const parameters = toToolJSONSchema( + entry.schema, + ) as unknown as AgentToolDefinition["parameters"]; + + const def: AgentToolDefinition = { + name: key, + description: entry.description, + parameters, + }; + if (entry.annotations) { + def.annotations = entry.annotations; + } + + out[key] = { + __toolkitRef: true, + pluginName, + localName, + def, + annotations: entry.annotations, + }; + } + + return out; +} diff --git a/packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts b/packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts new file mode 100644 index 00000000..b1b6a60c --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { buildToolkitEntries } from "../build-toolkit"; +import { defineTool, type ToolRegistry } from "../tools/define-tool"; +import { isToolkitEntry } from "../types"; + +const registry: ToolRegistry = { + query: defineTool({ + description: "Run a query", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + history: defineTool({ + description: "Get query history", + schema: z.object({}), + handler: () => [], + }), +}; + +describe("buildToolkitEntries", () => { + test("produces ToolkitEntry per registry item with default dotted prefix", () => { + const entries = buildToolkitEntries("analytics", registry); + expect(Object.keys(entries).sort()).toEqual([ + "analytics.history", + "analytics.query", + ]); + for (const entry of Object.values(entries)) { + expect(isToolkitEntry(entry)).toBe(true); + expect(entry.pluginName).toBe("analytics"); + } + }); + + test("respects prefix option (empty drops the namespace)", () => { + const entries = buildToolkitEntries("analytics", registry, { prefix: "" }); + expect(Object.keys(entries).sort()).toEqual(["history", "query"]); + }); + + test("respects custom prefix", () => { + const entries = buildToolkitEntries("analytics", registry, { + prefix: "db.", + }); + expect(Object.keys(entries).sort()).toEqual(["db.history", "db.query"]); + }); + + test("only filter keeps the listed local names", () => { + const entries = buildToolkitEntries("analytics", registry, { + only: ["query"], + }); + expect(Object.keys(entries)).toEqual(["analytics.query"]); + }); + + test("except filter drops the listed local names", () => { + const entries = buildToolkitEntries("analytics", registry, { + except: ["history"], + }); + expect(Object.keys(entries)).toEqual(["analytics.query"]); + }); + + test("rename remaps specific local names (overrides the prefix key)", () => { + const entries = buildToolkitEntries("analytics", registry, { + rename: { query: "sql" }, + }); + expect(Object.keys(entries).sort()).toEqual(["analytics.history", "sql"]); + }); + + test("exposes the original plugin+local name so dispatch can route", () => { + const entries = buildToolkitEntries("analytics", registry, { + prefix: "db.", + }); + const qEntry = entries["db.query"]; + expect(qEntry.pluginName).toBe("analytics"); + expect(qEntry.localName).toBe("query"); + expect(qEntry.def.name).toBe("db.query"); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/define-tool.test.ts b/packages/appkit/src/plugins/agents/tests/define-tool.test.ts new file mode 100644 index 00000000..ef61e8c4 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/define-tool.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { + defineTool, + executeFromRegistry, + type ToolRegistry, + toolsFromRegistry, +} from "../tools/define-tool"; + +describe("defineTool()", () => { + test("returns an entry matching the input config", () => { + const entry = defineTool({ + description: "echo", + schema: z.object({ msg: z.string() }), + annotations: { readOnly: true }, + handler: ({ msg }) => msg, + }); + + expect(entry.description).toBe("echo"); + expect(entry.annotations).toEqual({ readOnly: true }); + expect(typeof entry.handler).toBe("function"); + }); +}); + +describe("executeFromRegistry", () => { + const registry: ToolRegistry = { + echo: defineTool({ + description: "echo", + schema: z.object({ msg: z.string() }), + handler: ({ msg }) => `got ${msg}`, + }), + }; + + test("validates args and calls handler on success", async () => { + const result = await executeFromRegistry(registry, "echo", { msg: "hi" }); + expect(result).toBe("got hi"); + }); + + test("returns formatted error string on validation failure", async () => { + const result = await executeFromRegistry(registry, "echo", {}); + expect(typeof result).toBe("string"); + expect(result).toContain("Invalid arguments for echo"); + expect(result).toContain("msg"); + }); + + test("throws for unknown tool names", async () => { + await expect(executeFromRegistry(registry, "missing", {})).rejects.toThrow( + /Unknown tool: missing/, + ); + }); + + test("forwards AbortSignal to the handler", async () => { + const handler = vi.fn(async (_args: { x: string }, signal?: AbortSignal) => + signal?.aborted ? "aborted" : "ok", + ); + const reg: ToolRegistry = { + t: defineTool({ + description: "t", + schema: z.object({ x: z.string() }), + handler, + }), + }; + + const controller = new AbortController(); + controller.abort(); + await executeFromRegistry(reg, "t", { x: "hi" }, controller.signal); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][1]).toBe(controller.signal); + }); +}); + +describe("toolsFromRegistry", () => { + test("produces AgentToolDefinition[] with JSON Schema parameters", () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "Execute a SQL query", + schema: z.object({ + query: z.string().describe("SQL query"), + }), + annotations: { readOnly: true, requiresUserContext: true }, + handler: () => "ok", + }), + }; + + const defs = toolsFromRegistry(registry); + expect(defs).toHaveLength(1); + expect(defs[0].name).toBe("query"); + expect(defs[0].description).toBe("Execute a SQL query"); + expect(defs[0].parameters).toMatchObject({ + type: "object", + properties: { + query: { type: "string", description: "SQL query" }, + }, + required: ["query"], + }); + expect(defs[0].annotations).toEqual({ + readOnly: true, + requiresUserContext: true, + }); + }); + + test("preserves dotted names like uploads.list from the registry keys", () => { + const registry: ToolRegistry = { + "uploads.list": defineTool({ + description: "list uploads", + schema: z.object({}), + handler: () => [], + }), + "documents.list": defineTool({ + description: "list documents", + schema: z.object({}), + handler: () => [], + }), + }; + + const names = toolsFromRegistry(registry).map((d) => d.name); + expect(names).toContain("uploads.list"); + expect(names).toContain("documents.list"); + }); + + test("omits annotations when none are provided", () => { + const registry: ToolRegistry = { + plain: defineTool({ + description: "plain", + schema: z.object({}), + handler: () => "ok", + }), + }; + const [def] = toolsFromRegistry(registry); + expect(def.annotations).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/function-tool.test.ts b/packages/appkit/src/plugins/agents/tests/function-tool.test.ts new file mode 100644 index 00000000..8e668d69 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/function-tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { + functionToolToDefinition, + isFunctionTool, +} from "../tools/function-tool"; + +describe("isFunctionTool", () => { + test("returns true for valid FunctionTool", () => { + expect( + isFunctionTool({ + type: "function", + name: "greet", + execute: async () => "hello", + }), + ).toBe(true); + }); + + test("returns true for minimal FunctionTool", () => { + expect( + isFunctionTool({ + type: "function", + name: "x", + execute: () => "y", + }), + ).toBe(true); + }); + + test("returns false for null", () => { + expect(isFunctionTool(null)).toBe(false); + }); + + test("returns false for non-object", () => { + expect(isFunctionTool("function")).toBe(false); + }); + + test("returns false for wrong type", () => { + expect( + isFunctionTool({ + type: "genie-space", + name: "x", + execute: () => "y", + }), + ).toBe(false); + }); + + test("returns false when execute is missing", () => { + expect(isFunctionTool({ type: "function", name: "x" })).toBe(false); + }); + + test("returns false when name is missing", () => { + expect(isFunctionTool({ type: "function", execute: () => "y" })).toBe( + false, + ); + }); +}); + +describe("functionToolToDefinition", () => { + test("converts a FunctionTool with all fields", () => { + const def = functionToolToDefinition({ + type: "function", + name: "getWeather", + description: "Get current weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + execute: async () => "sunny", + }); + + expect(def.name).toBe("getWeather"); + expect(def.description).toBe("Get current weather"); + expect(def.parameters).toEqual({ + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }); + }); + + test("uses name as fallback description", () => { + const def = functionToolToDefinition({ + type: "function", + name: "myTool", + execute: async () => "result", + }); + + expect(def.description).toBe("myTool"); + }); + + test("uses empty object schema when parameters are null", () => { + const def = functionToolToDefinition({ + type: "function", + name: "noParams", + parameters: null, + execute: async () => "ok", + }); + + expect(def.parameters).toEqual({ type: "object", properties: {} }); + }); + + test("uses empty object schema when parameters are omitted", () => { + const def = functionToolToDefinition({ + type: "function", + name: "noParams", + execute: async () => "ok", + }); + + expect(def.parameters).toEqual({ type: "object", properties: {} }); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/hosted-tools.test.ts b/packages/appkit/src/plugins/agents/tests/hosted-tools.test.ts new file mode 100644 index 00000000..d62b266b --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/hosted-tools.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "vitest"; +import { isHostedTool, resolveHostedTools } from "../tools/hosted-tools"; + +describe("isHostedTool", () => { + test("returns true for genie-space", () => { + expect( + isHostedTool({ type: "genie-space", genie_space: { id: "abc" } }), + ).toBe(true); + }); + + test("returns true for vector_search_index", () => { + expect( + isHostedTool({ + type: "vector_search_index", + vector_search_index: { name: "cat.schema.idx" }, + }), + ).toBe(true); + }); + + test("returns true for custom_mcp_server", () => { + expect( + isHostedTool({ + type: "custom_mcp_server", + custom_mcp_server: { app_name: "my-app", app_url: "my-app-url" }, + }), + ).toBe(true); + }); + + test("returns true for external_mcp_server", () => { + expect( + isHostedTool({ + type: "external_mcp_server", + external_mcp_server: { connection_name: "conn1" }, + }), + ).toBe(true); + }); + + test("returns false for FunctionTool", () => { + expect( + isHostedTool({ type: "function", name: "x", execute: () => "y" }), + ).toBe(false); + }); + + test("returns false for null", () => { + expect(isHostedTool(null)).toBe(false); + }); + + test("returns false for unknown type", () => { + expect(isHostedTool({ type: "unknown" })).toBe(false); + }); + + test("returns false for non-object", () => { + expect(isHostedTool(42)).toBe(false); + }); +}); + +describe("resolveHostedTools", () => { + test("resolves genie-space to correct MCP endpoint", () => { + const configs = resolveHostedTools([ + { type: "genie-space", genie_space: { id: "space123" } }, + ]); + + expect(configs).toHaveLength(1); + expect(configs[0].name).toBe("genie-space123"); + expect(configs[0].url).toBe("/api/2.0/mcp/genie/space123"); + }); + + test("resolves vector_search_index with 3-part name", () => { + const configs = resolveHostedTools([ + { + type: "vector_search_index", + vector_search_index: { name: "catalog.schema.my_index" }, + }, + ]); + + expect(configs).toHaveLength(1); + expect(configs[0].name).toBe("vs-catalog-schema-my_index"); + expect(configs[0].url).toBe( + "/api/2.0/mcp/vector-search/catalog/schema/my_index", + ); + }); + + test("throws for invalid vector_search_index name", () => { + expect(() => + resolveHostedTools([ + { + type: "vector_search_index", + vector_search_index: { name: "bad.name" }, + }, + ]), + ).toThrow("3-part dotted"); + }); + + test("resolves custom_mcp_server", () => { + const configs = resolveHostedTools([ + { + type: "custom_mcp_server", + custom_mcp_server: { app_name: "my-app", app_url: "my-app-endpoint" }, + }, + ]); + + expect(configs[0].name).toBe("my-app"); + expect(configs[0].url).toBe("my-app-endpoint"); + }); + + test("resolves external_mcp_server", () => { + const configs = resolveHostedTools([ + { + type: "external_mcp_server", + external_mcp_server: { connection_name: "conn1" }, + }, + ]); + + expect(configs[0].name).toBe("conn1"); + expect(configs[0].url).toBe("/api/2.0/mcp/external/conn1"); + }); + + test("resolves multiple tools preserving order", () => { + const configs = resolveHostedTools([ + { type: "genie-space", genie_space: { id: "g1" } }, + { + type: "external_mcp_server", + external_mcp_server: { connection_name: "e1" }, + }, + ]); + + expect(configs).toHaveLength(2); + expect(configs[0].name).toBe("genie-g1"); + expect(configs[1].name).toBe("e1"); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/mcp-server-helper.test.ts b/packages/appkit/src/plugins/agents/tests/mcp-server-helper.test.ts new file mode 100644 index 00000000..96ad8e38 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/mcp-server-helper.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { + isHostedTool, + mcpServer, + resolveHostedTools, +} from "../tools/hosted-tools"; + +describe("mcpServer()", () => { + test("returns a CustomMcpServerTool with correct shape", () => { + const result = mcpServer("my-app", "https://example.com/mcp"); + + expect(result).toEqual({ + type: "custom_mcp_server", + custom_mcp_server: { + app_name: "my-app", + app_url: "https://example.com/mcp", + }, + }); + }); + + test("isHostedTool recognizes mcpServer() output", () => { + expect(isHostedTool(mcpServer("x", "y"))).toBe(true); + }); + + test("resolveHostedTools resolves mcpServer() output to an endpoint config", () => { + const configs = resolveHostedTools([ + mcpServer("vector-search", "https://host/mcp/vs"), + ]); + + expect(configs).toHaveLength(1); + expect(configs[0].name).toBe("vector-search"); + expect(configs[0].url).toBe("https://host/mcp/vs"); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/tool.test.ts b/packages/appkit/src/plugins/agents/tests/tool.test.ts new file mode 100644 index 00000000..3d47f3a9 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { formatZodError, tool } from "../tools/tool"; + +describe("tool()", () => { + test("produces a FunctionTool with JSON Schema parameters from the Zod schema", () => { + const weather = tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ + city: z.string().describe("City name"), + }), + execute: async ({ city }) => `Sunny in ${city}`, + }); + + expect(weather.type).toBe("function"); + expect(weather.name).toBe("get_weather"); + expect(weather.description).toBe("Get the current weather for a city"); + expect(weather.parameters).toMatchObject({ + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }); + }); + + test("execute receives typed args on valid input", async () => { + const echo = tool({ + name: "echo", + schema: z.object({ message: z.string() }), + execute: async ({ message }) => { + const _typed: string = message; + return `got ${_typed}`; + }, + }); + + const result = await echo.execute({ message: "hi" }); + expect(result).toBe("got hi"); + }); + + test("returns formatted error string (does not throw) when args are invalid", async () => { + const weather = tool({ + name: "get_weather", + schema: z.object({ city: z.string() }), + execute: async ({ city }) => `Sunny in ${city}`, + }); + + const result = await weather.execute({}); + expect(typeof result).toBe("string"); + expect(result).toContain("Invalid arguments for get_weather"); + expect(result).toContain("city"); + }); + + test("joins multiple validation errors with '; '", async () => { + const t = tool({ + name: "multi", + schema: z.object({ a: z.string(), b: z.number() }), + execute: async () => "ok", + }); + + const result = await t.execute({}); + expect(result).toContain("a:"); + expect(result).toContain("b:"); + expect(result).toContain(";"); + }); + + test("optional fields validate when absent", async () => { + const t = tool({ + name: "opt", + schema: z.object({ note: z.string().optional() }), + execute: async ({ note }) => note ?? "(no note)", + }); + + expect(await t.execute({})).toBe("(no note)"); + expect(await t.execute({ note: "hello" })).toBe("hello"); + }); + + test("description falls back to the tool name when omitted", () => { + const t = tool({ + name: "my_tool", + schema: z.object({}), + execute: async () => "ok", + }); + + expect(t.description).toBe("my_tool"); + expect(t.parameters).toBeDefined(); + }); +}); + +describe("formatZodError", () => { + test("formats a single issue with the tool name", () => { + const schema = z.object({ city: z.string() }); + const result = schema.safeParse({}); + if (result.success) throw new Error("expected failure"); + + const msg = formatZodError(result.error, "get_weather"); + expect(msg).toMatch(/^Invalid arguments for get_weather: /); + expect(msg).toContain("city:"); + }); + + test("joins multiple issues with '; '", () => { + const schema = z.object({ a: z.string(), b: z.number() }); + const result = schema.safeParse({}); + if (result.success) throw new Error("expected failure"); + + const msg = formatZodError(result.error, "t"); + expect(msg.split(";").length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tools/define-tool.ts b/packages/appkit/src/plugins/agents/tools/define-tool.ts new file mode 100644 index 00000000..bcefceef --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/define-tool.ts @@ -0,0 +1,84 @@ +import type { AgentToolDefinition, ToolAnnotations } from "shared"; +import type { z } from "zod"; +import { toToolJSONSchema } from "./json-schema"; +import { formatZodError } from "./tool"; + +/** + * Single-tool entry for a plugin's internal tool registry. + * + * Plugins collect these into a `Record` keyed by the tool's + * public name and dispatch via `executeFromRegistry`. + */ +export interface ToolEntry { + description: string; + schema: S; + annotations?: ToolAnnotations; + handler: ( + args: z.infer, + signal?: AbortSignal, + ) => unknown | Promise; +} + +export type ToolRegistry = Record; + +/** + * Defines a single tool entry for a plugin's internal registry. + * + * The generic `S` flows from `schema` through to the `handler` callback so + * `args` is fully typed from the Zod schema. Names are assigned by the + * registry key, so they are not repeated inside the entry. + */ +export function defineTool( + config: ToolEntry, +): ToolEntry { + return config; +} + +/** + * Validates tool-call arguments against the entry's schema and invokes its + * handler. On validation failure, returns an LLM-friendly error string + * (matching the behavior of `tool()`) rather than throwing, so the model + * can self-correct on its next turn. + */ +export async function executeFromRegistry( + registry: ToolRegistry, + name: string, + args: unknown, + signal?: AbortSignal, +): Promise { + const entry = registry[name]; + if (!entry) { + throw new Error(`Unknown tool: ${name}`); + } + const parsed = entry.schema.safeParse(args); + if (!parsed.success) { + return formatZodError(parsed.error, name); + } + return entry.handler(parsed.data, signal); +} + +/** + * Produces the `AgentToolDefinition[]` a ToolProvider exposes to the LLM, + * deriving `parameters` JSON Schema from each entry's Zod schema. + * + * Tool names come from registry keys (supports dotted names like + * `uploads.list` for dynamic plugins). + */ +export function toolsFromRegistry( + registry: ToolRegistry, +): AgentToolDefinition[] { + return Object.entries(registry).map(([name, entry]) => { + const parameters = toToolJSONSchema( + entry.schema, + ) as unknown as AgentToolDefinition["parameters"]; + const def: AgentToolDefinition = { + name, + description: entry.description, + parameters, + }; + if (entry.annotations) { + def.annotations = entry.annotations; + } + return def; + }); +} diff --git a/packages/appkit/src/plugins/agents/tools/function-tool.ts b/packages/appkit/src/plugins/agents/tools/function-tool.ts new file mode 100644 index 00000000..8ce634e0 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/function-tool.ts @@ -0,0 +1,33 @@ +import type { AgentToolDefinition } from "shared"; + +export interface FunctionTool { + type: "function"; + name: string; + description?: string | null; + parameters?: Record | null; + strict?: boolean | null; + execute: (args: Record) => Promise | string; +} + +export function isFunctionTool(value: unknown): value is FunctionTool { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.type === "function" && + typeof obj.name === "string" && + typeof obj.execute === "function" + ); +} + +export function functionToolToDefinition( + tool: FunctionTool, +): AgentToolDefinition { + return { + name: tool.name, + description: tool.description ?? tool.name, + parameters: (tool.parameters as AgentToolDefinition["parameters"]) ?? { + type: "object", + properties: {}, + }, + }; +} diff --git a/packages/appkit/src/plugins/agents/tools/hosted-tools.ts b/packages/appkit/src/plugins/agents/tools/hosted-tools.ts new file mode 100644 index 00000000..bce70c4f --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/hosted-tools.ts @@ -0,0 +1,102 @@ +export interface GenieTool { + type: "genie-space"; + genie_space: { id: string }; +} + +export interface VectorSearchIndexTool { + type: "vector_search_index"; + vector_search_index: { name: string }; +} + +export interface CustomMcpServerTool { + type: "custom_mcp_server"; + custom_mcp_server: { app_name: string; app_url: string }; +} + +export interface ExternalMcpServerTool { + type: "external_mcp_server"; + external_mcp_server: { connection_name: string }; +} + +export type HostedTool = + | GenieTool + | VectorSearchIndexTool + | CustomMcpServerTool + | ExternalMcpServerTool; + +const HOSTED_TOOL_TYPES = new Set([ + "genie-space", + "vector_search_index", + "custom_mcp_server", + "external_mcp_server", +]); + +export function isHostedTool(value: unknown): value is HostedTool { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return typeof obj.type === "string" && HOSTED_TOOL_TYPES.has(obj.type); +} + +export interface McpEndpointConfig { + name: string; + /** Absolute URL or path relative to workspace host */ + url: string; +} + +/** + * Resolves HostedTool configs into MCP endpoint configurations + * that the MCP client can connect to. + */ +function resolveHostedTool(tool: HostedTool): McpEndpointConfig { + switch (tool.type) { + case "genie-space": + return { + name: `genie-${tool.genie_space.id}`, + url: `/api/2.0/mcp/genie/${tool.genie_space.id}`, + }; + case "vector_search_index": { + const parts = tool.vector_search_index.name.split("."); + if (parts.length !== 3) { + throw new Error( + `vector_search_index name must be 3-part dotted (catalog.schema.index), got: ${tool.vector_search_index.name}`, + ); + } + return { + name: `vs-${parts.join("-")}`, + url: `/api/2.0/mcp/vector-search/${parts[0]}/${parts[1]}/${parts[2]}`, + }; + } + case "custom_mcp_server": + return { + name: tool.custom_mcp_server.app_name, + url: tool.custom_mcp_server.app_url, + }; + case "external_mcp_server": + return { + name: tool.external_mcp_server.connection_name, + url: `/api/2.0/mcp/external/${tool.external_mcp_server.connection_name}`, + }; + } +} + +export function resolveHostedTools(tools: HostedTool[]): McpEndpointConfig[] { + return tools.map(resolveHostedTool); +} + +/** + * Factory for declaring a custom MCP server tool. + * + * Replaces the verbose `{ type: "custom_mcp_server", custom_mcp_server: { app_name, app_url } }` + * wrapper with a concise positional call. + * + * Example: + * ```ts + * mcpServer("my-app", "https://my-app.databricksapps.com/mcp") + * ``` + */ +export function mcpServer(name: string, url: string): CustomMcpServerTool { + return { + type: "custom_mcp_server", + custom_mcp_server: { app_name: name, app_url: url }, + }; +} diff --git a/packages/appkit/src/plugins/agents/tools/index.ts b/packages/appkit/src/plugins/agents/tools/index.ts new file mode 100644 index 00000000..7b779d1c --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/index.ts @@ -0,0 +1,20 @@ +export { + defineTool, + executeFromRegistry, + type ToolEntry, + type ToolRegistry, + toolsFromRegistry, +} from "./define-tool"; +export { + type FunctionTool, + functionToolToDefinition, + isFunctionTool, +} from "./function-tool"; +export { + type HostedTool, + isHostedTool, + mcpServer, + resolveHostedTools, +} from "./hosted-tools"; +export { AppKitMcpClient } from "./mcp-client"; +export { type ToolConfig, tool } from "./tool"; diff --git a/packages/appkit/src/plugins/agents/tools/json-schema.ts b/packages/appkit/src/plugins/agents/tools/json-schema.ts new file mode 100644 index 00000000..805fd48f --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/json-schema.ts @@ -0,0 +1,22 @@ +import { toJSONSchema, type z } from "zod"; + +/** + * Converts a Zod schema to JSON Schema suitable for an LLM tool-call + * `parameters` field. + * + * Wraps `zod`'s `toJSONSchema()` and strips the top-level `$schema` annotation + * that Zod v4 emits by default (e.g. `"https://json-schema.org/draft/..."`). + * The Databricks Mosaic serving endpoint forwards tool schemas to Google's + * Gemini `function_declarations` format, which rejects any top-level key it + * doesn't explicitly recognize — including `$schema` — with a 400 + * `Invalid JSON payload received. Unknown name "$schema"` error. Other LLM + * providers either ignore the field or also trip on it, so stripping here is + * safe across backends. + */ +export function toToolJSONSchema( + schema: z.ZodType, +): Record { + const raw = toJSONSchema(schema) as Record; + const { $schema: _ignored, ...rest } = raw; + return rest; +} diff --git a/packages/appkit/src/plugins/agents/tools/mcp-client.ts b/packages/appkit/src/plugins/agents/tools/mcp-client.ts new file mode 100644 index 00000000..bd96d348 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/mcp-client.ts @@ -0,0 +1,278 @@ +import type { AgentToolDefinition } from "shared"; +import { createLogger } from "../../../logging/logger"; +import type { McpEndpointConfig } from "./hosted-tools"; + +const logger = createLogger("agent:mcp"); + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +interface McpToolSchema { + name: string; + description?: string; + inputSchema?: Record; +} + +interface McpToolCallResult { + content: Array<{ type: string; text?: string }>; + isError?: boolean; +} + +interface McpServerConnection { + config: McpEndpointConfig; + resolvedUrl: string; + tools: Map; +} + +/** + * Lightweight MCP client for Databricks-hosted MCP servers. + * + * Uses raw fetch() with JSON-RPC 2.0 over HTTP — no @modelcontextprotocol/sdk + * or LangChain dependency. Supports the Streamable HTTP transport (POST with + * JSON-RPC request, single JSON-RPC response). + */ +export class AppKitMcpClient { + private connections = new Map(); + private sessionIds = new Map(); + private requestId = 0; + private closed = false; + + constructor( + private workspaceHost: string, + private authenticate: () => Promise>, + ) {} + + async connectAll(endpoints: McpEndpointConfig[]): Promise { + const results = await Promise.allSettled( + endpoints.map((ep) => this.connect(ep)), + ); + for (let i = 0; i < results.length; i++) { + if (results[i].status === "rejected") { + logger.error( + "Failed to connect MCP server %s: %O", + endpoints[i].name, + (results[i] as PromiseRejectedResult).reason, + ); + } + } + } + + private resolveUrl(endpoint: McpEndpointConfig): string { + if ( + endpoint.url.startsWith("http://") || + endpoint.url.startsWith("https://") + ) { + return endpoint.url; + } + return `${this.workspaceHost}${endpoint.url}`; + } + + async connect(endpoint: McpEndpointConfig): Promise { + const url = this.resolveUrl(endpoint); + logger.info("Connecting to MCP server: %s at %s", endpoint.name, url); + + const initResponse = await this.sendRpc(url, "initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "appkit-agent", version: "0.1.0" }, + }); + + if (initResponse.sessionId) { + this.sessionIds.set(endpoint.name, initResponse.sessionId); + } + const sessionId = this.sessionIds.get(endpoint.name); + + await this.sendNotification(url, "notifications/initialized", sessionId); + + const listResponse = await this.sendRpc( + url, + "tools/list", + {}, + { sessionId }, + ); + const toolList = + (listResponse.result as { tools?: McpToolSchema[] })?.tools ?? []; + + const tools = new Map(); + for (const tool of toolList) { + tools.set(tool.name, tool); + } + + this.connections.set(endpoint.name, { + config: endpoint, + resolvedUrl: url, + tools, + }); + logger.info( + "Connected to MCP server %s: %d tools available", + endpoint.name, + tools.size, + ); + } + + getAllToolDefinitions(): AgentToolDefinition[] { + const defs: AgentToolDefinition[] = []; + for (const [serverName, conn] of this.connections) { + for (const [toolName, schema] of conn.tools) { + defs.push({ + name: `mcp.${serverName}.${toolName}`, + description: schema.description ?? toolName, + parameters: + (schema.inputSchema as AgentToolDefinition["parameters"]) ?? { + type: "object", + properties: {}, + }, + }); + } + } + return defs; + } + + async callTool( + qualifiedName: string, + args: unknown, + authHeaders?: Record, + ): Promise { + const parts = qualifiedName.split("."); + if (parts.length < 3 || parts[0] !== "mcp") { + throw new Error(`Invalid MCP tool name: ${qualifiedName}`); + } + const serverName = parts[1]; + const toolName = parts.slice(2).join("."); + + const conn = this.connections.get(serverName); + if (!conn) { + throw new Error(`MCP server not connected: ${serverName}`); + } + + const sessionId = this.sessionIds.get(serverName); + const rpcResult = await this.sendRpc( + conn.resolvedUrl, + "tools/call", + { name: toolName, arguments: args }, + { authOverride: authHeaders, sessionId }, + ); + const result = rpcResult.result as McpToolCallResult; + + if (result.isError) { + const errText = result.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + throw new Error(errText || "MCP tool call failed"); + } + + return result.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + + async close(): Promise { + this.closed = true; + this.connections.clear(); + } + + private async sendRpc( + url: string, + method: string, + params?: Record, + options?: { + authOverride?: Record; + sessionId?: string; + }, + ): Promise<{ result: unknown; sessionId?: string }> { + if (this.closed) throw new Error("MCP client is closed"); + + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id: ++this.requestId, + method, + ...(params && { params }), + }; + + const authHeaders = options?.authOverride ?? (await this.authenticate()); + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...authHeaders, + }; + if (options?.sessionId) { + headers["Mcp-Session-Id"] = options.sessionId; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(request), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error( + `MCP request to ${method} failed: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get("content-type") ?? ""; + let json: JsonRpcResponse; + + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + const lastData = text + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .pop(); + if (!lastData) { + throw new Error(`MCP SSE response for ${method} contained no data`); + } + json = JSON.parse(lastData) as JsonRpcResponse; + } else { + json = (await response.json()) as JsonRpcResponse; + } + + if (json.error) { + throw new Error(`MCP error (${json.error.code}): ${json.error.message}`); + } + + const sid = response.headers.get("mcp-session-id") ?? undefined; + return { result: json.result, sessionId: sid }; + } + + private async sendNotification( + url: string, + method: string, + sessionId?: string, + ): Promise { + if (this.closed) return; + + const authHeaders = await this.authenticate(); + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...authHeaders, + }; + if (sessionId) { + headers["Mcp-Session-Id"] = sessionId; + } + + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", method }), + signal: AbortSignal.timeout(30_000), + }); + } +} diff --git a/packages/appkit/src/plugins/agents/tools/tool.ts b/packages/appkit/src/plugins/agents/tools/tool.ts new file mode 100644 index 00000000..b5d4db65 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tools/tool.ts @@ -0,0 +1,53 @@ +import type { z } from "zod"; +import type { FunctionTool } from "./function-tool"; +import { toToolJSONSchema } from "./json-schema"; + +export interface ToolConfig { + name: string; + description?: string; + schema: S; + execute: (args: z.infer) => Promise | string; +} + +/** + * Factory for defining function tools with Zod schemas. + * + * - Generates JSON Schema (for the LLM) from the Zod schema via `z.toJSONSchema()`. + * - Infers the `execute` argument type from the schema. + * - Validates tool call arguments at runtime. On validation failure, returns + * a formatted error string to the LLM instead of throwing, so the model + * can self-correct on its next turn. + */ +export function tool(config: ToolConfig): FunctionTool { + const parameters = toToolJSONSchema(config.schema) as unknown as Record< + string, + unknown + >; + + return { + type: "function", + name: config.name, + description: config.description ?? config.name, + parameters, + execute: async (args: Record) => { + const parsed = config.schema.safeParse(args); + if (!parsed.success) { + return formatZodError(parsed.error, config.name); + } + return config.execute(parsed.data as z.infer); + }, + }; +} + +/** + * Formats a Zod validation error into an LLM-friendly string. + * + * Example: `Invalid arguments for get_weather: city: Invalid input: expected string, received undefined` + */ +export function formatZodError(error: z.ZodError, toolName: string): string { + const parts = error.issues.map((issue) => { + const field = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + return `${field}: ${issue.message}`; + }); + return `Invalid arguments for ${toolName}: ${parts.join("; ")}`; +} diff --git a/packages/appkit/src/plugins/agents/types.ts b/packages/appkit/src/plugins/agents/types.ts new file mode 100644 index 00000000..861be26d --- /dev/null +++ b/packages/appkit/src/plugins/agents/types.ts @@ -0,0 +1,47 @@ +import type { AgentToolDefinition, ToolAnnotations } from "shared"; +import type { FunctionTool } from "./tools/function-tool"; +import type { HostedTool } from "./tools/hosted-tools"; + +/** + * A tool reference produced by a plugin's `.toolkit()` call. The agents plugin + * recognizes the `__toolkitRef` brand and dispatches tool invocations through + * `PluginContext.executeTool(req, pluginName, localName, ...)`, preserving + * OBO (asUser) and telemetry spans. + */ +export interface ToolkitEntry { + readonly __toolkitRef: true; + pluginName: string; + localName: string; + def: AgentToolDefinition; + annotations?: ToolAnnotations; +} + +/** + * Any tool an agent can invoke: inline function tools (`tool()`), hosted MCP + * tools (`mcpServer()` / raw hosted), or toolkit references from plugins + * (`analytics().toolkit()`). + */ +export type AgentTool = FunctionTool | HostedTool | ToolkitEntry; + +export interface ToolkitOptions { + /** Key prefix to prepend to each tool's local name. Defaults to `${pluginName}.`. */ + prefix?: string; + /** Only include tools whose local name matches one of these. */ + only?: string[]; + /** Exclude tools whose local name matches one of these. */ + except?: string[]; + /** Remap specific local names to different keys (applied after prefix). */ + rename?: Record; +} + +/** + * Type guard for `ToolkitEntry` — used to differentiate toolkit references + * from inline tools in a mixed `tools` record. + */ +export function isToolkitEntry(value: unknown): value is ToolkitEntry { + return ( + typeof value === "object" && + value !== null && + (value as { __toolkitRef?: unknown }).__toolkitRef === true + ); +} diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index a9c688da..26f326cc 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -1,16 +1,25 @@ import type { WorkspaceClient } from "@databricks/sdk-experimental"; import type express from "express"; import type { + AgentToolDefinition, IAppRouter, PluginExecuteConfig, SQLTypeMarker, StreamExecutionSettings, + ToolProvider, } from "shared"; +import { z } from "zod"; import { SQLWarehouseConnector } from "../../connectors"; import { getWarehouseId, getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { buildToolkitEntries } from "../agents/build-toolkit"; +import { + defineTool, + executeFromRegistry, + toolsFromRegistry, +} from "../agents/tools/define-tool"; import { queryDefaults } from "./defaults"; import manifest from "./manifest.json"; import { QueryProcessor } from "./query"; @@ -22,7 +31,7 @@ import type { const logger = createLogger("analytics"); -export class AnalyticsPlugin extends Plugin { +export class AnalyticsPlugin extends Plugin implements ToolProvider { /** Plugin manifest declaring metadata and resource requirements */ static manifest = manifest as PluginManifest<"analytics">; @@ -262,6 +271,45 @@ export class AnalyticsPlugin extends Plugin { this.streamManager.abortAll(); } + private tools = { + query: defineTool({ + description: + "Execute a SQL query against the Databricks SQL warehouse. Returns the query results as JSON.", + schema: z.object({ + query: z.string().describe("The SQL query to execute"), + }), + annotations: { + readOnly: true, + requiresUserContext: true, + }, + handler: (args, signal) => + this.query(args.query, undefined, undefined, signal), + }), + }; + + getAgentTools(): AgentToolDefinition[] { + return toolsFromRegistry(this.tools); + } + + async executeAgentTool( + name: string, + args: unknown, + signal?: AbortSignal, + ): Promise { + return executeFromRegistry(this.tools, name, args, signal); + } + + /** + * Returns the plugin's tools as a keyed record of `ToolkitEntry` markers. + * Called by the agents plugin (via `resolveToolkitFromProvider`) to spread + * a filtered, renamed view of the plugin's tools into an agent's tool + * index. Most callers should go through `fromPlugin(analytics, opts)` at + * module scope instead of reaching for this directly. + */ + toolkit(opts?: import("../agents/types").ToolkitOptions) { + return buildToolkitEntries(this.name, this.tools, opts); + } + /** * Returns the public exports for the analytics plugin. * Note: `asUser()` is automatically added by AppKit. diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts index 9a30440e..29157fff 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts @@ -608,4 +608,22 @@ describe("Analytics Plugin", () => { }); }); }); + + describe("toolkit()", () => { + test("produces ToolkitEntry records keyed by the plugin name", () => { + const plugin = new AnalyticsPlugin({ name: "analytics" }); + const entries = plugin.toolkit(); + expect(Object.keys(entries)).toContain("analytics.query"); + const entry = entries["analytics.query"]; + expect(entry.__toolkitRef).toBe(true); + expect(entry.pluginName).toBe("analytics"); + expect(entry.localName).toBe("query"); + }); + + test("respects prefix and only options", () => { + const plugin = new AnalyticsPlugin({ name: "analytics" }); + const entries = plugin.toolkit({ prefix: "", only: ["query"] }); + expect(Object.keys(entries)).toEqual(["query"]); + }); + }); }); diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 9344af85..cb588352 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -2,7 +2,13 @@ import { STATUS_CODES } from "node:http"; import { Readable } from "node:stream"; import { ApiError } from "@databricks/sdk-experimental"; import type express from "express"; -import type { IAppRouter, PluginExecutionSettings } from "shared"; +import type { + AgentToolDefinition, + IAppRouter, + PluginExecutionSettings, + ToolProvider, +} from "shared"; +import { z } from "zod"; import { contentTypeFromPath, FilesConnector, @@ -15,6 +21,13 @@ import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest, ResourceRequirement } from "../../registry"; import { ResourceType } from "../../registry"; +import { buildToolkitEntries } from "../agents/build-toolkit"; +import { + defineTool, + executeFromRegistry, + type ToolRegistry, + toolsFromRegistry, +} from "../agents/tools/define-tool"; import { FILES_DOWNLOAD_DEFAULTS, FILES_MAX_UPLOAD_SIZE, @@ -34,7 +47,7 @@ import type { const logger = createLogger("files"); -export class FilesPlugin extends Plugin { +export class FilesPlugin extends Plugin implements ToolProvider { name = "files"; /** Plugin manifest declaring metadata and resource requirements. */ @@ -45,6 +58,7 @@ export class FilesPlugin extends Plugin { private volumeConnectors: Record = {}; private volumeConfigs: Record = {}; private volumeKeys: string[] = []; + private tools: ToolRegistry = {}; /** * Scans `process.env` for `DATABRICKS_VOLUME_*` keys and merges them with @@ -148,6 +162,79 @@ export class FilesPlugin extends Plugin { customContentTypes: mergedConfig.customContentTypes, }); } + + for (const volumeKey of this.volumeKeys) { + Object.assign(this.tools, this._defineVolumeTools(volumeKey)); + } + } + + /** + * Builds the registry entries for a single volume. One set of tools per + * configured volume, keyed by `${volumeKey}.${method}`. + */ + private _defineVolumeTools(volumeKey: string): ToolRegistry { + const api = () => this.createVolumeAPI(volumeKey); + return { + [`${volumeKey}.list`]: defineTool({ + description: `List files and directories in the "${volumeKey}" volume`, + schema: z.object({ + path: z + .string() + .optional() + .describe("Directory path to list (optional, defaults to root)"), + }), + annotations: { readOnly: true, requiresUserContext: true }, + handler: (args) => api().list(args.path), + }), + [`${volumeKey}.read`]: defineTool({ + description: `Read a text file from the "${volumeKey}" volume`, + schema: z.object({ + path: z.string().describe("File path to read"), + }), + annotations: { readOnly: true, requiresUserContext: true }, + handler: (args) => api().read(args.path), + }), + [`${volumeKey}.exists`]: defineTool({ + description: `Check if a file or directory exists in the "${volumeKey}" volume`, + schema: z.object({ + path: z.string().describe("Path to check"), + }), + annotations: { readOnly: true, requiresUserContext: true }, + handler: (args) => api().exists(args.path), + }), + [`${volumeKey}.metadata`]: defineTool({ + description: `Get metadata (size, type, last modified) for a file in the "${volumeKey}" volume`, + schema: z.object({ + path: z.string().describe("File path"), + }), + annotations: { readOnly: true, requiresUserContext: true }, + handler: (args) => api().metadata(args.path), + }), + [`${volumeKey}.upload`]: defineTool({ + description: `Upload a text file to the "${volumeKey}" volume`, + schema: z.object({ + path: z.string().describe("Destination file path"), + contents: z.string().describe("File contents as a string"), + overwrite: z + .boolean() + .optional() + .describe("Whether to overwrite existing file"), + }), + annotations: { destructive: true, requiresUserContext: true }, + handler: (args) => + api().upload(args.path, args.contents, { + overwrite: args.overwrite, + }), + }), + [`${volumeKey}.delete`]: defineTool({ + description: `Delete a file from the "${volumeKey}" volume`, + schema: z.object({ + path: z.string().describe("File path to delete"), + }), + annotations: { destructive: true, requiresUserContext: true }, + handler: (args) => api().delete(args.path), + }), + }; } /** @@ -950,6 +1037,23 @@ export class FilesPlugin extends Plugin { * appKit.files("uploads").list() * ``` */ + + getAgentTools(): AgentToolDefinition[] { + return toolsFromRegistry(this.tools); + } + + async executeAgentTool( + name: string, + args: unknown, + signal?: AbortSignal, + ): Promise { + return executeFromRegistry(this.tools, name, args, signal); + } + + toolkit(opts?: import("../agents/types").ToolkitOptions) { + return buildToolkitEntries(this.name, this.tools, opts); + } + exports(): FilesExport { const resolveVolume = (volumeKey: string): VolumeHandle => { if (!this.volumeKeys.includes(volumeKey)) { diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index 99e08b8c..17591a45 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -204,6 +204,62 @@ describe("FilesPlugin", () => { }); }); + describe("getAgentTools / executeAgentTool", () => { + test("produces independent tool entries per volume", () => { + const plugin = new FilesPlugin(VOLUMES_CONFIG); + const tools = plugin.getAgentTools(); + const names = tools.map((t) => t.name); + + expect(names).toContain("uploads.list"); + expect(names).toContain("uploads.read"); + expect(names).toContain("uploads.exists"); + expect(names).toContain("uploads.metadata"); + expect(names).toContain("uploads.upload"); + expect(names).toContain("uploads.delete"); + + expect(names).toContain("exports.list"); + expect(names).toContain("exports.read"); + expect(names).toContain("exports.delete"); + + expect(tools).toHaveLength(12); + }); + + test("dispatches to the correct volume API based on the tool name", async () => { + const plugin = new FilesPlugin(VOLUMES_CONFIG); + const asyncIterable = (items: { path: string }[]) => ({ + [Symbol.asyncIterator]: async function* () { + for (const item of items) yield item; + }, + }); + mockClient.files.listDirectoryContents.mockReturnValueOnce( + asyncIterable([{ path: "uploads-file" }]), + ); + mockClient.files.listDirectoryContents.mockReturnValueOnce( + asyncIterable([{ path: "exports-file" }]), + ); + + const uploadsResult = (await plugin.executeAgentTool( + "uploads.list", + {}, + )) as { path: string }[]; + const exportsResult = (await plugin.executeAgentTool( + "exports.list", + {}, + )) as { path: string }[]; + + expect(uploadsResult[0].path).toBe("uploads-file"); + expect(exportsResult[0].path).toBe("exports-file"); + }); + + test("returns LLM-friendly error string for invalid tool args", async () => { + const plugin = new FilesPlugin(VOLUMES_CONFIG); + const result = await plugin.executeAgentTool("uploads.read", {}); + expect(typeof result).toBe("string"); + expect(result).toContain("Invalid arguments for uploads.read"); + expect(result).toContain("path"); + }); + }); + describe("exports()", () => { test("returns a callable function with a .volume alias", () => { const plugin = new FilesPlugin(VOLUMES_CONFIG); diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 712aadbf..0c251994 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -1,11 +1,24 @@ import { randomUUID } from "node:crypto"; import type express from "express"; -import type { IAppRouter, StreamExecutionSettings } from "shared"; +import type { + AgentToolDefinition, + IAppRouter, + StreamExecutionSettings, + ToolProvider, +} from "shared"; +import { z } from "zod"; import { GenieConnector } from "../../connectors"; import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging"; import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { buildToolkitEntries } from "../agents/build-toolkit"; +import { + defineTool, + executeFromRegistry, + type ToolRegistry, + toolsFromRegistry, +} from "../agents/tools/define-tool"; import { genieStreamDefaults } from "./defaults"; import manifest from "./manifest.json"; import type { @@ -17,7 +30,7 @@ import type { const logger = createLogger("genie"); -export class GeniePlugin extends Plugin { +export class GeniePlugin extends Plugin implements ToolProvider { static manifest = manifest as PluginManifest<"genie">; protected static description = @@ -25,6 +38,7 @@ export class GeniePlugin extends Plugin { protected declare config: IGenieConfig; private readonly genieConnector: GenieConnector; + private tools: ToolRegistry = {}; constructor(config: IGenieConfig) { super(config); @@ -36,6 +50,53 @@ export class GeniePlugin extends Plugin { timeout: this.config.timeout, maxMessages: 200, }); + + for (const alias of Object.keys(this.config.spaces ?? {})) { + Object.assign(this.tools, this._defineSpaceTools(alias)); + } + } + + /** + * Builds the registry entries for a single Genie space alias. + * One set of tools per configured space, keyed by `${alias}.${method}`. + */ + private _defineSpaceTools(alias: string): ToolRegistry { + return { + [`${alias}.sendMessage`]: defineTool({ + description: `Send a natural language question to the Genie space "${alias}" and get data analysis results`, + schema: z.object({ + content: z.string().describe("The natural language question to ask"), + conversationId: z + .string() + .optional() + .describe( + "Optional conversation ID to continue an existing conversation", + ), + }), + annotations: { requiresUserContext: true }, + handler: async (args) => { + const events: GenieStreamEvent[] = []; + for await (const event of this.sendMessage( + alias, + args.content, + args.conversationId, + )) { + events.push(event); + } + return events; + }, + }), + [`${alias}.getConversation`]: defineTool({ + description: `Retrieve the conversation history from the Genie space "${alias}"`, + schema: z.object({ + conversationId: z + .string() + .describe("The conversation ID to retrieve"), + }), + annotations: { readOnly: true, requiresUserContext: true }, + handler: (args) => this.getConversation(alias, args.conversationId), + }), + }; } private defaultSpaces(): Record { @@ -287,6 +348,22 @@ export class GeniePlugin extends Plugin { this.streamManager.abortAll(); } + getAgentTools(): AgentToolDefinition[] { + return toolsFromRegistry(this.tools); + } + + async executeAgentTool( + name: string, + args: unknown, + signal?: AbortSignal, + ): Promise { + return executeFromRegistry(this.tools, name, args, signal); + } + + toolkit(opts?: import("../agents/types").ToolkitOptions) { + return buildToolkitEntries(this.name, this.tools, opts); + } + exports() { return { sendMessage: this.sendMessage, diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 3cf0784d..672e6242 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -187,6 +187,30 @@ describe("Genie Plugin", () => { }); }); + describe("getAgentTools / executeAgentTool", () => { + test("produces independent tool entries per configured space", () => { + const plugin = new GeniePlugin(config); + const names = plugin.getAgentTools().map((t) => t.name); + + expect(names).toContain("myspace.sendMessage"); + expect(names).toContain("myspace.getConversation"); + expect(names).toContain("salesbot.sendMessage"); + expect(names).toContain("salesbot.getConversation"); + expect(names).toHaveLength(4); + }); + + test("returns LLM-friendly error string for invalid tool args", async () => { + const plugin = new GeniePlugin(config); + const result = await plugin.executeAgentTool( + "myspace.getConversation", + {}, + ); + expect(typeof result).toBe("string"); + expect(result).toContain("Invalid arguments for myspace.getConversation"); + expect(result).toContain("conversationId"); + }); + }); + describe("space alias resolution", () => { test("should return 404 for unknown alias", async () => { const plugin = new GeniePlugin(config); diff --git a/packages/appkit/src/plugins/lakebase/lakebase.ts b/packages/appkit/src/plugins/lakebase/lakebase.ts index 3071d539..f1866d39 100644 --- a/packages/appkit/src/plugins/lakebase/lakebase.ts +++ b/packages/appkit/src/plugins/lakebase/lakebase.ts @@ -1,4 +1,6 @@ import type { Pool, QueryResult, QueryResultRow } from "pg"; +import type { AgentToolDefinition, ToolProvider } from "shared"; +import { z } from "zod"; import { createLakebasePool, getLakebaseOrmConfig, @@ -8,6 +10,12 @@ import { import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { buildToolkitEntries } from "../agents/build-toolkit"; +import { + defineTool, + executeFromRegistry, + toolsFromRegistry, +} from "../agents/tools/define-tool"; import manifest from "./manifest.json"; import type { ILakebaseConfig } from "./types"; @@ -30,7 +38,7 @@ const logger = createLogger("lakebase"); * const result = await AppKit.lakebase.query("SELECT * FROM users WHERE id = $1", [userId]); * ``` */ -class LakebasePlugin extends Plugin { +class LakebasePlugin extends Plugin implements ToolProvider { /** Plugin manifest declaring metadata and resource requirements */ static manifest = manifest as PluginManifest<"lakebase">; @@ -102,6 +110,50 @@ class LakebasePlugin extends Plugin { * - `getOrmConfig()` — Returns a config object compatible with Drizzle, TypeORM, Sequelize, etc. * - `getPgConfig()` — Returns a `pg.PoolConfig` object for manual pool construction */ + + private tools = { + query: defineTool({ + description: + "Execute a parameterized SQL query against the Lakebase PostgreSQL database. Use $1, $2, etc. as placeholders and pass values separately.", + schema: z.object({ + text: z + .string() + .describe( + "SQL query string with $1, $2, ... placeholders for parameters", + ), + values: z + .array(z.unknown()) + .optional() + .describe("Parameter values corresponding to placeholders"), + }), + annotations: { + readOnly: false, + destructive: false, + idempotent: false, + }, + handler: async (args) => { + const result = await this.query(args.text, args.values); + return result.rows; + }, + }), + }; + + getAgentTools(): AgentToolDefinition[] { + return toolsFromRegistry(this.tools); + } + + async executeAgentTool( + name: string, + args: unknown, + signal?: AbortSignal, + ): Promise { + return executeFromRegistry(this.tools, name, args, signal); + } + + toolkit(opts?: import("../agents/types").ToolkitOptions) { + return buildToolkitEntries(this.name, this.tools, opts); + } + exports() { return { // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup(), which AppKit always awaits before exposing the plugin API From e26795bdc134ff0e910e3c76f708c7e2cd3aac6b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:48:00 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(appkit):=20plugin=20infrastructure=20?= =?UTF-8?q?=E2=80=94=20attachContext=20lifecycle=20+=20PluginContext=20med?= =?UTF-8?q?iator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third layer: the substrate every downstream PR relies on. No user- facing API changes here; the surface for this PR is the mediator pattern, lifecycle semantics, and factory stamping. ### Split Plugin construction from context binding `Plugin` constructors become pure — no `CacheManager.getInstanceSync()`, no `TelemetryManager.getProvider()`, no `PluginContext` wiring inside `constructor()`. That work moves to a new lifecycle method: ```ts interface BasePlugin { attachContext?(deps: { context?: unknown; telemetryConfig?: TelemetryOptions; }): void; } ``` `createApp` calls `attachContext()` on every plugin after all constructors have run, before `setup()`. This lets factories return `PluginData` tuples at module scope without pulling core services into the import graph — a prerequisite for later PRs that construct agent definitions before `createApp`. ### PluginContext mediator `packages/appkit/src/core/plugin-context.ts` — new class that mediates all inter-plugin communication: - **Route buffering**: `addRoute()` / `addMiddleware()` buffer until the server plugin calls `registerAsRouteTarget()`, then flush via `addExtension()`. Eliminates plugin-ordering fragility. - **ToolProvider registry**: `registerToolProvider(name, plugin)` + live `getToolProviders()`. Typed discovery of tool-exposing plugins. - **User-scoped tool execution**: `executeTool(req, pluginName, localName, args, signal?)` resolves the provider, wraps in `asUser(req)` for OBO, opens a telemetry span, applies a 30s timeout, dispatches, returns. - **Lifecycle hooks**: `onLifecycle('setup:complete' | 'server:ready' | 'shutdown', cb)` + `emitLifecycle(event)`. Callback errors don't block siblings. ### `toPlugin` stamps `pluginName` `packages/appkit/src/plugin/to-plugin.ts` — the factory now attaches a read-only `pluginName` property to the returned function. Later PRs' `fromPlugin(factory)` reads it to identify which plugin a factory refers to without needing to construct an instance. `NamedPluginFactory` type exported for consumers who want to type-constrain factories. ### Server plugin defers start to `setup:complete` `ServerPlugin.setup()` no longer calls `extendRoutes()` synchronously. It subscribes to the `setup:complete` lifecycle event via `PluginContext` and starts the HTTP server there. This ensures that any deferred-phase plugin (agents plugin in a later PR) has had a chance to register routes via `PluginContext.addRoute()` before the server binds. Removes the `plugins` field from `ServerConfig` (routes are now discovered via the context, not a config snapshot). ### Test plan - 25 new PluginContext tests (route buffering, tool provider registry, executeTool paths, lifecycle hooks, plugin metadata) - Updated AppKit lifecycle tests to inject `context` instead of `plugins` - Full appkit vitest suite: 1237 tests passing - Typecheck clean across all 8 workspace projects Signed-off-by: MarioCadenas --- packages/appkit/src/core/appkit.ts | 27 +- packages/appkit/src/core/plugin-context.ts | 287 ++++++++++++++++ .../appkit/src/core/tests/databricks.test.ts | 15 +- .../src/core/tests/plugin-context.test.ts | 325 ++++++++++++++++++ packages/appkit/src/plugin/index.ts | 2 +- packages/appkit/src/plugin/plugin.ts | 56 ++- packages/appkit/src/plugin/to-plugin.ts | 32 +- packages/appkit/src/plugins/server/index.ts | 41 ++- .../src/plugins/server/tests/server.test.ts | 42 ++- packages/appkit/src/plugins/server/types.ts | 2 - packages/shared/src/plugin.ts | 9 + 11 files changed, 799 insertions(+), 39 deletions(-) create mode 100644 packages/appkit/src/core/plugin-context.ts create mode 100644 packages/appkit/src/core/tests/plugin-context.test.ts diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index a2cba994..a0c2e566 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -13,14 +13,18 @@ import { ServiceContext } from "../context"; import { ResourceRegistry, ResourceType } from "../registry"; import type { TelemetryConfig } from "../telemetry"; import { TelemetryManager } from "../telemetry"; +import { isToolProvider, PluginContext } from "./plugin-context"; export class AppKit { #pluginInstances: Record = {}; #setupPromises: Promise[] = []; + #context: PluginContext; private constructor(config: { plugins: TPlugins }) { const { plugins, ...globalConfig } = config; + this.#context = new PluginContext(); + const pluginEntries = Object.entries(plugins); const corePlugins = pluginEntries.filter(([_, p]) => { @@ -35,20 +39,24 @@ export class AppKit { for (const [name, pluginData] of corePlugins) { if (pluginData) { - this.createAndRegisterPlugin(globalConfig, name, pluginData); + this.createAndRegisterPlugin(globalConfig, name, pluginData, { + context: this.#context, + }); } } for (const [name, pluginData] of normalPlugins) { if (pluginData) { - this.createAndRegisterPlugin(globalConfig, name, pluginData); + this.createAndRegisterPlugin(globalConfig, name, pluginData, { + context: this.#context, + }); } } for (const [name, pluginData] of deferredPlugins) { if (pluginData) { this.createAndRegisterPlugin(globalConfig, name, pluginData, { - plugins: this.#pluginInstances, + context: this.#context, }); } } @@ -70,8 +78,20 @@ export class AppKit { }; const pluginInstance = new Plugin(baseConfig); + if (typeof pluginInstance.attachContext === "function") { + pluginInstance.attachContext({ + context: this.#context, + telemetryConfig: baseConfig.telemetry, + }); + } + this.#pluginInstances[name] = pluginInstance; + this.#context.registerPlugin(name, pluginInstance); + if (isToolProvider(pluginInstance)) { + this.#context.registerToolProvider(name, pluginInstance); + } + this.#setupPromises.push(pluginInstance.setup()); const self = this; @@ -199,6 +219,7 @@ export class AppKit { const instance = new AppKit(mergedConfig); await Promise.all(instance.#setupPromises); + await instance.#context.emitLifecycle("setup:complete"); return instance as unknown as PluginMap; } diff --git a/packages/appkit/src/core/plugin-context.ts b/packages/appkit/src/core/plugin-context.ts new file mode 100644 index 00000000..c2801585 --- /dev/null +++ b/packages/appkit/src/core/plugin-context.ts @@ -0,0 +1,287 @@ +import type express from "express"; +import type { BasePlugin, ToolProvider } from "shared"; +import { createLogger } from "../logging/logger"; +import { TelemetryManager } from "../telemetry"; + +const logger = createLogger("plugin-context"); + +interface BufferedRoute { + method: string; + path: string; + handlers: express.RequestHandler[]; +} + +interface RouteTarget { + addExtension(fn: (app: express.Application) => void): void; +} + +interface ToolProviderEntry { + plugin: BasePlugin & ToolProvider; + name: string; +} + +type LifecycleEvent = "setup:complete" | "server:ready" | "shutdown"; + +/** + * Mediator for inter-plugin communication. + * + * Created by AppKit core and passed to every plugin. Plugins request + * capabilities from the context instead of holding direct references + * to sibling plugin instances. + * + * Capabilities: + * - Route mounting with buffering (order-independent) + * - Typed ToolProvider registry (live, not snapshot-based) + * - User-scoped tool execution with automatic telemetry + * - Lifecycle hooks for plugin coordination + */ +export class PluginContext { + private routeBuffer: BufferedRoute[] = []; + private routeTarget: RouteTarget | null = null; + private toolProviders = new Map(); + private plugins = new Map(); + private lifecycleHooks = new Map< + LifecycleEvent, + Set<() => void | Promise> + >(); + private telemetry = TelemetryManager.getProvider("plugin-context"); + + /** + * Register a route on the root Express application. + * + * If a route target (server plugin) has registered, the route is applied + * immediately. Otherwise it is buffered and flushed when a route target + * becomes available. + */ + addRoute( + method: string, + path: string, + ...handlers: express.RequestHandler[] + ): void { + if (this.routeTarget) { + this.applyRoute({ method, path, handlers }); + } else { + this.routeBuffer.push({ method, path, handlers }); + } + } + + /** + * Register middleware on the root Express application. + * + * Same buffering semantics as `addRoute`. + */ + addMiddleware(path: string, ...handlers: express.RequestHandler[]): void { + if (this.routeTarget) { + this.applyMiddleware(path, handlers); + } else { + this.routeBuffer.push({ method: "use", path, handlers }); + } + } + + /** + * Called by the server plugin to opt in as the route target. + * Flushes all buffered routes via the server's `addExtension`. + */ + registerAsRouteTarget(target: RouteTarget): void { + this.routeTarget = target; + + for (const route of this.routeBuffer) { + if (route.method === "use") { + this.applyMiddleware(route.path, route.handlers); + } else { + this.applyRoute(route); + } + } + this.routeBuffer = []; + } + + /** + * Register a plugin that implements the ToolProvider interface. + * Called by AppKit core after constructing each plugin. + */ + registerToolProvider(name: string, plugin: BasePlugin & ToolProvider): void { + this.toolProviders.set(name, { plugin, name }); + } + + /** + * Register a plugin instance. + * Called by AppKit core after constructing each plugin. + */ + registerPlugin(name: string, instance: BasePlugin): void { + this.plugins.set(name, instance); + } + + /** + * Returns all registered plugin instances keyed by name. + * Used by the server plugin for route injection, client config, + * and shutdown coordination. + */ + getPlugins(): Map { + return this.plugins; + } + + /** + * Returns all registered ToolProvider plugins. + * Always returns the current set — not a frozen snapshot. + */ + getToolProviders(): Array<{ name: string; provider: ToolProvider }> { + return Array.from(this.toolProviders.values()).map((entry) => ({ + name: entry.name, + provider: entry.plugin, + })); + } + + /** + * Execute a tool on a ToolProvider plugin with automatic user scoping + * and telemetry. + * + * The context: + * 1. Resolves the plugin by name + * 2. Calls `asUser(req)` for user-scoped execution + * 3. Wraps the call in a telemetry span with a 30s timeout + */ + async executeTool( + req: express.Request, + pluginName: string, + toolName: string, + args: unknown, + signal?: AbortSignal, + ): Promise { + const entry = this.toolProviders.get(pluginName); + if (!entry) { + throw new Error( + `PluginContext: unknown plugin "${pluginName}". Available: ${Array.from(this.toolProviders.keys()).join(", ")}`, + ); + } + + const tracer = this.telemetry.getTracer(); + const operationName = `executeTool:${pluginName}.${toolName}`; + + return tracer.startActiveSpan(operationName, async (span) => { + const timeout = 30_000; + const timeoutSignal = AbortSignal.timeout(timeout); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + + try { + const userPlugin = (entry.plugin as any).asUser(req); + const result = await (userPlugin as ToolProvider).executeAgentTool( + toolName, + args, + combinedSignal, + ); + span.setStatus({ code: 0 }); + return result; + } catch (error) { + span.setStatus({ + code: 2, + message: + error instanceof Error ? error.message : "Tool execution failed", + }); + span.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } finally { + span.end(); + } + }); + } + + /** + * Register a lifecycle hook callback. + */ + onLifecycle(event: LifecycleEvent, fn: () => void | Promise): void { + let hooks = this.lifecycleHooks.get(event); + if (!hooks) { + hooks = new Set(); + this.lifecycleHooks.set(event, hooks); + } + hooks.add(fn); + } + + /** + * Emit a lifecycle event, calling all registered callbacks. + * Errors in individual callbacks are logged but do not prevent + * other callbacks from running. + * + * @internal Called by AppKit core only. + */ + async emitLifecycle(event: LifecycleEvent): Promise { + const hooks = this.lifecycleHooks.get(event); + if (!hooks) return; + + if ( + event === "setup:complete" && + this.routeBuffer.length > 0 && + !this.routeTarget + ) { + logger.warn( + "%d buffered routes were never applied — no server plugin registered as route target", + this.routeBuffer.length, + ); + } + + for (const fn of hooks) { + try { + await fn(); + } catch (error) { + logger.error("Lifecycle hook '%s' failed: %O", event, error); + } + } + } + + /** + * Returns all registered plugin names. + */ + getPluginNames(): string[] { + return Array.from(this.plugins.keys()); + } + + /** + * Check if a plugin with the given name is registered. + */ + hasPlugin(name: string): boolean { + return this.plugins.has(name); + } + + private applyRoute(route: BufferedRoute): void { + if (!this.routeTarget) return; + this.routeTarget.addExtension((app) => { + const method = route.method.toLowerCase() as keyof express.Application; + if (typeof app[method] === "function") { + (app[method] as (...a: unknown[]) => void)( + route.path, + ...route.handlers, + ); + } + }); + } + + private applyMiddleware( + path: string, + handlers: express.RequestHandler[], + ): void { + if (!this.routeTarget) return; + this.routeTarget.addExtension((app) => { + app.use(path, ...handlers); + }); + } +} + +/** + * Type guard: checks whether a plugin implements the ToolProvider interface. + */ +export function isToolProvider( + plugin: unknown, +): plugin is BasePlugin & ToolProvider { + return ( + typeof plugin === "object" && + plugin !== null && + "getAgentTools" in plugin && + typeof (plugin as ToolProvider).getAgentTools === "function" && + "executeAgentTool" in plugin && + typeof (plugin as ToolProvider).executeAgentTool === "function" + ); +} diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index c05345a6..9d3fe5f8 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -109,11 +109,11 @@ class DeferredTestPlugin implements BasePlugin { name = "deferredTest"; setupCalled = false; injectedConfig: any; - injectedPlugins: any; + injectedContext: any; constructor(config: any) { this.injectedConfig = config; - this.injectedPlugins = config.plugins; + this.injectedContext = config.context; } async setup() { @@ -130,7 +130,7 @@ class DeferredTestPlugin implements BasePlugin { return { setupCalled: this.setupCalled, injectedConfig: this.injectedConfig, - injectedPlugins: this.injectedPlugins, + injectedContext: this.injectedContext, }; } } @@ -276,7 +276,7 @@ describe("AppKit", () => { expect(setupOrder).toEqual(["core", "normal", "deferred"]); }); - test("should provide plugin instances to deferred plugins", async () => { + test("should provide PluginContext to deferred plugins", async () => { const pluginData = [ { plugin: CoreTestPlugin, config: {}, name: "coreTest" }, { plugin: DeferredTestPlugin, config: {}, name: "deferredTest" }, @@ -284,10 +284,9 @@ describe("AppKit", () => { const instance = (await createApp({ plugins: pluginData })) as any; - // Deferred plugins receive plugin instances (not SDKs) for internal use - expect(instance.deferredTest.injectedPlugins).toBeDefined(); - expect(instance.deferredTest.injectedPlugins.coreTest).toBeInstanceOf( - CoreTestPlugin, + expect(instance.deferredTest.injectedContext).toBeDefined(); + expect(instance.deferredTest.injectedContext.hasPlugin("coreTest")).toBe( + true, ); }); diff --git a/packages/appkit/src/core/tests/plugin-context.test.ts b/packages/appkit/src/core/tests/plugin-context.test.ts new file mode 100644 index 00000000..276c5502 --- /dev/null +++ b/packages/appkit/src/core/tests/plugin-context.test.ts @@ -0,0 +1,325 @@ +import type { AgentToolDefinition } from "shared"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { isToolProvider, PluginContext } from "../plugin-context"; + +vi.mock("../../telemetry", () => ({ + TelemetryManager: { + getProvider: () => ({ + getTracer: () => ({ + startActiveSpan: (_name: string, fn: (span: any) => any) => { + const span = { + setStatus: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + }; + return fn(span); + }, + }), + }), + }, +})); + +vi.mock("../../logging/logger", () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +function createMockToolProvider(tools: AgentToolDefinition[] = []) { + const mock = { + name: "mock-plugin", + setup: vi.fn().mockResolvedValue(undefined), + injectRoutes: vi.fn(), + getEndpoints: vi.fn().mockReturnValue({}), + getAgentTools: vi.fn().mockReturnValue(tools), + executeAgentTool: vi.fn().mockResolvedValue("tool-result"), + asUser: vi.fn().mockReturnThis(), + }; + return mock as any; +} + +describe("PluginContext", () => { + let ctx: PluginContext; + + beforeEach(() => { + ctx = new PluginContext(); + }); + + describe("route buffering", () => { + test("addRoute buffers when no route target exists", () => { + const handler = vi.fn(); + ctx.addRoute("post", "/invocations", handler); + + expect(ctx.getPluginNames()).toEqual([]); + }); + + test("flushRoutes applies buffered routes via addExtension", () => { + const handler = vi.fn(); + ctx.addRoute("post", "/invocations", handler); + + const addExtension = vi.fn(); + ctx.registerAsRouteTarget({ addExtension }); + + expect(addExtension).toHaveBeenCalledTimes(1); + const extensionFn = addExtension.mock.calls[0][0]; + + const mockApp = { post: vi.fn() }; + extensionFn(mockApp); + expect(mockApp.post).toHaveBeenCalledWith("/invocations", handler); + }); + + test("addRoute called after registerAsRouteTarget applies immediately", () => { + const addExtension = vi.fn(); + ctx.registerAsRouteTarget({ addExtension }); + + const handler = vi.fn(); + ctx.addRoute("get", "/health", handler); + + expect(addExtension).toHaveBeenCalledTimes(1); + const extensionFn = addExtension.mock.calls[0][0]; + + const mockApp = { get: vi.fn() }; + extensionFn(mockApp); + expect(mockApp.get).toHaveBeenCalledWith("/health", handler); + }); + + test("addRoute supports middleware chains", () => { + const auth = vi.fn(); + const handler = vi.fn(); + + const addExtension = vi.fn(); + ctx.registerAsRouteTarget({ addExtension }); + + ctx.addRoute("post", "/api", auth, handler); + + const extensionFn = addExtension.mock.calls[0][0]; + const mockApp = { post: vi.fn() }; + extensionFn(mockApp); + expect(mockApp.post).toHaveBeenCalledWith("/api", auth, handler); + }); + + test("addMiddleware buffers and applies via use()", () => { + const handler = vi.fn(); + ctx.addMiddleware("/api", handler); + + const addExtension = vi.fn(); + ctx.registerAsRouteTarget({ addExtension }); + + expect(addExtension).toHaveBeenCalledTimes(1); + const extensionFn = addExtension.mock.calls[0][0]; + + const mockApp = { use: vi.fn() }; + extensionFn(mockApp); + expect(mockApp.use).toHaveBeenCalledWith("/api", handler); + }); + + test("multiple buffered routes are all applied on registration", () => { + const h1 = vi.fn(); + const h2 = vi.fn(); + ctx.addRoute("post", "/a", h1); + ctx.addRoute("get", "/b", h2); + + const addExtension = vi.fn(); + ctx.registerAsRouteTarget({ addExtension }); + + expect(addExtension).toHaveBeenCalledTimes(2); + }); + }); + + describe("ToolProvider registry", () => { + test("registerToolProvider makes provider visible via getToolProviders", () => { + const provider = createMockToolProvider([ + { + name: "query", + description: "Run query", + parameters: { type: "object" }, + }, + ]); + + ctx.registerToolProvider("analytics", provider); + + const providers = ctx.getToolProviders(); + expect(providers).toHaveLength(1); + expect(providers[0].name).toBe("analytics"); + expect(providers[0].provider.getAgentTools()).toHaveLength(1); + }); + + test("getToolProviders returns all registered providers", () => { + ctx.registerToolProvider("analytics", createMockToolProvider()); + ctx.registerToolProvider("files", createMockToolProvider()); + ctx.registerToolProvider("genie", createMockToolProvider()); + + expect(ctx.getToolProviders()).toHaveLength(3); + }); + + test("getToolProviders returns current set, not snapshot", () => { + const before = ctx.getToolProviders(); + expect(before).toHaveLength(0); + + ctx.registerToolProvider("analytics", createMockToolProvider()); + + const after = ctx.getToolProviders(); + expect(after).toHaveLength(1); + }); + }); + + describe("executeTool", () => { + test("calls asUser(req).executeAgentTool on the correct plugin", async () => { + const provider = createMockToolProvider(); + ctx.registerToolProvider("analytics", provider); + + const mockReq = { headers: {} } as any; + await ctx.executeTool(mockReq, "analytics", "query", { sql: "SELECT 1" }); + + expect(provider.asUser).toHaveBeenCalledWith(mockReq); + expect(provider.executeAgentTool).toHaveBeenCalledWith( + "query", + { sql: "SELECT 1" }, + expect.any(Object), + ); + }); + + test("throws for unknown plugin name", async () => { + const mockReq = { headers: {} } as any; + + await expect( + ctx.executeTool(mockReq, "nonexistent", "query", {}), + ).rejects.toThrow('unknown plugin "nonexistent"'); + }); + + test("propagates tool execution errors", async () => { + const provider = createMockToolProvider(); + (provider.executeAgentTool as any).mockRejectedValue( + new Error("Query failed"), + ); + ctx.registerToolProvider("analytics", provider); + + const mockReq = { headers: {} } as any; + + await expect( + ctx.executeTool(mockReq, "analytics", "query", {}), + ).rejects.toThrow("Query failed"); + }); + + test("passes abort signal to executeAgentTool", async () => { + const provider = createMockToolProvider(); + ctx.registerToolProvider("analytics", provider); + + const controller = new AbortController(); + const mockReq = { headers: {} } as any; + + await ctx.executeTool( + mockReq, + "analytics", + "query", + {}, + controller.signal, + ); + + const callArgs = (provider.executeAgentTool as any).mock.calls[0]; + expect(callArgs[2]).toBeDefined(); + }); + }); + + describe("lifecycle hooks", () => { + test("onLifecycle registers callback, emitLifecycle invokes it", async () => { + const fn = vi.fn(); + ctx.onLifecycle("setup:complete", fn); + + await ctx.emitLifecycle("setup:complete"); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("multiple callbacks for the same event all fire", async () => { + const fn1 = vi.fn(); + const fn2 = vi.fn(); + ctx.onLifecycle("setup:complete", fn1); + ctx.onLifecycle("setup:complete", fn2); + + await ctx.emitLifecycle("setup:complete"); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + test("callback error does not prevent other callbacks from running", async () => { + const fn1 = vi.fn().mockRejectedValue(new Error("fail")); + const fn2 = vi.fn(); + ctx.onLifecycle("shutdown", fn1); + ctx.onLifecycle("shutdown", fn2); + + await ctx.emitLifecycle("shutdown"); + + expect(fn1).toHaveBeenCalled(); + expect(fn2).toHaveBeenCalled(); + }); + + test("emitLifecycle with no registered hooks does nothing", async () => { + await expect(ctx.emitLifecycle("server:ready")).resolves.toBeUndefined(); + }); + }); + + describe("plugin metadata", () => { + const stubPlugin = { name: "stub" } as any; + + test("getPluginNames returns all registered names", () => { + ctx.registerPlugin("analytics", stubPlugin); + ctx.registerPlugin("server", stubPlugin); + ctx.registerPlugin("agent", stubPlugin); + + const names = ctx.getPluginNames(); + expect(names).toContain("analytics"); + expect(names).toContain("server"); + expect(names).toContain("agent"); + expect(names).toHaveLength(3); + }); + + test("hasPlugin returns true for registered plugins", () => { + ctx.registerPlugin("analytics", stubPlugin); + + expect(ctx.hasPlugin("analytics")).toBe(true); + expect(ctx.hasPlugin("nonexistent")).toBe(false); + }); + + test("getPlugins returns all registered instances", () => { + const p1 = { name: "analytics" } as any; + const p2 = { name: "server" } as any; + ctx.registerPlugin("analytics", p1); + ctx.registerPlugin("server", p2); + + const plugins = ctx.getPlugins(); + expect(plugins.size).toBe(2); + expect(plugins.get("analytics")).toBe(p1); + expect(plugins.get("server")).toBe(p2); + }); + }); +}); + +describe("isToolProvider", () => { + test("returns true for objects with getAgentTools and executeAgentTool", () => { + const provider = createMockToolProvider(); + expect(isToolProvider(provider)).toBe(true); + }); + + test("returns false for null", () => { + expect(isToolProvider(null)).toBe(false); + }); + + test("returns false for objects missing executeAgentTool", () => { + expect(isToolProvider({ getAgentTools: vi.fn() })).toBe(false); + }); + + test("returns false for objects missing getAgentTools", () => { + expect(isToolProvider({ executeAgentTool: vi.fn() })).toBe(false); + }); + + test("returns false for non-objects", () => { + expect(isToolProvider("string")).toBe(false); + expect(isToolProvider(42)).toBe(false); + expect(isToolProvider(undefined)).toBe(false); + }); +}); diff --git a/packages/appkit/src/plugin/index.ts b/packages/appkit/src/plugin/index.ts index 93765219..46a4eb94 100644 --- a/packages/appkit/src/plugin/index.ts +++ b/packages/appkit/src/plugin/index.ts @@ -1,4 +1,4 @@ export type { ToPlugin } from "shared"; export type { ExecutionResult } from "./execution-result"; export { Plugin } from "./plugin"; -export { toPlugin } from "./to-plugin"; +export { type NamedPluginFactory, toPlugin } from "./to-plugin"; diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 5173cb61..4c9a0e64 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -19,6 +19,7 @@ import { ServiceContext, type UserContext, } from "../context"; +import type { PluginContext } from "../core/plugin-context"; import { AppKitError, AuthenticationError } from "../errors"; import { createLogger } from "../logging/logger"; import { StreamManager } from "../stream"; @@ -163,11 +164,12 @@ export abstract class Plugin< > implements BasePlugin { protected isReady = false; - protected cache: CacheManager; + protected cache!: CacheManager; protected app: AppManager; protected devFileReader: DevFileReader; protected streamManager: StreamManager; - protected telemetry: ITelemetry; + protected telemetry!: ITelemetry; + protected context?: PluginContext; /** Registered endpoints for this plugin */ private registeredEndpoints: PluginEndpointMap = {}; @@ -193,12 +195,58 @@ export abstract class Plugin< config.name ?? (this.constructor as { manifest?: { name: string } }).manifest?.name ?? "plugin"; - this.telemetry = TelemetryManager.getProvider(this.name, config.telemetry); this.streamManager = new StreamManager(); - this.cache = CacheManager.getInstanceSync(); this.app = new AppManager(); this.devFileReader = DevFileReader.getInstance(); + this.context = (config as Record).context as + | PluginContext + | undefined; + + // Eagerly bind telemetry + cache if the core services have already been + // initialized (normal createApp path, or tests that mock CacheManager). + // If they haven't, we leave these undefined and rely on `attachContext` + // being called later — this lets factories eagerly construct plugin + // instances at module top-level before `createApp` has run. + this.tryAttachContext(); + } + + private tryAttachContext(): void { + try { + this.cache = CacheManager.getInstanceSync(); + } catch { + return; + } + this.telemetry = TelemetryManager.getProvider( + this.name, + this.config.telemetry, + ); + this.isReady = true; + } + /** + * Binds runtime dependencies (telemetry provider, cache, plugin context) to + * this plugin. Called by `AppKit._createApp` after construction and before + * `setup()`. Idempotent: safe to call if the constructor already bound them + * eagerly. Kept separate so factories can eagerly construct plugin instances + * without running this before `TelemetryManager.initialize()` / + * `CacheManager.getInstance()` have run. + */ + attachContext( + deps: { + context?: unknown; + telemetryConfig?: BasePluginConfig["telemetry"]; + } = {}, + ): void { + if (!this.cache) { + this.cache = CacheManager.getInstanceSync(); + } + this.telemetry = TelemetryManager.getProvider( + this.name, + deps.telemetryConfig ?? this.config.telemetry, + ); + if (deps.context !== undefined) { + this.context = deps.context as PluginContext; + } this.isReady = true; } diff --git a/packages/appkit/src/plugin/to-plugin.ts b/packages/appkit/src/plugin/to-plugin.ts index 77725027..c882f300 100644 --- a/packages/appkit/src/plugin/to-plugin.ts +++ b/packages/appkit/src/plugin/to-plugin.ts @@ -1,19 +1,41 @@ import type { PluginConstructor, PluginData, ToPlugin } from "shared"; /** - * Wraps a plugin class so it can be passed to createApp with optional config. - * Infers config type from the constructor and plugin name from the static `name` property. + * Factory function produced by {@link toPlugin}. Carries a static + * `pluginName` field so tooling (e.g. `fromPlugin`) can identify which + * plugin a factory references without constructing an instance. + */ +export type NamedPluginFactory = { + readonly pluginName: Name; +}; + +/** + * Wraps a plugin class so it can be passed to `createApp` with optional + * config. Infers the config type from the constructor and the plugin name + * from the static `manifest.name` property, and stamps `pluginName` onto + * the returned factory function so `fromPlugin` can identify the plugin + * without needing to construct it. * * @internal */ export function toPlugin( plugin: T, -): ToPlugin[0], T["manifest"]["name"]> { +): ToPlugin[0], T["manifest"]["name"]> & + NamedPluginFactory { type Config = ConstructorParameters[0]; type Name = T["manifest"]["name"]; - return (config: Config = {} as Config): PluginData => ({ + const pluginName = plugin.manifest.name as Name; + const factory = ( + config: Config = {} as Config, + ): PluginData => ({ plugin: plugin as T, config: config as Config, - name: plugin.manifest.name as Name, + name: pluginName, + }); + Object.defineProperty(factory, "pluginName", { + value: pluginName, + writable: false, + enumerable: true, }); + return factory as ToPlugin & NamedPluginFactory; } diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index e7b9b31a..4c911b10 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -59,17 +59,34 @@ export class ServerPlugin extends Plugin { this.serverApplication = express(); this.server = null; this.serverExtensions = []; + } + + attachContext(deps: Parameters[0] = {}): void { + super.attachContext(deps); this.telemetry.registerInstrumentations([ instrumentations.http, instrumentations.express, ]); + this.context?.registerAsRouteTarget(this); } /** Setup the server plugin. */ async setup() { - if (this.shouldAutoStart()) { - await this.start(); + if (!this.shouldAutoStart()) return; + if (this.context) { + // Defer the actual listen+extendRoutes to the `setup:complete` lifecycle + // hook. That way every plugin (including other deferred-phase plugins + // like `agents`) is already registered in PluginContext by the time + // extendRoutes() iterates. Otherwise plugins declared after server() + // in the plugin array would be silently dropped from /api/* mounts. + this.context.onLifecycle("setup:complete", async () => { + await this.start(); + }); + return; } + // No plugin context (e.g. tests constructing ServerPlugin directly) — + // start immediately. + await this.start(); } /** Get the server configuration. */ @@ -179,6 +196,16 @@ export class ServerPlugin extends Plugin { return this; } + /** + * Register a server extension from another plugin during setup. + * Unlike extend(), this does not guard on autoStart — it's designed + * for internal plugin-to-plugin coordination where extensions are + * registered before the server starts listening. + */ + addExtension(fn: (app: express.Application) => void) { + this.serverExtensions.push(fn); + } + /** * Setup the routes with the plugins. * @@ -193,14 +220,15 @@ export class ServerPlugin extends Plugin { const endpoints: PluginEndpoints = {}; const pluginConfigs: PluginClientConfigs = {}; - if (!this.config.plugins) return { endpoints, pluginConfigs }; + const plugins = this.context?.getPlugins(); + if (!plugins || plugins.size === 0) return { endpoints, pluginConfigs }; this.serverApplication.get("/health", (_, res) => { res.status(200).json({ status: "ok" }); }); this.registerEndpoint("health", "/health"); - for (const plugin of Object.values(this.config.plugins)) { + for (const plugin of plugins.values()) { if (EXCLUDED_PLUGINS.includes(plugin.name)) continue; if (plugin?.injectRoutes && typeof plugin.injectRoutes === "function") { @@ -349,8 +377,9 @@ export class ServerPlugin extends Plugin { } // 1. abort active operations from plugins - if (this.config.plugins) { - for (const plugin of Object.values(this.config.plugins)) { + const shutdownPlugins = this.context?.getPlugins(); + if (shutdownPlugins) { + for (const plugin of shutdownPlugins.values()) { if (plugin.abortActiveOperations) { try { plugin.abortActiveOperations(); diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts index 22f18129..52d15845 100644 --- a/packages/appkit/src/plugins/server/tests/server.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.test.ts @@ -1,4 +1,6 @@ +import type { BasePlugin } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { PluginContext } from "../../../core/plugin-context"; // Use vi.hoisted for mocks that need to be available before module loading const { @@ -171,6 +173,14 @@ import { RemoteTunnelController } from "../remote-tunnel/remote-tunnel-controlle import { StaticServer } from "../static-server"; import { ViteDevServer } from "../vite-dev-server"; +function createContextWithPlugins(plugins: Record): PluginContext { + const ctx = new PluginContext(); + for (const [name, instance] of Object.entries(plugins)) { + ctx.registerPlugin(name, instance as BasePlugin); + } + return ctx; +} + describe("ServerPlugin", () => { let originalEnv: NodeJS.ProcessEnv; @@ -340,7 +350,7 @@ describe("ServerPlugin", () => { process.env.NODE_ENV = "production"; const injectRoutes = vi.fn(); - const plugins: any = { + const testPlugins: any = { "test-plugin": { name: "test-plugin", injectRoutes, @@ -348,7 +358,10 @@ describe("ServerPlugin", () => { }, }; - const plugin = new ServerPlugin({ autoStart: false, plugins }); + const plugin = new ServerPlugin({ + autoStart: false, + context: createContextWithPlugins(testPlugins), + } as any); await plugin.start(); const routerFn = (express as any).Router as ReturnType; @@ -386,7 +399,10 @@ describe("ServerPlugin", () => { }, }; - const plugin = new ServerPlugin({ autoStart: false, plugins }); + const plugin = new ServerPlugin({ + autoStart: false, + context: createContextWithPlugins(plugins), + } as any); await plugin.start(); expect(plugins["plugin-a"].clientConfig).toHaveBeenCalled(); @@ -413,7 +429,10 @@ describe("ServerPlugin", () => { }, }; - const plugin = new ServerPlugin({ autoStart: false, plugins }); + const plugin = new ServerPlugin({ + autoStart: false, + context: createContextWithPlugins(plugins), + } as any); await plugin.start(); expect(plugins["plugin-null"].clientConfig).toHaveBeenCalled(); @@ -444,7 +463,10 @@ describe("ServerPlugin", () => { }, }; - const plugin = new ServerPlugin({ autoStart: false, plugins }); + const plugin = new ServerPlugin({ + autoStart: false, + context: createContextWithPlugins(plugins), + } as any); await expect(plugin.start()).resolves.toBeDefined(); expect(mockLoggerError).toHaveBeenCalledWith( "Plugin '%s' clientConfig() failed, skipping its config: %O", @@ -608,19 +630,19 @@ describe("ServerPlugin", () => { const plugin = new ServerPlugin({ autoStart: false, - plugins: { + context: createContextWithPlugins({ ok: { name: "ok", abortActiveOperations: vi.fn(), - } as any, + }, bad: { name: "bad", abortActiveOperations: vi.fn(() => { throw new Error("boom"); }), - } as any, - }, - }); + }, + }), + } as any); // pretend started (plugin as any).server = mockHttpServer; diff --git a/packages/appkit/src/plugins/server/types.ts b/packages/appkit/src/plugins/server/types.ts index e187cacc..84a2327e 100644 --- a/packages/appkit/src/plugins/server/types.ts +++ b/packages/appkit/src/plugins/server/types.ts @@ -1,9 +1,7 @@ import type { BasePluginConfig } from "shared"; -import type { Plugin } from "../../plugin"; export interface ServerConfig extends BasePluginConfig { port?: number; - plugins?: Record; staticPath?: string; autoStart?: boolean; host?: string; diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 9fa8066c..651840c7 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -26,6 +26,15 @@ export interface BasePlugin { exports?(): unknown; clientConfig?(): Record; + + /** + * Binds runtime dependencies (telemetry, cache, plugin context) after the + * plugin has been constructed. Called by the AppKit core before `setup()`. + */ + attachContext?(deps: { + context?: unknown; + telemetryConfig?: TelemetryOptions; + }): void; } /** Base configuration interface for AppKit plugins */ From cb7fe2be3f0322fc88d89400b758638480a57fab Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:51:21 +0200 Subject: [PATCH 3/6] feat(appkit): agents() plugin, createAgent(def), and markdown-driven agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main product layer. Turns an AppKit app into an AI-agent host with markdown-driven agent discovery, code-defined agents, sub-agents, and a standalone run-without-HTTP executor. ### `createAgent(def)` — pure factory `packages/appkit/src/core/create-agent-def.ts`. Returns the passed-in definition after cycle-detecting the sub-agent graph. No adapter construction, no side effects — safe at module top-level. The returned `AgentDefinition` is plain data, consumable by either `agents({ agents })` or `runAgent(def, input)`. ### `agents()` plugin `packages/appkit/src/plugins/agents/agents.ts`. `AgentsPlugin` class: - Loads markdown agents from `config/agents/*.md` (configurable dir) via real YAML frontmatter parsing (`js-yaml`). Frontmatter schema: `endpoint`, `model`, `toolkits`, `tools`, `default`, `maxSteps`, `maxTokens`, `baseSystemPrompt`. Unknown keys logged, invalid YAML throws at boot. - Merges code-defined agents passed via `agents({ agents: { name: def } })`. Code wins on key collision. - For each agent, builds a per-agent tool index from: 1. Sub-agents (`agents: {...}`) — synthesized as `agent-` tools on the parent. 2. Explicit tool record entries — `ToolkitEntry`s, inline `FunctionTool`s, or `HostedTool`s. 3. Auto-inherit (if nothing explicit) — pulls every registered `ToolProvider` plugin's tools. Asymmetric default: markdown agents inherit (`file: true`), code-defined agents don't (`code: false`). - Mounts `POST /invocations` (OpenAI Responses compatible) + `POST /chat`, `POST /cancel`, `GET /threads/:id`, `DELETE /threads/:id`, `GET /info`. - SSE streaming via `executeStream`. Tool calls dispatch through `PluginContext.executeTool(req, pluginName, localName, args, signal)` for OBO, telemetry, and timeout. - Exposes `appkit.agent.{register, list, get, reload, getDefault, getThreads}` runtime helpers. ### `runAgent(def, input)` — standalone executor `packages/appkit/src/core/run-agent.ts`. Runs an `AgentDefinition` without `createApp` or HTTP. Drives the adapter's event stream to completion, executing inline tools + sub-agents along the way. Aggregates events into `{ text, events }`. Useful for tests, CLI scripts, and offline pipelines. Hosted/MCP tools and plugin toolkits require the agents plugin and throw clear errors with guidance. ### Event translation and thread storage - `AgentEventTranslator` — stateful converter from internal `AgentEvent`s to OpenAI Responses API `ResponseStreamEvent`s with sequence numbers and output indices. - `InMemoryThreadStore` — per-user conversation persistence. Nested `Map>`. Implements `ThreadStore` from shared types. - `buildBaseSystemPrompt` + `composeSystemPrompt` — formats the AppKit base prompt (with plugin names and tool names) and layers the agent's instructions on top. ### Frontmatter loader `load-agents.ts` — reads `*.md` files, parses YAML frontmatter with `js-yaml`, resolves `toolkits: [...]` entries against the plugin provider index at load time, wraps ambient tools (from `agents({ tools: {...} })`) for `tools: [...]` frontmatter references. ### Plumbing - Adds `js-yaml` + `@types/js-yaml` deps. - Manifest mounts routes at `/api/agent/*` (singular — matches `appkit.agent.*` runtime handle). - Exports from the main barrel: `agents`, `createAgent`, `runAgent`, `AgentDefinition`, `AgentsPluginConfig`, `AgentTool`, `ToolkitEntry`, `ToolkitOptions`, `BaseSystemPromptOption`, `PromptContext`, `isToolkitEntry`, `loadAgentFromFile`, `loadAgentsFromDir`. ### Test plan - 60 new tests: agents plugin lifecycle, markdown loading, code-agent registration, auto-inherit asymmetry, sub-agent tool synthesis, cycle detection, event translator, thread store, system prompt composition, standalone `runAgent`. - Full appkit vitest suite: 1297 tests passing. - Typecheck clean across all 8 workspace projects. Signed-off-by: MarioCadenas --- packages/appkit/package.json | 2 + packages/appkit/src/core/create-agent-def.ts | 53 + packages/appkit/src/core/run-agent.ts | 226 ++++ packages/appkit/src/index.ts | 15 +- packages/appkit/src/plugins/agents/agents.ts | 991 ++++++++++++++++++ .../appkit/src/plugins/agents/defaults.ts | 12 + .../src/plugins/agents/event-translator.ts | 230 ++++ packages/appkit/src/plugins/agents/index.ts | 22 + .../appkit/src/plugins/agents/load-agents.ts | 252 +++++ .../appkit/src/plugins/agents/manifest.json | 10 + packages/appkit/src/plugins/agents/schemas.ts | 19 + .../src/plugins/agents/system-prompt.ts | 40 + .../agents/tests/agents-plugin.test.ts | 289 +++++ .../plugins/agents/tests/create-agent.test.ts | 75 ++ .../agents/tests/event-translator.test.ts | 204 ++++ .../plugins/agents/tests/load-agents.test.ts | 150 +++ .../plugins/agents/tests/run-agent.test.ts | 120 +++ .../agents/tests/system-prompt.test.ts | 45 + .../plugins/agents/tests/thread-store.test.ts | 138 +++ .../appkit/src/plugins/agents/thread-store.ts | 59 ++ packages/appkit/src/plugins/agents/types.ts | 112 +- pnpm-lock.yaml | 11 + 22 files changed, 3071 insertions(+), 4 deletions(-) create mode 100644 packages/appkit/src/core/create-agent-def.ts create mode 100644 packages/appkit/src/core/run-agent.ts create mode 100644 packages/appkit/src/plugins/agents/agents.ts create mode 100644 packages/appkit/src/plugins/agents/defaults.ts create mode 100644 packages/appkit/src/plugins/agents/event-translator.ts create mode 100644 packages/appkit/src/plugins/agents/index.ts create mode 100644 packages/appkit/src/plugins/agents/load-agents.ts create mode 100644 packages/appkit/src/plugins/agents/manifest.json create mode 100644 packages/appkit/src/plugins/agents/schemas.ts create mode 100644 packages/appkit/src/plugins/agents/system-prompt.ts create mode 100644 packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/create-agent.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/event-translator.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/load-agents.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/run-agent.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/system-prompt.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/thread-store.test.ts create mode 100644 packages/appkit/src/plugins/agents/thread-store.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 27a14e66..49d6c516 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -83,6 +83,7 @@ "@types/semver": "7.7.1", "dotenv": "16.6.1", "express": "4.22.0", + "js-yaml": "^4.1.1", "obug": "2.1.1", "pg": "8.18.0", "picocolors": "1.1.1", @@ -108,6 +109,7 @@ "@ai-sdk/openai": "4.0.0-beta.27", "@langchain/core": "^1.1.39", "@types/express": "4.17.25", + "@types/js-yaml": "^4.0.9", "@types/json-schema": "7.0.15", "@types/pg": "8.16.0", "@types/ws": "8.18.1", diff --git a/packages/appkit/src/core/create-agent-def.ts b/packages/appkit/src/core/create-agent-def.ts new file mode 100644 index 00000000..3e93371d --- /dev/null +++ b/packages/appkit/src/core/create-agent-def.ts @@ -0,0 +1,53 @@ +import { ConfigurationError } from "../errors"; +import type { AgentDefinition } from "../plugins/agents/types"; + +/** + * Pure factory for agent definitions. Returns the passed-in definition after + * cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape + * and is safe to call at module top-level. + * + * The returned value is a plain `AgentDefinition` — no adapter construction, + * no side effects. Register it with `agents({ agents: { name: def } })` or run + * it standalone via `runAgent(def, input)`. + * + * @example + * ```ts + * const support = createAgent({ + * instructions: "You help customers.", + * model: "databricks-claude-sonnet-4-5", + * tools: { + * get_weather: tool({ ... }), + * }, + * }); + * ``` + */ +export function createAgent(def: AgentDefinition): AgentDefinition { + detectCycles(def); + return def; +} + +/** + * Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is + * found. Cycles would cause infinite recursion at tool-invocation time. + */ +function detectCycles(def: AgentDefinition): void { + const visiting = new Set(); + const visited = new Set(); + + const walk = (current: AgentDefinition, path: string[]): void => { + if (visited.has(current)) return; + if (visiting.has(current)) { + throw new ConfigurationError( + `Agent sub-agent cycle detected: ${path.join(" -> ")}`, + ); + } + visiting.add(current); + for (const [childKey, child] of Object.entries(current.agents ?? {})) { + walk(child, [...path, childKey]); + } + visiting.delete(current); + visited.add(current); + }; + + walk(def, [def.name ?? "(root)"]); +} diff --git a/packages/appkit/src/core/run-agent.ts b/packages/appkit/src/core/run-agent.ts new file mode 100644 index 00000000..e83c2c9c --- /dev/null +++ b/packages/appkit/src/core/run-agent.ts @@ -0,0 +1,226 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentAdapter, + AgentEvent, + AgentToolDefinition, + Message, +} from "shared"; +import { + type FunctionTool, + functionToolToDefinition, + isFunctionTool, +} from "../plugins/agents/tools/function-tool"; +import { isHostedTool } from "../plugins/agents/tools/hosted-tools"; +import type { + AgentDefinition, + AgentTool, + ToolkitEntry, +} from "../plugins/agents/types"; +import { isToolkitEntry } from "../plugins/agents/types"; + +export interface RunAgentInput { + /** Seed messages for the run. Either a single user string or a full message list. */ + messages: string | Message[]; + /** Abort signal for cancellation. */ + signal?: AbortSignal; +} + +export interface RunAgentResult { + /** Aggregated text output from all `message_delta` events. */ + text: string; + /** Every event the adapter yielded, in order. Useful for inspection/tests. */ + events: AgentEvent[]; +} + +/** + * Standalone agent execution without `createApp`. Resolves the adapter, binds + * inline tools, and drives the adapter's `run()` loop to completion. + * + * 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. + * - Sub-agents (`agents: { ... }` on the def) are executed as nested + * `runAgent` calls with no shared thread state. + */ +export async function runAgent( + def: AgentDefinition, + input: RunAgentInput, +): Promise { + const adapter = await resolveAdapter(def); + const messages = normalizeMessages(input.messages, def.instructions); + const toolIndex = buildStandaloneToolIndex(def); + const tools = Array.from(toolIndex.values()).map((e) => e.def); + + const signal = input.signal; + + const executeTool = async (name: string, args: unknown): Promise => { + const entry = toolIndex.get(name); + if (!entry) throw new Error(`Unknown tool: ${name}`); + if (entry.kind === "function") { + return entry.tool.execute(args as Record); + } + if (entry.kind === "subagent") { + const subInput: RunAgentInput = { + messages: + typeof args === "object" && + args !== null && + typeof (args as { input?: unknown }).input === "string" + ? (args as { input: string }).input + : JSON.stringify(args), + signal, + }; + 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(...)] }).", + ); + }; + + const events: AgentEvent[] = []; + let text = ""; + + const stream = adapter.run( + { + messages, + tools, + threadId: randomUUID(), + signal, + }, + { executeTool, signal }, + ); + + for await (const event of stream) { + if (signal?.aborted) break; + events.push(event); + if (event.type === "message_delta") { + text += event.content; + } else if (event.type === "message") { + text = event.content; + } + } + + return { text, events }; +} + +async function resolveAdapter(def: AgentDefinition): Promise { + const { model } = def; + if (!model) { + const { DatabricksAdapter } = await import("../agents/databricks"); + return DatabricksAdapter.fromModelServing(); + } + if (typeof model === "string") { + const { DatabricksAdapter } = await import("../agents/databricks"); + return DatabricksAdapter.fromModelServing(model); + } + return await model; +} + +function normalizeMessages( + input: string | Message[], + instructions: string, +): Message[] { + const systemMessage: Message = { + id: "system", + role: "system", + content: instructions, + createdAt: new Date(), + }; + if (typeof input === "string") { + return [ + systemMessage, + { + id: randomUUID(), + role: "user", + content: input, + createdAt: new Date(), + }, + ]; + } + return [systemMessage, ...input]; +} + +type StandaloneEntry = + | { + kind: "function"; + def: AgentToolDefinition; + tool: FunctionTool; + } + | { + kind: "subagent"; + def: AgentToolDefinition; + agentDef: AgentDefinition; + } + | { + kind: "toolkit"; + def: AgentToolDefinition; + entry: ToolkitEntry; + } + | { + kind: "hosted"; + def: AgentToolDefinition; + }; + +function buildStandaloneToolIndex( + def: AgentDefinition, +): Map { + const index = new Map(); + + for (const [key, tool] of Object.entries(def.tools ?? {})) { + index.set(key, classifyTool(key, tool)); + } + + for (const [childKey, child] of Object.entries(def.agents ?? {})) { + const toolName = `agent-${childKey}`; + index.set(toolName, { + kind: "subagent", + agentDef: { ...child, name: child.name ?? childKey }, + def: { + name: toolName, + description: + child.instructions.slice(0, 120) || + `Delegate to the ${childKey} sub-agent`, + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Message to send to the sub-agent.", + }, + }, + required: ["input"], + }, + }, + }); + } + + return index; +} + +function classifyTool(key: string, tool: AgentTool): StandaloneEntry { + if (isToolkitEntry(tool)) { + return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool }; + } + if (isFunctionTool(tool)) { + return { + kind: "function", + tool, + def: { ...functionToolToDefinition(tool), name: key }, + }; + } + if (isHostedTool(tool)) { + return { + kind: "hosted", + def: { + name: key, + description: `Hosted tool: ${tool.type}`, + parameters: { type: "object", properties: {} }, + }, + }; + } + throw new Error(`runAgent: unrecognized tool shape at key "${key}"`); +} diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 8d236780..dbefe3e5 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -43,6 +43,12 @@ export { } from "./connectors/lakebase"; export { getExecutionContext } from "./context"; export { createApp } from "./core"; +export { createAgent } from "./core/create-agent-def"; +export { + type RunAgentInput, + type RunAgentResult, + runAgent, +} from "./core/run-agent"; // Errors export { AppKitError, @@ -64,11 +70,18 @@ export { } from "./plugin"; export { analytics, files, genie, lakebase, server, serving } from "./plugins"; export { + type AgentDefinition, + type AgentsPluginConfig, type AgentTool, + agents, + type BaseSystemPromptOption, isToolkitEntry, + loadAgentFromFile, + loadAgentsFromDir, + type PromptContext, type ToolkitEntry, type ToolkitOptions, -} from "./plugins/agents/types"; +} from "./plugins/agents"; export { type FunctionTool, type HostedTool, diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts new file mode 100644 index 00000000..03b9257c --- /dev/null +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -0,0 +1,991 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import type express from "express"; +import pc from "picocolors"; +import type { + AgentAdapter, + AgentEvent, + AgentRunContext, + AgentToolDefinition, + IAppRouter, + Message, + PluginPhase, + ResponseStreamEvent, + Thread, + ToolProvider, +} from "shared"; +import { createLogger } from "../../logging/logger"; +import { Plugin, toPlugin } from "../../plugin"; +import type { PluginManifest } from "../../registry"; +import { agentStreamDefaults } from "./defaults"; +import { AgentEventTranslator } from "./event-translator"; +import { loadAgentsFromDir } from "./load-agents"; +import manifest from "./manifest.json"; +import { chatRequestSchema, invocationsRequestSchema } from "./schemas"; +import { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt"; +import { InMemoryThreadStore } from "./thread-store"; +import { + AppKitMcpClient, + type FunctionTool, + functionToolToDefinition, + isFunctionTool, + isHostedTool, + resolveHostedTools, +} from "./tools"; +import type { + AgentDefinition, + AgentsPluginConfig, + BaseSystemPromptOption, + PromptContext, + RegisteredAgent, + ResolvedToolEntry, +} from "./types"; +import { isToolkitEntry } from "./types"; + +const logger = createLogger("agents"); + +const DEFAULT_AGENTS_DIR = "./config/agents"; + +/** + * Context flag recorded on the in-memory AgentDefinition to indicate whether + * it came from markdown (file) or from user code. Drives the asymmetric + * `autoInheritTools` default. + */ +interface AgentSource { + origin: "file" | "code"; +} + +export class AgentsPlugin extends Plugin implements ToolProvider { + static manifest = manifest as PluginManifest; + static phase: PluginPhase = "deferred"; + + protected declare config: AgentsPluginConfig; + + private agents = new Map(); + private defaultAgentName: string | null = null; + private activeStreams = new Map(); + private mcpClient: AppKitMcpClient | null = null; + private threadStore; + + constructor(config: AgentsPluginConfig) { + super(config); + this.config = config; + this.threadStore = config.threadStore ?? new InMemoryThreadStore(); + } + + async setup() { + await this.loadAgents(); + this.mountInvocationsRoute(); + this.printRegistry(); + } + + /** + * Reload agents from the configured directory, preserving code-defined + * agents. Swaps the registry atomically at the end. + */ + async reload(): Promise { + this.agents.clear(); + this.defaultAgentName = null; + if (this.mcpClient) { + await this.mcpClient.close(); + this.mcpClient = null; + } + await this.loadAgents(); + } + + private async loadAgents() { + const { defs: fileDefs, defaultAgent: fileDefault } = + await this.loadFileDefinitions(); + + const codeDefs = this.config.agents ?? {}; + + for (const name of Object.keys(fileDefs)) { + if (codeDefs[name]) { + logger.warn( + "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", + name, + ); + } + } + + const merged: Record = + {}; + for (const [name, def] of Object.entries(fileDefs)) { + merged[name] = { def, src: { origin: "file" } }; + } + for (const [name, def] of Object.entries(codeDefs)) { + merged[name] = { def, src: { origin: "code" } }; + } + + if (Object.keys(merged).length === 0) { + logger.info( + "No agents registered (no files in %s, no code-defined agents)", + this.resolvedAgentsDir() ?? "", + ); + return; + } + + for (const [name, { def, src }] of Object.entries(merged)) { + try { + const registered = await this.buildRegisteredAgent(name, def, src); + this.agents.set(name, registered); + if (!this.defaultAgentName) this.defaultAgentName = name; + } catch (err) { + throw new Error( + `Failed to register agent '${name}' (${src.origin}): ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err instanceof Error ? err : undefined }, + ); + } + } + + if (this.config.defaultAgent) { + if (!this.agents.has(this.config.defaultAgent)) { + throw new Error( + `defaultAgent '${this.config.defaultAgent}' is not registered. Available: ${Array.from(this.agents.keys()).join(", ")}`, + ); + } + this.defaultAgentName = this.config.defaultAgent; + } else if (fileDefault && this.agents.has(fileDefault)) { + this.defaultAgentName = fileDefault; + } + } + + private resolvedAgentsDir(): string | null { + if (this.config.dir === false) return null; + const dir = this.config.dir ?? DEFAULT_AGENTS_DIR; + return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); + } + + private async loadFileDefinitions(): Promise<{ + defs: Record; + defaultAgent: string | null; + }> { + const dir = this.resolvedAgentsDir(); + if (!dir) return { defs: {}, defaultAgent: null }; + + const pluginToolProviders = this.pluginProviderIndex(); + const ambient = this.config.tools ?? {}; + + const result = await loadAgentsFromDir(dir, { + defaultModel: this.config.defaultModel, + availableTools: ambient, + plugins: pluginToolProviders, + }); + + return result; + } + + /** + * Builds the map of plugin-name → toolkit that the markdown loader consults + * when resolving `toolkits:` frontmatter entries. + */ + private pluginProviderIndex(): Map< + string, + { toolkit: (opts?: unknown) => Record } + > { + const out = new Map(); + if (!this.context) return out; + for (const { name, provider } of this.context.getToolProviders()) { + const withToolkit = provider as ToolProvider & { + toolkit?: (opts?: unknown) => Record; + }; + if (typeof withToolkit.toolkit === "function") { + out.set(name, { + toolkit: withToolkit.toolkit.bind(withToolkit), + }); + } + } + return out; + } + + private async buildRegisteredAgent( + name: string, + def: AgentDefinition, + src: AgentSource, + ): Promise { + const adapter = await this.resolveAdapter(def, name); + const toolIndex = await this.buildToolIndex(name, def, src); + + return { + name, + instructions: def.instructions, + adapter, + toolIndex, + baseSystemPrompt: def.baseSystemPrompt, + maxSteps: def.maxSteps, + maxTokens: def.maxTokens, + }; + } + + private async resolveAdapter( + def: AgentDefinition, + name: string, + ): Promise { + const source = def.model ?? this.config.defaultModel; + if (!source) { + const { DatabricksAdapter } = await import("../../agents/databricks"); + try { + return await DatabricksAdapter.fromModelServing(); + } catch (err) { + throw new Error( + `Agent '${name}' has no model configured and no DATABRICKS_AGENT_ENDPOINT default available`, + { cause: err instanceof Error ? err : undefined }, + ); + } + } + if (typeof source === "string") { + const { DatabricksAdapter } = await import("../../agents/databricks"); + return DatabricksAdapter.fromModelServing(source); + } + return await source; + } + + /** + * Resolves an agent's tool record into a per-agent dispatch index. Connects + * hosted tools via MCP client. Applies `autoInheritTools` defaults when the + * definition has no declared tools/agents. + */ + private async buildToolIndex( + agentName: string, + def: AgentDefinition, + src: AgentSource, + ): Promise> { + const index = new Map(); + const hasExplicitTools = def.tools && Object.keys(def.tools).length > 0; + const hasExplicitSubAgents = + def.agents && Object.keys(def.agents).length > 0; + + const inheritDefaults = normalizeAutoInherit(this.config.autoInheritTools); + const shouldInherit = + !hasExplicitTools && + !hasExplicitSubAgents && + (src.origin === "file" ? inheritDefaults.file : inheritDefaults.code); + + if (shouldInherit) { + await this.applyAutoInherit(agentName, index); + } + + // 1. Sub-agents → agent- + for (const [childKey, childDef] of Object.entries(def.agents ?? {})) { + const toolName = `agent-${childKey}`; + index.set(toolName, { + source: "subagent", + agentName: childDef.name ?? childKey, + def: { + name: toolName, + description: + childDef.instructions.slice(0, 120) || + `Delegate to the ${childKey} sub-agent`, + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Message to send to the sub-agent.", + }, + }, + required: ["input"], + }, + }, + }); + } + + // 2. Explicit tools (toolkit entries, function tools, hosted tools) + const hostedToCollect: import("./tools/hosted-tools").HostedTool[] = []; + for (const [key, tool] of Object.entries(def.tools ?? {})) { + if (isToolkitEntry(tool)) { + index.set(key, { + source: "toolkit", + pluginName: tool.pluginName, + localName: tool.localName, + def: { ...tool.def, name: key }, + }); + continue; + } + if (isFunctionTool(tool)) { + index.set(key, { + source: "function", + functionTool: tool, + def: { ...functionToolToDefinition(tool), name: key }, + }); + continue; + } + if (isHostedTool(tool)) { + hostedToCollect.push(tool); + continue; + } + throw new Error( + `Agent '${agentName}' tool '${key}' has an unrecognized shape`, + ); + } + + if (hostedToCollect.length > 0) { + await this.connectHostedTools(hostedToCollect, index); + } + + return index; + } + + private async applyAutoInherit( + agentName: string, + index: Map, + ): Promise { + if (!this.context) return; + for (const { + name: pluginName, + provider, + } of this.context.getToolProviders()) { + if (pluginName === this.name) continue; + const withToolkit = provider as ToolProvider & { + toolkit?: (opts?: unknown) => Record; + }; + if (typeof withToolkit.toolkit === "function") { + const entries = withToolkit.toolkit() as Record; + for (const [key, maybeEntry] of Object.entries(entries)) { + if (!isToolkitEntry(maybeEntry)) continue; + index.set(key, { + source: "toolkit", + pluginName: maybeEntry.pluginName, + localName: maybeEntry.localName, + def: { ...maybeEntry.def, name: key }, + }); + } + continue; + } + // Fallback: providers without a toolkit() still expose getAgentTools(); + // dispatch goes through PluginContext.executeTool by plugin name. + for (const tool of provider.getAgentTools()) { + const qualifiedName = `${pluginName}.${tool.name}`; + index.set(qualifiedName, { + source: "toolkit", + pluginName, + localName: tool.name, + def: { ...tool, name: qualifiedName }, + }); + } + } + const aliased = Array.from(index.keys()); + if (aliased.length > 0) { + logger.info( + "[agent %s] auto-inherited %d tools", + agentName, + aliased.length, + ); + } + } + + private async connectHostedTools( + hostedTools: import("./tools/hosted-tools").HostedTool[], + index: Map, + ): Promise { + let host: string | undefined; + let authenticate: () => Promise>; + + try { + const { getWorkspaceClient } = await import("../../context"); + const wsClient = getWorkspaceClient(); + await wsClient.config.ensureResolved(); + host = wsClient.config.host; + authenticate = async () => { + const headers = new Headers(); + await wsClient.config.authenticate(headers); + return Object.fromEntries(headers.entries()); + }; + } catch { + host = process.env.DATABRICKS_HOST; + authenticate = async (): Promise> => { + const token = process.env.DATABRICKS_TOKEN; + return token ? { Authorization: `Bearer ${token}` } : {}; + }; + } + + if (!host) { + logger.warn( + "No Databricks host available — skipping %d hosted tool(s)", + hostedTools.length, + ); + return; + } + + if (!this.mcpClient) { + this.mcpClient = new AppKitMcpClient(host, authenticate); + } + + const endpoints = resolveHostedTools(hostedTools); + await this.mcpClient.connectAll(endpoints); + + for (const def of this.mcpClient.getAllToolDefinitions()) { + index.set(def.name, { + source: "mcp", + mcpToolName: def.name, + def, + }); + } + } + + // ----------------- ToolProvider (no tools of our own) -------------------- + + getAgentTools(): AgentToolDefinition[] { + return []; + } + + async executeAgentTool(): Promise { + throw new Error("AgentsPlugin does not expose executeAgentTool directly"); + } + + // ----------------- Route mounting and handlers --------------------------- + + private mountInvocationsRoute() { + if (!this.context) return; + this.context.addRoute( + "post", + "/invocations", + (req: express.Request, res: express.Response) => { + this._handleInvocations(req, res); + }, + ); + } + + injectRoutes(router: IAppRouter) { + this.route(router, { + name: "chat", + method: "post", + path: "/chat", + handler: async (req, res) => this._handleChat(req, res), + }); + this.route(router, { + name: "cancel", + method: "post", + path: "/cancel", + handler: async (req, res) => this._handleCancel(req, res), + }); + this.route(router, { + name: "threads", + method: "get", + path: "/threads", + handler: async (req, res) => this._handleListThreads(req, res), + }); + this.route(router, { + name: "thread", + method: "get", + path: "/threads/:threadId", + handler: async (req, res) => this._handleGetThread(req, res), + }); + this.route(router, { + name: "deleteThread", + method: "delete", + path: "/threads/:threadId", + handler: async (req, res) => this._handleDeleteThread(req, res), + }); + this.route(router, { + name: "info", + method: "get", + path: "/info", + handler: async (_req, res) => { + res.json({ + agents: Array.from(this.agents.keys()), + defaultAgent: this.defaultAgentName, + }); + }, + }); + } + + clientConfig(): Record { + return { + agents: Array.from(this.agents.keys()), + defaultAgent: this.defaultAgentName, + }; + } + + private async _handleChat(req: express.Request, res: express.Response) { + const parsed = chatRequestSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + error: "Invalid request", + details: parsed.error.flatten().fieldErrors, + }); + return; + } + const { message, threadId, agent: agentName } = parsed.data; + + const registered = this.resolveAgent(agentName); + if (!registered) { + res.status(400).json({ + error: agentName + ? `Agent "${agentName}" not found` + : "No agent registered", + }); + return; + } + + const userId = this.resolveUserId(req); + let thread = threadId ? await this.threadStore.get(threadId, userId) : null; + if (threadId && !thread) { + res.status(404).json({ error: `Thread ${threadId} not found` }); + return; + } + if (!thread) { + thread = await this.threadStore.create(userId); + } + + const userMessage: Message = { + id: randomUUID(), + role: "user", + content: message, + createdAt: new Date(), + }; + await this.threadStore.addMessage(thread.id, userId, userMessage); + return this._streamAgent(req, res, registered, thread, userId); + } + + private async _handleInvocations( + req: express.Request, + res: express.Response, + ) { + const parsed = invocationsRequestSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + error: "Invalid request", + details: parsed.error.flatten().fieldErrors, + }); + return; + } + const { input } = parsed.data; + const registered = this.resolveAgent(); + if (!registered) { + res.status(400).json({ error: "No agent registered" }); + return; + } + const userId = this.resolveUserId(req); + const thread = await this.threadStore.create(userId); + + if (typeof input === "string") { + await this.threadStore.addMessage(thread.id, userId, { + id: randomUUID(), + role: "user", + content: input, + createdAt: new Date(), + }); + } else { + for (const item of input) { + const role = (item.role ?? "user") as Message["role"]; + const content = + typeof item.content === "string" + ? item.content + : JSON.stringify(item.content ?? ""); + if (!content) continue; + await this.threadStore.addMessage(thread.id, userId, { + id: randomUUID(), + role, + content, + createdAt: new Date(), + }); + } + } + + return this._streamAgent(req, res, registered, thread, userId); + } + + private async _streamAgent( + req: express.Request, + res: express.Response, + registered: RegisteredAgent, + thread: Thread, + userId: string, + ): Promise { + const abortController = new AbortController(); + const signal = abortController.signal; + const requestId = randomUUID(); + this.activeStreams.set(requestId, abortController); + + const tools = Array.from(registered.toolIndex.values()).map((e) => e.def); + const self = this; + + const executeTool = async ( + name: string, + args: unknown, + ): Promise => { + const entry = registered.toolIndex.get(name); + if (!entry) throw new Error(`Unknown tool: ${name}`); + + let result: unknown; + if (entry.source === "toolkit") { + if (!self.context) { + throw new Error( + "Plugin tool execution requires PluginContext; this should never happen through createApp", + ); + } + result = await self.context.executeTool( + req, + entry.pluginName, + entry.localName, + args, + signal, + ); + } else if (entry.source === "function") { + result = await entry.functionTool.execute( + args as Record, + ); + } else if (entry.source === "mcp") { + if (!self.mcpClient) throw new Error("MCP client not connected"); + const oboToken = req.headers["x-forwarded-access-token"]; + const mcpAuth = + typeof oboToken === "string" + ? { Authorization: `Bearer ${oboToken}` } + : undefined; + result = await self.mcpClient.callTool( + entry.mcpToolName, + args, + mcpAuth, + ); + } else if (entry.source === "subagent") { + const childAgent = self.agents.get(entry.agentName); + if (!childAgent) + throw new Error(`Sub-agent not found: ${entry.agentName}`); + result = await self.runSubAgent(req, childAgent, args, signal); + } + + if (result === undefined) { + return `Error: Tool "${name}" execution failed`; + } + const MAX = 50_000; + const serialized = + typeof result === "string" ? result : JSON.stringify(result); + if (serialized.length > MAX) { + return `${serialized.slice(0, MAX)}\n\n[Result truncated: ${serialized.length} chars exceeds ${MAX} limit]`; + } + return result; + }; + + await this.executeStream( + res, + async function* () { + const translator = new AgentEventTranslator(); + try { + for (const evt of translator.translate({ + type: "metadata", + data: { threadId: thread.id }, + })) { + yield evt; + } + + const pluginNames = self.context + ? self.context + .getPluginNames() + .filter((n) => n !== self.name && n !== "server") + : []; + const fullPrompt = composePromptForAgent( + registered, + self.config.baseSystemPrompt, + { + agentName: registered.name, + pluginNames, + toolNames: tools.map((t) => t.name), + }, + ); + + const messagesWithSystem: Message[] = [ + { + id: "system", + role: "system", + content: fullPrompt, + createdAt: new Date(), + }, + ...thread.messages, + ]; + + const stream = registered.adapter.run( + { + messages: messagesWithSystem, + tools, + threadId: thread.id, + signal, + }, + { executeTool, signal }, + ); + + let fullContent = ""; + for await (const event of stream) { + if (signal.aborted) break; + if (event.type === "message_delta") { + fullContent += event.content; + } + for (const translated of translator.translate(event)) { + yield translated; + } + } + + if (fullContent) { + await self.threadStore.addMessage(thread.id, userId, { + id: randomUUID(), + role: "assistant", + content: fullContent, + createdAt: new Date(), + }); + } + + for (const evt of translator.finalize()) yield evt; + } catch (error) { + if (signal.aborted) return; + logger.error("Agent chat error: %O", error); + throw error; + } finally { + self.activeStreams.delete(requestId); + } + }, + { + ...agentStreamDefaults, + stream: { ...agentStreamDefaults.stream, streamId: requestId }, + }, + ); + } + + /** + * Runs a sub-agent in response to an `agent-` tool call. Returns the + * concatenated text output to hand back to the parent adapter as the tool + * result. + */ + private async runSubAgent( + req: express.Request, + child: RegisteredAgent, + args: unknown, + signal: AbortSignal, + ): Promise { + const input = + typeof args === "object" && + args !== null && + typeof (args as { input?: unknown }).input === "string" + ? (args as { input: string }).input + : JSON.stringify(args); + const childTools = Array.from(child.toolIndex.values()).map((e) => e.def); + + const childExecute = async ( + name: string, + childArgs: unknown, + ): Promise => { + const entry = child.toolIndex.get(name); + if (!entry) throw new Error(`Unknown tool in sub-agent: ${name}`); + if (entry.source === "toolkit" && this.context) { + return this.context.executeTool( + req, + entry.pluginName, + entry.localName, + childArgs, + signal, + ); + } + if (entry.source === "function") { + return entry.functionTool.execute(childArgs as Record); + } + if (entry.source === "subagent") { + const grandchild = this.agents.get(entry.agentName); + if (!grandchild) + throw new Error(`Sub-agent not found: ${entry.agentName}`); + return this.runSubAgent(req, grandchild, childArgs, signal); + } + if (entry.source === "mcp" && this.mcpClient) { + const oboToken = req.headers["x-forwarded-access-token"]; + const mcpAuth = + typeof oboToken === "string" + ? { Authorization: `Bearer ${oboToken}` } + : undefined; + return this.mcpClient.callTool(entry.mcpToolName, childArgs, mcpAuth); + } + throw new Error(`Unsupported sub-agent tool source: ${entry.source}`); + }; + + const runContext: AgentRunContext = { executeTool: childExecute, signal }; + + const pluginNames = this.context + ? this.context + .getPluginNames() + .filter((n) => n !== this.name && n !== "server") + : []; + const systemPrompt = composePromptForAgent( + child, + this.config.baseSystemPrompt, + { + agentName: child.name, + pluginNames, + toolNames: childTools.map((t) => t.name), + }, + ); + + const messages: Message[] = [ + { + id: "system", + role: "system", + content: systemPrompt, + createdAt: new Date(), + }, + { + id: randomUUID(), + role: "user", + content: input, + createdAt: new Date(), + }, + ]; + + let output = ""; + const events: AgentEvent[] = []; + for await (const event of child.adapter.run( + { messages, tools: childTools, threadId: randomUUID(), signal }, + runContext, + )) { + events.push(event); + if (event.type === "message_delta") output += event.content; + else if (event.type === "message") output = event.content; + } + return output; + } + + private async _handleCancel(req: express.Request, res: express.Response) { + const { streamId } = req.body as { streamId?: string }; + if (!streamId) { + res.status(400).json({ error: "streamId is required" }); + return; + } + const controller = this.activeStreams.get(streamId); + if (controller) { + controller.abort("Cancelled by user"); + this.activeStreams.delete(streamId); + } + res.json({ cancelled: true }); + } + + private async _handleListThreads( + req: express.Request, + res: express.Response, + ) { + const userId = this.resolveUserId(req); + const threads = await this.threadStore.list(userId); + res.json({ threads }); + } + + private async _handleGetThread(req: express.Request, res: express.Response) { + const userId = this.resolveUserId(req); + const thread = await this.threadStore.get(req.params.threadId, userId); + if (!thread) { + res.status(404).json({ error: "Thread not found" }); + return; + } + res.json(thread); + } + + private async _handleDeleteThread( + req: express.Request, + res: express.Response, + ) { + const userId = this.resolveUserId(req); + const deleted = await this.threadStore.delete(req.params.threadId, userId); + if (!deleted) { + res.status(404).json({ error: "Thread not found" }); + return; + } + res.json({ deleted: true }); + } + + private resolveAgent(name?: string): RegisteredAgent | null { + if (name) return this.agents.get(name) ?? null; + if (this.defaultAgentName) { + return this.agents.get(this.defaultAgentName) ?? null; + } + const first = this.agents.values().next(); + return first.done ? null : first.value; + } + + private printRegistry(): void { + if (this.agents.size === 0) return; + console.log(""); + console.log(` ${pc.bold("Agents")} ${pc.dim(`(${this.agents.size})`)}`); + console.log(` ${pc.dim("─".repeat(60))}`); + for (const [name, reg] of this.agents) { + const tools = reg.toolIndex.size; + const marker = name === this.defaultAgentName ? pc.green("●") : " "; + console.log( + ` ${marker} ${pc.bold(name.padEnd(24))} ${pc.dim(`${tools} tools`)}`, + ); + } + console.log(` ${pc.dim("─".repeat(60))}`); + console.log(""); + } + + async shutdown(): Promise { + if (this.mcpClient) { + await this.mcpClient.close(); + this.mcpClient = null; + } + } + + exports() { + return { + register: (name: string, def: AgentDefinition) => + this.registerCodeAgent(name, def), + list: () => Array.from(this.agents.keys()), + get: (name: string) => this.agents.get(name) ?? null, + reload: () => this.reload(), + getDefault: () => this.defaultAgentName, + getThreads: (userId: string) => this.threadStore.list(userId), + }; + } + + private async registerCodeAgent( + name: string, + def: AgentDefinition, + ): Promise { + const registered = await this.buildRegisteredAgent(name, def, { + origin: "code", + }); + this.agents.set(name, registered); + if (!this.defaultAgentName) this.defaultAgentName = name; + } +} + +function normalizeAutoInherit(value: AgentsPluginConfig["autoInheritTools"]): { + file: boolean; + code: boolean; +} { + if (value === undefined) return { file: true, code: false }; + if (typeof value === "boolean") return { file: value, code: value }; + return { file: value.file ?? true, code: value.code ?? false }; +} + +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.pluginNames); + } + + return composeSystemPrompt(base, registered.instructions); +} + +/** + * Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, + * resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` + * runtime API and mounts `/invocations`. + * + * @example + * ```ts + * import { agents, analytics, createApp, server } from "@databricks/appkit"; + * + * await createApp({ + * plugins: [server(), analytics(), agents()], + * }); + * ``` + */ +export const agents = toPlugin(AgentsPlugin); diff --git a/packages/appkit/src/plugins/agents/defaults.ts b/packages/appkit/src/plugins/agents/defaults.ts new file mode 100644 index 00000000..4da11bef --- /dev/null +++ b/packages/appkit/src/plugins/agents/defaults.ts @@ -0,0 +1,12 @@ +import type { StreamExecutionSettings } from "shared"; + +export const agentStreamDefaults: StreamExecutionSettings = { + default: { + cache: { enabled: false }, + retry: { enabled: false }, + timeout: 300_000, + }, + stream: { + bufferSize: 200, + }, +}; diff --git a/packages/appkit/src/plugins/agents/event-translator.ts b/packages/appkit/src/plugins/agents/event-translator.ts new file mode 100644 index 00000000..314f8066 --- /dev/null +++ b/packages/appkit/src/plugins/agents/event-translator.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentEvent, + ResponseFunctionCallOutput, + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseStreamEvent, +} from "shared"; + +/** + * Translates internal AgentEvent stream into Responses API SSE events. + * + * Stateful: one instance per streaming request. Tracks sequence numbers, + * output indices, and message accumulation state. + */ +export class AgentEventTranslator { + private seqNum = 0; + private outputIndex = 0; + private messageId: string | null = null; + private messageText = ""; + private finalized = false; + + translate(event: AgentEvent): ResponseStreamEvent[] { + switch (event.type) { + case "message_delta": + return this.handleMessageDelta(event.content); + case "message": + return this.handleFullMessage(event.content); + case "tool_call": + return this.handleToolCall(event.callId, event.name, event.args); + case "tool_result": + return this.handleToolResult(event.callId, event.result, event.error); + case "thinking": + return [ + { + type: "appkit.thinking", + content: event.content, + sequence_number: this.seqNum++, + }, + ]; + case "metadata": + return [ + { + type: "appkit.metadata", + data: event.data, + sequence_number: this.seqNum++, + }, + ]; + case "status": + return this.handleStatus(event.status, event.error); + } + } + + finalize(): ResponseStreamEvent[] { + if (this.finalized) return []; + this.finalized = true; + + const events: ResponseStreamEvent[] = []; + + if (this.messageId) { + const doneItem: ResponseOutputMessage = { + type: "message", + id: this.messageId, + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: this.messageText }], + }; + events.push({ + type: "response.output_item.done", + output_index: 0, + item: doneItem, + sequence_number: this.seqNum++, + }); + } + + events.push({ + type: "response.completed", + sequence_number: this.seqNum++, + response: {}, + }); + + return events; + } + + private handleMessageDelta(content: string): ResponseStreamEvent[] { + const events: ResponseStreamEvent[] = []; + this.messageText += content; + + if (!this.messageId) { + this.messageId = `msg_${randomUUID()}`; + const item: ResponseOutputMessage = { + type: "message", + id: this.messageId, + status: "in_progress", + role: "assistant", + content: [], + }; + events.push({ + type: "response.output_item.added", + output_index: 0, + item, + sequence_number: this.seqNum++, + }); + } + + events.push({ + type: "response.output_text.delta", + item_id: this.messageId, + output_index: 0, + content_index: 0, + delta: content, + sequence_number: this.seqNum++, + }); + + return events; + } + + private handleFullMessage(content: string): ResponseStreamEvent[] { + if (!this.messageId) { + this.messageId = `msg_${randomUUID()}`; + } + this.messageText = content; + + const item: ResponseOutputMessage = { + type: "message", + id: this.messageId, + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: content }], + }; + + return [ + { + type: "response.output_item.added", + output_index: 0, + item, + sequence_number: this.seqNum++, + }, + { + type: "response.output_item.done", + output_index: 0, + item, + sequence_number: this.seqNum++, + }, + ]; + } + + private handleToolCall( + callId: string, + name: string, + args: unknown, + ): ResponseStreamEvent[] { + this.outputIndex++; + const item: ResponseFunctionToolCall = { + type: "function_call", + id: `fc_${randomUUID()}`, + call_id: callId, + name, + arguments: typeof args === "string" ? args : JSON.stringify(args), + }; + + return [ + { + type: "response.output_item.added", + output_index: this.outputIndex, + item, + sequence_number: this.seqNum++, + }, + { + type: "response.output_item.done", + output_index: this.outputIndex, + item, + sequence_number: this.seqNum++, + }, + ]; + } + + private handleToolResult( + callId: string, + result: unknown, + error?: string, + ): ResponseStreamEvent[] { + this.outputIndex++; + const output = + error ?? (typeof result === "string" ? result : JSON.stringify(result)); + const item: ResponseFunctionCallOutput = { + type: "function_call_output", + id: `fc_output_${randomUUID()}`, + call_id: callId, + output, + }; + + return [ + { + type: "response.output_item.added", + output_index: this.outputIndex, + item, + sequence_number: this.seqNum++, + }, + { + type: "response.output_item.done", + output_index: this.outputIndex, + item, + sequence_number: this.seqNum++, + }, + ]; + } + + private handleStatus(status: string, error?: string): ResponseStreamEvent[] { + if (status === "error") { + return [ + { + type: "error", + error: error ?? "Unknown error", + sequence_number: this.seqNum++, + }, + { + type: "response.failed", + sequence_number: this.seqNum++, + }, + ]; + } + + if (status === "complete") { + return this.finalize(); + } + + return []; + } +} diff --git a/packages/appkit/src/plugins/agents/index.ts b/packages/appkit/src/plugins/agents/index.ts new file mode 100644 index 00000000..1adc41c1 --- /dev/null +++ b/packages/appkit/src/plugins/agents/index.ts @@ -0,0 +1,22 @@ +export { AgentsPlugin, agents } from "./agents"; +export { buildToolkitEntries } from "./build-toolkit"; +export { + type LoadContext, + type LoadResult, + loadAgentFromFile, + loadAgentsFromDir, + parseFrontmatter, +} from "./load-agents"; +export { + type AgentDefinition, + type AgentsPluginConfig, + type AgentTool, + type AutoInheritToolsConfig, + type BaseSystemPromptOption, + isToolkitEntry, + type PromptContext, + type RegisteredAgent, + type ResolvedToolEntry, + type ToolkitEntry, + type ToolkitOptions, +} from "./types"; diff --git a/packages/appkit/src/plugins/agents/load-agents.ts b/packages/appkit/src/plugins/agents/load-agents.ts new file mode 100644 index 00000000..d10321c6 --- /dev/null +++ b/packages/appkit/src/plugins/agents/load-agents.ts @@ -0,0 +1,252 @@ +import fs from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; +import type { AgentAdapter } from "shared"; +import { createLogger } from "../../logging/logger"; +import type { + AgentDefinition, + AgentTool, + BaseSystemPromptOption, + ToolkitEntry, + ToolkitOptions, +} from "./types"; +import { isToolkitEntry } from "./types"; + +const logger = createLogger("agents:loader"); + +interface ToolkitProvider { + toolkit: (opts?: ToolkitOptions) => Record; +} + +export interface LoadContext { + /** Default model when frontmatter has no `endpoint` and the def has no `model`. */ + defaultModel?: AgentAdapter | Promise | string; + /** Ambient tool library referenced by frontmatter `tools: [key1, key2]`. */ + availableTools?: Record; + /** Registered plugin toolkits referenced by frontmatter `toolkits: [...]`. */ + plugins?: Map; +} + +export interface LoadResult { + /** Agent definitions keyed by file-stem name. */ + defs: Record; + /** First file with `default: true` frontmatter, or `null`. */ + defaultAgent: string | null; +} + +interface Frontmatter { + endpoint?: string; + model?: string; + toolkits?: ToolkitSpec[]; + tools?: string[]; + maxSteps?: number; + maxTokens?: number; + default?: boolean; + baseSystemPrompt?: false | string; +} + +type ToolkitSpec = string | { [pluginName: string]: ToolkitOptions | string[] }; + +const ALLOWED_KEYS = new Set([ + "endpoint", + "model", + "toolkits", + "tools", + "maxSteps", + "maxTokens", + "default", + "baseSystemPrompt", +]); + +/** + * Loads a single markdown agent file and resolves its frontmatter against + * registered plugin toolkits + ambient tool library. + */ +export async function loadAgentFromFile( + filePath: string, + ctx: LoadContext, +): Promise { + const raw = fs.readFileSync(filePath, "utf-8"); + const name = path.basename(filePath, ".md"); + return buildDefinition(name, raw, filePath, ctx); +} + +/** + * Scans a directory for `*.md` files and produces an `AgentDefinition` record + * keyed by file-stem. Throws on frontmatter errors or unresolved references. + * Returns an empty map if the directory does not exist. + */ +export async function loadAgentsFromDir( + dir: string, + ctx: LoadContext, +): Promise { + if (!fs.existsSync(dir)) { + return { defs: {}, defaultAgent: null }; + } + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); + const defs: Record = {}; + let defaultAgent: string | null = null; + + for (const file of files) { + const fullPath = path.join(dir, file); + const raw = fs.readFileSync(fullPath, "utf-8"); + const name = path.basename(file, ".md"); + defs[name] = buildDefinition(name, raw, fullPath, ctx); + const { data } = parseFrontmatter(raw, fullPath); + if (data?.default === true && !defaultAgent) { + defaultAgent = name; + } + } + + if (!defaultAgent && Object.keys(defs).length > 0) { + // Fall through — plugin's defaultAgent resolution handles "first registered". + } + + return { defs, defaultAgent }; +} + +/** Exposed for tests. Parses `--- yaml ---\nbody` and validates frontmatter keys. */ +export function parseFrontmatter( + raw: string, + sourcePath?: string, +): { data: Frontmatter | null; content: string } { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) { + return { data: null, content: raw.trim() }; + } + let parsed: unknown; + try { + parsed = yaml.load(match[1]); + } catch (err) { + const src = sourcePath ? ` (${sourcePath})` : ""; + throw new Error( + `Invalid YAML frontmatter${src}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (parsed === null || parsed === undefined) { + return { data: {}, content: match[2].trim() }; + } + if (typeof parsed !== "object" || Array.isArray(parsed)) { + const src = sourcePath ? ` (${sourcePath})` : ""; + throw new Error(`Frontmatter must be a YAML object${src}`); + } + const data = parsed as Record; + for (const key of Object.keys(data)) { + if (!ALLOWED_KEYS.has(key)) { + logger.warn( + "Ignoring unknown frontmatter key '%s' in %s", + key, + sourcePath ?? "", + ); + } + } + return { data: data as Frontmatter, content: match[2].trim() }; +} + +function buildDefinition( + name: string, + raw: string, + filePath: string, + ctx: LoadContext, +): AgentDefinition { + const { data, content } = parseFrontmatter(raw, filePath); + const fm: Frontmatter = data ?? {}; + + const tools = resolveFrontmatterTools(name, fm, filePath, ctx); + const model = fm.model ?? fm.endpoint ?? ctx.defaultModel; + + let baseSystemPrompt: BaseSystemPromptOption | undefined; + if (fm.baseSystemPrompt === false) baseSystemPrompt = false; + else if (typeof fm.baseSystemPrompt === "string") + baseSystemPrompt = fm.baseSystemPrompt; + + return { + name, + instructions: content, + model, + tools: Object.keys(tools).length > 0 ? tools : undefined, + maxSteps: typeof fm.maxSteps === "number" ? fm.maxSteps : undefined, + maxTokens: typeof fm.maxTokens === "number" ? fm.maxTokens : undefined, + baseSystemPrompt, + }; +} + +function resolveFrontmatterTools( + agentName: string, + fm: Frontmatter, + filePath: string, + ctx: LoadContext, +): Record { + const out: Record = {}; + const pluginIdx = ctx.plugins ?? new Map(); + + for (const spec of fm.toolkits ?? []) { + const [pluginName, opts] = parseToolkitSpec(spec, filePath, agentName); + const provider = pluginIdx.get(pluginName); + if (!provider) { + throw new Error( + `Agent '${agentName}' (${filePath}) references toolkit '${pluginName}', but plugin '${pluginName}' is not registered. Available: ${ + pluginIdx.size > 0 + ? Array.from(pluginIdx.keys()).join(", ") + : "" + }`, + ); + } + const entries = provider.toolkit(opts) as Record; + for (const [key, entry] of Object.entries(entries)) { + if (!isToolkitEntry(entry)) { + throw new Error( + `Plugin '${pluginName}'.toolkit() returned a value at key '${key}' that is not a ToolkitEntry`, + ); + } + out[key] = entry as ToolkitEntry; + } + } + + for (const key of fm.tools ?? []) { + const tool = ctx.availableTools?.[key]; + if (!tool) { + const available = ctx.availableTools + ? Object.keys(ctx.availableTools).join(", ") + : ""; + throw new Error( + `Agent '${agentName}' (${filePath}) references tool '${key}', which is not in the agents() plugin's tools field. Available: ${available}`, + ); + } + out[key] = tool; + } + + return out; +} + +function parseToolkitSpec( + spec: ToolkitSpec, + filePath: string, + agentName: string, +): [string, ToolkitOptions | undefined] { + if (typeof spec === "string") { + return [spec, undefined]; + } + if (typeof spec !== "object" || spec === null) { + throw new Error( + `Agent '${agentName}' (${filePath}) has invalid toolkit entry: ${JSON.stringify(spec)}`, + ); + } + const keys = Object.keys(spec); + if (keys.length !== 1) { + throw new Error( + `Agent '${agentName}' (${filePath}) toolkit entry must have exactly one key, got: ${keys.join(", ")}`, + ); + } + const pluginName = keys[0]; + const value = spec[pluginName]; + if (Array.isArray(value)) { + return [pluginName, { only: value }]; + } + if (typeof value === "object" && value !== null) { + return [pluginName, value as ToolkitOptions]; + } + throw new Error( + `Agent '${agentName}' (${filePath}) toolkit '${pluginName}' options must be an array of tool names or an options object`, + ); +} diff --git a/packages/appkit/src/plugins/agents/manifest.json b/packages/appkit/src/plugins/agents/manifest.json new file mode 100644 index 00000000..0cdf2170 --- /dev/null +++ b/packages/appkit/src/plugins/agents/manifest.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "agent", + "displayName": "Agents Plugin", + "description": "AI agents driven by markdown configs or code, with auto-tool-discovery from registered plugins", + "resources": { + "required": [], + "optional": [] + } +} diff --git a/packages/appkit/src/plugins/agents/schemas.ts b/packages/appkit/src/plugins/agents/schemas.ts new file mode 100644 index 00000000..84ab3b88 --- /dev/null +++ b/packages/appkit/src/plugins/agents/schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const chatRequestSchema = z.object({ + message: z.string().min(1, "message must not be empty"), + threadId: z.string().optional(), + agent: z.string().optional(), +}); + +const messageItemSchema = z.object({ + role: z.enum(["user", "assistant", "system"]).optional(), + content: z.union([z.string(), z.array(z.any())]).optional(), + type: z.string().optional(), +}); + +export const invocationsRequestSchema = z.object({ + input: z.union([z.string().min(1), z.array(messageItemSchema).min(1)]), + stream: z.boolean().optional().default(true), + model: z.string().optional(), +}); diff --git a/packages/appkit/src/plugins/agents/system-prompt.ts b/packages/appkit/src/plugins/agents/system-prompt.ts new file mode 100644 index 00000000..634f49c5 --- /dev/null +++ b/packages/appkit/src/plugins/agents/system-prompt.ts @@ -0,0 +1,40 @@ +/** + * Builds the AppKit base system prompt from active plugin names. + * + * The base prompt provides guidelines and app context. It does NOT + * include individual tool descriptions — those are sent via the + * structured `tools` API parameter to the LLM. + */ +export function buildBaseSystemPrompt(pluginNames: string[]): string { + const lines: string[] = [ + "You are an AI assistant running on Databricks AppKit.", + ]; + + if (pluginNames.length > 0) { + lines.push(""); + lines.push(`Active plugins: ${pluginNames.join(", ")}`); + } + + lines.push(""); + lines.push("Guidelines:"); + lines.push("- Use Databricks SQL syntax when writing queries"); + lines.push( + "- When results are large, summarize key findings rather than dumping raw data", + ); + lines.push("- If a tool call fails, explain the error clearly to the user"); + lines.push("- When browsing files, verify the path exists before reading"); + + return lines.join("\n"); +} + +/** + * Compose the full system prompt from the base prompt and an optional + * per-agent user prompt. + */ +export function composeSystemPrompt( + basePrompt: string, + agentPrompt?: string, +): string { + if (!agentPrompt) return basePrompt; + return `${basePrompt}\n\n${agentPrompt}`; +} diff --git a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts new file mode 100644 index 00000000..8116551e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -0,0 +1,289 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + AgentAdapter, + AgentInput, + AgentRunContext, + AgentToolDefinition, + ToolProvider, +} from "shared"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { CacheManager } from "../../../cache"; +// Import the class directly so we can construct it without a createApp +import { AgentsPlugin } from "../agents"; +import { buildToolkitEntries } from "../build-toolkit"; +import { defineTool, type ToolRegistry } from "../tools/define-tool"; +import type { AgentsPluginConfig, ToolkitEntry } from "../types"; +import { isToolkitEntry } from "../types"; + +interface FakeContext { + providers: Array<{ name: string; provider: ToolProvider }>; + getToolProviders(): Array<{ name: string; provider: ToolProvider }>; + getPluginNames(): string[]; + addRoute(): void; + executeTool: ( + req: unknown, + pluginName: string, + localName: string, + args: unknown, + ) => Promise; +} + +function fakeContext( + providers: Array<{ name: string; provider: ToolProvider }>, +): FakeContext { + return { + providers, + getToolProviders: () => providers, + getPluginNames: () => providers.map((p) => p.name), + addRoute: vi.fn(), + executeTool: vi.fn(async (_req, p, n, args) => ({ + plugin: p, + tool: n, + args, + })), + }; +} + +function stubAdapter(): AgentAdapter { + return { + async *run(_input: AgentInput, _ctx: AgentRunContext) { + yield { type: "message_delta", content: "" }; + }, + }; +} + +function makeToolProvider( + pluginName: string, + registry: ToolRegistry, +): ToolProvider & { + toolkit: (opts?: unknown) => Record; +} { + return { + getAgentTools(): AgentToolDefinition[] { + return Object.entries(registry).map(([name, entry]) => ({ + name, + description: entry.description, + parameters: { type: "object", properties: {} }, + })); + }, + async executeAgentTool(name, args) { + return { callFrom: pluginName, name, args }; + }, + toolkit: (opts) => buildToolkitEntries(pluginName, registry, opts as never), + }; +} + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-plugin-")); + const storage = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + keys: vi.fn(), + healthCheck: vi.fn(async () => true), + close: vi.fn(async () => {}), + }; + // biome-ignore lint/suspicious/noExplicitAny: test-only CacheManager wiring + await CacheManager.getInstance({ storage: storage as any }); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function instantiate(config: AgentsPluginConfig, ctx?: FakeContext) { + const plugin = new AgentsPlugin({ ...config, name: "agent" }); + plugin.attachContext({ context: ctx as unknown as object }); + return plugin; +} + +describe("AgentsPlugin", () => { + test("registers code-defined agents and exposes them via exports", async () => { + const plugin = instantiate({ + dir: false, + agents: { + support: { + instructions: "You help customers.", + model: stubAdapter(), + }, + }, + }); + await plugin.setup(); + + const api = plugin.exports() as { + list: () => string[]; + getDefault: () => string | null; + }; + expect(api.list()).toEqual(["support"]); + expect(api.getDefault()).toBe("support"); + }); + + test("loads markdown agents from a directory", async () => { + fs.writeFileSync( + path.join(tmpDir, "assistant.md"), + "---\ndefault: true\n---\nYou are helpful.", + "utf-8", + ); + const plugin = instantiate({ + dir: tmpDir, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as { + list: () => string[]; + getDefault: () => string | null; + }; + expect(api.list()).toEqual(["assistant"]); + expect(api.getDefault()).toBe("assistant"); + }); + + test("code definitions override markdown on key collision", async () => { + fs.writeFileSync( + path.join(tmpDir, "support.md"), + "---\n---\nFrom markdown.", + "utf-8", + ); + const plugin = instantiate({ + dir: tmpDir, + defaultModel: stubAdapter(), + agents: { + support: { + instructions: "From code", + model: stubAdapter(), + }, + }, + }); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { instructions: string } | null; + }; + expect(api.get("support")?.instructions).toBe("From code"); + }); + + test("auto-inherit default is asymmetric (file yes, code no)", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const provider = makeToolProvider("analytics", registry); + const ctx = fakeContext([{ name: "analytics", provider }]); + + fs.writeFileSync( + path.join(tmpDir, "assistant.md"), + "---\n---\nYou are helpful.", + "utf-8", + ); + + const plugin = instantiate( + { + dir: tmpDir, + defaultModel: stubAdapter(), + agents: { + manual: { + instructions: "Manual agent", + model: stubAdapter(), + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const fileAgent = api.get("assistant"); + const codeAgent = api.get("manual"); + + expect(fileAgent?.toolIndex.size).toBeGreaterThan(0); // inherited analytics.query + expect(codeAgent?.toolIndex.size).toBe(0); // code opted out by default + }); + + test("file-loaded agent respects explicit toolkits (skips auto-inherit)", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const registry2: ToolRegistry = { + list: defineTool({ + description: "l", + schema: z.object({}), + handler: () => [], + }), + }; + const ctx = fakeContext([ + { name: "analytics", provider: makeToolProvider("analytics", registry) }, + { name: "files", provider: makeToolProvider("files", registry2) }, + ]); + + fs.writeFileSync( + path.join(tmpDir, "analyst.md"), + "---\ntoolkits: [analytics]\n---\nAnalyst.", + "utf-8", + ); + + const plugin = instantiate( + { dir: tmpDir, defaultModel: stubAdapter() }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("analyst"); + 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("registers sub-agents as agent- tools", async () => { + const plugin = instantiate({ + dir: false, + agents: { + supervisor: { + instructions: "Supervise", + model: stubAdapter(), + agents: { + worker: { + instructions: "Work", + model: stubAdapter(), + }, + }, + }, + }, + }); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const sup = api.get("supervisor"); + expect(sup?.toolIndex.has("agent-worker")).toBe(true); + }); + + test("isToolkitEntry type guard recognizes toolkit entries", () => { + const entry: ToolkitEntry = { + __toolkitRef: true, + pluginName: "x", + localName: "y", + def: { name: "x.y", description: "", parameters: { type: "object" } }, + }; + expect(isToolkitEntry(entry)).toBe(true); + expect(isToolkitEntry({ foo: 1 })).toBe(false); + expect(isToolkitEntry(null)).toBe(false); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/create-agent.test.ts b/packages/appkit/src/plugins/agents/tests/create-agent.test.ts new file mode 100644 index 00000000..3822897f --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/create-agent.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { createAgent } from "../../../core/create-agent-def"; +import { tool } from "../tools/tool"; +import type { AgentDefinition } from "../types"; + +describe("createAgent", () => { + test("returns the definition unchanged for a simple agent", () => { + const def: AgentDefinition = { + name: "support", + instructions: "You help customers.", + model: "endpoint-x", + }; + const result = createAgent(def); + expect(result).toBe(def); + }); + + test("accepts tools as a keyed record", () => { + const get_weather = tool({ + name: "get_weather", + description: "Get the weather", + schema: z.object({ city: z.string() }), + execute: async ({ city }) => `Sunny in ${city}`, + }); + + const def = createAgent({ + instructions: "...", + tools: { get_weather }, + }); + + expect(def.tools?.get_weather).toBe(get_weather); + }); + + test("accepts sub-agents in a keyed record", () => { + const researcher = createAgent({ instructions: "Research." }); + const supervisor = createAgent({ + instructions: "Supervise.", + agents: { researcher }, + }); + expect(supervisor.agents?.researcher).toBe(researcher); + }); + + test("throws on a direct self-cycle", () => { + const a: AgentDefinition = { instructions: "a" }; + // biome-ignore lint/suspicious/noExplicitAny: intentional cycle setup for test + (a as any).agents = { self: a }; + expect(() => createAgent(a)).toThrow(/cycle/i); + }); + + test("throws on an indirect cycle", () => { + const a: AgentDefinition = { instructions: "a" }; + const b: AgentDefinition = { instructions: "b" }; + a.agents = { b }; + b.agents = { a }; + expect(() => createAgent(a)).toThrow(/cycle/i); + }); + + test("accepts a DAG of sub-agents without throwing", () => { + const leaf: AgentDefinition = { instructions: "leaf" }; + const branchA: AgentDefinition = { + instructions: "a", + agents: { leaf }, + }; + const branchB: AgentDefinition = { + instructions: "b", + agents: { leaf }, + }; + const root = createAgent({ + instructions: "root", + agents: { branchA, branchB }, + }); + expect(root.agents?.branchA.agents?.leaf).toBe(leaf); + expect(root.agents?.branchB.agents?.leaf).toBe(leaf); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/event-translator.test.ts b/packages/appkit/src/plugins/agents/tests/event-translator.test.ts new file mode 100644 index 00000000..eda72ebb --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/event-translator.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "vitest"; +import { AgentEventTranslator } from "../event-translator"; + +describe("AgentEventTranslator", () => { + test("translates message_delta to output_item.added + output_text.delta on first delta", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "message_delta", + content: "Hello", + }); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("response.output_item.added"); + expect(events[1].type).toBe("response.output_text.delta"); + + if (events[1].type === "response.output_text.delta") { + expect(events[1].delta).toBe("Hello"); + } + }); + + test("subsequent message_delta only produces output_text.delta", () => { + const translator = new AgentEventTranslator(); + translator.translate({ type: "message_delta", content: "Hello" }); + const events = translator.translate({ + type: "message_delta", + content: " world", + }); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("response.output_text.delta"); + }); + + test("sequence_number is monotonically increasing", () => { + const translator = new AgentEventTranslator(); + const e1 = translator.translate({ type: "message_delta", content: "a" }); + const e2 = translator.translate({ type: "message_delta", content: "b" }); + const e3 = translator.finalize(); + + const allSeqs = [...e1, ...e2, ...e3].map((e) => + "sequence_number" in e ? e.sequence_number : -1, + ); + + for (let i = 1; i < allSeqs.length; i++) { + expect(allSeqs[i]).toBeGreaterThan(allSeqs[i - 1]); + } + }); + + test("translates tool_call to paired output_item.added + output_item.done", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "tool_call", + callId: "call_1", + name: "analytics.query", + args: { sql: "SELECT 1" }, + }); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("response.output_item.added"); + expect(events[1].type).toBe("response.output_item.done"); + + if (events[0].type === "response.output_item.added") { + expect(events[0].item.type).toBe("function_call"); + if (events[0].item.type === "function_call") { + expect(events[0].item.name).toBe("analytics.query"); + expect(events[0].item.call_id).toBe("call_1"); + } + } + }); + + test("translates tool_result to paired output_item events", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "tool_result", + callId: "call_1", + result: { rows: 42 }, + }); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("response.output_item.added"); + + if (events[0].type === "response.output_item.added") { + expect(events[0].item.type).toBe("function_call_output"); + } + }); + + test("translates tool_result error", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "tool_result", + callId: "call_1", + result: null, + error: "Query failed", + }); + + if ( + events[0].type === "response.output_item.added" && + events[0].item.type === "function_call_output" + ) { + expect(events[0].item.output).toBe("Query failed"); + } + }); + + test("translates thinking to appkit.thinking extension event", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "thinking", + content: "Let me think about this...", + }); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("appkit.thinking"); + if (events[0].type === "appkit.thinking") { + expect(events[0].content).toBe("Let me think about this..."); + } + }); + + test("translates metadata to appkit.metadata extension event", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "metadata", + data: { threadId: "t-123" }, + }); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("appkit.metadata"); + if (events[0].type === "appkit.metadata") { + expect(events[0].data.threadId).toBe("t-123"); + } + }); + + test("status:complete triggers finalize with response.completed", () => { + const translator = new AgentEventTranslator(); + translator.translate({ type: "message_delta", content: "Hi" }); + const events = translator.translate({ type: "status", status: "complete" }); + + const types = events.map((e) => e.type); + expect(types).toContain("response.output_item.done"); + expect(types).toContain("response.completed"); + }); + + test("status:error emits error + response.failed", () => { + const translator = new AgentEventTranslator(); + const events = translator.translate({ + type: "status", + status: "error", + error: "Something broke", + }); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("error"); + expect(events[1].type).toBe("response.failed"); + + if (events[0].type === "error") { + expect(events[0].error).toBe("Something broke"); + } + }); + + test("finalize produces response.completed", () => { + const translator = new AgentEventTranslator(); + const events = translator.finalize(); + + expect(events.some((e) => e.type === "response.completed")).toBe(true); + }); + + test("finalize with accumulated message text produces output_item.done", () => { + const translator = new AgentEventTranslator(); + translator.translate({ type: "message_delta", content: "Hello " }); + translator.translate({ type: "message_delta", content: "world" }); + const events = translator.finalize(); + + const doneEvent = events.find( + (e) => e.type === "response.output_item.done", + ); + expect(doneEvent).toBeDefined(); + if ( + doneEvent?.type === "response.output_item.done" && + doneEvent.item.type === "message" + ) { + expect(doneEvent.item.content[0].text).toBe("Hello world"); + } + }); + + test("output_index increments for tool calls", () => { + const translator = new AgentEventTranslator(); + const e1 = translator.translate({ + type: "tool_call", + callId: "c1", + name: "tool1", + args: {}, + }); + const e2 = translator.translate({ + type: "tool_result", + callId: "c1", + result: "ok", + }); + + if ( + e1[0].type === "response.output_item.added" && + e2[0].type === "response.output_item.added" + ) { + expect(e2[0].output_index).toBeGreaterThan(e1[0].output_index); + } + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/load-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-agents.test.ts new file mode 100644 index 00000000..5a7b1253 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/load-agents.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { z } from "zod"; +import { buildToolkitEntries } from "../build-toolkit"; +import { + loadAgentFromFile, + loadAgentsFromDir, + parseFrontmatter, +} from "../load-agents"; +import { defineTool, type ToolRegistry } from "../tools/define-tool"; +import { tool } from "../tools/tool"; + +let workDir: string; + +beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-test-")); +}); + +afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +function write(name: string, content: string) { + fs.writeFileSync(path.join(workDir, name), content, "utf-8"); + return path.join(workDir, name); +} + +describe("parseFrontmatter", () => { + test("parses a simple object", () => { + const { data, content } = parseFrontmatter( + "---\nendpoint: foo\ndefault: true\n---\nHello body", + ); + expect(data).toEqual({ endpoint: "foo", default: true }); + expect(content).toBe("Hello body"); + }); + + test("parses nested arrays", () => { + const { data } = parseFrontmatter( + "---\ntoolkits:\n - analytics\n - files: [uploads.list]\n---\nbody", + ); + expect(data?.toolkits).toEqual(["analytics", { files: ["uploads.list"] }]); + }); + + test("returns null data when no frontmatter", () => { + const { data, content } = parseFrontmatter("No frontmatter here"); + expect(data).toBeNull(); + expect(content).toBe("No frontmatter here"); + }); + + test("throws on invalid YAML", () => { + expect(() => parseFrontmatter("---\nkey: : : bad\n---\n")).toThrow(/YAML/); + }); +}); + +describe("loadAgentFromFile", () => { + test("returns AgentDefinition with body as instructions", async () => { + const p = write( + "assistant.md", + "---\nendpoint: e-1\n---\nYou are helpful.", + ); + const def = await loadAgentFromFile(p, {}); + expect(def.name).toBe("assistant"); + expect(def.instructions).toBe("You are helpful."); + expect(def.model).toBe("e-1"); + }); +}); + +describe("loadAgentsFromDir", () => { + test("returns empty map when dir doesn't exist", async () => { + const res = await loadAgentsFromDir("/nonexistent-for-tests", {}); + expect(res.defs).toEqual({}); + expect(res.defaultAgent).toBeNull(); + }); + + test("loads all .md files keyed by file-stem", async () => { + write("support.md", "---\nendpoint: e-1\n---\nSupport prompt."); + write("sales.md", "---\nendpoint: e-2\n---\nSales prompt."); + const res = await loadAgentsFromDir(workDir, {}); + expect(Object.keys(res.defs).sort()).toEqual(["sales", "support"]); + }); + + test("picks up default: true from frontmatter", async () => { + write("one.md", "---\nendpoint: a\n---\nOne."); + write("two.md", "---\nendpoint: b\ndefault: true\n---\nTwo."); + const res = await loadAgentsFromDir(workDir, {}); + expect(res.defaultAgent).toBe("two"); + }); + + test("throws when frontmatter references an unregistered plugin", async () => { + write( + "broken.md", + "---\nendpoint: e\ntoolkits: [missing]\n---\nBroken agent.", + ); + await expect(loadAgentsFromDir(workDir, {})).rejects.toThrow( + /references toolkit 'missing'/, + ); + }); + + test("throws when frontmatter references an unknown ambient tool", async () => { + write("broken.md", "---\nendpoint: e\ntools: [unknown_tool]\n---\nBroken."); + await expect(loadAgentsFromDir(workDir, {})).rejects.toThrow( + /references tool 'unknown_tool'/, + ); + }); + + test("resolves toolkits + ambient tools when provided", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const plugins = new Map< + string, + { toolkit: (opts?: unknown) => Record } + >([ + [ + "analytics", + { + toolkit: (opts) => + buildToolkitEntries("analytics", registry, opts as never), + }, + ], + ]); + + const weather = tool({ + name: "get_weather", + description: "Weather", + schema: z.object({ city: z.string() }), + execute: async () => "sunny", + }); + + write( + "analyst.md", + "---\nendpoint: e\ntoolkits:\n - analytics\ntools:\n - get_weather\n---\nBody.", + ); + const res = await loadAgentsFromDir(workDir, { + plugins, + availableTools: { get_weather: weather }, + }); + expect(res.defs.analyst.tools).toBeDefined(); + expect(Object.keys(res.defs.analyst.tools ?? {}).sort()).toEqual([ + "analytics.query", + "get_weather", + ]); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/run-agent.test.ts b/packages/appkit/src/plugins/agents/tests/run-agent.test.ts new file mode 100644 index 00000000..1a974811 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/run-agent.test.ts @@ -0,0 +1,120 @@ +import type { + AgentAdapter, + AgentEvent, + AgentInput, + AgentRunContext, +} from "shared"; +import { describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { createAgent } from "../../../core/create-agent-def"; +import { runAgent } from "../../../core/run-agent"; +import { tool } from "../tools/tool"; +import type { ToolkitEntry } from "../types"; + +function scriptedAdapter(events: AgentEvent[]): AgentAdapter { + return { + async *run(_input: AgentInput, _context: AgentRunContext) { + for (const event of events) { + yield event; + } + }, + }; +} + +describe("runAgent", () => { + test("drives the adapter and returns aggregated text", async () => { + const events: AgentEvent[] = [ + { type: "message_delta", content: "Hello " }, + { type: "message_delta", content: "world" }, + { type: "status", status: "complete" }, + ]; + const def = createAgent({ + instructions: "Say hello", + model: scriptedAdapter(events), + }); + + const result = await runAgent(def, { messages: "hi" }); + expect(result.text).toBe("Hello world"); + expect(result.events).toHaveLength(3); + }); + + test("prefers terminal 'message' event over deltas when present", async () => { + const events: AgentEvent[] = [ + { type: "message_delta", content: "partial" }, + { type: "message", content: "final answer" }, + ]; + const def = createAgent({ + instructions: "x", + model: scriptedAdapter(events), + }); + const result = await runAgent(def, { messages: "hi" }); + expect(result.text).toBe("final answer"); + }); + + test("invokes inline tools via executeTool callback", async () => { + const weatherFn = vi.fn(async () => "Sunny in NYC"); + const weather = tool({ + name: "get_weather", + description: "Weather", + schema: z.object({ city: z.string() }), + execute: weatherFn, + }); + + 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: { get_weather: weather }, + }); + + await runAgent(def, { messages: "hi" }); + expect(capturedCtx).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: asserted above + const result = await capturedCtx!.executeTool("get_weather", { + city: "NYC", + }); + expect(result).toBe("Sunny in NYC"); + expect(weatherFn).toHaveBeenCalledWith({ city: "NYC" }); + }); + + test("throws a clear error when a ToolkitEntry is invoked", async () => { + const toolkitEntry: ToolkitEntry = { + __toolkitRef: true, + pluginName: "analytics", + localName: "query", + def: { + name: "analytics.query", + description: "SQL", + parameters: { type: "object", properties: {} }, + }, + }; + + 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: { "analytics.query": toolkitEntry }, + }); + + await runAgent(def, { messages: "hi" }); + expect(capturedCtx).not.toBeNull(); + await expect( + // biome-ignore lint/style/noNonNullAssertion: asserted above + capturedCtx!.executeTool("analytics.query", {}), + ).rejects.toThrow(/only usable via createApp/); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/system-prompt.test.ts b/packages/appkit/src/plugins/agents/tests/system-prompt.test.ts new file mode 100644 index 00000000..83bf8e19 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/system-prompt.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; +import { buildBaseSystemPrompt, composeSystemPrompt } from "../system-prompt"; + +describe("buildBaseSystemPrompt", () => { + test("includes plugin names", () => { + const prompt = buildBaseSystemPrompt(["analytics", "files", "genie"]); + expect(prompt).toContain("Active plugins: analytics, files, genie"); + }); + + test("includes guidelines", () => { + const prompt = buildBaseSystemPrompt([]); + expect(prompt).toContain("Guidelines:"); + expect(prompt).toContain("Databricks SQL"); + expect(prompt).toContain("summarize key findings"); + }); + + test("works with no plugins", () => { + const prompt = buildBaseSystemPrompt([]); + expect(prompt).toContain("AI assistant running on Databricks AppKit"); + expect(prompt).not.toContain("Active plugins:"); + }); + + test("does NOT include individual tool names", () => { + const prompt = buildBaseSystemPrompt(["analytics"]); + expect(prompt).not.toContain("analytics.query"); + expect(prompt).not.toContain("Available tools:"); + }); +}); + +describe("composeSystemPrompt", () => { + test("concatenates base + agent prompt with double newline", () => { + const composed = composeSystemPrompt("Base prompt.", "Agent prompt."); + expect(composed).toBe("Base prompt.\n\nAgent prompt."); + }); + + test("returns base prompt alone when no agent prompt", () => { + const composed = composeSystemPrompt("Base prompt."); + expect(composed).toBe("Base prompt."); + }); + + test("returns base prompt when agent prompt is empty string", () => { + const composed = composeSystemPrompt("Base prompt.", ""); + expect(composed).toBe("Base prompt."); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/thread-store.test.ts b/packages/appkit/src/plugins/agents/tests/thread-store.test.ts new file mode 100644 index 00000000..ed4f70ba --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/thread-store.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "vitest"; +import { InMemoryThreadStore } from "../thread-store"; + +describe("InMemoryThreadStore", () => { + test("create() returns a new thread with the given userId", async () => { + const store = new InMemoryThreadStore(); + const thread = await store.create("user-1"); + + expect(thread.id).toBeDefined(); + expect(thread.userId).toBe("user-1"); + expect(thread.messages).toEqual([]); + expect(thread.createdAt).toBeInstanceOf(Date); + expect(thread.updatedAt).toBeInstanceOf(Date); + }); + + test("get() returns the thread for the correct user", async () => { + const store = new InMemoryThreadStore(); + const thread = await store.create("user-1"); + + const retrieved = await store.get(thread.id, "user-1"); + expect(retrieved).toEqual(thread); + }); + + test("get() returns null for wrong user", async () => { + const store = new InMemoryThreadStore(); + const thread = await store.create("user-1"); + + const retrieved = await store.get(thread.id, "user-2"); + expect(retrieved).toBeNull(); + }); + + test("get() returns null for non-existent thread", async () => { + const store = new InMemoryThreadStore(); + const retrieved = await store.get("non-existent", "user-1"); + expect(retrieved).toBeNull(); + }); + + test("list() returns threads sorted by updatedAt desc", async () => { + const store = new InMemoryThreadStore(); + const t1 = await store.create("user-1"); + const t2 = await store.create("user-1"); + + // Make t1 more recently updated + await store.addMessage(t1.id, "user-1", { + id: "msg-1", + role: "user", + content: "hello", + createdAt: new Date(), + }); + + const threads = await store.list("user-1"); + expect(threads).toHaveLength(2); + expect(threads[0].id).toBe(t1.id); + expect(threads[1].id).toBe(t2.id); + }); + + test("list() returns empty for unknown user", async () => { + const store = new InMemoryThreadStore(); + await store.create("user-1"); + + const threads = await store.list("user-2"); + expect(threads).toEqual([]); + }); + + test("addMessage() appends to thread and updates timestamp", async () => { + const store = new InMemoryThreadStore(); + const thread = await store.create("user-1"); + const originalUpdatedAt = thread.updatedAt; + + // Small delay to ensure timestamp differs + await new Promise((r) => setTimeout(r, 5)); + + await store.addMessage(thread.id, "user-1", { + id: "msg-1", + role: "user", + content: "hello", + createdAt: new Date(), + }); + + const updated = await store.get(thread.id, "user-1"); + expect(updated?.messages).toHaveLength(1); + expect(updated?.messages[0].content).toBe("hello"); + expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual( + originalUpdatedAt.getTime(), + ); + }); + + test("addMessage() throws for non-existent thread", async () => { + const store = new InMemoryThreadStore(); + + await expect( + store.addMessage("non-existent", "user-1", { + id: "msg-1", + role: "user", + content: "hello", + createdAt: new Date(), + }), + ).rejects.toThrow("Thread non-existent not found"); + }); + + test("delete() removes a thread and returns true", async () => { + const store = new InMemoryThreadStore(); + const thread = await store.create("user-1"); + + const deleted = await store.delete(thread.id, "user-1"); + expect(deleted).toBe(true); + + const retrieved = await store.get(thread.id, "user-1"); + expect(retrieved).toBeNull(); + }); + + test("delete() returns false for non-existent thread", async () => { + const store = new InMemoryThreadStore(); + const deleted = await store.delete("non-existent", "user-1"); + expect(deleted).toBe(false); + }); + + test("delete() returns false for wrong user", async () => { + const store = new InMemoryThreadStore(); + const thread = await store.create("user-1"); + + const deleted = await store.delete(thread.id, "user-2"); + expect(deleted).toBe(false); + }); + + test("threads are isolated per user", async () => { + const store = new InMemoryThreadStore(); + await store.create("user-1"); + await store.create("user-1"); + await store.create("user-2"); + + const user1Threads = await store.list("user-1"); + const user2Threads = await store.list("user-2"); + + expect(user1Threads).toHaveLength(2); + expect(user2Threads).toHaveLength(1); + }); +}); diff --git a/packages/appkit/src/plugins/agents/thread-store.ts b/packages/appkit/src/plugins/agents/thread-store.ts new file mode 100644 index 00000000..f3ca0599 --- /dev/null +++ b/packages/appkit/src/plugins/agents/thread-store.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; +import type { Message, Thread, ThreadStore } from "shared"; + +/** + * In-memory thread store backed by a nested Map. + * + * Outer key: userId, inner key: threadId. + * Suitable for development and single-instance deployments. + */ +export class InMemoryThreadStore implements ThreadStore { + private store = new Map>(); + + async create(userId: string): Promise { + const now = new Date(); + const thread: Thread = { + id: randomUUID(), + userId, + messages: [], + createdAt: now, + updatedAt: now, + }; + this.userMap(userId).set(thread.id, thread); + return thread; + } + + async get(threadId: string, userId: string): Promise { + return this.userMap(userId).get(threadId) ?? null; + } + + async list(userId: string): Promise { + return Array.from(this.userMap(userId).values()).sort( + (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), + ); + } + + async addMessage( + threadId: string, + userId: string, + message: Message, + ): Promise { + const thread = this.userMap(userId).get(threadId); + if (!thread) throw new Error(`Thread ${threadId} not found`); + thread.messages.push(message); + thread.updatedAt = new Date(); + } + + async delete(threadId: string, userId: string): Promise { + return this.userMap(userId).delete(threadId); + } + + private userMap(userId: string): Map { + let map = this.store.get(userId); + if (!map) { + map = new Map(); + this.store.set(userId, map); + } + return map; + } +} diff --git a/packages/appkit/src/plugins/agents/types.ts b/packages/appkit/src/plugins/agents/types.ts index 861be26d..4963a52a 100644 --- a/packages/appkit/src/plugins/agents/types.ts +++ b/packages/appkit/src/plugins/agents/types.ts @@ -1,4 +1,10 @@ -import type { AgentToolDefinition, ToolAnnotations } from "shared"; +import type { + AgentAdapter, + AgentToolDefinition, + BasePluginConfig, + ThreadStore, + ToolAnnotations, +} from "shared"; import type { FunctionTool } from "./tools/function-tool"; import type { HostedTool } from "./tools/hosted-tools"; @@ -35,8 +41,108 @@ export interface ToolkitOptions { } /** - * Type guard for `ToolkitEntry` — used to differentiate toolkit references - * from inline tools in a mixed `tools` record. + * Context passed to `baseSystemPrompt` callbacks. + */ +export interface PromptContext { + agentName: string; + pluginNames: string[]; + toolNames: string[]; +} + +export type BaseSystemPromptOption = + | false + | string + | ((ctx: PromptContext) => string); + +export interface AgentDefinition { + /** Filled in from the enclosing key when used in `agents: { foo: def }`. */ + name?: string; + /** System prompt body. For markdown-loaded agents this is the file body. */ + instructions: string; + /** + * Model adapter (or endpoint-name string sugar for + * `DatabricksAdapter.fromServingEndpoint({ endpointName })`). Optional — + * falls back to the plugin's `defaultModel`. + */ + model?: AgentAdapter | Promise | string; + /** Per-agent tool record. Key is the LLM-visible tool-call name. */ + tools?: Record; + /** Sub-agents, exposed as `agent-` tools on this agent. */ + agents?: Record; + /** Override the plugin's baseSystemPrompt for this agent only. */ + baseSystemPrompt?: BaseSystemPromptOption; + maxSteps?: number; + maxTokens?: number; +} + +/** + * Asymmetric auto-inherit configuration. `true` on either side means "spread + * every registered ToolProvider plugin's toolkit() output into this agent's + * tool record when it declares no explicit tools/toolkits". + */ +export interface AutoInheritToolsConfig { + /** Default for agents loaded from markdown files. Default: `true`. */ + file?: boolean; + /** Default for code-defined agents (via `agents: { foo: createAgent(...) }`). Default: `false`. */ + code?: boolean; +} + +export interface AgentsPluginConfig extends BasePluginConfig { + /** Directory to scan for markdown agent files. Default `./config/agents`. Set to `false` to disable. */ + dir?: string | false; + /** Code-defined agents, merged with file-loaded ones (code wins on key collision). */ + agents?: Record; + /** Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter. */ + defaultAgent?: string; + /** Default model for agents that don't specify their own (in code or frontmatter). */ + defaultModel?: AgentAdapter | Promise | string; + /** Ambient tool library. Keys may be referenced by markdown frontmatter via `tools: [key1, key2]`. */ + tools?: Record; + /** Whether to auto-inherit every ToolProvider plugin's toolkit. Accepts a boolean shorthand. */ + autoInheritTools?: boolean | AutoInheritToolsConfig; + /** Persistent thread store. Default: in-memory. */ + threadStore?: ThreadStore; + /** Customize or disable the AppKit base system prompt. */ + baseSystemPrompt?: BaseSystemPromptOption; +} + +/** Internal tool-index entry after a tool record has been resolved to a dispatchable form. */ +export type ResolvedToolEntry = + | { + source: "toolkit"; + pluginName: string; + localName: string; + def: AgentToolDefinition; + } + | { + source: "function"; + functionTool: FunctionTool; + def: AgentToolDefinition; + } + | { + source: "mcp"; + mcpToolName: string; + def: AgentToolDefinition; + } + | { + source: "subagent"; + agentName: string; + def: AgentToolDefinition; + }; + +export interface RegisteredAgent { + name: string; + instructions: string; + adapter: AgentAdapter; + toolIndex: Map; + baseSystemPrompt?: BaseSystemPromptOption; + maxSteps?: number; + maxTokens?: number; +} + +/** + * Type guard for `ToolkitEntry` — used by the agents plugin to differentiate + * toolkit references from inline tools in a mixed `tools` record. */ export function isToolkitEntry(value: unknown): value is ToolkitEntry { return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16079b1d..307f44cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: express: specifier: 4.22.0 version: 4.22.0 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 obug: specifier: 2.1.1 version: 2.1.1 @@ -339,6 +342,9 @@ importers: '@types/express': specifier: 4.17.25 version: 4.17.25 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/json-schema': specifier: 7.0.15 version: 7.0.15 @@ -4989,6 +4995,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -17421,6 +17430,8 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} From 29e35345a212cb2350768a16447155bab301cbf4 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:52:50 +0200 Subject: [PATCH 4/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. ### `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 --- packages/appkit/src/core/run-agent.ts | 145 +++++++++++- packages/appkit/src/index.ts | 4 + packages/appkit/src/plugins/agents/agents.ts | 91 +++++--- .../appkit/src/plugins/agents/from-plugin.ts | 97 +++++++++ packages/appkit/src/plugins/agents/index.ts | 8 + .../agents/tests/agents-plugin.test.ts | 206 ++++++++++++++++++ .../plugins/agents/tests/from-plugin.test.ts | 80 +++++++ .../plugins/agents/tests/run-agent.test.ts | 96 ++++++++ .../src/plugins/agents/toolkit-resolver.ts | 62 ++++++ packages/appkit/src/plugins/agents/types.ts | 13 +- 10 files changed, 765 insertions(+), 37 deletions(-) create mode 100644 packages/appkit/src/plugins/agents/from-plugin.ts create mode 100644 packages/appkit/src/plugins/agents/tests/from-plugin.test.ts create mode 100644 packages/appkit/src/plugins/agents/toolkit-resolver.ts diff --git a/packages/appkit/src/core/run-agent.ts b/packages/appkit/src/core/run-agent.ts index e83c2c9c..6bbed55f 100644 --- a/packages/appkit/src/core/run-agent.ts +++ b/packages/appkit/src/core/run-agent.ts @@ -4,7 +4,12 @@ import type { AgentEvent, AgentToolDefinition, Message, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; +import { isFromPluginMarker } from "../plugins/agents/from-plugin"; +import { resolveToolkitFromProvider } from "../plugins/agents/toolkit-resolver"; import { type FunctionTool, functionToolToDefinition, @@ -23,6 +28,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 { @@ -39,11 +52,12 @@ export interface RunAgentResult { * Limitations vs. running through the agents() plugin: * - No OBO: there is no HTTP request, so plugin tools run as the service * principal (when they work at all). - * - Plugin tools (`ToolkitEntry`) are not supported — they require a live - * `PluginContext` that only exists when registered in a `createApp` - * instance. This function throws a clear error if encountered. + * - Hosted tools (MCP) are not supported — they require a live MCP client + * that only exists inside the agents plugin. * - Sub-agents (`agents: { ... }` on the def) are executed as nested * `runAgent` calls with no shared thread state. + * - Plugin tools (`fromPlugin` markers or `ToolkitEntry` spreads) require + * passing `plugins: [...]` via `RunAgentInput`. */ export async function runAgent( def: AgentDefinition, @@ -51,7 +65,7 @@ export async function runAgent( ): Promise { const adapter = await resolveAdapter(def); const messages = normalizeMessages(input.messages, def.instructions); - const toolIndex = buildStandaloneToolIndex(def); + const toolIndex = buildStandaloneToolIndex(def, input.plugins ?? []); const tools = Array.from(toolIndex.values()).map((e) => e.def); const signal = input.signal; @@ -62,6 +76,13 @@ export async function runAgent( if (entry.kind === "function") { return entry.tool.execute(args as Record); } + if (entry.kind === "toolkit") { + return entry.provider.executeAgentTool( + entry.localName, + args as Record, + signal, + ); + } if (entry.kind === "subagent") { const subInput: RunAgentInput = { messages: @@ -71,13 +92,14 @@ export async function runAgent( ? (args as { input: string }).input : JSON.stringify(args), signal, + plugins: input.plugins, }; const res = await runAgent(entry.agentDef, subInput); return res.text; } throw new Error( `runAgent: tool "${name}" is a ${entry.kind} tool. ` + - "Plugin toolkits and MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).", + "Hosted/MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).", ); }; @@ -158,20 +180,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 ?? {})) { @@ -203,7 +266,7 @@ function buildStandaloneToolIndex( function classifyTool(key: string, tool: AgentTool): StandaloneEntry { if (isToolkitEntry(tool)) { - return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool }; + return toolkitEntryToStandalone(key, tool); } if (isFunctionTool(tool)) { return { @@ -224,3 +287,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/index.ts b/packages/appkit/src/index.ts index dbefe3e5..6e643cc8 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -73,8 +73,12 @@ export { type AgentDefinition, type AgentsPluginConfig, type AgentTool, + type AgentTools, agents, type BaseSystemPromptOption, + type FromPluginMarker, + fromPlugin, + isFromPluginMarker, isToolkitEntry, loadAgentFromFile, loadAgentsFromDir, diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 03b9257c..c8758ac8 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -19,11 +19,13 @@ import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; import { agentStreamDefaults } from "./defaults"; import { AgentEventTranslator } from "./event-translator"; +import { isFromPluginMarker } from "./from-plugin"; import { loadAgentsFromDir } from "./load-agents"; import manifest from "./manifest.json"; import { chatRequestSchema, invocationsRequestSchema } from "./schemas"; import { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt"; import { InMemoryThreadStore } from "./thread-store"; +import { resolveToolkitFromProvider } from "./toolkit-resolver"; import { AppKitMcpClient, type FunctionTool, @@ -253,7 +255,11 @@ export class AgentsPlugin extends Plugin implements ToolProvider { src: AgentSource, ): Promise> { const index = new Map(); - const hasExplicitTools = def.tools && Object.keys(def.tools).length > 0; + const toolsRecord = def.tools ?? {}; + const hasExplicitTools = + def.tools !== undefined && + (Object.keys(toolsRecord).length > 0 || + Object.getOwnPropertySymbols(toolsRecord).length > 0); const hasExplicitSubAgents = def.agents && Object.keys(def.agents).length > 0; @@ -292,9 +298,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider { }); } - // 2. Explicit tools (toolkit entries, function tools, hosted tools) + // 2. fromPlugin markers — resolve against registered ToolProviders first so + // explicit string-keyed tools can still overwrite on the same key. + this.resolveFromPluginMarkers(agentName, toolsRecord, index); + + // 3. Explicit tools (toolkit entries, function tools, hosted tools) const hostedToCollect: import("./tools/hosted-tools").HostedTool[] = []; - for (const [key, tool] of Object.entries(def.tools ?? {})) { + for (const [key, tool] of Object.entries(toolsRecord)) { if (isToolkitEntry(tool)) { index.set(key, { source: "toolkit", @@ -338,31 +348,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider { provider, } of this.context.getToolProviders()) { if (pluginName === this.name) continue; - const withToolkit = provider as ToolProvider & { - toolkit?: (opts?: unknown) => Record; - }; - if (typeof withToolkit.toolkit === "function") { - const entries = withToolkit.toolkit() as Record; - for (const [key, maybeEntry] of Object.entries(entries)) { - if (!isToolkitEntry(maybeEntry)) continue; - index.set(key, { - source: "toolkit", - pluginName: maybeEntry.pluginName, - localName: maybeEntry.localName, - def: { ...maybeEntry.def, name: key }, - }); - } - continue; - } - // Fallback: providers without a toolkit() still expose getAgentTools(); - // dispatch goes through PluginContext.executeTool by plugin name. - for (const tool of provider.getAgentTools()) { - const qualifiedName = `${pluginName}.${tool.name}`; - index.set(qualifiedName, { + const entries = resolveToolkitFromProvider(pluginName, provider); + for (const [key, entry] of Object.entries(entries)) { + index.set(key, { source: "toolkit", - pluginName, - localName: tool.name, - def: { ...tool, name: qualifiedName }, + pluginName: entry.pluginName, + localName: entry.localName, + def: { ...entry.def, name: key }, }); } } @@ -376,6 +368,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("./tools/hosted-tools").HostedTool[], index: Map, diff --git a/packages/appkit/src/plugins/agents/from-plugin.ts b/packages/appkit/src/plugins/agents/from-plugin.ts new file mode 100644 index 00000000..b1128594 --- /dev/null +++ b/packages/appkit/src/plugins/agents/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/plugins/agents/index.ts b/packages/appkit/src/plugins/agents/index.ts index 1adc41c1..7adc49ff 100644 --- a/packages/appkit/src/plugins/agents/index.ts +++ b/packages/appkit/src/plugins/agents/index.ts @@ -1,5 +1,12 @@ export { AgentsPlugin, agents } from "./agents"; export { buildToolkitEntries } from "./build-toolkit"; +export { + FROM_PLUGIN_MARKER, + type FromPluginMarker, + type FromPluginSpread, + fromPlugin, + isFromPluginMarker, +} from "./from-plugin"; export { type LoadContext, type LoadResult, @@ -11,6 +18,7 @@ export { type AgentDefinition, type AgentsPluginConfig, type AgentTool, + type AgentTools, type AutoInheritToolsConfig, type BaseSystemPromptOption, isToolkitEntry, diff --git a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts index 8116551e..b2152b61 100644 --- a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -14,10 +14,18 @@ import { CacheManager } from "../../../cache"; // Import the class directly so we can construct it without a createApp import { AgentsPlugin } from "../agents"; import { buildToolkitEntries } from "../build-toolkit"; +import { fromPlugin } from "../from-plugin"; import { defineTool, type ToolRegistry } from "../tools/define-tool"; +import { tool } from "../tools/tool"; import type { AgentsPluginConfig, ToolkitEntry } from "../types"; import { isToolkitEntry } from "../types"; +function namedFactory(name: string) { + const f = () => ({ name }); + Object.defineProperty(f, "pluginName", { value: name, enumerable: true }); + return f as typeof f & { readonly pluginName: string }; +} + interface FakeContext { providers: Array<{ name: string; provider: ToolProvider }>; getToolProviders(): Array<{ name: string; provider: ToolProvider }>; @@ -286,4 +294,202 @@ describe("AgentsPlugin", () => { expect(isToolkitEntry({ foo: 1 })).toBe(false); expect(isToolkitEntry(null)).toBe(false); }); + + describe("fromPlugin markers", () => { + test("spreading fromPlugin registers all tools from the referenced plugin", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const ctx = fakeContext([ + { + name: "analytics", + provider: makeToolProvider("analytics", registry), + }, + ]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("analytics")) }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + expect(agent?.toolIndex.has("analytics.query")).toBe(true); + }); + + test("mixed inline + fromPlugin tools coexist", async () => { + const registry: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const ctx = fakeContext([ + { + name: "analytics", + provider: makeToolProvider("analytics", registry), + }, + ]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { + ...fromPlugin(namedFactory("analytics")), + get_weather: tool({ + name: "get_weather", + description: "Weather", + schema: z.object({ city: z.string() }), + execute: async ({ city }) => `Sunny in ${city}`, + }), + }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + expect(agent?.toolIndex.has("analytics.query")).toBe(true); + expect(agent?.toolIndex.has("get_weather")).toBe(true); + }); + + test("missing plugin throws at setup with Available: listing", async () => { + const ctx = fakeContext([ + { + name: "files", + provider: makeToolProvider("files", {}), + }, + ]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("analytics")) }, + }, + }, + }, + ctx, + ); + await expect(plugin.setup()).rejects.toThrow(/analytics/); + await expect(plugin.setup()).rejects.toThrow(/Available:/); + await expect(plugin.setup()).rejects.toThrow(/files/); + }); + + test("symbol-only tools record disables auto-inherit", async () => { + const analyticsReg: ToolRegistry = { + query: defineTool({ + description: "q", + schema: z.object({ sql: z.string() }), + handler: () => "ok", + }), + }; + const filesReg: ToolRegistry = { + list: defineTool({ + description: "l", + schema: z.object({}), + handler: () => [], + }), + }; + const ctx = fakeContext([ + { + name: "analytics", + provider: makeToolProvider("analytics", analyticsReg), + }, + { + name: "files", + provider: makeToolProvider("files", filesReg), + }, + ]); + + const plugin = instantiate( + { + dir: false, + autoInheritTools: { code: true }, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("analytics")) }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + const toolNames = Array.from(agent?.toolIndex.keys() ?? []); + expect(toolNames.some((n) => n.startsWith("analytics."))).toBe(true); + expect(toolNames.some((n) => n.startsWith("files."))).toBe(false); + }); + + test("falls back to getAgentTools() for providers without toolkit()", async () => { + // Provider lacks .toolkit() — only getAgentTools/executeAgentTool. + const bareProvider: ToolProvider = { + getAgentTools: () => [ + { + name: "ping", + description: "ping", + parameters: { type: "object", properties: {} }, + }, + ], + executeAgentTool: vi.fn(async () => "pong"), + }; + const ctx = fakeContext([{ name: "bare", provider: bareProvider }]); + + const plugin = instantiate( + { + dir: false, + agents: { + support: { + instructions: "...", + model: stubAdapter(), + tools: { ...fromPlugin(namedFactory("bare")) }, + }, + }, + }, + ctx, + ); + await plugin.setup(); + + const api = plugin.exports() as { + get: (name: string) => { toolIndex: Map } | null; + }; + const agent = api.get("support"); + expect(agent?.toolIndex.has("bare.ping")).toBe(true); + }); + }); }); diff --git a/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts new file mode 100644 index 00000000..cd8a12b4 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/from-plugin.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { + FROM_PLUGIN_MARKER, + fromPlugin, + isFromPluginMarker, +} from "../from-plugin"; + +function fakeFactory(name: string) { + const f = () => ({ name }); + Object.defineProperty(f, "pluginName", { value: name, enumerable: true }); + return f as typeof f & { readonly pluginName: string }; +} + +describe("fromPlugin", () => { + test("returns a spread-friendly object with a single symbol-keyed marker", () => { + const spread = fromPlugin(fakeFactory("analytics")); + + expect(Object.keys(spread)).toHaveLength(0); + const syms = Object.getOwnPropertySymbols(spread); + expect(syms).toHaveLength(1); + + const marker = (spread as Record)[syms[0]!]; + expect(isFromPluginMarker(marker)).toBe(true); + expect((marker as { pluginName: string }).pluginName).toBe("analytics"); + }); + + test("multiple calls produce distinct symbol keys (spreads coexist)", () => { + const spread = { + ...fromPlugin(fakeFactory("analytics")), + ...fromPlugin(fakeFactory("analytics")), + ...fromPlugin(fakeFactory("files")), + }; + + const syms = Object.getOwnPropertySymbols(spread); + expect(syms).toHaveLength(3); + }); + + test("passes opts through to the marker", () => { + const spread = fromPlugin(fakeFactory("analytics"), { + only: ["query"], + prefix: "q_", + }); + const sym = Object.getOwnPropertySymbols(spread)[0]!; + const marker = (spread as Record)[sym] as { + opts: { only: string[]; prefix: string }; + }; + expect(marker.opts.only).toEqual(["query"]); + expect(marker.opts.prefix).toBe("q_"); + }); + + test("throws when factory has no pluginName", () => { + const missing = () => ({ name: "nope" }); + expect(() => + fromPlugin(missing as unknown as { readonly pluginName: string }), + ).toThrow(/missing pluginName/); + }); + + test("FROM_PLUGIN_MARKER is a globally-interned symbol", () => { + expect(FROM_PLUGIN_MARKER).toBe( + Symbol.for("@databricks/appkit.fromPluginMarker"), + ); + }); +}); + +describe("isFromPluginMarker", () => { + test("returns true for real markers", () => { + const spread = fromPlugin(fakeFactory("analytics")); + const sym = Object.getOwnPropertySymbols(spread)[0]!; + expect(isFromPluginMarker((spread as Record)[sym])).toBe( + true, + ); + }); + + test("returns false for objects without the brand", () => { + expect(isFromPluginMarker({ pluginName: "x" })).toBe(false); + expect(isFromPluginMarker(null)).toBe(false); + expect(isFromPluginMarker(undefined)).toBe(false); + expect(isFromPluginMarker("string")).toBe(false); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/run-agent.test.ts b/packages/appkit/src/plugins/agents/tests/run-agent.test.ts index 1a974811..da626f49 100644 --- a/packages/appkit/src/plugins/agents/tests/run-agent.test.ts +++ b/packages/appkit/src/plugins/agents/tests/run-agent.test.ts @@ -3,11 +3,16 @@ import type { AgentEvent, AgentInput, AgentRunContext, + AgentToolDefinition, + PluginConstructor, + PluginData, + ToolProvider, } from "shared"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { createAgent } from "../../../core/create-agent-def"; import { runAgent } from "../../../core/run-agent"; +import { fromPlugin } from "../from-plugin"; import { tool } from "../tools/tool"; import type { ToolkitEntry } from "../types"; @@ -84,6 +89,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/plugins/agents/toolkit-resolver.ts b/packages/appkit/src/plugins/agents/toolkit-resolver.ts new file mode 100644 index 00000000..8ec8cf1f --- /dev/null +++ b/packages/appkit/src/plugins/agents/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/plugins/agents/types.ts b/packages/appkit/src/plugins/agents/types.ts index 4963a52a..37c322e6 100644 --- a/packages/appkit/src/plugins/agents/types.ts +++ b/packages/appkit/src/plugins/agents/types.ts @@ -5,6 +5,7 @@ import type { ThreadStore, ToolAnnotations, } from "shared"; +import type { FromPluginMarker } from "./from-plugin"; import type { FunctionTool } from "./tools/function-tool"; import type { HostedTool } from "./tools/hosted-tools"; @@ -54,6 +55,16 @@ export type BaseSystemPromptOption = | string | ((ctx: PromptContext) => string); +/** + * Per-agent tool record. String keys map to inline tools, toolkit entries, + * hosted tools, etc. Symbol keys hold `FromPluginMarker` references produced + * by `fromPlugin(factory)` spreads — these are resolved at + * `AgentsPlugin.setup()` time against registered `ToolProvider` plugins. + */ +export type AgentTools = { [key: string]: AgentTool } & { + [key: symbol]: FromPluginMarker; +}; + export interface AgentDefinition { /** Filled in from the enclosing key when used in `agents: { foo: def }`. */ name?: string; @@ -66,7 +77,7 @@ export interface AgentDefinition { */ model?: AgentAdapter | Promise | string; /** Per-agent tool record. Key is the LLM-visible tool-call name. */ - tools?: Record; + tools?: AgentTools; /** Sub-agents, exposed as `agent-` tools on this agent. */ agents?: Record; /** Override the plugin's baseSystemPrompt for this agent only. */ From 4a441d2259caa82ad665eb57cc6f75a2f2b41617 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:58:04 +0200 Subject: [PATCH 5/6] feat(appkit): reference agent-app, dev-playground chat UI, docs, and template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final layer of the agents feature stack. Everything needed to exercise, demonstrate, and learn the feature. ### Reference application: agent-app `apps/agent-app/` — a standalone app purpose-built around the agents feature. Ships with: - `server.ts` — full example of code-defined agents via `fromPlugin`: ```ts const support = createAgent({ instructions: "…", tools: { ...fromPlugin(analytics), ...fromPlugin(files), get_weather, "mcp.vector-search": mcpServer("vector-search", "https://…"), }, }); await createApp({ plugins: [server({ port }), analytics(), files(), agents({ agents: { support } })], }); ``` - `config/agents/assistant.md` — markdown-driven agent alongside the code-defined one, showing the asymmetric auto-inherit default. - Vite + React 19 + TailwindCSS frontend with a chat UI. - Databricks deployment config (`databricks.yml`, `app.yaml`) and deploy scripts. ### dev-playground chat UI + demo agent `apps/dev-playground/client/src/routes/agent.route.tsx` — chat UI with inline autocomplete (hits the `autocomplete` markdown agent) and a full threaded conversation panel (hits the default agent). `apps/dev-playground/server/index.ts` — adds a code-defined `helper` agent using `fromPlugin(analytics)` alongside the markdown-driven `autocomplete` agent in `config/agents/`. Exercises the mixed-style setup (markdown + code) against the same plugin list. `apps/dev-playground/config/agents/*.md` — both agents defined with valid YAML frontmatter. ### Docs `docs/docs/plugins/agents.md` — progressive five-level guide: 1. Drop a markdown file → it just works. 2. Scope tools via `toolkits:` / `tools:` frontmatter. 3. Code-defined agents with `fromPlugin()`. 4. Sub-agents. 5. Standalone `runAgent()` (no `createApp` or HTTP). Plus a configuration reference, runtime API reference, and frontmatter schema table. `docs/docs/api/appkit/` — regenerated typedoc for the new public surface (fromPlugin, runAgent, AgentDefinition, AgentsPluginConfig, ToolkitEntry, ToolkitOptions, all adapter types, and the agents plugin factory). ### Template `template/appkit.plugins.json` — adds the `agent` plugin entry so `npx @databricks/appkit init --features agent` scaffolds the plugin correctly. ### Test plan - Full appkit vitest suite: 1311 tests passing - Typecheck clean across all 8 workspace projects - `pnpm docs:build` clean (no broken links) - `pnpm --filter=@databricks/appkit build:package` clean, publint clean Signed-off-by: MarioCadenas --- apps/agent-app/.env.example | 11 + apps/agent-app/.gitignore | 3 + apps/agent-app/app.yaml | 8 + apps/agent-app/config/agents/assistant.md | 12 + apps/agent-app/databricks.yml | 50 ++ apps/agent-app/index.html | 12 + apps/agent-app/package.json | 40 ++ apps/agent-app/postcss.config.js | 6 + apps/agent-app/server.ts | 75 +++ apps/agent-app/src/App.css | 362 ++++++++++++++ apps/agent-app/src/App.tsx | 292 +++++++++++ .../src/components/theme-selector.tsx | 135 +++++ apps/agent-app/src/index.css | 1 + apps/agent-app/src/main.tsx | 15 + apps/agent-app/tailwind.config.ts | 11 + apps/agent-app/tsconfig.app.json | 24 + apps/agent-app/tsconfig.json | 7 + apps/agent-app/tsconfig.node.json | 22 + apps/agent-app/vite.config.ts | 31 ++ .../client/src/routes/__root.tsx | 12 +- .../client/src/routes/agent.route.tsx | 466 ++++++++++++++++++ .../client/src/routes/index.tsx | 11 +- .../dev-playground/config/agents/assistant.md | 6 + .../config/agents/autocomplete.md | 6 + apps/dev-playground/server/index.ts | 38 +- docs/docs/api/appkit/Class.Plugin.md | 44 ++ docs/docs/api/appkit/Function.createAgent.md | 35 ++ docs/docs/api/appkit/Function.fromPlugin.md | 50 ++ .../api/appkit/Function.isFromPluginMarker.md | 17 + .../api/appkit/Function.isFunctionTool.md | 15 + docs/docs/api/appkit/Function.isHostedTool.md | 15 + .../api/appkit/Function.isToolkitEntry.md | 18 + .../api/appkit/Function.loadAgentFromFile.md | 19 + .../api/appkit/Function.loadAgentsFromDir.md | 20 + docs/docs/api/appkit/Function.mcpServer.md | 26 + docs/docs/api/appkit/Function.runAgent.md | 29 ++ docs/docs/api/appkit/Function.tool.md | 29 ++ .../docs/api/appkit/Interface.AgentAdapter.md | 20 + .../api/appkit/Interface.AgentDefinition.md | 82 +++ docs/docs/api/appkit/Interface.AgentInput.md | 33 ++ .../api/appkit/Interface.AgentRunContext.md | 28 ++ .../appkit/Interface.AgentToolDefinition.md | 33 ++ .../appkit/Interface.AgentsPluginConfig.md | 132 +++++ .../api/appkit/Interface.BasePluginConfig.md | 4 + .../api/appkit/Interface.FromPluginMarker.md | 32 ++ .../docs/api/appkit/Interface.FunctionTool.md | 59 +++ docs/docs/api/appkit/Interface.Message.md | 49 ++ .../api/appkit/Interface.PromptContext.md | 27 + .../api/appkit/Interface.RunAgentInput.md | 35 ++ .../api/appkit/Interface.RunAgentResult.md | 21 + docs/docs/api/appkit/Interface.Thread.md | 41 ++ docs/docs/api/appkit/Interface.ThreadStore.md | 98 ++++ docs/docs/api/appkit/Interface.ToolConfig.md | 49 ++ .../docs/api/appkit/Interface.ToolProvider.md | 36 ++ .../docs/api/appkit/Interface.ToolkitEntry.md | 46 ++ .../api/appkit/Interface.ToolkitOptions.md | 41 ++ docs/docs/api/appkit/TypeAlias.AgentEvent.md | 38 ++ docs/docs/api/appkit/TypeAlias.AgentTool.md | 12 + docs/docs/api/appkit/TypeAlias.AgentTools.md | 14 + .../TypeAlias.BaseSystemPromptOption.md | 8 + docs/docs/api/appkit/TypeAlias.HostedTool.md | 9 + docs/docs/api/appkit/Variable.agents.md | 19 + docs/docs/api/appkit/index.md | 35 ++ docs/docs/api/appkit/typedoc-sidebar.ts | 175 +++++++ docs/docs/plugins/agents.md | 237 +++++++++ pnpm-lock.yaml | 336 +++++++++++-- template/appkit.plugins.json | 10 + 67 files changed, 3673 insertions(+), 59 deletions(-) create mode 100644 apps/agent-app/.env.example create mode 100644 apps/agent-app/.gitignore create mode 100644 apps/agent-app/app.yaml create mode 100644 apps/agent-app/config/agents/assistant.md create mode 100644 apps/agent-app/databricks.yml create mode 100644 apps/agent-app/index.html create mode 100644 apps/agent-app/package.json create mode 100644 apps/agent-app/postcss.config.js create mode 100644 apps/agent-app/server.ts create mode 100644 apps/agent-app/src/App.css create mode 100644 apps/agent-app/src/App.tsx create mode 100644 apps/agent-app/src/components/theme-selector.tsx create mode 100644 apps/agent-app/src/index.css create mode 100644 apps/agent-app/src/main.tsx create mode 100644 apps/agent-app/tailwind.config.ts create mode 100644 apps/agent-app/tsconfig.app.json create mode 100644 apps/agent-app/tsconfig.json create mode 100644 apps/agent-app/tsconfig.node.json create mode 100644 apps/agent-app/vite.config.ts create mode 100644 apps/dev-playground/client/src/routes/agent.route.tsx create mode 100644 apps/dev-playground/config/agents/assistant.md create mode 100644 apps/dev-playground/config/agents/autocomplete.md create mode 100644 docs/docs/api/appkit/Function.createAgent.md create mode 100644 docs/docs/api/appkit/Function.fromPlugin.md create mode 100644 docs/docs/api/appkit/Function.isFromPluginMarker.md create mode 100644 docs/docs/api/appkit/Function.isFunctionTool.md create mode 100644 docs/docs/api/appkit/Function.isHostedTool.md create mode 100644 docs/docs/api/appkit/Function.isToolkitEntry.md create mode 100644 docs/docs/api/appkit/Function.loadAgentFromFile.md create mode 100644 docs/docs/api/appkit/Function.loadAgentsFromDir.md create mode 100644 docs/docs/api/appkit/Function.mcpServer.md create mode 100644 docs/docs/api/appkit/Function.runAgent.md create mode 100644 docs/docs/api/appkit/Function.tool.md create mode 100644 docs/docs/api/appkit/Interface.AgentAdapter.md create mode 100644 docs/docs/api/appkit/Interface.AgentDefinition.md create mode 100644 docs/docs/api/appkit/Interface.AgentInput.md create mode 100644 docs/docs/api/appkit/Interface.AgentRunContext.md create mode 100644 docs/docs/api/appkit/Interface.AgentToolDefinition.md create mode 100644 docs/docs/api/appkit/Interface.AgentsPluginConfig.md create mode 100644 docs/docs/api/appkit/Interface.FromPluginMarker.md create mode 100644 docs/docs/api/appkit/Interface.FunctionTool.md create mode 100644 docs/docs/api/appkit/Interface.Message.md create mode 100644 docs/docs/api/appkit/Interface.PromptContext.md create mode 100644 docs/docs/api/appkit/Interface.RunAgentInput.md create mode 100644 docs/docs/api/appkit/Interface.RunAgentResult.md create mode 100644 docs/docs/api/appkit/Interface.Thread.md create mode 100644 docs/docs/api/appkit/Interface.ThreadStore.md create mode 100644 docs/docs/api/appkit/Interface.ToolConfig.md create mode 100644 docs/docs/api/appkit/Interface.ToolProvider.md create mode 100644 docs/docs/api/appkit/Interface.ToolkitEntry.md create mode 100644 docs/docs/api/appkit/Interface.ToolkitOptions.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentEvent.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentTool.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentTools.md create mode 100644 docs/docs/api/appkit/TypeAlias.BaseSystemPromptOption.md create mode 100644 docs/docs/api/appkit/TypeAlias.HostedTool.md create mode 100644 docs/docs/api/appkit/Variable.agents.md create mode 100644 docs/docs/plugins/agents.md diff --git a/apps/agent-app/.env.example b/apps/agent-app/.env.example new file mode 100644 index 00000000..c062af54 --- /dev/null +++ b/apps/agent-app/.env.example @@ -0,0 +1,11 @@ +# Databricks workspace (auto-injected by platform on deploy) +DATABRICKS_HOST=https://e2-dogfood.staging.cloud.databricks.com + +# Agent LLM endpoint +DATABRICKS_AGENT_ENDPOINT=databricks-claude-sonnet-4-5 + +# Analytics plugin — SQL warehouse ID +DATABRICKS_WAREHOUSE_ID=dd43ee29fedd958d + +# Files plugin — Volume path +DATABRICKS_VOLUME_FILES=/Volumes/main/mario/mario-vol diff --git a/apps/agent-app/.gitignore b/apps/agent-app/.gitignore new file mode 100644 index 00000000..9c97bbd4 --- /dev/null +++ b/apps/agent-app/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/apps/agent-app/app.yaml b/apps/agent-app/app.yaml new file mode 100644 index 00000000..215b89ec --- /dev/null +++ b/apps/agent-app/app.yaml @@ -0,0 +1,8 @@ +command: ['node', '--import', 'tsx', 'server.ts'] +env: + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: sql-warehouse + - name: DATABRICKS_AGENT_ENDPOINT + valueFrom: serving-endpoint + - name: DATABRICKS_VOLUME_FILES + valueFrom: volume diff --git a/apps/agent-app/config/agents/assistant.md b/apps/agent-app/config/agents/assistant.md new file mode 100644 index 00000000..bd6e9b7e --- /dev/null +++ b/apps/agent-app/config/agents/assistant.md @@ -0,0 +1,12 @@ +--- +endpoint: databricks-claude-sonnet-4-5 +default: true +--- + +You are a helpful data assistant running on Databricks. + +Use the available tools to query data, browse files, and help users with their analysis. + +When using `analytics.query`, write Databricks SQL. When results are large, summarize the key findings rather than dumping raw data. + +You also have access to additional tools from MCP servers — use them when relevant. diff --git a/apps/agent-app/databricks.yml b/apps/agent-app/databricks.yml new file mode 100644 index 00000000..3ed6e50a --- /dev/null +++ b/apps/agent-app/databricks.yml @@ -0,0 +1,50 @@ +bundle: + name: appkit-agent-app + +variables: + sql_warehouse_id: + description: SQL Warehouse ID for analytics queries + serving_endpoint_name: + description: Model Serving endpoint name for the agent LLM + volume_full_name: + description: "UC Volume full name (e.g. catalog.schema.volume_name)" + +resources: + apps: + agent_app: + name: "appkit-agent-app" + description: "AppKit agent with auto-discovered tools from analytics, files, and genie plugins" + source_code_path: ./ + + user_api_scopes: + - sql + - files.files + - dashboards.genie + + resources: + - name: sql-warehouse + sql_warehouse: + id: ${var.sql_warehouse_id} + permission: CAN_USE + + - name: serving-endpoint + serving_endpoint: + name: ${var.serving_endpoint_name} + permission: CAN_QUERY + + - name: volume + uc_securable: + securable_type: VOLUME + securable_full_name: ${var.volume_full_name} + permission: WRITE_VOLUME + +targets: + dogfood: + default: true + workspace: + host: https://e2-dogfood.staging.cloud.databricks.com + + variables: + sql_warehouse_id: dd43ee29fedd958d + serving_endpoint_name: databricks-claude-sonnet-4-5 + volume_full_name: main.mario.mario-vol diff --git a/apps/agent-app/index.html b/apps/agent-app/index.html new file mode 100644 index 00000000..80e54faf --- /dev/null +++ b/apps/agent-app/index.html @@ -0,0 +1,12 @@ + + + + + + AppKit Agent + + +
+ + + diff --git a/apps/agent-app/package.json b/apps/agent-app/package.json new file mode 100644 index 00000000..ed159ca8 --- /dev/null +++ b/apps/agent-app/package.json @@ -0,0 +1,40 @@ +{ + "name": "agent-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "NODE_ENV=development tsx watch server.ts", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@databricks/appkit": "workspace:*", + "@databricks/appkit-ui": "workspace:*", + "@databricks/sdk-experimental": "^0.16.0", + "dotenv": "^16.6.1", + "lucide-react": "^0.511.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "marked": "^15.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.1.17", + "@types/node": "24.10.1", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "autoprefixer": "10.4.21", + "postcss": "8.5.6", + "tailwindcss": "4.1.17", + "tailwindcss-animate": "1.0.7", + "tw-animate-css": "1.4.0", + "tsx": "4.20.6", + "typescript": "5.9.3", + "vite": "npm:rolldown-vite@7.1.14" + }, + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + } +} diff --git a/apps/agent-app/postcss.config.js b/apps/agent-app/postcss.config.js new file mode 100644 index 00000000..f69c5d41 --- /dev/null +++ b/apps/agent-app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts new file mode 100644 index 00000000..29756079 --- /dev/null +++ b/apps/agent-app/server.ts @@ -0,0 +1,75 @@ +import { + agents, + analytics, + createAgent, + createApp, + files, + fromPlugin, + mcpServer, + server, + tool, +} from "@databricks/appkit"; +import { z } from "zod"; + +const port = Number(process.env.DATABRICKS_APP_PORT) || 8003; + +// Shared tool available to any agent that declares `tools: [get_weather]` in +// its markdown frontmatter. +const get_weather = tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ + city: z.string().describe("City name"), + }), + execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, +}); + +// Code-defined agent. Overrides config/agents/support.md if a file with that +// name exists. Tools here are explicit; defaults are strict (no auto-inherit +// for code-defined agents), so we pull analytics + files in via fromPlugin. +const support = createAgent({ + instructions: + "You help customers with data analysis, file browsing, and general questions. " + + "Use the available tools as needed and summarize results concisely.", + tools: { + ...fromPlugin(analytics), + ...fromPlugin(files), + get_weather, + "mcp.vector-search": mcpServer( + "vector-search", + "https://e2-dogfood.staging.cloud.databricks.com/api/2.0/mcp/vector-search/main/default", + ), + "mcp.uc-greet": mcpServer( + "uc-greet", + "https://e2-dogfood.staging.cloud.databricks.com/api/2.0/mcp/functions/main/mario/greet", + ), + "mcp.mario-hello": mcpServer( + "mario-mcp-hello", + "https://mario-mcp-hello-6051921418418893.staging.aws.databricksapps.com/mcp", + ), + }, +}); + +const appkit = await createApp({ + plugins: [ + server({ port }), + analytics(), + files(), + agents({ + // Ambient tool library referenced by markdown frontmatter `tools: [...]`. + tools: { get_weather }, + // Code-defined agents are merged with markdown agents; code wins on key + // collision. Markdown agents still auto-inherit analytics+files tools + // unless their frontmatter says otherwise. + agents: { support }, + }), + ], +}); + +const registry = appkit.agent as { + list: () => string[]; + getDefault: () => string | null; +}; +console.log( + `Agent app running on port ${port}. Agents: ${registry.list().join(", ")}. Default: ${registry.getDefault() ?? "(none)"}.`, +); diff --git a/apps/agent-app/src/App.css b/apps/agent-app/src/App.css new file mode 100644 index 00000000..1928960d --- /dev/null +++ b/apps/agent-app/src/App.css @@ -0,0 +1,362 @@ +:root { + --bg: #fafafa; + --card: #ffffff; + --border: #e5e5e5; + --text: #171717; + --text-muted: #737373; + --text-faint: #a3a3a3; + --primary: #2563eb; + --primary-fg: #ffffff; + --muted: #f5f5f5; + --ring: #93c5fd; + --radius: 10px; + --font: system-ui, -apple-system, sans-serif; + --mono: "SF Mono", "Cascadia Code", "Fira Code", monospace; +} + +:root.dark { + --bg: #0a0a0a; + --card: #171717; + --border: #262626; + --text: #fafafa; + --text-muted: #a3a3a3; + --text-faint: #525252; + --primary: #3b82f6; + --primary-fg: #ffffff; + --muted: #262626; + --ring: #1d4ed8; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +.app { + min-height: 100vh; +} + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 2.5rem 1.5rem; +} + +.header { + margin-bottom: 1.5rem; + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.header h1 { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.025em; +} + +.subtitle { + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.thread-id { + font-family: var(--mono); + font-size: 0.75rem; + opacity: 0.6; +} + +.main-layout { + display: flex; + gap: 1.25rem; + height: 700px; +} + +.chat-panel { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + min-width: 0; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.empty-state { + text-align: center; + padding: 5rem 1rem; + color: var(--text-muted); +} + +.empty-title { + font-size: 1.1rem; + font-weight: 500; +} + +.empty-sub { + font-size: 0.85rem; + margin-top: 0.5rem; + color: var(--text-faint); +} + +.message-row { + display: flex; +} + +.message-row.user { + justify-content: flex-end; +} + +.message-row.assistant { + justify-content: flex-start; +} + +.bubble { + max-width: 80%; + padding: 0.625rem 0.875rem; + border-radius: var(--radius); + font-size: 0.875rem; + line-height: 1.5; + word-break: break-word; +} + +.bubble.user { + white-space: pre-wrap; + background: var(--primary); + color: var(--primary-fg); + border-bottom-right-radius: 3px; +} + +.bubble.assistant { + background: var(--muted); + color: var(--text); + border-bottom-left-radius: 3px; +} + +.bubble.thinking { + color: var(--text-muted); + animation: pulse 1.5s ease-in-out infinite; +} + +.bubble.assistant > * + * { + margin-top: 0.5em; +} + +.bubble.assistant p { + margin: 0; +} + +.bubble.assistant p + p { + margin-top: 0.4em; +} + +.bubble.assistant code { + font-family: var(--mono); + font-size: 0.8em; + background: color-mix(in srgb, var(--text) 8%, transparent); + padding: 0.15em 0.35em; + border-radius: 4px; +} + +.bubble.assistant pre { + margin: 0.5em 0; + padding: 0.75em; + border-radius: 6px; + background: color-mix(in srgb, var(--text) 6%, transparent); + overflow-x: auto; +} + +.bubble.assistant pre code { + background: none; + padding: 0; + font-size: 0.8em; +} + +.bubble.assistant ul, +.bubble.assistant ol { + margin: 0.4em 0; + padding-left: 1.5em; +} + +.bubble.assistant li { + margin: 0.15em 0; +} + +.bubble.assistant h1, +.bubble.assistant h2, +.bubble.assistant h3 { + font-weight: 600; +} + +.bubble.assistant h1 { + font-size: 1.1em; +} +.bubble.assistant h2 { + font-size: 1em; +} +.bubble.assistant h3 { + font-size: 0.95em; +} + +.bubble.assistant blockquote { + margin: 0.4em 0; + padding-left: 0.75em; + border-left: 3px solid var(--border); + color: var(--text-muted); +} + +.bubble.assistant table { + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.85em; +} + +.bubble.assistant th, +.bubble.assistant td { + border: 1px solid var(--border); + padding: 0.35em 0.6em; +} + +.bubble.assistant th { + background: color-mix(in srgb, var(--text) 4%, transparent); + font-weight: 600; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.input-bar { + display: flex; + gap: 0.5rem; + padding: 0.875rem 1rem; + border-top: 1px solid var(--border); +} + +.input-bar textarea { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 0.875rem; + resize: none; + outline: none; + transition: border-color 0.15s; +} + +.input-bar textarea:focus { + border-color: var(--ring); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 25%, transparent); +} + +.input-bar textarea:disabled { + opacity: 0.5; +} + +.input-bar button { + padding: 0.5rem 1rem; + border: none; + border-radius: 8px; + background: var(--primary); + color: var(--primary-fg); + font-family: var(--font); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; + align-self: flex-end; +} + +.input-bar button:hover:not(:disabled) { + opacity: 0.9; +} + +.input-bar button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.event-panel { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + overflow: hidden; +} + +.event-header { + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.event-list { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.event-empty { + text-align: center; + padding: 2.5rem 0; + font-size: 0.75rem; + color: var(--text-faint); +} + +.event-row { + font-family: var(--mono); + font-size: 0.7rem; + line-height: 1.4; + display: flex; + gap: 0.5rem; +} + +.event-type { + flex-shrink: 0; + width: 90px; + text-align: right; + color: var(--text-faint); +} + +.event-detail { + color: var(--text-muted); + word-break: break-all; +} diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx new file mode 100644 index 00000000..5c54997a --- /dev/null +++ b/apps/agent-app/src/App.tsx @@ -0,0 +1,292 @@ +import { TooltipProvider } from "@databricks/appkit-ui/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import "./App.css"; +import { ThemeSelector } from "./components/theme-selector"; + +interface SSEEvent { + type: string; + delta?: string; + item_id?: string; + item?: { + type?: string; + id?: string; + call_id?: string; + name?: string; + arguments?: string; + output?: string; + status?: string; + }; + content?: string; + data?: Record; + error?: string; + sequence_number?: number; + output_index?: number; +} + +interface ChatMessage { + id: number; + role: "user" | "assistant"; + content: string; +} + +export default function App() { + const [messages, setMessages] = useState([]); + const [events, setEvents] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [threadId, setThreadId] = useState(null); + const messagesEndRef = useRef(null); + const idRef = useRef(0); + + const [toolCount, setToolCount] = useState(0); + + useEffect(() => { + const timer = setTimeout(() => { + fetch("/api/agent/info") + .then((r) => r.json()) + .then((data) => setToolCount(data.toolCount ?? 0)) + .catch(() => {}); + }, 500); + return () => clearTimeout(timer); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return; + + const text = input.trim(); + setInput(""); + setMessages((prev) => [ + ...prev, + { id: ++idRef.current, role: "user", content: text }, + ]); + setEvents([]); + setIsLoading(true); + + try { + const res = await fetch("/api/agent/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: text, + ...(threadId && { threadId }), + }), + }); + + if (!res.ok) { + const err = await res.json(); + setMessages((prev) => [ + ...prev, + { + id: ++idRef.current, + role: "assistant", + content: `Error: ${err.error}`, + }, + ]); + return; + } + + const reader = res.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let content = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event: SSEEvent = JSON.parse(data); + if (!event.type) continue; + setEvents((prev) => [...prev, event]); + + if (event.type === "appkit.metadata" && event.data?.threadId) { + setThreadId(event.data.threadId as string); + } + if (event.type === "response.output_text.delta" && event.delta) { + content += event.delta; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, content }; + } else { + updated.push({ + id: ++idRef.current, + role: "assistant", + content, + }); + } + return updated; + }); + } + } catch { + /* skip */ + } + } + } + } catch (err) { + setMessages((prev) => [ + ...prev, + { + id: ++idRef.current, + role: "assistant", + content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`, + }, + ]); + } finally { + setIsLoading(false); + } + }, [input, isLoading, threadId]); + + return ( + +
+
+
+
+

Agent Chat

+

+ AI agent with {toolCount} auto-discovered tools + {threadId && ( + + {" "} + · Thread {threadId.slice(0, 8)} + + )} +

+
+ +
+ +
+
+
+ {messages.length === 0 && ( +
+

+ Send a message to start a conversation +

+

+ The agent can query data, browse files, and more +

+
+ )} + + {messages.map((msg) => ( +
+
+

{msg.content}

+
+
+ ))} + + {isLoading && + messages[messages.length - 1]?.role === "user" && ( +
+
+ Thinking... +
+
+ )} + +
+
+ +
{ + e.preventDefault(); + sendMessage(); + }} + > +