From 429335911f0b72ea803e6f7dbb3867ccf3949db4 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 18 Aug 2026 10:51:16 +0200 Subject: [PATCH 1/7] feat(appkit): 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: a relative dir resolves dist/|build/ before source, an absolute dir is verbatim, so a bundled server never imports .ts under plain Node. - Markdown loader skips folders without agent.md so code + asset dirs coexist; drop the RESERVED_DIRS list. - One `dir` knob (default server/agents) feeds both loaders; codeAgentsDir retired. config/agents is read as a deprecated fallback (per-agent merge, new location wins, one-time warning); cross-location sub-agent refs resolve. - Cross-kind sub-agent references resolve by folder id. - Migrate template, dev-playground, docs, and tests to the folder layout; add fallback / built-first / cross-dir test coverage. Signed-off-by: MarioCadenas --- apps/dev-playground/package.json | 4 +- .../agents/anomaly/agent.md | 0 .../agents/autocomplete/agent.md | 0 .../server/agents/dashboard_pilot/agent.ts | 237 +++++++++++++ .../server/agents/helper/agent.ts | 22 ++ .../agents/insights/agent.md | 0 .../{config => server}/agents/query/agent.md | 0 .../server/agents/sql_analyst/agent.ts | 16 + .../server/agents/supervisor/agent.ts | 33 ++ apps/dev-playground/server/index.ts | 333 +----------------- docs/docs/api/appkit/Function.createAgent.md | 12 +- .../api/appkit/Interface.AgentDefinition.md | 15 +- .../appkit/Interface.AgentsPluginConfig.md | 23 +- docs/docs/api/appkit/Variable.agents.md | 10 +- docs/docs/api/appkit/index.md | 4 +- docs/docs/plugins/agents.md | 65 +++- .../appkit/src/core/agent/create-agent.ts | 40 ++- packages/appkit/src/core/agent/load-agents.ts | 17 +- .../appkit/src/core/agent/load-code-agents.ts | 182 ++++++++++ .../src/core/agent/tests/create-agent.test.ts | 31 +- .../src/core/agent/tests/load-agents.test.ts | 12 +- packages/appkit/src/core/agent/types.ts | 32 +- packages/appkit/src/plugins/agents/agents.ts | 252 +++++++++++-- .../plugins/agents/tests/discovery.test.ts | 261 ++++++++++++++ .../code-agents-default/alpha/agent.ts | 2 + .../code-agents-default/beta/agent.ts | 2 + .../code-agents-default/planner/agent.md | 4 + .../fixtures/code-agents-multi/multi/agent.ts | 3 + .../fixtures/code-agents/builder/agent.ts | 2 + .../fixtures/code-agents/helper/agent.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 | 127 +++++++ .../client/src/pages/agents/AgentChat.tsx | 8 +- .../agents/{helper.ts => helper/agent.ts} | 20 +- .../agents/planner/agent.md | 0 template/server/server.ts | 7 - template/tsdown.server.config.ts | 5 +- 41 files changed, 1352 insertions(+), 446 deletions(-) rename apps/dev-playground/{config => server}/agents/anomaly/agent.md (100%) rename apps/dev-playground/{config => server}/agents/autocomplete/agent.md (100%) create mode 100644 apps/dev-playground/server/agents/dashboard_pilot/agent.ts create mode 100644 apps/dev-playground/server/agents/helper/agent.ts rename apps/dev-playground/{config => server}/agents/insights/agent.md (100%) rename apps/dev-playground/{config => server}/agents/query/agent.md (100%) create mode 100644 apps/dev-playground/server/agents/sql_analyst/agent.ts create mode 100644 apps/dev-playground/server/agents/supervisor/agent.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/agent.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 create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.ts create mode 100644 packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.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 create mode 100644 packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts rename template/server/agents/{helper.ts => helper/agent.ts} (71%) rename template/{config => server}/agents/planner/agent.md (100%) diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index 7af74ef0e..b1a8bf9f2 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -8,8 +8,8 @@ "dev": "NODE_ENV=development tsx watch server/index.ts", "dev:inspect": "NODE_ENV=development tsx --inspect --tsconfig ./tsconfig.json ./server", "build": "npm run build:app", - "build:app": "tsdown --out-dir build server/index.ts && cd client && npm run build", - "build:server": "tsdown --out-dir build server/index.ts", + "build:app": "tsdown --out-dir build server/index.ts 'server/agents/*/agent.ts' && cd client && npm run build", + "build:server": "tsdown --out-dir build server/index.ts 'server/agents/*/agent.ts'", "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", diff --git a/apps/dev-playground/config/agents/anomaly/agent.md b/apps/dev-playground/server/agents/anomaly/agent.md similarity index 100% rename from apps/dev-playground/config/agents/anomaly/agent.md rename to apps/dev-playground/server/agents/anomaly/agent.md diff --git a/apps/dev-playground/config/agents/autocomplete/agent.md b/apps/dev-playground/server/agents/autocomplete/agent.md similarity index 100% rename from apps/dev-playground/config/agents/autocomplete/agent.md rename to apps/dev-playground/server/agents/autocomplete/agent.md diff --git a/apps/dev-playground/server/agents/dashboard_pilot/agent.ts b/apps/dev-playground/server/agents/dashboard_pilot/agent.ts new file mode 100644 index 000000000..50a6cde1e --- /dev/null +++ b/apps/dev-playground/server/agents/dashboard_pilot/agent.ts @@ -0,0 +1,237 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Smart-Dashboard pilot: emits UI-action tool calls the client applies to the +// dashboard. A sub-agent of the markdown `query` dispatcher. +// +// Narrow, single-purpose tools. +// +// The earlier polymorphic `apply_filter({ field, operator, value })` was +// too expressive — the LLM could emit valid-looking calls the dispatcher +// couldn't faithfully apply (e.g. `field: "dropoff_zone"` when the +// dashboard only has a `pickup_zip` filter; `operator: "eq"` with a date). +// Splitting into one tool per filter verb removes the whole class of +// "agent said it worked but nothing moved" bugs. +// +// Each tool has exactly one client-side effect, rendered by +// use-action-dispatcher. Server handlers are still stubs — the tool-call +// JSON is the action payload. + +const filter_by_date_range = tool({ + name: "filter_by_date_range", + description: + "Filter the dashboard to trips within a date range. Both start and end are required and must be ISO dates (YYYY-MM-DD) within 2016.", + schema: z.object({ + start: z.string().describe("Start date in ISO format, e.g. 2016-03-01"), + end: z.string().describe("End date in ISO format, e.g. 2016-03-31"), + }), + execute: async ({ start, end }) => + `Filtered dashboard to trips between ${start} and ${end}.`, +}); + +const filter_by_pickup_zip = tool({ + name: "filter_by_pickup_zip", + description: + "Filter the dashboard to trips originating from a specific pickup ZIP code. Use when the user asks about a specific pickup zone or ZIP.", + schema: z.object({ + zip: z.string().describe("Pickup ZIP code, e.g. 10001"), + }), + execute: async ({ zip }) => + `Filtered dashboard to trips picked up in ${zip}.`, +}); + +const filter_by_fare = tool({ + name: "filter_by_fare", + description: + "Filter the dashboard to trips within a fare range. At least one of min or max must be provided.", + schema: z + .object({ + min: z.number().optional().describe("Minimum fare in USD"), + max: z.number().optional().describe("Maximum fare in USD"), + }) + .refine((v) => v.min !== undefined || v.max !== undefined, { + message: "Provide at least one of min or max.", + }), + execute: async ({ min, max }) => { + const parts = [] as string[]; + if (min !== undefined) parts.push(`>= $${min}`); + if (max !== undefined) parts.push(`<= $${max}`); + return `Filtered dashboard to trips with fare ${parts.join(" and ")}.`; + }, +}); + +const clear_filters = tool({ + name: "clear_filters", + description: + "Remove all active filters from the dashboard. Use when the user asks to reset, clear, or remove filters.", + schema: z.object({}), + execute: async () => "All filters cleared.", +}); + +const highlight_period = tool({ + name: "highlight_period", + description: + "Highlight a time period on the Trips Over Time chart to draw attention to a specific date range.", + schema: z.object({ + start: z.string().describe("Start date in ISO format (YYYY-MM-DD)"), + end: z.string().describe("End date in ISO format (YYYY-MM-DD)"), + color: z + .enum(["blue", "red", "yellow"]) + .optional() + .describe("Highlight color. Defaults to blue."), + label: z + .string() + .optional() + .describe("Optional label for the highlighted period"), + }), + execute: async ({ start, end, color: _color, label }) => { + const suffix = label ? ` (${label})` : ""; + return `Highlighted period ${start} to ${end}${suffix} on the dashboard.`; + }, +}); + +const clear_highlights = tool({ + name: "clear_highlights", + description: + "Remove all highlight overlays from the charts. Use when the user asks to clear, reset, or remove highlights.", + schema: z.object({}), + execute: async () => "All highlights cleared.", +}); + +// Restores a previously saved view. The tool-call arguments are the +// authoritative state: the client listens for this function_call on SSE +// and applies the filters + highlights directly without needing a round +// trip back for metadata. The agent is expected to have looked up the +// saved view server-side before emitting this call (it passes the +// already-resolved state through). +const load_view = tool({ + name: "load_view", + description: + "Restore a previously saved dashboard view by applying its filters and highlights. The caller supplies the already-resolved state so the client can apply it from this tool call without a second round trip.", + schema: z.object({ + name: z.string().describe("The saved view's name (for UI feedback)"), + filters: z + .object({ + date_from: z.string().optional(), + date_to: z.string().optional(), + pickup_zip: z.string().optional(), + fare_min: z.string().optional(), + fare_max: z.string().optional(), + }) + .passthrough() + .describe("Filters to restore. Omit fields that should not be set."), + highlights: z + .array( + z.object({ + start: z.string(), + end: z.string(), + color: z.enum(["blue", "red", "yellow"]).optional(), + label: z.string().optional(), + }), + ) + .describe("Highlight ranges to restore."), + }), + execute: async ({ name }) => `Restored saved view "${name}".`, +}); + +const focus_chart = tool({ + name: "focus_chart", + description: + "Scroll the user's viewport to a specific chart on the dashboard and briefly pulse it to draw attention. Use when the user asks to 'look at' or 'focus on' a specific visualization.", + schema: z.object({ + chart_id: z + .enum([ + "kpis", + "trips_over_time", + "fare_distribution", + "hourly_heatmap", + "top_zones", + ]) + .describe("Which chart to focus on"), + }), + execute: async ({ chart_id }) => `Focused on ${chart_id}.`, +}); + +const highlight_zone = tool({ + name: "highlight_zone", + description: + "Draw an emphasis ring around a specific pickup ZIP on the Top Pickup Zones chart. Use this to call attention to a standout zone without filtering the whole dashboard to that ZIP.", + schema: z.object({ + zip: z.string().describe("Pickup ZIP code to highlight (e.g. '10017')"), + label: z + .string() + .optional() + .describe("Optional short label shown inside the highlighted bar"), + }), + execute: async ({ zip, label }) => + `Highlighted pickup ZIP ${zip}${label ? ` (${label})` : ""}.`, +}); + +const clear_zone_highlights = tool({ + name: "clear_zone_highlights", + description: "Remove all emphasis rings from the Top Pickup Zones chart.", + schema: z.object({}), + execute: async () => "Zone highlights cleared.", +}); + +// Write tool: exercises the approval gate. Server handler is a stub — +// no view persistence — but `effect: "write"` forces the human-in-the-loop +// flow before the agent can call it. We pick `write` (not `destructive`) +// because capturing a view CREATES a new file; nothing is deleted or +// overwritten. The approval card will render the low-severity blue +// "writes" treatment rather than the alarming red "destructive" one. +const save_view = tool({ + name: "save_view", + description: + "Persist the current dashboard configuration (filters + highlights) as a named view the user can recall later. Always surfaces the approval gate as a write action.", + annotations: { effect: "write" }, + schema: z.object({ + name: z.string().describe("Short human-readable name for the saved view"), + description: z + .string() + .optional() + .describe("Optional longer description for the saved view"), + }), + execute: async ({ name, description }) => { + const suffix = description ? `: ${description}` : ""; + return `Saved view "${name}"${suffix}.`; + }, +}); + +export default createAgent({ + instructions: [ + "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", + "Filters:", + "- `filter_by_date_range({start, end})` — narrow to a date window within 2016.", + "- `filter_by_pickup_zip({zip})` — narrow to trips from a specific ZIP.", + "- `filter_by_fare({min?, max?})` — narrow by fare range (at least one bound required).", + "- `clear_filters()` — remove all active filters.", + "Highlights:", + "- `highlight_period({start, end, color?, label?})` — shade a date window on the Trips Over Time chart.", + "- `clear_highlights()` — remove all shaded overlays from the trips chart.", + "- `highlight_zone({zip, label?})` — draw an emphasis ring around a specific ZIP on the Top Pickup Zones chart.", + "- `clear_zone_highlights()` — remove all ZIP emphasis rings.", + "Focus & save:", + "- `focus_chart({chart_id})` — scroll the viewport to one of `kpis`, `trips_over_time`, `fare_distribution`, `hourly_heatmap`, `top_zones` and briefly pulse it.", + "- `save_view({name, description?})` — persist the current configuration. Write action; the user will see an approval card.", + "- `load_view({name, filters, highlights})` — restore a previously saved view. Always pass the resolved state; never leave fields unset.", + "Rules:", + "1. Pick the single tool that matches the user's intent. Do not chain filters unless the user asks for a compound filter.", + "2. Briefly state what you did after the tool returns. Do not narrate before calling the tool.", + "3. If the user's request is ambiguous (e.g. 'filter to last month' without a 2016 context), ask one clarifying question before calling any tool.", + "4. For standout ZIPs, prefer `highlight_zone` over `filter_by_pickup_zip` so the rest of the dashboard stays in context. Only filter when the user explicitly asks to narrow the whole dashboard.", + ].join("\n"), + tools: { + filter_by_date_range, + filter_by_pickup_zip, + filter_by_fare, + clear_filters, + highlight_period, + clear_highlights, + highlight_zone, + clear_zone_highlights, + focus_chart, + save_view, + load_view, + }, +}); diff --git a/apps/dev-playground/server/agents/helper/agent.ts b/apps/dev-playground/server/agents/helper/agent.ts new file mode 100644 index 000000000..2242e7559 --- /dev/null +++ b/apps/dev-playground/server/agents/helper/agent.ts @@ -0,0 +1,22 @@ +import { createAgent, tool } from "@databricks/appkit/beta"; +import { z } from "zod"; + +// Code-defined demo agent showing the tools(plugins) function form alongside +// the markdown-driven agents. Discovered automatically from +// server/agents/helper/ — its id is the folder name ("helper"). +export default createAgent({ + instructions: + "You are a demo helper. Use analytics tools to answer data questions, " + + "or get_weather for light small-talk.", + tools(plugins) { + return { + ...plugins.analytics.toolkit(), + get_weather: tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ city: z.string().describe("City name") }), + execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, + }), + }; + }, +}); diff --git a/apps/dev-playground/config/agents/insights/agent.md b/apps/dev-playground/server/agents/insights/agent.md similarity index 100% rename from apps/dev-playground/config/agents/insights/agent.md rename to apps/dev-playground/server/agents/insights/agent.md diff --git a/apps/dev-playground/config/agents/query/agent.md b/apps/dev-playground/server/agents/query/agent.md similarity index 100% rename from apps/dev-playground/config/agents/query/agent.md rename to apps/dev-playground/server/agents/query/agent.md diff --git a/apps/dev-playground/server/agents/sql_analyst/agent.ts b/apps/dev-playground/server/agents/sql_analyst/agent.ts new file mode 100644 index 000000000..ab149558a --- /dev/null +++ b/apps/dev-playground/server/agents/sql_analyst/agent.ts @@ -0,0 +1,16 @@ +import { createAgent } from "@databricks/appkit/beta"; + +// Smart-Dashboard specialist: writes Databricks SQL against +// `samples.nyctaxi.trips`. A sub-agent of the markdown `query` dispatcher. +export default createAgent({ + instructions: [ + "You are a SQL expert for NYC taxi trip data (`samples.nyctaxi.trips`).", + "Write Databricks SQL to answer the user's question and summarize the results clearly.", + "IMPORTANT: The dataset only contains trips from 2016. Always add `WHERE tpep_pickup_datetime >= '2016-01-01' AND tpep_pickup_datetime < '2017-01-01'` unless the user specifies a narrower date range within 2016.", + "If the user asks about dates outside 2016, say the dataset only covers 2016.", + "Available columns: tpep_pickup_datetime, tpep_dropoff_datetime, trip_distance, fare_amount, pickup_zip, dropoff_zip.", + ].join(" "), + tools(plugins) { + return { ...plugins.analytics.toolkit() }; + }, +}); diff --git a/apps/dev-playground/server/agents/supervisor/agent.ts b/apps/dev-playground/server/agents/supervisor/agent.ts new file mode 100644 index 000000000..1449a4337 --- /dev/null +++ b/apps/dev-playground/server/agents/supervisor/agent.ts @@ -0,0 +1,33 @@ +import { + createAgent, + DatabricksAdapter, + supervisorTools, +} from "@databricks/appkit/beta"; + +// Supervisor API demo agent. The Databricks AI Gateway executes hosted +// tools server-side; declare them via `createAgent({ tools })` like any +// other agent tool — the agents plugin classifies the tagged record and +// routes it to the adapter via AgentInput.extensions. Uncomment an entry +// below to give the model real powers. +// +// `createAgent({ model })` accepts an adapter promise, so the factory's +// host/credential resolution is awaited lazily on first dispatch (via +// `resolveAdapter` in the agents plugin). A misconfigured workspace will +// surface at first chat request, not at module init. +export default createAgent({ + instructions: + "You are an assistant powered by the Databricks Supervisor API.", + model: DatabricksAdapter.fromSupervisorApi({ + model: "databricks-claude-sonnet-4-5", + }), + tools: () => ({ + nyc: supervisorTools.genieSpace({ + id: process.env.DATABRICKS_GENIE_SPACE_ID ?? "", + description: "NYC taxi trip records and zones", + }), + add: supervisorTools.ucFunction({ + name: process.env.DATABRICKS_UC_FUNCTION_NAME ?? "", + description: "Adds two integers and returns the sum.", + }), + }), +}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 0435fad4e..eb88c2a97 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -12,15 +12,7 @@ import { serving, WRITE_ACTIONS, } from "@databricks/appkit"; -import { - agents, - aiSearch, - createAgent, - DatabricksAdapter, - supervisorTools, - tool, -} from "@databricks/appkit/beta"; -import { z } from "zod"; +import { agents, aiSearch } from "@databricks/appkit/beta"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; @@ -57,314 +49,6 @@ const adminOnly: FilePolicy = (action, _resource, user) => { return true; }; -// Code-defined demo agent showing the tools(plugins) function form -// alongside the markdown-driven agents in config/agents/. -const helper = createAgent({ - instructions: - "You are a demo helper. Use analytics tools to answer data questions, " + - "or get_weather for light small-talk.", - tools(plugins) { - return { - ...plugins.analytics.toolkit(), - get_weather: tool({ - name: "get_weather", - description: "Get the current weather for a city", - schema: z.object({ city: z.string().describe("City name") }), - execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, - }), - }; - }, -}); - -// Supervisor API demo agent. The Databricks AI Gateway executes hosted -// tools server-side; declare them via `createAgent({ tools })` like any -// other agent tool — the agents plugin classifies the tagged record and -// routes it to the adapter via AgentInput.extensions. Import -// `supervisorTools` from '@databricks/appkit/beta' and uncomment an -// entry below to give the model real powers. -// -// `createAgent({ model })` accepts an adapter promise, so the factory's -// host/credential resolution is awaited lazily on first dispatch (via -// `resolveAdapter` in the agents plugin). A misconfigured workspace will -// surface at first chat request, not at module init. -const supervisor = createAgent({ - instructions: - "You are an assistant powered by the Databricks Supervisor API.", - model: DatabricksAdapter.fromSupervisorApi({ - model: "databricks-claude-sonnet-4-5", - }), - tools: () => ({ - nyc: supervisorTools.genieSpace({ - id: process.env.DATABRICKS_GENIE_SPACE_ID ?? "", - description: "NYC taxi trip records and zones", - }), - add: supervisorTools.ucFunction({ - name: process.env.DATABRICKS_UC_FUNCTION_NAME ?? "", - description: "Adds two integers and returns the sum.", - }), - }), -}); - -/* - * Smart-Dashboard agents. - * - * The three agents form a dispatcher pattern for the /smart-dashboard route. - * The `query` agent (markdown, in config/agents/query/) routes user - * questions to one of two specialists: - * - * - `sql_analyst` — writes Databricks SQL against `samples.nyctaxi.trips` - * using the analytics plugin's query tool. - * - `dashboard_pilot` — emits UI-action tool calls (`apply_filter`, - * `highlight_period`) that the client reads off the SSE stream and - * translates into React state mutations. The server-side handlers are - * intentionally stubs — the tool-call JSON is the action payload. - */ - -// Narrow, single-purpose tools. -// -// The earlier polymorphic `apply_filter({ field, operator, value })` was -// too expressive — the LLM could emit valid-looking calls the dispatcher -// couldn't faithfully apply (e.g. `field: "dropoff_zone"` when the -// dashboard only has a `pickup_zip` filter; `operator: "eq"` with a date). -// Splitting into one tool per filter verb removes the whole class of -// "agent said it worked but nothing moved" bugs. -// -// Each tool has exactly one client-side effect, rendered by -// use-action-dispatcher. Server handlers are still stubs — the tool-call -// JSON is the action payload. - -const filter_by_date_range = tool({ - name: "filter_by_date_range", - description: - "Filter the dashboard to trips within a date range. Both start and end are required and must be ISO dates (YYYY-MM-DD) within 2016.", - schema: z.object({ - start: z.string().describe("Start date in ISO format, e.g. 2016-03-01"), - end: z.string().describe("End date in ISO format, e.g. 2016-03-31"), - }), - execute: async ({ start, end }) => - `Filtered dashboard to trips between ${start} and ${end}.`, -}); - -const filter_by_pickup_zip = tool({ - name: "filter_by_pickup_zip", - description: - "Filter the dashboard to trips originating from a specific pickup ZIP code. Use when the user asks about a specific pickup zone or ZIP.", - schema: z.object({ - zip: z.string().describe("Pickup ZIP code, e.g. 10001"), - }), - execute: async ({ zip }) => - `Filtered dashboard to trips picked up in ${zip}.`, -}); - -const filter_by_fare = tool({ - name: "filter_by_fare", - description: - "Filter the dashboard to trips within a fare range. At least one of min or max must be provided.", - schema: z - .object({ - min: z.number().optional().describe("Minimum fare in USD"), - max: z.number().optional().describe("Maximum fare in USD"), - }) - .refine((v) => v.min !== undefined || v.max !== undefined, { - message: "Provide at least one of min or max.", - }), - execute: async ({ min, max }) => { - const parts = [] as string[]; - if (min !== undefined) parts.push(`>= $${min}`); - if (max !== undefined) parts.push(`<= $${max}`); - return `Filtered dashboard to trips with fare ${parts.join(" and ")}.`; - }, -}); - -const clear_filters = tool({ - name: "clear_filters", - description: - "Remove all active filters from the dashboard. Use when the user asks to reset, clear, or remove filters.", - schema: z.object({}), - execute: async () => "All filters cleared.", -}); - -const highlight_period = tool({ - name: "highlight_period", - description: - "Highlight a time period on the Trips Over Time chart to draw attention to a specific date range.", - schema: z.object({ - start: z.string().describe("Start date in ISO format (YYYY-MM-DD)"), - end: z.string().describe("End date in ISO format (YYYY-MM-DD)"), - color: z - .enum(["blue", "red", "yellow"]) - .optional() - .describe("Highlight color. Defaults to blue."), - label: z - .string() - .optional() - .describe("Optional label for the highlighted period"), - }), - execute: async ({ start, end, color: _color, label }) => { - const suffix = label ? ` (${label})` : ""; - return `Highlighted period ${start} to ${end}${suffix} on the dashboard.`; - }, -}); - -const clear_highlights = tool({ - name: "clear_highlights", - description: - "Remove all highlight overlays from the charts. Use when the user asks to clear, reset, or remove highlights.", - schema: z.object({}), - execute: async () => "All highlights cleared.", -}); - -// Restores a previously saved view. The tool-call arguments are the -// authoritative state: the client listens for this function_call on SSE -// and applies the filters + highlights directly without needing a round -// trip back for metadata. The agent is expected to have looked up the -// saved view server-side before emitting this call (it passes the -// already-resolved state through). -const load_view = tool({ - name: "load_view", - description: - "Restore a previously saved dashboard view by applying its filters and highlights. The caller supplies the already-resolved state so the client can apply it from this tool call without a second round trip.", - schema: z.object({ - name: z.string().describe("The saved view's name (for UI feedback)"), - filters: z - .object({ - date_from: z.string().optional(), - date_to: z.string().optional(), - pickup_zip: z.string().optional(), - fare_min: z.string().optional(), - fare_max: z.string().optional(), - }) - .passthrough() - .describe("Filters to restore. Omit fields that should not be set."), - highlights: z - .array( - z.object({ - start: z.string(), - end: z.string(), - color: z.enum(["blue", "red", "yellow"]).optional(), - label: z.string().optional(), - }), - ) - .describe("Highlight ranges to restore."), - }), - execute: async ({ name }) => `Restored saved view "${name}".`, -}); - -const focus_chart = tool({ - name: "focus_chart", - description: - "Scroll the user's viewport to a specific chart on the dashboard and briefly pulse it to draw attention. Use when the user asks to 'look at' or 'focus on' a specific visualization.", - schema: z.object({ - chart_id: z - .enum([ - "kpis", - "trips_over_time", - "fare_distribution", - "hourly_heatmap", - "top_zones", - ]) - .describe("Which chart to focus on"), - }), - execute: async ({ chart_id }) => `Focused on ${chart_id}.`, -}); - -const highlight_zone = tool({ - name: "highlight_zone", - description: - "Draw an emphasis ring around a specific pickup ZIP on the Top Pickup Zones chart. Use this to call attention to a standout zone without filtering the whole dashboard to that ZIP.", - schema: z.object({ - zip: z.string().describe("Pickup ZIP code to highlight (e.g. '10017')"), - label: z - .string() - .optional() - .describe("Optional short label shown inside the highlighted bar"), - }), - execute: async ({ zip, label }) => - `Highlighted pickup ZIP ${zip}${label ? ` (${label})` : ""}.`, -}); - -const clear_zone_highlights = tool({ - name: "clear_zone_highlights", - description: "Remove all emphasis rings from the Top Pickup Zones chart.", - schema: z.object({}), - execute: async () => "Zone highlights cleared.", -}); - -// Write tool: exercises the approval gate. Server handler is a stub — -// no view persistence — but `effect: "write"` forces the human-in-the-loop -// flow before the agent can call it. We pick `write` (not `destructive`) -// because capturing a view CREATES a new file; nothing is deleted or -// overwritten. The approval card will render the low-severity blue -// "writes" treatment rather than the alarming red "destructive" one. -const save_view = tool({ - name: "save_view", - description: - "Persist the current dashboard configuration (filters + highlights) as a named view the user can recall later. Always surfaces the approval gate as a write action.", - annotations: { effect: "write" }, - schema: z.object({ - name: z.string().describe("Short human-readable name for the saved view"), - description: z - .string() - .optional() - .describe("Optional longer description for the saved view"), - }), - execute: async ({ name, description }) => { - const suffix = description ? `: ${description}` : ""; - return `Saved view "${name}"${suffix}.`; - }, -}); - -const sql_analyst = createAgent({ - instructions: [ - "You are a SQL expert for NYC taxi trip data (`samples.nyctaxi.trips`).", - "Write Databricks SQL to answer the user's question and summarize the results clearly.", - "IMPORTANT: The dataset only contains trips from 2016. Always add `WHERE tpep_pickup_datetime >= '2016-01-01' AND tpep_pickup_datetime < '2017-01-01'` unless the user specifies a narrower date range within 2016.", - "If the user asks about dates outside 2016, say the dataset only covers 2016.", - "Available columns: tpep_pickup_datetime, tpep_dropoff_datetime, trip_distance, fare_amount, pickup_zip, dropoff_zip.", - ].join(" "), - tools(plugins) { - return { ...plugins.analytics.toolkit() }; - }, -}); - -const dashboard_pilot = createAgent({ - instructions: [ - "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", - "Filters:", - "- `filter_by_date_range({start, end})` — narrow to a date window within 2016.", - "- `filter_by_pickup_zip({zip})` — narrow to trips from a specific ZIP.", - "- `filter_by_fare({min?, max?})` — narrow by fare range (at least one bound required).", - "- `clear_filters()` — remove all active filters.", - "Highlights:", - "- `highlight_period({start, end, color?, label?})` — shade a date window on the Trips Over Time chart.", - "- `clear_highlights()` — remove all shaded overlays from the trips chart.", - "- `highlight_zone({zip, label?})` — draw an emphasis ring around a specific ZIP on the Top Pickup Zones chart.", - "- `clear_zone_highlights()` — remove all ZIP emphasis rings.", - "Focus & save:", - "- `focus_chart({chart_id})` — scroll the viewport to one of `kpis`, `trips_over_time`, `fare_distribution`, `hourly_heatmap`, `top_zones` and briefly pulse it.", - "- `save_view({name, description?})` — persist the current configuration. Write action; the user will see an approval card.", - "- `load_view({name, filters, highlights})` — restore a previously saved view. Always pass the resolved state; never leave fields unset.", - "Rules:", - "1. Pick the single tool that matches the user's intent. Do not chain filters unless the user asks for a compound filter.", - "2. Briefly state what you did after the tool returns. Do not narrate before calling the tool.", - "3. If the user's request is ambiguous (e.g. 'filter to last month' without a 2016 context), ask one clarifying question before calling any tool.", - "4. For standout ZIPs, prefer `highlight_zone` over `filter_by_pickup_zip` so the rest of the dashboard stays in context. Only filter when the user explicitly asks to narrow the whole dashboard.", - ].join("\n"), - tools: { - filter_by_date_range, - filter_by_pickup_zip, - filter_by_fare, - clear_filters, - highlight_period, - clear_highlights, - highlight_zone, - clear_zone_highlights, - focus_chart, - save_view, - load_view, - }, -}); - /** * OBO demo policy: deny anything running as the SP (including the dev * fallback when no `x-forwarded-access-token` is present). Only real @@ -422,13 +106,14 @@ createApp({ }), serving(), agents({ - agents: { helper, sql_analyst, dashboard_pilot, supervisor }, - // `query` (markdown dispatcher) + `sql_analyst` + `dashboard_pilot` - // wire the /smart-dashboard route. `insights` and `anomaly` are - // ephemeral markdown agents auto-fired by the route's AgentSidebar. - // `helper` is the conversational default for the bare `/agent` route - // (the markdown agents are dispatchers or ephemeral and don't make - // sense as the user-facing landing agent). + // Every agent lives under server/agents// — code agents as agent.ts + // (helper, supervisor, sql_analyst, dashboard_pilot), markdown agents as + // agent.md (query, insights, anomaly, autocomplete). `query` (markdown + // dispatcher) delegates to the code `sql_analyst` + `dashboard_pilot` to + // wire the /smart-dashboard route. `insights` and `anomaly` are ephemeral + // markdown agents auto-fired by the route's AgentSidebar. `helper` is the + // conversational default for the bare `/agent` route (the markdown agents + // are dispatchers or ephemeral and don't make sense as the landing agent). defaultAgent: "helper", }), aiSearch({ diff --git a/docs/docs/api/appkit/Function.createAgent.md b/docs/docs/api/appkit/Function.createAgent.md index 61064e512..a51e0c57d 100644 --- a/docs/docs/api/appkit/Function.createAgent.md +++ b/docs/docs/api/appkit/Function.createAgent.md @@ -4,13 +4,11 @@ function createAgent(def: AgentDefinition): AgentDefinition; ``` -Pure factory for agent definitions. Returns the passed-in definition after -cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape -and is safe to call at module top-level. - -The returned value is a plain `AgentDefinition` — no adapter construction, -no side effects. Register it with `agents({ agents: { name: def } })` or run -it standalone via `runAgent(def, input)`. +Pure factory for agent definitions: cycle-detects the sub-agent graph and +returns the same object, stamped with a non-enumerable AGENT\_BRAND +so discovery recognizes it. Safe at module top-level; no adapter is built. +Don't `Object.freeze` the definition before passing it in — the brand is +written onto the argument. ## Parameters diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 8996e759c..9ce1f9bc5 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -22,6 +22,19 @@ Override the plugin's baseSystemPrompt for this agent only. *** +### default? + +```ts +optional default: boolean; +``` + +Marks this agent as the default one chosen when a client doesn't name an +agent. Mirrors markdown frontmatter `default: true`. When several agents +set it, the first in stable id order wins; an explicit +`agents({ defaultAgent })` always overrides it. Defaults to `false`. + +*** + ### ephemeral? ```ts @@ -100,7 +113,7 @@ optional name: string; Stable identifier for the agent. **Optional and informational** — when the definition is registered via `agents: { foo: def }` (code) or -lives at `config/agents//agent.md` (markdown), the **registry key +lives at `server/agents//agent.md` (markdown), the **registry key always wins** and `name` is ignored. The agent will be reachable as `foo` (or ``) regardless of what this field contains. diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index c038d41c1..b51037a9e 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -14,13 +14,21 @@ Base configuration interface for AppKit plugins ## Properties -### agents? +### ~~agents?~~ ```ts optional agents: Record; ``` -Code-defined agents, merged with file-loaded ones (code wins on key collision). +#### Deprecated + +Put each code agent in its own folder under +`server/agents//agent.ts` (`export default createAgent({ ... })`); it is +discovered automatically at startup and the call collapses to +`agents({ ... })` with no map. Still honored for backward compatibility +(emits a one-time deprecation warning) but will be removed in a future +minor. If both discovery and this map define the same id, discovery wins +and the map entry is ignored. *** @@ -112,7 +120,16 @@ Default model for agents that don't specify their own (in code or frontmatter). optional dir: string | false; ``` -Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. +Unified agents root. Each `/` folder holds either `agent.md` (markdown) +or `agent.ts` (code, `export default createAgent({ ... })`); the folder +name is the agent id. Default `server/agents` — leave unset. In a built +server, code agents are loaded from the compiled output (`dist/` / +`build/`, where `` is this dir's basename) and markdown is read +from source. A **relative** custom path is resolved this way too; an +**absolute** path is scanned verbatim (you own compiling it for prod). Set +to `false` to disable all file discovery — including the `config/agents/` +fallback below. Markdown still under `config/agents/` is otherwise read as a +deprecated fallback (one-time warning). *** diff --git a/docs/docs/api/appkit/Variable.agents.md b/docs/docs/api/appkit/Variable.agents.md index 227a5bf3b..4e83e1ddb 100644 --- a/docs/docs/api/appkit/Variable.agents.md +++ b/docs/docs/api/appkit/Variable.agents.md @@ -4,10 +4,12 @@ const agents: ToPlugin; ``` -Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, -resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` -runtime API and mounts `POST /invocations` and `POST /responses` (aliased -non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). +Plugin factory for the agents plugin. Discovers agents from +`server/agents//agent.{ts,md}` by default (markdown still in +`config/agents/` is read as a deprecated fallback), resolves toolkits/tools +from registered plugins, exposes the `appkit.agents.*` runtime API and mounts +`POST /invocations` and `POST /responses` (aliased non-streaming invoke +endpoints) plus `POST /chat` (streaming, HITL-capable). ## Example diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f39a52db2..c720277b8 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -127,7 +127,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | Variable | Description | | ------ | ------ | -| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | +| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | @@ -142,7 +142,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [agentIdFromMarkdownPath](Function.agentIdFromMarkdownPath.md) | Derives the logical agent id from a markdown path. When the file is named `agent.md`, the id is the parent directory name (folder-based layout); otherwise the id is the file stem (e.g. legacy single-file paths). | | [appKitServingTypesPlugin](Function.appKitServingTypesPlugin.md) | Vite plugin to generate TypeScript types for AppKit serving endpoints. Fetches OpenAPI schemas from Databricks and generates a .d.ts with ServingEndpointRegistry module augmentation. | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | -| [createAgent](Function.createAgent.md) | Pure factory for agent definitions. Returns the passed-in definition after cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape and is safe to call at module top-level. | +| [createAgent](Function.createAgent.md) | Pure factory for agent definitions: cycle-detects the sub-agent graph and returns the same object, stamped with a non-enumerable AGENT\_BRAND so discovery recognizes it. Safe at module top-level; no adapter is built. Don't `Object.freeze` the definition before passing it in — the brand is written onto the argument. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index d1b7a79e4..e5385d7d1 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -6,7 +6,7 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor ::: -The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It loads agent definitions from markdown on disk (one folder per agent: `config/agents//agent.md`), from TypeScript (`createAgent(def)`), or both, and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. +The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It discovers agent definitions from disk — one folder per agent under `server/agents/`, holding either `agent.md` (markdown) or `agent.ts` (code) — and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval. In every case the agent's id is its folder name; there's no map to maintain and no id to restate. This page covers the full lifecycle. For the hand-written primitives (`tool()`, `mcpServer()`), see [tools](./server.md). @@ -37,14 +37,15 @@ That alone gives you a live HTTP server with `POST /invocations` (and its alias ## Level 1: drop a markdown agent package -Each agent lives in its own directory with a fixed entry file `agent.md`. A reserved top-level folder named `skills` is ignored until per-agent skills ship (you can add other asset folders beside `agent.md` under each agent id). +Each agent lives in its own folder under `server/agents/` with entry file `agent.md`. A folder is an agent only if it holds an entry file (`agent.md` or `agent.ts`); a folder without one is skipped, so per-agent asset folders like `skills/` sit beside the entry. ``` my-app/ - server.ts - config/agents/ - assistant/ - agent.md + server/ + server.ts + agents/ + assistant/ + agent.md ``` ```md @@ -60,13 +61,17 @@ Use the available tools to query data, browse files, and help users. On startup the plugin: -1. Discovers `./config/agents/assistant/agent.md` and registers agent id `assistant`. +1. Discovers `server/agents/assistant/agent.md` and registers agent id `assistant`. 2. Parses the YAML frontmatter and markdown body as the agent's `instructions`. 3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_AGENT_ENDPOINT`). 4. Mounts the agent at the default name (`assistant`). The agent starts with **no tools**. Tools are opt-in — declare them in frontmatter (Level 2 below) or opt into auto-inherit explicitly with `agents({ autoInheritTools: { file: true } })`. See "Auto-inherit posture" further down for what that costs and why it's off by default. +:::note Migrating from `config/agents/` +Earlier versions kept markdown agents under `config/agents//agent.md`. That location is still read as a deprecated fallback (one-time warning on boot); move each folder to `server/agents//agent.md` so every agent — markdown and code — lives in one place. +::: + Requests land at `POST /invocations` (or its alias `POST /responses`) with an OpenAI Responses-compatible body. These endpoints run the agent to completion and return a single JSON response — no SSE. Streaming clients should use `POST /chat`. Every tool call runs through `asUser(req)` so SQL executes as the requesting user, file access respects Unity Catalog ACLs, and telemetry spans are created automatically. :::warning No HITL on `/invocations` and `/responses` @@ -100,12 +105,14 @@ When any `tools:` is declared the auto-inherit default is turned off — the age ## Level 3: code-defined agents +Code agents live one-per-folder under `server/agents/`, with entry file `agent.ts` (mirroring markdown's `agent.md`). The entry exports a created agent and its **id is the folder name** (`server/agents/support/agent.ts` → `support`). Nothing restates the id. + ```ts -import { analytics, createApp, files, server } from "@databricks/appkit"; -import { agents, createAgent, tool } from "@databricks/appkit/beta"; +// server/agents/support/agent.ts +import { createAgent, tool } from "@databricks/appkit/beta"; import { z } from "zod"; -const support = createAgent({ +export default createAgent({ // id derived from folder name: "support" instructions: "You help customers with data and files.", model: "databricks-claude-sonnet-4-5", // string sugar tools(plugins) { @@ -120,18 +127,38 @@ const support = createAgent({ }; }, }); +``` + +The `agents` plugin discovers these files at startup — no registration, no map: + +```ts +// server/server.ts +import { analytics, createApp, files, server } from "@databricks/appkit"; +import { agents } from "@databricks/appkit/beta"; await createApp({ - plugins: [server(), analytics(), files(), agents({ agents: { support } })], + plugins: [server(), analytics(), files(), agents()], // no agent map, no import }); ``` +Discovery imports each `server/agents//agent.ts` — the source `.ts` under `tsx` in dev, and the compiled `dist/agents//agent.js` in a production build (built output wins over source, independent of `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*/agent.ts` as build entries so `dist/agents/*/agent.js` are emitted for the scan — that wiring is what lets a dropped-in folder survive the prod bundle. (Markdown `agent.md` is read from source in both dev and prod — it's data, not compiled.) The root is `server/agents` by default; override with `agents({ dir })` (a relative path is resolved built-first the same way; an absolute path is scanned verbatim; `false` disables discovery, including the `config/agents/` fallback). + +:::note Built output shadows source in dev +Because compiled output wins over source, a stale `dist/agents` / `build/agents` left over from a previous `npm run build` will be picked up by `npm run dev` instead of your live `server/agents/*.ts`, so edits appear ignored. Delete the build dir (or re-run the build) if a code agent seems frozen. Markdown is always read from source, so `agent.md` edits are never shadowed. +::: + +The entry may `export default createAgent({...})` or export a single named created agent; either way the id is the folder name. A folder whose entry exports no created agent (or has no `agent.ts`/`agent.md` at all) is skipped. Mark one agent as the default with `createAgent({ default: true })` (mirrors markdown frontmatter `default: true`); an explicit `agents({ defaultAgent })` still wins. + Code-defined agents start with no tools by default. The function form `tools(plugins) => Record` is the primary way to pull in plugin tools: each plugin registered in `createApp({ plugins: [...] })` shows up on the `plugins` parameter, and you call `.toolkit(opts?)` on it to get a spread-friendly record. The runtime invokes the function once at agent setup and caches the result — every plugin is mentioned exactly once (in `createApp`), with no held variables or marker imports. -Inline `tool({...})` calls live in the same record. `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). +Inline `tool({...})` calls live in the same record. Their `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. +:::warning Deprecated: the `agents({ agents: { ... } })` map +Passing a hand-built agent map still works and is honored for backward compatibility, but it emits a one-time deprecation warning and will be removed in a future minor. It restates each agent's id (once in `createAgent`, once as the map key); discovery from `server/agents/` removes both the map and the restatement. Migrate by moving each `createAgent(...)` into its own `server/agents//agent.ts` (default or single named export) and dropping the map. A discovered agent and a map entry may not share an id. (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) +::: + ### Scoping tools in code `plugins..toolkit(opts?)` accepts the same `ToolkitOptions` as markdown frontmatter: @@ -167,15 +194,15 @@ const supervisor = createAgent({ agents: { researcher, writer }, // exposed as agent-researcher, agent-writer }); +// server/agents/{supervisor,researcher,writer}/agent.ts — one folder each +export default supervisor; + await createApp({ - plugins: [ - server(), - agents({ agents: { supervisor, researcher, writer } }), - ], + plugins: [server(), agents()], // discovered from server/agents/ }); ``` -Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. +Put `supervisor`, `researcher`, and `writer` in their own `server/agents//agent.ts` folders (default export each) — a markdown parent can also delegate to a code child in a sibling folder via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time. ## Level 5: standalone (no `createApp`) @@ -351,8 +378,8 @@ Some hosted tool kinds return their final assistant text without incremental `ou ```ts agents({ - dir?: string | false, // "./config/agents" default; false disables - agents?: Record, + dir?: string | false, // agents root; "server/agents" default; false disables (config/agents read as deprecated fallback) + agents?: Record, // DEPRECATED — use server/agents// discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, tools?: Record, diff --git a/packages/appkit/src/core/agent/create-agent.ts b/packages/appkit/src/core/agent/create-agent.ts index b4b119010..67c589317 100644 --- a/packages/appkit/src/core/agent/create-agent.ts +++ b/packages/appkit/src/core/agent/create-agent.ts @@ -2,13 +2,23 @@ import { ConfigurationError } from "../../errors"; import type { AgentDefinition } from "./types"; /** - * Pure factory for agent definitions. Returns the passed-in definition after - * cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape - * and is safe to call at module top-level. + * Non-enumerable brand stamped on every {@link createAgent} result. The + * code-agent loader ({@link loadCodeAgentsFromDir}) uses it to tell a real + * agent export from any other value a module in `server/agents/` might + * export, without duck-typing or guessing from the filename. * - * The returned value is a plain `AgentDefinition` — no adapter construction, - * no side effects. Register it with `agents({ agents: { name: def } })` or run - * it standalone via `runAgent(def, input)`. + * A registered (`Symbol.for`) symbol so the check still holds if two copies + * of the package end up loaded in one process — the app's agent files and + * the plugin can resolve `@databricks/appkit` independently. + */ +const AGENT_BRAND: unique symbol = Symbol.for("appkit.agent"); + +/** + * Pure factory for agent definitions: cycle-detects the sub-agent graph and + * returns the same object, stamped with a non-enumerable {@link AGENT_BRAND} + * so discovery recognizes it. Safe at module top-level; no adapter is built. + * Don't `Object.freeze` the definition before passing it in — the brand is + * written onto the argument. * * @example * ```ts @@ -23,9 +33,27 @@ import type { AgentDefinition } from "./types"; */ export function createAgent(def: AgentDefinition): AgentDefinition { detectCycles(def); + // Non-enumerable + in-place: identity, JSON, and spread are unaffected. + Object.defineProperty(def, AGENT_BRAND, { + value: true, + enumerable: false, + configurable: true, + }); return def; } +/** + * Type guard: true when `value` was produced by {@link createAgent}. Used by + * the code-agent loader to pick the agent export out of a discovered module. + */ +export function isCreatedAgent(value: unknown): value is AgentDefinition { + return ( + typeof value === "object" && + value !== null && + (value as Record)[AGENT_BRAND] === true + ); +} + /** * Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is * found. Cycles would cause infinite recursion at tool-invocation time. diff --git a/packages/appkit/src/core/agent/load-agents.ts b/packages/appkit/src/core/agent/load-agents.ts index 9b220a420..afb3c806b 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -206,13 +206,11 @@ export async function loadAgentsFromDir( ); } - /** Reserved folder name until per-agent skills land; not an agent package. */ - const RESERVED_DIRS = new Set(["skills"]); - const agentIds = entries - .filter((e) => e.isDirectory()) + // Symlinked agent folders count; a symlink to a file is filtered out below + // when reading agent.md (ENOTDIR). + .filter((e) => e.isDirectory() || e.isSymbolicLink()) .map((e) => e.name) - .filter((name) => !RESERVED_DIRS.has(name)) .sort(); const defs: Record = {}; @@ -226,11 +224,10 @@ export async function loadAgentsFromDir( try { raw = await fs.readFile(agentPath, "utf-8"); } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error( - `Agents subdirectory '${path.join(dir, id)}' must contain agent.md.`, - ); - } + // No agent.md → a code-agent folder (agent.ts) or an asset dir (skills/); + // ENOTDIR → the entry is a symlink to a file, not an agent folder. + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") continue; throw err; } defs[id] = buildDefinition(id, raw, agentPath, ctx); diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts new file mode 100644 index 000000000..e88ac3f68 --- /dev/null +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -0,0 +1,182 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { createLogger } from "../../logging/logger"; +import { isCreatedAgent } from "./create-agent"; +import type { AgentDefinition } from "./types"; + +const logger = createLogger("agents:code-loader"); + +/** Where code agents live in source (a `tsx` dev run imports the `.ts`). */ +export const CODE_AGENTS_SOURCE_DIR = "server/agents"; +/** Compiled-output roots probed before source (tsdown emits into `dist`/`build`). */ +const CODE_AGENTS_BUILT_ROOTS = ["dist", "build"]; +/** Per-agent entry file, mirroring markdown's `agent.md`. */ +const ENTRY_BASENAME = "agent"; + +interface ResolvedCodeAgentsDir { + dir: string; + extensions: string[]; +} + +/** + * Resolves which directory to scan for code agents. Compiled output wins over + * source unconditionally, so a bundled server never `import()`s a `.ts` (plain + * Node can't load one): for a relative source dir the matching `dist/` / + * `build/` is probed first, with the source `.ts` dir as fallback. + * `override`: `false` disables discovery; a relative string is the source dir + * (still built-first); an absolute string is scanned verbatim (the caller pins + * the exact path and owns compiling it for a prod build). + */ +export function resolveCodeAgentsDir(opts: { + cwd: string; + override?: string | false; + exists: (dir: string) => boolean; +}): ResolvedCodeAgentsDir | null { + if (opts.override === false) return null; + if (typeof opts.override === "string" && path.isAbsolute(opts.override)) { + return { dir: opts.override, extensions: [".ts", ".tsx", ".js", ".mjs"] }; + } + + const sourceRel = opts.override ?? CODE_AGENTS_SOURCE_DIR; + const name = path.basename(sourceRel); + const source: ResolvedCodeAgentsDir = { + dir: path.resolve(opts.cwd, sourceRel), + extensions: [".ts", ".tsx"], + }; + const built: ResolvedCodeAgentsDir[] = CODE_AGENTS_BUILT_ROOTS.map( + (root) => ({ + dir: path.resolve(opts.cwd, root, name), + extensions: [".js", ".mjs"], + }), + ); + + for (const candidate of [...built, source]) { + if (opts.exists(candidate.dir)) return candidate; + } + return source; +} + +/** + * The `agent.` entry file inside an agent folder, or `null` if none — + * a markdown agent (`agent.md`) or a non-agent asset dir (`skills/`). + */ +async function findEntryFile( + agentDir: string, + extensions: string[], +): Promise { + let files: string[]; + try { + files = await fs.readdir(agentDir); + } catch { + return null; + } + for (const ext of extensions) { + const name = `${ENTRY_BASENAME}${ext}`; + if (files.includes(name)) return path.join(agentDir, name); + } + return null; +} + +/** + * The single created agent a module exports — the default export, else the one + * branded named export. `undefined` if none (a helper or bundler chunk); throws + * if the entry file exports more than one (the folder name is the id). + */ +function pickAgentExport( + mod: Record, + filePath: string, +): AgentDefinition | undefined { + if (isCreatedAgent(mod.default)) return mod.default; + + const named = Object.entries(mod).filter( + ([key, value]) => key !== "default" && isCreatedAgent(value), + ); + if (named.length === 0) return undefined; + if (named.length > 1) { + throw new Error( + `Agent file '${filePath}' exports ${named.length} created agents (${named + .map(([k]) => k) + .join(", ")}); expected exactly one. ` + + "Export a single agent per folder (the folder name is its id).", + ); + } + return named[0][1] as AgentDefinition; +} + +/** + * Discovers code agents by importing each `/agent.` under `dir`; the + * agent's id is its folder name. Folders with no `agent.` (markdown + * agents, asset dirs) are skipped. Returns `{}` when `dir` is absent. + * + * Imports key on the plain `file://` URL, so `reload()` picks up added/removed + * folders but not edits to an already-imported one (that needs a restart). + */ +export async function loadCodeAgentsFromDir( + dir: string, + opts: { extensions: string[] }, +): Promise> { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } + + const folders = entries + // Include symlinked agent folders; findEntryFile's readdir follows the link + // and returns null for anything that isn't a real directory. + .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .map((e) => e.name) + .sort(); + + const agents: Record = {}; + + for (const id of folders) { + const entryFile = await findEntryFile(path.join(dir, id), opts.extensions); + if (!entryFile) continue; + + let mod: Record; + try { + mod = (await import(pathToFileURL(entryFile).href)) as Record< + string, + unknown + >; + } catch (err) { + // No TS loader for these `.ts` modules — warn once and bail rather than + // crash boot (every folder would fail the same way). + if ( + (err as NodeJS.ErrnoException).code === "ERR_UNKNOWN_FILE_EXTENSION" + ) { + logger.warn( + "Cannot import code agents from %s under this runtime (no TypeScript loader). " + + "A production build must compile server/agents/ to JS — check the `server/agents/*/agent.ts` entry glob in the tsdown config. Discovered no code agents.", + dir, + ); + return {}; + } + throw new Error( + `Failed to import code agent '${entryFile}': ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err instanceof Error ? err : undefined }, + ); + } + + const agent = pickAgentExport(mod, entryFile); + if (!agent) { + logger.debug( + "Skipping %s — no createAgent export (not a code agent).", + entryFile, + ); + continue; + } + + agents[id] = agent; + } + + return agents; +} diff --git a/packages/appkit/src/core/agent/tests/create-agent.test.ts b/packages/appkit/src/core/agent/tests/create-agent.test.ts index 638425b8a..1f6a3caf3 100644 --- a/packages/appkit/src/core/agent/tests/create-agent.test.ts +++ b/packages/appkit/src/core/agent/tests/create-agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; -import { createAgent } from "../create-agent"; +import { createAgent, isCreatedAgent } from "../create-agent"; import { tool } from "../tools/tool"; import type { AgentDefinition } from "../types"; @@ -37,6 +37,35 @@ describe("createAgent", () => { } }); + test("name is optional (id is derived elsewhere)", () => { + const def = createAgent({ instructions: "no name here" }); + expect(def.name).toBeUndefined(); + expect(def.instructions).toBe("no name here"); + }); + + test("carries the default flag through unchanged", () => { + const def = createAgent({ + instructions: "I am the default.", + default: true, + }); + expect(def.default).toBe(true); + }); + + test("brands the result so the code-agent loader can recognize it", () => { + const def = createAgent({ instructions: "branded" }); + expect(isCreatedAgent(def)).toBe(true); + // The brand is non-enumerable — invisible to spread and JSON. + expect(Object.keys(def)).not.toContain("Symbol(appkit.agent)"); + expect(JSON.parse(JSON.stringify(def))).toEqual({ + instructions: "branded", + }); + // Plain objects are not agents. + expect(isCreatedAgent({ instructions: "not made by createAgent" })).toBe( + false, + ); + expect(isCreatedAgent(null)).toBe(false); + }); + test("accepts sub-agents in a keyed record", () => { const researcher = createAgent({ instructions: "Research." }); const supervisor = createAgent({ diff --git a/packages/appkit/src/core/agent/tests/load-agents.test.ts b/packages/appkit/src/core/agent/tests/load-agents.test.ts index a25ace024..e17160168 100644 --- a/packages/appkit/src/core/agent/tests/load-agents.test.ts +++ b/packages/appkit/src/core/agent/tests/load-agents.test.ts @@ -184,14 +184,10 @@ describe("loadAgentsFromDir", () => { ); }); - test("throws when a subdirectory lacks agent.md", async () => { - fs.mkdirSync(path.join(workDir, "broken"), { recursive: true }); - await expect(loadAgentsFromDir(workDir, {})).rejects.toThrow( - /must contain agent\.md/, - ); - }); - - test("ignores reserved skills directory without agent.md", async () => { + test("skips a subdirectory that lacks agent.md", async () => { + // A folder with no agent.md is a code-agent folder (agent.ts) or an asset + // dir (skills/), not a markdown agent — skip it, don't throw. + fs.mkdirSync(path.join(workDir, "code-only"), { recursive: true }); fs.mkdirSync(path.join(workDir, "skills"), { recursive: true }); writeAgent("solo", "---\nendpoint: e\n---\nOnly real agent."); const res = await loadAgentsFromDir(workDir, {}); diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index d322c495f..d8302ff43 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -127,7 +127,7 @@ export interface AgentDefinition { /** * Stable identifier for the agent. **Optional and informational** — * when the definition is registered via `agents: { foo: def }` (code) or - * lives at `config/agents//agent.md` (markdown), the **registry key + * lives at `server/agents//agent.md` (markdown), the **registry key * always wins** and `name` is ignored. The agent will be reachable as * `foo` (or ``) regardless of what this field contains. * @@ -143,6 +143,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; /** @@ -207,9 +214,28 @@ export interface AutoInheritToolsConfig { } export interface AgentsPluginConfig extends BasePluginConfig { - /** Directory of agent packages (`/agent.md` each). Default `./config/agents`. Set to `false` to disable. */ + /** + * Unified agents root. Each `/` folder holds either `agent.md` (markdown) + * or `agent.ts` (code, `export default createAgent({ ... })`); the folder + * name is the agent id. Default `server/agents` — leave unset. In a built + * server, code agents are loaded from the compiled output (`dist/` / + * `build/`, where `` is this dir's basename) and markdown is read + * from source. A **relative** custom path is resolved this way too; an + * **absolute** path is scanned verbatim (you own compiling it for prod). Set + * to `false` to disable all file discovery — including the `config/agents/` + * fallback below. Markdown still under `config/agents/` is otherwise read as a + * deprecated fallback (one-time warning). + */ dir?: string | false; - /** Code-defined agents, merged with file-loaded ones (code wins on key collision). */ + /** + * @deprecated Put each code agent in its own folder under + * `server/agents//agent.ts` (`export default createAgent({ ... })`); it is + * discovered automatically at startup and the call collapses to + * `agents({ ... })` with no map. Still honored for backward compatibility + * (emits a one-time deprecation warning) but will be removed in a future + * minor. If both discovery and this map define the same id, discovery wins + * and the map entry is ignored. + */ agents?: Record; /** Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter. */ defaultAgent?: string; diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 6f755462d..f94cab998 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import type express from "express"; @@ -25,6 +26,11 @@ import { import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; +import { + CODE_AGENTS_SOURCE_DIR, + loadCodeAgentsFromDir, + resolveCodeAgentsDir, +} from "../../core/agent/load-code-agents"; import { normalizeToolResult } from "../../core/agent/normalize-result"; import { createPluginsProxy } from "../../core/agent/plugins-map"; import { @@ -75,7 +81,8 @@ import { ToolApprovalGate } from "./tool-approval-gate"; const logger = createLogger("agents"); -const DEFAULT_AGENTS_DIR = "./config/agents"; +/** Deprecated markdown location, read as a fallback with a one-time warning. */ +const LEGACY_MARKDOWN_DIR = "config/agents"; /** * Context flag recorded on the in-memory AgentDefinition to indicate whether @@ -167,6 +174,10 @@ export class AgentsPlugin extends Plugin implements ToolProvider { private mcpClient: AppKitMcpClient | null = null; private threadStore; private approvalGate = new ToolApprovalGate(); + /** Guards the `agents({ agents })` deprecation warning to once per instance. */ + private agentsMapDeprecationWarned = false; + /** Guards the `config/agents` deprecation warning to once per instance. */ + private configAgentsDeprecationWarned = false; constructor(config: AgentsPluginConfig) { super(config); @@ -333,45 +344,81 @@ export class AgentsPlugin extends Plugin implements ToolProvider { agents: Map; defaultAgentName: string | null; }> { - const { defs: fileDefs, defaultAgent: fileDefault } = - await this.loadFileDefinitions(); + // Two "code" sources: discovered files and the deprecated `agents({ agents })` map. + const discovered = await this.loadCodeAgents(); + const deprecatedMapRaw = this.config.agents ?? {}; - const codeDefs = this.config.agents ?? {}; + if (Object.keys(deprecatedMapRaw).length > 0) { + this.warnAgentsMapDeprecated(); + } - for (const name of Object.keys(fileDefs)) { - if (codeDefs[name]) { + // On a discovered/map id clash, discovery wins (drop the map entry) rather + // than crash boot on upgrade. + const deprecatedMap: Record = {}; + for (const [id, def] of Object.entries(deprecatedMapRaw)) { + if (discovered[id]) { logger.warn( - "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", - name, + "Agent '%s' is both discovered in %s and passed to agents({ agents }). " + + "Using the discovered file; ignoring the map entry.", + id, + CODE_AGENTS_SOURCE_DIR, ); + continue; } + deprecatedMap[id] = def; } + // Code agents also resolve markdown `agents: [child]` sub-agent references. + const codeAgents: Record = { + ...discovered, + ...deprecatedMap, + }; + + const { defs: fileDefs, defaultAgent: fileDefault } = + await this.loadFileDefinitions(codeAgents); + + // Merge order (markdown, discovered, map) sets precedence and the + // first-registered default fallback. const merged: Record = {}; for (const [name, def] of Object.entries(fileDefs)) { merged[name] = { def, src: { origin: "file" } }; } - for (const [name, def] of Object.entries(codeDefs)) { + for (const [name, def] of Object.entries(discovered)) { + if (merged[name]?.src.origin === "file") { + // Discovery is new API — clash with markdown is a hard error (the + // deprecated map only warns). A folder with both agent.ts and + // agent.md lands here too: one kind per folder. + throw new Error( + `Agent '${name}' is defined as both a code agent (agent.ts) and a markdown agent (agent.md). ` + + `Keep one kind per folder. Available: ${Object.keys(merged).sort().join(", ")}`, + ); + } + merged[name] = { def, src: { origin: "code" } }; + } + for (const [name, def] of Object.entries(deprecatedMap)) { + if (merged[name]?.src.origin === "file") { + logger.warn( + "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.", + name, + ); + } merged[name] = { def, src: { origin: "code" } }; } const agents = new Map(); - let defaultAgentName: string | null = null; if (Object.keys(merged).length === 0) { logger.info( - "No agents registered (no files in %s, no code-defined agents)", + "No agents registered (no files in %s, no discovered or code-defined agents)", this.resolvedAgentsDir() ?? "", ); - return { agents, defaultAgentName }; + return { agents, defaultAgentName: null }; } for (const [name, { def, src }] of Object.entries(merged)) { try { - const registered = await this.buildRegisteredAgent(name, def, src); - agents.set(name, registered); - if (!defaultAgentName) defaultAgentName = name; + agents.set(name, await this.buildRegisteredAgent(name, def, src)); } catch (err) { throw new Error( `Failed to register agent '${name}' (${src.origin}): ${ @@ -382,44 +429,177 @@ export class AgentsPlugin extends Plugin implements ToolProvider { } } + return { + agents, + defaultAgentName: this.resolveDefaultAgent(agents, merged, fileDefault), + }; + } + + /** + * Resolves the default agent. Precedence: explicit `config.defaultAgent` > + * a code/discovered agent flagged `default: true` (stable id order) > + * markdown `default: true` > first registered (insertion order). + */ + private resolveDefaultAgent( + agents: Map, + merged: Record, + fileDefault: string | null, + ): string | null { if (this.config.defaultAgent) { if (!agents.has(this.config.defaultAgent)) { throw new Error( `defaultAgent '${this.config.defaultAgent}' is not registered. Available: ${Array.from(agents.keys()).join(", ")}`, ); } - defaultAgentName = this.config.defaultAgent; - } else if (fileDefault && agents.has(fileDefault)) { - defaultAgentName = fileDefault; + return this.config.defaultAgent; } - return { agents, defaultAgentName }; + const codeDefault = Object.keys(merged) + .filter( + (id) => merged[id].src.origin === "code" && merged[id].def.default, + ) + .sort()[0]; + if (codeDefault) return codeDefault; + + if (fileDefault && agents.has(fileDefault)) return fileDefault; + + return agents.keys().next().value ?? null; + } + + /** + * Emits the one-time deprecation warning for the `agents({ agents })` map. + * Guarded so `reload()` (which re-runs `buildAgentRegistry`) doesn't spam it. + */ + private warnAgentsMapDeprecated(): void { + if (this.agentsMapDeprecationWarned) return; + this.agentsMapDeprecationWarned = true; + logger.warn( + "agents({ agents: { ... } }) is deprecated. Put each code agent in its own folder under " + + "server/agents//agent.ts (export default createAgent({ ... })) and it is discovered " + + "automatically — the call collapses to agents({ ... }) with no agent map. The `agents` field " + + "still works but will be removed in a future minor. See docs/plugins/agents.md.", + ); + } + + /** + * One-time deprecation warning for markdown agents still living in + * `config/agents/`. Guarded so `reload()` doesn't spam it. + */ + private warnConfigAgentsDeprecated(): void { + if (this.configAgentsDeprecationWarned) return; + this.configAgentsDeprecationWarned = true; + logger.warn( + "Markdown agents under config/agents/ are deprecated. Move each config/agents//agent.md to " + + "server/agents//agent.md — every agent now lives in one place (server/agents). config/agents " + + "is still read for now but will be removed in a future minor. See docs/plugins/agents.md.", + ); } private resolvedAgentsDir(): string | null { if (this.config.dir === false) return null; - const dir = this.config.dir ?? DEFAULT_AGENTS_DIR; + const dir = this.config.dir ?? CODE_AGENTS_SOURCE_DIR; return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); } - private async loadFileDefinitions(): Promise<{ + /** + * Discovers code agents (see {@link resolveCodeAgentsDir} and + * {@link loadCodeAgentsFromDir}). Warns if sources exist but nothing was + * discovered — usually the build didn't emit the compiled agents — unless + * the deprecated `agents({ agents })` map is carrying them instead. + */ + private async loadCodeAgents(): Promise> { + const resolved = resolveCodeAgentsDir({ + cwd: process.cwd(), + override: this.config.dir, + exists: existsSync, + }); + if (!resolved) return {}; + + const discovered = await loadCodeAgentsFromDir(resolved.dir, { + extensions: resolved.extensions, + }); + + const usingDeprecatedMap = Object.keys(this.config.agents ?? {}).length > 0; + const sourceDir = this.resolvedAgentsDir(); + if ( + Object.keys(discovered).length === 0 && + !usingDeprecatedMap && + sourceDir && + this.hasCodeAgentSources(sourceDir) + ) { + logger.warn( + "Found code-agent sources in %s but discovered no code agents (scanned %s). " + + "In a production build, ensure `/*/agent.ts` is included as tsdown entries so the compiled agents are emitted.", + sourceDir, + resolved.dir, + ); + } + + return discovered; + } + + /** True when `dir` holds at least one `/agent.{ts,tsx}` folder. */ + private hasCodeAgentSources(dir: string): boolean { + try { + return readdirSync(dir, { withFileTypes: true }).some((e) => { + if (!e.isDirectory() && !e.isSymbolicLink()) return false; + try { + return readdirSync(path.join(dir, e.name)).some( + (f) => f === "agent.ts" || f === "agent.tsx", + ); + } catch { + return false; + } + }); + } catch { + return false; + } + } + + private async loadFileDefinitions( + codeAgents: Record, + ): Promise<{ defs: Record; defaultAgent: string | null; }> { - const dir = this.resolvedAgentsDir(); - if (!dir) return { defs: {}, defaultAgent: null }; + const primaryDir = this.resolvedAgentsDir(); + if (!primaryDir) return { defs: {}, defaultAgent: null }; - const pluginToolProviders = this.pluginProviderIndex(); - const ambient = this.config.tools ?? {}; - - const result = await loadAgentsFromDir(dir, { + // Discovered code agents + the deprecated map resolve markdown `agents:` + // sub-agent references, so a markdown parent can delegate to a code child. + const baseCtx = { defaultModel: this.config.defaultModel, - availableTools: ambient, - plugins: pluginToolProviders, - codeAgents: this.config.agents, + availableTools: this.config.tools ?? {}, + plugins: this.pluginProviderIndex(), + }; + + // Configured dir already points at config/agents → single scan, no fallback. + const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); + if (primaryDir === legacyDir) { + return loadAgentsFromDir(primaryDir, { ...baseCtx, codeAgents }); + } + + // Deprecated fallback: scan config/agents first, then server/agents with the + // legacy defs added as resolvable sub-agent targets — so a parent already + // moved to server/agents can still reference a child left in config/agents + // mid-migration. Code agents keep precedence in resolution and in the final + // merge (server/agents wins on an id clash). + const legacy = await loadAgentsFromDir(legacyDir, { + ...baseCtx, + codeAgents, }); + const primary = await loadAgentsFromDir(primaryDir, { + ...baseCtx, + codeAgents: { ...legacy.defs, ...codeAgents }, + }); + + if (Object.keys(legacy.defs).length === 0) return primary; - return result; + this.warnConfigAgentsDeprecated(); + return { + defs: { ...legacy.defs, ...primary.defs }, + defaultAgent: primary.defaultAgent ?? legacy.defaultAgent, + }; } /** @@ -1965,10 +2145,12 @@ function warnOnCapabilityMismatch( } /** - * Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, - * resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` - * runtime API and mounts `POST /invocations` and `POST /responses` (aliased - * non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). + * Plugin factory for the agents plugin. Discovers agents from + * `server/agents//agent.{ts,md}` by default (markdown still in + * `config/agents/` is read as a deprecated fallback), resolves toolkits/tools + * from registered plugins, exposes the `appkit.agents.*` runtime API and mounts + * `POST /invocations` and `POST /responses` (aliased non-streaming invoke + * endpoints) plus `POST /chat` (streaming, HITL-capable). * * @example * ```ts diff --git a/packages/appkit/src/plugins/agents/tests/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts new file mode 100644 index 000000000..db2510e8e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -0,0 +1,261 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { AgentAdapter, AgentInput, AgentRunContext } from "shared"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { CacheManager } from "../../../cache"; +import type { AgentsPluginConfig } from "../../../core/agent/types"; +import { AgentsPlugin } from "../agents"; + +/** Absolute path to a committed agent fixture directory. */ +const fixtureDir = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +function stubAdapter(): AgentAdapter { + return { + async *run(_input: AgentInput, _ctx: AgentRunContext) { + yield { type: "message_delta", content: "" }; + }, + }; +} + +beforeEach(async () => { + // Agent setup reads the cache singleton; initialize it with defaults. + await CacheManager.getInstance(); +}); + +function instantiate(config: AgentsPluginConfig) { + const plugin = new AgentsPlugin({ ...config, name: "agent" }); + plugin.attachContext({ context: undefined as unknown as object }); + return plugin; +} + +type ExportsApi = { + list: () => string[]; + get: (name: string) => { toolIndex: Map } | null; + getDefault: () => string | null; +}; + +describe("AgentsPlugin agent discovery", () => { + test("discovers code agents from the dir with no map at the call site", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + // notAnAgent/ exports no created agent and is skipped. + expect(api.list().sort()).toEqual(["builder", "helper"]); + expect(api.getDefault()).toBe("builder"); + }); + + test("honors default: true on a discovered code agent", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("a discovered code default: true beats a markdown default: true", async () => { + // code-agents-default holds beta (code, default:true) + planner (markdown, + // default:true) side by side; code wins. + const plugin = instantiate({ + dir: fixtureDir("code-agents-default"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); + }); + + test("explicit defaultAgent overrides a discovered default: true", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents-default"), + defaultAgent: "alpha", + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).getDefault()).toBe("alpha"); + }); + + test("a markdown parent can delegate to a code sub-agent in a sibling folder", async () => { + const plugin = instantiate({ + dir: fixtureDir("md-parent-code-child"), + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + expect(api.getDefault()).toBe("planner"); + }); + + test("discovery wins over a colliding deprecated-map entry (warns, no throw)", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const plugin = instantiate({ + dir: fixtureDir("code-agents"), + agents: { + helper: { instructions: "from the map", model: stubAdapter() }, + }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["builder", "helper"]); + // The discovered agent (instructions "I help.") wins over the map entry. + const helper = api.get("helper") as { instructions: string } | null; + expect(helper?.instructions).toBe("I help."); + const warned = warnSpy.mock.calls + .map((a) => a.join(" ")) + .some( + (s) => + s.includes("both discovered") && s.includes("ignoring the map entry"), + ); + expect(warned).toBe(true); + warnSpy.mockRestore(); + }); + + test("a map-only app with discovery disabled works unchanged (no throw)", async () => { + const plugin = instantiate({ + dir: false, + agents: { + legacy: { instructions: "map agent", model: stubAdapter() }, + }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + const api = plugin.exports() as ExportsApi; + expect(api.list()).toEqual(["legacy"]); + expect(api.getDefault()).toBe("legacy"); + }); + + test("throws when defaultAgent names an unregistered agent", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-agents"), + defaultAgent: "nope", + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow(/is not registered/); + }); + + test("throws when a folder holds both agent.ts and agent.md", async () => { + const plugin = instantiate({ + dir: fixtureDir("code-md-collision"), + defaultModel: stubAdapter(), + }); + await expect(plugin.setup()).rejects.toThrow( + /both a code agent .* and a markdown agent/, + ); + }); + + test("emits a one-time deprecation warning for agents({ agents }) and none for discovery", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const deprecated = instantiate({ + dir: false, + agents: { legacy: { instructions: "x", model: stubAdapter() } }, + }); + await deprecated.setup(); + await deprecated.reload(); // must not re-warn + + const deprecationWarnings = warnSpy.mock.calls + .map((args) => args.join(" ")) + .filter((s) => s.includes("agents: { ... } }) is deprecated")); + expect(deprecationWarnings).toHaveLength(1); + + warnSpy.mockClear(); + const discoveredPlugin = instantiate({ + dir: fixtureDir("code-agents"), + defaultModel: stubAdapter(), + }); + await discoveredPlugin.setup(); + + const discoveryWarnings = warnSpy.mock.calls + .map((args) => args.join(" ")) + .filter((s) => s.includes("is deprecated")); + expect(discoveryWarnings).toHaveLength(0); + warnSpy.mockRestore(); + }); +}); + +// The config/agents fallback is cwd-relative (path.resolve(cwd, "config/agents")), +// so these run in a temp cwd holding both roots rather than pointing `dir` at a +// fixture. +describe("AgentsPlugin config/agents deprecated fallback", () => { + let tmp: string; + let priorCwd: string; + + const write = (rel: string, content: string) => { + const p = path.join(tmp, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, "utf-8"); + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agents-fallback-")); + priorCwd = process.cwd(); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(priorCwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test("merges config/agents markdown with server/agents (server wins) and warns once", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + write("config/agents/legacy/agent.md", "---\n---\nLegacy only."); + write("config/agents/shared/agent.md", "---\n---\nFrom config (old)."); + write("server/agents/shared/agent.md", "---\n---\nFrom server (new)."); + + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + await plugin.reload(); // must not re-warn + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["legacy", "shared"]); + const shared = api.get("shared") as { instructions: string } | null; + expect(shared?.instructions).toContain("From server (new)."); + + const warns = warnSpy.mock.calls + .map((a) => a.join(" ")) + .filter((s) => s.includes("config/agents/ are deprecated")); + expect(warns).toHaveLength(1); + warnSpy.mockRestore(); + }); + + test("a server/agents parent resolves a sub-agent still in config/agents", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + write("config/agents/helper/agent.md", "---\n---\nI help."); + write( + "server/agents/planner/agent.md", + "---\ndefault: true\nagents:\n - helper\n---\nPlan.", + ); + + const plugin = instantiate({ defaultModel: stubAdapter() }); + await plugin.setup(); + + const api = plugin.exports() as ExportsApi; + expect(api.list().sort()).toEqual(["helper", "planner"]); + expect(api.get("planner")?.toolIndex.has("agent-helper")).toBe(true); + warnSpy.mockRestore(); + }); + + test("dir:false disables the config/agents fallback too", async () => { + write("config/agents/legacy/agent.md", "---\n---\nLegacy only."); + const plugin = instantiate({ + dir: false, + agents: { mapped: { instructions: "map", model: stubAdapter() } }, + defaultModel: stubAdapter(), + }); + await plugin.setup(); + expect((plugin.exports() as ExportsApi).list()).toEqual(["mapped"]); + }); +}); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.ts new file mode 100644 index 000000000..df32e67d0 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/alpha/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "alpha" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta/agent.ts new file mode 100644 index 000000000..e48de4fc2 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/beta/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "beta", default: true }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/planner/agent.md b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/planner/agent.md new file mode 100644 index 000000000..123c74863 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-default/planner/agent.md @@ -0,0 +1,4 @@ +--- +default: true +--- +Plan. diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts new file mode 100644 index 000000000..b51a6ef45 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents-multi/multi/agent.ts @@ -0,0 +1,3 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export const a = createAgent({ instructions: "a" }); +export const b = createAgent({ instructions: "b" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.ts new file mode 100644 index 000000000..85951585e --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/builder/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "I build." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.ts new file mode 100644 index 000000000..9d2eeabd1 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/helper/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export const helper = createAgent({ instructions: "I help." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent/agent.ts new file mode 100644 index 000000000..fc656a044 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-agents/notAnAgent/agent.ts @@ -0,0 +1,2 @@ +// agent.ts that exports no created agent — the loader must skip this folder. +export const CONSTANT = 42; diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md new file mode 100644 index 000000000..f8b6e48a3 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.md @@ -0,0 +1,3 @@ +--- +--- +From markdown. diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts new file mode 100644 index 000000000..aae12931f --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/code-md-collision/helper/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export default createAgent({ instructions: "from code" }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.ts b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.ts new file mode 100644 index 000000000..9d2eeabd1 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/helper/agent.ts @@ -0,0 +1,2 @@ +import { createAgent } from "../../../../../../core/agent/create-agent"; +export const helper = createAgent({ instructions: "I help." }); diff --git a/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/planner/agent.md b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/planner/agent.md new file mode 100644 index 000000000..fc3a48483 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/fixtures/md-parent-code-child/planner/agent.md @@ -0,0 +1,6 @@ +--- +default: true +agents: + - helper +--- +Plan. diff --git a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts new file mode 100644 index 000000000..c26b5c231 --- /dev/null +++ b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts @@ -0,0 +1,127 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + loadCodeAgentsFromDir, + resolveCodeAgentsDir, +} from "../../../core/agent/load-code-agents"; + +const fixtureDir = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +const TS = { extensions: [".ts", ".tsx"] }; + +describe("loadCodeAgentsFromDir", () => { + it("returns an empty record when the directory does not exist", async () => { + expect( + await loadCodeAgentsFromDir(fixtureDir("does-not-exist"), TS), + ).toEqual({}); + }); + + it("discovers default and named agent exports, id = folder name", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + expect(Object.keys(agents).sort()).toEqual(["builder", "helper"]); + expect(agents.builder.instructions).toBe("I build."); + expect(agents.helper.instructions).toBe("I help."); + }); + + it("skips folders whose entry file exports no created agent", async () => { + const agents = await loadCodeAgentsFromDir(fixtureDir("code-agents"), TS); + // notAnAgent/agent.ts exports a plain constant — must not be registered. + expect(agents.notAnAgent).toBeUndefined(); + }); + + it("skips folders that have no agent entry file (markdown / asset dirs)", async () => { + // md-parent-code-child/planner has only agent.md; the code loader ignores it. + const agents = await loadCodeAgentsFromDir( + fixtureDir("md-parent-code-child"), + TS, + ); + expect(Object.keys(agents)).toEqual(["helper"]); + }); + + it("throws when one entry file exports more than one agent", async () => { + await expect( + loadCodeAgentsFromDir(fixtureDir("code-agents-multi"), TS), + ).rejects.toThrow(/exports 2 created agents/); + }); +}); + +describe("resolveCodeAgentsDir", () => { + const cwd = "/app"; + const dist = path.resolve(cwd, "dist/agents"); + const build = path.resolve(cwd, "build/agents"); + const source = path.resolve(cwd, "server/agents"); + const existsIn = + (...present: string[]) => + (dir: string) => + present.includes(dir); + + it("returns null when discovery is disabled", () => { + expect( + resolveCodeAgentsDir({ cwd, override: false, exists: () => true }), + ).toBeNull(); + }); + + it("scans an absolute override verbatim, accepting any module extension", () => { + expect( + resolveCodeAgentsDir({ + cwd, + override: "/abs/agents", + exists: () => true, + }), + ).toEqual({ + dir: "/abs/agents", + extensions: [".ts", ".tsx", ".js", ".mjs"], + }); + }); + + it("resolves a relative override built-first (dist/ over source)", () => { + const customDist = path.resolve(cwd, "dist/my-agents"); + const customSrc = path.resolve(cwd, "my-agents"); + expect( + resolveCodeAgentsDir({ + cwd, + override: "my-agents", + exists: existsIn(customDist, customSrc), + }), + ).toEqual({ dir: customDist, extensions: [".js", ".mjs"] }); + // No built output → falls back to the source `.ts` dir. + expect( + resolveCodeAgentsDir({ cwd, override: "my-agents", exists: () => false }), + ).toEqual({ dir: customSrc, extensions: [".ts", ".tsx"] }); + }); + + it("stays built-first when dir is set to the default value explicitly", () => { + // Regression: `agents({ dir: "server/agents" })` must NOT bypass built-first + // (previously a string override was scanned verbatim → prod loaded .ts). + const r = resolveCodeAgentsDir({ + cwd, + override: "server/agents", + exists: existsIn(dist, source), + }); + expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); + }); + + it("prefers compiled dist/agents (.js) over source — built wins", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(dist, source) }); + expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); + }); + + it("falls back to build/agents when dist/agents is absent", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(build, source) }); + expect(r).toEqual({ dir: build, extensions: [".js", ".mjs"] }); + }); + + it("uses server/agents (.ts) only when no built dir exists", () => { + const r = resolveCodeAgentsDir({ cwd, exists: existsIn(source) }); + expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + }); + + it("targets server/agents when nothing exists (empty scan downstream)", () => { + const r = resolveCodeAgentsDir({ cwd, exists: () => false }); + expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + }); +}); diff --git a/template/client/src/pages/agents/AgentChat.tsx b/template/client/src/pages/agents/AgentChat.tsx index 5d1e5758d..115230197 100644 --- a/template/client/src/pages/agents/AgentChat.tsx +++ b/template/client/src/pages/agents/AgentChat.tsx @@ -34,11 +34,11 @@ interface AgentsClientConfig { * The template ships a single coordinator agent and uses the agents * plugin's sub-agent feature to compose two authoring forms behind it: * - * - `planner` (markdown, `config/agents/planner/agent.md`) is the + * - `planner` (markdown, `server/agents/planner/agent.md`) is the * user-facing chat: pure prose, no tools, opinionated planning * prompt. Declares `agents: [helper]` in its frontmatter so it * can delegate computational actions. - * - `helper` (code, `server/agents/helper.ts`) holds the tools + * - `helper` (code, `server/agents/helper/agent.ts`) holds the tools * (`current_time`, `count_words`). It's reachable from planner as * the `agent-helper` tool; planner calls it when the user * explicitly asks for a side-effecty action. @@ -132,10 +132,10 @@ export function AgentChat() {

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

