From a01d975c50b0e23fdbadd0d0578d2cca122263dd Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 13 Aug 2026 16:11:18 +0200 Subject: [PATCH 1/5] feat(appkit): auto-discover code agents from server/agents/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in code agents: a file `server/agents/.ts` that `export default createAgent(...)` is discovered at startup — the agent id is the filename, so there is no agent map and no import at the call site (`agents()`). Symmetric with markdown agents in `config/agents/`. Discovery is a runtime scan resolved by NODE_ENV: dev scans `server/agents/*.ts` under tsx; a bundled server scans the compiled `dist/agents` (or `build/agents`) `*.js`. `createAgent` brands its result (`Symbol.for("appkit.agent")`) so the loader picks the agent out of a module's exports and skips helpers and bundler chunks. The template's tsdown config lists `server/agents/*.ts` as build entries so the compiled files exist for the production scan (a runtime scan of a dynamic path would be dropped by the bundler — the trap this avoids). Backward compatible: `agents({ agents: { ... } })` still works and emits a one-time deprecation warning; `createAgent({ name })` is still honored; markdown discovery is unchanged. Also migrates the dev-playground reference app to the new pattern. Signed-off-by: MarioCadenas --- apps/dev-playground/package.json | 4 +- .../server/agents/dashboard_pilot.ts | 239 +++++++++++++ apps/dev-playground/server/agents/helper.ts | 22 ++ .../server/agents/sql_analyst.ts | 18 + .../server/agents/supervisor.ts | 33 ++ apps/dev-playground/server/index.ts | 333 +----------------- .../api/appkit/Interface.AgentDefinition.md | 13 + .../appkit/Interface.AgentsPluginConfig.md | 25 +- docs/docs/plugins/agents.md | 45 ++- .../appkit/src/core/agent/create-agent.ts | 32 ++ .../appkit/src/core/agent/load-code-agents.ts | 134 +++++++ .../src/core/agent/tests/create-agent.test.ts | 31 +- packages/appkit/src/core/agent/types.ts | 24 +- packages/appkit/src/plugins/agents/agents.ts | 234 ++++++++++-- .../plugins/agents/tests/discovery.test.ts | 173 +++++++++ .../fixtures/code-agents-default/alpha.ts | 2 + .../fixtures/code-agents-default/beta.ts | 2 + .../tests/fixtures/code-agents-dup/dup.ts | 2 + .../tests/fixtures/code-agents-dup/dup.tsx | 2 + .../tests/fixtures/code-agents-multi/multi.ts | 3 + .../tests/fixtures/code-agents/builder.ts | 2 + .../tests/fixtures/code-agents/helper.ts | 2 + .../tests/fixtures/code-agents/notAnAgent.ts | 2 + .../agents/tests/load-code-agents.test.ts | 41 +++ template/server/agents/helper.ts | 19 +- template/server/server.ts | 7 - template/tsdown.server.config.ts | 5 +- 27 files changed, 1069 insertions(+), 380 deletions(-) create mode 100644 apps/dev-playground/server/agents/dashboard_pilot.ts create mode 100644 apps/dev-playground/server/agents/helper.ts create mode 100644 apps/dev-playground/server/agents/sql_analyst.ts create mode 100644 apps/dev-playground/server/agents/supervisor.ts create mode 100644 packages/appkit/src/core/agent/load-code-agents.ts create mode 100644 packages/appkit/src/plugins/agents/tests/discovery.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index 7af74ef0e..c27a971ff 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -8,8 +8,8 @@ "dev": "NODE_ENV=development tsx watch server/index.ts", "dev:inspect": "NODE_ENV=development tsx --inspect --tsconfig ./tsconfig.json ./server", "build": "npm run build:app", - "build:app": "tsdown --out-dir build server/index.ts && cd client && npm run build", - "build:server": "tsdown --out-dir build server/index.ts", + "build:app": "tsdown --out-dir build server/index.ts 'server/agents/*.ts' && cd client && npm run build", + "build:server": "tsdown --out-dir build server/index.ts 'server/agents/*.ts'", "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", diff --git a/apps/dev-playground/server/agents/dashboard_pilot.ts b/apps/dev-playground/server/agents/dashboard_pilot.ts new file mode 100644 index 000000000..ac305dac8 --- /dev/null +++ b/apps/dev-playground/server/agents/dashboard_pilot.ts @@ -0,0 +1,239 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Smart-Dashboard pilot: emits UI-action tool calls the client reads off the +// SSE stream and translates into React state mutations. Referenced as a +// sub-agent by the markdown `query` dispatcher (config/agents/query), resolved +// by this file's id ("dashboard_pilot"). +// +// Narrow, single-purpose tools. +// +// The earlier polymorphic `apply_filter({ field, operator, value })` was +// too expressive — the LLM could emit valid-looking calls the dispatcher +// couldn't faithfully apply (e.g. `field: "dropoff_zone"` when the +// dashboard only has a `pickup_zip` filter; `operator: "eq"` with a date). +// Splitting into one tool per filter verb removes the whole class of +// "agent said it worked but nothing moved" bugs. +// +// Each tool has exactly one client-side effect, rendered by +// use-action-dispatcher. Server handlers are still stubs — the tool-call +// JSON is the action payload. + +const filter_by_date_range = tool({ + name: "filter_by_date_range", + description: + "Filter the dashboard to trips within a date range. Both start and end are required and must be ISO dates (YYYY-MM-DD) within 2016.", + schema: z.object({ + start: z.string().describe("Start date in ISO format, e.g. 2016-03-01"), + end: z.string().describe("End date in ISO format, e.g. 2016-03-31"), + }), + execute: async ({ start, end }) => + `Filtered dashboard to trips between ${start} and ${end}.`, +}); + +const filter_by_pickup_zip = tool({ + name: "filter_by_pickup_zip", + description: + "Filter the dashboard to trips originating from a specific pickup ZIP code. Use when the user asks about a specific pickup zone or ZIP.", + schema: z.object({ + zip: z.string().describe("Pickup ZIP code, e.g. 10001"), + }), + execute: async ({ zip }) => + `Filtered dashboard to trips picked up in ${zip}.`, +}); + +const filter_by_fare = tool({ + name: "filter_by_fare", + description: + "Filter the dashboard to trips within a fare range. At least one of min or max must be provided.", + schema: z + .object({ + min: z.number().optional().describe("Minimum fare in USD"), + max: z.number().optional().describe("Maximum fare in USD"), + }) + .refine((v) => v.min !== undefined || v.max !== undefined, { + message: "Provide at least one of min or max.", + }), + execute: async ({ min, max }) => { + const parts = [] as string[]; + if (min !== undefined) parts.push(`>= $${min}`); + if (max !== undefined) parts.push(`<= $${max}`); + return `Filtered dashboard to trips with fare ${parts.join(" and ")}.`; + }, +}); + +const clear_filters = tool({ + name: "clear_filters", + description: + "Remove all active filters from the dashboard. Use when the user asks to reset, clear, or remove filters.", + schema: z.object({}), + execute: async () => "All filters cleared.", +}); + +const highlight_period = tool({ + name: "highlight_period", + description: + "Highlight a time period on the Trips Over Time chart to draw attention to a specific date range.", + schema: z.object({ + start: z.string().describe("Start date in ISO format (YYYY-MM-DD)"), + end: z.string().describe("End date in ISO format (YYYY-MM-DD)"), + color: z + .enum(["blue", "red", "yellow"]) + .optional() + .describe("Highlight color. Defaults to blue."), + label: z + .string() + .optional() + .describe("Optional label for the highlighted period"), + }), + execute: async ({ start, end, color: _color, label }) => { + const suffix = label ? ` (${label})` : ""; + return `Highlighted period ${start} to ${end}${suffix} on the dashboard.`; + }, +}); + +const clear_highlights = tool({ + name: "clear_highlights", + description: + "Remove all highlight overlays from the charts. Use when the user asks to clear, reset, or remove highlights.", + schema: z.object({}), + execute: async () => "All highlights cleared.", +}); + +// Restores a previously saved view. The tool-call arguments are the +// authoritative state: the client listens for this function_call on SSE +// and applies the filters + highlights directly without needing a round +// trip back for metadata. The agent is expected to have looked up the +// saved view server-side before emitting this call (it passes the +// already-resolved state through). +const load_view = tool({ + name: "load_view", + description: + "Restore a previously saved dashboard view by applying its filters and highlights. The caller supplies the already-resolved state so the client can apply it from this tool call without a second round trip.", + schema: z.object({ + name: z.string().describe("The saved view's name (for UI feedback)"), + filters: z + .object({ + date_from: z.string().optional(), + date_to: z.string().optional(), + pickup_zip: z.string().optional(), + fare_min: z.string().optional(), + fare_max: z.string().optional(), + }) + .passthrough() + .describe("Filters to restore. Omit fields that should not be set."), + highlights: z + .array( + z.object({ + start: z.string(), + end: z.string(), + color: z.enum(["blue", "red", "yellow"]).optional(), + label: z.string().optional(), + }), + ) + .describe("Highlight ranges to restore."), + }), + execute: async ({ name }) => `Restored saved view "${name}".`, +}); + +const focus_chart = tool({ + name: "focus_chart", + description: + "Scroll the user's viewport to a specific chart on the dashboard and briefly pulse it to draw attention. Use when the user asks to 'look at' or 'focus on' a specific visualization.", + schema: z.object({ + chart_id: z + .enum([ + "kpis", + "trips_over_time", + "fare_distribution", + "hourly_heatmap", + "top_zones", + ]) + .describe("Which chart to focus on"), + }), + execute: async ({ chart_id }) => `Focused on ${chart_id}.`, +}); + +const highlight_zone = tool({ + name: "highlight_zone", + description: + "Draw an emphasis ring around a specific pickup ZIP on the Top Pickup Zones chart. Use this to call attention to a standout zone without filtering the whole dashboard to that ZIP.", + schema: z.object({ + zip: z.string().describe("Pickup ZIP code to highlight (e.g. '10017')"), + label: z + .string() + .optional() + .describe("Optional short label shown inside the highlighted bar"), + }), + execute: async ({ zip, label }) => + `Highlighted pickup ZIP ${zip}${label ? ` (${label})` : ""}.`, +}); + +const clear_zone_highlights = tool({ + name: "clear_zone_highlights", + description: "Remove all emphasis rings from the Top Pickup Zones chart.", + schema: z.object({}), + execute: async () => "Zone highlights cleared.", +}); + +// Write tool: exercises the approval gate. Server handler is a stub — +// no view persistence — but `effect: "write"` forces the human-in-the-loop +// flow before the agent can call it. We pick `write` (not `destructive`) +// because capturing a view CREATES a new file; nothing is deleted or +// overwritten. The approval card will render the low-severity blue +// "writes" treatment rather than the alarming red "destructive" one. +const save_view = tool({ + name: "save_view", + description: + "Persist the current dashboard configuration (filters + highlights) as a named view the user can recall later. Always surfaces the approval gate as a write action.", + annotations: { effect: "write" }, + schema: z.object({ + name: z.string().describe("Short human-readable name for the saved view"), + description: z + .string() + .optional() + .describe("Optional longer description for the saved view"), + }), + execute: async ({ name, description }) => { + const suffix = description ? `: ${description}` : ""; + return `Saved view "${name}"${suffix}.`; + }, +}); + +export default createAgent({ + instructions: [ + "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", + "Filters:", + "- `filter_by_date_range({start, end})` — narrow to a date window within 2016.", + "- `filter_by_pickup_zip({zip})` — narrow to trips from a specific ZIP.", + "- `filter_by_fare({min?, max?})` — narrow by fare range (at least one bound required).", + "- `clear_filters()` — remove all active filters.", + "Highlights:", + "- `highlight_period({start, end, color?, label?})` — shade a date window on the Trips Over Time chart.", + "- `clear_highlights()` — remove all shaded overlays from the trips chart.", + "- `highlight_zone({zip, label?})` — draw an emphasis ring around a specific ZIP on the Top Pickup Zones chart.", + "- `clear_zone_highlights()` — remove all ZIP emphasis rings.", + "Focus & save:", + "- `focus_chart({chart_id})` — scroll the viewport to one of `kpis`, `trips_over_time`, `fare_distribution`, `hourly_heatmap`, `top_zones` and briefly pulse it.", + "- `save_view({name, description?})` — persist the current configuration. Write action; the user will see an approval card.", + "- `load_view({name, filters, highlights})` — restore a previously saved view. Always pass the resolved state; never leave fields unset.", + "Rules:", + "1. Pick the single tool that matches the user's intent. Do not chain filters unless the user asks for a compound filter.", + "2. Briefly state what you did after the tool returns. Do not narrate before calling the tool.", + "3. If the user's request is ambiguous (e.g. 'filter to last month' without a 2016 context), ask one clarifying question before calling any tool.", + "4. For standout ZIPs, prefer `highlight_zone` over `filter_by_pickup_zip` so the rest of the dashboard stays in context. Only filter when the user explicitly asks to narrow the whole dashboard.", + ].join("\n"), + tools: { + filter_by_date_range, + filter_by_pickup_zip, + filter_by_fare, + clear_filters, + highlight_period, + clear_highlights, + highlight_zone, + clear_zone_highlights, + focus_chart, + save_view, + load_view, + }, +}); diff --git a/apps/dev-playground/server/agents/helper.ts b/apps/dev-playground/server/agents/helper.ts new file mode 100644 index 000000000..14267d7fc --- /dev/null +++ b/apps/dev-playground/server/agents/helper.ts @@ -0,0 +1,22 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Code-defined demo agent showing the tools(plugins) function form alongside +// the markdown-driven agents in config/agents/. Discovered automatically from +// server/agents/ — its id is the filename ("helper"). +export default createAgent({ + instructions: + "You are a demo helper. Use analytics tools to answer data questions, " + + "or get_weather for light small-talk.", + tools(plugins) { + return { + ...plugins.analytics.toolkit(), + get_weather: tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ city: z.string().describe("City name") }), + execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, + }), + }; + }, +}); diff --git a/apps/dev-playground/server/agents/sql_analyst.ts b/apps/dev-playground/server/agents/sql_analyst.ts new file mode 100644 index 000000000..2148e416e --- /dev/null +++ b/apps/dev-playground/server/agents/sql_analyst.ts @@ -0,0 +1,18 @@ +import { createAgent } from "@databricks/appkit/beta"; + +// Smart-Dashboard specialist: writes Databricks SQL against +// `samples.nyctaxi.trips`. Referenced as a sub-agent by the markdown `query` +// dispatcher (config/agents/query/agent.md, `agents: [sql_analyst, ...]`), +// which resolves it by this file's id ("sql_analyst"). +export default createAgent({ + instructions: [ + "You are a SQL expert for NYC taxi trip data (`samples.nyctaxi.trips`).", + "Write Databricks SQL to answer the user's question and summarize the results clearly.", + "IMPORTANT: The dataset only contains trips from 2016. Always add `WHERE tpep_pickup_datetime >= '2016-01-01' AND tpep_pickup_datetime < '2017-01-01'` unless the user specifies a narrower date range within 2016.", + "If the user asks about dates outside 2016, say the dataset only covers 2016.", + "Available columns: tpep_pickup_datetime, tpep_dropoff_datetime, trip_distance, fare_amount, pickup_zip, dropoff_zip.", + ].join(" "), + tools(plugins) { + return { ...plugins.analytics.toolkit() }; + }, +}); diff --git a/apps/dev-playground/server/agents/supervisor.ts b/apps/dev-playground/server/agents/supervisor.ts new file mode 100644 index 000000000..1449a4337 --- /dev/null +++ b/apps/dev-playground/server/agents/supervisor.ts @@ -0,0 +1,33 @@ +import { + createAgent, + DatabricksAdapter, + supervisorTools, +} from "@databricks/appkit/beta"; + +// Supervisor API demo agent. The Databricks AI Gateway executes hosted +// tools server-side; declare them via `createAgent({ tools })` like any +// other agent tool — the agents plugin classifies the tagged record and +// routes it to the adapter via AgentInput.extensions. Uncomment an entry +// below to give the model real powers. +// +// `createAgent({ model })` accepts an adapter promise, so the factory's +// host/credential resolution is awaited lazily on first dispatch (via +// `resolveAdapter` in the agents plugin). A misconfigured workspace will +// surface at first chat request, not at module init. +export default createAgent({ + instructions: + "You are an assistant powered by the Databricks Supervisor API.", + model: DatabricksAdapter.fromSupervisorApi({ + model: "databricks-claude-sonnet-4-5", + }), + tools: () => ({ + nyc: supervisorTools.genieSpace({ + id: process.env.DATABRICKS_GENIE_SPACE_ID ?? "", + description: "NYC taxi trip records and zones", + }), + add: supervisorTools.ucFunction({ + name: process.env.DATABRICKS_UC_FUNCTION_NAME ?? "", + description: "Adds two integers and returns the sum.", + }), + }), +}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index b30c51684..cec291833 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -12,15 +12,7 @@ import { serving, WRITE_ACTIONS, } from "@databricks/appkit"; -import { - agents, - aiSearch, - createAgent, - DatabricksAdapter, - supervisorTools, - tool, -} from "@databricks/appkit/beta"; -import { z } from "zod"; +import { agents, aiSearch } from "@databricks/appkit/beta"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -56,314 +48,6 @@ const adminOnly: FilePolicy = (action, _resource, user) => { return true; }; -// Code-defined demo agent showing the tools(plugins) function form -// alongside the markdown-driven agents in config/agents/. -const helper = createAgent({ - instructions: - "You are a demo helper. Use analytics tools to answer data questions, " + - "or get_weather for light small-talk.", - tools(plugins) { - return { - ...plugins.analytics.toolkit(), - get_weather: tool({ - name: "get_weather", - description: "Get the current weather for a city", - schema: z.object({ city: z.string().describe("City name") }), - execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, - }), - }; - }, -}); - -// Supervisor API demo agent. The Databricks AI Gateway executes hosted -// tools server-side; declare them via `createAgent({ tools })` like any -// other agent tool — the agents plugin classifies the tagged record and -// routes it to the adapter via AgentInput.extensions. Import -// `supervisorTools` from '@databricks/appkit/beta' and uncomment an -// entry below to give the model real powers. -// -// `createAgent({ model })` accepts an adapter promise, so the factory's -// host/credential resolution is awaited lazily on first dispatch (via -// `resolveAdapter` in the agents plugin). A misconfigured workspace will -// surface at first chat request, not at module init. -const supervisor = createAgent({ - instructions: - "You are an assistant powered by the Databricks Supervisor API.", - model: DatabricksAdapter.fromSupervisorApi({ - model: "databricks-claude-sonnet-4-5", - }), - tools: () => ({ - nyc: supervisorTools.genieSpace({ - id: process.env.DATABRICKS_GENIE_SPACE_ID ?? "", - description: "NYC taxi trip records and zones", - }), - add: supervisorTools.ucFunction({ - name: process.env.DATABRICKS_UC_FUNCTION_NAME ?? "", - description: "Adds two integers and returns the sum.", - }), - }), -}); - -/* - * Smart-Dashboard agents. - * - * The three agents form a dispatcher pattern for the /smart-dashboard route. - * The `query` agent (markdown, in config/agents/query/) routes user - * questions to one of two specialists: - * - * - `sql_analyst` — writes Databricks SQL against `samples.nyctaxi.trips` - * using the analytics plugin's query tool. - * - `dashboard_pilot` — emits UI-action tool calls (`apply_filter`, - * `highlight_period`) that the client reads off the SSE stream and - * translates into React state mutations. The server-side handlers are - * intentionally stubs — the tool-call JSON is the action payload. - */ - -// Narrow, single-purpose tools. -// -// The earlier polymorphic `apply_filter({ field, operator, value })` was -// too expressive — the LLM could emit valid-looking calls the dispatcher -// couldn't faithfully apply (e.g. `field: "dropoff_zone"` when the -// dashboard only has a `pickup_zip` filter; `operator: "eq"` with a date). -// Splitting into one tool per filter verb removes the whole class of -// "agent said it worked but nothing moved" bugs. -// -// Each tool has exactly one client-side effect, rendered by -// use-action-dispatcher. Server handlers are still stubs — the tool-call -// JSON is the action payload. - -const filter_by_date_range = tool({ - name: "filter_by_date_range", - description: - "Filter the dashboard to trips within a date range. Both start and end are required and must be ISO dates (YYYY-MM-DD) within 2016.", - schema: z.object({ - start: z.string().describe("Start date in ISO format, e.g. 2016-03-01"), - end: z.string().describe("End date in ISO format, e.g. 2016-03-31"), - }), - execute: async ({ start, end }) => - `Filtered dashboard to trips between ${start} and ${end}.`, -}); - -const filter_by_pickup_zip = tool({ - name: "filter_by_pickup_zip", - description: - "Filter the dashboard to trips originating from a specific pickup ZIP code. Use when the user asks about a specific pickup zone or ZIP.", - schema: z.object({ - zip: z.string().describe("Pickup ZIP code, e.g. 10001"), - }), - execute: async ({ zip }) => - `Filtered dashboard to trips picked up in ${zip}.`, -}); - -const filter_by_fare = tool({ - name: "filter_by_fare", - description: - "Filter the dashboard to trips within a fare range. At least one of min or max must be provided.", - schema: z - .object({ - min: z.number().optional().describe("Minimum fare in USD"), - max: z.number().optional().describe("Maximum fare in USD"), - }) - .refine((v) => v.min !== undefined || v.max !== undefined, { - message: "Provide at least one of min or max.", - }), - execute: async ({ min, max }) => { - const parts = [] as string[]; - if (min !== undefined) parts.push(`>= $${min}`); - if (max !== undefined) parts.push(`<= $${max}`); - return `Filtered dashboard to trips with fare ${parts.join(" and ")}.`; - }, -}); - -const clear_filters = tool({ - name: "clear_filters", - description: - "Remove all active filters from the dashboard. Use when the user asks to reset, clear, or remove filters.", - schema: z.object({}), - execute: async () => "All filters cleared.", -}); - -const highlight_period = tool({ - name: "highlight_period", - description: - "Highlight a time period on the Trips Over Time chart to draw attention to a specific date range.", - schema: z.object({ - start: z.string().describe("Start date in ISO format (YYYY-MM-DD)"), - end: z.string().describe("End date in ISO format (YYYY-MM-DD)"), - color: z - .enum(["blue", "red", "yellow"]) - .optional() - .describe("Highlight color. Defaults to blue."), - label: z - .string() - .optional() - .describe("Optional label for the highlighted period"), - }), - execute: async ({ start, end, color: _color, label }) => { - const suffix = label ? ` (${label})` : ""; - return `Highlighted period ${start} to ${end}${suffix} on the dashboard.`; - }, -}); - -const clear_highlights = tool({ - name: "clear_highlights", - description: - "Remove all highlight overlays from the charts. Use when the user asks to clear, reset, or remove highlights.", - schema: z.object({}), - execute: async () => "All highlights cleared.", -}); - -// Restores a previously saved view. The tool-call arguments are the -// authoritative state: the client listens for this function_call on SSE -// and applies the filters + highlights directly without needing a round -// trip back for metadata. The agent is expected to have looked up the -// saved view server-side before emitting this call (it passes the -// already-resolved state through). -const load_view = tool({ - name: "load_view", - description: - "Restore a previously saved dashboard view by applying its filters and highlights. The caller supplies the already-resolved state so the client can apply it from this tool call without a second round trip.", - schema: z.object({ - name: z.string().describe("The saved view's name (for UI feedback)"), - filters: z - .object({ - date_from: z.string().optional(), - date_to: z.string().optional(), - pickup_zip: z.string().optional(), - fare_min: z.string().optional(), - fare_max: z.string().optional(), - }) - .passthrough() - .describe("Filters to restore. Omit fields that should not be set."), - highlights: z - .array( - z.object({ - start: z.string(), - end: z.string(), - color: z.enum(["blue", "red", "yellow"]).optional(), - label: z.string().optional(), - }), - ) - .describe("Highlight ranges to restore."), - }), - execute: async ({ name }) => `Restored saved view "${name}".`, -}); - -const focus_chart = tool({ - name: "focus_chart", - description: - "Scroll the user's viewport to a specific chart on the dashboard and briefly pulse it to draw attention. Use when the user asks to 'look at' or 'focus on' a specific visualization.", - schema: z.object({ - chart_id: z - .enum([ - "kpis", - "trips_over_time", - "fare_distribution", - "hourly_heatmap", - "top_zones", - ]) - .describe("Which chart to focus on"), - }), - execute: async ({ chart_id }) => `Focused on ${chart_id}.`, -}); - -const highlight_zone = tool({ - name: "highlight_zone", - description: - "Draw an emphasis ring around a specific pickup ZIP on the Top Pickup Zones chart. Use this to call attention to a standout zone without filtering the whole dashboard to that ZIP.", - schema: z.object({ - zip: z.string().describe("Pickup ZIP code to highlight (e.g. '10017')"), - label: z - .string() - .optional() - .describe("Optional short label shown inside the highlighted bar"), - }), - execute: async ({ zip, label }) => - `Highlighted pickup ZIP ${zip}${label ? ` (${label})` : ""}.`, -}); - -const clear_zone_highlights = tool({ - name: "clear_zone_highlights", - description: "Remove all emphasis rings from the Top Pickup Zones chart.", - schema: z.object({}), - execute: async () => "Zone highlights cleared.", -}); - -// Write tool: exercises the approval gate. Server handler is a stub — -// no view persistence — but `effect: "write"` forces the human-in-the-loop -// flow before the agent can call it. We pick `write` (not `destructive`) -// because capturing a view CREATES a new file; nothing is deleted or -// overwritten. The approval card will render the low-severity blue -// "writes" treatment rather than the alarming red "destructive" one. -const save_view = tool({ - name: "save_view", - description: - "Persist the current dashboard configuration (filters + highlights) as a named view the user can recall later. Always surfaces the approval gate as a write action.", - annotations: { effect: "write" }, - schema: z.object({ - name: z.string().describe("Short human-readable name for the saved view"), - description: z - .string() - .optional() - .describe("Optional longer description for the saved view"), - }), - execute: async ({ name, description }) => { - const suffix = description ? `: ${description}` : ""; - return `Saved view "${name}"${suffix}.`; - }, -}); - -const sql_analyst = createAgent({ - instructions: [ - "You are a SQL expert for NYC taxi trip data (`samples.nyctaxi.trips`).", - "Write Databricks SQL to answer the user's question and summarize the results clearly.", - "IMPORTANT: The dataset only contains trips from 2016. Always add `WHERE tpep_pickup_datetime >= '2016-01-01' AND tpep_pickup_datetime < '2017-01-01'` unless the user specifies a narrower date range within 2016.", - "If the user asks about dates outside 2016, say the dataset only covers 2016.", - "Available columns: tpep_pickup_datetime, tpep_dropoff_datetime, trip_distance, fare_amount, pickup_zip, dropoff_zip.", - ].join(" "), - tools(plugins) { - return { ...plugins.analytics.toolkit() }; - }, -}); - -const dashboard_pilot = createAgent({ - instructions: [ - "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", - "Filters:", - "- `filter_by_date_range({start, end})` — narrow to a date window within 2016.", - "- `filter_by_pickup_zip({zip})` — narrow to trips from a specific ZIP.", - "- `filter_by_fare({min?, max?})` — narrow by fare range (at least one bound required).", - "- `clear_filters()` — remove all active filters.", - "Highlights:", - "- `highlight_period({start, end, color?, label?})` — shade a date window on the Trips Over Time chart.", - "- `clear_highlights()` — remove all shaded overlays from the trips chart.", - "- `highlight_zone({zip, label?})` — draw an emphasis ring around a specific ZIP on the Top Pickup Zones chart.", - "- `clear_zone_highlights()` — remove all ZIP emphasis rings.", - "Focus & save:", - "- `focus_chart({chart_id})` — scroll the viewport to one of `kpis`, `trips_over_time`, `fare_distribution`, `hourly_heatmap`, `top_zones` and briefly pulse it.", - "- `save_view({name, description?})` — persist the current configuration. Write action; the user will see an approval card.", - "- `load_view({name, filters, highlights})` — restore a previously saved view. Always pass the resolved state; never leave fields unset.", - "Rules:", - "1. Pick the single tool that matches the user's intent. Do not chain filters unless the user asks for a compound filter.", - "2. Briefly state what you did after the tool returns. Do not narrate before calling the tool.", - "3. If the user's request is ambiguous (e.g. 'filter to last month' without a 2016 context), ask one clarifying question before calling any tool.", - "4. For standout ZIPs, prefer `highlight_zone` over `filter_by_pickup_zip` so the rest of the dashboard stays in context. Only filter when the user explicitly asks to narrow the whole dashboard.", - ].join("\n"), - tools: { - filter_by_date_range, - filter_by_pickup_zip, - filter_by_fare, - clear_filters, - highlight_period, - clear_highlights, - highlight_zone, - clear_zone_highlights, - focus_chart, - save_view, - load_view, - }, -}); - /** * OBO demo policy: deny anything running as the SP (including the dev * fallback when no `x-forwarded-access-token` is present). Only real @@ -421,13 +105,14 @@ createApp({ }), serving(), agents({ - agents: { helper, sql_analyst, dashboard_pilot, supervisor }, - // `query` (markdown dispatcher) + `sql_analyst` + `dashboard_pilot` - // wire the /smart-dashboard route. `insights` and `anomaly` are - // ephemeral markdown agents auto-fired by the route's AgentSidebar. - // `helper` is the conversational default for the bare `/agent` route - // (the markdown agents are dispatchers or ephemeral and don't make - // sense as the user-facing landing agent). + // Code agents are discovered from server/agents/ (helper, supervisor, + // sql_analyst, dashboard_pilot); markdown agents from config/agents/. + // `query` (markdown dispatcher) delegates to the discovered + // `sql_analyst` + `dashboard_pilot` to wire the /smart-dashboard route. + // `insights` and `anomaly` are ephemeral markdown agents auto-fired by + // the route's AgentSidebar. `helper` is the conversational default for + // the bare `/agent` route (the markdown agents are dispatchers or + // ephemeral and don't make sense as the user-facing landing agent). defaultAgent: "helper", }), aiSearch({ diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 8996e759c..46d9534db 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -22,6 +22,19 @@ Override the plugin's baseSystemPrompt for this agent only. *** +### default? + +```ts +optional default: boolean; +``` + +Marks this agent as the default one chosen when a client doesn't name an +agent. Mirrors markdown frontmatter `default: true`. When several agents +set it, the first in stable id order wins; an explicit +`agents({ defaultAgent })` always overrides it. Defaults to `false`. + +*** + ### ephemeral? ```ts diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index c038d41c1..257fba74b 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -14,13 +14,20 @@ Base configuration interface for AppKit plugins ## Properties -### agents? +### ~~agents?~~ ```ts optional agents: Record; ``` -Code-defined agents, merged with file-loaded ones (code wins on key collision). +#### Deprecated + +Put each code agent in its own file under `server/agents/` +(`export default createAgent({ ... })`); `appkit generate-agents` +discovers them automatically and the call collapses to `agents({ ... })` +with no map. Still honored for backward compatibility (emits a one-time +deprecation warning) but will be removed in a future minor. Discovered +agents and this map may not both define the same id. *** @@ -83,6 +90,20 @@ Customize or disable the AppKit base system prompt. *** +### codeAgentsDir? + +```ts +optional codeAgentsDir: string | false; +``` + +Directory of code agents (one `.ts` file per agent, each +`export default createAgent({ ... })`). Discovered at startup and merged +with markdown agents. Defaults to `server/agents` in dev and the compiled +`dist/agents` in a production build (resolved by `NODE_ENV`). Set to `false` +to disable code-agent discovery, or a string to point at a custom directory. + +*** + ### defaultAgent? ```ts diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index d1b7a79e4..bd9dd570d 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -6,7 +6,7 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor ::: -The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It loads agent definitions from markdown on disk (one folder per agent: `config/agents//agent.md`), from TypeScript (`createAgent(def)`), or both, and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. +The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It discovers agent definitions from disk — markdown packages (one folder per agent: `config/agents//agent.md`) and code agents (one file per agent: `server/agents/.ts`) — and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. In both cases the agent's id is its filename/folder name; there's no map to maintain and no id to restate. This page covers the full lifecycle. For the hand-written primitives (`tool()`, `mcpServer()`), see [tools](./server.md). @@ -100,12 +100,14 @@ When any `tools:` is declared the auto-inherit default is turned off — the age ## Level 3: code-defined agents +Code agents live one-per-file under `server/agents/`. Each file exports a created agent and its **id is the filename** (`server/agents/support.ts` → `support`), mirroring how a markdown agent's id is its folder name. Nothing restates the id. + ```ts -import { analytics, createApp, files, server } from "@databricks/appkit"; -import { agents, createAgent, tool } from "@databricks/appkit/beta"; +// server/agents/support.ts +import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; -const support = createAgent({ +export default createAgent({ // id derived from filename: "support" instructions: "You help customers with data and files.", model: "databricks-claude-sonnet-4-5", // string sugar tools(plugins) { @@ -120,18 +122,34 @@ const support = createAgent({ }; }, }); +``` + +The `agents` plugin discovers these files at startup — no registration, no map: + +```ts +// server/server.ts +import { analytics, createApp, files, server } from "@databricks/appkit"; +import { agents } from "@databricks/appkit/beta"; await createApp({ - plugins: [server(), analytics(), files(), agents({ agents: { support } })], + plugins: [server(), analytics(), files(), agents()], // no agent map, no import }); ``` +Discovery scans the code-agents directory and imports each module: `server/agents/*.ts` under `tsx` in dev, and the compiled `dist/agents/*.js` in a production build (chosen by `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*.ts` as build entries so `dist/agents/*.js` are emitted for the scan — that wiring is what lets a dropped-in file survive the prod bundle. The directory is `server/agents` by default; override or disable it with `agents({ codeAgentsDir })` (a path, or `false`). + +A file may `export default createAgent({...})` or export a single named created agent; either way the id is the filename. A module that exports no created agent (a shared helper) is skipped. Mark one agent as the default with `createAgent({ default: true })` (mirrors markdown frontmatter `default: true`); an explicit `agents({ defaultAgent })` still wins. + Code-defined agents start with no tools by default. The function form `tools(plugins) => Record` is the primary way to pull in plugin tools: each plugin registered in `createApp({ plugins: [...] })` shows up on the `plugins` parameter, and you call `.toolkit(opts?)` on it to get a spread-friendly record. The runtime invokes the function once at agent setup and caches the result — every plugin is mentioned exactly once (in `createApp`), with no held variables or marker imports. -Inline `tool({...})` calls live in the same record. `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). +Inline `tool({...})` calls live in the same record. Their `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. +:::warning Deprecated: the `agents({ agents: { ... } })` map +Passing a hand-built agent map still works and is honored for backward compatibility, but it emits a one-time deprecation warning and will be removed in a future minor. It restates each agent's id (once in `createAgent`, once as the map key); discovery from `server/agents/` removes both the map and the restatement. Migrate by moving each `createAgent(...)` into its own `server/agents/.ts` (default or single named export) and dropping the map. A discovered agent and a map entry may not share an id. (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) +::: + ### Scoping tools in code `plugins..toolkit(opts?)` accepts the same `ToolkitOptions` as markdown frontmatter: @@ -167,15 +185,15 @@ const supervisor = createAgent({ agents: { researcher, writer }, // exposed as agent-researcher, agent-writer }); +// server/agents/supervisor.ts (+ researcher.ts, writer.ts) — one file each +export default supervisor; + await createApp({ - plugins: [ - server(), - agents({ agents: { supervisor, researcher, writer } }), - ], + plugins: [server(), agents()], // discovered from server/agents/ }); ``` -Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. +Put `supervisor`, `researcher`, and `writer` in their own `server/agents/*.ts` files (default export each) — a markdown parent can also delegate to a discovered code child via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. ## Level 5: standalone (no `createApp`) @@ -351,8 +369,9 @@ Some hosted tool kinds return their final assistant text without incremental `ou ```ts agents({ - dir?: string | false, // "./config/agents" default; false disables - agents?: Record, + dir?: string | false, // markdown agents; "./config/agents" default; false disables + codeAgentsDir?: string | false, // code agents; "server/agents" (dev) / "dist/agents" (prod); false disables + agents?: Record, // DEPRECATED — use server/agents/ discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, tools?: Record, diff --git a/packages/appkit/src/core/agent/create-agent.ts b/packages/appkit/src/core/agent/create-agent.ts index b4b119010..20d52f22c 100644 --- a/packages/appkit/src/core/agent/create-agent.ts +++ b/packages/appkit/src/core/agent/create-agent.ts @@ -1,6 +1,18 @@ import { ConfigurationError } from "../../errors"; import type { AgentDefinition } from "./types"; +/** + * Non-enumerable brand stamped on every {@link createAgent} result. The + * code-agent loader ({@link loadCodeAgentsFromDir}) uses it to tell a real + * agent export from any other value a module in `server/agents/` might + * export, without duck-typing or guessing from the filename. + * + * A registered (`Symbol.for`) symbol so the check still holds if two copies + * of the package end up loaded in one process — the app's agent files and + * the plugin can resolve `@databricks/appkit` independently. + */ +const AGENT_BRAND: unique symbol = Symbol.for("appkit.agent"); + /** * Pure factory for agent definitions. Returns the passed-in definition after * cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape @@ -23,9 +35,29 @@ import type { AgentDefinition } from "./types"; */ export function createAgent(def: AgentDefinition): AgentDefinition { detectCycles(def); + // Brand for runtime discovery. Non-enumerable so it never shows up in + // spreads or JSON, and defined in-place so the returned value stays + // identical to the input (`createAgent(def) === def`). + Object.defineProperty(def, AGENT_BRAND, { + value: true, + enumerable: false, + configurable: true, + }); return def; } +/** + * Type guard: true when `value` was produced by {@link createAgent}. Used by + * the code-agent loader to pick the agent export out of a discovered module. + */ +export function isCreatedAgent(value: unknown): value is AgentDefinition { + return ( + typeof value === "object" && + value !== null && + (value as Record)[AGENT_BRAND] === true + ); +} + /** * Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is * found. Cycles would cause infinite recursion at tool-invocation time. diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts new file mode 100644 index 000000000..daacc7167 --- /dev/null +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -0,0 +1,134 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createLogger } from "../../logging/logger"; +import { isCreatedAgent } from "./create-agent"; +import type { AgentDefinition } from "./types"; + +const logger = createLogger("agents:code-loader"); + +/** Files in the code-agents dir that are never themselves agents. */ +function isIgnored(name: string): boolean { + return ( + name.endsWith(".d.ts") || + name.endsWith(".test.ts") || + name.endsWith(".test.tsx") || + name.endsWith(".test.js") || + name.endsWith(".spec.ts") || + name.endsWith(".spec.tsx") || + name.endsWith(".spec.js") || + /^index\.(ts|tsx|js|mjs)$/.test(name) + ); +} + +/** + * Picks the single created agent a module exports. Prefers the default + * export; otherwise accepts exactly one branded named export. Returns + * `undefined` when the module exports no agent (e.g. a helper file or a + * bundler-emitted chunk sitting next to the agents), and throws when a + * single file exports more than one agent (the filename is the id, so it + * can only stand for one). + */ +function pickAgentExport( + mod: Record, + filePath: string, +): AgentDefinition | undefined { + if (isCreatedAgent(mod.default)) return mod.default; + + const named = Object.entries(mod).filter( + ([key, value]) => key !== "default" && isCreatedAgent(value), + ); + if (named.length === 0) return undefined; + if (named.length > 1) { + throw new Error( + `Agent file '${filePath}' exports ${named.length} created agents (${named + .map(([k]) => k) + .join(", ")}); expected exactly one. ` + + "Split them into one file per agent (the filename is the agent id).", + ); + } + return named[0][1] as AgentDefinition; +} + +/** + * Discovers code agents by importing every module in `dir` and taking the + * agent each exports. The agent's id is its filename without extension — + * the single source of truth — mirroring how a markdown agent's id is its + * folder name. + * + * This is the runtime counterpart to the markdown `loadAgentsFromDir`: dev + * points at the `.ts` sources (run under `tsx`), a bundled server points at + * the compiled `.js` in `dist/`. The caller resolves which directory and + * which extensions apply; this function just imports and brand-checks. + * + * Returns an empty record when the directory does not exist. Files that + * export no agent are skipped (debug-logged); a syntax/import error in an + * agent file, a duplicate id, or a multi-agent file all throw with the + * offending path. + */ +export async function loadCodeAgentsFromDir( + dir: string, + opts: { extensions: string[] }, +): Promise> { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } + + const files = entries + .filter( + (e) => + e.isFile() && + opts.extensions.some((ext) => e.name.endsWith(ext)) && + !isIgnored(e.name), + ) + .map((e) => e.name) + .sort(); + + const agents: Record = {}; + const sourceById = new Map(); + + for (const file of files) { + const filePath = path.join(dir, file); + + let mod: Record; + try { + mod = (await import(pathToFileURL(filePath).href)) as Record< + string, + unknown + >; + } catch (err) { + throw new Error( + `Failed to import code agent '${filePath}': ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err instanceof Error ? err : undefined }, + ); + } + + const agent = pickAgentExport(mod, filePath); + if (!agent) { + logger.debug( + "Skipping %s — no createAgent export (not a code agent).", + filePath, + ); + continue; + } + + const id = file.replace(/\.(ts|tsx|js|mjs|cjs)$/, ""); + const prior = sourceById.get(id); + if (prior) { + throw new Error( + `Duplicate code-agent id '${id}': both '${prior}' and '${file}' resolve to it. Rename one file.`, + ); + } + sourceById.set(id, file); + agents[id] = agent; + } + + return agents; +} diff --git a/packages/appkit/src/core/agent/tests/create-agent.test.ts b/packages/appkit/src/core/agent/tests/create-agent.test.ts index 46668e1a0..f336752e8 100644 --- a/packages/appkit/src/core/agent/tests/create-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/create-agent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; -import { createAgent } from "../create-agent"; +import { createAgent, isCreatedAgent } from "../create-agent"; import { tool } from "../tools/tool"; import type { AgentDefinition } from "../types"; @@ -36,6 +36,35 @@ describe("createAgent", () => { } }); + test("name is optional (id is derived elsewhere)", () => { + const def = createAgent({ instructions: "no name here" }); + expect(def.name).toBeUndefined(); + expect(def.instructions).toBe("no name here"); + }); + + test("carries the default flag through unchanged", () => { + const def = createAgent({ + instructions: "I am the default.", + default: true, + }); + expect(def.default).toBe(true); + }); + + test("brands the result so the code-agent loader can recognize it", () => { + const def = createAgent({ instructions: "branded" }); + expect(isCreatedAgent(def)).toBe(true); + // The brand is non-enumerable — invisible to spread and JSON. + expect(Object.keys(def)).not.toContain("Symbol(appkit.agent)"); + expect(JSON.parse(JSON.stringify(def))).toEqual({ + instructions: "branded", + }); + // Plain objects are not agents. + expect(isCreatedAgent({ instructions: "not made by createAgent" })).toBe( + false, + ); + expect(isCreatedAgent(null)).toBe(false); + }); + test("accepts sub-agents in a keyed record", () => { const researcher = createAgent({ instructions: "Research." }); const supervisor = createAgent({ diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 572879565..85ff749ad 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -142,6 +142,13 @@ export interface AgentDefinition { * entirely. */ name?: string; + /** + * Marks this agent as the default one chosen when a client doesn't name an + * agent. Mirrors markdown frontmatter `default: true`. When several agents + * set it, the first in stable id order wins; an explicit + * `agents({ defaultAgent })` always overrides it. Defaults to `false`. + */ + default?: boolean; /** System prompt body. For markdown-loaded agents this is the file body. */ instructions: string; /** @@ -208,7 +215,22 @@ export interface AutoInheritToolsConfig { export interface AgentsPluginConfig extends BasePluginConfig { /** Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. */ dir?: string | false; - /** Code-defined agents, merged with file-loaded ones (code wins on key collision). */ + /** + * Directory of code agents (one `.ts` file per agent, each + * `export default createAgent({ ... })`). Discovered at startup and merged + * with markdown agents. Defaults to `server/agents` in dev and the compiled + * `dist/agents` in a production build (resolved by `NODE_ENV`). Set to `false` + * to disable code-agent discovery, or a string to point at a custom directory. + */ + codeAgentsDir?: string | false; + /** + * @deprecated Put each code agent in its own file under `server/agents/` + * (`export default createAgent({ ... })`); `appkit generate-agents` + * discovers them automatically and the call collapses to `agents({ ... })` + * with no map. Still honored for backward compatibility (emits a one-time + * deprecation warning) but will be removed in a future minor. Discovered + * agents and this map may not both define the same id. + */ agents?: Record; /** Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter. */ defaultAgent?: string; diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 1d9162a96..fd36f52ec 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import type express from "express"; import pc from "picocolors"; @@ -23,6 +24,7 @@ import { import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; +import { loadCodeAgentsFromDir } from "../../core/agent/load-code-agents"; import { normalizeToolResult } from "../../core/agent/normalize-result"; import { createPluginsProxy } from "../../core/agent/plugins-map"; import { @@ -75,6 +77,14 @@ const logger = createLogger("agents"); const DEFAULT_AGENTS_DIR = "./config/agents"; +/** Where code agents live in source (dev, run under `tsx`). */ +const CODE_AGENTS_SOURCE_DIR = "server/agents"; +/** + * Where they land once compiled into the server bundle (production). Probed + * in order — `tsdown` projects conventionally emit to `dist/` or `build/`. + */ +const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/agents"]; + /** * Context flag recorded on the in-memory AgentDefinition to indicate whether * it came from markdown (file) or from user code. Drives the asymmetric @@ -165,6 +175,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { private mcpClient: AppKitMcpClient | null = null; private threadStore; private approvalGate = new ToolApprovalGate(); + /** Guards the `agents({ agents })` deprecation warning to once per instance. */ + private agentsMapDeprecationWarned = false; constructor(config: AgentsPluginConfig) { super(config); @@ -331,45 +343,83 @@ export class AgentsPlugin extends Plugin implements ToolProvider { agents: Map; defaultAgentName: string | null; }> { - const { defs: fileDefs, defaultAgent: fileDefault } = - await this.loadFileDefinitions(); - - const codeDefs = this.config.agents ?? {}; + // Two code-agent sources: agents discovered from the code-agents dir + // (server/agents in dev, dist/agents in a bundled server) and the + // deprecated `agents({ agents })` map. Both are "code" origin; markdown + // agents are loaded separately below. + const discovered = await this.loadCodeAgents(); + const deprecatedMap = this.config.agents ?? {}; + + if (Object.keys(deprecatedMap).length > 0) { + this.warnAgentsMapDeprecated(); + } - for (const name of Object.keys(fileDefs)) { - if (codeDefs[name]) { - logger.warn( - "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", - name, + // Same id in both code sources is ambiguous — a file AND a hand-written + // map entry claim it. Fail loud rather than silently pick one. + for (const id of Object.keys(discovered)) { + if (deprecatedMap[id]) { + throw new Error( + `Agent '${id}' is both discovered in ${this.resolvedAgentsDir() ?? "server/agents"} and passed to agents({ agents: { ${id} } }). ` + + "Remove the map entry — discovery already registers it.", ); } } + // Code agents also feed markdown sub-agent resolution: a markdown parent + // with `agents: [helper]` frontmatter resolves `helper` against these. + const codeAgents: Record = { + ...discovered, + ...deprecatedMap, + }; + + const { defs: fileDefs, defaultAgent: fileDefault } = + await this.loadFileDefinitions(codeAgents); + + // Build the merged registry. Order: markdown, then discovered, then the + // deprecated map — this order also determines the "first registered" + // default fallback. const merged: Record = {}; for (const [name, def] of Object.entries(fileDefs)) { merged[name] = { def, src: { origin: "file" } }; } - for (const [name, def] of Object.entries(codeDefs)) { + for (const [name, def] of Object.entries(discovered)) { + if (merged[name]?.src.origin === "file") { + // Discovery is new API, so a discovered/markdown clash is a hard error + // (unlike the grandfathered map-vs-markdown warning below). + throw new Error( + `Agent '${name}' is defined as both a code agent (server/agents/${name}.ts) and a markdown agent. ` + + `Rename one. Available: ${Object.keys(merged).sort().join(", ")}`, + ); + } + merged[name] = { def, src: { origin: "code" } }; + } + for (const [name, def] of Object.entries(deprecatedMap)) { + if (merged[name]?.src.origin === "file") { + logger.warn( + "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", + name, + ); + } merged[name] = { def, src: { origin: "code" } }; } const agents = new Map(); - let defaultAgentName: string | null = null; + let firstRegistered: string | null = null; if (Object.keys(merged).length === 0) { logger.info( - "No agents registered (no files in %s, no code-defined agents)", + "No agents registered (no files in %s, no discovered or code-defined agents)", this.resolvedAgentsDir() ?? "", ); - return { agents, defaultAgentName }; + return { agents, defaultAgentName: null }; } for (const [name, { def, src }] of Object.entries(merged)) { try { const registered = await this.buildRegisteredAgent(name, def, src); agents.set(name, registered); - if (!defaultAgentName) defaultAgentName = name; + if (!firstRegistered) firstRegistered = name; } catch (err) { throw new Error( `Failed to register agent '${name}' (${src.origin}): ${ @@ -380,18 +430,62 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } } + return { + agents, + defaultAgentName: this.resolveDefaultAgent( + agents, + merged, + fileDefault, + firstRegistered, + ), + }; + } + + /** + * Resolves the default agent. Precedence: explicit `config.defaultAgent` > + * a code/discovered agent flagged `default: true` (stable id order) > + * markdown `default: true` > first registered (stable order). + */ + private resolveDefaultAgent( + agents: Map, + merged: Record, + fileDefault: string | null, + firstRegistered: string | null, + ): string | null { if (this.config.defaultAgent) { if (!agents.has(this.config.defaultAgent)) { throw new Error( `defaultAgent '${this.config.defaultAgent}' is not registered. Available: ${Array.from(agents.keys()).join(", ")}`, ); } - defaultAgentName = this.config.defaultAgent; - } else if (fileDefault && agents.has(fileDefault)) { - defaultAgentName = fileDefault; + return this.config.defaultAgent; } - return { agents, defaultAgentName }; + const codeDefault = Object.keys(merged) + .filter( + (id) => merged[id].src.origin === "code" && merged[id].def.default, + ) + .sort()[0]; + if (codeDefault) return codeDefault; + + if (fileDefault && agents.has(fileDefault)) return fileDefault; + + return firstRegistered; + } + + /** + * Emits the one-time deprecation warning for the `agents({ agents })` map. + * Guarded so `reload()` (which re-runs `buildAgentRegistry`) doesn't spam it. + */ + private warnAgentsMapDeprecated(): void { + if (this.agentsMapDeprecationWarned) return; + this.agentsMapDeprecationWarned = true; + logger.warn( + "agents({ agents: { ... } }) is deprecated. Put each code agent in its own file under " + + "server/agents/ (export default createAgent({ ... })) and it is discovered automatically — " + + "the call collapses to agents({ ... }) with no agent map. The `agents` field still works but " + + "will be removed in a future minor. See docs/plugins/agents.md.", + ); } private resolvedAgentsDir(): string | null { @@ -400,7 +494,104 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); } - private async loadFileDefinitions(): Promise<{ + /** + * Discovers code agents from the code-agents directory (default + * `server/agents`). See {@link loadCodeAgentsFromDir} for the contract. + * + * When nothing is found in a production build, emits a loud warning: the + * most likely cause is the bundler not emitting `dist/agents/*.js` (e.g. a + * missing `server/agents/*.ts` entry in the tsdown config), which would + * otherwise leave the app running with silently-missing agents. + */ + private async loadCodeAgents(): Promise> { + const resolved = this.resolveCodeAgentsDir(); + if (!resolved) return {}; + + const discovered = await loadCodeAgentsFromDir(resolved.dir, { + extensions: resolved.extensions, + }); + + if ( + resolved.isProduction && + Object.keys(discovered).length === 0 && + this.hasCodeAgentSources() + ) { + logger.warn( + "No code agents were loaded from %s in this production build, but source files exist in %s. " + + "The bundler may not have emitted them — ensure `server/agents/*.ts` is included as tsdown entries so `dist/agents/*.js` are produced.", + resolved.dir, + path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), + ); + } + + return discovered; + } + + /** + * Resolves which directory to scan for code agents and which extensions to + * look for. + * + * - `codeAgentsDir: false` disables code-agent discovery. + * - A string override is used verbatim (accepts any of the module + * extensions) — used by tests and by apps with a non-standard layout. + * - Otherwise the convention: a production build runs compiled output, so + * prefer `dist/agents/*.js`; dev runs sources under `tsx`, so prefer + * `server/agents/*.ts`. The `NODE_ENV` check is what keeps a stale + * `dist/` from a previous build from shadowing live sources in dev. The + * non-preferred location is a fallback if the preferred one is absent. + */ + private resolveCodeAgentsDir(): { + dir: string; + extensions: string[]; + isProduction: boolean; + } | null { + const isProduction = process.env.NODE_ENV === "production"; + if (this.config.codeAgentsDir === false) return null; + + if (typeof this.config.codeAgentsDir === "string") { + const dir = path.isAbsolute(this.config.codeAgentsDir) + ? this.config.codeAgentsDir + : path.resolve(process.cwd(), this.config.codeAgentsDir); + return { dir, extensions: [".ts", ".tsx", ".js", ".mjs"], isProduction }; + } + + const source = { + dir: path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), + extensions: [".ts", ".tsx"], + isProduction, + }; + const built = CODE_AGENTS_BUILT_DIRS.map((d) => ({ + dir: path.resolve(process.cwd(), d), + extensions: [".js", ".mjs"], + isProduction, + })); + // Built dirs first, source last — flipped in dev so a stale dist/ or + // build/ from a previous compile can't shadow live sources under tsx. + const order = isProduction ? [...built, source] : [source, ...built]; + for (const candidate of order) { + if (existsSync(candidate.dir)) return candidate; + } + // Nothing exists yet: target the preferred location so a downstream + // ENOENT resolves to an empty registry (and the prod warning can fire). + return order[0]; + } + + /** True when the code-agent source dir has at least one `.ts`/`.tsx` file. */ + private hasCodeAgentSources(): boolean { + const srcDir = path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); + try { + return readdirSync(srcDir).some( + (f) => + (f.endsWith(".ts") || f.endsWith(".tsx")) && !f.endsWith(".d.ts"), + ); + } catch { + return false; + } + } + + private async loadFileDefinitions( + codeAgents: Record, + ): Promise<{ defs: Record; defaultAgent: string | null; }> { @@ -414,7 +605,10 @@ export class AgentsPlugin extends Plugin implements ToolProvider { defaultModel: this.config.defaultModel, availableTools: ambient, plugins: pluginToolProviders, - codeAgents: this.config.agents, + // Discovered code agents + the deprecated map both resolve markdown + // `agents:` sub-agent references, so a markdown parent can delegate to + // a discovered code child (e.g. planner → helper). + codeAgents, }); return result; diff --git a/packages/appkit/src/plugins/agents/tests/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts new file mode 100644 index 000000000..b262ae749 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentAdapter, AgentInput, AgentRunContext } from "shared"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { CacheManager } from "../../../cache"; +import type { AgentsPluginConfig } from "../../../core/agent/types"; +import { AgentsPlugin } from "../agents"; + +/** Absolute path to a committed code-agent fixture directory. */ +const fixtureDir = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +function stubAdapter(): AgentAdapter { + return { + async *run(_input: AgentInput, _ctx: AgentRunContext) { + yield { type: "message_delta", content: "" }; + }, + }; +} + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-discovery-")); + // Agent setup reads the cache singleton; initialize it with defaults. + await CacheManager.getInstance(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function instantiate(config: AgentsPluginConfig) { + const plugin = new AgentsPlugin({ ...config, name: "agent" }); + plugin.attachContext({ context: undefined as unknown as object }); + return plugin; +} + +function writeMarkdownAgent(dir: string, id: string, content: string) { + const folder = path.join(dir, id); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync(path.join(folder, "agent.md"), content, "utf-8"); +} + +type ExportsApi = { + list: () => string[]; + get: (name: string) => { toolIndex: Map } | null; + getDefault: () => string | null; +}; + +describe("AgentsPlugin code-agent discovery", () => { + test("discovers code agents from the dir with no map at the call site", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + // notAnAgent.ts is skipped; builder + helper are discovered. + expect(api.list().sort()).toEqual(["builder", "helper"]); + expect(api.getDefault()).toBe("builder"); + }); + + test("honors default: true on a discovered agent", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("a discovered default: true beats markdown default: true", async () => { + writeMarkdownAgent(tmpDir, "planner", "---\ndefault: true\n---\nPlan."); + const plugin = instantiate({ + dir: tmpDir, + codeAgentsDir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("explicit defaultAgent overrides a discovered default: true", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents-default"), + defaultAgent: "alpha", + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("alpha"); + }); + + test("a markdown parent can delegate to a discovered code sub-agent", async () => { + writeMarkdownAgent( + tmpDir, + "planner", + "---\ndefault: true\nagents:\n - helper\n---\nPlan.", + ); + const plugin = instantiate({ + dir: tmpDir, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["builder", "helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + expect(api.getDefault()).toBe("planner"); + }); + + test("throws when a discovered id collides with the deprecated map", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + agents: { helper: { instructions: "map", model: stubAdapter() } }, + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow( + /both discovered .* and passed to agents\(\{ agents/, + ); + }); + + test("throws when a discovered id collides with a markdown agent", async () => { + writeMarkdownAgent(tmpDir, "helper", "---\n---\nFrom markdown."); + const plugin = instantiate({ + dir: tmpDir, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow( + /both a code agent .* and a markdown agent/, + ); + }); + + test("emits a one-time deprecation warning for agents({ agents }) and none for discovery", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const deprecated = instantiate({ + dir: false, + codeAgentsDir: false, + agents: { legacy: { instructions: "x", model: stubAdapter() } }, + }); + await deprecated.setup(); + await deprecated.reload(); // must not re-warn + + const deprecationWarnings = warnSpy.mock.calls + .map((args) => args.join(" ")) + .filter((s) => s.includes("agents: { ... } }) is deprecated")); + expect(deprecationWarnings).toHaveLength(1); + + warnSpy.mockClear(); + const discoveredPlugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await discoveredPlugin.setup(); + + const discoveryWarnings = warnSpy.mock.calls + .map((args) => args.join(" ")) + .filter((s) => s.includes("is deprecated")); + expect(discoveryWarnings).toHaveLength(0); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts new file mode 100644 index 000000000..5e4328357 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "alpha" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts new file mode 100644 index 000000000..d866cd68e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "beta", default: true }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts new file mode 100644 index 000000000..9b12ab541 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "from ts" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx new file mode 100644 index 000000000..07b04b878 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "from tsx" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts new file mode 100644 index 000000000..e10e1465e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts @@ -0,0 +1,3 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export const a = createAgent({ instructions: "a" }); +export const b = createAgent({ instructions: "b" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts new file mode 100644 index 000000000..f8893c5e9 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "I build." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts new file mode 100644 index 000000000..f3a7a1a88 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../core/agent/create-agent"; +export const helper = createAgent({ instructions: "I help." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts new file mode 100644 index 000000000..2a04d9777 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts @@ -0,0 +1,2 @@ +// A helper module that is not an agent — the loader must skip it. +export const CONSTANT = 42; diff --git a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts new file mode 100644 index 000000000..0d7bf7792 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -0,0 +1,41 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { loadCodeAgentsFromDir } from "../../../core/agent/load-code-agents"; + +const fixtureDir = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +const TS = { extensions: [".ts", ".tsx"] }; + +describe("loadCodeAgentsFromDir", () => { + it("returns an empty record when the directory does not exist", async () => { + expect( + await loadCodeAgentsFromDir(fixtureDir("does-not-exist"), TS), + ).toEqual({}); + }); + + it("discovers default and named agent exports, id = filename", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + expect(Object.keys(agents).sort()).toEqual(["builder", "helper"]); + expect(agents.builder.instructions).toBe("I build."); + expect(agents.helper.instructions).toBe("I help."); + }); + + it("skips modules that export no created agent", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + // notAnAgent.ts exports a plain constant — must not be registered. + expect(agents.notAnAgent).toBeUndefined(); + }); + + it("throws when one file exports more than one agent", async () => { + await expect( + loadCodeAgentsFromDir(fixtureDir("code-agents-multi"), TS), + ).rejects.toThrow(/exports 2 created agents/); + }); + + it("throws on a duplicate id across .ts and .tsx", async () => { + await expect( + loadCodeAgentsFromDir(fixtureDir("code-agents-dup"), TS), + ).rejects.toThrow(/Duplicate code-agent id 'dup'/); + }); +}); diff --git a/template/server/agents/helper.ts b/template/server/agents/helper.ts index 47a69f00a..7ef751ea6 100644 --- a/template/server/agents/helper.ts +++ b/template/server/agents/helper.ts @@ -3,13 +3,15 @@ import { createAgent, tool } from '@databricks/appkit/beta'; import { z } from 'zod'; /** - * Code-defined helper agent: holds the tools. Shipped as a sub-agent of - * the user-facing `planner` markdown agent (which references it via - * `agents: [helper]` in its frontmatter) rather than a chat-tab on its - * own. When the user asks planner for a computational action — "what - * time is it?", "count the words in this string" — planner calls the - * `agent-helper` tool, the agents plugin routes the sub-agent - * invocation here, and the answer flows back into the planner thread. + * Code-defined helper agent: holds the tools. This file lives in + * `server/agents/`, so `appkit generate-agents` discovers it automatically — + * its agent id is the filename (`helper`), and nothing needs to restate it. + * Shipped as a sub-agent of the user-facing `planner` markdown agent (which + * references it via `agents: [helper]` in its frontmatter) rather than a + * chat-tab on its own. When the user asks planner for a computational action — + * "what time is it?", "count the words in this string" — planner calls the + * `agent-helper` tool, the agents plugin routes the sub-agent invocation here, + * and the answer flows back into the planner thread. * * Two reasons to keep this code-defined instead of folding it into the * markdown: @@ -26,8 +28,7 @@ import { z } from 'zod'; * volumes, no external APIs) so the round-trip works on a bare * scaffold regardless of which other plugins were selected. */ -export const helper = createAgent({ - name: 'helper', +export default createAgent({ instructions: [ 'You are a tool-using helper agent.', 'When the user asks about the time, call `current_time`.', diff --git a/template/server/server.ts b/template/server/server.ts index 47f8f9c1c..b33bb94d8 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -15,18 +15,11 @@ import { {{$betaImports}} } from '@databricks/appkit/beta'; {{- if .plugins.lakebase}} import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes'; {{- end}} -{{- if .plugins.agents}} -import { helper } from './agents/helper'; -{{- end}} createApp({ plugins: [ {{- range $name, $_ := .plugins}} -{{- if eq $name "agents"}} - agents({ agents: { helper } }), -{{- else}} {{$name}}(), -{{- end}} {{- end}} ], {{- if .plugins.lakebase}} diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index 759b79d00..ed0f4f784 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: 'server/server.ts', + // Code agents live in server/agents/ and are auto-discovered at runtime. + // They are not statically imported anywhere, so they are listed as entries + // here to force the bundler to emit dist/agents/*.js for the discovery scan. + entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json', From 36a3933152e3ed9cde870f573dd12954b63a14de Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 14 Aug 2026 16:38:53 +0200 Subject: [PATCH 2/5] fix(appkit): address code-review findings for agent discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve the code-agents dir built-first and NODE_ENV-independently (prefer dist/agents|build/agents .js; fall back to server/agents .ts only when no built dir exists). Fixes a boot crash where a compiled server run without NODE_ENV=production imported .ts under plain Node. - Loader gracefully skips (warns) when it cannot import a module for lack of a TS loader, instead of crashing setup. - Drop the phantom `appkit generate-agents` command from the deprecation JSDoc and the scaffold template — discovery is a runtime scan. - Discovered id colliding with the deprecated agents({ agents }) map now warns and lets discovery win, rather than throwing at boot on upgrade. - Document that createAgent brands (mutates) its input; de-noise the prod zero-agents warning when the deprecated map is in use; template tsdown clean:true so a deleted agent can't linger in dist/agents. - Extract resolveCodeAgentsDir as a pure function and unit-test the built-first ordering, override, and fallback; add map-only and defaultAgent-not-registered regression tests. Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Function.createAgent.md | 9 +- .../appkit/Interface.AgentsPluginConfig.md | 17 +-- .../appkit/src/core/agent/create-agent.ts | 9 +- .../appkit/src/core/agent/load-code-agents.ts | 94 ++++++++++++-- packages/appkit/src/core/agent/types.ts | 17 +-- packages/appkit/src/plugins/agents/agents.ts | 116 ++++++------------ .../plugins/agents/tests/discovery.test.ts | 50 +++++++- .../agents/tests/load-code-agents.test.ts | 63 +++++++++- template/server/agents/helper.ts | 5 +- template/tsdown.server.config.ts | 3 + 10 files changed, 268 insertions(+), 115 deletions(-) diff --git a/docs/docs/api/appkit/Function.createAgent.md b/docs/docs/api/appkit/Function.createAgent.md index 61064e512..7721d985e 100644 --- a/docs/docs/api/appkit/Function.createAgent.md +++ b/docs/docs/api/appkit/Function.createAgent.md @@ -8,9 +8,12 @@ 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)`. +The returned value is the same `AgentDefinition` object, stamped with a +non-enumerable brand so runtime discovery can recognize it (identity, JSON, +and spread are unaffected). Because it writes that brand onto the argument, +don't `Object.freeze` a definition before passing it in. No adapter is +constructed. Put each agent in its own `server/agents/.ts` for +auto-discovery, or run it standalone via `runAgent(def, input)`. ## Parameters diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index 257fba74b..bf5a5f0ea 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -23,11 +23,11 @@ optional agents: Record; #### Deprecated Put each code agent in its own file under `server/agents/` -(`export default createAgent({ ... })`); `appkit generate-agents` -discovers them automatically and the call collapses to `agents({ ... })` -with no map. Still honored for backward compatibility (emits a one-time -deprecation warning) but will be removed in a future minor. Discovered -agents and this map may not both define the same id. +(`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. *** @@ -98,9 +98,10 @@ optional codeAgentsDir: string | false; Directory of code agents (one `.ts` file per agent, each `export default createAgent({ ... })`). Discovered at startup and merged -with markdown agents. Defaults to `server/agents` in dev and the compiled -`dist/agents` in a production build (resolved by `NODE_ENV`). Set to `false` -to disable code-agent discovery, or a string to point at a custom directory. +with markdown agents. By default the plugin scans the compiled +`dist/agents` / `build/agents` when present (a built server) and otherwise +the `server/agents` sources (a `tsx` dev run). Set to `false` to disable +code-agent discovery, or a string to point at a custom directory. *** diff --git a/packages/appkit/src/core/agent/create-agent.ts b/packages/appkit/src/core/agent/create-agent.ts index 20d52f22c..a8e7c8a1f 100644 --- a/packages/appkit/src/core/agent/create-agent.ts +++ b/packages/appkit/src/core/agent/create-agent.ts @@ -18,9 +18,12 @@ const AGENT_BRAND: unique symbol = Symbol.for("appkit.agent"); * 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)`. + * The returned value is the same `AgentDefinition` object, stamped with a + * non-enumerable brand so runtime discovery can recognize it (identity, JSON, + * and spread are unaffected). Because it writes that brand onto the argument, + * don't `Object.freeze` a definition before passing it in. No adapter is + * constructed. Put each agent in its own `server/agents/.ts` for + * auto-discovery, or run it standalone via `runAgent(def, input)`. * * @example * ```ts diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts index daacc7167..4b79ef307 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -8,6 +8,66 @@ 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"; +/** + * Where they land once compiled into the server bundle. Probed in order — + * `tsdown` projects conventionally emit to `dist/` or `build/`. + */ +const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/agents"]; + +interface ResolvedCodeAgentsDir { + dir: string; + /** Filename suffixes to treat as loadable code-agent modules. */ + extensions: string[]; +} + +/** + * Resolves which directory to scan for code agents. + * + * **Compiled output is preferred over source, unconditionally.** A compiled + * server (`node dist/server.js`) finds `dist/agents` / `build/agents` (`.js`) + * and never tries to `import()` a `.ts` file — plain Node cannot load one and + * doing so would crash boot. Only when no built dir exists (a `tsx` dev run, + * which has no compiled output) does it fall back to `server/agents` (`.ts`), + * where the TS loader handles the import. This is deliberately independent of + * `NODE_ENV`: running the built server without `NODE_ENV=production` must not + * flip resolution to the unloadable `.ts` sources. + * + * `override` short-circuits: `false` disables discovery; a string is used + * verbatim (accepting any module extension) for tests and non-standard layouts. + */ +export function resolveCodeAgentsDir(opts: { + cwd: string; + override?: string | false; + exists: (dir: string) => boolean; +}): ResolvedCodeAgentsDir | null { + if (opts.override === false) return null; + if (typeof opts.override === "string") { + const dir = path.isAbsolute(opts.override) + ? opts.override + : path.resolve(opts.cwd, opts.override); + return { dir, extensions: [".ts", ".tsx", ".js", ".mjs"] }; + } + + const source: ResolvedCodeAgentsDir = { + dir: path.resolve(opts.cwd, CODE_AGENTS_SOURCE_DIR), + extensions: [".ts", ".tsx"], + }; + const built: ResolvedCodeAgentsDir[] = CODE_AGENTS_BUILT_DIRS.map((rel) => ({ + dir: path.resolve(opts.cwd, rel), + extensions: [".js", ".mjs"], + })); + + // Built output wins over source; see the note above. + for (const candidate of [...built, source]) { + if (opts.exists(candidate.dir)) return candidate; + } + // Nothing exists yet (first-run dev tree, or an app with no code agents): + // target the source dir so the downstream scan resolves to an empty registry. + return source; +} + /** Files in the code-agents dir that are never themselves agents. */ function isIgnored(name: string): boolean { return ( @@ -57,15 +117,19 @@ function pickAgentExport( * the single source of truth — mirroring how a markdown agent's id is its * folder name. * - * This is the runtime counterpart to the markdown `loadAgentsFromDir`: dev - * points at the `.ts` sources (run under `tsx`), a bundled server points at - * the compiled `.js` in `dist/`. The caller resolves which directory and - * which extensions apply; this function just imports and brand-checks. - * * Returns an empty record when the directory does not exist. Files that - * export no agent are skipped (debug-logged); a syntax/import error in an - * agent file, a duplicate id, or a multi-agent file all throw with the - * offending path. + * export no agent are skipped (debug-logged); a duplicate id or a + * multi-agent file throws with the offending path. If a module cannot be + * loaded because the runtime has no TypeScript loader (e.g. a built server + * that ended up pointed at `.ts` sources), the scan bails with one clear + * warning rather than crashing boot — a genuine syntax/runtime error in an + * agent file still throws (fail loud). + * + * Each module is imported by its plain `file://` URL, so the ESM module cache + * keys on the path: `reload()` re-registers added/removed files but does NOT + * pick up edits to an already-imported file — that needs a process restart + * (the `tsx watch` dev loop restarts on change, so this only bites manual + * `reload()` calls). */ export async function loadCodeAgentsFromDir( dir: string, @@ -102,6 +166,20 @@ export async function loadCodeAgentsFromDir( unknown >; } catch (err) { + // The runtime can't load these modules (a built server resolved to + // `.ts` sources with no TS loader). Every remaining file would fail + // the same way — warn once and yield no code agents instead of + // crashing the whole app at boot. + 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/*.ts` entry glob in the tsdown config. Discovered no code agents.", + dir, + ); + return {}; + } throw new Error( `Failed to import code agent '${filePath}': ${ err instanceof Error ? err.message : String(err) diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 85ff749ad..f3f7dd1b7 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -218,18 +218,19 @@ export interface AgentsPluginConfig extends BasePluginConfig { /** * Directory of code agents (one `.ts` file per agent, each * `export default createAgent({ ... })`). Discovered at startup and merged - * with markdown agents. Defaults to `server/agents` in dev and the compiled - * `dist/agents` in a production build (resolved by `NODE_ENV`). Set to `false` - * to disable code-agent discovery, or a string to point at a custom directory. + * with markdown agents. By default the plugin scans the compiled + * `dist/agents` / `build/agents` when present (a built server) and otherwise + * the `server/agents` sources (a `tsx` dev run). Set to `false` to disable + * code-agent discovery, or a string to point at a custom directory. */ codeAgentsDir?: string | false; /** * @deprecated Put each code agent in its own file under `server/agents/` - * (`export default createAgent({ ... })`); `appkit generate-agents` - * discovers them automatically and the call collapses to `agents({ ... })` - * with no map. Still honored for backward compatibility (emits a one-time - * deprecation warning) but will be removed in a future minor. Discovered - * agents and this map may not both define the same id. + * (`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. */ diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index fd36f52ec..e363324bb 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -24,7 +24,11 @@ import { import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; -import { loadCodeAgentsFromDir } from "../../core/agent/load-code-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 { @@ -77,14 +81,6 @@ const logger = createLogger("agents"); const DEFAULT_AGENTS_DIR = "./config/agents"; -/** Where code agents live in source (dev, run under `tsx`). */ -const CODE_AGENTS_SOURCE_DIR = "server/agents"; -/** - * Where they land once compiled into the server bundle (production). Probed - * in order — `tsdown` projects conventionally emit to `dist/` or `build/`. - */ -const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/agents"]; - /** * Context flag recorded on the in-memory AgentDefinition to indicate whether * it came from markdown (file) or from user code. Drives the asymmetric @@ -348,21 +344,28 @@ export class AgentsPlugin extends Plugin implements ToolProvider { // deprecated `agents({ agents })` map. Both are "code" origin; markdown // agents are loaded separately below. const discovered = await this.loadCodeAgents(); - const deprecatedMap = this.config.agents ?? {}; + const deprecatedMapRaw = this.config.agents ?? {}; - if (Object.keys(deprecatedMap).length > 0) { + if (Object.keys(deprecatedMapRaw).length > 0) { this.warnAgentsMapDeprecated(); } - // Same id in both code sources is ambiguous — a file AND a hand-written - // map entry claim it. Fail loud rather than silently pick one. - for (const id of Object.keys(discovered)) { - if (deprecatedMap[id]) { - throw new Error( - `Agent '${id}' is both discovered in ${this.resolvedAgentsDir() ?? "server/agents"} and passed to agents({ agents: { ${id} } }). ` + - "Remove the map entry — discovery already registers it.", + // A discovered file and a deprecated-map entry may claim the same id — + // e.g. an app that moved an agent into server/agents/ but still passes it + // through the map. Rather than crash boot on upgrade, discovery wins and + // the now-redundant map entry is dropped with a warning. + const deprecatedMap: Record = {}; + for (const [id, def] of Object.entries(deprecatedMapRaw)) { + if (discovered[id]) { + logger.warn( + "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 feed markdown sub-agent resolution: a markdown parent @@ -496,86 +499,45 @@ export class AgentsPlugin extends Plugin implements ToolProvider { /** * Discovers code agents from the code-agents directory (default - * `server/agents`). See {@link loadCodeAgentsFromDir} for the contract. + * `server/agents` in source, `dist/agents`/`build/agents` once compiled). + * See {@link resolveCodeAgentsDir} and {@link loadCodeAgentsFromDir}. * - * When nothing is found in a production build, emits a loud warning: the - * most likely cause is the bundler not emitting `dist/agents/*.js` (e.g. a - * missing `server/agents/*.ts` entry in the tsdown config), which would - * otherwise leave the app running with silently-missing agents. + * If the app has `server/agents/*.ts` sources but discovery yields nothing, + * emits a loud warning — the likely cause is the bundler not emitting the + * compiled agents (a missing `server/agents/*.ts` entry in the tsdown + * config), which would otherwise leave the app silently agent-less. The + * warning is suppressed when the deprecated `agents({ agents })` map is in + * use, since such an app isn't relying on discovery at all. */ private async loadCodeAgents(): Promise> { - const resolved = this.resolveCodeAgentsDir(); + const resolved = resolveCodeAgentsDir({ + cwd: process.cwd(), + override: this.config.codeAgentsDir, + exists: existsSync, + }); if (!resolved) return {}; const discovered = await loadCodeAgentsFromDir(resolved.dir, { extensions: resolved.extensions, }); + const usingDeprecatedMap = Object.keys(this.config.agents ?? {}).length > 0; if ( - resolved.isProduction && Object.keys(discovered).length === 0 && + !usingDeprecatedMap && this.hasCodeAgentSources() ) { logger.warn( - "No code agents were loaded from %s in this production build, but source files exist in %s. " + - "The bundler may not have emitted them — ensure `server/agents/*.ts` is included as tsdown entries so `dist/agents/*.js` are produced.", - resolved.dir, + "Found code-agent sources in %s but discovered no code agents (scanned %s). " + + "In a production build, ensure `server/agents/*.ts` is included as tsdown entries so the compiled agents are emitted.", path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), + resolved.dir, ); } return discovered; } - /** - * Resolves which directory to scan for code agents and which extensions to - * look for. - * - * - `codeAgentsDir: false` disables code-agent discovery. - * - A string override is used verbatim (accepts any of the module - * extensions) — used by tests and by apps with a non-standard layout. - * - Otherwise the convention: a production build runs compiled output, so - * prefer `dist/agents/*.js`; dev runs sources under `tsx`, so prefer - * `server/agents/*.ts`. The `NODE_ENV` check is what keeps a stale - * `dist/` from a previous build from shadowing live sources in dev. The - * non-preferred location is a fallback if the preferred one is absent. - */ - private resolveCodeAgentsDir(): { - dir: string; - extensions: string[]; - isProduction: boolean; - } | null { - const isProduction = process.env.NODE_ENV === "production"; - if (this.config.codeAgentsDir === false) return null; - - if (typeof this.config.codeAgentsDir === "string") { - const dir = path.isAbsolute(this.config.codeAgentsDir) - ? this.config.codeAgentsDir - : path.resolve(process.cwd(), this.config.codeAgentsDir); - return { dir, extensions: [".ts", ".tsx", ".js", ".mjs"], isProduction }; - } - - const source = { - dir: path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), - extensions: [".ts", ".tsx"], - isProduction, - }; - const built = CODE_AGENTS_BUILT_DIRS.map((d) => ({ - dir: path.resolve(process.cwd(), d), - extensions: [".js", ".mjs"], - isProduction, - })); - // Built dirs first, source last — flipped in dev so a stale dist/ or - // build/ from a previous compile can't shadow live sources under tsx. - const order = isProduction ? [...built, source] : [source, ...built]; - for (const candidate of order) { - if (existsSync(candidate.dir)) return candidate; - } - // Nothing exists yet: target the preferred location so a downstream - // ENOENT resolves to an empty registry (and the prod warning can fire). - return order[0]; - } - /** True when the code-agent source dir has at least one `.ts`/`.tsx` file. */ private hasCodeAgentSources(): boolean { const srcDir = path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); diff --git a/packages/appkit/src/plugins/agents/tests/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts index b262ae749..2302bd5b1 100644 --- a/packages/appkit/src/plugins/agents/tests/discovery.test.ts +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -116,16 +116,56 @@ describe("AgentsPlugin code-agent discovery", () => { expect(api.getDefault()).toBe("planner"); }); - test("throws when a discovered id collides with the deprecated map", async () => { + test("discovery wins over a colliding deprecated-map entry (warns, no throw)", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const plugin = instantiate({ dir: false, codeAgentsDir: fixtureDir("code-agents"), - agents: { helper: { instructions: "map", model: stubAdapter() } }, + agents: { + helper: { instructions: "from the map", model: stubAdapter() }, + }, defaultModel: stubAdapter(), }); - await expect(plugin.setup()).rejects.toThrow( - /both discovered .* and passed to agents\(\{ agents/, - ); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["builder", "helper"]); + // The discovered file (instructions "I help.") wins over the map entry. + const helper = api.get("helper") as { instructions: string } | null; + expect(helper?.instructions).toBe("I help."); + const warned = warnSpy.mock.calls + .map((a) => a.join(" ")) + .some( + (s) => + s.includes("both discovered") && s.includes("ignoring the map entry"), + ); + expect(warned).toBe(true); + warnSpy.mockRestore(); + }); + + test("a map-only app with no code-agents dir works unchanged (no discovery, no throw)", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: false, + agents: { + legacy: { instructions: "map agent", model: stubAdapter() }, + }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + const api = plugin.exports() as ExportsApi; + expect(api.list()).toEqual(["legacy"]); + expect(api.getDefault()).toBe("legacy"); + }); + + test("throws when defaultAgent names an unregistered agent", async () => { + const plugin = instantiate({ + dir: false, + codeAgentsDir: fixtureDir("code-agents"), + defaultAgent: "nope", + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow(/is not registered/); }); test("throws when a discovered id collides with a markdown agent", async () => { 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 index 0d7bf7792..681c5dbd9 100644 --- a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -1,6 +1,10 @@ +import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { loadCodeAgentsFromDir } from "../../../core/agent/load-code-agents"; +import { + loadCodeAgentsFromDir, + resolveCodeAgentsDir, +} from "../../../core/agent/load-code-agents"; const fixtureDir = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); @@ -39,3 +43,60 @@ describe("loadCodeAgentsFromDir", () => { ).rejects.toThrow(/Duplicate code-agent id 'dup'/); }); }); + +describe("resolveCodeAgentsDir", () => { + const cwd = "/app"; + const dist = path.resolve(cwd, "dist/agents"); + const build = path.resolve(cwd, "build/agents"); + const source = path.resolve(cwd, "server/agents"); + const existsIn = + (...present: string[]) => + (dir: string) => + present.includes(dir); + + it("returns null when discovery is disabled", () => { + expect( + resolveCodeAgentsDir({ cwd, override: false, exists: () => true }), + ).toBeNull(); + }); + + it("uses a string override verbatim, accepting any module extension", () => { + expect( + resolveCodeAgentsDir({ + cwd, + override: "custom/agents", + exists: () => false, + }), + ).toEqual({ + dir: path.resolve(cwd, "custom/agents"), + extensions: [".ts", ".tsx", ".js", ".mjs"], + }); + expect( + resolveCodeAgentsDir({ + cwd, + override: "/abs/agents", + exists: () => true, + }), + ).toMatchObject({ dir: "/abs/agents" }); + }); + + it("prefers compiled dist/agents (.js) over source — built wins", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(dist, source) }); + expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); + }); + + it("falls back to build/agents when dist/agents is absent", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(build, source) }); + expect(r).toEqual({ dir: build, extensions: [".js", ".mjs"] }); + }); + + it("uses server/agents (.ts) only when no built dir exists", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(source) }); + expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + }); + + it("targets server/agents when nothing exists (empty scan downstream)", () => { + const r = resolveCodeAgentsDir({ cwd, exists: () => false }); + expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + }); +}); diff --git a/template/server/agents/helper.ts b/template/server/agents/helper.ts index 7ef751ea6..073ad8a21 100644 --- a/template/server/agents/helper.ts +++ b/template/server/agents/helper.ts @@ -4,8 +4,9 @@ import { z } from 'zod'; /** * Code-defined helper agent: holds the tools. This file lives in - * `server/agents/`, so `appkit generate-agents` discovers it automatically — - * its agent id is the filename (`helper`), and nothing needs to restate it. + * `server/agents/`, so the agents plugin discovers it automatically at + * startup — its agent id is the filename (`helper`), and nothing needs to + * restate it. * Shipped as a sub-agent of the user-facing `planner` markdown agent (which * references it via `agents: [helper]` in its frontmatter) rather than a * chat-tab on its own. When the user asks planner for a computational action — diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index ed0f4f784..50c0b91c8 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ // here to force the bundler to emit dist/agents/*.js for the discovery scan. entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, + // Wipe the out dir each build so a deleted server/agents/*.ts can't leave a + // stale dist/agents/*.js behind for the runtime discovery scan to pick up. + clean: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json', outExtensions: () => ({ From 9eaeeb928627049aba2a2fca77d133777a2e61d0 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 17 Aug 2026 12:03:36 +0200 Subject: [PATCH 3/5] refactor(appkit): simplify code-agent discovery and trim comments - Derive the first-registered default from the agent Map instead of tracking a separate variable + param. - Collapse the isIgnored test/spec cross-product to one regex; derive the agent id via path.parse (drops a duplicated extension list). - Reuse the loader's isIgnored in hasCodeAgentSources so the "sources exist but nothing discovered" warning no longer fires for index/test-only dirs. - Trim verbose docblocks/comments to their load-bearing facts and keep the brand rationale in one place. Signed-off-by: MarioCadenas --- .../server/agents/dashboard_pilot.ts | 6 +- .../server/agents/sql_analyst.ts | 4 +- .../appkit/src/core/agent/create-agent.ts | 19 ++--- .../appkit/src/core/agent/load-code-agents.ts | 75 +++++-------------- packages/appkit/src/plugins/agents/agents.ts | 64 +++++----------- template/tsdown.server.config.ts | 7 +- 6 files changed, 50 insertions(+), 125 deletions(-) diff --git a/apps/dev-playground/server/agents/dashboard_pilot.ts b/apps/dev-playground/server/agents/dashboard_pilot.ts index ac305dac8..50a6cde1e 100644 --- a/apps/dev-playground/server/agents/dashboard_pilot.ts +++ b/apps/dev-playground/server/agents/dashboard_pilot.ts @@ -1,10 +1,8 @@ import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; -// Smart-Dashboard pilot: emits UI-action tool calls the client reads off the -// SSE stream and translates into React state mutations. Referenced as a -// sub-agent by the markdown `query` dispatcher (config/agents/query), resolved -// by this file's id ("dashboard_pilot"). +// 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. // diff --git a/apps/dev-playground/server/agents/sql_analyst.ts b/apps/dev-playground/server/agents/sql_analyst.ts index 2148e416e..ab149558a 100644 --- a/apps/dev-playground/server/agents/sql_analyst.ts +++ b/apps/dev-playground/server/agents/sql_analyst.ts @@ -1,9 +1,7 @@ import { createAgent } from "@databricks/appkit/beta"; // Smart-Dashboard specialist: writes Databricks SQL against -// `samples.nyctaxi.trips`. Referenced as a sub-agent by the markdown `query` -// dispatcher (config/agents/query/agent.md, `agents: [sql_analyst, ...]`), -// which resolves it by this file's id ("sql_analyst"). +// `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`).", diff --git a/packages/appkit/src/core/agent/create-agent.ts b/packages/appkit/src/core/agent/create-agent.ts index a8e7c8a1f..67c589317 100644 --- a/packages/appkit/src/core/agent/create-agent.ts +++ b/packages/appkit/src/core/agent/create-agent.ts @@ -14,16 +14,11 @@ import type { AgentDefinition } from "./types"; const AGENT_BRAND: unique symbol = Symbol.for("appkit.agent"); /** - * Pure factory for agent definitions. Returns the passed-in definition after - * cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape - * and is safe to call at module top-level. - * - * The returned value is the same `AgentDefinition` object, stamped with a - * non-enumerable brand so runtime discovery can recognize it (identity, JSON, - * and spread are unaffected). Because it writes that brand onto the argument, - * don't `Object.freeze` a definition before passing it in. No adapter is - * constructed. Put each agent in its own `server/agents/.ts` for - * auto-discovery, 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 {@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 @@ -38,9 +33,7 @@ const AGENT_BRAND: unique symbol = Symbol.for("appkit.agent"); */ export function createAgent(def: AgentDefinition): AgentDefinition { detectCycles(def); - // Brand for runtime discovery. Non-enumerable so it never shows up in - // spreads or JSON, and defined in-place so the returned value stays - // identical to the input (`createAgent(def) === def`). + // Non-enumerable + in-place: identity, JSON, and spread are unaffected. Object.defineProperty(def, AGENT_BRAND, { value: true, enumerable: false, diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts index 4b79ef307..f746b45e6 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -10,32 +10,20 @@ 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"; -/** - * Where they land once compiled into the server bundle. Probed in order — - * `tsdown` projects conventionally emit to `dist/` or `build/`. - */ +/** Compiled output dirs, probed in order (tsdown emits to `dist/` or `build/`). */ const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/agents"]; interface ResolvedCodeAgentsDir { dir: string; - /** Filename suffixes to treat as loadable code-agent modules. */ extensions: string[]; } /** - * Resolves which directory to scan for code agents. - * - * **Compiled output is preferred over source, unconditionally.** A compiled - * server (`node dist/server.js`) finds `dist/agents` / `build/agents` (`.js`) - * and never tries to `import()` a `.ts` file — plain Node cannot load one and - * doing so would crash boot. Only when no built dir exists (a `tsx` dev run, - * which has no compiled output) does it fall back to `server/agents` (`.ts`), - * where the TS loader handles the import. This is deliberately independent of - * `NODE_ENV`: running the built server without `NODE_ENV=production` must not - * flip resolution to the unloadable `.ts` sources. - * - * `override` short-circuits: `false` disables discovery; a string is used - * verbatim (accepting any module extension) for tests and non-standard layouts. + * Resolves which directory to scan for code agents. Built output wins over + * source unconditionally, so a compiled server never `import()`s a `.ts` file + * (plain Node can't load one); `server/agents` (`.ts`) is used only when no + * built dir exists. `override`: `false` disables discovery, a string is used + * verbatim. */ export function resolveCodeAgentsDir(opts: { cwd: string; @@ -59,36 +47,25 @@ export function resolveCodeAgentsDir(opts: { extensions: [".js", ".mjs"], })); - // Built output wins over source; see the note above. for (const candidate of [...built, source]) { if (opts.exists(candidate.dir)) return candidate; } - // Nothing exists yet (first-run dev tree, or an app with no code agents): - // target the source dir so the downstream scan resolves to an empty registry. return source; } /** Files in the code-agents dir that are never themselves agents. */ -function isIgnored(name: string): boolean { +export function isIgnored(name: string): boolean { return ( name.endsWith(".d.ts") || - name.endsWith(".test.ts") || - name.endsWith(".test.tsx") || - name.endsWith(".test.js") || - name.endsWith(".spec.ts") || - name.endsWith(".spec.tsx") || - name.endsWith(".spec.js") || + /\.(test|spec)\.(tsx?|js)$/.test(name) || /^index\.(ts|tsx|js|mjs)$/.test(name) ); } /** - * Picks the single created agent a module exports. Prefers the default - * export; otherwise accepts exactly one branded named export. Returns - * `undefined` when the module exports no agent (e.g. a helper file or a - * bundler-emitted chunk sitting next to the agents), and throws when a - * single file exports more than one agent (the filename is the id, so it - * can only stand for one). + * 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 a file exports more than one (the filename is the id). */ function pickAgentExport( mod: Record, @@ -112,24 +89,12 @@ function pickAgentExport( } /** - * Discovers code agents by importing every module in `dir` and taking the - * agent each exports. The agent's id is its filename without extension — - * the single source of truth — mirroring how a markdown agent's id is its - * folder name. - * - * Returns an empty record when the directory does not exist. Files that - * export no agent are skipped (debug-logged); a duplicate id or a - * multi-agent file throws with the offending path. If a module cannot be - * loaded because the runtime has no TypeScript loader (e.g. a built server - * that ended up pointed at `.ts` sources), the scan bails with one clear - * warning rather than crashing boot — a genuine syntax/runtime error in an - * agent file still throws (fail loud). + * Discovers code agents by importing each module in `dir`; the agent's id is + * its filename. Returns `{}` when the dir is absent. Non-agent files are + * skipped; a duplicate id or a multi-agent file throws. * - * Each module is imported by its plain `file://` URL, so the ESM module cache - * keys on the path: `reload()` re-registers added/removed files but does NOT - * pick up edits to an already-imported file — that needs a process restart - * (the `tsx watch` dev loop restarts on change, so this only bites manual - * `reload()` calls). + * Imports key on the plain `file://` URL, so `reload()` picks up added/removed + * files but not edits to an already-imported one (that needs a restart). */ export async function loadCodeAgentsFromDir( dir: string, @@ -166,10 +131,8 @@ export async function loadCodeAgentsFromDir( unknown >; } catch (err) { - // The runtime can't load these modules (a built server resolved to - // `.ts` sources with no TS loader). Every remaining file would fail - // the same way — warn once and yield no code agents instead of - // crashing the whole app at boot. + // No TS loader for these `.ts` modules — warn once and bail rather than + // crash boot (every file would fail the same way). if ( (err as NodeJS.ErrnoException).code === "ERR_UNKNOWN_FILE_EXTENSION" ) { @@ -197,7 +160,7 @@ export async function loadCodeAgentsFromDir( continue; } - const id = file.replace(/\.(ts|tsx|js|mjs|cjs)$/, ""); + const id = path.parse(file).name; const prior = sourceById.get(id); if (prior) { throw new Error( diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index e363324bb..c68ca0529 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -26,6 +26,7 @@ import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; import { CODE_AGENTS_SOURCE_DIR, + isIgnored, loadCodeAgentsFromDir, resolveCodeAgentsDir, } from "../../core/agent/load-code-agents"; @@ -339,10 +340,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { agents: Map; defaultAgentName: string | null; }> { - // Two code-agent sources: agents discovered from the code-agents dir - // (server/agents in dev, dist/agents in a bundled server) and the - // deprecated `agents({ agents })` map. Both are "code" origin; markdown - // agents are loaded separately below. + // Two "code" sources: discovered files and the deprecated `agents({ agents })` map. const discovered = await this.loadCodeAgents(); const deprecatedMapRaw = this.config.agents ?? {}; @@ -350,10 +348,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { this.warnAgentsMapDeprecated(); } - // A discovered file and a deprecated-map entry may claim the same id — - // e.g. an app that moved an agent into server/agents/ but still passes it - // through the map. Rather than crash boot on upgrade, discovery wins and - // the now-redundant map entry is dropped with a warning. + // 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]) { @@ -368,8 +364,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { deprecatedMap[id] = def; } - // Code agents also feed markdown sub-agent resolution: a markdown parent - // with `agents: [helper]` frontmatter resolves `helper` against these. + // Code agents also resolve markdown `agents: [child]` sub-agent references. const codeAgents: Record = { ...discovered, ...deprecatedMap, @@ -378,9 +373,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { const { defs: fileDefs, defaultAgent: fileDefault } = await this.loadFileDefinitions(codeAgents); - // Build the merged registry. Order: markdown, then discovered, then the - // deprecated map — this order also determines the "first registered" - // default fallback. + // Merge order (markdown, discovered, map) sets precedence and the + // first-registered default fallback. const merged: Record = {}; for (const [name, def] of Object.entries(fileDefs)) { @@ -388,8 +382,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } for (const [name, def] of Object.entries(discovered)) { if (merged[name]?.src.origin === "file") { - // Discovery is new API, so a discovered/markdown clash is a hard error - // (unlike the grandfathered map-vs-markdown warning below). + // Discovery is new API — clash with markdown is a hard error (the + // deprecated map only warns). throw new Error( `Agent '${name}' is defined as both a code agent (server/agents/${name}.ts) and a markdown agent. ` + `Rename one. Available: ${Object.keys(merged).sort().join(", ")}`, @@ -408,7 +402,6 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } const agents = new Map(); - let firstRegistered: string | null = null; if (Object.keys(merged).length === 0) { logger.info( @@ -420,9 +413,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { for (const [name, { def, src }] of Object.entries(merged)) { try { - const registered = await this.buildRegisteredAgent(name, def, src); - agents.set(name, registered); - if (!firstRegistered) firstRegistered = name; + agents.set(name, await this.buildRegisteredAgent(name, def, src)); } catch (err) { throw new Error( `Failed to register agent '${name}' (${src.origin}): ${ @@ -435,25 +426,19 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return { agents, - defaultAgentName: this.resolveDefaultAgent( - agents, - merged, - fileDefault, - firstRegistered, - ), + 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 (stable order). + * markdown `default: true` > first registered (insertion order). */ private resolveDefaultAgent( agents: Map, merged: Record, fileDefault: string | null, - firstRegistered: string | null, ): string | null { if (this.config.defaultAgent) { if (!agents.has(this.config.defaultAgent)) { @@ -473,7 +458,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (fileDefault && agents.has(fileDefault)) return fileDefault; - return firstRegistered; + return agents.keys().next().value ?? null; } /** @@ -498,16 +483,10 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } /** - * Discovers code agents from the code-agents directory (default - * `server/agents` in source, `dist/agents`/`build/agents` once compiled). - * See {@link resolveCodeAgentsDir} and {@link loadCodeAgentsFromDir}. - * - * If the app has `server/agents/*.ts` sources but discovery yields nothing, - * emits a loud warning — the likely cause is the bundler not emitting the - * compiled agents (a missing `server/agents/*.ts` entry in the tsdown - * config), which would otherwise leave the app silently agent-less. The - * warning is suppressed when the deprecated `agents({ agents })` map is in - * use, since such an app isn't relying on discovery at all. + * 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({ @@ -538,13 +517,12 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return discovered; } - /** True when the code-agent source dir has at least one `.ts`/`.tsx` file. */ + /** True when the code-agent source dir holds at least one discoverable file. */ private hasCodeAgentSources(): boolean { const srcDir = path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); try { return readdirSync(srcDir).some( - (f) => - (f.endsWith(".ts") || f.endsWith(".tsx")) && !f.endsWith(".d.ts"), + (f) => (f.endsWith(".ts") || f.endsWith(".tsx")) && !isIgnored(f), ); } catch { return false; @@ -563,7 +541,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { const pluginToolProviders = this.pluginProviderIndex(); const ambient = this.config.tools ?? {}; - const result = await loadAgentsFromDir(dir, { + return loadAgentsFromDir(dir, { defaultModel: this.config.defaultModel, availableTools: ambient, plugins: pluginToolProviders, @@ -572,8 +550,6 @@ export class AgentsPlugin extends Plugin implements ToolProvider { // a discovered code child (e.g. planner → helper). codeAgents, }); - - return result; } /** diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index 50c0b91c8..9d52b5cb2 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,13 +1,10 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - // Code agents live in server/agents/ and are auto-discovered at runtime. - // They are not statically imported anywhere, so they are listed as entries - // here to force the bundler to emit dist/agents/*.js for the discovery scan. + // server/agents/*.ts aren't imported anywhere; list them as entries so the build emits dist/agents/*.js for runtime discovery. entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, - // Wipe the out dir each build so a deleted server/agents/*.ts can't leave a - // stale dist/agents/*.js behind for the runtime discovery scan to pick up. + // Clear the out dir so a deleted agent can't linger in dist/agents/. clean: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json', From b44b5bd85aeba2eb16bda53919c06b51ecad330d Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 17 Aug 2026 14:41:58 +0200 Subject: [PATCH 4/5] feat: unify agents into folder-per-agent discovery under server/agents Every agent is a folder under server/agents// holding agent.md (markdown) or agent.ts (code); the folder name is the id. - Code loader scans /agent.{ts,tsx,js,mjs}, built-first (dist/build over source); folders without an entry file are skipped. - Markdown loader skips folders lacking agent.md (was a throw) so code folders and asset dirs coexist; drop the RESERVED_DIRS list. - One dir knob (default server/agents) feeds both loaders; retire codeAgentsDir. config/agents is read as a deprecated fallback (per-agent merge, new location wins, one-time warning). - Cross-kind sub-agent references resolve by folder id. - Migrate template, dev-playground, docs, and test fixtures. Signed-off-by: MarioCadenas --- apps/dev-playground/package.json | 4 +- .../agents/anomaly/agent.md | 0 .../agents/autocomplete/agent.md | 0 .../agent.ts} | 0 .../agents/{helper.ts => helper/agent.ts} | 4 +- .../agents/insights/agent.md | 0 .../{config => server}/agents/query/agent.md | 0 .../{sql_analyst.ts => sql_analyst/agent.ts} | 0 .../{supervisor.ts => supervisor/agent.ts} | 0 apps/dev-playground/server/index.ts | 16 +-- docs/docs/api/appkit/Function.createAgent.md | 15 +-- .../appkit/Interface.AgentsPluginConfig.md | 36 +++--- docs/docs/api/appkit/Variable.agents.md | 10 +- docs/docs/api/appkit/index.md | 4 +- docs/docs/plugins/agents.md | 40 ++++--- packages/appkit/src/core/agent/load-agents.ts | 11 +- .../appkit/src/core/agent/load-code-agents.ts | 75 ++++++------- .../src/core/agent/tests/load-agents.test.ts | 12 +- packages/appkit/src/core/agent/types.ts | 30 ++--- packages/appkit/src/plugins/agents/agents.ts | 103 ++++++++++++------ .../plugins/agents/tests/discovery.test.ts | 76 ++++--------- .../fixtures/code-agents-default/alpha.ts | 2 - .../code-agents-default/alpha/agent.ts | 2 + .../fixtures/code-agents-default/beta.ts | 2 - .../code-agents-default/beta/agent.ts | 2 + .../code-agents-default/planner/agent.md | 4 + .../tests/fixtures/code-agents-dup/dup.ts | 2 - .../tests/fixtures/code-agents-dup/dup.tsx | 2 - .../{multi.ts => multi/agent.ts} | 2 +- .../tests/fixtures/code-agents/builder.ts | 2 - .../fixtures/code-agents/builder/agent.ts | 2 + .../tests/fixtures/code-agents/helper.ts | 2 - .../fixtures/code-agents/helper/agent.ts | 2 + .../tests/fixtures/code-agents/notAnAgent.ts | 2 - .../fixtures/code-agents/notAnAgent/agent.ts | 2 + .../code-md-collision/helper/agent.md | 3 + .../code-md-collision/helper/agent.ts | 2 + .../md-parent-code-child/helper/agent.ts | 2 + .../md-parent-code-child/planner/agent.md | 6 + .../agents/tests/load-code-agents.test.ts | 23 ++-- .../agents/{helper.ts => helper/agent.ts} | 4 +- .../agents/planner/agent.md | 0 template/tsdown.server.config.ts | 4 +- 43 files changed, 259 insertions(+), 251 deletions(-) rename apps/dev-playground/{config => server}/agents/anomaly/agent.md (100%) rename apps/dev-playground/{config => server}/agents/autocomplete/agent.md (100%) rename apps/dev-playground/server/agents/{dashboard_pilot.ts => dashboard_pilot/agent.ts} (100%) rename apps/dev-playground/server/agents/{helper.ts => helper/agent.ts} (83%) rename apps/dev-playground/{config => server}/agents/insights/agent.md (100%) rename apps/dev-playground/{config => server}/agents/query/agent.md (100%) rename apps/dev-playground/server/agents/{sql_analyst.ts => sql_analyst/agent.ts} (100%) rename apps/dev-playground/server/agents/{supervisor.ts => supervisor/agent.ts} (100%) delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.ts delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta/agent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/planner/agent.md delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx rename packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/{multi.ts => multi/agent.ts} (59%) delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.ts delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.ts delete mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent/agent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/planner/agent.md rename template/server/agents/{helper.ts => helper/agent.ts} (93%) rename template/{config => server}/agents/planner/agent.md (100%) diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index c27a971ff..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 'server/agents/*.ts' && cd client && npm run build", - "build:server": "tsdown --out-dir build server/index.ts 'server/agents/*.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.ts b/apps/dev-playground/server/agents/dashboard_pilot/agent.ts similarity index 100% rename from apps/dev-playground/server/agents/dashboard_pilot.ts rename to apps/dev-playground/server/agents/dashboard_pilot/agent.ts diff --git a/apps/dev-playground/server/agents/helper.ts b/apps/dev-playground/server/agents/helper/agent.ts similarity index 83% rename from apps/dev-playground/server/agents/helper.ts rename to apps/dev-playground/server/agents/helper/agent.ts index 14267d7fc..2242e7559 100644 --- a/apps/dev-playground/server/agents/helper.ts +++ b/apps/dev-playground/server/agents/helper/agent.ts @@ -2,8 +2,8 @@ import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; // Code-defined demo agent showing the tools(plugins) function form alongside -// the markdown-driven agents in config/agents/. Discovered automatically from -// server/agents/ — its id is the filename ("helper"). +// 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, " + 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.ts b/apps/dev-playground/server/agents/sql_analyst/agent.ts similarity index 100% rename from apps/dev-playground/server/agents/sql_analyst.ts rename to apps/dev-playground/server/agents/sql_analyst/agent.ts diff --git a/apps/dev-playground/server/agents/supervisor.ts b/apps/dev-playground/server/agents/supervisor/agent.ts similarity index 100% rename from apps/dev-playground/server/agents/supervisor.ts rename to apps/dev-playground/server/agents/supervisor/agent.ts diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index cec291833..2ef35fc7c 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -105,14 +105,14 @@ createApp({ }), serving(), agents({ - // Code agents are discovered from server/agents/ (helper, supervisor, - // sql_analyst, dashboard_pilot); markdown agents from config/agents/. - // `query` (markdown dispatcher) delegates to the discovered - // `sql_analyst` + `dashboard_pilot` to wire the /smart-dashboard route. - // `insights` and `anomaly` are ephemeral markdown agents auto-fired by - // the route's AgentSidebar. `helper` is the conversational default for - // the bare `/agent` route (the markdown agents are dispatchers or - // ephemeral and don't make sense as the user-facing landing agent). + // 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 7721d985e..a51e0c57d 100644 --- a/docs/docs/api/appkit/Function.createAgent.md +++ b/docs/docs/api/appkit/Function.createAgent.md @@ -4,16 +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 the same `AgentDefinition` object, stamped with a -non-enumerable brand so runtime discovery can recognize it (identity, JSON, -and spread are unaffected). Because it writes that brand onto the argument, -don't `Object.freeze` a definition before passing it in. No adapter is -constructed. Put each agent in its own `server/agents/.ts` for -auto-discovery, 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.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index bf5a5f0ea..fed2776b6 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -22,12 +22,13 @@ optional agents: Record; #### Deprecated -Put each code agent in its own file under `server/agents/` -(`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. +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. *** @@ -90,21 +91,6 @@ Customize or disable the AppKit base system prompt. *** -### codeAgentsDir? - -```ts -optional codeAgentsDir: string | false; -``` - -Directory of code agents (one `.ts` file per agent, each -`export default createAgent({ ... })`). Discovered at startup and merged -with markdown agents. By default the plugin scans the compiled -`dist/agents` / `build/agents` when present (a built server) and otherwise -the `server/agents` sources (a `tsx` dev run). Set to `false` to disable -code-agent discovery, or a string to point at a custom directory. - -*** - ### defaultAgent? ```ts @@ -134,7 +120,13 @@ Default model for agents that don't specify their own (in code or frontmatter). optional dir: string | false; ``` -Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. +Unified agents root. Each `/` folder holds either `agent.md` (markdown) +or `agent.ts` (code, `export default createAgent({ ... })`); the folder +name is the agent id. Default `server/agents` — leave unset. In a built +server, code agents are loaded from the compiled `dist/agents` / +`build/agents`; markdown is read from source. Set to `false` to disable +discovery. Markdown still under `config/agents/` is read as a deprecated +fallback (one-time warning). *** diff --git a/docs/docs/api/appkit/Variable.agents.md b/docs/docs/api/appkit/Variable.agents.md index 227a5bf3b..4e83e1ddb 100644 --- a/docs/docs/api/appkit/Variable.agents.md +++ b/docs/docs/api/appkit/Variable.agents.md @@ -4,10 +4,12 @@ const agents: ToPlugin; ``` -Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, -resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` -runtime API and mounts `POST /invocations` and `POST /responses` (aliased -non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). +Plugin factory for the agents plugin. Discovers agents from +`server/agents//agent.{ts,md}` by default (markdown still in +`config/agents/` is read as a deprecated fallback), resolves toolkits/tools +from registered plugins, exposes the `appkit.agents.*` runtime API and mounts +`POST /invocations` and `POST /responses` (aliased non-streaming invoke +endpoints) plus `POST /chat` (streaming, HITL-capable). ## Example diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f39a52db2..c720277b8 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -127,7 +127,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | Variable | Description | | ------ | ------ | -| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | +| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | @@ -142,7 +142,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [agentIdFromMarkdownPath](Function.agentIdFromMarkdownPath.md) | Derives the logical agent id from a markdown path. When the file is named `agent.md`, the id is the parent directory name (folder-based layout); otherwise the id is the file stem (e.g. legacy single-file paths). | | [appKitServingTypesPlugin](Function.appKitServingTypesPlugin.md) | Vite plugin to generate TypeScript types for AppKit serving endpoints. Fetches OpenAPI schemas from Databricks and generates a .d.ts with ServingEndpointRegistry module augmentation. | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | -| [createAgent](Function.createAgent.md) | Pure factory for agent definitions. Returns the passed-in definition after cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape and is safe to call at module top-level. | +| [createAgent](Function.createAgent.md) | Pure factory for agent definitions: cycle-detects the sub-agent graph and returns the same object, stamped with a non-enumerable AGENT\_BRAND so discovery recognizes it. Safe at module top-level; no adapter is built. Don't `Object.freeze` the definition before passing it in — the brand is written onto the argument. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index bd9dd570d..98814e360 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 discovers agent definitions from disk — markdown packages (one folder per agent: `config/agents//agent.md`) and code agents (one file per agent: `server/agents/.ts`) — and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. In both cases the agent's id is its filename/folder name; there's no map to maintain and no id to restate. +The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It discovers agent definitions from disk — one folder per agent under `server/agents/`, holding either `agent.md` (markdown) or `agent.ts` (code) — and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. In every case the agent's id is its folder name; there's no map to maintain and no id to restate. This page covers the full lifecycle. For the hand-written primitives (`tool()`, `mcpServer()`), see [tools](./server.md). @@ -37,14 +37,15 @@ That alone gives you a live HTTP server with `POST /invocations` (and its alias ## Level 1: drop a markdown agent package -Each agent lives in its own directory with a fixed entry file `agent.md`. A reserved top-level folder named `skills` is ignored until per-agent skills ship (you can add other asset folders beside `agent.md` under each agent id). +Each agent lives in its own folder under `server/agents/` with entry file `agent.md`. A folder is an agent only if it holds an entry file (`agent.md` or `agent.ts`); a folder without one is skipped, so per-agent asset folders like `skills/` sit beside the entry. ``` my-app/ - server.ts - config/agents/ - assistant/ - agent.md + server/ + server.ts + agents/ + assistant/ + agent.md ``` ```md @@ -60,13 +61,17 @@ Use the available tools to query data, browse files, and help users. On startup the plugin: -1. Discovers `./config/agents/assistant/agent.md` and registers agent id `assistant`. +1. Discovers `server/agents/assistant/agent.md` and registers agent id `assistant`. 2. Parses the YAML frontmatter and markdown body as the agent's `instructions`. 3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_AGENT_ENDPOINT`). 4. Mounts the agent at the default name (`assistant`). The agent starts with **no tools**. Tools are opt-in — declare them in frontmatter (Level 2 below) or opt into auto-inherit explicitly with `agents({ autoInheritTools: { file: true } })`. See "Auto-inherit posture" further down for what that costs and why it's off by default. +:::note Migrating from `config/agents/` +Earlier versions kept markdown agents under `config/agents//agent.md`. That location is still read as a deprecated fallback (one-time warning on boot); move each folder to `server/agents//agent.md` so every agent — markdown and code — lives in one place. +::: + Requests land at `POST /invocations` (or its alias `POST /responses`) with an OpenAI Responses-compatible body. These endpoints run the agent to completion and return a single JSON response — no SSE. Streaming clients should use `POST /chat`. Every tool call runs through `asUser(req)` so SQL executes as the requesting user, file access respects Unity Catalog ACLs, and telemetry spans are created automatically. :::warning No HITL on `/invocations` and `/responses` @@ -100,14 +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-file under `server/agents/`. Each file exports a created agent and its **id is the filename** (`server/agents/support.ts` → `support`), mirroring how a markdown agent's id is its folder name. Nothing restates the id. +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 -// server/agents/support.ts +// server/agents/support/agent.ts import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; -export default createAgent({ // id derived from filename: "support" +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) { @@ -136,9 +141,9 @@ await createApp({ }); ``` -Discovery scans the code-agents directory and imports each module: `server/agents/*.ts` under `tsx` in dev, and the compiled `dist/agents/*.js` in a production build (chosen by `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*.ts` as build entries so `dist/agents/*.js` are emitted for the scan — that wiring is what lets a dropped-in file survive the prod bundle. The directory is `server/agents` by default; override or disable it with `agents({ codeAgentsDir })` (a path, or `false`). +Discovery imports each `server/agents//agent.ts` — the source `.ts` under `tsx` in dev, and the compiled `dist/agents//agent.js` in a production build (built output wins over source, independent of `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*/agent.ts` as build entries so `dist/agents/*/agent.js` are emitted for the scan — that wiring is what lets a dropped-in folder survive the prod bundle. (Markdown `agent.md` is read from source in both dev and prod — it's data, not compiled.) The root is `server/agents` by default; override or disable it with `agents({ dir })` (a path, or `false`). -A file may `export default createAgent({...})` or export a single named created agent; either way the id is the filename. A module that exports no created agent (a shared helper) is skipped. Mark one agent as the default with `createAgent({ default: true })` (mirrors markdown frontmatter `default: true`); an explicit `agents({ defaultAgent })` still wins. +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. @@ -147,7 +152,7 @@ Inline `tool({...})` calls live in the same record. Their `name` is optional — The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. :::warning Deprecated: the `agents({ agents: { ... } })` map -Passing a hand-built agent map still works and is honored for backward compatibility, but it emits a one-time deprecation warning and will be removed in a future minor. It restates each agent's id (once in `createAgent`, once as the map key); discovery from `server/agents/` removes both the map and the restatement. Migrate by moving each `createAgent(...)` into its own `server/agents/.ts` (default or single named export) and dropping the map. A discovered agent and a map entry may not share an id. (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) +Passing a hand-built agent map still works and is honored for backward compatibility, but it emits a one-time deprecation warning and will be removed in a future minor. It restates each agent's id (once in `createAgent`, once as the map key); discovery from `server/agents/` removes both the map and the restatement. Migrate by moving each `createAgent(...)` into its own `server/agents//agent.ts` (default or single named export) and dropping the map. A discovered agent and a map entry may not share an id. (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) ::: ### Scoping tools in code @@ -185,7 +190,7 @@ const supervisor = createAgent({ agents: { researcher, writer }, // exposed as agent-researcher, agent-writer }); -// server/agents/supervisor.ts (+ researcher.ts, writer.ts) — one file each +// server/agents/{supervisor,researcher,writer}/agent.ts — one folder each export default supervisor; await createApp({ @@ -193,7 +198,7 @@ await createApp({ }); ``` -Put `supervisor`, `researcher`, and `writer` in their own `server/agents/*.ts` files (default export each) — a markdown parent can also delegate to a discovered code child via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. +Put `supervisor`, `researcher`, and `writer` in their own `server/agents//agent.ts` folders (default export each) — a markdown parent can also delegate to a code child in a sibling folder via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. ## Level 5: standalone (no `createApp`) @@ -369,9 +374,8 @@ Some hosted tool kinds return their final assistant text without incremental `ou ```ts agents({ - dir?: string | false, // markdown agents; "./config/agents" default; false disables - codeAgentsDir?: string | false, // code agents; "server/agents" (dev) / "dist/agents" (prod); false disables - agents?: Record, // DEPRECATED — use server/agents/ discovery + dir?: string | false, // agents root; "server/agents" default; false disables (config/agents read as deprecated fallback) + agents?: Record, // DEPRECATED — use server/agents// discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, tools?: Record, diff --git a/packages/appkit/src/core/agent/load-agents.ts b/packages/appkit/src/core/agent/load-agents.ts index 5f535cafc..8e493e1bc 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -204,13 +204,9 @@ 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(); const defs: Record = {}; @@ -224,11 +220,8 @@ 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/). + if ((err as NodeJS.ErrnoException).code === "ENOENT") 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 index f746b45e6..17ed99341 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -12,6 +12,8 @@ const logger = createLogger("agents:code-loader"); export const CODE_AGENTS_SOURCE_DIR = "server/agents"; /** Compiled output dirs, probed in order (tsdown emits to `dist/` or `build/`). */ const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/agents"]; +/** Per-agent entry file, mirroring markdown's `agent.md`. */ +const ENTRY_BASENAME = "agent"; interface ResolvedCodeAgentsDir { dir: string; @@ -53,19 +55,31 @@ export function resolveCodeAgentsDir(opts: { return source; } -/** Files in the code-agents dir that are never themselves agents. */ -export function isIgnored(name: string): boolean { - return ( - name.endsWith(".d.ts") || - /\.(test|spec)\.(tsx?|js)$/.test(name) || - /^index\.(ts|tsx|js|mjs)$/.test(name) - ); +/** + * The `agent.` entry file inside an agent folder, or `null` if none — + * a markdown agent (`agent.md`) or a non-agent asset dir (`skills/`). + */ +async function findEntryFile( + agentDir: string, + extensions: string[], +): Promise { + let files: string[]; + try { + files = await fs.readdir(agentDir); + } catch { + return null; + } + for (const ext of extensions) { + const name = `${ENTRY_BASENAME}${ext}`; + if (files.includes(name)) return path.join(agentDir, name); + } + return null; } /** * The single created agent a module exports — the default export, else the one * branded named export. `undefined` if none (a helper or bundler chunk); throws - * if a file exports more than one (the filename is the id). + * if the entry file exports more than one (the folder name is the id). */ function pickAgentExport( mod: Record, @@ -82,19 +96,19 @@ function pickAgentExport( `Agent file '${filePath}' exports ${named.length} created agents (${named .map(([k]) => k) .join(", ")}); expected exactly one. ` + - "Split them into one file per agent (the filename is the agent id).", + "Export a single agent per folder (the folder name is its id).", ); } return named[0][1] as AgentDefinition; } /** - * Discovers code agents by importing each module in `dir`; the agent's id is - * its filename. Returns `{}` when the dir is absent. Non-agent files are - * skipped; a duplicate id or a multi-agent file throws. + * 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 - * files but not edits to an already-imported one (that needs a restart). + * folders but not edits to an already-imported one (that needs a restart). */ export async function loadCodeAgentsFromDir( dir: string, @@ -108,66 +122,53 @@ export async function loadCodeAgentsFromDir( throw err; } - const files = entries - .filter( - (e) => - e.isFile() && - opts.extensions.some((ext) => e.name.endsWith(ext)) && - !isIgnored(e.name), - ) + const folders = entries + .filter((e) => e.isDirectory()) .map((e) => e.name) .sort(); const agents: Record = {}; - const sourceById = new Map(); - for (const file of files) { - const filePath = path.join(dir, file); + 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(filePath).href)) as Record< + 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 file would fail the same way). + // 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/*.ts` entry glob in the tsdown config. Discovered no code agents.", + "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 '${filePath}': ${ + `Failed to import code agent '${entryFile}': ${ err instanceof Error ? err.message : String(err) }`, { cause: err instanceof Error ? err : undefined }, ); } - const agent = pickAgentExport(mod, filePath); + const agent = pickAgentExport(mod, entryFile); if (!agent) { logger.debug( "Skipping %s — no createAgent export (not a code agent).", - filePath, + entryFile, ); continue; } - const id = path.parse(file).name; - const prior = sourceById.get(id); - if (prior) { - throw new Error( - `Duplicate code-agent id '${id}': both '${prior}' and '${file}' resolve to it. Rename one file.`, - ); - } - sourceById.set(id, file); agents[id] = agent; } diff --git a/packages/appkit/src/core/agent/tests/load-agents.test.ts b/packages/appkit/src/core/agent/tests/load-agents.test.ts index de07e5be9..a9a3af1c1 100644 --- a/packages/appkit/src/core/agent/tests/load-agents.test.ts +++ b/packages/appkit/src/core/agent/tests/load-agents.test.ts @@ -182,14 +182,10 @@ describe("loadAgentsFromDir", () => { ); }); - test("throws when a subdirectory lacks agent.md", async () => { - fs.mkdirSync(path.join(workDir, "broken"), { recursive: true }); - await expect(loadAgentsFromDir(workDir, {})).rejects.toThrow( - /must contain agent\.md/, - ); - }); - - test("ignores reserved skills directory without agent.md", async () => { + test("skips a subdirectory that lacks agent.md", async () => { + // A folder with no agent.md is a code-agent folder (agent.ts) or an asset + // dir (skills/), not a markdown agent — skip it, don't throw. + fs.mkdirSync(path.join(workDir, "code-only"), { recursive: true }); fs.mkdirSync(path.join(workDir, "skills"), { recursive: true }); writeAgent("solo", "---\nendpoint: e\n---\nOnly real agent."); const res = await loadAgentsFromDir(workDir, {}); diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index f3f7dd1b7..9099d77e8 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -213,24 +213,24 @@ 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; /** - * Directory of code agents (one `.ts` file per agent, each - * `export default createAgent({ ... })`). Discovered at startup and merged - * with markdown agents. By default the plugin scans the compiled - * `dist/agents` / `build/agents` when present (a built server) and otherwise - * the `server/agents` sources (a `tsx` dev run). Set to `false` to disable - * code-agent discovery, or a string to point at a custom directory. + * Unified agents root. Each `/` folder holds either `agent.md` (markdown) + * or `agent.ts` (code, `export default createAgent({ ... })`); the folder + * name is the agent id. Default `server/agents` — leave unset. In a built + * server, code agents are loaded from the compiled `dist/agents` / + * `build/agents`; markdown is read from source. Set to `false` to disable + * discovery. Markdown still under `config/agents/` is read as a deprecated + * fallback (one-time warning). */ - codeAgentsDir?: string | false; + dir?: string | false; /** - * @deprecated Put each code agent in its own file under `server/agents/` - * (`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. + * @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. */ diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index c68ca0529..f0f26eb15 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -26,7 +26,6 @@ import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; import { CODE_AGENTS_SOURCE_DIR, - isIgnored, loadCodeAgentsFromDir, resolveCodeAgentsDir, } from "../../core/agent/load-code-agents"; @@ -80,7 +79,10 @@ import { ToolApprovalGate } from "./tool-approval-gate"; const logger = createLogger("agents"); -const DEFAULT_AGENTS_DIR = "./config/agents"; +/** Unified agents root: `server/agents//agent.{ts,md}`. */ +const DEFAULT_AGENTS_DIR = "server/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 @@ -174,6 +176,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { 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); @@ -383,10 +387,11 @@ export class AgentsPlugin extends Plugin implements ToolProvider { 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). + // 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 (server/agents/${name}.ts) and a markdown agent. ` + - `Rename one. Available: ${Object.keys(merged).sort().join(", ")}`, + `Agent '${name}' is defined as both a code agent (agent.ts) and a markdown agent (agent.md). ` + + `Keep one kind per folder. Available: ${Object.keys(merged).sort().join(", ")}`, ); } merged[name] = { def, src: { origin: "code" } }; @@ -469,10 +474,24 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (this.agentsMapDeprecationWarned) return; this.agentsMapDeprecationWarned = true; logger.warn( - "agents({ agents: { ... } }) is deprecated. Put each code agent in its own file under " + - "server/agents/ (export default createAgent({ ... })) and it is discovered automatically — " + - "the call collapses to agents({ ... }) with no agent map. The `agents` field still works but " + - "will be removed in a future minor. See docs/plugins/agents.md.", + "agents({ agents: { ... } }) is deprecated. Put each code agent in its own folder under " + + "server/agents//agent.ts (export default createAgent({ ... })) and it is discovered " + + "automatically — the call collapses to agents({ ... }) with no agent map. The `agents` field " + + "still works but will be removed in a future minor. See docs/plugins/agents.md.", + ); + } + + /** + * One-time deprecation warning for markdown agents still living in + * `config/agents/`. Guarded so `reload()` doesn't spam it. + */ + private warnConfigAgentsDeprecated(): void { + if (this.configAgentsDeprecationWarned) return; + this.configAgentsDeprecationWarned = true; + logger.warn( + "Markdown agents under config/agents/ are deprecated. Move each config/agents//agent.md to " + + "server/agents//agent.md — every agent now lives in one place (server/agents). config/agents " + + "is still read for now but will be removed in a future minor. See docs/plugins/agents.md.", ); } @@ -491,7 +510,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { private async loadCodeAgents(): Promise> { const resolved = resolveCodeAgentsDir({ cwd: process.cwd(), - override: this.config.codeAgentsDir, + override: this.config.dir, exists: existsSync, }); if (!resolved) return {}; @@ -508,7 +527,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { ) { logger.warn( "Found code-agent sources in %s but discovered no code agents (scanned %s). " + - "In a production build, ensure `server/agents/*.ts` is included as tsdown entries so the compiled agents are emitted.", + "In a production build, ensure `server/agents/*/agent.ts` is included as tsdown entries so the compiled agents are emitted.", path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), resolved.dir, ); @@ -517,13 +536,20 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return discovered; } - /** True when the code-agent source dir holds at least one discoverable file. */ + /** True when the source dir holds at least one `/agent.{ts,tsx}` folder. */ private hasCodeAgentSources(): boolean { const srcDir = path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); try { - return readdirSync(srcDir).some( - (f) => (f.endsWith(".ts") || f.endsWith(".tsx")) && !isIgnored(f), - ); + return readdirSync(srcDir, { withFileTypes: true }).some((e) => { + if (!e.isDirectory()) return false; + try { + return readdirSync(path.join(srcDir, e.name)).some( + (f) => f === "agent.ts" || f === "agent.tsx", + ); + } catch { + return false; + } + }); } catch { return false; } @@ -535,21 +561,34 @@ export class AgentsPlugin extends Plugin implements ToolProvider { defs: Record; defaultAgent: string | null; }> { - const dir = this.resolvedAgentsDir(); - if (!dir) return { defs: {}, defaultAgent: null }; - - const pluginToolProviders = this.pluginProviderIndex(); - const ambient = this.config.tools ?? {}; + const primaryDir = this.resolvedAgentsDir(); + if (!primaryDir) return { defs: {}, defaultAgent: null }; - return 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 ctx = { defaultModel: this.config.defaultModel, - availableTools: ambient, - plugins: pluginToolProviders, - // Discovered code agents + the deprecated map both resolve markdown - // `agents:` sub-agent references, so a markdown parent can delegate to - // a discovered code child (e.g. planner → helper). + availableTools: this.config.tools ?? {}, + plugins: this.pluginProviderIndex(), codeAgents, - }); + }; + + const primary = await loadAgentsFromDir(primaryDir, ctx); + + // Deprecated fallback: markdown still under config/agents is merged in, + // with the primary dir winning on an id clash. Skipped when the configured + // dir already points there (no double-scan / spurious warning). + const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); + if (primaryDir === legacyDir) return primary; + + const legacy = await loadAgentsFromDir(legacyDir, ctx); + if (Object.keys(legacy.defs).length === 0) return primary; + + this.warnConfigAgentsDeprecated(); + return { + defs: { ...legacy.defs, ...primary.defs }, + defaultAgent: primary.defaultAgent ?? legacy.defaultAgent, + }; } /** @@ -2095,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/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts index 2302bd5b1..2734a7c57 100644 --- a/packages/appkit/src/plugins/agents/tests/discovery.test.ts +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -1,14 +1,11 @@ -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 { beforeEach, describe, expect, test, vi } from "vitest"; import { CacheManager } from "../../../cache"; import type { AgentsPluginConfig } from "../../../core/agent/types"; import { AgentsPlugin } from "../agents"; -/** Absolute path to a committed code-agent fixture directory. */ +/** Absolute path to a committed agent fixture directory. */ const fixtureDir = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); @@ -20,66 +17,51 @@ function stubAdapter(): AgentAdapter { }; } -let tmpDir: string; - beforeEach(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-discovery-")); // Agent setup reads the cache singleton; initialize it with defaults. await CacheManager.getInstance(); }); -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - function instantiate(config: AgentsPluginConfig) { const plugin = new AgentsPlugin({ ...config, name: "agent" }); plugin.attachContext({ context: undefined as unknown as object }); return plugin; } -function writeMarkdownAgent(dir: string, id: string, content: string) { - const folder = path.join(dir, id); - fs.mkdirSync(folder, { recursive: true }); - fs.writeFileSync(path.join(folder, "agent.md"), content, "utf-8"); -} - type ExportsApi = { list: () => string[]; get: (name: string) => { toolIndex: Map } | null; getDefault: () => string | null; }; -describe("AgentsPlugin code-agent discovery", () => { +describe("AgentsPlugin agent discovery", () => { test("discovers code agents from the dir with no map at the call site", async () => { const plugin = instantiate({ - dir: false, - codeAgentsDir: fixtureDir("code-agents"), + dir: fixtureDir("code-agents"), defaultModel: stubAdapter(), }); await plugin.setup(); const api = plugin.exports() as ExportsApi; - // notAnAgent.ts is skipped; builder + helper are discovered. + // 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 agent", async () => { + test("honors default: true on a discovered code agent", async () => { const plugin = instantiate({ - dir: false, - codeAgentsDir: fixtureDir("code-agents-default"), + dir: fixtureDir("code-agents-default"), defaultModel: stubAdapter(), }); await plugin.setup(); expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); }); - test("a discovered default: true beats markdown default: true", async () => { - writeMarkdownAgent(tmpDir, "planner", "---\ndefault: true\n---\nPlan."); + test("a discovered code default: true beats a markdown default: true", async () => { + // code-agents-default holds beta (code, default:true) + planner (markdown, + // default:true) side by side; code wins. const plugin = instantiate({ - dir: tmpDir, - codeAgentsDir: fixtureDir("code-agents-default"), + dir: fixtureDir("code-agents-default"), defaultModel: stubAdapter(), }); await plugin.setup(); @@ -88,8 +70,7 @@ describe("AgentsPlugin code-agent discovery", () => { test("explicit defaultAgent overrides a discovered default: true", async () => { const plugin = instantiate({ - dir: false, - codeAgentsDir: fixtureDir("code-agents-default"), + dir: fixtureDir("code-agents-default"), defaultAgent: "alpha", defaultModel: stubAdapter(), }); @@ -97,21 +78,15 @@ describe("AgentsPlugin code-agent discovery", () => { expect((plugin.exports() as ExportsApi).getDefault()).toBe("alpha"); }); - test("a markdown parent can delegate to a discovered code sub-agent", async () => { - writeMarkdownAgent( - tmpDir, - "planner", - "---\ndefault: true\nagents:\n - helper\n---\nPlan.", - ); + test("a markdown parent can delegate to a code sub-agent in a sibling folder", async () => { const plugin = instantiate({ - dir: tmpDir, - codeAgentsDir: fixtureDir("code-agents"), + dir: fixtureDir("md-parent-code-child"), defaultModel: stubAdapter(), }); await plugin.setup(); const api = plugin.exports() as ExportsApi; - expect(api.list().sort()).toEqual(["builder", "helper", "planner"]); + expect(api.list().sort()).toEqual(["helper", "planner"]); expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); expect(api.getDefault()).toBe("planner"); }); @@ -119,8 +94,7 @@ describe("AgentsPlugin code-agent discovery", () => { test("discovery wins over a colliding deprecated-map entry (warns, no throw)", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const plugin = instantiate({ - dir: false, - codeAgentsDir: fixtureDir("code-agents"), + dir: fixtureDir("code-agents"), agents: { helper: { instructions: "from the map", model: stubAdapter() }, }, @@ -130,7 +104,7 @@ describe("AgentsPlugin code-agent discovery", () => { const api = plugin.exports() as ExportsApi; expect(api.list().sort()).toEqual(["builder", "helper"]); - // The discovered file (instructions "I help.") wins over the map entry. + // 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 @@ -143,10 +117,9 @@ describe("AgentsPlugin code-agent discovery", () => { warnSpy.mockRestore(); }); - test("a map-only app with no code-agents dir works unchanged (no discovery, no throw)", async () => { + test("a map-only app with discovery disabled works unchanged (no throw)", async () => { const plugin = instantiate({ dir: false, - codeAgentsDir: false, agents: { legacy: { instructions: "map agent", model: stubAdapter() }, }, @@ -160,19 +133,16 @@ describe("AgentsPlugin code-agent discovery", () => { test("throws when defaultAgent names an unregistered agent", async () => { const plugin = instantiate({ - dir: false, - codeAgentsDir: fixtureDir("code-agents"), + dir: fixtureDir("code-agents"), defaultAgent: "nope", defaultModel: stubAdapter(), }); await expect(plugin.setup()).rejects.toThrow(/is not registered/); }); - test("throws when a discovered id collides with a markdown agent", async () => { - writeMarkdownAgent(tmpDir, "helper", "---\n---\nFrom markdown."); + test("throws when a folder holds both agent.ts and agent.md", async () => { const plugin = instantiate({ - dir: tmpDir, - codeAgentsDir: fixtureDir("code-agents"), + dir: fixtureDir("code-md-collision"), defaultModel: stubAdapter(), }); await expect(plugin.setup()).rejects.toThrow( @@ -185,7 +155,6 @@ describe("AgentsPlugin code-agent discovery", () => { const deprecated = instantiate({ dir: false, - codeAgentsDir: false, agents: { legacy: { instructions: "x", model: stubAdapter() } }, }); await deprecated.setup(); @@ -198,8 +167,7 @@ describe("AgentsPlugin code-agent discovery", () => { warnSpy.mockClear(); const discoveredPlugin = instantiate({ - dir: false, - codeAgentsDir: fixtureDir("code-agents"), + dir: fixtureDir("code-agents"), defaultModel: stubAdapter(), }); await discoveredPlugin.setup(); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts deleted file mode 100644 index 5e4328357..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha.ts +++ /dev/null @@ -1,2 +0,0 @@ -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/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.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts deleted file mode 100644 index d866cd68e..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta.ts +++ /dev/null @@ -1,2 +0,0 @@ -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/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-dup/dup.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts deleted file mode 100644 index 9b12ab541..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { createAgent } from "../../../../../core/agent/create-agent"; -export default createAgent({ instructions: "from ts" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx deleted file mode 100644 index 07b04b878..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-dup/dup.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { createAgent } from "../../../../../core/agent/create-agent"; -export default createAgent({ instructions: "from tsx" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts similarity index 59% rename from packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts rename to packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts index e10e1465e..b51a6ef45 100644 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi.ts +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts @@ -1,3 +1,3 @@ -import { createAgent } from "../../../../../core/agent/create-agent"; +import { createAgent } from "../../../../../../core/agent/create-agent"; export const a = createAgent({ instructions: "a" }); export const b = createAgent({ instructions: "b" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts deleted file mode 100644 index f8893c5e9..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder.ts +++ /dev/null @@ -1,2 +0,0 @@ -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/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.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts deleted file mode 100644 index f3a7a1a88..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper.ts +++ /dev/null @@ -1,2 +0,0 @@ -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/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.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts deleted file mode 100644 index 2a04d9777..000000000 --- a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent.ts +++ /dev/null @@ -1,2 +0,0 @@ -// A helper module that is not an agent — the loader must skip it. -export const CONSTANT = 42; diff --git a/packages/appkit/src/plugins/agents/tests/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 index 681c5dbd9..a89901083 100644 --- a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -18,29 +18,32 @@ describe("loadCodeAgentsFromDir", () => { ).toEqual({}); }); - it("discovers default and named agent exports, id = filename", async () => { + 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 modules that export no created agent", async () => { + it("skips folders whose entry file exports no created agent", async () => { const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); - // notAnAgent.ts exports a plain constant — must not be registered. + // notAnAgent/agent.ts exports a plain constant — must not be registered. expect(agents.notAnAgent).toBeUndefined(); }); - it("throws when one file exports more than one agent", async () => { - await expect( - loadCodeAgentsFromDir(fixtureDir("code-agents-multi"), TS), - ).rejects.toThrow(/exports 2 created agents/); + it("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 on a duplicate id across .ts and .tsx", async () => { + it("throws when one entry file exports more than one agent", async () => { await expect( - loadCodeAgentsFromDir(fixtureDir("code-agents-dup"), TS), - ).rejects.toThrow(/Duplicate code-agent id 'dup'/); + loadCodeAgentsFromDir(fixtureDir("code-agents-multi"), TS), + ).rejects.toThrow(/exports 2 created agents/); }); }); diff --git a/template/server/agents/helper.ts b/template/server/agents/helper/agent.ts similarity index 93% rename from template/server/agents/helper.ts rename to template/server/agents/helper/agent.ts index 073ad8a21..87603daeb 100644 --- a/template/server/agents/helper.ts +++ b/template/server/agents/helper/agent.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; /** * Code-defined helper agent: holds the tools. This file lives in - * `server/agents/`, so the agents plugin discovers it automatically at - * startup — its agent id is the filename (`helper`), and nothing needs to + * `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 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/tsdown.server.config.ts b/template/tsdown.server.config.ts index 9d52b5cb2..ac236e108 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - // server/agents/*.ts aren't imported anywhere; list them as entries so the build emits dist/agents/*.js for runtime discovery. - entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*.ts'{{else}}'server/server.ts'{{end}}], + // server/agents/*/agent.ts aren't imported anywhere; list them as entries so the build emits dist/agents/*/agent.js for runtime discovery. + entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*/agent.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, // Clear the out dir so a deleted agent can't linger in dist/agents/. clean: true, From 45e72842bcdb3c75c73fdbea10ae81de43aa5670 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 17 Aug 2026 17:23:22 +0200 Subject: [PATCH 5/5] fix(appkit): address folder-discovery review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveCodeAgentsDir: a relative `dir` is now built-first (dist/, build/ probed before source); only an absolute path is verbatim. Fixes prod discovering zero code agents when `dir` was a relative path (e.g. the documented default typed explicitly) — dev worked, prod empty. - Cross-location sub-agent refs resolve mid-migration: config/agents loads first and its defs feed the server/agents resolution. - hasCodeAgentSources + its warning track the configured dir; drop the duplicate DEFAULT_AGENTS_DIR constant. - Discover symlinked agent folders in both loaders. - Template AgentChat.tsx: correct the scaffold's shown agent paths. - Docs: dir:false also disables the config/agents fallback; note stale-build shadowing in dev; fix AgentDefinition.name tsdoc. - Tests: cover the config/agents fallback (merge, precedence, one-time warning), cross-dir refs, dir:false, and built-first for a relative dir. Signed-off-by: MarioCadenas --- .../api/appkit/Interface.AgentDefinition.md | 2 +- .../appkit/Interface.AgentsPluginConfig.md | 11 ++- docs/docs/plugins/agents.md | 6 +- packages/appkit/src/core/agent/load-agents.ts | 10 ++- .../appkit/src/core/agent/load-code-agents.ts | 41 +++++----- packages/appkit/src/core/agent/types.ts | 13 +-- packages/appkit/src/plugins/agents/agents.ts | 51 +++++++----- .../plugins/agents/tests/discovery.test.ts | 80 ++++++++++++++++++- .../agents/tests/load-code-agents.test.ts | 34 ++++++-- .../client/src/pages/agents/AgentChat.tsx | 8 +- 10 files changed, 191 insertions(+), 65 deletions(-) diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 46d9534db..9ce1f9bc5 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -113,7 +113,7 @@ optional name: string; Stable identifier for the agent. **Optional and informational** — when the definition is registered via `agents: { foo: def }` (code) or -lives at `config/agents//agent.md` (markdown), the **registry key +lives at `server/agents//agent.md` (markdown), the **registry key always wins** and `name` is ignored. The agent will be reachable as `foo` (or ``) regardless of what this field contains. diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index fed2776b6..b51037a9e 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -123,10 +123,13 @@ optional dir: string | false; Unified agents root. Each `/` folder holds either `agent.md` (markdown) or `agent.ts` (code, `export default createAgent({ ... })`); the folder name is the agent id. Default `server/agents` — leave unset. In a built -server, code agents are loaded from the compiled `dist/agents` / -`build/agents`; markdown is read from source. Set to `false` to disable -discovery. Markdown still under `config/agents/` is read as a deprecated -fallback (one-time warning). +server, code agents are loaded from the compiled output (`dist/` / +`build/`, where `` is this dir's basename) and markdown is read +from source. A **relative** custom path is resolved this way too; an +**absolute** path is scanned verbatim (you own compiling it for prod). Set +to `false` to disable all file discovery — including the `config/agents/` +fallback below. Markdown still under `config/agents/` is otherwise read as a +deprecated fallback (one-time warning). *** diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index 98814e360..e5385d7d1 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -141,7 +141,11 @@ await createApp({ }); ``` -Discovery imports each `server/agents//agent.ts` — the source `.ts` under `tsx` in dev, and the compiled `dist/agents//agent.js` in a production build (built output wins over source, independent of `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*/agent.ts` as build entries so `dist/agents/*/agent.js` are emitted for the scan — that wiring is what lets a dropped-in folder survive the prod bundle. (Markdown `agent.md` is read from source in both dev and prod — it's data, not compiled.) The root is `server/agents` by default; override or disable it with `agents({ dir })` (a path, or `false`). +Discovery imports each `server/agents//agent.ts` — the source `.ts` under `tsx` in dev, and the compiled `dist/agents//agent.js` in a production build (built output wins over source, independent of `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*/agent.ts` as build entries so `dist/agents/*/agent.js` are emitted for the scan — that wiring is what lets a dropped-in folder survive the prod bundle. (Markdown `agent.md` is read from source in both dev and prod — it's data, not compiled.) The root is `server/agents` by default; override with `agents({ dir })` (a relative path is resolved built-first the same way; an absolute path is scanned verbatim; `false` disables discovery, including the `config/agents/` fallback). + +:::note Built output shadows source in dev +Because compiled output wins over source, a stale `dist/agents` / `build/agents` left over from a previous `npm run build` will be picked up by `npm run dev` instead of your live `server/agents/*.ts`, so edits appear ignored. Delete the build dir (or re-run the build) if a code agent seems frozen. Markdown is always read from source, so `agent.md` edits are never shadowed. +::: The entry may `export default createAgent({...})` or export a single named created agent; either way the id is the folder name. A folder whose entry exports no created agent (or has no `agent.ts`/`agent.md` at all) is skipped. Mark one agent as the default with `createAgent({ default: true })` (mirrors markdown frontmatter `default: true`); an explicit `agents({ defaultAgent })` still wins. diff --git a/packages/appkit/src/core/agent/load-agents.ts b/packages/appkit/src/core/agent/load-agents.ts index 8e493e1bc..4a2914bad 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -205,7 +205,9 @@ export async function loadAgentsFromDir( } const agentIds = entries - .filter((e) => e.isDirectory()) + // Symlinked agent folders count; a symlink to a file is filtered out below + // when reading agent.md (ENOTDIR). + .filter((e) => e.isDirectory() || e.isSymbolicLink()) .map((e) => e.name) .sort(); @@ -220,8 +222,10 @@ export async function loadAgentsFromDir( try { raw = await fs.readFile(agentPath, "utf-8"); } catch (err) { - // No agent.md → a code-agent folder (agent.ts) or an asset dir (skills/). - if ((err as NodeJS.ErrnoException).code === "ENOENT") continue; + // 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 index 17ed99341..f959d6660 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -10,8 +10,8 @@ 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 dirs, probed in order (tsdown emits to `dist/` or `build/`). */ -const CODE_AGENTS_BUILT_DIRS = ["dist/agents", "build/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"; @@ -21,11 +21,13 @@ interface ResolvedCodeAgentsDir { } /** - * Resolves which directory to scan for code agents. Built output wins over - * source unconditionally, so a compiled server never `import()`s a `.ts` file - * (plain Node can't load one); `server/agents` (`.ts`) is used only when no - * built dir exists. `override`: `false` disables discovery, a string is used - * verbatim. + * Resolves which directory to scan for code agents. Compiled output wins over + * source unconditionally, so a bundled server never `import()`s a `.ts` (plain + * Node can't load one): for a relative source dir the matching `dist/` / + * `build/` is probed first, with the source `.ts` dir as fallback. + * `override`: `false` disables discovery; a relative string is the source dir + * (still built-first); an absolute string is scanned verbatim (the caller pins + * the exact path and owns compiling it for a prod build). */ export function resolveCodeAgentsDir(opts: { cwd: string; @@ -33,21 +35,22 @@ export function resolveCodeAgentsDir(opts: { exists: (dir: string) => boolean; }): ResolvedCodeAgentsDir | null { if (opts.override === false) return null; - if (typeof opts.override === "string") { - const dir = path.isAbsolute(opts.override) - ? opts.override - : path.resolve(opts.cwd, opts.override); - return { dir, extensions: [".ts", ".tsx", ".js", ".mjs"] }; + if (typeof opts.override === "string" && path.isAbsolute(opts.override)) { + return { dir: opts.override, extensions: [".ts", ".tsx", ".js", ".mjs"] }; } + const sourceRel = opts.override ?? CODE_AGENTS_SOURCE_DIR; + const name = path.basename(sourceRel); const source: ResolvedCodeAgentsDir = { - dir: path.resolve(opts.cwd, CODE_AGENTS_SOURCE_DIR), + dir: path.resolve(opts.cwd, sourceRel), extensions: [".ts", ".tsx"], }; - const built: ResolvedCodeAgentsDir[] = CODE_AGENTS_BUILT_DIRS.map((rel) => ({ - dir: path.resolve(opts.cwd, rel), - extensions: [".js", ".mjs"], - })); + 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; @@ -123,7 +126,9 @@ export async function loadCodeAgentsFromDir( } const folders = entries - .filter((e) => e.isDirectory()) + // Include symlinked agent folders; findEntryFile's readdir follows the link + // and returns null for anything that isn't a real directory. + .filter((e) => e.isDirectory() || e.isSymbolicLink()) .map((e) => e.name) .sort(); diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 9099d77e8..b75a022d5 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -126,7 +126,7 @@ export interface AgentDefinition { /** * Stable identifier for the agent. **Optional and informational** — * when the definition is registered via `agents: { foo: def }` (code) or - * lives at `config/agents//agent.md` (markdown), the **registry key + * lives at `server/agents//agent.md` (markdown), the **registry key * always wins** and `name` is ignored. The agent will be reachable as * `foo` (or ``) regardless of what this field contains. * @@ -217,10 +217,13 @@ export interface AgentsPluginConfig extends BasePluginConfig { * Unified agents root. Each `/` folder holds either `agent.md` (markdown) * or `agent.ts` (code, `export default createAgent({ ... })`); the folder * name is the agent id. Default `server/agents` — leave unset. In a built - * server, code agents are loaded from the compiled `dist/agents` / - * `build/agents`; markdown is read from source. Set to `false` to disable - * discovery. Markdown still under `config/agents/` is read as a deprecated - * fallback (one-time warning). + * server, code agents are loaded from the compiled output (`dist/` / + * `build/`, where `` is this dir's basename) and markdown is read + * from source. A **relative** custom path is resolved this way too; an + * **absolute** path is scanned verbatim (you own compiling it for prod). Set + * to `false` to disable all file discovery — including the `config/agents/` + * fallback below. Markdown still under `config/agents/` is otherwise read as a + * deprecated fallback (one-time warning). */ dir?: string | false; /** diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index f0f26eb15..f41b8a3d7 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -79,8 +79,6 @@ import { ToolApprovalGate } from "./tool-approval-gate"; const logger = createLogger("agents"); -/** Unified agents root: `server/agents//agent.{ts,md}`. */ -const DEFAULT_AGENTS_DIR = "server/agents"; /** Deprecated markdown location, read as a fallback with a one-time warning. */ const LEGACY_MARKDOWN_DIR = "config/agents"; @@ -497,7 +495,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { private resolvedAgentsDir(): string | null { if (this.config.dir === false) return null; - const dir = this.config.dir ?? DEFAULT_AGENTS_DIR; + const dir = this.config.dir ?? CODE_AGENTS_SOURCE_DIR; return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); } @@ -520,15 +518,17 @@ export class AgentsPlugin extends Plugin implements ToolProvider { }); const usingDeprecatedMap = Object.keys(this.config.agents ?? {}).length > 0; + const sourceDir = this.resolvedAgentsDir(); if ( Object.keys(discovered).length === 0 && !usingDeprecatedMap && - this.hasCodeAgentSources() + sourceDir && + this.hasCodeAgentSources(sourceDir) ) { logger.warn( "Found code-agent sources in %s but discovered no code agents (scanned %s). " + - "In a production build, ensure `server/agents/*/agent.ts` is included as tsdown entries so the compiled agents are emitted.", - path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR), + "In a production build, ensure `/*/agent.ts` is included as tsdown entries so the compiled agents are emitted.", + sourceDir, resolved.dir, ); } @@ -536,14 +536,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return discovered; } - /** True when the source dir holds at least one `/agent.{ts,tsx}` folder. */ - private hasCodeAgentSources(): boolean { - const srcDir = path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); + /** True when `dir` holds at least one `/agent.{ts,tsx}` folder. */ + private hasCodeAgentSources(dir: string): boolean { try { - return readdirSync(srcDir, { withFileTypes: true }).some((e) => { - if (!e.isDirectory()) return false; + return readdirSync(dir, { withFileTypes: true }).some((e) => { + if (!e.isDirectory() && !e.isSymbolicLink()) return false; try { - return readdirSync(path.join(srcDir, e.name)).some( + return readdirSync(path.join(dir, e.name)).some( (f) => f === "agent.ts" || f === "agent.tsx", ); } catch { @@ -566,22 +565,32 @@ export class AgentsPlugin extends Plugin implements ToolProvider { // Discovered code agents + the deprecated map resolve markdown `agents:` // sub-agent references, so a markdown parent can delegate to a code child. - const ctx = { + const baseCtx = { defaultModel: this.config.defaultModel, availableTools: this.config.tools ?? {}, plugins: this.pluginProviderIndex(), - codeAgents, }; - const primary = await loadAgentsFromDir(primaryDir, ctx); - - // Deprecated fallback: markdown still under config/agents is merged in, - // with the primary dir winning on an id clash. Skipped when the configured - // dir already points there (no double-scan / spurious warning). + // Configured dir already points at config/agents → single scan, no fallback. const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); - if (primaryDir === legacyDir) return primary; + if (primaryDir === legacyDir) { + return loadAgentsFromDir(primaryDir, { ...baseCtx, codeAgents }); + } + + // Deprecated fallback: scan config/agents first, then server/agents with the + // legacy defs added as resolvable sub-agent targets — so a parent already + // moved to server/agents can still reference a child left in config/agents + // mid-migration. Code agents keep precedence in resolution and in the final + // merge (server/agents wins on an id clash). + const legacy = await loadAgentsFromDir(legacyDir, { + ...baseCtx, + codeAgents, + }); + const primary = await loadAgentsFromDir(primaryDir, { + ...baseCtx, + codeAgents: { ...legacy.defs, ...codeAgents }, + }); - const legacy = await loadAgentsFromDir(legacyDir, ctx); if (Object.keys(legacy.defs).length === 0) return primary; this.warnConfigAgentsDeprecated(); diff --git a/packages/appkit/src/plugins/agents/tests/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts index 2734a7c57..12f4b1204 100644 --- a/packages/appkit/src/plugins/agents/tests/discovery.test.ts +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -1,6 +1,9 @@ +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 { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { CacheManager } from "../../../cache"; import type { AgentsPluginConfig } from "../../../core/agent/types"; import { AgentsPlugin } from "../agents"; @@ -179,3 +182,78 @@ describe("AgentsPlugin agent discovery", () => { warnSpy.mockRestore(); }); }); + +// The config/agents fallback is cwd-relative (path.resolve(cwd, "config/agents")), +// so these run in a temp cwd holding both roots rather than pointing `dir` at a +// fixture. +describe("AgentsPlugin config/agents deprecated fallback", () => { + let tmp: string; + let priorCwd: string; + + const write = (rel: string, content: string) => { + const p = path.join(tmp, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, "utf-8"); + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agents-fallback-")); + priorCwd = process.cwd(); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(priorCwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test("merges config/agents markdown with server/agents (server wins) and warns once", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + write("config/agents/legacy/agent.md", "---\n---\nLegacy only."); + write("config/agents/shared/agent.md", "---\n---\nFrom config (old)."); + write("server/agents/shared/agent.md", "---\n---\nFrom server (new)."); + + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + await plugin.reload(); // must not re-warn + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["legacy", "shared"]); + const shared = api.get("shared") as { instructions: string } | null; + expect(shared?.instructions).toContain("From server (new)."); + + const warns = warnSpy.mock.calls + .map((a) => a.join(" ")) + .filter((s) => s.includes("config/agents/ are deprecated")); + expect(warns).toHaveLength(1); + warnSpy.mockRestore(); + }); + + test("a server/agents parent resolves a sub-agent still in config/agents", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + write("config/agents/helper/agent.md", "---\n---\nI help."); + write( + "server/agents/planner/agent.md", + "---\ndefault: true\nagents:\n - helper\n---\nPlan.", + ); + + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + warnSpy.mockRestore(); + }); + + test("dir:false disables the config/agents fallback too", async () => { + write("config/agents/legacy/agent.md", "---\n---\nLegacy only."); + const plugin = instantiate({ + dir: false, + agents: { mapped: { instructions: "map", model: stubAdapter() } }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).list()).toEqual(["mapped"]); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts index a89901083..1a4889252 100644 --- a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -63,24 +63,44 @@ describe("resolveCodeAgentsDir", () => { ).toBeNull(); }); - it("uses a string override verbatim, accepting any module extension", () => { + it("scans an absolute override verbatim, accepting any module extension", () => { expect( resolveCodeAgentsDir({ cwd, - override: "custom/agents", - exists: () => false, + override: "/abs/agents", + exists: () => true, }), ).toEqual({ - dir: path.resolve(cwd, "custom/agents"), + dir: "/abs/agents", extensions: [".ts", ".tsx", ".js", ".mjs"], }); + }); + + it("resolves a relative override built-first (dist/ over source)", () => { + const customDist = path.resolve(cwd, "dist/my-agents"); + const customSrc = path.resolve(cwd, "my-agents"); expect( resolveCodeAgentsDir({ cwd, - override: "/abs/agents", - exists: () => true, + override: "my-agents", + exists: existsIn(customDist, customSrc), }), - ).toMatchObject({ dir: "/abs/agents" }); + ).toEqual({ dir: customDist, extensions: [".js", ".mjs"] }); + // No built output → falls back to the source `.ts` dir. + expect( + resolveCodeAgentsDir({ cwd, override: "my-agents", exists: () => false }), + ).toEqual({ dir: customSrc, extensions: [".ts", ".tsx"] }); + }); + + it("stays built-first when dir is set to the default value explicitly", () => { + // Regression: `agents({ dir: "server/agents" })` must NOT bypass built-first + // (previously a string override was scanned verbatim → prod loaded .ts). + const r = resolveCodeAgentsDir({ + cwd, + override: "server/agents", + exists: existsIn(dist, source), + }); + expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); }); it("prefers compiled dist/agents (.js) over source — built wins", () => { 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.