diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index 7af74ef0e..c27a971ff 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -8,8 +8,8 @@ "dev": "NODE_ENV=development tsx watch server/index.ts", "dev:inspect": "NODE_ENV=development tsx --inspect --tsconfig ./tsconfig.json ./server", "build": "npm run build:app", - "build:app": "tsdown --out-dir build server/index.ts && cd client && npm run build", - "build:server": "tsdown --out-dir build server/index.ts", + "build:app": "tsdown --out-dir build server/index.ts 'server/agents/*.ts' && cd client && npm run build", + "build:server": "tsdown --out-dir build server/index.ts 'server/agents/*.ts'", "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", diff --git a/apps/dev-playground/server/agents/dashboard_pilot.ts b/apps/dev-playground/server/agents/dashboard_pilot.ts new file mode 100644 index 000000000..ac305dac8 --- /dev/null +++ b/apps/dev-playground/server/agents/dashboard_pilot.ts @@ -0,0 +1,239 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Smart-Dashboard pilot: emits UI-action tool calls the client reads off the +// SSE stream and translates into React state mutations. Referenced as a +// sub-agent by the markdown `query` dispatcher (config/agents/query), resolved +// by this file's id ("dashboard_pilot"). +// +// Narrow, single-purpose tools. +// +// The earlier polymorphic `apply_filter({ field, operator, value })` was +// too expressive — the LLM could emit valid-looking calls the dispatcher +// couldn't faithfully apply (e.g. `field: "dropoff_zone"` when the +// dashboard only has a `pickup_zip` filter; `operator: "eq"` with a date). +// Splitting into one tool per filter verb removes the whole class of +// "agent said it worked but nothing moved" bugs. +// +// Each tool has exactly one client-side effect, rendered by +// use-action-dispatcher. Server handlers are still stubs — the tool-call +// JSON is the action payload. + +const filter_by_date_range = tool({ + name: "filter_by_date_range", + description: + "Filter the dashboard to trips within a date range. Both start and end are required and must be ISO dates (YYYY-MM-DD) within 2016.", + schema: z.object({ + start: z.string().describe("Start date in ISO format, e.g. 2016-03-01"), + end: z.string().describe("End date in ISO format, e.g. 2016-03-31"), + }), + execute: async ({ start, end }) => + `Filtered dashboard to trips between ${start} and ${end}.`, +}); + +const filter_by_pickup_zip = tool({ + name: "filter_by_pickup_zip", + description: + "Filter the dashboard to trips originating from a specific pickup ZIP code. Use when the user asks about a specific pickup zone or ZIP.", + schema: z.object({ + zip: z.string().describe("Pickup ZIP code, e.g. 10001"), + }), + execute: async ({ zip }) => + `Filtered dashboard to trips picked up in ${zip}.`, +}); + +const filter_by_fare = tool({ + name: "filter_by_fare", + description: + "Filter the dashboard to trips within a fare range. At least one of min or max must be provided.", + schema: z + .object({ + min: z.number().optional().describe("Minimum fare in USD"), + max: z.number().optional().describe("Maximum fare in USD"), + }) + .refine((v) => v.min !== undefined || v.max !== undefined, { + message: "Provide at least one of min or max.", + }), + execute: async ({ min, max }) => { + const parts = [] as string[]; + if (min !== undefined) parts.push(`>= $${min}`); + if (max !== undefined) parts.push(`<= $${max}`); + return `Filtered dashboard to trips with fare ${parts.join(" and ")}.`; + }, +}); + +const clear_filters = tool({ + name: "clear_filters", + description: + "Remove all active filters from the dashboard. Use when the user asks to reset, clear, or remove filters.", + schema: z.object({}), + execute: async () => "All filters cleared.", +}); + +const highlight_period = tool({ + name: "highlight_period", + description: + "Highlight a time period on the Trips Over Time chart to draw attention to a specific date range.", + schema: z.object({ + start: z.string().describe("Start date in ISO format (YYYY-MM-DD)"), + end: z.string().describe("End date in ISO format (YYYY-MM-DD)"), + color: z + .enum(["blue", "red", "yellow"]) + .optional() + .describe("Highlight color. Defaults to blue."), + label: z + .string() + .optional() + .describe("Optional label for the highlighted period"), + }), + execute: async ({ start, end, color: _color, label }) => { + const suffix = label ? ` (${label})` : ""; + return `Highlighted period ${start} to ${end}${suffix} on the dashboard.`; + }, +}); + +const clear_highlights = tool({ + name: "clear_highlights", + description: + "Remove all highlight overlays from the charts. Use when the user asks to clear, reset, or remove highlights.", + schema: z.object({}), + execute: async () => "All highlights cleared.", +}); + +// Restores a previously saved view. The tool-call arguments are the +// authoritative state: the client listens for this function_call on SSE +// and applies the filters + highlights directly without needing a round +// trip back for metadata. The agent is expected to have looked up the +// saved view server-side before emitting this call (it passes the +// already-resolved state through). +const load_view = tool({ + name: "load_view", + description: + "Restore a previously saved dashboard view by applying its filters and highlights. The caller supplies the already-resolved state so the client can apply it from this tool call without a second round trip.", + schema: z.object({ + name: z.string().describe("The saved view's name (for UI feedback)"), + filters: z + .object({ + date_from: z.string().optional(), + date_to: z.string().optional(), + pickup_zip: z.string().optional(), + fare_min: z.string().optional(), + fare_max: z.string().optional(), + }) + .passthrough() + .describe("Filters to restore. Omit fields that should not be set."), + highlights: z + .array( + z.object({ + start: z.string(), + end: z.string(), + color: z.enum(["blue", "red", "yellow"]).optional(), + label: z.string().optional(), + }), + ) + .describe("Highlight ranges to restore."), + }), + execute: async ({ name }) => `Restored saved view "${name}".`, +}); + +const focus_chart = tool({ + name: "focus_chart", + description: + "Scroll the user's viewport to a specific chart on the dashboard and briefly pulse it to draw attention. Use when the user asks to 'look at' or 'focus on' a specific visualization.", + schema: z.object({ + chart_id: z + .enum([ + "kpis", + "trips_over_time", + "fare_distribution", + "hourly_heatmap", + "top_zones", + ]) + .describe("Which chart to focus on"), + }), + execute: async ({ chart_id }) => `Focused on ${chart_id}.`, +}); + +const highlight_zone = tool({ + name: "highlight_zone", + description: + "Draw an emphasis ring around a specific pickup ZIP on the Top Pickup Zones chart. Use this to call attention to a standout zone without filtering the whole dashboard to that ZIP.", + schema: z.object({ + zip: z.string().describe("Pickup ZIP code to highlight (e.g. '10017')"), + label: z + .string() + .optional() + .describe("Optional short label shown inside the highlighted bar"), + }), + execute: async ({ zip, label }) => + `Highlighted pickup ZIP ${zip}${label ? ` (${label})` : ""}.`, +}); + +const clear_zone_highlights = tool({ + name: "clear_zone_highlights", + description: "Remove all emphasis rings from the Top Pickup Zones chart.", + schema: z.object({}), + execute: async () => "Zone highlights cleared.", +}); + +// Write tool: exercises the approval gate. Server handler is a stub — +// no view persistence — but `effect: "write"` forces the human-in-the-loop +// flow before the agent can call it. We pick `write` (not `destructive`) +// because capturing a view CREATES a new file; nothing is deleted or +// overwritten. The approval card will render the low-severity blue +// "writes" treatment rather than the alarming red "destructive" one. +const save_view = tool({ + name: "save_view", + description: + "Persist the current dashboard configuration (filters + highlights) as a named view the user can recall later. Always surfaces the approval gate as a write action.", + annotations: { effect: "write" }, + schema: z.object({ + name: z.string().describe("Short human-readable name for the saved view"), + description: z + .string() + .optional() + .describe("Optional longer description for the saved view"), + }), + execute: async ({ name, description }) => { + const suffix = description ? `: ${description}` : ""; + return `Saved view "${name}"${suffix}.`; + }, +}); + +export default createAgent({ + instructions: [ + "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", + "Filters:", + "- `filter_by_date_range({start, end})` — narrow to a date window within 2016.", + "- `filter_by_pickup_zip({zip})` — narrow to trips from a specific ZIP.", + "- `filter_by_fare({min?, max?})` — narrow by fare range (at least one bound required).", + "- `clear_filters()` — remove all active filters.", + "Highlights:", + "- `highlight_period({start, end, color?, label?})` — shade a date window on the Trips Over Time chart.", + "- `clear_highlights()` — remove all shaded overlays from the trips chart.", + "- `highlight_zone({zip, label?})` — draw an emphasis ring around a specific ZIP on the Top Pickup Zones chart.", + "- `clear_zone_highlights()` — remove all ZIP emphasis rings.", + "Focus & save:", + "- `focus_chart({chart_id})` — scroll the viewport to one of `kpis`, `trips_over_time`, `fare_distribution`, `hourly_heatmap`, `top_zones` and briefly pulse it.", + "- `save_view({name, description?})` — persist the current configuration. Write action; the user will see an approval card.", + "- `load_view({name, filters, highlights})` — restore a previously saved view. Always pass the resolved state; never leave fields unset.", + "Rules:", + "1. Pick the single tool that matches the user's intent. Do not chain filters unless the user asks for a compound filter.", + "2. Briefly state what you did after the tool returns. Do not narrate before calling the tool.", + "3. If the user's request is ambiguous (e.g. 'filter to last month' without a 2016 context), ask one clarifying question before calling any tool.", + "4. For standout ZIPs, prefer `highlight_zone` over `filter_by_pickup_zip` so the rest of the dashboard stays in context. Only filter when the user explicitly asks to narrow the whole dashboard.", + ].join("\n"), + tools: { + filter_by_date_range, + filter_by_pickup_zip, + filter_by_fare, + clear_filters, + highlight_period, + clear_highlights, + highlight_zone, + clear_zone_highlights, + focus_chart, + save_view, + load_view, + }, +}); diff --git a/apps/dev-playground/server/agents/helper.ts b/apps/dev-playground/server/agents/helper.ts new file mode 100644 index 000000000..14267d7fc --- /dev/null +++ b/apps/dev-playground/server/agents/helper.ts @@ -0,0 +1,22 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Code-defined demo agent showing the tools(plugins) function form alongside +// the markdown-driven agents in config/agents/. Discovered automatically from +// server/agents/ — its id is the filename ("helper"). +export default createAgent({ + instructions: + "You are a demo helper. Use analytics tools to answer data questions, " + + "or get_weather for light small-talk.", + tools(plugins) { + return { + ...plugins.analytics.toolkit(), + get_weather: tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ city: z.string().describe("City name") }), + execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, + }), + }; + }, +}); diff --git a/apps/dev-playground/server/agents/sql_analyst.ts b/apps/dev-playground/server/agents/sql_analyst.ts new file mode 100644 index 000000000..2148e416e --- /dev/null +++ b/apps/dev-playground/server/agents/sql_analyst.ts @@ -0,0 +1,18 @@ +import { createAgent } from "@databricks/appkit/beta"; + +// Smart-Dashboard specialist: writes Databricks SQL against +// `samples.nyctaxi.trips`. Referenced as a sub-agent by the markdown `query` +// dispatcher (config/agents/query/agent.md, `agents: [sql_analyst, ...]`), +// which resolves it by this file's id ("sql_analyst"). +export default createAgent({ + instructions: [ + "You are a SQL expert for NYC taxi trip data (`samples.nyctaxi.trips`).", + "Write Databricks SQL to answer the user's question and summarize the results clearly.", + "IMPORTANT: The dataset only contains trips from 2016. Always add `WHERE tpep_pickup_datetime >= '2016-01-01' AND tpep_pickup_datetime < '2017-01-01'` unless the user specifies a narrower date range within 2016.", + "If the user asks about dates outside 2016, say the dataset only covers 2016.", + "Available columns: tpep_pickup_datetime, tpep_dropoff_datetime, trip_distance, fare_amount, pickup_zip, dropoff_zip.", + ].join(" "), + tools(plugins) { + return { ...plugins.analytics.toolkit() }; + }, +}); diff --git a/apps/dev-playground/server/agents/supervisor.ts b/apps/dev-playground/server/agents/supervisor.ts new file mode 100644 index 000000000..1449a4337 --- /dev/null +++ b/apps/dev-playground/server/agents/supervisor.ts @@ -0,0 +1,33 @@ +import { + createAgent, + DatabricksAdapter, + supervisorTools, +} from "@databricks/appkit/beta"; + +// Supervisor API demo agent. The Databricks AI Gateway executes hosted +// tools server-side; declare them via `createAgent({ tools })` like any +// other agent tool — the agents plugin classifies the tagged record and +// routes it to the adapter via AgentInput.extensions. Uncomment an entry +// below to give the model real powers. +// +// `createAgent({ model })` accepts an adapter promise, so the factory's +// host/credential resolution is awaited lazily on first dispatch (via +// `resolveAdapter` in the agents plugin). A misconfigured workspace will +// surface at first chat request, not at module init. +export default createAgent({ + instructions: + "You are an assistant powered by the Databricks Supervisor API.", + model: DatabricksAdapter.fromSupervisorApi({ + model: "databricks-claude-sonnet-4-5", + }), + tools: () => ({ + nyc: supervisorTools.genieSpace({ + id: process.env.DATABRICKS_GENIE_SPACE_ID ?? "", + description: "NYC taxi trip records and zones", + }), + add: supervisorTools.ucFunction({ + name: process.env.DATABRICKS_UC_FUNCTION_NAME ?? "", + description: "Adds two integers and returns the sum.", + }), + }), +}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index b30c51684..cec291833 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -12,15 +12,7 @@ import { serving, WRITE_ACTIONS, } from "@databricks/appkit"; -import { - agents, - aiSearch, - createAgent, - DatabricksAdapter, - supervisorTools, - tool, -} from "@databricks/appkit/beta"; -import { z } from "zod"; +import { agents, aiSearch } from "@databricks/appkit/beta"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -56,314 +48,6 @@ const adminOnly: FilePolicy = (action, _resource, user) => { return true; }; -// Code-defined demo agent showing the tools(plugins) function form -// alongside the markdown-driven agents in config/agents/. -const helper = createAgent({ - instructions: - "You are a demo helper. Use analytics tools to answer data questions, " + - "or get_weather for light small-talk.", - tools(plugins) { - return { - ...plugins.analytics.toolkit(), - get_weather: tool({ - name: "get_weather", - description: "Get the current weather for a city", - schema: z.object({ city: z.string().describe("City name") }), - execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, - }), - }; - }, -}); - -// Supervisor API demo agent. The Databricks AI Gateway executes hosted -// tools server-side; declare them via `createAgent({ tools })` like any -// other agent tool — the agents plugin classifies the tagged record and -// routes it to the adapter via AgentInput.extensions. Import -// `supervisorTools` from '@databricks/appkit/beta' and uncomment an -// entry below to give the model real powers. -// -// `createAgent({ model })` accepts an adapter promise, so the factory's -// host/credential resolution is awaited lazily on first dispatch (via -// `resolveAdapter` in the agents plugin). A misconfigured workspace will -// surface at first chat request, not at module init. -const supervisor = createAgent({ - instructions: - "You are an assistant powered by the Databricks Supervisor API.", - model: DatabricksAdapter.fromSupervisorApi({ - model: "databricks-claude-sonnet-4-5", - }), - tools: () => ({ - nyc: supervisorTools.genieSpace({ - id: process.env.DATABRICKS_GENIE_SPACE_ID ?? "", - description: "NYC taxi trip records and zones", - }), - add: supervisorTools.ucFunction({ - name: process.env.DATABRICKS_UC_FUNCTION_NAME ?? "", - description: "Adds two integers and returns the sum.", - }), - }), -}); - -/* - * Smart-Dashboard agents. - * - * The three agents form a dispatcher pattern for the /smart-dashboard route. - * The `query` agent (markdown, in config/agents/query/) routes user - * questions to one of two specialists: - * - * - `sql_analyst` — writes Databricks SQL against `samples.nyctaxi.trips` - * using the analytics plugin's query tool. - * - `dashboard_pilot` — emits UI-action tool calls (`apply_filter`, - * `highlight_period`) that the client reads off the SSE stream and - * translates into React state mutations. The server-side handlers are - * intentionally stubs — the tool-call JSON is the action payload. - */ - -// Narrow, single-purpose tools. -// -// The earlier polymorphic `apply_filter({ field, operator, value })` was -// too expressive — the LLM could emit valid-looking calls the dispatcher -// couldn't faithfully apply (e.g. `field: "dropoff_zone"` when the -// dashboard only has a `pickup_zip` filter; `operator: "eq"` with a date). -// Splitting into one tool per filter verb removes the whole class of -// "agent said it worked but nothing moved" bugs. -// -// Each tool has exactly one client-side effect, rendered by -// use-action-dispatcher. Server handlers are still stubs — the tool-call -// JSON is the action payload. - -const filter_by_date_range = tool({ - name: "filter_by_date_range", - description: - "Filter the dashboard to trips within a date range. Both start and end are required and must be ISO dates (YYYY-MM-DD) within 2016.", - schema: z.object({ - start: z.string().describe("Start date in ISO format, e.g. 2016-03-01"), - end: z.string().describe("End date in ISO format, e.g. 2016-03-31"), - }), - execute: async ({ start, end }) => - `Filtered dashboard to trips between ${start} and ${end}.`, -}); - -const filter_by_pickup_zip = tool({ - name: "filter_by_pickup_zip", - description: - "Filter the dashboard to trips originating from a specific pickup ZIP code. Use when the user asks about a specific pickup zone or ZIP.", - schema: z.object({ - zip: z.string().describe("Pickup ZIP code, e.g. 10001"), - }), - execute: async ({ zip }) => - `Filtered dashboard to trips picked up in ${zip}.`, -}); - -const filter_by_fare = tool({ - name: "filter_by_fare", - description: - "Filter the dashboard to trips within a fare range. At least one of min or max must be provided.", - schema: z - .object({ - min: z.number().optional().describe("Minimum fare in USD"), - max: z.number().optional().describe("Maximum fare in USD"), - }) - .refine((v) => v.min !== undefined || v.max !== undefined, { - message: "Provide at least one of min or max.", - }), - execute: async ({ min, max }) => { - const parts = [] as string[]; - if (min !== undefined) parts.push(`>= $${min}`); - if (max !== undefined) parts.push(`<= $${max}`); - return `Filtered dashboard to trips with fare ${parts.join(" and ")}.`; - }, -}); - -const clear_filters = tool({ - name: "clear_filters", - description: - "Remove all active filters from the dashboard. Use when the user asks to reset, clear, or remove filters.", - schema: z.object({}), - execute: async () => "All filters cleared.", -}); - -const highlight_period = tool({ - name: "highlight_period", - description: - "Highlight a time period on the Trips Over Time chart to draw attention to a specific date range.", - schema: z.object({ - start: z.string().describe("Start date in ISO format (YYYY-MM-DD)"), - end: z.string().describe("End date in ISO format (YYYY-MM-DD)"), - color: z - .enum(["blue", "red", "yellow"]) - .optional() - .describe("Highlight color. Defaults to blue."), - label: z - .string() - .optional() - .describe("Optional label for the highlighted period"), - }), - execute: async ({ start, end, color: _color, label }) => { - const suffix = label ? ` (${label})` : ""; - return `Highlighted period ${start} to ${end}${suffix} on the dashboard.`; - }, -}); - -const clear_highlights = tool({ - name: "clear_highlights", - description: - "Remove all highlight overlays from the charts. Use when the user asks to clear, reset, or remove highlights.", - schema: z.object({}), - execute: async () => "All highlights cleared.", -}); - -// Restores a previously saved view. The tool-call arguments are the -// authoritative state: the client listens for this function_call on SSE -// and applies the filters + highlights directly without needing a round -// trip back for metadata. The agent is expected to have looked up the -// saved view server-side before emitting this call (it passes the -// already-resolved state through). -const load_view = tool({ - name: "load_view", - description: - "Restore a previously saved dashboard view by applying its filters and highlights. The caller supplies the already-resolved state so the client can apply it from this tool call without a second round trip.", - schema: z.object({ - name: z.string().describe("The saved view's name (for UI feedback)"), - filters: z - .object({ - date_from: z.string().optional(), - date_to: z.string().optional(), - pickup_zip: z.string().optional(), - fare_min: z.string().optional(), - fare_max: z.string().optional(), - }) - .passthrough() - .describe("Filters to restore. Omit fields that should not be set."), - highlights: z - .array( - z.object({ - start: z.string(), - end: z.string(), - color: z.enum(["blue", "red", "yellow"]).optional(), - label: z.string().optional(), - }), - ) - .describe("Highlight ranges to restore."), - }), - execute: async ({ name }) => `Restored saved view "${name}".`, -}); - -const focus_chart = tool({ - name: "focus_chart", - description: - "Scroll the user's viewport to a specific chart on the dashboard and briefly pulse it to draw attention. Use when the user asks to 'look at' or 'focus on' a specific visualization.", - schema: z.object({ - chart_id: z - .enum([ - "kpis", - "trips_over_time", - "fare_distribution", - "hourly_heatmap", - "top_zones", - ]) - .describe("Which chart to focus on"), - }), - execute: async ({ chart_id }) => `Focused on ${chart_id}.`, -}); - -const highlight_zone = tool({ - name: "highlight_zone", - description: - "Draw an emphasis ring around a specific pickup ZIP on the Top Pickup Zones chart. Use this to call attention to a standout zone without filtering the whole dashboard to that ZIP.", - schema: z.object({ - zip: z.string().describe("Pickup ZIP code to highlight (e.g. '10017')"), - label: z - .string() - .optional() - .describe("Optional short label shown inside the highlighted bar"), - }), - execute: async ({ zip, label }) => - `Highlighted pickup ZIP ${zip}${label ? ` (${label})` : ""}.`, -}); - -const clear_zone_highlights = tool({ - name: "clear_zone_highlights", - description: "Remove all emphasis rings from the Top Pickup Zones chart.", - schema: z.object({}), - execute: async () => "Zone highlights cleared.", -}); - -// Write tool: exercises the approval gate. Server handler is a stub — -// no view persistence — but `effect: "write"` forces the human-in-the-loop -// flow before the agent can call it. We pick `write` (not `destructive`) -// because capturing a view CREATES a new file; nothing is deleted or -// overwritten. The approval card will render the low-severity blue -// "writes" treatment rather than the alarming red "destructive" one. -const save_view = tool({ - name: "save_view", - description: - "Persist the current dashboard configuration (filters + highlights) as a named view the user can recall later. Always surfaces the approval gate as a write action.", - annotations: { effect: "write" }, - schema: z.object({ - name: z.string().describe("Short human-readable name for the saved view"), - description: z - .string() - .optional() - .describe("Optional longer description for the saved view"), - }), - execute: async ({ name, description }) => { - const suffix = description ? `: ${description}` : ""; - return `Saved view "${name}"${suffix}.`; - }, -}); - -const sql_analyst = createAgent({ - instructions: [ - "You are a SQL expert for NYC taxi trip data (`samples.nyctaxi.trips`).", - "Write Databricks SQL to answer the user's question and summarize the results clearly.", - "IMPORTANT: The dataset only contains trips from 2016. Always add `WHERE tpep_pickup_datetime >= '2016-01-01' AND tpep_pickup_datetime < '2017-01-01'` unless the user specifies a narrower date range within 2016.", - "If the user asks about dates outside 2016, say the dataset only covers 2016.", - "Available columns: tpep_pickup_datetime, tpep_dropoff_datetime, trip_distance, fare_amount, pickup_zip, dropoff_zip.", - ].join(" "), - tools(plugins) { - return { ...plugins.analytics.toolkit() }; - }, -}); - -const dashboard_pilot = createAgent({ - instructions: [ - "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", - "Filters:", - "- `filter_by_date_range({start, end})` — narrow to a date window within 2016.", - "- `filter_by_pickup_zip({zip})` — narrow to trips from a specific ZIP.", - "- `filter_by_fare({min?, max?})` — narrow by fare range (at least one bound required).", - "- `clear_filters()` — remove all active filters.", - "Highlights:", - "- `highlight_period({start, end, color?, label?})` — shade a date window on the Trips Over Time chart.", - "- `clear_highlights()` — remove all shaded overlays from the trips chart.", - "- `highlight_zone({zip, label?})` — draw an emphasis ring around a specific ZIP on the Top Pickup Zones chart.", - "- `clear_zone_highlights()` — remove all ZIP emphasis rings.", - "Focus & save:", - "- `focus_chart({chart_id})` — scroll the viewport to one of `kpis`, `trips_over_time`, `fare_distribution`, `hourly_heatmap`, `top_zones` and briefly pulse it.", - "- `save_view({name, description?})` — persist the current configuration. Write action; the user will see an approval card.", - "- `load_view({name, filters, highlights})` — restore a previously saved view. Always pass the resolved state; never leave fields unset.", - "Rules:", - "1. Pick the single tool that matches the user's intent. Do not chain filters unless the user asks for a compound filter.", - "2. Briefly state what you did after the tool returns. Do not narrate before calling the tool.", - "3. If the user's request is ambiguous (e.g. 'filter to last month' without a 2016 context), ask one clarifying question before calling any tool.", - "4. For standout ZIPs, prefer `highlight_zone` over `filter_by_pickup_zip` so the rest of the dashboard stays in context. Only filter when the user explicitly asks to narrow the whole dashboard.", - ].join("\n"), - tools: { - filter_by_date_range, - filter_by_pickup_zip, - filter_by_fare, - clear_filters, - highlight_period, - clear_highlights, - highlight_zone, - clear_zone_highlights, - focus_chart, - save_view, - load_view, - }, -}); - /** * OBO demo policy: deny anything running as the SP (including the dev * fallback when no `x-forwarded-access-token` is present). Only real @@ -421,13 +105,14 @@ createApp({ }), serving(), agents({ - agents: { helper, sql_analyst, dashboard_pilot, supervisor }, - // `query` (markdown dispatcher) + `sql_analyst` + `dashboard_pilot` - // wire the /smart-dashboard route. `insights` and `anomaly` are - // ephemeral markdown agents auto-fired by the route's AgentSidebar. - // `helper` is the conversational default for the bare `/agent` route - // (the markdown agents are dispatchers or ephemeral and don't make - // sense as the user-facing landing agent). + // Code agents are discovered from server/agents/ (helper, supervisor, + // sql_analyst, dashboard_pilot); markdown agents from config/agents/. + // `query` (markdown dispatcher) delegates to the discovered + // `sql_analyst` + `dashboard_pilot` to wire the /smart-dashboard route. + // `insights` and `anomaly` are ephemeral markdown agents auto-fired by + // the route's AgentSidebar. `helper` is the conversational default for + // the bare `/agent` route (the markdown agents are dispatchers or + // ephemeral and don't make sense as the user-facing landing agent). defaultAgent: "helper", }), aiSearch({ diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 8996e759c..46d9534db 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -22,6 +22,19 @@ Override the plugin's baseSystemPrompt for this agent only. *** +### default? + +```ts +optional default: boolean; +``` + +Marks this agent as the default one chosen when a client doesn't name an +agent. Mirrors markdown frontmatter `default: true`. When several agents +set it, the first in stable id order wins; an explicit +`agents({ defaultAgent })` always overrides it. Defaults to `false`. + +*** + ### ephemeral? ```ts diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index c038d41c1..257fba74b 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -14,13 +14,20 @@ Base configuration interface for AppKit plugins ## Properties -### agents? +### ~~agents?~~ ```ts optional agents: Record; ``` -Code-defined agents, merged with file-loaded ones (code wins on key collision). +#### Deprecated + +Put each code agent in its own file under `server/agents/` +(`export default createAgent({ ... })`); `appkit generate-agents` +discovers them automatically and the call collapses to `agents({ ... })` +with no map. Still honored for backward compatibility (emits a one-time +deprecation warning) but will be removed in a future minor. Discovered +agents and this map may not both define the same id. *** @@ -83,6 +90,20 @@ Customize or disable the AppKit base system prompt. *** +### codeAgentsDir? + +```ts +optional codeAgentsDir: string | false; +``` + +Directory of code agents (one `.ts` file per agent, each +`export default createAgent({ ... })`). Discovered at startup and merged +with markdown agents. Defaults to `server/agents` in dev and the compiled +`dist/agents` in a production build (resolved by `NODE_ENV`). Set to `false` +to disable code-agent discovery, or a string to point at a custom directory. + +*** + ### defaultAgent? ```ts diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index d1b7a79e4..bd9dd570d 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -6,7 +6,7 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor ::: -The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It loads agent definitions from markdown on disk (one folder per agent: `config/agents//agent.md`), from TypeScript (`createAgent(def)`), or both, and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. +The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It discovers agent definitions from disk — markdown packages (one folder per agent: `config/agents//agent.md`) and code agents (one file per agent: `server/agents/.ts`) — and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. In both cases the agent's id is its filename/folder name; there's no map to maintain and no id to restate. This page covers the full lifecycle. For the hand-written primitives (`tool()`, `mcpServer()`), see [tools](./server.md). @@ -100,12 +100,14 @@ When any `tools:` is declared the auto-inherit default is turned off — the age ## Level 3: code-defined agents +Code agents live one-per-file under `server/agents/`. Each file exports a created agent and its **id is the filename** (`server/agents/support.ts` → `support`), mirroring how a markdown agent's id is its folder name. Nothing restates the id. + ```ts -import { analytics, createApp, files, server } from "@databricks/appkit"; -import { agents, createAgent, tool } from "@databricks/appkit/beta"; +// server/agents/support.ts +import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; -const support = createAgent({ +export default createAgent({ // id derived from filename: "support" instructions: "You help customers with data and files.", model: "databricks-claude-sonnet-4-5", // string sugar tools(plugins) { @@ -120,18 +122,34 @@ const support = createAgent({ }; }, }); +``` + +The `agents` plugin discovers these files at startup — no registration, no map: + +```ts +// server/server.ts +import { analytics, createApp, files, server } from "@databricks/appkit"; +import { agents } from "@databricks/appkit/beta"; await createApp({ - plugins: [server(), analytics(), files(), agents({ agents: { support } })], + plugins: [server(), analytics(), files(), agents()], // no agent map, no import }); ``` +Discovery scans the code-agents directory and imports each module: `server/agents/*.ts` under `tsx` in dev, and the compiled `dist/agents/*.js` in a production build (chosen by `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*.ts` as build entries so `dist/agents/*.js` are emitted for the scan — that wiring is what lets a dropped-in file survive the prod bundle. The directory is `server/agents` by default; override or disable it with `agents({ codeAgentsDir })` (a path, or `false`). + +A file may `export default createAgent({...})` or export a single named created agent; either way the id is the filename. A module that exports no created agent (a shared helper) is skipped. Mark one agent as the default with `createAgent({ default: true })` (mirrors markdown frontmatter `default: true`); an explicit `agents({ defaultAgent })` still wins. + Code-defined agents start with no tools by default. The function form `tools(plugins) => Record` is the primary way to pull in plugin tools: each plugin registered in `createApp({ plugins: [...] })` shows up on the `plugins` parameter, and you call `.toolkit(opts?)` on it to get a spread-friendly record. The runtime invokes the function once at agent setup and caches the result — every plugin is mentioned exactly once (in `createApp`), with no held variables or marker imports. -Inline `tool({...})` calls live in the same record. `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). +Inline `tool({...})` calls live in the same record. Their `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. +:::warning Deprecated: the `agents({ agents: { ... } })` map +Passing a hand-built agent map still works and is honored for backward compatibility, but it emits a one-time deprecation warning and will be removed in a future minor. It restates each agent's id (once in `createAgent`, once as the map key); discovery from `server/agents/` removes both the map and the restatement. Migrate by moving each `createAgent(...)` into its own `server/agents/.ts` (default or single named export) and dropping the map. A discovered agent and a map entry may not share an id. (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) +::: + ### Scoping tools in code `plugins..toolkit(opts?)` accepts the same `ToolkitOptions` as markdown frontmatter: @@ -167,15 +185,15 @@ const supervisor = createAgent({ agents: { researcher, writer }, // exposed as agent-researcher, agent-writer }); +// server/agents/supervisor.ts (+ researcher.ts, writer.ts) — one file each +export default supervisor; + await createApp({ - plugins: [ - server(), - agents({ agents: { supervisor, researcher, writer } }), - ], + plugins: [server(), agents()], // discovered from server/agents/ }); ``` -Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. +Put `supervisor`, `researcher`, and `writer` in their own `server/agents/*.ts` files (default export each) — a markdown parent can also delegate to a discovered code child via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. ## Level 5: standalone (no `createApp`) @@ -351,8 +369,9 @@ Some hosted tool kinds return their final assistant text without incremental `ou ```ts agents({ - dir?: string | false, // "./config/agents" default; false disables - agents?: Record, + dir?: string | false, // markdown agents; "./config/agents" default; false disables + codeAgentsDir?: string | false, // code agents; "server/agents" (dev) / "dist/agents" (prod); false disables + agents?: Record, // DEPRECATED — use server/agents/ discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, tools?: Record, diff --git a/packages/appkit/src/core/agent/create-agent.ts b/packages/appkit/src/core/agent/create-agent.ts index b4b119010..20d52f22c 100644 --- a/packages/appkit/src/core/agent/create-agent.ts +++ b/packages/appkit/src/core/agent/create-agent.ts @@ -1,6 +1,18 @@ import { ConfigurationError } from "../../errors"; import type { AgentDefinition } from "./types"; +/** + * Non-enumerable brand stamped on every {@link createAgent} result. The + * code-agent loader ({@link loadCodeAgentsFromDir}) uses it to tell a real + * agent export from any other value a module in `server/agents/` might + * export, without duck-typing or guessing from the filename. + * + * A registered (`Symbol.for`) symbol so the check still holds if two copies + * of the package end up loaded in one process — the app's agent files and + * the plugin can resolve `@databricks/appkit` independently. + */ +const AGENT_BRAND: unique symbol = Symbol.for("appkit.agent"); + /** * Pure factory for agent definitions. Returns the passed-in definition after * cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape @@ -23,9 +35,29 @@ import type { AgentDefinition } from "./types"; */ export function createAgent(def: AgentDefinition): AgentDefinition { detectCycles(def); + // Brand for runtime discovery. Non-enumerable so it never shows up in + // spreads or JSON, and defined in-place so the returned value stays + // identical to the input (`createAgent(def) === def`). + Object.defineProperty(def, AGENT_BRAND, { + value: true, + enumerable: false, + configurable: true, + }); return def; } +/** + * Type guard: true when `value` was produced by {@link createAgent}. Used by + * the code-agent loader to pick the agent export out of a discovered module. + */ +export function isCreatedAgent(value: unknown): value is AgentDefinition { + return ( + typeof value === "object" && + value !== null && + (value as Record)[AGENT_BRAND] === true + ); +} + /** * Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is * found. Cycles would cause infinite recursion at tool-invocation time. diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts new file mode 100644 index 000000000..daacc7167 --- /dev/null +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -0,0 +1,134 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createLogger } from "../../logging/logger"; +import { isCreatedAgent } from "./create-agent"; +import type { AgentDefinition } from "./types"; + +const logger = createLogger("agents:code-loader"); + +/** Files in the code-agents dir that are never themselves agents. */ +function isIgnored(name: string): boolean { + return ( + name.endsWith(".d.ts") || + name.endsWith(".test.ts") || + name.endsWith(".test.tsx") || + name.endsWith(".test.js") || + name.endsWith(".spec.ts") || + name.endsWith(".spec.tsx") || + name.endsWith(".spec.js") || + /^index\.(ts|tsx|js|mjs)$/.test(name) + ); +} + +/** + * Picks the single created agent a module exports. Prefers the default + * export; otherwise accepts exactly one branded named export. Returns + * `undefined` when the module exports no agent (e.g. a helper file or a + * bundler-emitted chunk sitting next to the agents), and throws when a + * single file exports more than one agent (the filename is the id, so it + * can only stand for one). + */ +function pickAgentExport( + mod: Record, + filePath: string, +): AgentDefinition | undefined { + if (isCreatedAgent(mod.default)) return mod.default; + + const named = Object.entries(mod).filter( + ([key, value]) => key !== "default" && isCreatedAgent(value), + ); + if (named.length === 0) return undefined; + if (named.length > 1) { + throw new Error( + `Agent file '${filePath}' exports ${named.length} created agents (${named + .map(([k]) => k) + .join(", ")}); expected exactly one. ` + + "Split them into one file per agent (the filename is the agent id).", + ); + } + return named[0][1] as AgentDefinition; +} + +/** + * Discovers code agents by importing every module in `dir` and taking the + * agent each exports. The agent's id is its filename without extension — + * the single source of truth — mirroring how a markdown agent's id is its + * folder name. + * + * This is the runtime counterpart to the markdown `loadAgentsFromDir`: dev + * points at the `.ts` sources (run under `tsx`), a bundled server points at + * the compiled `.js` in `dist/`. The caller resolves which directory and + * which extensions apply; this function just imports and brand-checks. + * + * Returns an empty record when the directory does not exist. Files that + * export no agent are skipped (debug-logged); a syntax/import error in an + * agent file, a duplicate id, or a multi-agent file all throw with the + * offending path. + */ +export async function loadCodeAgentsFromDir( + dir: string, + opts: { extensions: string[] }, +): Promise> { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } + + const files = entries + .filter( + (e) => + e.isFile() && + opts.extensions.some((ext) => e.name.endsWith(ext)) && + !isIgnored(e.name), + ) + .map((e) => e.name) + .sort(); + + const agents: Record = {}; + const sourceById = new Map(); + + for (const file of files) { + const filePath = path.join(dir, file); + + let mod: Record; + try { + mod = (await import(pathToFileURL(filePath).href)) as Record< + string, + unknown + >; + } catch (err) { + throw new Error( + `Failed to import code agent '${filePath}': ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err instanceof Error ? err : undefined }, + ); + } + + const agent = pickAgentExport(mod, filePath); + if (!agent) { + logger.debug( + "Skipping %s — no createAgent export (not a code agent).", + filePath, + ); + continue; + } + + const id = file.replace(/\.(ts|tsx|js|mjs|cjs)$/, ""); + const prior = sourceById.get(id); + if (prior) { + throw new Error( + `Duplicate code-agent id '${id}': both '${prior}' and '${file}' resolve to it. Rename one file.`, + ); + } + sourceById.set(id, file); + agents[id] = agent; + } + + return agents; +} diff --git a/packages/appkit/src/core/agent/tests/create-agent.test.ts b/packages/appkit/src/core/agent/tests/create-agent.test.ts index 46668e1a0..f336752e8 100644 --- a/packages/appkit/src/core/agent/tests/create-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/create-agent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; -import { createAgent } from "../create-agent"; +import { createAgent, isCreatedAgent } from "../create-agent"; import { tool } from "../tools/tool"; import type { AgentDefinition } from "../types"; @@ -36,6 +36,35 @@ describe("createAgent", () => { } }); + test("name is optional (id is derived elsewhere)", () => { + const def = createAgent({ instructions: "no name here" }); + expect(def.name).toBeUndefined(); + expect(def.instructions).toBe("no name here"); + }); + + test("carries the default flag through unchanged", () => { + const def = createAgent({ + instructions: "I am the default.", + default: true, + }); + expect(def.default).toBe(true); + }); + + test("brands the result so the code-agent loader can recognize it", () => { + const def = createAgent({ instructions: "branded" }); + expect(isCreatedAgent(def)).toBe(true); + // The brand is non-enumerable — invisible to spread and JSON. + expect(Object.keys(def)).not.toContain("Symbol(appkit.agent)"); + expect(JSON.parse(JSON.stringify(def))).toEqual({ + instructions: "branded", + }); + // Plain objects are not agents. + expect(isCreatedAgent({ instructions: "not made by createAgent" })).toBe( + false, + ); + expect(isCreatedAgent(null)).toBe(false); + }); + test("accepts sub-agents in a keyed record", () => { const researcher = createAgent({ instructions: "Research." }); const supervisor = createAgent({ diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 572879565..85ff749ad 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -142,6 +142,13 @@ export interface AgentDefinition { * entirely. */ name?: string; + /** + * Marks this agent as the default one chosen when a client doesn't name an + * agent. Mirrors markdown frontmatter `default: true`. When several agents + * set it, the first in stable id order wins; an explicit + * `agents({ defaultAgent })` always overrides it. Defaults to `false`. + */ + default?: boolean; /** System prompt body. For markdown-loaded agents this is the file body. */ instructions: string; /** @@ -208,7 +215,22 @@ export interface AutoInheritToolsConfig { export interface AgentsPluginConfig extends BasePluginConfig { /** Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. */ dir?: string | false; - /** Code-defined agents, merged with file-loaded ones (code wins on key collision). */ + /** + * Directory of code agents (one `.ts` file per agent, each + * `export default createAgent({ ... })`). Discovered at startup and merged + * with markdown agents. Defaults to `server/agents` in dev and the compiled + * `dist/agents` in a production build (resolved by `NODE_ENV`). Set to `false` + * to disable code-agent discovery, or a string to point at a custom directory. + */ + codeAgentsDir?: string | false; + /** + * @deprecated Put each code agent in its own file under `server/agents/` + * (`export default createAgent({ ... })`); `appkit generate-agents` + * discovers them automatically and the call collapses to `agents({ ... })` + * with no map. Still honored for backward compatibility (emits a one-time + * deprecation warning) but will be removed in a future minor. Discovered + * agents and this map may not both define the same id. + */ 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; diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 1d9162a96..fd36f52ec 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import type express from "express"; import pc from "picocolors"; @@ -23,6 +24,7 @@ import { import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; +import { loadCodeAgentsFromDir } from "../../core/agent/load-code-agents"; import { normalizeToolResult } from "../../core/agent/normalize-result"; import { createPluginsProxy } from "../../core/agent/plugins-map"; import { @@ -75,6 +77,14 @@ const logger = createLogger("agents"); const DEFAULT_AGENTS_DIR = "./config/agents"; +/** Where code agents live in source (dev, run under `tsx`). */ +const CODE_AGENTS_SOURCE_DIR = "server/agents"; +/** + * Where they land once compiled into the server bundle (production). Probed + * in order — `tsdown` projects conventionally emit to `dist/` or `build/`. + */ +const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/agents"]; + /** * Context flag recorded on the in-memory AgentDefinition to indicate whether * it came from markdown (file) or from user code. Drives the asymmetric @@ -165,6 +175,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { private mcpClient: AppKitMcpClient | null = null; private threadStore; private approvalGate = new ToolApprovalGate(); + /** Guards the `agents({ agents })` deprecation warning to once per instance. */ + private agentsMapDeprecationWarned = false; constructor(config: AgentsPluginConfig) { super(config); @@ -331,45 +343,83 @@ export class AgentsPlugin extends Plugin implements ToolProvider { agents: Map; defaultAgentName: string | null; }> { - const { defs: fileDefs, defaultAgent: fileDefault } = - await this.loadFileDefinitions(); - - const codeDefs = this.config.agents ?? {}; + // Two code-agent sources: agents discovered from the code-agents dir + // (server/agents in dev, dist/agents in a bundled server) and the + // deprecated `agents({ agents })` map. Both are "code" origin; markdown + // agents are loaded separately below. + const discovered = await this.loadCodeAgents(); + const deprecatedMap = this.config.agents ?? {}; + + if (Object.keys(deprecatedMap).length > 0) { + this.warnAgentsMapDeprecated(); + } - 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, + // Same id in both code sources is ambiguous — a file AND a hand-written + // map entry claim it. Fail loud rather than silently pick one. + for (const id of Object.keys(discovered)) { + if (deprecatedMap[id]) { + throw new Error( + `Agent '${id}' is both discovered in ${this.resolvedAgentsDir() ?? "server/agents"} and passed to agents({ agents: { ${id} } }). ` + + "Remove the map entry — discovery already registers it.", ); } } + // Code agents also feed markdown sub-agent resolution: a markdown parent + // with `agents: [helper]` frontmatter resolves `helper` against these. + const codeAgents: Record = { + ...discovered, + ...deprecatedMap, + }; + + const { defs: fileDefs, defaultAgent: fileDefault } = + await this.loadFileDefinitions(codeAgents); + + // Build the merged registry. Order: markdown, then discovered, then the + // deprecated map — this order also determines the "first registered" + // default fallback. 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)) { + for (const [name, def] of Object.entries(discovered)) { + if (merged[name]?.src.origin === "file") { + // Discovery is new API, so a discovered/markdown clash is a hard error + // (unlike the grandfathered map-vs-markdown warning below). + throw new Error( + `Agent '${name}' is defined as both a code agent (server/agents/${name}.ts) and a markdown agent. ` + + `Rename one. Available: ${Object.keys(merged).sort().join(", ")}`, + ); + } + merged[name] = { def, src: { origin: "code" } }; + } + for (const [name, def] of Object.entries(deprecatedMap)) { + if (merged[name]?.src.origin === "file") { + logger.warn( + "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", + name, + ); + } merged[name] = { def, src: { origin: "code" } }; } const agents = new Map(); - let defaultAgentName: string | null = null; + let firstRegistered: string | null = null; if (Object.keys(merged).length === 0) { logger.info( - "No agents registered (no files in %s, no code-defined agents)", + "No agents registered (no files in %s, no discovered or code-defined agents)", this.resolvedAgentsDir() ?? "", ); - return { agents, defaultAgentName }; + return { agents, defaultAgentName: null }; } for (const [name, { def, src }] of Object.entries(merged)) { try { const registered = await this.buildRegisteredAgent(name, def, src); agents.set(name, registered); - if (!defaultAgentName) defaultAgentName = name; + if (!firstRegistered) firstRegistered = name; } catch (err) { throw new Error( `Failed to register agent '${name}' (${src.origin}): ${ @@ -380,18 +430,62 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } } + return { + agents, + defaultAgentName: this.resolveDefaultAgent( + agents, + merged, + fileDefault, + firstRegistered, + ), + }; + } + + /** + * Resolves the default agent. Precedence: explicit `config.defaultAgent` > + * a code/discovered agent flagged `default: true` (stable id order) > + * markdown `default: true` > first registered (stable order). + */ + private resolveDefaultAgent( + agents: Map, + merged: Record, + fileDefault: string | null, + firstRegistered: string | null, + ): string | null { if (this.config.defaultAgent) { if (!agents.has(this.config.defaultAgent)) { throw new Error( `defaultAgent '${this.config.defaultAgent}' is not registered. Available: ${Array.from(agents.keys()).join(", ")}`, ); } - defaultAgentName = this.config.defaultAgent; - } else if (fileDefault && agents.has(fileDefault)) { - defaultAgentName = fileDefault; + return this.config.defaultAgent; } - return { agents, defaultAgentName }; + const codeDefault = Object.keys(merged) + .filter( + (id) => merged[id].src.origin === "code" && merged[id].def.default, + ) + .sort()[0]; + if (codeDefault) return codeDefault; + + if (fileDefault && agents.has(fileDefault)) return fileDefault; + + return firstRegistered; + } + + /** + * Emits the one-time deprecation warning for the `agents({ agents })` map. + * Guarded so `reload()` (which re-runs `buildAgentRegistry`) doesn't spam it. + */ + private warnAgentsMapDeprecated(): void { + if (this.agentsMapDeprecationWarned) return; + this.agentsMapDeprecationWarned = true; + logger.warn( + "agents({ agents: { ... } }) is deprecated. Put each code agent in its own file under " + + "server/agents/ (export default createAgent({ ... })) and it is discovered automatically — " + + "the call collapses to agents({ ... }) with no agent map. The `agents` field still works but " + + "will be removed in a future minor. See docs/plugins/agents.md.", + ); } private resolvedAgentsDir(): string | null { @@ -400,7 +494,104 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); } - private async loadFileDefinitions(): Promise<{ + /** + * Discovers code agents from the code-agents directory (default + * `server/agents`). See {@link loadCodeAgentsFromDir} for the contract. + * + * When nothing is found in a production build, emits a loud warning: the + * most likely cause is the bundler not emitting `dist/agents/*.js` (e.g. a + * missing `server/agents/*.ts` entry in the tsdown config), which would + * otherwise leave the app running with silently-missing agents. + */ + private async loadCodeAgents(): Promise> { + const resolved = this.resolveCodeAgentsDir(); + if (!resolved) return {}; + + const discovered = await loadCodeAgentsFromDir(resolved.dir, { + extensions: resolved.extensions, + }); + + if ( + resolved.isProduction && + Object.keys(discovered).length === 0 && + this.hasCodeAgentSources() + ) { + logger.warn( + "No code agents were loaded from %s in this production build, but source files exist in %s. " + + "The bundler may not have emitted them — ensure `server/agents/*.ts` is included as tsdown entries so `dist/agents/*.js` are produced.", + resolved.dir, + path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), + ); + } + + return discovered; + } + + /** + * Resolves which directory to scan for code agents and which extensions to + * look for. + * + * - `codeAgentsDir: false` disables code-agent discovery. + * - A string override is used verbatim (accepts any of the module + * extensions) — used by tests and by apps with a non-standard layout. + * - Otherwise the convention: a production build runs compiled output, so + * prefer `dist/agents/*.js`; dev runs sources under `tsx`, so prefer + * `server/agents/*.ts`. The `NODE_ENV` check is what keeps a stale + * `dist/` from a previous build from shadowing live sources in dev. The + * non-preferred location is a fallback if the preferred one is absent. + */ + private resolveCodeAgentsDir(): { + dir: string; + extensions: string[]; + isProduction: boolean; + } | null { + const isProduction = process.env.NODE_ENV === "production"; + if (this.config.codeAgentsDir === false) return null; + + if (typeof this.config.codeAgentsDir === "string") { + const dir = path.isAbsolute(this.config.codeAgentsDir) + ? this.config.codeAgentsDir + : path.resolve(process.cwd(), this.config.codeAgentsDir); + return { dir, extensions: [".ts", ".tsx", ".js", ".mjs"], isProduction }; + } + + const source = { + dir: path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), + extensions: [".ts", ".tsx"], + isProduction, + }; + const built = CODE_AGENTS_BUILT_DIRS.map((d) => ({ + dir: path.resolve(process.cwd(), d), + extensions: [".js", ".mjs"], + isProduction, + })); + // Built dirs first, source last — flipped in dev so a stale dist/ or + // build/ from a previous compile can't shadow live sources under tsx. + const order = isProduction ? [...built, source] : [source, ...built]; + for (const candidate of order) { + if (existsSync(candidate.dir)) return candidate; + } + // Nothing exists yet: target the preferred location so a downstream + // ENOENT resolves to an empty registry (and the prod warning can fire). + return order[0]; + } + + /** True when the code-agent source dir has at least one `.ts`/`.tsx` file. */ + private hasCodeAgentSources(): boolean { + const srcDir = path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); + try { + return readdirSync(srcDir).some( + (f) => + (f.endsWith(".ts") || f.endsWith(".tsx")) && !f.endsWith(".d.ts"), + ); + } catch { + return false; + } + } + + private async loadFileDefinitions( + codeAgents: Record, + ): Promise<{ defs: Record; defaultAgent: string | null; }> { @@ -414,7 +605,10 @@ export class AgentsPlugin extends Plugin implements ToolProvider { defaultModel: this.config.defaultModel, availableTools: ambient, plugins: pluginToolProviders, - codeAgents: this.config.agents, + // Discovered code agents + the deprecated map both resolve markdown + // `agents:` sub-agent references, so a markdown parent can delegate to + // a discovered code child (e.g. planner → helper). + codeAgents, }); return result; diff --git a/packages/appkit/src/plugins/agents/tests/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts new file mode 100644 index 000000000..b262ae749 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentAdapter, AgentInput, AgentRunContext } from "shared"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { CacheManager } from "../../../cache"; +import type { AgentsPluginConfig } from "../../../core/agent/types"; +import { AgentsPlugin } from "../agents"; + +/** Absolute path to a committed code-agent fixture directory. */ +const fixtureDir = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +function stubAdapter(): AgentAdapter { + return { + async *run(_input: AgentInput, _ctx: AgentRunContext) { + yield { type: "message_delta", content: "" }; + }, + }; +} + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-discovery-")); + // Agent setup reads the cache singleton; initialize it with defaults. + await CacheManager.getInstance(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function instantiate(config: AgentsPluginConfig) { + const plugin = new AgentsPlugin({ ...config, name: "agent" }); + plugin.attachContext({ context: undefined as unknown as object }); + return plugin; +} + +function writeMarkdownAgent(dir: string, id: string, content: string) { + const folder = path.join(dir, id); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync(path.join(folder, "agent.md"), content, "utf-8"); +} + +type ExportsApi = { + list: () => string[]; + get: (name: string) => { toolIndex: Map } | null; + getDefault: () => string | null; +}; + +describe("AgentsPlugin code-agent discovery", () => { + test("discovers code agents from the dir with no map at the call site", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + // notAnAgent.ts is skipped; builder + helper are discovered. + expect(api.list().sort()).toEqual(["builder", "helper"]); + expect(api.getDefault()).toBe("builder"); + }); + + test("honors default: true on a discovered agent", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("a discovered default: true beats markdown default: true", async () => { + writeMarkdownAgent(tmpDir, "planner", "---\ndefault: true\n---\nPlan."); + const plugin = instantiate({ + dir: tmpDir, + codeAgentsDir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("explicit defaultAgent overrides a discovered default: true", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents-default"), + defaultAgent: "alpha", + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("alpha"); + }); + + test("a markdown parent can delegate to a discovered code sub-agent", async () => { + writeMarkdownAgent( + tmpDir, + "planner", + "---\ndefault: true\nagents:\n - helper\n---\nPlan.", + ); + const plugin = instantiate({ + dir: tmpDir, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["builder", "helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + expect(api.getDefault()).toBe("planner"); + }); + + test("throws when a discovered id collides with the deprecated map", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + agents: { helper: { instructions: "map", model: stubAdapter() } }, + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow( + /both discovered .* and passed to agents\(\{ agents/, + ); + }); + + test("throws when a discovered id collides with a markdown agent", async () => { + writeMarkdownAgent(tmpDir, "helper", "---\n---\nFrom markdown."); + const plugin = instantiate({ + dir: tmpDir, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow( + /both a code agent .* and a markdown agent/, + ); + }); + + test("emits a one-time deprecation warning for agents({ agents }) and none for discovery", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const deprecated = instantiate({ + dir: false, + codeAgentsDir: false, + agents: { legacy: { instructions: "x", model: stubAdapter() } }, + }); + await deprecated.setup(); + await deprecated.reload(); // must not re-warn + + const deprecationWarnings = warnSpy.mock.calls + .map((args) => args.join(" ")) + .filter((s) => s.includes("agents: { ... } }) is deprecated")); + expect(deprecationWarnings).toHaveLength(1); + + warnSpy.mockClear(); + const discoveredPlugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await discoveredPlugin.setup(); + + const discoveryWarnings = warnSpy.mock.calls + .map((args) => args.join(" ")) + .filter((s) => s.includes("is deprecated")); + expect(discoveryWarnings).toHaveLength(0); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts new file mode 100644 index 000000000..5e4328357 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "alpha" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts new file mode 100644 index 000000000..d866cd68e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "beta", default: true }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts new file mode 100644 index 000000000..9b12ab541 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "from ts" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx new file mode 100644 index 000000000..07b04b878 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "from tsx" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts new file mode 100644 index 000000000..e10e1465e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts @@ -0,0 +1,3 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export const a = createAgent({ instructions: "a" }); +export const b = createAgent({ instructions: "b" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts new file mode 100644 index 000000000..f8893c5e9 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "I build." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts new file mode 100644 index 000000000..f3a7a1a88 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export const helper = createAgent({ instructions: "I help." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts new file mode 100644 index 000000000..2a04d9777 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts @@ -0,0 +1,2 @@ +// A helper module that is not an agent — the loader must skip it. +export const CONSTANT = 42; diff --git a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts new file mode 100644 index 000000000..0d7bf7792 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -0,0 +1,41 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { loadCodeAgentsFromDir } from "../../../core/agent/load-code-agents"; + +const fixtureDir = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +const TS = { extensions: [".ts", ".tsx"] }; + +describe("loadCodeAgentsFromDir", () => { + it("returns an empty record when the directory does not exist", async () => { + expect( + await loadCodeAgentsFromDir(fixtureDir("does-not-exist"), TS), + ).toEqual({}); + }); + + it("discovers default and named agent exports, id = filename", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + expect(Object.keys(agents).sort()).toEqual(["builder", "helper"]); + expect(agents.builder.instructions).toBe("I build."); + expect(agents.helper.instructions).toBe("I help."); + }); + + it("skips modules that export no created agent", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + // notAnAgent.ts exports a plain constant — must not be registered. + expect(agents.notAnAgent).toBeUndefined(); + }); + + it("throws when one file exports more than one agent", async () => { + await expect( + loadCodeAgentsFromDir(fixtureDir("code-agents-multi"), TS), + ).rejects.toThrow(/exports 2 created agents/); + }); + + it("throws on a duplicate id across .ts and .tsx", async () => { + await expect( + loadCodeAgentsFromDir(fixtureDir("code-agents-dup"), TS), + ).rejects.toThrow(/Duplicate code-agent id 'dup'/); + }); +}); diff --git a/template/server/agents/helper.ts b/template/server/agents/helper.ts index 47a69f00a..7ef751ea6 100644 --- a/template/server/agents/helper.ts +++ b/template/server/agents/helper.ts @@ -3,13 +3,15 @@ import { createAgent, tool } from '@databricks/appkit/beta'; import { z } from 'zod'; /** - * Code-defined helper agent: holds the tools. Shipped as a sub-agent of - * the user-facing `planner` markdown agent (which references it via - * `agents: [helper]` in its frontmatter) rather than a chat-tab on its - * own. When the user asks planner for a computational action — "what - * time is it?", "count the words in this string" — planner calls the - * `agent-helper` tool, the agents plugin routes the sub-agent - * invocation here, and the answer flows back into the planner thread. + * Code-defined helper agent: holds the tools. This file lives in + * `server/agents/`, so `appkit generate-agents` discovers it automatically — + * its agent id is the filename (`helper`), and nothing needs to restate it. + * Shipped as a sub-agent of the user-facing `planner` markdown agent (which + * references it via `agents: [helper]` in its frontmatter) rather than a + * chat-tab on its own. When the user asks planner for a computational action — + * "what time is it?", "count the words in this string" — planner calls the + * `agent-helper` tool, the agents plugin routes the sub-agent invocation here, + * and the answer flows back into the planner thread. * * Two reasons to keep this code-defined instead of folding it into the * markdown: @@ -26,8 +28,7 @@ import { z } from 'zod'; * volumes, no external APIs) so the round-trip works on a bare * scaffold regardless of which other plugins were selected. */ -export const helper = createAgent({ - name: 'helper', +export default createAgent({ instructions: [ 'You are a tool-using helper agent.', 'When the user asks about the time, call `current_time`.', diff --git a/template/server/server.ts b/template/server/server.ts index 47f8f9c1c..b33bb94d8 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -15,18 +15,11 @@ import { {{$betaImports}} } from '@databricks/appkit/beta'; {{- if .plugins.lakebase}} import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes'; {{- end}} -{{- if .plugins.agents}} -import { helper } from './agents/helper'; -{{- end}} createApp({ plugins: [ {{- range $name, $_ := .plugins}} -{{- if eq $name "agents"}} - agents({ agents: { helper } }), -{{- else}} {{$name}}(), -{{- end}} {{- end}} ], {{- if .plugins.lakebase}} diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index 759b79d00..ed0f4f784 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: 'server/server.ts', + // Code agents live in server/agents/ and are auto-discovered at runtime. + // They are not statically imported anywhere, so they are listed as entries + // here to force the bundler to emit dist/agents/*.js for the discovery scan. + entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json',