diff --git a/template/server/agents/helper.ts b/template/server/agents/helper/agent.ts similarity index 71% rename from template/server/agents/helper.ts rename to template/server/agents/helper/agent.ts index 47a69f00a..87603daeb 100644 --- a/template/server/agents/helper.ts +++ b/template/server/agents/helper/agent.ts @@ -3,13 +3,16 @@ import { createAgent, tool } from '@databricks/appkit/beta'; import { z } from 'zod'; /** - * Code-defined helper agent: holds the tools. Shipped as a sub-agent of - * the user-facing `planner` markdown agent (which references it via - * `agents: [helper]` in its frontmatter) rather than a chat-tab on its - * own. When the user asks planner for a computational action — "what - * time is it?", "count the words in this string" — planner calls the - * `agent-helper` tool, the agents plugin routes the sub-agent - * invocation here, and the answer flows back into the planner thread. + * Code-defined helper agent: holds the tools. This file lives in + * `server/agents/helper/`, so the agents plugin discovers it automatically at + * startup — its agent id is the folder name (`helper`), and nothing needs to + * restate it. + * Shipped as a sub-agent of the user-facing `planner` markdown agent (which + * references it via `agents: [helper]` in its frontmatter) rather than a + * chat-tab on its own. When the user asks planner for a computational action — + * "what time is it?", "count the words in this string" — planner calls the + * `agent-helper` tool, the agents plugin routes the sub-agent invocation here, + * and the answer flows back into the planner thread. * * Two reasons to keep this code-defined instead of folding it into the * markdown: @@ -26,8 +29,7 @@ import { z } from 'zod'; * volumes, no external APIs) so the round-trip works on a bare * scaffold regardless of which other plugins were selected. */ -export const helper = createAgent({ - name: 'helper', +export default createAgent({ instructions: [ 'You are a tool-using helper agent.', 'When the user asks about the time, call `current_time`.', diff --git a/template/config/agents/planner/agent.md b/template/server/agents/planner/agent.md similarity index 100% rename from template/config/agents/planner/agent.md rename to template/server/agents/planner/agent.md diff --git a/template/server/server.ts b/template/server/server.ts index 47f8f9c1c..b33bb94d8 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -15,18 +15,11 @@ import { {{$betaImports}} } from '@databricks/appkit/beta'; {{- if .plugins.lakebase}} import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes'; {{- end}} -{{- if .plugins.agents}} -import { helper } from './agents/helper'; -{{- end}} createApp({ plugins: [ {{- range $name, $_ := .plugins}} -{{- if eq $name "agents"}} - agents({ agents: { helper } }), -{{- else}} {{$name}}(), -{{- end}} {{- end}} ], {{- if .plugins.lakebase}} diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index 759b79d00..ac236e108 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,8 +1,11 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: 'server/server.ts', + // server/agents/*/agent.ts aren't imported anywhere; list them as entries so the build emits dist/agents/*/agent.js for runtime discovery. + entry: [{{if .plugins.agents}}'server/server.ts', 'server/agents/*/agent.ts'{{else}}'server/server.ts'{{end}}], unbundle: true, + // Clear the out dir so a deleted agent can't linger in dist/agents/. + clean: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json', outExtensions: () => ({ From 599c105ff69203ed7302bce5d7d2ebbdbd7f92fb Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 18 Aug 2026 19:08:22 +0200 Subject: [PATCH 2/7] fix(appkit): tighten folder-discovery edge cases + doc accuracy Follow-up to the review + inconsistency fan-out: - findEntryFile now rethrows non-ENOENT/ENOTDIR errors (an unreadable agent folder no longer silently vanishes in prod). - Clearer discovered-vs-markdown collision message (covers the cross-root config/agents fallback case, not just one folder). - Template tsdown: scope clean:true to the agents case so a non-agents scaffold's build config is unchanged. - Docs: fix DATABRICKS_SERVING_ENDPOINT_NAME, the auto-inherit default (off for both), cycle-rejection scope, /api/agents/approve path, defaultAgent precedence, dir:false wording, stale-dist note, and add the agents/generationParams frontmatter keys + toolCallTimeoutMs limit. Signed-off-by: MarioCadenas --- .../api/appkit/Interface.AgentDefinition.md | 5 +++-- .../api/appkit/Interface.AgentsPluginConfig.md | 4 ++-- docs/docs/api/appkit/TypeAlias.AgentEvent.md | 2 +- docs/docs/plugins/agents.md | 18 ++++++++++++------ .../appkit/src/core/agent/load-code-agents.ts | 8 ++++++-- packages/appkit/src/core/agent/types.ts | 9 +++++---- packages/appkit/src/plugins/agents/agents.ts | 5 +++-- .../src/plugins/agents/tool-approval-gate.ts | 2 +- packages/shared/src/agent.ts | 4 ++-- template/tsdown.server.config.ts | 8 ++++++-- 10 files changed, 41 insertions(+), 24 deletions(-) diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 9ce1f9bc5..2c9b5bb27 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -30,8 +30,9 @@ 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`. +set it, a code (discovered) agent wins over a markdown one, then the +lowest id; an explicit `agents({ defaultAgent })` always overrides it. +Defaults to `false`. *** diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index b51037a9e..929c5e094 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -45,7 +45,7 @@ Human-in-the-loop approval gate for mutating tool calls. When enabled (the default), the agents plugin emits an `appkit.approval_pending` SSE event before executing any tool whose annotation flags it as mutating — `effect: "write" | "update" | "destructive"` (preferred) or the legacy -`destructive: true` boolean — and waits for a `POST /chat/approve` +`destructive: true` boolean — and waits for a `POST /api/agents/approve` decision from the same user who initiated the stream. A missing decision after `timeoutMs` auto-denies the call. @@ -97,7 +97,7 @@ Customize or disable the AppKit base system prompt. optional defaultAgent: string; ``` -Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter. +Agent used when clients don't specify one. Precedence: this value, else a code agent with `default: true`, else a markdown agent with `default: true`, else the first-registered agent. *** diff --git a/docs/docs/api/appkit/TypeAlias.AgentEvent.md b/docs/docs/api/appkit/TypeAlias.AgentEvent.md index de9226a2b..0650bb8dc 100644 --- a/docs/docs/api/appkit/TypeAlias.AgentEvent.md +++ b/docs/docs/api/appkit/TypeAlias.AgentEvent.md @@ -266,5 +266,5 @@ Emitted by the agents plugin (not adapters) when a mutating tool call is awaiting human approval — fires for tools annotated with `effect: "write" | "update" | "destructive"` (preferred) or the legacy `destructive: true` boolean. Clients should render an approval -prompt and POST to `/chat/approve` with the matching `approvalId` and +prompt and POST to `/api/agents/approve` with the matching `approvalId` and a `decision` of `approve` or `deny`. diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index e5385d7d1..bab9988ff 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -63,7 +63,7 @@ On startup the plugin: 1. Discovers `server/agents/assistant/agent.md` and registers agent id `assistant`. 2. Parses the YAML frontmatter and markdown body as the agent's `instructions`. -3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_AGENT_ENDPOINT`). +3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_SERVING_ENDPOINT_NAME`). 4. Mounts the agent at the default name (`assistant`). The agent starts with **no tools**. Tools are opt-in — declare them in frontmatter (Level 2 below) or opt into auto-inherit explicitly with `agents({ autoInheritTools: { file: true } })`. See "Auto-inherit posture" further down for what that costs and why it's off by default. @@ -144,7 +144,7 @@ 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 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. +Because compiled output wins over source, a stale `dist/agents` / `build/agents` left over from a previous `npm run build` will be picked up by `npm run dev` instead of your live `server/agents/*.ts`, so edits appear ignored. **Delete the build dir** if a code agent seems frozen — a rebuild only swaps in a newer snapshot, so only deleting it restores live-from-source dev reload. Markdown is always read from source, so `agent.md` edits are never shadowed. ::: The entry may `export default createAgent({...})` or export a single named created agent; either way the id is the folder name. A folder whose entry exports no created agent (or has no `agent.ts`/`agent.md` at all) is skipped. Mark one agent as the default with `createAgent({ default: true })` (mirrors markdown frontmatter `default: true`); an explicit `agents({ defaultAgent })` still wins. @@ -153,10 +153,12 @@ Code-defined agents start with no tools by default. The function form `tools(plu Inline `tool({...})` calls live in the same record. Their `name` is optional — the agents plugin overrides it with the record key (`get_weather` above). -The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises. +Auto-inherit is **off for both origins by default** — a markdown or code agent with no declared `tools:` gets an empty tool index. Opt an origin in explicitly with `agents({ autoInheritTools: { file: true } })` (or `{ code: true }`, or `true` for both). :::warning Deprecated: the `agents({ agents: { ... } })` map -Passing a hand-built agent map still works and is honored for backward compatibility, but it emits a one-time deprecation warning and will be removed in a future minor. It restates each agent's id (once in `createAgent`, once as the map key); discovery from `server/agents/` removes both the map and the restatement. Migrate by moving each `createAgent(...)` into its own `server/agents//agent.ts` (default or single named export) and dropping the map. 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. If a discovered agent and a map entry share an id, discovery wins and the map entry is ignored (with a one-time warning). (Inline sub-agents — `createAgent({ agents: { ... } })` on a definition — are unaffected; only the plugin-level map is deprecated.) + +Some examples further down still pass agents inline via this map for snippet brevity — in a real app each of those `createAgent(...)` definitions lives in its own `server/agents//agent.ts` and needs no map. ::: ### Scoping tools in code @@ -202,7 +204,7 @@ await createApp({ }); ``` -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. +Put `supervisor`, `researcher`, and `writer` in their own `server/agents//agent.ts` folders (default export each) — a markdown parent can also delegate to a code child in a sibling folder via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles in a code agent's inline `agents: {}` graph are rejected at load (`createAgent`); markdown `agents:` delegation rejects self-references at load and bounds deeper cycles at runtime via `limits.maxSubAgentDepth`. ## Level 5: standalone (no `createApp`) @@ -378,7 +380,7 @@ Some hosted tool kinds return their final assistant text without incremental `ou ```ts agents({ - dir?: string | false, // agents root; "server/agents" default; false disables (config/agents read as deprecated fallback) + dir?: string | false, // unified agents root; default "server/agents". config/agents is read as a deprecated fallback; false disables ALL file discovery (fallback included) agents?: Record, // DEPRECATED — use server/agents// discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, @@ -398,6 +400,7 @@ agents({ maxConcurrentStreamsPerUser?: number, // default: 5 maxToolCalls?: number, // default: 50 maxSubAgentDepth?: number, // default: 3 + toolCallTimeoutMs?: number, // default: 300_000 (5 min) }, }) ``` @@ -545,6 +548,7 @@ agents({ maxConcurrentStreamsPerUser: 5, // HTTP 429 + Retry-After when exceeded maxToolCalls: 50, // aborts the run if the budget is exhausted maxSubAgentDepth: 3, // rejects sub-agent recursion beyond this + toolCallTimeoutMs: 300_000, // per-tool-call timeout (5 min; cold SQL/Genie headroom) }, }); ``` @@ -572,8 +576,10 @@ appkit.agents.getThreads(userId); // list user's threads | `model` | string | Same as `endpoint`; either works. | | `tools` | array | Unified tool list. Entries are `plugin:` / `plugin:: [t1, t2]` / `plugin:: { only, except, rename, prefix }` for plugin tools, or a bare `` resolved against `agents({ tools: {...} })` for ambient tools. See "Level 2: scope tools in frontmatter" above for examples. | | `default` | boolean | First agent id (sorted order) with `default: true` becomes the default agent. | +| `agents` | array | Sub-agent ids (sibling folders) to delegate to; each becomes an `agent-` tool. Resolves against other markdown and code agents. | | `maxSteps` | number | Adapter max-step hint. | | `maxTokens` | number | Adapter max-token hint. | +| `generationParams` | object | Adapter generation params (e.g. `temperature`, `top_p`) passed through when AppKit builds the adapter. | | `baseSystemPrompt` | false \| string | Per-agent override. `false` disables the AppKit base prompt. | | `ephemeral` | boolean | If `true`, the thread created for a chat request against this agent is deleted from `ThreadStore` after the stream finishes. Use for stateless one-shot agents (e.g. autocomplete) so history does not accumulate or contaminate future calls. Defaults to `false`. | diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts index e88ac3f68..585d1ea70 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -70,8 +70,12 @@ async function findEntryFile( let files: string[]; try { files = await fs.readdir(agentDir); - } catch { - return null; + } catch (err) { + // Absent (ENOENT) or a symlink to a file (ENOTDIR) → not an agent folder. + // Surface anything else (e.g. EACCES) rather than silently dropping it. + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return null; + throw err; } for (const ext of extensions) { const name = `${ENTRY_BASENAME}${ext}`; diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index d8302ff43..e5d4c3574 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -146,8 +146,9 @@ export interface AgentDefinition { /** * 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`. + * set it, a code (discovered) agent wins over a markdown one, then the + * lowest id; an explicit `agents({ defaultAgent })` always overrides it. + * Defaults to `false`. */ default?: boolean; /** System prompt body. For markdown-loaded agents this is the file body. */ @@ -237,7 +238,7 @@ export interface AgentsPluginConfig extends BasePluginConfig { * and the map entry is ignored. */ agents?: Record; - /** Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter. */ + /** Agent used when clients don't specify one. Precedence: this value, else a code agent with `default: true`, else a markdown agent with `default: true`, else the first-registered agent. */ defaultAgent?: string; /** Default model for agents that don't specify their own (in code or frontmatter). */ defaultModel?: AgentAdapter | Promise | string; @@ -261,7 +262,7 @@ export interface AgentsPluginConfig extends BasePluginConfig { * (the default), the agents plugin emits an `appkit.approval_pending` SSE * event before executing any tool whose annotation flags it as mutating — * `effect: "write" | "update" | "destructive"` (preferred) or the legacy - * `destructive: true` boolean — and waits for a `POST /chat/approve` + * `destructive: true` boolean — and waits for a `POST /api/agents/approve` * decision from the same user who initiated the stream. A missing decision * after `timeoutMs` auto-denies the call. */ diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index f94cab998..229f21d2d 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -390,8 +390,9 @@ export class AgentsPlugin extends Plugin implements ToolProvider { // deprecated map only warns). A folder with both agent.ts and // agent.md lands here too: one kind per folder. throw new Error( - `Agent '${name}' is defined as both a code agent (agent.ts) and a markdown agent (agent.md). ` + - `Keep one kind per folder. Available: ${Object.keys(merged).sort().join(", ")}`, + `Agent '${name}' is defined as both a code agent (agent.ts) and a markdown agent (agent.md) — ` + + `in one folder, or across server/agents and the deprecated config/agents fallback. ` + + `Keep a single kind per id. Available: ${Object.keys(merged).sort().join(", ")}`, ); } merged[name] = { def, src: { origin: "code" } }; diff --git a/packages/appkit/src/plugins/agents/tool-approval-gate.ts b/packages/appkit/src/plugins/agents/tool-approval-gate.ts index 4aeb92925..313c56400 100644 --- a/packages/appkit/src/plugins/agents/tool-approval-gate.ts +++ b/packages/appkit/src/plugins/agents/tool-approval-gate.ts @@ -10,7 +10,7 @@ * scheduled for auto-deny. The returned promise is what blocks the * adapter until the decision arrives. * 2. The client receives an `appkit.approval_pending` SSE event carrying the - * `approvalId` + `streamId` and posts a decision to `POST /chat/approve`. + * `approvalId` + `streamId` and posts a decision to `POST /api/agents/approve`. * The route calls {@link ToolApprovalGate.submit} which resolves the * pending promise and clears the timer. * 3. If no submit arrives within `timeoutMs`, the timer fires and the diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 5ec2caf35..811092b84 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -135,7 +135,7 @@ export type AgentEvent = * is awaiting human approval — fires for tools annotated with * `effect: "write" | "update" | "destructive"` (preferred) or the * legacy `destructive: true` boolean. Clients should render an approval - * prompt and POST to `/chat/approve` with the matching `approvalId` and + * prompt and POST to `/api/agents/approve` with the matching `approvalId` and * a `decision` of `approve` or `deny`. */ type: "approval_pending"; @@ -240,7 +240,7 @@ export interface AppKitMetadataEvent { * Emitted when a mutating tool call is awaiting human approval. Fires for * tools annotated with `effect: "write" | "update" | "destructive"` * (preferred) or the legacy `destructive: true` boolean. The client should - * render an approval UI and POST the decision to `/chat/approve` with + * render an approval UI and POST the decision to `/api/agents/approve` with * `{ streamId, approvalId, decision: "approve" | "deny" }`. If no decision * arrives before the server-side timeout, the call is auto-denied and the * agent receives a denial string as the tool output. diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index ac236e108..869950ddc 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,11 +1,15 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ +{{- if .plugins.agents}} // 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, + entry: ['server/server.ts', 'server/agents/*/agent.ts'], // Clear the out dir so a deleted agent can't linger in dist/agents/. clean: true, +{{- else}} + entry: 'server/server.ts', +{{- end}} + unbundle: true, external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), tsconfig: 'tsconfig.server.json', outExtensions: () => ({ From 4b6287a309804c354e81d018c8164c75802217a0 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 18 Aug 2026 22:26:36 +0200 Subject: [PATCH 3/7] feat(appkit): add @databricks/appkit/tsdown server build preset Ships the server build wiring from the package so a scaffolded app's tsdown.server.config.ts is a one-liner instead of hand-maintained config: import { appkitServerConfig } from '@databricks/appkit/tsdown'; export default appkitServerConfig(); - appkitServerConfig(overrides?, opts?) auto-detects server/agents//agent.ts and adds the entry glob + clean only when code agents exist. - Object overrides merge with intent (entry unioned so the agent glob can't be clobbered, external composed, other keys win); a function override receives the computed base for full control. - Dependency-free (node: builtins only) so it stays lean at build time. - New ./tsdown export subpath (attw + publint clean); template drops its {{if .plugins.agents}} conditional. Signed-off-by: MarioCadenas --- packages/appkit/package.json | 9 ++ packages/appkit/src/tsdown/index.ts | 119 ++++++++++++++++++ .../tsdown/tests/appkit-server-config.test.ts | 93 ++++++++++++++ packages/appkit/tsdown.config.ts | 7 +- template/tsdown.server.config.ts | 23 +--- 5 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 packages/appkit/src/tsdown/index.ts create mode 100644 packages/appkit/src/tsdown/tests/appkit-server-config.test.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index e2cc5a7c2..ce7d880d0 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -47,6 +47,11 @@ "development": "./src/testing/index.ts", "default": "./dist/testing/index.js" }, + "./tsdown": { + "types": "./dist/tsdown/index.d.ts", + "development": "./src/tsdown/index.ts", + "default": "./dist/tsdown/index.js" + }, "./dist/shared/src/plugin": { "types": "./dist/shared/src/plugin.d.ts", "default": "./dist/shared/src/plugin.d.ts" @@ -131,6 +136,10 @@ "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" }, + "./tsdown": { + "types": "./dist/tsdown/index.d.ts", + "default": "./dist/tsdown/index.js" + }, "./package.json": "./package.json" } } diff --git a/packages/appkit/src/tsdown/index.ts b/packages/appkit/src/tsdown/index.ts new file mode 100644 index 000000000..49fc80284 --- /dev/null +++ b/packages/appkit/src/tsdown/index.ts @@ -0,0 +1,119 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; + +/** + * `@databricks/appkit/tsdown` — the server build preset. + * + * A scaffolded app's `tsdown.server.config.ts` is a single line: + * + * ```ts + * import { appkitServerConfig } from "@databricks/appkit/tsdown"; + * export default appkitServerConfig(); + * ``` + * + * so the agent-discovery build wiring lives in the package and reaches existing + * apps on upgrade, instead of being hand-maintained in every scaffold. The + * returned object is a plain tsdown config (no `defineConfig` wrapper needed). + * + * This module is intentionally dependency-free (only `node:` builtins) — it is + * loaded at build time and must not pull in the runtime SDK. + */ + +/** The tsdown options this preset sets. A structural subset of tsdown's config. */ +export interface ServerBuildConfig { + entry?: string | string[]; + unbundle?: boolean; + clean?: boolean; + external?: (id: string) => boolean; + outExtensions?: () => { js: string }; + tsconfig?: string; + /** Any other tsdown option passes through untouched. */ + [key: string]: unknown; +} + +/** + * Overrides accepted by {@link appkitServerConfig}: either a partial config + * (merged — `entry` is unioned, `external` composed, other keys win) or a + * function that receives AppKit's computed base config for full control. + */ +export type ServerConfigOverrides = + | ServerBuildConfig + | ((base: ServerBuildConfig) => ServerBuildConfig); + +const SERVER_ENTRY = "server/server.ts"; +const AGENT_ENTRY = "server/agents/*/agent.ts"; + +/** AppKit default: keep anything resolving outside the project out of the bundle. */ +const defaultExternal = (id: string): boolean => + /^[^./]/.test(id) || id.includes("/node_modules/"); + +function toEntryArray(entry: string | string[] | undefined): string[] { + if (entry === undefined) return []; + return Array.isArray(entry) ? entry : [entry]; +} + +/** True when `server/agents/` holds at least one `/agent.ts` (a code agent). */ +function hasCodeAgents(cwd: string): boolean { + const root = path.join(cwd, "server", "agents"); + try { + return readdirSync(root, { withFileTypes: true }).some((e) => { + if (!e.isDirectory() && !e.isSymbolicLink()) return false; + try { + return readdirSync(path.join(root, e.name)).includes("agent.ts"); + } catch { + return false; + } + }); + } catch { + return false; + } +} + +/** AppKit's base server config, with the agent entry + `clean` only when needed. */ +function baseConfig(codeAgents: boolean): ServerBuildConfig { + return { + entry: [SERVER_ENTRY, ...(codeAgents ? [AGENT_ENTRY] : [])], + unbundle: true, + external: defaultExternal, + outExtensions: () => ({ js: ".js" }), + ...(codeAgents ? { clean: true } : {}), + }; +} + +/** + * The server tsdown config, merging AppKit's required wiring with `overrides`. + * + * Object overrides merge with intent, not a blind spread: + * - `entry` is UNIONed — the agent glob can't be dropped by an override; + * - `external` is COMPOSED — the caller's predicate runs alongside AppKit's; + * - every other key wins. + * A function override instead receives the computed base and returns the final + * config, for callers that need full control (including removing the glob). + * + * The agent entry + `clean` are included only when `server/agents/` actually + * holds code agents, so the same call works for every app. Pass + * `opts.codeAgents` to force that decision (e.g. a non-standard layout). + */ +export function appkitServerConfig( + overrides: ServerConfigOverrides = {}, + opts: { cwd?: string; codeAgents?: boolean } = {}, +): ServerBuildConfig { + const cwd = opts.cwd ?? process.cwd(); + const codeAgents = opts.codeAgents ?? hasCodeAgents(cwd); + const base = baseConfig(codeAgents); + + if (typeof overrides === "function") return overrides(base); + + const baseEntries = toEntryArray(base.entry); + const entry = [ + ...baseEntries, + ...toEntryArray(overrides.entry).filter((e) => !baseEntries.includes(e)), + ]; + + const userExternal = overrides.external; + const external = userExternal + ? (id: string): boolean => defaultExternal(id) || userExternal(id) + : defaultExternal; + + return { ...base, ...overrides, entry, external }; +} diff --git a/packages/appkit/src/tsdown/tests/appkit-server-config.test.ts b/packages/appkit/src/tsdown/tests/appkit-server-config.test.ts new file mode 100644 index 000000000..7cb000c3c --- /dev/null +++ b/packages/appkit/src/tsdown/tests/appkit-server-config.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { appkitServerConfig } from "../index"; + +describe("appkitServerConfig", () => { + it("adds the agent glob + clean when code agents exist", () => { + const c = appkitServerConfig({}, { codeAgents: true }); + expect(c.entry).toEqual(["server/server.ts", "server/agents/*/agent.ts"]); + expect(c.clean).toBe(true); + expect(c.unbundle).toBe(true); + }); + + it("omits the agent glob + clean when there are no code agents", () => { + const c = appkitServerConfig({}, { codeAgents: false }); + expect(c.entry).toEqual(["server/server.ts"]); + expect(c.clean).toBeUndefined(); + }); + + it("unions user entries — the agent glob survives an entry override", () => { + const c = appkitServerConfig( + { entry: "server/worker.ts" }, + { codeAgents: true }, + ); + expect(c.entry).toEqual([ + "server/server.ts", + "server/agents/*/agent.ts", + "server/worker.ts", + ]); + }); + + it("composes external instead of clobbering AppKit's", () => { + const c = appkitServerConfig( + { external: (id) => id === "keepme" }, + { codeAgents: true }, + ); + const ext = c.external as (id: string) => boolean; + expect(ext("keepme")).toBe(true); // caller's rule + expect(ext("express")).toBe(true); // AppKit default (bare specifier) + expect(ext("./local")).toBe(false); // neither → bundled + }); + + it("passes through unrelated overrides while protecting entry", () => { + const c = appkitServerConfig( + { tsconfig: "tsconfig.custom.json", sourcemap: true }, + { codeAgents: true }, + ); + expect(c.tsconfig).toBe("tsconfig.custom.json"); + expect(c.sourcemap).toBe(true); + expect(c.entry).toContain("server/agents/*/agent.ts"); + }); + + it("hands the computed base to a function override for full control", () => { + const c = appkitServerConfig( + (base) => ({ ...base, entry: ["only/this.ts"], unbundle: false }), + { codeAgents: true }, + ); + // The function form can override even the protected fields. + expect(c.entry).toEqual(["only/this.ts"]); + expect(c.unbundle).toBe(false); + }); + + describe("code-agent auto-detection (fs)", () => { + let tmp: string; + const mk = (rel: string) => { + const p = path.join(tmp, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, ""); + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-tsdown-")); + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it("detects server/agents//agent.ts under cwd", () => { + mk("server/agents/helper/agent.ts"); + expect(appkitServerConfig({}, { cwd: tmp }).entry).toContain( + "server/agents/*/agent.ts", + ); + }); + + it("does not add the glob when server/agents has no code agent", () => { + mk("server/agents/planner/agent.md"); // markdown only + expect(appkitServerConfig({}, { cwd: tmp }).entry).toEqual([ + "server/server.ts", + ]); + }); + }); +}); diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 679b0c607..cb809352e 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -9,7 +9,12 @@ export default defineConfig([ excludeEntrypoints: ["./type-generator"], }, name: "@databricks/appkit", - entry: ["src/index.ts", "src/beta.ts", "src/testing/index.ts"], + entry: [ + "src/index.ts", + "src/beta.ts", + "src/testing/index.ts", + "src/tsdown/index.ts", + ], outDir: "dist", hash: false, format: "esm", diff --git a/template/tsdown.server.config.ts b/template/tsdown.server.config.ts index 869950ddc..6cc06b45c 100644 --- a/template/tsdown.server.config.ts +++ b/template/tsdown.server.config.ts @@ -1,18 +1,7 @@ -import { defineConfig } from 'tsdown'; +import { appkitServerConfig } from '@databricks/appkit/tsdown'; -export default defineConfig({ -{{- if .plugins.agents}} - // server/agents/*/agent.ts aren't imported anywhere; list them as entries so the build emits dist/agents/*/agent.js for runtime discovery. - entry: ['server/server.ts', 'server/agents/*/agent.ts'], - // Clear the out dir so a deleted agent can't linger in dist/agents/. - clean: true, -{{- else}} - entry: 'server/server.ts', -{{- end}} - unbundle: true, - external: (id) => /^[^./]/.test(id) || id.includes('/node_modules/'), - tsconfig: 'tsconfig.server.json', - outExtensions: () => ({ - js: '.js', - }), -}); +// AppKit's server build preset. When you keep code agents in `server/agents/`, +// it auto-includes them as build entries so discovery works in a bundled build. +// Customize with overrides — `appkitServerConfig({ external, define, ... })` — +// or a function form for full control: `appkitServerConfig((base) => ({ ...base }))`. +export default appkitServerConfig(); From 035bfd2f9bc1def5eff57ec9227835ac059ad076 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 19 Aug 2026 10:24:21 +0200 Subject: [PATCH 4/7] docs(agents): document adding agents to an existing app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown agents need no build change; code agents require the server build to emit them (dev via tsx hides this — only a bundled build breaks). Points at the appkitServerConfig() preset as the one-line fix, notes the manual entry-glob alternative, and the startup warning that catches a forgotten build change. Signed-off-by: MarioCadenas --- docs/docs/plugins/agents.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index bab9988ff..bd76c471e 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -252,6 +252,34 @@ const result = await runAgent(classifier, { MCP hosted tools (`mcpServer(...)`) still require `agents()` (they need a live MCP client). Supervisor-API hosted tools (`supervisorTools.*`), by contrast, **work in standalone `runAgent`** — the adapter has everything it needs to execute them server-side. This makes batch-eval / CI use of supervisor agents possible without `createApp`. Plugin tool dispatch in standalone mode runs as the service principal (no OBO) and **bypasses the agents-plugin approval gate** — treat standalone runAgent as a trusted-prompt environment (CI, batch eval, internal scripts), not as an exposed user-facing surface. +## Adding agents to an existing app + +Already have an app and want to add agents? What you touch depends on the kind: + +**Markdown agents** — just the plugin. Drop `server/agents//agent.md`, add `agents()` to your `plugins`, done. Markdown is read from source at runtime in both dev and prod, so there is no build change. + +**Code agents** (`server/agents//agent.ts`) — also update your server build so a production bundle emits them. Code agents aren't imported anywhere, so a build that only compiles `server/server.ts` never produces `dist/agents/*/agent.js`, and a bundled `npm run build` + start would discover **zero** code agents. + +:::warning Dev hides this +`npm run dev` (tsx) imports the `.ts` source directly, so code agents work there with no build change — the gap only appears in a bundled build. If you add code agents but forget the build change, the plugin warns at startup (and names the fix) rather than failing silently. +::: + +The one-line fix is to adopt the build preset: + +```ts +// tsdown.server.config.ts +import { appkitServerConfig } from '@databricks/appkit/tsdown'; + +export default appkitServerConfig(); +``` + +`appkitServerConfig()` auto-detects `server/agents/` and adds the entry glob + `clean` only when code agents exist; pass overrides as `appkitServerConfig({ external, define, ... })`, or a function `appkitServerConfig((base) => ({ ...base }))` for full control. It's also the last time you touch this file — future build-wiring changes ship with the package. If you'd rather keep a hand-written config, add the entries yourself: + +```ts +entry: ['server/server.ts', 'server/agents/*/agent.ts'], +clean: true, +``` + ## Managed agents: the Supervisor API adapter `DatabricksAdapter.fromSupervisorApi` (beta) is the zero-config way to run an agent: instead of provisioning and pointing at a model-serving endpoint, you run the agentic loop in the Databricks workspace by targeting the AI Gateway Responses API (`/ai-gateway/mlflow/v1/responses`), which runs the LLM — and any hosted tools — as a managed service on Databricks. No `DATABRICKS_SERVING_ENDPOINT_NAME`, no stream-capability check, no JS tool plumbing for the common cases. From c3f6872ef9aa1da2f60b6547eb0c454de4b40aa4 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 19 Aug 2026 11:34:47 +0200 Subject: [PATCH 5/7] refactor(appkit): dedup agent folder-listing + make code-agent detection .ts-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the /simplify pass: - Extract agentDirNames() so both loaders share one folder-selection policy (dir + symlink) instead of duplicating the subtle filter + comment. - Code-agent source detection is .ts-only (resolveCodeAgentsDir source exts + hasCodeAgentSources), matching the build entry glob — an agent.tsx would otherwise load in dev but never be emitted for a prod bundle. Signed-off-by: MarioCadenas --- packages/appkit/src/core/agent/agent-dirs.ts | 14 ++++++++++++++ packages/appkit/src/core/agent/load-agents.ts | 9 +++------ packages/appkit/src/core/agent/load-code-agents.ts | 14 +++++++------- packages/appkit/src/plugins/agents/agents.ts | 6 ++---- .../plugins/agents/tests/load-code-agents.test.ts | 8 ++++---- 5 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 packages/appkit/src/core/agent/agent-dirs.ts diff --git a/packages/appkit/src/core/agent/agent-dirs.ts b/packages/appkit/src/core/agent/agent-dirs.ts new file mode 100644 index 000000000..393442a76 --- /dev/null +++ b/packages/appkit/src/core/agent/agent-dirs.ts @@ -0,0 +1,14 @@ +import type { Dirent } from "node:fs"; + +/** + * Agent-folder names from a directory listing, sorted. An agent lives in a + * subfolder, so directories count — and symlinks-to-directories too (a shared + * agent folder linked into the agents dir). Single-sourced so the markdown and + * code loaders keep the same folder-selection policy. + */ +export function agentDirNames(entries: Dirent[]): string[] { + return entries + .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .map((e) => e.name) + .sort(); +} diff --git a/packages/appkit/src/core/agent/load-agents.ts b/packages/appkit/src/core/agent/load-agents.ts index afb3c806b..54c77f157 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -15,6 +15,7 @@ import type { } from "../../core/agent/types"; import { isToolkitEntry } from "../../core/agent/types"; import { createLogger } from "../../logging/logger"; +import { agentDirNames } from "./agent-dirs"; const logger = createLogger("agents:loader"); @@ -206,12 +207,8 @@ export async function loadAgentsFromDir( ); } - const agentIds = entries - // 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(); + // A symlink to a file is filtered out below when reading agent.md (ENOTDIR). + const agentIds = agentDirNames(entries); const defs: Record = {}; const subAgentRefs: Record = {}; diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts index 585d1ea70..551a14e64 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { createLogger } from "../../logging/logger"; +import { agentDirNames } from "./agent-dirs"; import { isCreatedAgent } from "./create-agent"; import type { AgentDefinition } from "./types"; @@ -44,7 +45,9 @@ export function resolveCodeAgentsDir(opts: { const name = path.basename(sourceRel); const source: ResolvedCodeAgentsDir = { dir: path.resolve(opts.cwd, sourceRel), - extensions: [".ts", ".tsx"], + // `.ts` only: the build entry glob is `/*/agent.ts`, so an `agent.tsx` + // would load in dev but never be emitted for a prod bundle. + extensions: [".ts"], }; const built: ResolvedCodeAgentsDir[] = CODE_AGENTS_BUILT_ROOTS.map( (root) => ({ @@ -130,12 +133,9 @@ export async function loadCodeAgentsFromDir( throw err; } - const folders = entries - // Include symlinked agent folders; findEntryFile's readdir follows the link - // and returns null for anything that isn't a real directory. - .filter((e) => e.isDirectory() || e.isSymbolicLink()) - .map((e) => e.name) - .sort(); + // findEntryFile's readdir follows a symlinked folder and returns null for + // anything that isn't a real directory. + const folders = agentDirNames(entries); const agents: Record = {}; diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 229f21d2d..23dfdba08 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -539,15 +539,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return discovered; } - /** True when `dir` holds at least one `/agent.{ts,tsx}` folder. */ + /** True when `dir` holds at least one `/agent.ts` folder. */ private hasCodeAgentSources(dir: string): boolean { try { return readdirSync(dir, { withFileTypes: true }).some((e) => { if (!e.isDirectory() && !e.isSymbolicLink()) return false; try { - return readdirSync(path.join(dir, e.name)).some( - (f) => f === "agent.ts" || f === "agent.tsx", - ); + return readdirSync(path.join(dir, e.name)).includes("agent.ts"); } catch { return false; } 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 c26b5c231..b54c6b623 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 @@ -11,7 +11,7 @@ import { const fixtureDir = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); -const TS = { extensions: [".ts", ".tsx"] }; +const TS = { extensions: [".ts"] }; describe("loadCodeAgentsFromDir", () => { it("returns an empty record when the directory does not exist", async () => { @@ -91,7 +91,7 @@ describe("resolveCodeAgentsDir", () => { // No built output → falls back to the source `.ts` dir. expect( resolveCodeAgentsDir({ cwd, override: "my-agents", exists: () => false }), - ).toEqual({ dir: customSrc, extensions: [".ts", ".tsx"] }); + ).toEqual({ dir: customSrc, extensions: [".ts"] }); }); it("stays built-first when dir is set to the default value explicitly", () => { @@ -117,11 +117,11 @@ describe("resolveCodeAgentsDir", () => { 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"] }); + expect(r).toEqual({ dir: source, extensions: [".ts"] }); }); it("targets server/agents when nothing exists (empty scan downstream)", () => { const r = resolveCodeAgentsDir({ cwd, exists: () => false }); - expect(r).toEqual({ dir: source, extensions: [".ts", ".tsx"] }); + expect(r).toEqual({ dir: source, extensions: [".ts"] }); }); }); From eb9e15df7da358066df71e1f1df6ee431ffe93ed Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 20 Aug 2026 18:38:51 +0200 Subject: [PATCH 6/7] refactor(appkit): lock code-agent discovery to server/agents (drop dir option) Remove the agents({ dir }) config option and make server/agents a fixed convention. This deletes the build<->runtime dir disconnect at the root: the tsdown build glob and the runtime scan can no longer disagree about where agents live, since the path is now a single constant. - resolveCodeAgentsDir drops the override param; always built-first from server/agents (dist/agents | build/agents, then source .ts). - config/agents/ is still read as the deprecated markdown fallback and still emits the one-time deprecation warning. - dir:false (disable discovery) is gone too; a map-only app works via an absent server/agents folder. Tests point discovery at fixtures via chdir + a symlinked server/agents instead of an absolute dir. Signed-off-by: MarioCadenas --- .../appkit/Interface.AgentsPluginConfig.md | 19 ---- docs/docs/plugins/agents.md | 4 +- docs/docs/plugins/testing.md | 4 +- .../appkit/src/core/agent/load-code-agents.ts | 20 +--- packages/appkit/src/core/agent/types.ts | 13 --- packages/appkit/src/plugins/agents/agents.ts | 16 +--- .../agents/tests/agents-plugin.test.ts | 31 ++----- .../agents/tests/approval-route.test.ts | 16 ++-- .../plugins/agents/tests/discovery.test.ts | 91 ++++++++++--------- .../agents/tests/dispatch-tool-call.test.ts | 19 ++-- .../plugins/agents/tests/dos-limits.test.ts | 8 +- .../agents/tests/load-code-agents.test.ts | 46 ---------- .../agents/tests/route-handler-errors.test.ts | 10 +- 13 files changed, 92 insertions(+), 205 deletions(-) diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index 929c5e094..59d7d1220 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -114,25 +114,6 @@ Default model for agents that don't specify their own (in code or frontmatter). *** -### dir? - -```ts -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 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). - -*** - ### host? ```ts diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index bd76c471e..11bb32505 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -141,7 +141,7 @@ 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 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). +Discovery imports each `server/agents//agent.ts` — the source `.ts` under `tsx` in dev, and the compiled `dist/agents//agent.js` in a production build (built output wins over source, independent of `NODE_ENV`). Because the production server is bundled and only imports things reachable from `server/server.ts`, the template's `tsdown` config lists `server/agents/*/agent.ts` as build entries so `dist/agents/*/agent.js` are emitted for the scan — that wiring is what lets a dropped-in folder survive the prod bundle. (Markdown `agent.md` is read from source in both dev and prod — it's data, not compiled.) The root is always `server/agents` — there is no config option to relocate it; markdown still under `config/agents/` is read as a deprecated fallback (one-time warning). :::note Built output shadows source in dev Because compiled output wins over source, a stale `dist/agents` / `build/agents` left over from a previous `npm run build` will be picked up by `npm run dev` instead of your live `server/agents/*.ts`, so edits appear ignored. **Delete the build dir** if a code agent seems frozen — a rebuild only swaps in a newer snapshot, so only deleting it restores live-from-source dev reload. Markdown is always read from source, so `agent.md` edits are never shadowed. @@ -408,7 +408,7 @@ Some hosted tool kinds return their final assistant text without incremental `ou ```ts agents({ - dir?: string | false, // unified agents root; default "server/agents". config/agents is read as a deprecated fallback; false disables ALL file discovery (fallback included) + // Agents live under server/agents// (fixed root). config/agents is read as a deprecated fallback. agents?: Record, // DEPRECATED — use server/agents// discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, diff --git a/docs/docs/plugins/testing.md b/docs/docs/plugins/testing.md index 8adb47132..30fc66642 100644 --- a/docs/docs/plugins/testing.md +++ b/docs/docs/plugins/testing.md @@ -52,7 +52,7 @@ const mock = createTestPluginContext({ `attach()` wires the context to a plugin the production way: it seeds an in-memory cache (if AppKit hasn't already initialized one), then calls the plugin's `attachContext`, which rebuilds telemetry and flips `isReady` to `true`. Await it before exercising any handler that reads `this.context`, `this.cache`, or gates on `isReady`: ```ts -const plugin = new MyAgentPlugin({ dir: false }); +const plugin = new MyAgentPlugin({}); await mock.attach(plugin); ``` @@ -216,7 +216,7 @@ To test a plugin that dispatches cross-plugin tool calls, register fake provider ```ts const mock = createTestPluginContext({ analytics: { query: [{ n: 1 }] } }); -const plugin = new MyAgentPlugin({ dir: false }); +const plugin = new MyAgentPlugin({}); await mock.attach(plugin); // `obo` sets the forwarded identity headers `asUser` needs — without them the diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts index 551a14e64..3e5d910f6 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -25,26 +25,16 @@ interface ResolvedCodeAgentsDir { /** * 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). + * Node can't load one): the matching `dist/agents` / `build/agents` is probed + * first, with the source `server/agents` `.ts` dir as fallback. */ export function resolveCodeAgentsDir(opts: { cwd: string; - override?: string | false; exists: (dir: string) => boolean; -}): ResolvedCodeAgentsDir | null { - if (opts.override === false) return null; - if (typeof opts.override === "string" && path.isAbsolute(opts.override)) { - return { dir: opts.override, extensions: [".ts", ".tsx", ".js", ".mjs"] }; - } - - const sourceRel = opts.override ?? CODE_AGENTS_SOURCE_DIR; - const name = path.basename(sourceRel); +}): ResolvedCodeAgentsDir { + const name = path.basename(CODE_AGENTS_SOURCE_DIR); const source: ResolvedCodeAgentsDir = { - dir: path.resolve(opts.cwd, sourceRel), + dir: path.resolve(opts.cwd, CODE_AGENTS_SOURCE_DIR), // `.ts` only: the build entry glob is `/*/agent.ts`, so an `agent.tsx` // would load in dev but never be emitted for a prod bundle. extensions: [".ts"], diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index e5d4c3574..234aa45c1 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -215,19 +215,6 @@ export interface AutoInheritToolsConfig { } 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 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; /** * @deprecated Put each code agent in its own folder under * `server/agents//agent.ts` (`export default createAgent({ ... })`); it is diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 23dfdba08..d34e867d4 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -412,7 +412,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (Object.keys(merged).length === 0) { logger.info( "No agents registered (no files in %s, no discovered or code-defined agents)", - this.resolvedAgentsDir() ?? "", + this.resolvedAgentsDir(), ); return { agents, defaultAgentName: null }; } @@ -496,10 +496,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { ); } - private resolvedAgentsDir(): string | null { - if (this.config.dir === false) return null; - const dir = this.config.dir ?? CODE_AGENTS_SOURCE_DIR; - return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); + private resolvedAgentsDir(): string { + return path.resolve(process.cwd(), CODE_AGENTS_SOURCE_DIR); } /** @@ -511,10 +509,8 @@ export class AgentsPlugin extends Plugin implements ToolProvider { private async loadCodeAgents(): Promise> { const resolved = resolveCodeAgentsDir({ cwd: process.cwd(), - override: this.config.dir, exists: existsSync, }); - if (!resolved) return {}; const discovered = await loadCodeAgentsFromDir(resolved.dir, { extensions: resolved.extensions, @@ -525,7 +521,6 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if ( Object.keys(discovered).length === 0 && !usingDeprecatedMap && - sourceDir && this.hasCodeAgentSources(sourceDir) ) { logger.warn( @@ -562,7 +557,6 @@ export class AgentsPlugin extends Plugin implements ToolProvider { defaultAgent: string | null; }> { const primaryDir = this.resolvedAgentsDir(); - if (!primaryDir) return { defs: {}, defaultAgent: null }; // Discovered code agents + the deprecated map resolve markdown `agents:` // sub-agent references, so a markdown parent can delegate to a code child. @@ -572,11 +566,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { plugins: this.pluginProviderIndex(), }; - // Configured dir already points at config/agents → single scan, no fallback. const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); - if (primaryDir === legacyDir) { - return loadAgentsFromDir(primaryDir, { ...baseCtx, codeAgents }); - } // Deprecated fallback: scan config/agents first, then server/agents with the // legacy defs added as resolvable sub-agent targets — so a parent already diff --git a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts index 8eda5e389..e57225dd2 100644 --- a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -86,9 +86,13 @@ function makeToolProvider( } let tmpDir: string; +let priorCwd: string; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agents-plugin-")); + // Discovery scans /server/agents, so run in tmpDir and write agents there. + priorCwd = process.cwd(); + process.chdir(tmpDir); const storage = { get: vi.fn(), set: vi.fn(), @@ -101,6 +105,7 @@ beforeEach(async () => { }); afterEach(() => { + process.chdir(priorCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -111,7 +116,7 @@ function instantiate(config: AgentsPluginConfig, ctx?: FakeContext) { } function writeMarkdownAgent(dir: string, id: string, content: string) { - const folder = path.join(dir, id); + const folder = path.join(dir, "server", "agents", id); fs.mkdirSync(folder, { recursive: true }); fs.writeFileSync(path.join(folder, "agent.md"), content, "utf-8"); } @@ -119,7 +124,6 @@ function writeMarkdownAgent(dir: string, id: string, content: string) { describe("AgentsPlugin", () => { test("registers code-defined agents and exposes them via exports", async () => { const plugin = instantiate({ - dir: false, agents: { support: { instructions: "You help customers.", @@ -144,7 +148,6 @@ describe("AgentsPlugin", () => { "---\ndefault: true\n---\nYou are helpful.", ); const plugin = instantiate({ - dir: tmpDir, defaultModel: stubAdapter(), }); await plugin.setup(); @@ -160,7 +163,6 @@ describe("AgentsPlugin", () => { test("code definitions override markdown on key collision", async () => { writeMarkdownAgent(tmpDir, "support", "---\n---\nFrom markdown."); const plugin = instantiate({ - dir: tmpDir, defaultModel: stubAdapter(), agents: { support: { @@ -185,7 +187,7 @@ describe("AgentsPlugin", () => { // client is closed" mid-conversation. The fix removes the // synchronous close — the existing client survives reload and // dispatches keep working. - const plugin = instantiate({ dir: false }); + const plugin = instantiate({}); const closeSpy = vi.fn(async () => {}); const fakeClient = { close: closeSpy, @@ -217,7 +219,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: tmpDir, defaultModel: stubAdapter(), agents: { manual: { @@ -262,7 +263,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: tmpDir, defaultModel: stubAdapter(), autoInheritTools: { file: true }, }, @@ -297,7 +297,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, defaultModel: stubAdapter(), autoInheritTools: true, agents: { @@ -345,10 +344,7 @@ describe("AgentsPlugin", () => { "---\ntools:\n - plugin:analytics\n---\nAnalyst.", ); - const plugin = instantiate( - { dir: tmpDir, defaultModel: stubAdapter() }, - ctx, - ); + const plugin = instantiate({ defaultModel: stubAdapter() }, ctx); await plugin.setup(); const api = plugin.exports() as { @@ -362,7 +358,6 @@ describe("AgentsPlugin", () => { test("registers sub-agents as agent- tools", async () => { const plugin = instantiate({ - dir: false, agents: { supervisor: { instructions: "Supervise", @@ -415,7 +410,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -454,7 +448,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -489,7 +482,6 @@ describe("AgentsPlugin", () => { const ctx = fakeContext([]); const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -538,7 +530,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, autoInheritTools: { code: true }, agents: { support: { @@ -582,7 +573,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, autoInheritTools: { code: true }, agents: { support: { @@ -622,7 +612,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -649,7 +638,6 @@ describe("AgentsPlugin", () => { const toolsFn = vi.fn(() => ({})); const plugin = instantiate( { - dir: false, agents: { support: { instructions: "...", @@ -681,7 +669,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { assistant: { instructions: "x", @@ -718,7 +705,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { mismatched: { instructions: "x", @@ -757,7 +743,6 @@ describe("AgentsPlugin", () => { const plugin = instantiate( { - dir: false, agents: { leaky: { instructions: "x", diff --git a/packages/appkit/src/plugins/agents/tests/approval-route.test.ts b/packages/appkit/src/plugins/agents/tests/approval-route.test.ts index bddd92522..d06002444 100644 --- a/packages/appkit/src/plugins/agents/tests/approval-route.test.ts +++ b/packages/appkit/src/plugins/agents/tests/approval-route.test.ts @@ -63,7 +63,7 @@ beforeEach(() => { describe("POST /approve route handler", () => { test("rejects invalid body shape with 400", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { res, json } = mockRes(); await (plugin as any)._handleApprove(mockReq({}, "alice"), res); expect(res.status).toHaveBeenCalledWith(400); @@ -73,7 +73,7 @@ describe("POST /approve route handler", () => { }); test("returns 404 when the streamId is unknown", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { res, json } = mockRes(); await ( plugin as unknown as { @@ -96,7 +96,7 @@ describe("POST /approve route handler", () => { }); test("returns 403 when submitter is different from stream owner", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-x", { controller: new AbortController(), userId: "alice", @@ -135,7 +135,7 @@ describe("POST /approve route handler", () => { }); test("returns 404 when approvalId is unknown on an active stream", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-y", { controller: new AbortController(), userId: "alice", @@ -164,7 +164,7 @@ describe("POST /approve route handler", () => { }); test("happy path: approve resolves pending gate with 'approve'", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-z", { controller: new AbortController(), userId: "alice", @@ -198,7 +198,7 @@ describe("POST /approve route handler", () => { }); test("happy path: deny resolves pending gate with 'deny'", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).activeStreams.set("stream-z", { controller: new AbortController(), userId: "alice", @@ -233,7 +233,7 @@ describe("POST /approve route handler", () => { describe("POST /cancel ownership + gate cleanup", () => { test("cancelling a stream denies every pending approval on that stream", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const controller = new AbortController(); (plugin as any).activeStreams.set("stream-c", { controller, @@ -270,7 +270,7 @@ describe("POST /cancel ownership + gate cleanup", () => { }); test("cancel from a different user is refused with 403", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const controller = new AbortController(); (plugin as any).activeStreams.set("stream-d", { controller, diff --git a/packages/appkit/src/plugins/agents/tests/discovery.test.ts b/packages/appkit/src/plugins/agents/tests/discovery.test.ts index db2510e8e..3ab960a82 100644 --- a/packages/appkit/src/plugins/agents/tests/discovery.test.ts +++ b/packages/appkit/src/plugins/agents/tests/discovery.test.ts @@ -39,12 +39,39 @@ type ExportsApi = { getDefault: () => string | null; }; +// Discovery scans `/server/agents`, so each test runs in a fresh temp cwd. +// Code fixtures are symlinked in (not copied) so their `.ts` files resolve their +// relative imports from the committed location; markdown-only cases pass no +// fixture (an absent server/agents = empty discovery). describe("AgentsPlugin agent discovery", () => { - test("discovers code agents from the dir with no map at the call site", async () => { - const plugin = instantiate({ - dir: fixtureDir("code-agents"), - defaultModel: stubAdapter(), - }); + let restoreCwd: (() => void) | undefined; + + afterEach(() => { + restoreCwd?.(); + restoreCwd = undefined; + }); + + function chdirWithAgents(fixture?: string) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agents-discovery-")); + if (fixture) { + fs.mkdirSync(path.join(tmp, "server")); + fs.symlinkSync( + fixtureDir(fixture), + path.join(tmp, "server", "agents"), + "dir", + ); + } + const prior = process.cwd(); + process.chdir(tmp); + restoreCwd = () => { + process.chdir(prior); + fs.rmSync(tmp, { recursive: true, force: true }); + }; + } + + test("discovers code agents from server/agents", async () => { + chdirWithAgents("code-agents"); + const plugin = instantiate({ defaultModel: stubAdapter() }); await plugin.setup(); const api = plugin.exports() as ExportsApi; @@ -54,10 +81,8 @@ describe("AgentsPlugin agent discovery", () => { }); test("honors default: true on a discovered code agent", async () => { - const plugin = instantiate({ - dir: fixtureDir("code-agents-default"), - defaultModel: stubAdapter(), - }); + chdirWithAgents("code-agents-default"); + const plugin = instantiate({ defaultModel: stubAdapter() }); await plugin.setup(); expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); }); @@ -65,17 +90,15 @@ describe("AgentsPlugin agent discovery", () => { test("a discovered code default: true beats a markdown default: true", async () => { // code-agents-default holds beta (code, default:true) + planner (markdown, // default:true) side by side; code wins. - const plugin = instantiate({ - dir: fixtureDir("code-agents-default"), - defaultModel: stubAdapter(), - }); + chdirWithAgents("code-agents-default"); + const plugin = instantiate({ defaultModel: stubAdapter() }); await plugin.setup(); expect((plugin.exports() as ExportsApi).getDefault()).toBe("beta"); }); test("explicit defaultAgent overrides a discovered default: true", async () => { + chdirWithAgents("code-agents-default"); const plugin = instantiate({ - dir: fixtureDir("code-agents-default"), defaultAgent: "alpha", defaultModel: stubAdapter(), }); @@ -84,10 +107,8 @@ describe("AgentsPlugin agent discovery", () => { }); test("a markdown parent can delegate to a code sub-agent in a sibling folder", async () => { - const plugin = instantiate({ - dir: fixtureDir("md-parent-code-child"), - defaultModel: stubAdapter(), - }); + chdirWithAgents("md-parent-code-child"); + const plugin = instantiate({ defaultModel: stubAdapter() }); await plugin.setup(); const api = plugin.exports() as ExportsApi; @@ -97,9 +118,9 @@ describe("AgentsPlugin agent discovery", () => { }); test("discovery wins over a colliding deprecated-map entry (warns, no throw)", async () => { + chdirWithAgents("code-agents"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const plugin = instantiate({ - dir: fixtureDir("code-agents"), agents: { helper: { instructions: "from the map", model: stubAdapter() }, }, @@ -122,9 +143,9 @@ describe("AgentsPlugin agent discovery", () => { warnSpy.mockRestore(); }); - test("a map-only app with discovery disabled works unchanged (no throw)", async () => { + test("a map-only app works unchanged when server/agents is absent (no throw)", async () => { + chdirWithAgents(); const plugin = instantiate({ - dir: false, agents: { legacy: { instructions: "map agent", model: stubAdapter() }, }, @@ -137,8 +158,8 @@ describe("AgentsPlugin agent discovery", () => { }); test("throws when defaultAgent names an unregistered agent", async () => { + chdirWithAgents("code-agents"); const plugin = instantiate({ - dir: fixtureDir("code-agents"), defaultAgent: "nope", defaultModel: stubAdapter(), }); @@ -146,10 +167,8 @@ describe("AgentsPlugin agent discovery", () => { }); test("throws when a folder holds both agent.ts and agent.md", async () => { - const plugin = instantiate({ - dir: fixtureDir("code-md-collision"), - defaultModel: stubAdapter(), - }); + chdirWithAgents("code-md-collision"); + const plugin = instantiate({ defaultModel: stubAdapter() }); await expect(plugin.setup()).rejects.toThrow( /both a code agent .* and a markdown agent/, ); @@ -158,8 +177,8 @@ describe("AgentsPlugin agent discovery", () => { test("emits a one-time deprecation warning for agents({ agents }) and none for discovery", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + chdirWithAgents(); const deprecated = instantiate({ - dir: false, agents: { legacy: { instructions: "x", model: stubAdapter() } }, }); await deprecated.setup(); @@ -171,10 +190,9 @@ describe("AgentsPlugin agent discovery", () => { expect(deprecationWarnings).toHaveLength(1); warnSpy.mockClear(); - const discoveredPlugin = instantiate({ - dir: fixtureDir("code-agents"), - defaultModel: stubAdapter(), - }); + restoreCwd?.(); + chdirWithAgents("code-agents"); + const discoveredPlugin = instantiate({ defaultModel: stubAdapter() }); await discoveredPlugin.setup(); const discoveryWarnings = warnSpy.mock.calls @@ -247,15 +265,4 @@ describe("AgentsPlugin config/agents deprecated fallback", () => { 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/dispatch-tool-call.test.ts b/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts index 70a195f9c..f700379f4 100644 --- a/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts +++ b/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts @@ -111,7 +111,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { // Regression for finding #1 on PR #304: the gate previously checked // only `annotations.destructive === true` and let `effect:"destructive"` // through unapproved. - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState, pushed } = makeRunState(plugin); const execute = vi.fn().mockResolvedValue("ok"); @@ -151,7 +151,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { ])( "does NOT fire for non-mutating `effect` value %p", async (effect, expectGate) => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); const annotations = effect ? { effect } : undefined; @@ -187,7 +187,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { ); test("denying the gate returns the deny string and does not invoke the tool", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); const execute = vi.fn(); @@ -225,7 +225,7 @@ describe("dispatchToolCall — approval gate honours `effect`", () => { describe("dispatchToolCall — shared tool-call budget", () => { test("subsequent calls increment the shared counter", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); const toolIndex = new Map([ @@ -252,7 +252,6 @@ describe("dispatchToolCall — shared tool-call budget", () => { test("rejects + aborts when the budget is exhausted", async () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxToolCalls: 2 }, }); const { runState } = makeRunState(plugin); @@ -309,7 +308,7 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { ]); test("forwards runState.limits.toolCallTimeoutMs to PluginContext.executeTool", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); runState.limits.toolCallTimeoutMs = 90_000; @@ -352,7 +351,7 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { // End-to-end proof that the timeout value the agents plugin forwards // reaches real AbortSignal composition inside PluginContext.executeTool — // a stubbed executeTool would silently ignore the timeout. - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const { runState } = makeRunState(plugin); runState.limits.toolCallTimeoutMs = 5; @@ -379,14 +378,13 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { }); test("resolvedLimits exposes the documented 5-minute default", () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const limits = (plugin as any).resolvedLimits; expect(limits.toolCallTimeoutMs).toBe(300_000); }); test("honours agents({ limits: { toolCallTimeoutMs } })", () => { const plugin = new AgentsPlugin({ - dir: false, limits: { toolCallTimeoutMs: 600_000 }, }); const limits = (plugin as any).resolvedLimits; @@ -408,7 +406,6 @@ describe("runSubAgent — sub-agent event forwarding", () => { // cycle, two agents delegating to each other will eventually exceed // the depth limit and we want a clear error, not an unbounded stack. const plugin = new AgentsPlugin({ - dir: false, agents: {}, limits: { maxSubAgentDepth: 2 }, }); @@ -430,7 +427,7 @@ describe("runSubAgent — sub-agent event forwarding", () => { }); test("forwards every sub-agent event into the parent stream except metadata", async () => { - const plugin = new AgentsPlugin({ dir: false, agents: {} }); + const plugin = new AgentsPlugin({ agents: {} }); const { runState, pushed } = makeRunState(plugin); const child = { diff --git a/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts b/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts index 2713e50b9..dac3fba56 100644 --- a/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts +++ b/packages/appkit/src/plugins/agents/tests/dos-limits.test.ts @@ -129,7 +129,7 @@ describe("invocationsRequestSchema — input caps", () => { describe("POST /chat — per-user concurrent-stream limit", () => { function seedPlugin( - overrides: ConstructorParameters[0] = { dir: false }, + overrides: ConstructorParameters[0] = {}, ): AgentsPlugin { const plugin = new AgentsPlugin(overrides); // Seed the agents map directly so _handleChat can resolve "hello" @@ -189,7 +189,6 @@ describe("POST /chat — per-user concurrent-stream limit", () => { test("honours agents({ limits: { maxConcurrentStreamsPerUser } })", async () => { const plugin = seedPlugin({ - dir: false, limits: { maxConcurrentStreamsPerUser: 2 }, }); for (let i = 0; i < 2; i++) { @@ -270,7 +269,7 @@ describe("POST /chat — per-user concurrent-stream limit", () => { describe("resolvedLimits — default values", () => { test("exposes the documented MVP defaults when unconfigured", () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); const limits = (plugin as any).resolvedLimits; expect(limits).toEqual({ maxConcurrentStreamsPerUser: 5, @@ -282,7 +281,6 @@ describe("resolvedLimits — default values", () => { test("lets callers override any subset", () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxToolCalls: 100 }, }); const limits = (plugin as any).resolvedLimits; @@ -334,7 +332,6 @@ describe("runSubAgent — depth guard", () => { test("rejects when depth exceeds the configured maximum", async () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxSubAgentDepth: 2 }, }); const runState = makeRunState(plugin, { maxSubAgentDepth: 2 }); @@ -350,7 +347,6 @@ describe("runSubAgent — depth guard", () => { test("accepts at the boundary (depth === limit)", async () => { const plugin = new AgentsPlugin({ - dir: false, limits: { maxSubAgentDepth: 3 }, agents: {}, }); diff --git a/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts b/packages/appkit/src/plugins/agents/tests/load-code-agents.test.ts index b54c6b623..0ec266fcc 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 @@ -59,52 +59,6 @@ describe("resolveCodeAgentsDir", () => { (dir: string) => present.includes(dir); - it("returns null when discovery is disabled", () => { - expect( - resolveCodeAgentsDir({ cwd, override: false, exists: () => true }), - ).toBeNull(); - }); - - it("scans an absolute override verbatim, accepting any module extension", () => { - expect( - resolveCodeAgentsDir({ - cwd, - override: "/abs/agents", - exists: () => true, - }), - ).toEqual({ - dir: "/abs/agents", - extensions: [".ts", ".tsx", ".js", ".mjs"], - }); - }); - - it("resolves a relative override built-first (dist/ over source)", () => { - const customDist = path.resolve(cwd, "dist/my-agents"); - const customSrc = path.resolve(cwd, "my-agents"); - expect( - resolveCodeAgentsDir({ - cwd, - override: "my-agents", - exists: existsIn(customDist, customSrc), - }), - ).toEqual({ dir: customDist, extensions: [".js", ".mjs"] }); - // No built output → falls back to the source `.ts` dir. - expect( - resolveCodeAgentsDir({ cwd, override: "my-agents", exists: () => false }), - ).toEqual({ dir: customSrc, extensions: [".ts"] }); - }); - - it("stays built-first when dir is set to the default value explicitly", () => { - // Regression: `agents({ dir: "server/agents" })` must NOT bypass built-first - // (previously a string override was scanned verbatim → prod loaded .ts). - const r = resolveCodeAgentsDir({ - cwd, - override: "server/agents", - exists: existsIn(dist, source), - }); - expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); - }); - it("prefers compiled dist/agents (.js) over source — built wins", () => { const r = resolveCodeAgentsDir({ cwd, exists: existsIn(dist, source) }); expect(r).toEqual({ dir: dist, extensions: [".js", ".mjs"] }); diff --git a/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts b/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts index 4aa069a61..06abd8dac 100644 --- a/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts +++ b/packages/appkit/src/plugins/agents/tests/route-handler-errors.test.ts @@ -62,7 +62,7 @@ function mockRes() { } function seedPlugin(): AgentsPlugin { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).agents.set("default", { name: "default", instructions: "hi", @@ -190,7 +190,7 @@ describe("POST /invocations — threadStore failure", () => { describe("POST /invocations & /responses — HITL pre-flight", () => { function seedPluginWithTools( toolAnnotations: Record, - overrides: ConstructorParameters[0] = { dir: false }, + overrides: ConstructorParameters[0] = {}, ): AgentsPlugin { const plugin = new AgentsPlugin(overrides); const toolIndex = new Map(); @@ -273,7 +273,7 @@ describe("POST /invocations & /responses — HITL pre-flight", () => { test("passes pre-flight when approval.requireForDestructive is disabled", async () => { const plugin = seedPluginWithTools( { effect: "destructive" }, - { dir: false, approval: { requireForDestructive: false } }, + { approval: { requireForDestructive: false } }, ); (plugin as any)._runAgentNonStreaming = vi.fn(async () => undefined); (plugin as any).threadStore = { @@ -320,7 +320,7 @@ describe("POST /invocations & /responses — HITL pre-flight", () => { describe("POST /invocations & /responses — successful invoke", () => { test("returns OpenAI Responses-shaped JSON with aggregated assistant text", async () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); (plugin as any).agents.set("default", { name: "default", instructions: "hi", @@ -378,7 +378,7 @@ describe("POST /invocations & /responses — successful invoke", () => { describe("/invocations and /responses are aliases", () => { test("both routes are registered and bound to the same handler", () => { - const plugin = new AgentsPlugin({ dir: false }); + const plugin = new AgentsPlugin({}); // Attach the real PluginContext via the testing kit. Its route recorder // captures the RAW handlers passed to addRoute — the aliasing assertion // needs the original references, which the context's forwardAsyncErrors From bc75ed3d55fb41b0d477ce482a60ac46549b2dbb Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 21 Aug 2026 11:32:21 +0200 Subject: [PATCH 7/7] refactor(appkit): address discovery review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-dirs: stop following symlinked entries (only real directories), so a link planted in server/agents can't pull agent code from outside the tree. - agents: skip the config/agents fallback scan when the dir is absent — a single scan of server/agents in the common case. - load-code-agents: import discovered agents concurrently (Promise.all), preserving sorted-folder order and the no-loader / import-error semantics. Signed-off-by: MarioCadenas --- packages/appkit/src/core/agent/agent-dirs.ts | 8 +- .../appkit/src/core/agent/load-code-agents.ts | 94 +++++++++++-------- packages/appkit/src/plugins/agents/agents.ts | 12 ++- .../agents/tests/load-code-agents.test.ts | 29 ++++++ 4 files changed, 94 insertions(+), 49 deletions(-) diff --git a/packages/appkit/src/core/agent/agent-dirs.ts b/packages/appkit/src/core/agent/agent-dirs.ts index 393442a76..1a50d506f 100644 --- a/packages/appkit/src/core/agent/agent-dirs.ts +++ b/packages/appkit/src/core/agent/agent-dirs.ts @@ -2,13 +2,13 @@ import type { Dirent } from "node:fs"; /** * Agent-folder names from a directory listing, sorted. An agent lives in a - * subfolder, so directories count — and symlinks-to-directories too (a shared - * agent folder linked into the agents dir). Single-sourced so the markdown and - * code loaders keep the same folder-selection policy. + * subfolder, so only real directories count — symlinks are not followed, so a + * link planted in the agents dir can't pull code from outside it. Single-sourced + * so the markdown and code loaders keep the same folder-selection policy. */ export function agentDirNames(entries: Dirent[]): string[] { return entries - .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .filter((e) => e.isDirectory()) .map((e) => e.name) .sort(); } diff --git a/packages/appkit/src/core/agent/load-code-agents.ts b/packages/appkit/src/core/agent/load-code-agents.ts index 3e5d910f6..89f7f9199 100644 --- a/packages/appkit/src/core/agent/load-code-agents.ts +++ b/packages/appkit/src/core/agent/load-code-agents.ts @@ -123,54 +123,66 @@ export async function loadCodeAgentsFromDir( throw err; } - // findEntryFile's readdir follows a symlinked folder and returns null for - // anything that isn't a real directory. const folders = agentDirNames(entries); - const agents: Record = {}; - - for (const id of folders) { - const entryFile = await findEntryFile(path.join(dir, id), opts.extensions); - if (!entryFile) continue; - - let mod: Record; - try { - mod = (await import(pathToFileURL(entryFile).href)) as Record< - string, - unknown - >; - } catch (err) { - // No TS loader for these `.ts` modules — warn once and bail rather than - // crash boot (every folder would fail the same way). - if ( - (err as NodeJS.ErrnoException).code === "ERR_UNKNOWN_FILE_EXTENSION" - ) { - logger.warn( - "Cannot import code agents from %s under this runtime (no TypeScript loader). " + - "A production build must compile server/agents/ to JS — check the `server/agents/*/agent.ts` entry glob in the tsdown config. Discovered no code agents.", - dir, + // Each agent is an independent read + import, so load them concurrently — + // startup no longer scales linearly with the agent count. Results stay in + // sorted-folder order (the default-agent fallback registers the first). + const NO_LOADER = Symbol("no-ts-loader"); + const loaded = await Promise.all( + folders.map(async (id) => { + const entryFile = await findEntryFile( + path.join(dir, id), + opts.extensions, + ); + if (!entryFile) return null; + + let mod: Record; + try { + mod = (await import(pathToFileURL(entryFile).href)) as Record< + string, + unknown + >; + } catch (err) { + if ( + (err as NodeJS.ErrnoException).code === "ERR_UNKNOWN_FILE_EXTENSION" + ) { + return NO_LOADER; + } + throw new Error( + `Failed to import code agent '${entryFile}': ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err instanceof Error ? err : undefined }, ); - return {}; } - throw new Error( - `Failed to import code agent '${entryFile}': ${ - err instanceof Error ? err.message : String(err) - }`, - { cause: err instanceof Error ? err : undefined }, - ); - } - const agent = pickAgentExport(mod, entryFile); - if (!agent) { - logger.debug( - "Skipping %s — no createAgent export (not a code agent).", - entryFile, - ); - continue; - } + const agent = pickAgentExport(mod, entryFile); + if (!agent) { + logger.debug( + "Skipping %s — no createAgent export (not a code agent).", + entryFile, + ); + return null; + } + return { id, agent }; + }), + ); - agents[id] = agent; + // No TS loader for these `.ts` modules — warn once and bail rather than crash + // boot (every folder fails the same way). + if (loaded.includes(NO_LOADER)) { + logger.warn( + "Cannot import code agents from %s under this runtime (no TypeScript loader). " + + "A production build must compile server/agents/ to JS — check the `server/agents/*/agent.ts` entry glob in the tsdown config. Discovered no code agents.", + dir, + ); + return {}; } + const agents: Record = {}; + for (const entry of loaded) { + if (entry && entry !== NO_LOADER) agents[entry.id] = entry.agent; + } return agents; } diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index d34e867d4..b9e508a45 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -568,11 +568,15 @@ export class AgentsPlugin extends Plugin implements ToolProvider { const legacyDir = path.resolve(process.cwd(), LEGACY_MARKDOWN_DIR); - // Deprecated fallback: scan config/agents first, then server/agents with the - // legacy defs added as resolvable sub-agent targets — so a parent already + // Common case: no deprecated config/agents/ dir → a single scan of server/agents. + if (!existsSync(legacyDir)) { + return loadAgentsFromDir(primaryDir, { ...baseCtx, codeAgents }); + } + + // Deprecated fallback: config/agents/ exists → scan it 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). + // mid-migration. Code agents keep precedence; server/agents wins on an id clash. const legacy = await loadAgentsFromDir(legacyDir, { ...baseCtx, codeAgents, 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 0ec266fcc..67ce6a32e 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,8 +1,10 @@ +import type { Dirent } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { agentDirNames } from "../../../core/agent/agent-dirs"; import { loadCodeAgentsFromDir, resolveCodeAgentsDir, @@ -79,3 +81,30 @@ describe("resolveCodeAgentsDir", () => { expect(r).toEqual({ dir: source, extensions: [".ts"] }); }); }); + +describe("agentDirNames", () => { + const dirent = (name: string, kind: "dir" | "symlink" | "file"): Dirent => + ({ + name, + isDirectory: () => kind === "dir", + isSymbolicLink: () => kind === "symlink", + }) as unknown as Dirent; + + it("keeps only real directories — symlinks and files are excluded", () => { + // Security: a symlink planted in server/agents must not be followed, so it + // can't pull agent code from outside the tree. + const names = agentDirNames([ + dirent("real", "dir"), + dirent("linked", "symlink"), + dirent("agent.md", "file"), + ]); + expect(names).toEqual(["real"]); + }); + + it("sorts the folder names", () => { + expect(agentDirNames([dirent("b", "dir"), dirent("a", "dir")])).toEqual([ + "a", + "b", + ]); + }); +});