diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index 7af74ef0e..b1a8bf9f2 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/*/agent.ts' && cd client && npm run build", + "build:server": "tsdown --out-dir build server/index.ts 'server/agents/*/agent.ts'", "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", diff --git a/apps/dev-playground/config/agents/anomaly/agent.md b/apps/dev-playground/server/agents/anomaly/agent.md similarity index 100% rename from apps/dev-playground/config/agents/anomaly/agent.md rename to apps/dev-playground/server/agents/anomaly/agent.md diff --git a/apps/dev-playground/config/agents/autocomplete/agent.md b/apps/dev-playground/server/agents/autocomplete/agent.md similarity index 100% rename from apps/dev-playground/config/agents/autocomplete/agent.md rename to apps/dev-playground/server/agents/autocomplete/agent.md diff --git a/apps/dev-playground/server/agents/dashboard_pilot/agent.ts b/apps/dev-playground/server/agents/dashboard_pilot/agent.ts new file mode 100644 index 000000000..50a6cde1e --- /dev/null +++ b/apps/dev-playground/server/agents/dashboard_pilot/agent.ts @@ -0,0 +1,237 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Smart-Dashboard pilot: emits UI-action tool calls the client applies to the +// dashboard. A sub-agent of the markdown `query` dispatcher. +// +// 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/agent.ts b/apps/dev-playground/server/agents/helper/agent.ts new file mode 100644 index 000000000..2242e7559 --- /dev/null +++ b/apps/dev-playground/server/agents/helper/agent.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. Discovered automatically from +// server/agents/helper/ — its id is the folder name ("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/config/agents/insights/agent.md b/apps/dev-playground/server/agents/insights/agent.md similarity index 100% rename from apps/dev-playground/config/agents/insights/agent.md rename to apps/dev-playground/server/agents/insights/agent.md diff --git a/apps/dev-playground/config/agents/query/agent.md b/apps/dev-playground/server/agents/query/agent.md similarity index 100% rename from apps/dev-playground/config/agents/query/agent.md rename to apps/dev-playground/server/agents/query/agent.md diff --git a/apps/dev-playground/server/agents/sql_analyst/agent.ts b/apps/dev-playground/server/agents/sql_analyst/agent.ts new file mode 100644 index 000000000..ab149558a --- /dev/null +++ b/apps/dev-playground/server/agents/sql_analyst/agent.ts @@ -0,0 +1,16 @@ +import { createAgent } from "@databricks/appkit/beta"; + +// Smart-Dashboard specialist: writes Databricks SQL against +// `samples.nyctaxi.trips`. A sub-agent of the markdown `query` dispatcher. +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/agent.ts b/apps/dev-playground/server/agents/supervisor/agent.ts new file mode 100644 index 000000000..1449a4337 --- /dev/null +++ b/apps/dev-playground/server/agents/supervisor/agent.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..2ef35fc7c 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). + // Every agent lives under server/agents// — code agents as agent.ts + // (helper, supervisor, sql_analyst, dashboard_pilot), markdown agents as + // agent.md (query, insights, anomaly, autocomplete). `query` (markdown + // dispatcher) delegates to the code `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 landing agent). defaultAgent: "helper", }), aiSearch({ diff --git a/docs/docs/api/appkit/Function.createAgent.md b/docs/docs/api/appkit/Function.createAgent.md index 61064e512..a51e0c57d 100644 --- a/docs/docs/api/appkit/Function.createAgent.md +++ b/docs/docs/api/appkit/Function.createAgent.md @@ -4,13 +4,11 @@ function createAgent(def: AgentDefinition): AgentDefinition; ``` -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)`. +Pure factory for agent definitions: cycle-detects the sub-agent graph and +returns the same object, stamped with a non-enumerable AGENT\_BRAND +so discovery recognizes it. Safe at module top-level; no adapter is built. +Don't `Object.freeze` the definition before passing it in — the brand is +written onto the argument. ## Parameters diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 8996e759c..9ce1f9bc5 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 @@ -100,7 +113,7 @@ optional name: string; Stable identifier for the agent. **Optional and informational** — when the definition is registered via `agents: { foo: def }` (code) or -lives at `config/agents//agent.md` (markdown), the **registry key +lives at `server/agents//agent.md` (markdown), the **registry key always wins** and `name` is ignored. The agent will be reachable as `foo` (or ``) regardless of what this field contains. diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index c038d41c1..b51037a9e 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -14,13 +14,21 @@ 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 folder under +`server/agents//agent.ts` (`export default createAgent({ ... })`); it is +discovered automatically at startup 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. If both discovery and this map define the same id, discovery wins +and the map entry is ignored. *** @@ -112,7 +120,16 @@ Default model for agents that don't specify their own (in code or frontmatter). optional dir: string | false; ``` -Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. +Unified agents root. Each `/` folder holds either `agent.md` (markdown) +or `agent.ts` (code, `export default createAgent({ ... })`); the folder +name is the agent id. Default `server/agents` — leave unset. In a built +server, code agents are loaded from the compiled output (`dist/` / +`build/`, where `` is this dir's basename) and markdown is read +from source. A **relative** custom path is resolved this way too; an +**absolute** path is scanned verbatim (you own compiling it for prod). Set +to `false` to disable all file discovery — including the `config/agents/` +fallback below. Markdown still under `config/agents/` is otherwise read as a +deprecated fallback (one-time warning). *** diff --git a/docs/docs/api/appkit/Variable.agents.md b/docs/docs/api/appkit/Variable.agents.md index 227a5bf3b..4e83e1ddb 100644 --- a/docs/docs/api/appkit/Variable.agents.md +++ b/docs/docs/api/appkit/Variable.agents.md @@ -4,10 +4,12 @@ const agents: ToPlugin; ``` -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 `POST /invocations` and `POST /responses` (aliased -non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). +Plugin factory for the agents plugin. Discovers agents from +`server/agents//agent.{ts,md}` by default (markdown still in +`config/agents/` is read as a deprecated fallback), resolves toolkits/tools +from registered plugins, exposes the `appkit.agents.*` runtime API and mounts +`POST /invocations` and `POST /responses` (aliased non-streaming invoke +endpoints) plus `POST /chat` (streaming, HITL-capable). ## Example diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f39a52db2..c720277b8 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -127,7 +127,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | Variable | Description | | ------ | ------ | -| [agents](Variable.agents.md) | 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 `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | +| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | @@ -142,7 +142,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [agentIdFromMarkdownPath](Function.agentIdFromMarkdownPath.md) | Derives the logical agent id from a markdown path. When the file is named `agent.md`, the id is the parent directory name (folder-based layout); otherwise the id is the file stem (e.g. legacy single-file paths). | | [appKitServingTypesPlugin](Function.appKitServingTypesPlugin.md) | Vite plugin to generate TypeScript types for AppKit serving endpoints. Fetches OpenAPI schemas from Databricks and generates a .d.ts with ServingEndpointRegistry module augmentation. | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | -| [createAgent](Function.createAgent.md) | 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. | +| [createAgent](Function.createAgent.md) | Pure factory for agent definitions: cycle-detects the sub-agent graph and returns the same object, stamped with a non-enumerable AGENT\_BRAND so discovery recognizes it. Safe at module top-level; no adapter is built. Don't `Object.freeze` the definition before passing it in — the brand is written onto the argument. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index d1b7a79e4..e5385d7d1 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 — one folder per agent under `server/agents/`, holding either `agent.md` (markdown) or `agent.ts` (code) — 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 every case the agent's id is its 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). @@ -37,14 +37,15 @@ That alone gives you a live HTTP server with `POST /invocations` (and its alias ## Level 1: drop a markdown agent package -Each agent lives in its own directory with a fixed entry file `agent.md`. A reserved top-level folder named `skills` is ignored until per-agent skills ship (you can add other asset folders beside `agent.md` under each agent id). +Each agent lives in its own folder under `server/agents/` with entry file `agent.md`. A folder is an agent only if it holds an entry file (`agent.md` or `agent.ts`); a folder without one is skipped, so per-agent asset folders like `skills/` sit beside the entry. ``` my-app/ - server.ts - config/agents/ - assistant/ - agent.md + server/ + server.ts + agents/ + assistant/ + agent.md ``` ```md @@ -60,13 +61,17 @@ Use the available tools to query data, browse files, and help users. On startup the plugin: -1. Discovers `./config/agents/assistant/agent.md` and registers agent id `assistant`. +1. Discovers `server/agents/assistant/agent.md` and registers agent id `assistant`. 2. Parses the YAML frontmatter and markdown body as the agent's `instructions`. 3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_AGENT_ENDPOINT`). 4. Mounts the agent at the default name (`assistant`). The agent starts with **no tools**. Tools are opt-in — declare them in frontmatter (Level 2 below) or opt into auto-inherit explicitly with `agents({ autoInheritTools: { file: true } })`. See "Auto-inherit posture" further down for what that costs and why it's off by default. +:::note Migrating from `config/agents/` +Earlier versions kept markdown agents under `config/agents//agent.md`. That location is still read as a deprecated fallback (one-time warning on boot); move each folder to `server/agents//agent.md` so every agent — markdown and code — lives in one place. +::: + Requests land at `POST /invocations` (or its alias `POST /responses`) with an OpenAI Responses-compatible body. These endpoints run the agent to completion and return a single JSON response — no SSE. Streaming clients should use `POST /chat`. Every tool call runs through `asUser(req)` so SQL executes as the requesting user, file access respects Unity Catalog ACLs, and telemetry spans are created automatically. :::warning No HITL on `/invocations` and `/responses` @@ -100,12 +105,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-folder under `server/agents/`, with entry file `agent.ts` (mirroring markdown's `agent.md`). The entry exports a created agent and its **id is the folder name** (`server/agents/support/agent.ts` → `support`). Nothing restates the id. + ```ts -import { analytics, createApp, files, server } from "@databricks/appkit"; -import { agents, createAgent, tool } from "@databricks/appkit/beta"; +// server/agents/support/agent.ts +import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; -const support = createAgent({ +export default createAgent({ // id derived from folder name: "support" instructions: "You help customers with data and files.", model: "databricks-claude-sonnet-4-5", // string sugar tools(plugins) { @@ -120,18 +127,38 @@ 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 imports each `server/agents//agent.ts` — the source `.ts` under `tsx` in dev, and the compiled `dist/agents//agent.js` in a production build (built output wins over source, independent of `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/*/agent.ts` as build entries so `dist/agents/*/agent.js` are emitted for the scan — that wiring is what lets a dropped-in folder survive the prod bundle. (Markdown `agent.md` is read from source in both dev and prod — it's data, not compiled.) The root is `server/agents` by default; override with `agents({ dir })` (a relative path is resolved built-first the same way; an absolute path is scanned verbatim; `false` disables discovery, including the `config/agents/` fallback). + +:::note Built output shadows source in dev +Because compiled output wins over source, a stale `dist/agents` / `build/agents` left over from a previous `npm run build` will be picked up by `npm run dev` instead of your live `server/agents/*.ts`, so edits appear ignored. Delete the build dir (or re-run the build) if a code agent seems frozen. Markdown is always read from source, so `agent.md` edits are never shadowed. +::: + +The entry may `export default createAgent({...})` or export a single named created agent; either way the id is the folder name. A folder whose entry exports no created agent (or has no `agent.ts`/`agent.md` at all) 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//agent.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 +194,15 @@ const supervisor = createAgent({ agents: { researcher, writer }, // exposed as agent-researcher, agent-writer }); +// server/agents/{supervisor,researcher,writer}/agent.ts — one folder 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//agent.ts` folders (default export each) — a markdown parent can also delegate to a code child in a sibling folder 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 +378,8 @@ 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, // agents root; "server/agents" default; false disables (config/agents read as deprecated fallback) + 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..67c589317 100644 --- a/packages/appkit/src/core/agent/create-agent.ts +++ b/packages/appkit/src/core/agent/create-agent.ts @@ -2,13 +2,23 @@ import { ConfigurationError } from "../../errors"; import type { AgentDefinition } from "./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. + * 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. * - * 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)`. + * 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: cycle-detects the sub-agent graph and + * returns the same object, stamped with a non-enumerable {@link AGENT_BRAND} + * so discovery recognizes it. Safe at module top-level; no adapter is built. + * Don't `Object.freeze` the definition before passing it in — the brand is + * written onto the argument. * * @example * ```ts @@ -23,9 +33,27 @@ import type { AgentDefinition } from "./types"; */ export function createAgent(def: AgentDefinition): AgentDefinition { detectCycles(def); + // Non-enumerable + in-place: identity, JSON, and spread are unaffected. + 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-agents.ts b/packages/appkit/src/core/agent/load-agents.ts index 5f535cafc..4a2914bad 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -204,13 +204,11 @@ export async function loadAgentsFromDir( ); } - /** Reserved folder name until per-agent skills land; not an agent package. */ - const RESERVED_DIRS = new Set(["skills"]); - const agentIds = entries - .filter((e) => e.isDirectory()) + // Symlinked agent folders count; a symlink to a file is filtered out below + // when reading agent.md (ENOTDIR). + .filter((e) => e.isDirectory() || e.isSymbolicLink()) .map((e) => e.name) - .filter((name) => !RESERVED_DIRS.has(name)) .sort(); const defs: Record = {}; @@ -224,11 +222,10 @@ export async function loadAgentsFromDir( try { raw = await fs.readFile(agentPath, "utf-8"); } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error( - `Agents subdirectory '${path.join(dir, id)}' must contain agent.md.`, - ); - } + // No agent.md → a code-agent folder (agent.ts) or an asset dir (skills/); + // ENOTDIR → the entry is a symlink to a file, not an agent folder. + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") continue; throw err; } defs[id] = buildDefinition(id, raw, agentPath, ctx); 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..f959d6660 --- /dev/null +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -0,0 +1,181 @@ +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"); + +/** Where code agents live in source (a `tsx` dev run imports the `.ts`). */ +export const CODE_AGENTS_SOURCE_DIR = "server/agents"; +/** Compiled-output roots probed before source (tsdown emits into `dist`/`build`). */ +const CODE_AGENTS_BUILT_ROOTS = ["dist", "build"]; +/** Per-agent entry file, mirroring markdown's `agent.md`. */ +const ENTRY_BASENAME = "agent"; + +interface ResolvedCodeAgentsDir { + dir: string; + extensions: string[]; +} + +/** + * Resolves which directory to scan for code agents. Compiled output wins over + * source unconditionally, so a bundled server never `import()`s a `.ts` (plain + * Node can't load one): for a relative source dir the matching `dist/` / + * `build/` is probed first, with the source `.ts` dir as fallback. + * `override`: `false` disables discovery; a relative string is the source dir + * (still built-first); an absolute string is scanned verbatim (the caller pins + * the exact path and owns compiling it for a prod build). + */ +export function resolveCodeAgentsDir(opts: { + cwd: string; + override?: string | false; + exists: (dir: string) => boolean; +}): ResolvedCodeAgentsDir | null { + if (opts.override === false) return null; + if (typeof opts.override === "string" && path.isAbsolute(opts.override)) { + return { dir: opts.override, extensions: [".ts", ".tsx", ".js", ".mjs"] }; + } + + const sourceRel = opts.override ?? CODE_AGENTS_SOURCE_DIR; + const name = path.basename(sourceRel); + const source: ResolvedCodeAgentsDir = { + dir: path.resolve(opts.cwd, sourceRel), + extensions: [".ts", ".tsx"], + }; + const built: ResolvedCodeAgentsDir[] = CODE_AGENTS_BUILT_ROOTS.map( + (root) => ({ + dir: path.resolve(opts.cwd, root, name), + extensions: [".js", ".mjs"], + }), + ); + + for (const candidate of [...built, source]) { + if (opts.exists(candidate.dir)) return candidate; + } + return source; +} + +/** + * The `agent.` entry file inside an agent folder, or `null` if none — + * a markdown agent (`agent.md`) or a non-agent asset dir (`skills/`). + */ +async function findEntryFile( + agentDir: string, + extensions: string[], +): Promise { + let files: string[]; + try { + files = await fs.readdir(agentDir); + } catch { + return null; + } + for (const ext of extensions) { + const name = `${ENTRY_BASENAME}${ext}`; + if (files.includes(name)) return path.join(agentDir, name); + } + return null; +} + +/** + * The single created agent a module exports — the default export, else the one + * branded named export. `undefined` if none (a helper or bundler chunk); throws + * if the entry file exports more than one (the folder name is the id). + */ +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. ` + + "Export a single agent per folder (the folder name is its id).", + ); + } + return named[0][1] as AgentDefinition; +} + +/** + * Discovers code agents by importing each `/agent.` under `dir`; the + * agent's id is its folder name. Folders with no `agent.` (markdown + * agents, asset dirs) are skipped. Returns `{}` when `dir` is absent. + * + * Imports key on the plain `file://` URL, so `reload()` picks up added/removed + * folders but not edits to an already-imported one (that needs a restart). + */ +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 folders = entries + // Include symlinked agent folders; findEntryFile's readdir follows the link + // and returns null for anything that isn't a real directory. + .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .map((e) => e.name) + .sort(); + + const agents: Record = {}; + + for (const id of folders) { + const entryFile = await findEntryFile(path.join(dir, id), opts.extensions); + if (!entryFile) continue; + + let mod: Record; + try { + mod = (await import(pathToFileURL(entryFile).href)) as Record< + string, + unknown + >; + } catch (err) { + // No TS loader for these `.ts` modules — warn once and bail rather than + // crash boot (every folder would fail the same way). + if ( + (err as NodeJS.ErrnoException).code === "ERR_UNKNOWN_FILE_EXTENSION" + ) { + logger.warn( + "Cannot import code agents from %s under this runtime (no TypeScript loader). " + + "A production build must compile server/agents/ to JS — check the `server/agents/*/agent.ts` entry glob in the tsdown config. Discovered no code agents.", + dir, + ); + return {}; + } + throw new Error( + `Failed to import code agent '${entryFile}': ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err instanceof Error ? err : undefined }, + ); + } + + const agent = pickAgentExport(mod, entryFile); + if (!agent) { + logger.debug( + "Skipping %s — no createAgent export (not a code agent).", + entryFile, + ); + continue; + } + + 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/tests/load-agents.test.ts b/packages/appkit/src/core/agent/tests/load-agents.test.ts index de07e5be9..a9a3af1c1 100644 --- a/packages/appkit/src/core/agent/tests/load-agents.test.ts +++ b/packages/appkit/src/core/agent/tests/load-agents.test.ts @@ -182,14 +182,10 @@ describe("loadAgentsFromDir", () => { ); }); - test("throws when a subdirectory lacks agent.md", async () => { - fs.mkdirSync(path.join(workDir, "broken"), { recursive: true }); - await expect(loadAgentsFromDir(workDir, {})).rejects.toThrow( - /must contain agent\.md/, - ); - }); - - test("ignores reserved skills directory without agent.md", async () => { + test("skips a subdirectory that lacks agent.md", async () => { + // A folder with no agent.md is a code-agent folder (agent.ts) or an asset + // dir (skills/), not a markdown agent — skip it, don't throw. + fs.mkdirSync(path.join(workDir, "code-only"), { recursive: true }); fs.mkdirSync(path.join(workDir, "skills"), { recursive: true }); writeAgent("solo", "---\nendpoint: e\n---\nOnly real agent."); const res = await loadAgentsFromDir(workDir, {}); diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 572879565..b75a022d5 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -126,7 +126,7 @@ export interface AgentDefinition { /** * Stable identifier for the agent. **Optional and informational** — * when the definition is registered via `agents: { foo: def }` (code) or - * lives at `config/agents//agent.md` (markdown), the **registry key + * lives at `server/agents//agent.md` (markdown), the **registry key * always wins** and `name` is ignored. The agent will be reachable as * `foo` (or ``) regardless of what this field contains. * @@ -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; /** @@ -206,9 +213,28 @@ export interface AutoInheritToolsConfig { } export interface AgentsPluginConfig extends BasePluginConfig { - /** Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. */ + /** + * Unified agents root. Each `/` folder holds either `agent.md` (markdown) + * or `agent.ts` (code, `export default createAgent({ ... })`); the folder + * name is the agent id. Default `server/agents` — leave unset. In a built + * server, code agents are loaded from the compiled output (`dist/` / + * `build/`, where `` is this dir's basename) and markdown is read + * from source. A **relative** custom path is resolved this way too; an + * **absolute** path is scanned verbatim (you own compiling it for prod). Set + * to `false` to disable all file discovery — including the `config/agents/` + * fallback below. Markdown still under `config/agents/` is otherwise read as a + * deprecated fallback (one-time warning). + */ dir?: string | false; - /** Code-defined agents, merged with file-loaded ones (code wins on key collision). */ + /** + * @deprecated Put each code agent in its own folder under + * `server/agents//agent.ts` (`export default createAgent({ ... })`); it is + * discovered automatically at startup 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. If both discovery and this map define the same id, discovery wins + * and the map entry is ignored. + */ 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..f41b8a3d7 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,11 @@ import { import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; +import { + CODE_AGENTS_SOURCE_DIR, + loadCodeAgentsFromDir, + resolveCodeAgentsDir, +} from "../../core/agent/load-code-agents"; import { normalizeToolResult } from "../../core/agent/normalize-result"; import { createPluginsProxy } from "../../core/agent/plugins-map"; import { @@ -73,7 +79,8 @@ import { ToolApprovalGate } from "./tool-approval-gate"; const logger = createLogger("agents"); -const DEFAULT_AGENTS_DIR = "./config/agents"; +/** Deprecated markdown location, read as a fallback with a one-time warning. */ +const LEGACY_MARKDOWN_DIR = "config/agents"; /** * Context flag recorded on the in-memory AgentDefinition to indicate whether @@ -165,6 +172,10 @@ 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; + /** Guards the `config/agents` deprecation warning to once per instance. */ + private configAgentsDeprecationWarned = false; constructor(config: AgentsPluginConfig) { super(config); @@ -331,45 +342,81 @@ export class AgentsPlugin extends Plugin implements ToolProvider { agents: Map; defaultAgentName: string | null; }> { - const { defs: fileDefs, defaultAgent: fileDefault } = - await this.loadFileDefinitions(); + // Two "code" sources: discovered files and the deprecated `agents({ agents })` map. + const discovered = await this.loadCodeAgents(); + const deprecatedMapRaw = this.config.agents ?? {}; - const codeDefs = this.config.agents ?? {}; + if (Object.keys(deprecatedMapRaw).length > 0) { + this.warnAgentsMapDeprecated(); + } - for (const name of Object.keys(fileDefs)) { - if (codeDefs[name]) { + // On a discovered/map id clash, discovery wins (drop the map entry) rather + // than crash boot on upgrade. + const deprecatedMap: Record = {}; + for (const [id, def] of Object.entries(deprecatedMapRaw)) { + if (discovered[id]) { logger.warn( - "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", - name, + "Agent '%s' is both discovered in %s and passed to agents({ agents }). " + + "Using the discovered file; ignoring the map entry.", + id, + CODE_AGENTS_SOURCE_DIR, ); + continue; } + deprecatedMap[id] = def; } + // Code agents also resolve markdown `agents: [child]` sub-agent references. + const codeAgents: Record = { + ...discovered, + ...deprecatedMap, + }; + + const { defs: fileDefs, defaultAgent: fileDefault } = + await this.loadFileDefinitions(codeAgents); + + // Merge order (markdown, discovered, map) sets precedence and 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 — clash with markdown is a hard error (the + // deprecated map only warns). A folder with both agent.ts and + // agent.md lands here too: one kind per folder. + throw new Error( + `Agent '${name}' is defined as both a code agent (agent.ts) and a markdown agent (agent.md). ` + + `Keep one kind per folder. 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; 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; + agents.set(name, await this.buildRegisteredAgent(name, def, src)); } catch (err) { throw new Error( `Failed to register agent '${name}' (${src.origin}): ${ @@ -380,44 +427,177 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } } + return { + agents, + defaultAgentName: this.resolveDefaultAgent(agents, merged, fileDefault), + }; + } + + /** + * Resolves the default agent. Precedence: explicit `config.defaultAgent` > + * a code/discovered agent flagged `default: true` (stable id order) > + * markdown `default: true` > first registered (insertion order). + */ + private resolveDefaultAgent( + agents: Map, + merged: Record, + fileDefault: 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 agents.keys().next().value ?? null; + } + + /** + * 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 folder under " + + "server/agents//agent.ts (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.", + ); + } + + /** + * One-time deprecation warning for markdown agents still living in + * `config/agents/`. Guarded so `reload()` doesn't spam it. + */ + private warnConfigAgentsDeprecated(): void { + if (this.configAgentsDeprecationWarned) return; + this.configAgentsDeprecationWarned = true; + logger.warn( + "Markdown agents under config/agents/ are deprecated. Move each config/agents//agent.md to " + + "server/agents//agent.md — every agent now lives in one place (server/agents). config/agents " + + "is still read for now but will be removed in a future minor. See docs/plugins/agents.md.", + ); } private resolvedAgentsDir(): string | null { if (this.config.dir === false) return null; - const dir = this.config.dir ?? DEFAULT_AGENTS_DIR; + const dir = this.config.dir ?? CODE_AGENTS_SOURCE_DIR; return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); } - private async loadFileDefinitions(): Promise<{ + /** + * Discovers code agents (see {@link resolveCodeAgentsDir} and + * {@link loadCodeAgentsFromDir}). Warns if sources exist but nothing was + * discovered — usually the build didn't emit the compiled agents — unless + * the deprecated `agents({ agents })` map is carrying them instead. + */ + private async loadCodeAgents(): Promise> { + const resolved = resolveCodeAgentsDir({ + cwd: process.cwd(), + override: this.config.dir, + exists: existsSync, + }); + if (!resolved) return {}; + + const discovered = await loadCodeAgentsFromDir(resolved.dir, { + extensions: resolved.extensions, + }); + + const usingDeprecatedMap = Object.keys(this.config.agents ?? {}).length > 0; + const sourceDir = this.resolvedAgentsDir(); + if ( + Object.keys(discovered).length === 0 && + !usingDeprecatedMap && + sourceDir && + this.hasCodeAgentSources(sourceDir) + ) { + logger.warn( + "Found code-agent sources in %s but discovered no code agents (scanned %s). " + + "In a production build, ensure `/*/agent.ts` is included as tsdown entries so the compiled agents are emitted.", + sourceDir, + resolved.dir, + ); + } + + return discovered; + } + + /** True when `dir` holds at least one `/agent.{ts,tsx}` folder. */ + private hasCodeAgentSources(dir: string): boolean { + try { + return readdirSync(dir, { withFileTypes: true }).some((e) => { + if (!e.isDirectory() && !e.isSymbolicLink()) return false; + try { + return readdirSync(path.join(dir, e.name)).some( + (f) => f === "agent.ts" || f === "agent.tsx", + ); + } catch { + return false; + } + }); + } catch { + return false; + } + } + + private async loadFileDefinitions( + codeAgents: Record, + ): Promise<{ defs: Record; defaultAgent: string | null; }> { - const dir = this.resolvedAgentsDir(); - if (!dir) return { defs: {}, defaultAgent: null }; + const primaryDir = this.resolvedAgentsDir(); + if (!primaryDir) return { defs: {}, defaultAgent: null }; - const pluginToolProviders = this.pluginProviderIndex(); - const ambient = this.config.tools ?? {}; - - const result = await loadAgentsFromDir(dir, { + // Discovered code agents + the deprecated map resolve markdown `agents:` + // sub-agent references, so a markdown parent can delegate to a code child. + const baseCtx = { defaultModel: this.config.defaultModel, - availableTools: ambient, - plugins: pluginToolProviders, - codeAgents: this.config.agents, + availableTools: this.config.tools ?? {}, + plugins: this.pluginProviderIndex(), + }; + + // Configured dir already points at config/agents → single scan, no fallback. + const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); + if (primaryDir === legacyDir) { + return loadAgentsFromDir(primaryDir, { ...baseCtx, codeAgents }); + } + + // Deprecated fallback: scan config/agents first, then server/agents with the + // legacy defs added as resolvable sub-agent targets — so a parent already + // moved to server/agents can still reference a child left in config/agents + // mid-migration. Code agents keep precedence in resolution and in the final + // merge (server/agents wins on an id clash). + const legacy = await loadAgentsFromDir(legacyDir, { + ...baseCtx, + codeAgents, }); + const primary = await loadAgentsFromDir(primaryDir, { + ...baseCtx, + codeAgents: { ...legacy.defs, ...codeAgents }, + }); + + if (Object.keys(legacy.defs).length === 0) return primary; - return result; + this.warnConfigAgentsDeprecated(); + return { + defs: { ...legacy.defs, ...primary.defs }, + defaultAgent: primary.defaultAgent ?? legacy.defaultAgent, + }; } /** @@ -1963,10 +2143,12 @@ function warnOnCapabilityMismatch( } /** - * 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 `POST /invocations` and `POST /responses` (aliased - * non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). + * Plugin factory for the agents plugin. Discovers agents from + * `server/agents//agent.{ts,md}` by default (markdown still in + * `config/agents/` is read as a deprecated fallback), resolves toolkits/tools + * from registered plugins, exposes the `appkit.agents.*` runtime API and mounts + * `POST /invocations` and `POST /responses` (aliased non-streaming invoke + * endpoints) plus `POST /chat` (streaming, HITL-capable). * * @example * ```ts 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..12f4b1204 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -0,0 +1,259 @@ +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 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: "" }; + }, + }; +} + +beforeEach(async () => { + // Agent setup reads the cache singleton; initialize it with defaults. + await CacheManager.getInstance(); +}); + +function instantiate(config: AgentsPluginConfig) { + const plugin = new AgentsPlugin({ ...config, name: "agent" }); + plugin.attachContext({ context: undefined as unknown as object }); + return plugin; +} + +type ExportsApi = { + list: () => string[]; + get: (name: string) => { toolIndex: Map } | null; + getDefault: () => string | null; +}; + +describe("AgentsPlugin agent discovery", () => { + test("discovers code agents from the dir with no map at the call site", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + // notAnAgent/ exports no created agent and is skipped. + expect(api.list().sort()).toEqual(["builder", "helper"]); + expect(api.getDefault()).toBe("builder"); + }); + + test("honors default: true on a discovered code agent", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("a discovered code default: true beats a markdown default: true", async () => { + // code-agents-default holds beta (code, default:true) + planner (markdown, + // default:true) side by side; code wins. + const plugin = instantiate({ + dir: 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: 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 code sub-agent in a sibling folder", async () => { + const plugin = instantiate({ + dir: fixtureDir("md-parent-code-child"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + expect(api.getDefault()).toBe("planner"); + }); + + test("discovery wins over a colliding deprecated-map entry (warns, no throw)", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const plugin = instantiate({ + dir: fixtureDir("code-agents"), + agents: { + helper: { instructions: "from the map", model: stubAdapter() }, + }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["builder", "helper"]); + // The discovered agent (instructions "I help.") wins over the map entry. + const helper = api.get("helper") as { instructions: string } | null; + expect(helper?.instructions).toBe("I help."); + const warned = warnSpy.mock.calls + .map((a) => a.join(" ")) + .some( + (s) => + s.includes("both discovered") && s.includes("ignoring the map entry"), + ); + expect(warned).toBe(true); + warnSpy.mockRestore(); + }); + + test("a map-only app with discovery disabled works unchanged (no throw)", async () => { + const plugin = instantiate({ + dir: false, + agents: { + legacy: { instructions: "map agent", model: stubAdapter() }, + }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + const api = plugin.exports() as ExportsApi; + expect(api.list()).toEqual(["legacy"]); + expect(api.getDefault()).toBe("legacy"); + }); + + test("throws when defaultAgent names an unregistered agent", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents"), + defaultAgent: "nope", + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow(/is not registered/); + }); + + test("throws when a folder holds both agent.ts and agent.md", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-md-collision"), + 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, + 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: 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(); + }); +}); + +// The config/agents fallback is cwd-relative (path.resolve(cwd, "config/agents")), +// so these run in a temp cwd holding both roots rather than pointing `dir` at a +// fixture. +describe("AgentsPlugin config/agents deprecated fallback", () => { + let tmp: string; + let priorCwd: string; + + const write = (rel: string, content: string) => { + const p = path.join(tmp, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, "utf-8"); + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agents-fallback-")); + priorCwd = process.cwd(); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(priorCwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test("merges config/agents markdown with server/agents (server wins) and warns once", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + write("config/agents/legacy/agent.md", "---\n---\nLegacy only."); + write("config/agents/shared/agent.md", "---\n---\nFrom config (old)."); + write("server/agents/shared/agent.md", "---\n---\nFrom server (new)."); + + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + await plugin.reload(); // must not re-warn + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["legacy", "shared"]); + const shared = api.get("shared") as { instructions: string } | null; + expect(shared?.instructions).toContain("From server (new)."); + + const warns = warnSpy.mock.calls + .map((a) => a.join(" ")) + .filter((s) => s.includes("config/agents/ are deprecated")); + expect(warns).toHaveLength(1); + warnSpy.mockRestore(); + }); + + test("a server/agents parent resolves a sub-agent still in config/agents", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + write("config/agents/helper/agent.md", "---\n---\nI help."); + write( + "server/agents/planner/agent.md", + "---\ndefault: true\nagents:\n - helper\n---\nPlan.", + ); + + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + warnSpy.mockRestore(); + }); + + test("dir:false disables the config/agents fallback too", async () => { + write("config/agents/legacy/agent.md", "---\n---\nLegacy only."); + const plugin = instantiate({ + dir: false, + agents: { mapped: { instructions: "map", model: stubAdapter() } }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).list()).toEqual(["mapped"]); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.ts new file mode 100644 index 000000000..df32e67d0 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.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/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta/agent.ts new file mode 100644 index 000000000..e48de4fc2 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta/agent.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-default/planner/agent.md b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/planner/agent.md new file mode 100644 index 000000000..123c74863 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/planner/agent.md @@ -0,0 +1,4 @@ +--- +default: true +--- +Plan. diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts new file mode 100644 index 000000000..b51a6ef45 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.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/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.ts new file mode 100644 index 000000000..85951585e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.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/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.ts new file mode 100644 index 000000000..9d2eeabd1 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.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/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent/agent.ts new file mode 100644 index 000000000..fc656a044 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent/agent.ts @@ -0,0 +1,2 @@ +// agent.ts that exports no created agent — the loader must skip this folder. +export const CONSTANT = 42; diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md new file mode 100644 index 000000000..f8b6e48a3 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md @@ -0,0 +1,3 @@ +--- +--- +From markdown. diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts new file mode 100644 index 000000000..aae12931f --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "from code" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.ts new file mode 100644 index 000000000..9d2eeabd1 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.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/md-parent-code-child/planner/agent.md b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/planner/agent.md new file mode 100644 index 000000000..fc3a48483 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/planner/agent.md @@ -0,0 +1,6 @@ +--- +default: true +agents: + - helper +--- +Plan. 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..1a4889252 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -0,0 +1,125 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + loadCodeAgentsFromDir, + resolveCodeAgentsDir, +} 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 = folder name", 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 folders whose entry file exports no created agent", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + // notAnAgent/agent.ts exports a plain constant — must not be registered. + expect(agents.notAnAgent).toBeUndefined(); + }); + + it("skips folders that have no agent entry file (markdown / asset dirs)", async () => { + // md-parent-code-child/planner has only agent.md; the code loader ignores it. + const agents = await loadCodeAgentsFromDir( + fixtureDir("md-parent-code-child"), + TS, + ); + expect(Object.keys(agents)).toEqual(["helper"]); + }); + + it("throws when one entry file exports more than one agent", async () => { + await expect( + loadCodeAgentsFromDir(fixtureDir("code-agents-multi"), TS), + ).rejects.toThrow(/exports 2 created agents/); + }); +}); + +describe("resolveCodeAgentsDir", () => { + const cwd = "/app"; + const dist = path.resolve(cwd, "dist/agents"); + const build = path.resolve(cwd, "build/agents"); + const source = path.resolve(cwd, "server/agents"); + const existsIn = + (...present: string[]) => + (dir: string) => + present.includes(dir); + + it("returns null when discovery is disabled", () => { + expect( + resolveCodeAgentsDir({ cwd, override: false, exists: () => true }), + ).toBeNull(); + }); + + it("scans an absolute override verbatim, accepting any module extension", () => { + expect( + resolveCodeAgentsDir({ + cwd, + override: "/abs/agents", + exists: () => true, + }), + ).toEqual({ + dir: "/abs/agents", + extensions: [".ts", ".tsx", ".js", ".mjs"], + }); + }); + + it("resolves a relative override built-first (dist/ over source)", () => { + const customDist = path.resolve(cwd, "dist/my-agents"); + const customSrc = path.resolve(cwd, "my-agents"); + expect( + resolveCodeAgentsDir({ + cwd, + override: "my-agents", + exists: existsIn(customDist, customSrc), + }), + ).toEqual({ dir: customDist, extensions: [".js", ".mjs"] }); + // No built output → falls back to the source `.ts` dir. + expect( + resolveCodeAgentsDir({ cwd, override: "my-agents", exists: () => false }), + ).toEqual({ dir: customSrc, extensions: [".ts", ".tsx"] }); + }); + + it("stays built-first when dir is set to the default value explicitly", () => { + // Regression: `agents({ dir: "server/agents" })` must NOT bypass built-first + // (previously a string override was scanned verbatim → prod loaded .ts). + const r = resolveCodeAgentsDir({ + cwd, + override: "server/agents", + exists: existsIn(dist, source), + }); + expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); + }); + + it("prefers compiled dist/agents (.js) over source — built wins", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(dist, source) }); + expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); + }); + + it("falls back to build/agents when dist/agents is absent", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(build, source) }); + expect(r).toEqual({ dir: build, extensions: [".js", ".mjs"] }); + }); + + it("uses server/agents (.ts) only when no built dir exists", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(source) }); + expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + }); + + it("targets server/agents when nothing exists (empty scan downstream)", () => { + const r = resolveCodeAgentsDir({ cwd, exists: () => false }); + expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + }); +}); diff --git a/template/client/src/pages/agents/AgentChat.tsx b/template/client/src/pages/agents/AgentChat.tsx index 5d1e5758d..115230197 100644 --- a/template/client/src/pages/agents/AgentChat.tsx +++ b/template/client/src/pages/agents/AgentChat.tsx @@ -34,11 +34,11 @@ interface AgentsClientConfig { * The template ships a single coordinator agent and uses the agents * plugin's sub-agent feature to compose two authoring forms behind it: * - * - `planner` (markdown, `config/agents/planner/agent.md`) is the + * - `planner` (markdown, `server/agents/planner/agent.md`) is the * user-facing chat: pure prose, no tools, opinionated planning * prompt. Declares `agents: [helper]` in its frontmatter so it * can delegate computational actions. - * - `helper` (code, `server/agents/helper.ts`) holds the tools + * - `helper` (code, `server/agents/helper/agent.ts`) holds the tools * (`current_time`, `count_words`). It's reachable from planner as * the `agent-helper` tool; planner calls it when the user * explicitly asks for a side-effecty action. @@ -132,10 +132,10 @@ export function AgentChat() {

You're talking to planner, a markdown agent at - config/agents/planner/agent.md. + server/agents/planner/agent.md. For computational actions it delegates to its sub-agent helper (code-defined at - server/agents/helper.ts), which + server/agents/helper/agent.ts), which surfaces as an agent-helper tool call.

diff --git a/template/server/agents/helper.ts b/template/server/agents/helper/agent.ts similarity index 71% rename from template/server/agents/helper.ts rename to template/server/agents/helper/agent.ts index 47a69f00a..87603daeb 100644 --- a/template/server/agents/helper.ts +++ b/template/server/agents/helper/agent.ts @@ -3,13 +3,16 @@ 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/helper/`, so the agents plugin discovers it automatically at + * startup — its agent id is the folder name (`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 +29,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/config/agents/planner/agent.md b/template/server/agents/planner/agent.md similarity index 100% rename from template/config/agents/planner/agent.md rename to template/server/agents/planner/agent.md 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..ac236e108 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,8 +1,11 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: 'server/server.ts', + // server/agents/*/agent.ts aren't imported anywhere; list them as entries so the build emits dist/agents/*/agent.js for runtime discovery. + entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*/agent.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, + // Clear the out dir so a deleted agent can't linger in dist/agents/. + clean: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json', outExtensions: () => ({