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 0435fad4e..eb88c2a97 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"; @@ -57,314 +49,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 @@ -422,13 +106,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..2c9b5bb27 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -22,6 +22,20 @@ 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, a code (discovered) agent wins over a markdown one, then the +lowest id; an explicit `agents({ defaultAgent })` always overrides it. +Defaults to `false`. + +*** + ### ephemeral? ```ts @@ -100,7 +114,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..59d7d1220 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. *** @@ -37,7 +45,7 @@ Human-in-the-loop approval gate for mutating tool calls. When enabled (the default), the agents plugin emits an `appkit.approval_pending` SSE event before executing any tool whose annotation flags it as mutating — `effect: "write" | "update" | "destructive"` (preferred) or the legacy -`destructive: true` boolean — and waits for a `POST /chat/approve` +`destructive: true` boolean — and waits for a `POST /api/agents/approve` decision from the same user who initiated the stream. A missing decision after `timeoutMs` auto-denies the call. @@ -89,7 +97,7 @@ Customize or disable the AppKit base system prompt. optional defaultAgent: string; ``` -Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter. +Agent used when clients don't specify one. Precedence: this value, else a code agent with `default: true`, else a markdown agent with `default: true`, else the first-registered agent. *** @@ -106,16 +114,6 @@ Default model for agents that don't specify their own (in code or frontmatter). *** -### dir? - -```ts -optional dir: string | false; -``` - -Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. - -*** - ### host? ```ts diff --git a/docs/docs/api/appkit/TypeAlias.AgentEvent.md b/docs/docs/api/appkit/TypeAlias.AgentEvent.md index de9226a2b..0650bb8dc 100644 --- a/docs/docs/api/appkit/TypeAlias.AgentEvent.md +++ b/docs/docs/api/appkit/TypeAlias.AgentEvent.md @@ -266,5 +266,5 @@ Emitted by the agents plugin (not adapters) when a mutating tool call is awaiting human approval — fires for tools annotated with `effect: "write" | "update" | "destructive"` (preferred) or the legacy `destructive: true` boolean. Clients should render an approval -prompt and POST to `/chat/approve` with the matching `approvalId` and +prompt and POST to `/api/agents/approve` with the matching `approvalId` and a `decision` of `approve` or `deny`. 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..11bb32505 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`). +3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_SERVING_ENDPOINT_NAME`). 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,17 +127,39 @@ 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 always `server/agents` — there is no config option to relocate it; markdown still under `config/agents/` is read as a deprecated fallback (one-time warning). + +:::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** if a code agent seems frozen — a rebuild only swaps in a newer snapshot, so only deleting it restores live-from-source dev reload. 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. +Auto-inherit is **off for both origins by default** — a markdown or code agent with no declared `tools:` gets an empty tool index. Opt an origin in explicitly with `agents({ autoInheritTools: { file: true } })` (or `{ code: true }`, or `true` for both). + +:::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. If a discovered agent and a map entry share an id, discovery wins and the map entry is ignored (with a one-time warning). (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) + +Some examples further down still pass agents inline via this map for snippet brevity — in a real app each of those `createAgent(...)` definitions lives in its own `server/agents//agent.ts` and needs no map. +::: ### Scoping tools in code @@ -167,15 +196,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 in a code agent's inline `agents: {}` graph are rejected at load (`createAgent`); markdown `agents:` delegation rejects self-references at load and bounds deeper cycles at runtime via `limits.maxSubAgentDepth`. ## Level 5: standalone (no `createApp`) @@ -223,6 +252,34 @@ const result = await runAgent(classifier, { MCP hosted tools (`mcpServer(...)`) still require `agents()` (they need a live MCP client). Supervisor-API hosted tools (`supervisorTools.*`), by contrast, **work in standalone `runAgent`** — the adapter has everything it needs to execute them server-side. This makes batch-eval / CI use of supervisor agents possible without `createApp`. Plugin tool dispatch in standalone mode runs as the service principal (no OBO) and **bypasses the agents-plugin approval gate** — treat standalone runAgent as a trusted-prompt environment (CI, batch eval, internal scripts), not as an exposed user-facing surface. +## Adding agents to an existing app + +Already have an app and want to add agents? What you touch depends on the kind: + +**Markdown agents** — just the plugin. Drop `server/agents//agent.md`, add `agents()` to your `plugins`, done. Markdown is read from source at runtime in both dev and prod, so there is no build change. + +**Code agents** (`server/agents//agent.ts`) — also update your server build so a production bundle emits them. Code agents aren't imported anywhere, so a build that only compiles `server/server.ts` never produces `dist/agents/*/agent.js`, and a bundled `npm run build` + start would discover **zero** code agents. + +:::warning Dev hides this +`npm run dev` (tsx) imports the `.ts` source directly, so code agents work there with no build change — the gap only appears in a bundled build. If you add code agents but forget the build change, the plugin warns at startup (and names the fix) rather than failing silently. +::: + +The one-line fix is to adopt the build preset: + +```ts +// tsdown.server.config.ts +import { appkitServerConfig } from '@databricks/appkit/tsdown'; + +export default appkitServerConfig(); +``` + +`appkitServerConfig()` auto-detects `server/agents/` and adds the entry glob + `clean` only when code agents exist; pass overrides as `appkitServerConfig({ external, define, ... })`, or a function `appkitServerConfig((base) => ({ ...base }))` for full control. It's also the last time you touch this file — future build-wiring changes ship with the package. If you'd rather keep a hand-written config, add the entries yourself: + +```ts +entry: ['server/server.ts', 'server/agents/*/agent.ts'], +clean: true, +``` + ## Managed agents: the Supervisor API adapter `DatabricksAdapter.fromSupervisorApi` (beta) is the zero-config way to run an agent: instead of provisioning and pointing at a model-serving endpoint, you run the agentic loop in the Databricks workspace by targeting the AI Gateway Responses API (`/ai-gateway/mlflow/v1/responses`), which runs the LLM — and any hosted tools — as a managed service on Databricks. No `DATABRICKS_SERVING_ENDPOINT_NAME`, no stream-capability check, no JS tool plumbing for the common cases. @@ -351,8 +408,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, + // Agents live under server/agents// (fixed root). config/agents is read as a deprecated fallback. + agents?: Record, // DEPRECATED — use server/agents// discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, tools?: Record, @@ -371,6 +428,7 @@ agents({ maxConcurrentStreamsPerUser?: number, // default: 5 maxToolCalls?: number, // default: 50 maxSubAgentDepth?: number, // default: 3 + toolCallTimeoutMs?: number, // default: 300_000 (5 min) }, }) ``` @@ -518,6 +576,7 @@ agents({ maxConcurrentStreamsPerUser: 5, // HTTP 429 + Retry-After when exceeded maxToolCalls: 50, // aborts the run if the budget is exhausted maxSubAgentDepth: 3, // rejects sub-agent recursion beyond this + toolCallTimeoutMs: 300_000, // per-tool-call timeout (5 min; cold SQL/Genie headroom) }, }); ``` @@ -545,8 +604,10 @@ appkit.agents.getThreads(userId); // list user's threads | `model` | string | Same as `endpoint`; either works. | | `tools` | array | Unified tool list. Entries are `plugin:` / `plugin:: [t1, t2]` / `plugin:: { only, except, rename, prefix }` for plugin tools, or a bare `` resolved against `agents({ tools: {...} })` for ambient tools. See "Level 2: scope tools in frontmatter" above for examples. | | `default` | boolean | First agent id (sorted order) with `default: true` becomes the default agent. | +| `agents` | array | Sub-agent ids (sibling folders) to delegate to; each becomes an `agent-` tool. Resolves against other markdown and code agents. | | `maxSteps` | number | Adapter max-step hint. | | `maxTokens` | number | Adapter max-token hint. | +| `generationParams` | object | Adapter generation params (e.g. `temperature`, `top_p`) passed through when AppKit builds the adapter. | | `baseSystemPrompt` | false \| string | Per-agent override. `false` disables the AppKit base prompt. | | `ephemeral` | boolean | If `true`, the thread created for a chat request against this agent is deleted from `ThreadStore` after the stream finishes. Use for stateless one-shot agents (e.g. autocomplete) so history does not accumulate or contaminate future calls. Defaults to `false`. | diff --git a/docs/docs/plugins/testing.md b/docs/docs/plugins/testing.md index 8adb47132..30fc66642 100644 --- a/docs/docs/plugins/testing.md +++ b/docs/docs/plugins/testing.md @@ -52,7 +52,7 @@ const mock = createTestPluginContext({ `attach()` wires the context to a plugin the production way: it seeds an in-memory cache (if AppKit hasn't already initialized one), then calls the plugin's `attachContext`, which rebuilds telemetry and flips `isReady` to `true`. Await it before exercising any handler that reads `this.context`, `this.cache`, or gates on `isReady`: ```ts -const plugin = new MyAgentPlugin({ dir: false }); +const plugin = new MyAgentPlugin({}); await mock.attach(plugin); ``` @@ -216,7 +216,7 @@ To test a plugin that dispatches cross-plugin tool calls, register fake provider ```ts const mock = createTestPluginContext({ analytics: { query: [{ n: 1 }] } }); -const plugin = new MyAgentPlugin({ dir: false }); +const plugin = new MyAgentPlugin({}); await mock.attach(plugin); // `obo` sets the forwarded identity headers `asUser` needs — without them the diff --git a/packages/appkit/package.json b/packages/appkit/package.json index e2cc5a7c2..ce7d880d0 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -47,6 +47,11 @@ "development": "./src/testing/index.ts", "default": "./dist/testing/index.js" }, + "./tsdown": { + "types": "./dist/tsdown/index.d.ts", + "development": "./src/tsdown/index.ts", + "default": "./dist/tsdown/index.js" + }, "./dist/shared/src/plugin": { "types": "./dist/shared/src/plugin.d.ts", "default": "./dist/shared/src/plugin.d.ts" @@ -131,6 +136,10 @@ "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" }, + "./tsdown": { + "types": "./dist/tsdown/index.d.ts", + "default": "./dist/tsdown/index.js" + }, "./package.json": "./package.json" } } diff --git a/packages/appkit/src/core/agent/agent-dirs.ts b/packages/appkit/src/core/agent/agent-dirs.ts new file mode 100644 index 000000000..393442a76 --- /dev/null +++ b/packages/appkit/src/core/agent/agent-dirs.ts @@ -0,0 +1,14 @@ +import type { Dirent } from "node:fs"; + +/** + * Agent-folder names from a directory listing, sorted. An agent lives in a + * subfolder, so directories count — and symlinks-to-directories too (a shared + * agent folder linked into the agents dir). Single-sourced so the markdown and + * code loaders keep the same folder-selection policy. + */ +export function agentDirNames(entries: Dirent[]): string[] { + return entries + .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .map((e) => e.name) + .sort(); +} 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 9b220a420..54c77f157 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -15,6 +15,7 @@ import type { } from "../../core/agent/types"; import { isToolkitEntry } from "../../core/agent/types"; import { createLogger } from "../../logging/logger"; +import { agentDirNames } from "./agent-dirs"; const logger = createLogger("agents:loader"); @@ -206,14 +207,8 @@ 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()) - .map((e) => e.name) - .filter((name) => !RESERVED_DIRS.has(name)) - .sort(); + // A symlink to a file is filtered out below when reading agent.md (ENOTDIR). + const agentIds = agentDirNames(entries); const defs: Record = {}; const subAgentRefs: Record = {}; @@ -226,11 +221,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..3e5d910f6 --- /dev/null +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -0,0 +1,176 @@ +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 { agentDirNames } from "./agent-dirs"; +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): the matching `dist/agents` / `build/agents` is probed + * first, with the source `server/agents` `.ts` dir as fallback. + */ +export function resolveCodeAgentsDir(opts: { + cwd: string; + exists: (dir: string) => boolean; +}): ResolvedCodeAgentsDir { + const name = path.basename(CODE_AGENTS_SOURCE_DIR); + const source: ResolvedCodeAgentsDir = { + dir: path.resolve(opts.cwd, CODE_AGENTS_SOURCE_DIR), + // `.ts` only: the build entry glob is `/*/agent.ts`, so an `agent.tsx` + // would load in dev but never be emitted for a prod bundle. + extensions: [".ts"], + }; + 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 (err) { + // Absent (ENOENT) or a symlink to a file (ENOTDIR) → not an agent folder. + // Surface anything else (e.g. EACCES) rather than silently dropping it. + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return null; + throw err; + } + 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; + } + + // findEntryFile's readdir follows a symlinked folder and returns null for + // anything that isn't a real directory. + const folders = agentDirNames(entries); + + 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 638425b8a..1f6a3caf3 100644 --- a/packages/appkit/src/core/agent/tests/create-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/create-agent.test.ts @@ -1,7 +1,7 @@ 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"; @@ -37,6 +37,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 a25ace024..e17160168 100644 --- a/packages/appkit/src/core/agent/tests/load-agents.test.ts +++ b/packages/appkit/src/core/agent/tests/load-agents.test.ts @@ -184,14 +184,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 d322c495f..234aa45c1 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -127,7 +127,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. * @@ -143,6 +143,14 @@ 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, a code (discovered) agent wins over a markdown one, then the + * lowest id; 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; /** @@ -207,11 +215,17 @@ export interface AutoInheritToolsConfig { } export interface AgentsPluginConfig extends BasePluginConfig { - /** Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. */ - dir?: string | false; - /** Code-defined agents, merged with file-loaded ones (code wins on key collision). */ + /** + * @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. */ + /** Agent used when clients don't specify one. Precedence: this value, else a code agent with `default: true`, else a markdown agent with `default: true`, else the first-registered agent. */ defaultAgent?: string; /** Default model for agents that don't specify their own (in code or frontmatter). */ defaultModel?: AgentAdapter | Promise | string; @@ -235,7 +249,7 @@ export interface AgentsPluginConfig extends BasePluginConfig { * (the default), the agents plugin emits an `appkit.approval_pending` SSE * event before executing any tool whose annotation flags it as mutating — * `effect: "write" | "update" | "destructive"` (preferred) or the legacy - * `destructive: true` boolean — and waits for a `POST /chat/approve` + * `destructive: true` boolean — and waits for a `POST /api/agents/approve` * decision from the same user who initiated the stream. A missing decision * after `timeoutMs` auto-denies the call. */ diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 6f755462d..d34e867d4 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"; @@ -25,6 +26,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 { @@ -75,7 +81,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 @@ -167,6 +174,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); @@ -333,45 +344,82 @@ 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) — ` + + `in one folder, or across server/agents and the deprecated config/agents fallback. ` + + `Keep a single kind per id. 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)", - this.resolvedAgentsDir() ?? "", + "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}): ${ @@ -382,44 +430,165 @@ 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; } - private resolvedAgentsDir(): string | null { - if (this.config.dir === false) return null; - const dir = this.config.dir ?? DEFAULT_AGENTS_DIR; - return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); + /** + * 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.", + ); } - private async loadFileDefinitions(): Promise<{ + /** + * 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 { + return path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); + } + + /** + * 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(), + exists: existsSync, + }); + + 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 && + 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` 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)).includes("agent.ts"); + } 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(); - 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(), + }; + + const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); + + // 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, + }; } /** @@ -1965,10 +2134,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/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts index 8eda5e389..e57225dd2 100644 --- a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -86,9 +86,13 @@ function makeToolProvider( } let tmpDir: string; +let priorCwd: string; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-plugin-")); + // Discovery scans /server/agents, so run in tmpDir and write agents there. + priorCwd = process.cwd(); + process.chdir(tmpDir); const storage = { get: vi.fn(), set: vi.fn(), @@ -101,6 +105,7 @@ beforeEach(async () => { }); afterEach(() => { + process.chdir(priorCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -111,7 +116,7 @@ function instantiate(config: AgentsPluginConfig, ctx?: FakeContext) { } function writeMarkdownAgent(dir: string, id: string, content: string) { - const folder = path.join(dir, id); + const folder = path.join(dir, "server", "agents", id); fs.mkdirSync(folder, { recursive: true }); fs.writeFileSync(path.join(folder, "agent.md"), content, "utf-8"); } @@ -119,7 +124,6 @@ function writeMarkdownAgent(dir: string, id: string, content: string) { describe("AgentsPlugin", () => { test("registers code-defined agents and exposes them via exports", async () => { const plugin = instantiate({ - dir: false, agents: { support: { instructions: "You help customers.", @@ -144,7 +148,6 @@ describe("AgentsPlugin", () => { "---\ndefault: true\n---\nYou are helpful.", ); const plugin = instantiate({ - dir: tmpDir, defaultModel: stubAdapter(), }); await plugin.setup(); @@ -160,7 +163,6 @@ describe("AgentsPlugin", () => { test("code definitions override markdown on key collision", async () => { writeMarkdownAgent(tmpDir, "support", "---\n---\nFrom markdown."); const plugin = instantiate({ - dir: tmpDir, defaultModel: stubAdapter(), agents: { support: { @@ -185,7 +187,7 @@ describe("AgentsPlugin", () => { // client is closed" mid-conversation. The fix removes the // synchronous close — the existing client survives reload and // dispatches keep working. - const plugin = instantiate({ dir: false }); + const plugin = instantiate({}); const closeSpy = vi.fn(async () => {}); const fakeClient = { close: closeSpy, @@ -217,7 +219,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: tmpDir, defaultModel: stubAdapter(), agents: { manual: { @@ -262,7 +263,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: tmpDir, defaultModel: stubAdapter(), autoInheritTools: { file: true }, }, @@ -297,7 +297,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, defaultModel: stubAdapter(), autoInheritTools: true, agents: { @@ -345,10 +344,7 @@ describe("AgentsPlugin", () => { "---\ntools:\n - plugin:analytics\n---\nAnalyst.", ); - const plugin = instantiate( - { dir: tmpDir, defaultModel: stubAdapter() }, - ctx, - ); + const plugin = instantiate({ defaultModel: stubAdapter() }, ctx); await plugin.setup(); const api = plugin.exports() as { @@ -362,7 +358,6 @@ describe("AgentsPlugin", () => { test("registers sub-agents as agent- tools", async () => { const plugin = instantiate({ - dir: false, agents: { supervisor: { instructions: "Supervise", @@ -415,7 +410,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -454,7 +448,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -489,7 +482,6 @@ describe("AgentsPlugin", () => { const ctx = fakeContext([]); const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -538,7 +530,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, autoInheritTools: { code: true }, agents: { support: { @@ -582,7 +573,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, autoInheritTools: { code: true }, agents: { support: { @@ -622,7 +612,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -649,7 +638,6 @@ describe("AgentsPlugin", () => { const toolsFn = vi.fn(() => ({})); const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -681,7 +669,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { assistant: { instructions: "x", @@ -718,7 +705,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { mismatched: { instructions: "x", @@ -757,7 +743,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { leaky: { instructions: "x", diff --git a/packages/appkit/src/plugins/agents/tests/approval-route.test.ts b/packages/appkit/src/plugins/agents/tests/approval-route.test.ts index bddd92522..d06002444 100644 --- a/packages/appkit/src/plugins/agents/tests/approval-route.test.ts +++ b/packages/appkit/src/plugins/agents/tests/approval-route.test.ts @@ -63,7 +63,7 @@ beforeEach(() => { describe("POST /approve route handler", () => { test("rejects invalid body shape with 400", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { res, json } = mockRes(); await (plugin as any)._handleApprove(mockReq({}, "alice"), res); expect(res.status).toHaveBeenCalledWith(400); @@ -73,7 +73,7 @@ describe("POST /approve route handler", () => { }); test("returns 404 when the streamId is unknown", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { res, json } = mockRes(); await ( plugin as unknown as { @@ -96,7 +96,7 @@ describe("POST /approve route handler", () => { }); test("returns 403 when submitter is different from stream owner", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-x", { controller: new AbortController(), userId: "alice", @@ -135,7 +135,7 @@ describe("POST /approve route handler", () => { }); test("returns 404 when approvalId is unknown on an active stream", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-y", { controller: new AbortController(), userId: "alice", @@ -164,7 +164,7 @@ describe("POST /approve route handler", () => { }); test("happy path: approve resolves pending gate with 'approve'", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-z", { controller: new AbortController(), userId: "alice", @@ -198,7 +198,7 @@ describe("POST /approve route handler", () => { }); test("happy path: deny resolves pending gate with 'deny'", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-z", { controller: new AbortController(), userId: "alice", @@ -233,7 +233,7 @@ describe("POST /approve route handler", () => { describe("POST /cancel ownership + gate cleanup", () => { test("cancelling a stream denies every pending approval on that stream", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const controller = new AbortController(); (plugin as any).activeStreams.set("stream-c", { controller, @@ -270,7 +270,7 @@ describe("POST /cancel ownership + gate cleanup", () => { }); test("cancel from a different user is refused with 403", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const controller = new AbortController(); (plugin as any).activeStreams.set("stream-d", { controller, 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..3ab960a82 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -0,0 +1,268 @@ +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; +}; + +// Discovery scans `/server/agents`, so each test runs in a fresh temp cwd. +// Code fixtures are symlinked in (not copied) so their `.ts` files resolve their +// relative imports from the committed location; markdown-only cases pass no +// fixture (an absent server/agents = empty discovery). +describe("AgentsPlugin agent discovery", () => { + let restoreCwd: (() => void) | undefined; + + afterEach(() => { + restoreCwd?.(); + restoreCwd = undefined; + }); + + function chdirWithAgents(fixture?: string) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agents-discovery-")); + if (fixture) { + fs.mkdirSync(path.join(tmp, "server")); + fs.symlinkSync( + fixtureDir(fixture), + path.join(tmp, "server", "agents"), + "dir", + ); + } + const prior = process.cwd(); + process.chdir(tmp); + restoreCwd = () => { + process.chdir(prior); + fs.rmSync(tmp, { recursive: true, force: true }); + }; + } + + test("discovers code agents from server/agents", async () => { + chdirWithAgents("code-agents"); + const plugin = instantiate({ 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 () => { + chdirWithAgents("code-agents-default"); + const plugin = instantiate({ 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. + chdirWithAgents("code-agents-default"); + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("explicit defaultAgent overrides a discovered default: true", async () => { + chdirWithAgents("code-agents-default"); + const plugin = instantiate({ + 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 () => { + chdirWithAgents("md-parent-code-child"); + 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); + expect(api.getDefault()).toBe("planner"); + }); + + test("discovery wins over a colliding deprecated-map entry (warns, no throw)", async () => { + chdirWithAgents("code-agents"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const plugin = instantiate({ + 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 works unchanged when server/agents is absent (no throw)", async () => { + chdirWithAgents(); + const plugin = instantiate({ + 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 () => { + chdirWithAgents("code-agents"); + const plugin = instantiate({ + 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 () => { + chdirWithAgents("code-md-collision"); + const plugin = instantiate({ 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(() => {}); + + chdirWithAgents(); + const deprecated = instantiate({ + 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(); + restoreCwd?.(); + chdirWithAgents("code-agents"); + const discoveredPlugin = instantiate({ 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(); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts b/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts index 70a195f9c..f700379f4 100644 --- a/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts +++ b/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts @@ -111,7 +111,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { // Regression for finding #1 on PR #304: the gate previously checked // only `annotations.destructive === true` and let `effect:"destructive"` // through unapproved. - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState, pushed } = makeRunState(plugin); const execute = vi.fn().mockResolvedValue("ok"); @@ -151,7 +151,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { ])( "does NOT fire for non-mutating `effect` value %p", async (effect, expectGate) => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); const annotations = effect ? { effect } : undefined; @@ -187,7 +187,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { ); test("denying the gate returns the deny string and does not invoke the tool", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); const execute = vi.fn(); @@ -225,7 +225,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { describe("dispatchToolCall — shared tool-call budget", () => { test("subsequent calls increment the shared counter", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); const toolIndex = new Map([ @@ -252,7 +252,6 @@ describe("dispatchToolCall — shared tool-call budget", () => { test("rejects + aborts when the budget is exhausted", async () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxToolCalls: 2 }, }); const { runState } = makeRunState(plugin); @@ -309,7 +308,7 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { ]); test("forwards runState.limits.toolCallTimeoutMs to PluginContext.executeTool", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); runState.limits.toolCallTimeoutMs = 90_000; @@ -352,7 +351,7 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { // End-to-end proof that the timeout value the agents plugin forwards // reaches real AbortSignal composition inside PluginContext.executeTool — // a stubbed executeTool would silently ignore the timeout. - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); runState.limits.toolCallTimeoutMs = 5; @@ -379,14 +378,13 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { }); test("resolvedLimits exposes the documented 5-minute default", () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const limits = (plugin as any).resolvedLimits; expect(limits.toolCallTimeoutMs).toBe(300_000); }); test("honours agents({ limits: { toolCallTimeoutMs } })", () => { const plugin = new AgentsPlugin({ - dir: false, limits: { toolCallTimeoutMs: 600_000 }, }); const limits = (plugin as any).resolvedLimits; @@ -408,7 +406,6 @@ describe("runSubAgent — sub-agent event forwarding", () => { // cycle, two agents delegating to each other will eventually exceed // the depth limit and we want a clear error, not an unbounded stack. const plugin = new AgentsPlugin({ - dir: false, agents: {}, limits: { maxSubAgentDepth: 2 }, }); @@ -430,7 +427,7 @@ describe("runSubAgent — sub-agent event forwarding", () => { }); test("forwards every sub-agent event into the parent stream except metadata", async () => { - const plugin = new AgentsPlugin({ dir: false, agents: {} }); + const plugin = new AgentsPlugin({ agents: {} }); const { runState, pushed } = makeRunState(plugin); const child = { diff --git a/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts b/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts index 2713e50b9..dac3fba56 100644 --- a/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts +++ b/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts @@ -129,7 +129,7 @@ describe("invocationsRequestSchema — input caps", () => { describe("POST /chat — per-user concurrent-stream limit", () => { function seedPlugin( - overrides: ConstructorParameters[0] = { dir: false }, + overrides: ConstructorParameters[0] = {}, ): AgentsPlugin { const plugin = new AgentsPlugin(overrides); // Seed the agents map directly so _handleChat can resolve "hello" @@ -189,7 +189,6 @@ describe("POST /chat — per-user concurrent-stream limit", () => { test("honours agents({ limits: { maxConcurrentStreamsPerUser } })", async () => { const plugin = seedPlugin({ - dir: false, limits: { maxConcurrentStreamsPerUser: 2 }, }); for (let i = 0; i < 2; i++) { @@ -270,7 +269,7 @@ describe("POST /chat — per-user concurrent-stream limit", () => { describe("resolvedLimits — default values", () => { test("exposes the documented MVP defaults when unconfigured", () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const limits = (plugin as any).resolvedLimits; expect(limits).toEqual({ maxConcurrentStreamsPerUser: 5, @@ -282,7 +281,6 @@ describe("resolvedLimits — default values", () => { test("lets callers override any subset", () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxToolCalls: 100 }, }); const limits = (plugin as any).resolvedLimits; @@ -334,7 +332,6 @@ describe("runSubAgent — depth guard", () => { test("rejects when depth exceeds the configured maximum", async () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxSubAgentDepth: 2 }, }); const runState = makeRunState(plugin, { maxSubAgentDepth: 2 }); @@ -350,7 +347,6 @@ describe("runSubAgent — depth guard", () => { test("accepts at the boundary (depth === limit)", async () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxSubAgentDepth: 3 }, agents: {}, }); 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..0ec266fcc --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -0,0 +1,81 @@ +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"] }; + +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("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"] }); + }); + + it("targets server/agents when nothing exists (empty scan downstream)", () => { + const r = resolveCodeAgentsDir({ cwd, exists: () => false }); + expect(r).toEqual({ dir: source, extensions: [".ts"] }); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts b/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts index 4aa069a61..06abd8dac 100644 --- a/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts +++ b/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts @@ -62,7 +62,7 @@ function mockRes() { } function seedPlugin(): AgentsPlugin { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).agents.set("default", { name: "default", instructions: "hi", @@ -190,7 +190,7 @@ describe("POST /invocations — threadStore failure", () => { describe("POST /invocations & /responses — HITL pre-flight", () => { function seedPluginWithTools( toolAnnotations: Record, - overrides: ConstructorParameters[0] = { dir: false }, + overrides: ConstructorParameters[0] = {}, ): AgentsPlugin { const plugin = new AgentsPlugin(overrides); const toolIndex = new Map(); @@ -273,7 +273,7 @@ describe("POST /invocations & /responses — HITL pre-flight", () => { test("passes pre-flight when approval.requireForDestructive is disabled", async () => { const plugin = seedPluginWithTools( { effect: "destructive" }, - { dir: false, approval: { requireForDestructive: false } }, + { approval: { requireForDestructive: false } }, ); (plugin as any)._runAgentNonStreaming = vi.fn(async () => undefined); (plugin as any).threadStore = { @@ -320,7 +320,7 @@ describe("POST /invocations & /responses — HITL pre-flight", () => { describe("POST /invocations & /responses — successful invoke", () => { test("returns OpenAI Responses-shaped JSON with aggregated assistant text", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).agents.set("default", { name: "default", instructions: "hi", @@ -378,7 +378,7 @@ describe("POST /invocations & /responses — successful invoke", () => { describe("/invocations and /responses are aliases", () => { test("both routes are registered and bound to the same handler", () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); // Attach the real PluginContext via the testing kit. Its route recorder // captures the RAW handlers passed to addRoute — the aliasing assertion // needs the original references, which the context's forwardAsyncErrors diff --git a/packages/appkit/src/plugins/agents/tool-approval-gate.ts b/packages/appkit/src/plugins/agents/tool-approval-gate.ts index 4aeb92925..313c56400 100644 --- a/packages/appkit/src/plugins/agents/tool-approval-gate.ts +++ b/packages/appkit/src/plugins/agents/tool-approval-gate.ts @@ -10,7 +10,7 @@ * scheduled for auto-deny. The returned promise is what blocks the * adapter until the decision arrives. * 2. The client receives an `appkit.approval_pending` SSE event carrying the - * `approvalId` + `streamId` and posts a decision to `POST /chat/approve`. + * `approvalId` + `streamId` and posts a decision to `POST /api/agents/approve`. * The route calls {@link ToolApprovalGate.submit} which resolves the * pending promise and clears the timer. * 3. If no submit arrives within `timeoutMs`, the timer fires and the diff --git a/packages/appkit/src/tsdown/index.ts b/packages/appkit/src/tsdown/index.ts new file mode 100644 index 000000000..49fc80284 --- /dev/null +++ b/packages/appkit/src/tsdown/index.ts @@ -0,0 +1,119 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; + +/** + * `@databricks/appkit/tsdown` — the server build preset. + * + * A scaffolded app's `tsdown.server.config.ts` is a single line: + * + * ```ts + * import { appkitServerConfig } from "@databricks/appkit/tsdown"; + * export default appkitServerConfig(); + * ``` + * + * so the agent-discovery build wiring lives in the package and reaches existing + * apps on upgrade, instead of being hand-maintained in every scaffold. The + * returned object is a plain tsdown config (no `defineConfig` wrapper needed). + * + * This module is intentionally dependency-free (only `node:` builtins) — it is + * loaded at build time and must not pull in the runtime SDK. + */ + +/** The tsdown options this preset sets. A structural subset of tsdown's config. */ +export interface ServerBuildConfig { + entry?: string | string[]; + unbundle?: boolean; + clean?: boolean; + external?: (id: string) => boolean; + outExtensions?: () => { js: string }; + tsconfig?: string; + /** Any other tsdown option passes through untouched. */ + [key: string]: unknown; +} + +/** + * Overrides accepted by {@link appkitServerConfig}: either a partial config + * (merged — `entry` is unioned, `external` composed, other keys win) or a + * function that receives AppKit's computed base config for full control. + */ +export type ServerConfigOverrides = + | ServerBuildConfig + | ((base: ServerBuildConfig) => ServerBuildConfig); + +const SERVER_ENTRY = "server/server.ts"; +const AGENT_ENTRY = "server/agents/*/agent.ts"; + +/** AppKit default: keep anything resolving outside the project out of the bundle. */ +const defaultExternal = (id: string): boolean => + /^[^./]/.test(id) || id.includes("/node_modules/"); + +function toEntryArray(entry: string | string[] | undefined): string[] { + if (entry === undefined) return []; + return Array.isArray(entry) ? entry : [entry]; +} + +/** True when `server/agents/` holds at least one `/agent.ts` (a code agent). */ +function hasCodeAgents(cwd: string): boolean { + const root = path.join(cwd, "server", "agents"); + try { + return readdirSync(root, { withFileTypes: true }).some((e) => { + if (!e.isDirectory() && !e.isSymbolicLink()) return false; + try { + return readdirSync(path.join(root, e.name)).includes("agent.ts"); + } catch { + return false; + } + }); + } catch { + return false; + } +} + +/** AppKit's base server config, with the agent entry + `clean` only when needed. */ +function baseConfig(codeAgents: boolean): ServerBuildConfig { + return { + entry: [SERVER_ENTRY, ...(codeAgents ? [AGENT_ENTRY] : [])], + unbundle: true, + external: defaultExternal, + outExtensions: () => ({ js: ".js" }), + ...(codeAgents ? { clean: true } : {}), + }; +} + +/** + * The server tsdown config, merging AppKit's required wiring with `overrides`. + * + * Object overrides merge with intent, not a blind spread: + * - `entry` is UNIONed — the agent glob can't be dropped by an override; + * - `external` is COMPOSED — the caller's predicate runs alongside AppKit's; + * - every other key wins. + * A function override instead receives the computed base and returns the final + * config, for callers that need full control (including removing the glob). + * + * The agent entry + `clean` are included only when `server/agents/` actually + * holds code agents, so the same call works for every app. Pass + * `opts.codeAgents` to force that decision (e.g. a non-standard layout). + */ +export function appkitServerConfig( + overrides: ServerConfigOverrides = {}, + opts: { cwd?: string; codeAgents?: boolean } = {}, +): ServerBuildConfig { + const cwd = opts.cwd ?? process.cwd(); + const codeAgents = opts.codeAgents ?? hasCodeAgents(cwd); + const base = baseConfig(codeAgents); + + if (typeof overrides === "function") return overrides(base); + + const baseEntries = toEntryArray(base.entry); + const entry = [ + ...baseEntries, + ...toEntryArray(overrides.entry).filter((e) => !baseEntries.includes(e)), + ]; + + const userExternal = overrides.external; + const external = userExternal + ? (id: string): boolean => defaultExternal(id) || userExternal(id) + : defaultExternal; + + return { ...base, ...overrides, entry, external }; +} diff --git a/packages/appkit/src/tsdown/tests/appkit-server-config.test.ts b/packages/appkit/src/tsdown/tests/appkit-server-config.test.ts new file mode 100644 index 000000000..7cb000c3c --- /dev/null +++ b/packages/appkit/src/tsdown/tests/appkit-server-config.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { appkitServerConfig } from "../index"; + +describe("appkitServerConfig", () => { + it("adds the agent glob + clean when code agents exist", () => { + const c = appkitServerConfig({}, { codeAgents: true }); + expect(c.entry).toEqual(["server/server.ts", "server/agents/*/agent.ts"]); + expect(c.clean).toBe(true); + expect(c.unbundle).toBe(true); + }); + + it("omits the agent glob + clean when there are no code agents", () => { + const c = appkitServerConfig({}, { codeAgents: false }); + expect(c.entry).toEqual(["server/server.ts"]); + expect(c.clean).toBeUndefined(); + }); + + it("unions user entries — the agent glob survives an entry override", () => { + const c = appkitServerConfig( + { entry: "server/worker.ts" }, + { codeAgents: true }, + ); + expect(c.entry).toEqual([ + "server/server.ts", + "server/agents/*/agent.ts", + "server/worker.ts", + ]); + }); + + it("composes external instead of clobbering AppKit's", () => { + const c = appkitServerConfig( + { external: (id) => id === "keepme" }, + { codeAgents: true }, + ); + const ext = c.external as (id: string) => boolean; + expect(ext("keepme")).toBe(true); // caller's rule + expect(ext("express")).toBe(true); // AppKit default (bare specifier) + expect(ext("./local")).toBe(false); // neither → bundled + }); + + it("passes through unrelated overrides while protecting entry", () => { + const c = appkitServerConfig( + { tsconfig: "tsconfig.custom.json", sourcemap: true }, + { codeAgents: true }, + ); + expect(c.tsconfig).toBe("tsconfig.custom.json"); + expect(c.sourcemap).toBe(true); + expect(c.entry).toContain("server/agents/*/agent.ts"); + }); + + it("hands the computed base to a function override for full control", () => { + const c = appkitServerConfig( + (base) => ({ ...base, entry: ["only/this.ts"], unbundle: false }), + { codeAgents: true }, + ); + // The function form can override even the protected fields. + expect(c.entry).toEqual(["only/this.ts"]); + expect(c.unbundle).toBe(false); + }); + + describe("code-agent auto-detection (fs)", () => { + let tmp: string; + const mk = (rel: string) => { + const p = path.join(tmp, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, ""); + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-tsdown-")); + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it("detects server/agents//agent.ts under cwd", () => { + mk("server/agents/helper/agent.ts"); + expect(appkitServerConfig({}, { cwd: tmp }).entry).toContain( + "server/agents/*/agent.ts", + ); + }); + + it("does not add the glob when server/agents has no code agent", () => { + mk("server/agents/planner/agent.md"); // markdown only + expect(appkitServerConfig({}, { cwd: tmp }).entry).toEqual([ + "server/server.ts", + ]); + }); + }); +}); diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 679b0c607..cb809352e 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -9,7 +9,12 @@ export default defineConfig([ excludeEntrypoints: ["./type-generator"], }, name: "@databricks/appkit", - entry: ["src/index.ts", "src/beta.ts", "src/testing/index.ts"], + entry: [ + "src/index.ts", + "src/beta.ts", + "src/testing/index.ts", + "src/tsdown/index.ts", + ], outDir: "dist", hash: false, format: "esm", diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 5ec2caf35..811092b84 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -135,7 +135,7 @@ export type AgentEvent = * is awaiting human approval — fires for tools annotated with * `effect: "write" | "update" | "destructive"` (preferred) or the * legacy `destructive: true` boolean. Clients should render an approval - * prompt and POST to `/chat/approve` with the matching `approvalId` and + * prompt and POST to `/api/agents/approve` with the matching `approvalId` and * a `decision` of `approve` or `deny`. */ type: "approval_pending"; @@ -240,7 +240,7 @@ export interface AppKitMetadataEvent { * Emitted when a mutating tool call is awaiting human approval. Fires for * tools annotated with `effect: "write" | "update" | "destructive"` * (preferred) or the legacy `destructive: true` boolean. The client should - * render an approval UI and POST the decision to `/chat/approve` with + * render an approval UI and POST the decision to `/api/agents/approve` with * `{ streamId, approvalId, decision: "approve" | "deny" }`. If no decision * arrives before the server-side timeout, the call is auto-denied and the * agent receives a denial string as the tool output. 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..6cc06b45c 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,11 +1,7 @@ -import { defineConfig } from 'tsdown'; +import { appkitServerConfig } from '@databricks/appkit/tsdown'; -export default defineConfig({ - entry: 'server/server.ts', - unbundle: true, - external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), - tsconfig: 'tsconfig.server.json', - outExtensions: () => ({ - js: '.js', - }), -}); +// AppKit's server build preset. When you keep code agents in `server/agents/`, +// it auto-includes them as build entries so discovery works in a bundled build. +// Customize with overrides — `appkitServerConfig({ external, define, ... })` — +// or a function form for full control: `appkitServerConfig((base) => ({ ...base }))`. +export default appkitServerConfig();