From fbf65daf4f87bf0626f49ff249e924b0296837a1 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 20 Apr 2026 12:30:30 +0200 Subject: [PATCH] feat(appkit): add agents() plugin, createAgent() factory, and .toolkit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a redesigned agent API while keeping the previous shape exported for migration. The new API separates agent *definition* (pure data) from app *composition* (plugin registration). New public surface: - createAgent(def): pure factory that returns an AgentDefinition after DFS cycle-detecting the sub-agent graph. No side effects; safe at module top-level. - agents(config): plugin factory (plural) for createApp. Loads markdown agents from ./config/agents by default, merges code-defined agents, resolves toolkit refs + ambient tools, builds per-agent tool indexes, and mounts /invocations. Internally registers under plugin name "agent" (singular) so its routes mount at /api/agent/* — matches the existing URL convention and keeps the public factory export as agents() for DX. - runAgent(def, input): standalone executor for code-only agents without createApp. Throws a clear error if invoked against a ToolkitEntry (plugin tools require PluginContext). - loadAgentFromFile, loadAgentsFromDir: public loaders. Use js-yaml for frontmatter (replaces the hand-rolled regex parser). - tool() + mcpServer() unchanged; tools are now a keyed record on AgentDefinition.tools with keys = LLM-visible tool-call names. .toolkit() on every ToolProvider plugin: - analytics(), files(), genie(), lakebase() each expose a toolkit(opts) method on their PluginData. Users spread it into AgentDefinition.tools. - buildToolkitEntries() util produces branded ToolkitEntry records with { prefix, only, except, rename } filtering. Dispatches through PluginContext.executeTool for OBO + telemetry. - toPluginWithInstance() variant of toPlugin() eagerly constructs the plugin instance at factory-call time. AppKit._createApp reuses the instance instead of re-constructing. Asymmetric auto-inherit default: markdown agents without explicit toolkits get every registered plugin's tools by default (file: true). Code-defined agents start empty (code: false). Configurable via autoInheritTools: { file, code } with boolean shorthand. Frontmatter schema (array-of-string-or-object): toolkits: - analytics - files: [uploads.list, uploads.read] - genie: { except: [getConversation] } tools: [get_weather] endpoint: databricks-claude-sonnet-4-5 default: true Load-time errors throw with a clear "Available: ..." listing. No silent skip. Sub-agents: parent emits tool_call to agent-; AgentsPlugin runs the child's adapter with no shared thread state. Cycles rejected at load. Deprecations (kept exported, JSDoc @deprecated only, no runtime warnings): - agent() plugin -> use agents() (plural). - createAgent(config) app shortcut -> re-exported as createAgentApp(). The name createAgent is now the pure factory. Tests: 37 new tests covering create-agent cycles, build-toolkit filters, load-agents (js-yaml, missing refs, toolkit resolution), run-agent standalone, and AgentsPlugin (auto-inherit asymmetry, sub-agent dispatch, code-over-markdown precedence). All 1301 tests pass. Adds js-yaml (+ @types/js-yaml) as a runtime dep (~20 KB gzipped). Signed-off-by: MarioCadenas --- packages/appkit/package.json | 2 + packages/appkit/src/core/appkit.ts | 5 +- packages/appkit/src/core/create-agent-def.ts | 53 + packages/appkit/src/core/create-agent.ts | 6 + packages/appkit/src/core/run-agent.ts | 226 ++++ packages/appkit/src/index.ts | 31 +- packages/appkit/src/plugin/index.ts | 2 +- packages/appkit/src/plugin/to-plugin.ts | 53 +- packages/appkit/src/plugins/agent/agent.ts | 4 + packages/appkit/src/plugins/agents/agents.ts | 995 ++++++++++++++++++ .../src/plugins/agents/build-toolkit.ts | 62 ++ packages/appkit/src/plugins/agents/index.ts | 22 + .../appkit/src/plugins/agents/load-agents.ts | 252 +++++ .../appkit/src/plugins/agents/manifest.json | 10 + .../agents/tests/agents-plugin.test.ts | 289 +++++ .../agents/tests/build-toolkit.test.ts | 75 ++ .../plugins/agents/tests/create-agent.test.ts | 75 ++ .../plugins/agents/tests/load-agents.test.ts | 150 +++ .../plugins/agents/tests/run-agent.test.ts | 120 +++ packages/appkit/src/plugins/agents/types.ts | 153 +++ .../appkit/src/plugins/analytics/analytics.ts | 24 +- .../plugins/analytics/tests/analytics.test.ts | 19 + packages/appkit/src/plugins/files/plugin.ts | 9 +- packages/appkit/src/plugins/genie/genie.ts | 9 +- .../appkit/src/plugins/lakebase/lakebase.ts | 11 +- pnpm-lock.yaml | 11 + 26 files changed, 2655 insertions(+), 13 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/build-toolkit.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/tests/agents-plugin.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/create-agent.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/types.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 409527ac..15038b25 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/appkit.ts b/packages/appkit/src/core/appkit.ts index a0c2e566..5252ce5c 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -76,7 +76,10 @@ export class AppKit { name, ...extraData, }; - const pluginInstance = new Plugin(baseConfig); + // If the factory eagerly constructed an instance (via + // `toPluginWithInstance`), reuse it; otherwise construct now. + const preBuilt = (pluginData as { instance?: BasePlugin }).instance; + const pluginInstance = preBuilt ?? new Plugin(baseConfig); if (typeof pluginInstance.attachContext === "function") { pluginInstance.attachContext({ 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/create-agent.ts b/packages/appkit/src/core/create-agent.ts index 367ad70f..23fdb184 100644 --- a/packages/appkit/src/core/create-agent.ts +++ b/packages/appkit/src/core/create-agent.ts @@ -87,6 +87,12 @@ export interface AgentHandle { * }); * ``` */ +/** + * @deprecated Use `createAgent(def)` (pure factory) + `agents()` plugin + + * `createApp()` instead. The new shape separates agent *definition* from + * *app composition*. Re-exported as `createAgentApp` in the main package + * index for migration; will be removed in a future release. + */ export async function createAgent( config: CreateAgentConfig = {}, ): Promise { diff --git a/packages/appkit/src/core/run-agent.ts b/packages/appkit/src/core/run-agent.ts new file mode 100644 index 00000000..2082e70c --- /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/agent/tools/function-tool"; +import { isHostedTool } from "../plugins/agent/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 2ea33fbb..28743d4c 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -44,7 +44,21 @@ export { export { getExecutionContext } from "./context"; export { createApp } from "./core"; export type { AgentHandle, CreateAgentConfig } from "./core/create-agent"; -export { createAgent } from "./core/create-agent"; +/** + * @deprecated Use `createAgent(def)` (pure factory) together with the + * `agents()` plugin and `createApp`. This shortcut composes server + + * agent plugins in a single call; the new shape separates those concerns. + * Import path preserved for backward compatibility during migration. + */ +export { createAgent as createAgentApp } from "./core/create-agent"; +// New pure-data agent factory (replaces the old createAgent shortcut once +// callers migrate — they coexist during the deprecation window). +export { createAgent } from "./core/create-agent-def"; +export { + type RunAgentInput, + type RunAgentResult, + runAgent, +} from "./core/run-agent"; // Errors export { AppKitError, @@ -65,6 +79,7 @@ export { toPlugin, } from "./plugin"; export { + /** @deprecated Use `agents()` (plural) instead. Kept for migration. */ agent, analytics, files, @@ -82,7 +97,19 @@ export { type ToolConfig, tool, } from "./plugins/agent/tools"; -export type { AgentTool } from "./plugins/agent/types"; +export { + type AgentDefinition, + type AgentsPluginConfig, + type AgentTool, + agents, + type BaseSystemPromptOption, + isToolkitEntry, + loadAgentFromFile, + loadAgentsFromDir, + type PromptContext, + type ToolkitEntry, + type ToolkitOptions, +} from "./plugins/agents"; export type { EndpointConfig, ServingEndpointEntry, diff --git a/packages/appkit/src/plugin/index.ts b/packages/appkit/src/plugin/index.ts index 93765219..3ea1f553 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 { toPlugin, toPluginWithInstance } from "./to-plugin"; diff --git a/packages/appkit/src/plugin/to-plugin.ts b/packages/appkit/src/plugin/to-plugin.ts index 77725027..0add3f4d 100644 --- a/packages/appkit/src/plugin/to-plugin.ts +++ b/packages/appkit/src/plugin/to-plugin.ts @@ -1,4 +1,9 @@ -import type { PluginConstructor, PluginData, ToPlugin } from "shared"; +import type { + BasePlugin, + PluginConstructor, + PluginData, + ToPlugin, +} from "shared"; /** * Wraps a plugin class so it can be passed to createApp with optional config. @@ -17,3 +22,49 @@ export function toPlugin( name: plugin.manifest.name as Name, }); } + +/** + * Variant of `toPlugin` that eagerly constructs the plugin instance and + * exposes it (plus any instance methods specified in `expose`) on the + * returned `PluginData`. Lets users call plugin-level helpers like + * `analytics().toolkit()` at module top-level. `AppKit._createApp` reuses the + * eagerly constructed instance instead of constructing a new one. + * + * @internal + */ +export function toPluginWithInstance< + T extends PluginConstructor, + Methods extends readonly (keyof InstanceType)[], +>(plugin: T, expose: Methods) { + type Config = ConstructorParameters[0]; + type Name = T["manifest"]["name"]; + type Instance = InstanceType; + type Exposed = Pick; + + return ( + config: Config = {} as Config, + ): PluginData & { + instance: BasePlugin; + } & Exposed => { + const name = plugin.manifest.name as Name; + const instance = new plugin({ ...(config ?? {}), name }) as Instance; + + const exposed: Record = {}; + for (const methodName of expose) { + const bound = instance[methodName]; + if (typeof bound === "function") { + exposed[methodName as string] = (bound as Function).bind(instance); + } else { + exposed[methodName as string] = bound; + } + } + + return { + plugin: plugin as T, + config: config as Config, + name, + instance: instance as unknown as BasePlugin, + ...(exposed as Exposed), + }; + }; +} diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts index 9873291c..4fee598c 100644 --- a/packages/appkit/src/plugins/agent/agent.ts +++ b/packages/appkit/src/plugins/agent/agent.ts @@ -753,5 +753,9 @@ export class AgentPlugin extends Plugin { /** * @internal + * @deprecated Use `agents()` (plural) from `@databricks/appkit` instead. The + * new plugin supports per-agent tool indexes, the keyed `tools: { ... }` + * record shape, and the `createAgent(def)` pure factory. This export stays + * for migration; it will be removed in a future release. */ export const agent = toPlugin(AgentPlugin); diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts new file mode 100644 index 00000000..cf4c4fdb --- /dev/null +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -0,0 +1,995 @@ +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 "../agent/defaults"; +import { AgentEventTranslator } from "../agent/event-translator"; +import { chatRequestSchema, invocationsRequestSchema } from "../agent/schemas"; +import { + buildBaseSystemPrompt, + composeSystemPrompt, +} from "../agent/system-prompt"; +import { InMemoryThreadStore } from "../agent/thread-store"; +import { + AppKitMcpClient, + type FunctionTool, + functionToolToDefinition, + isFunctionTool, + isHostedTool, + resolveHostedTools, +} from "../agent/tools"; +import { loadAgentsFromDir } from "./load-agents"; +import manifest from "./manifest.json"; +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("../agent/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("../agent/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/build-toolkit.ts b/packages/appkit/src/plugins/agents/build-toolkit.ts new file mode 100644 index 00000000..cc070251 --- /dev/null +++ b/packages/appkit/src/plugins/agents/build-toolkit.ts @@ -0,0 +1,62 @@ +import type { AgentToolDefinition } from "shared"; +import { toJSONSchema } from "zod"; +import type { ToolRegistry } from "../agent/tools/define-tool"; +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 = toJSONSchema( + 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/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/tests/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts new file mode 100644 index 00000000..0b549d68 --- /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 { defineTool, type ToolRegistry } from "../../agent/tools/define-tool"; +// Import the class directly so we can construct it without a createApp +import { AgentsPlugin } from "../agents"; +import { buildToolkitEntries } from "../build-toolkit"; +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/build-toolkit.test.ts b/packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts new file mode 100644 index 00000000..0525073e --- /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 { defineTool, type ToolRegistry } from "../../agent/tools/define-tool"; +import { buildToolkitEntries } from "../build-toolkit"; +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/create-agent.test.ts b/packages/appkit/src/plugins/agents/tests/create-agent.test.ts new file mode 100644 index 00000000..a6802a67 --- /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 "../../agent/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/load-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-agents.test.ts new file mode 100644 index 00000000..ff2967ea --- /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 { defineTool, type ToolRegistry } from "../../agent/tools/define-tool"; +import { tool } from "../../agent/tools/tool"; +import { buildToolkitEntries } from "../build-toolkit"; +import { + loadAgentFromFile, + loadAgentsFromDir, + parseFrontmatter, +} from "../load-agents"; + +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..d933000c --- /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 "../../agent/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/types.ts b/packages/appkit/src/plugins/agents/types.ts new file mode 100644 index 00000000..e343751b --- /dev/null +++ b/packages/appkit/src/plugins/agents/types.ts @@ -0,0 +1,153 @@ +import type { + AgentAdapter, + AgentToolDefinition, + BasePluginConfig, + ThreadStore, + ToolAnnotations, +} from "shared"; +import type { FunctionTool } from "../agent/tools/function-tool"; +import type { HostedTool } from "../agent/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; +} + +/** + * 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 ( + 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 1bd97d18..8e77967b 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -12,13 +12,14 @@ import { z } from "zod"; import { SQLWarehouseConnector } from "../../connectors"; import { getWarehouseId, getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; -import { Plugin, toPlugin } from "../../plugin"; +import { Plugin, toPluginWithInstance } from "../../plugin"; import type { PluginManifest } from "../../registry"; import { defineTool, executeFromRegistry, toolsFromRegistry, } from "../agent/tools/define-tool"; +import { buildToolkitEntries } from "../agents/build-toolkit"; import { queryDefaults } from "./defaults"; import manifest from "./manifest.json"; import { QueryProcessor } from "./query"; @@ -298,6 +299,23 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { return executeFromRegistry(this.tools, name, args, signal); } + /** + * Returns the plugin's tools as a keyed record of `ToolkitEntry` markers, + * suitable for spreading into an `AgentDefinition.tools` record. + * + * @example + * ```ts + * const analyticsP = analytics(); + * createAgent({ + * instructions: "...", + * tools: { ...analyticsP.toolkit({ only: ["query"] }) }, + * }); + * ``` + */ + 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. @@ -315,4 +333,6 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { /** * @internal */ -export const analytics = toPlugin(AnalyticsPlugin); +export const analytics = toPluginWithInstance(AnalyticsPlugin, [ + "toolkit", +] as const); diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts index 9a30440e..22f53e9f 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts @@ -608,4 +608,23 @@ describe("Analytics Plugin", () => { }); }); }); + + describe("toolkit()", () => { + test("factory exposes a toolkit() method producing ToolkitEntry records", () => { + const pluginData = analytics({}); + expect(typeof pluginData.toolkit).toBe("function"); + const entries = pluginData.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("toolkit() respects prefix and only options", () => { + const pluginData = analytics({}); + const entries = pluginData.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 2d92c198..19fdeea1 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -18,7 +18,7 @@ import { import { getWorkspaceClient, isInUserContext } from "../../context"; import { AuthenticationError } from "../../errors"; import { createLogger } from "../../logging/logger"; -import { Plugin, toPlugin } from "../../plugin"; +import { Plugin, toPluginWithInstance } from "../../plugin"; import type { PluginManifest, ResourceRequirement } from "../../registry"; import { ResourceType } from "../../registry"; import { @@ -27,6 +27,7 @@ import { type ToolRegistry, toolsFromRegistry, } from "../agent/tools/define-tool"; +import { buildToolkitEntries } from "../agents/build-toolkit"; import { FILES_DOWNLOAD_DEFAULTS, FILES_MAX_UPLOAD_SIZE, @@ -1049,6 +1050,10 @@ export class FilesPlugin extends Plugin implements ToolProvider { 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)) { @@ -1084,4 +1089,4 @@ export class FilesPlugin extends Plugin implements ToolProvider { /** * @internal */ -export const files = toPlugin(FilesPlugin); +export const files = toPluginWithInstance(FilesPlugin, ["toolkit"] as const); diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 96c34b64..318c8925 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import { GenieConnector } from "../../connectors"; import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging"; -import { Plugin, toPlugin } from "../../plugin"; +import { Plugin, toPluginWithInstance } from "../../plugin"; import type { PluginManifest } from "../../registry"; import { defineTool, @@ -18,6 +18,7 @@ import { type ToolRegistry, toolsFromRegistry, } from "../agent/tools/define-tool"; +import { buildToolkitEntries } from "../agents/build-toolkit"; import { genieStreamDefaults } from "./defaults"; import manifest from "./manifest.json"; import type { @@ -359,6 +360,10 @@ export class GeniePlugin extends Plugin implements ToolProvider { 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, @@ -370,4 +375,4 @@ export class GeniePlugin extends Plugin implements ToolProvider { /** * @internal */ -export const genie = toPlugin(GeniePlugin); +export const genie = toPluginWithInstance(GeniePlugin, ["toolkit"] as const); diff --git a/packages/appkit/src/plugins/lakebase/lakebase.ts b/packages/appkit/src/plugins/lakebase/lakebase.ts index 4ad3384e..56df8333 100644 --- a/packages/appkit/src/plugins/lakebase/lakebase.ts +++ b/packages/appkit/src/plugins/lakebase/lakebase.ts @@ -8,13 +8,14 @@ import { getUsernameWithApiLookup, } from "../../connectors/lakebase"; import { createLogger } from "../../logging/logger"; -import { Plugin, toPlugin } from "../../plugin"; +import { Plugin, toPluginWithInstance } from "../../plugin"; import type { PluginManifest } from "../../registry"; import { defineTool, executeFromRegistry, toolsFromRegistry, } from "../agent/tools/define-tool"; +import { buildToolkitEntries } from "../agents/build-toolkit"; import manifest from "./manifest.json"; import type { ILakebaseConfig } from "./types"; @@ -149,6 +150,10 @@ class LakebasePlugin extends Plugin implements ToolProvider { 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 @@ -163,4 +168,6 @@ class LakebasePlugin extends Plugin implements ToolProvider { /** * @internal */ -export const lakebase = toPlugin(LakebasePlugin); +export const lakebase = toPluginWithInstance(LakebasePlugin, [ + "toolkit", +] as const); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 120147f4..2e010053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,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 @@ -415,6 +418,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 @@ -5191,6 +5197,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==} @@ -17770,6 +17779,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': {}