fix(core): Enable multiturn conversations when building an agent via agent builder route in AIA (no-changelog) (#33840)

Co-authored-by: Michael Drury <michael.drury@n8n.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anne Aguirre
2026-07-08 16:41:37 +00:00
committed by GitHub
co-authored by Michael Drury Claude Opus 4.8
parent 7a8742d7db
commit 50d2340c51
34 changed files with 925 additions and 450 deletions
+1
View File
@@ -141,6 +141,7 @@ export {
createRuntimeSkillSource,
createRuntimeSkillTools,
createSkillLoadTool,
filterRuntimeSkillSource,
formatSkillValidationErrors,
InvalidRuntimeSkillError,
loadRuntimeSkillsFromDirectory,
@@ -8,6 +8,7 @@ import {
createRuntimeSkillSource,
createRuntimeSkillTools,
createSkillLoadTool,
filterRuntimeSkillSource,
InvalidRuntimeSkillError,
loadRuntimeSkillSourceFromDirectory,
parseRuntimeSkillMarkdown,
@@ -359,6 +360,35 @@ Use the workflow SDK.`,
}
});
it('filters a source so hidden skills are unlisted, unloadable, and excluded from the hash', async () => {
const skills = [
{ id: 'hidden_skill', name: 'Hidden skill', description: 'Hide me.', instructions: 'Body.' },
{ id: 'kept_skill', name: 'Kept skill', description: 'Keep me.', instructions: 'Body.' },
];
const source = {
...createRuntimeSkillSource(skills),
loadFile: async (skillId: string, filePath: string) =>
await Promise.resolve({ skillId, filePath, content: 'file body' }),
};
const filtered = filterRuntimeSkillSource(source, ['hidden_skill']);
expect(filtered.registry.skills.map((skill) => skill.id)).toEqual(['kept_skill']);
// The hash must describe the filtered catalog, not the original one, so
// workspace manifests keyed on it can't match a differently-filtered set.
expect(filtered.registry.skillsHash).not.toBe(source.registry.skillsHash);
expect(filtered.registry.skillsHash).toBe(
createRuntimeSkillRegistry(skills.filter((skill) => skill.id !== 'hidden_skill')).skillsHash,
);
await expect(filtered.loadSkill('hidden_skill')).resolves.toBeNull();
await expect(filtered.loadSkill('kept_skill')).resolves.toMatchObject({ id: 'kept_skill' });
await expect(filtered.loadFile?.('hidden_skill', 'references/a.md')).resolves.toBeNull();
await expect(filtered.loadFile?.('kept_skill', 'references/a.md')).resolves.toMatchObject({
content: 'file body',
});
});
it('renders a compact skill catalog without skill bodies', () => {
const source = createRuntimeSkillSource([
{
+1
View File
@@ -33,6 +33,7 @@ export {
export {
createRuntimeSkillRegistry,
createRuntimeSkillSource,
filterRuntimeSkillSource,
formatSkillValidationErrors,
InvalidRuntimeSkillError,
loadRuntimeSkillsFromDirectory,
@@ -55,6 +55,37 @@ export function createRuntimeSkillRegistry(skills: RuntimeSkill[]): RuntimeSkill
};
}
/**
* Hide skills from an already-loaded source. Recomputes `skillsHash` so
* manifests/prebaked bundles keyed on it can't match a differently-filtered
* catalog, and wraps the loaders so hidden skills are unavailable rather than
* just absent from the registry.
*/
export function filterRuntimeSkillSource(
source: RuntimeSkillSource,
excludeSkillIds: string[],
): RuntimeSkillSource {
const excluded = new Set(excludeSkillIds);
const skills = source.registry.skills.filter((skill) => !excluded.has(skill.id));
const { loadFile } = source;
return {
...source,
registry: {
...source.registry,
skillsHash: hashRegistry(skills),
skills,
},
loadSkill: async (skillId) => (excluded.has(skillId) ? null : await source.loadSkill(skillId)),
...(loadFile
? {
loadFile: async (skillId: string, filePath: string) =>
excluded.has(skillId) ? null : await loadFile(skillId, filePath),
}
: {}),
};
}
export function loadRuntimeSkillSourceFromDirectory(
rootDir: string,
options: LoadRuntimeSkillSourceFromDirectoryOptions = {},
@@ -226,6 +226,8 @@ graph LR
If any step fails, the agent reads the error output, fixes the code, and retries. This loop runs entirely inside the sandbox — the n8n host is never involved until the final save.
The agent builder uses the workspace the same way, but purely as a file medium: the agent config JSON lives in a workspace file (`src/agents/<slug>.agent.json`) that the agent edits with the normal file tools and persists with `build_agent`. Nothing executes in the sandbox for agent configs — parsing, schema validation, and the freshness (hash) check all run host-side before the config is saved. Because config edits go through workspace files, the agent-builder skill is only offered when the sandbox workspace is available.
## Boundaries
**Sandboxing is not the filesystem service.** The sandbox gives the agent a private workspace for building workflows. The filesystem service (and gateway) gives the agent access to the user's project files on their machine. These are separate systems with different security models and do not overlap.
+18 -9
View File
@@ -680,8 +680,16 @@ A single router tool that lets the assistant **create and configure an n8n
*Agent*** (the `AgentJsonConfig`: instructions, model, node/workflow/MCP/custom
tools, skills, tasks, integrations, sub-agents). It is registered as a **deferred**
tool and loaded on demand by the `agent-builder` skill; it is present only when the
`agents` module is enabled. All builder capabilities are exposed as `action`s on
this one tool. See `docs/agent-builder.md` for the design.
`agents` module is enabled (and the skill is only offered when the sandbox
workspace is available, since config edits go through workspace files). All
builder capabilities are exposed as `action`s on this one tool. See
`docs/agent-builder.md` for the design.
Config mutations are file-based: the assistant writes the agent config JSON to
a workspace file (`src/agents/<slug>.agent.json`), edits it with the normal
file tools, and persists it with `build_agent` — mirroring how workflow builds
consume `.workflow.ts` sources. Validation stays host-side; the workspace is
only the file medium.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
@@ -696,17 +704,17 @@ this one tool. See `docs/agent-builder.md` for the design.
| Action | Description |
|--------|-------------|
| `create_agent` | Create a new empty agent and bind the conversation to it. Call this first when no agent is targeted yet. |
| `read_config` | Read the current agent config plus freshness metadata (`configHash`, `updatedAt`, `versionId`). Call before every write/patch. |
| `write_config` | Replace the whole agent config from a JSON string. Validates the schema, rejects empty instructions and unsupported native web search, enforces no `$fromAI` on stable dynamic selectors, and normalizes native web-search provider tools. Requires `baseConfigHash` (stale-write guard). |
| `patch_config` | Apply RFC-6902 JSON Patch operations to the config (same validation as `write_config`). Requires `baseConfigHash`. |
| `read_config` | Read the current agent config plus freshness metadata (`configHash`, `updatedAt`, `versionId`). Call before every `build_agent`; use its `config` to (re)materialize the config file. |
| `build_agent` | Validate and persist the agent config from a workspace JSON file (`filePath`). Validates the schema, rejects empty instructions and unsupported native web search, enforces no `$fromAI` on stable dynamic selectors, and normalizes native web-search provider tools. Requires `baseConfigHash` (stale-write guard) and the runtime workspace. |
| `search_nodes` | Search the node catalog for **agent-tool-capable** nodes (excludes triggers/hidden/HITL) to add as node tools. |
| `get_node_types` | Get TypeScript type definitions for node types — exact parameter names, enums, credential types, and `@searchListMethod`/`@loadOptionsMethod`/`@builderHint` annotations. |
| `get_resource_locator_options` | Resolve live options for a parameter behind a `resourceLocator` / `loadOptionsMethod` / `loadOptions` routing (stable IDs like Linear teamId, Slack channel, model). Returns each option's `parameterValue` to write into `nodeParameters` (instead of `$fromAI`). |
| `create_skill` | Create a reusable, load-on-demand target-agent skill (name + routing description + structured body). Does not attach it — follow up with `patch_config`/`write_config`. |
| `create_skill` | Create a reusable, load-on-demand target-agent skill (name + routing description + structured body). Does not attach it — add the `{ type: "skill", id }` ref in the config file and `build_agent`. |
| `create_task` | Create a recurring scheduled task (name + objective + cron) for the target agent. Adds a `{ type: "task" }` ref to the config. |
| `build_custom_tool` | Compile and store a custom TypeScript tool (`export default new Tool(...)`), sandbox-validated. Register it via `patch_config` (`{ type: "custom", id }`). |
| `build_custom_tool` | Compile and store a custom TypeScript tool (`export default new Tool(...)`), sandbox-validated. Register it by adding `{ type: "custom", id }` to `tools` in the config file and calling `build_agent`. |
| `list_integration_types` | List chat-platform integration types with their supported credential types and builder guidance. |
| `list_sub_agents` | List published same-project agents that can be attached as sub-agents. |
| `list_agents` | List every agent in the target agents project (no published-only filter; use to discover agents to edit or see what exists). |
| `list_workflows` | List workflows attachable as `type: "workflow"` tools (supported trigger types only). |
| `search_mcp_servers` | Search the MCP registry for servers to attach (returns url, transport, auth, credential type, tools). |
| `verify_mcp_server` | Test connectivity to an MCP server and list its tools before adding it to the config. |
@@ -716,11 +724,12 @@ this one tool. See `docs/agent-builder.md` for the design.
anything (a choice, which credential, which model), use the native `ask-user` tool.
Credentials are listed via the native `credentials` tool (`action: "list"`; +
`ask-user` when several match); the main LLM via `resolve_llm` (+ `ask-user`
fallback), then written with `write_config`.
fallback), then written into the config file and persisted with `build_agent`.
**Targeting:** actions that mutate a specific agent require a bound agent; before one
exists they return a structured error telling the model to `create_agent` first.
`create_agent` is target-less and binds the run to the new agent.
`create_agent` is target-less; it binds the run to the new agent and persists the
binding in thread metadata so follow-up turns keep editing the same agent.
## Other Domain Tools
-1
View File
@@ -66,7 +66,6 @@
"@n8n/workflow-sdk": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@thednp/dommatrix": "^2.0.12",
"fast-json-patch": "catalog:",
"ai": "catalog:",
"csv-parse": "catalog:",
"fast-glob": "catalog:",
@@ -20,16 +20,39 @@ before acting — do not act on an area from memory.
All builder actions run through a single `agent_builder` tool. Invoke an action
as `agent_builder({ action: "<name>", ...args })`. Available actions:
`create_agent`, `read_config`, `write_config`, `patch_config`, `search_nodes`,
`create_agent`, `read_config`, `build_agent`, `search_nodes`,
`get_node_types`, `get_resource_locator_options`, `create_skill`, `create_task`,
`build_custom_tool`, `list_integration_types`, `list_sub_agents`,
`build_custom_tool`, `list_integration_types`, `list_sub_agents`, `list_agents`,
`list_workflows`, `search_mcp_servers`, `verify_mcp_server`, `resolve_llm`.
Credentials are listed via the native `credentials` tool (not an `agent_builder`
action) — call `credentials({ action: "list", type?, name? })`.
Where a reference below names an action (e.g. "call `write_config`"), invoke it
as `agent_builder({ action: "write_config", ... })`.
Where a reference below names an action (e.g. "call `build_agent`"), invoke it
as `agent_builder({ action: "build_agent", ... })`.
## Config editing flow (required)
The agent config is edited as a JSON file in the workspace, then persisted with
`build_agent` — never composed inline:
1. Call `agent_builder({ action: "read_config" })` to get the persisted
`config` and `configHash`.
2. Write the config JSON to a stable workspace file,
`src/agents/<slug>.agent.json` (for an existing agent, write the `config`
returned by `read_config`; for a brand-new agent, write the full new
config). Reuse the same file for the rest of the conversation; re-create it
from `read_config` if the workspace was reset.
3. Make the requested change by editing that file with the workspace file
tools — always the smallest edit that fulfills the request.
4. Call `agent_builder({ action: "build_agent", filePath:
"src/agents/<slug>.agent.json", baseConfigHash: <configHash from step 1> })`.
Pass `baseConfigHash: null` only when `read_config` showed no config yet.
5. On `{ ok: false, stage: "stale" }` the config changed elsewhere: take the
returned `config`/`configHash`, re-apply the edit on top of it in the file,
and call `build_agent` again with the new hash. On validation errors, fix
the file and rebuild. On success, the returned `configHash` is the base for
the next edit.
## Asking the user, credentials, and the LLM
@@ -47,17 +70,19 @@ There are no builder-specific picker cards. When you need input from the user:
`agent_builder({ action: "resolve_llm", provider?, model? })`. If it returns
`ok: true`, use the returned `provider`/`model`/`credentialId`. If it returns
`ok: false` (missing/ambiguous/unsupported), ask the user with `ask-user` using
the returned options, then write the choice via `write_config`.
the returned options, then write the choice into the config file and call
`build_agent`.
## First: make sure an agent is being built
Every action below operates on a single target agent. If no agent is targeted
yet (a fresh request to build an agent, or `read_config` / `write_config` /
`patch_config` reports that no agent is being built), call
yet (a fresh request to build an agent, or `read_config` / `build_agent`
reports that no agent is being built), call
`agent_builder({ action: "create_agent", name: "<short name>" })` once to create
it. That binds the rest of the conversation to the new agent; then call
`agent_builder({ action: "read_config" })` and proceed. Do not create the agent
again if one is already being built or edited.
`agent_builder({ action: "read_config" })` and proceed with the config editing
flow above. Do not create the agent again if one is already being built or
edited — the binding persists across turns.
## Routing
@@ -65,7 +90,7 @@ again if one is already being built or edited.
- **MCP servers** — Use when adding, removing, or updating MCP (Model Context Protocol) servers on the target agent. Load [references/mcp.md](references/mcp.md).
- **Resource locators** — Use when adding or changing node tools with stable dynamic selector fields: resourceLocator, loadOptionsMethod, loadOptions routing, "Name or ID" parameters, teamId, channelId, projectId, calendarId, databaseId, tableId, model selectors, or when write_config/patch_config rejects $fromAI on a dynamic selector. Load [references/resource-locators.md](references/resource-locators.md).
- **Resource locators** — Use when adding or changing node tools with stable dynamic selector fields: resourceLocator, loadOptionsMethod, loadOptions routing, "Name or ID" parameters, teamId, channelId, projectId, calendarId, databaseId, tableId, model selectors, or when build_agent rejects $fromAI on a dynamic selector. Load [references/resource-locators.md](references/resource-locators.md).
- **Sub-agents** — Use when configuring inline or saved sub-agent delegation for the target agent, selecting published same-project sub-agents, or changing subAgents.maxChildren. Load [references/sub-agents.md](references/sub-agents.md).
@@ -27,9 +27,9 @@ Follow these steps in order when adding an MCP server:
"Asking the user, credentials, and the LLM"). Never invent credential IDs.
3. Verify: call `agent_builder` (`action: "verify_mcp_server"`) with `name`,
`url`, `transport`, `authentication`, and (if applicable) `credential`.
4. Write config: call `agent_builder` (`action: "read_config"`), then
`agent_builder` (`action: "patch_config"`) to add the entry to `mcpServers[]`
using the patch pattern below.
4. Write config: follow the config editing flow in SKILL.md — `read_config`,
add the entry to `mcpServers[]` in the config file (initialize the array if
missing), then `agent_builder` (`action: "build_agent"`).
If `search_mcp_servers` returns no matches and the user provides a custom
server URL, skip the search result mapping and continue with manual
@@ -95,12 +95,10 @@ Auth, or None) via the `ask-user` tool. Then resolve a credential
- `multipleHeadersAuth` -> `httpMultipleHeadersAuth`
- `mcpOAuth2Api` -> `mcpOAuth2Api`
### Patch pattern
### Config edit pattern
1. Initialize the array if missing:
`{ "op": "add", "path": "/mcpServers", "value": [] }`
2. Append each server:
`{ "op": "add", "path": "/mcpServers/-", "value": { ... } }`
In the config file, initialize `"mcpServers": []` if the array is missing,
then append each server entry to it. Persist with `build_agent`.
## Gotchas
@@ -12,7 +12,7 @@ locator values that the target agent cannot reliably guess at runtime.
database, table, model, folder, or another "Name or ID" field.
- `get_node_types` shows a parameter with `type: "resourceLocator"`,
`typeOptions.loadOptionsMethod`, or `typeOptions.loadOptions`.
- `write_config` or `patch_config` rejects a node parameter with a dynamic
- `build_agent` rejects a node parameter with a dynamic
selector / `get_resource_locator_options` error.
## Workflow
@@ -67,15 +67,14 @@ locator values that the target agent cannot reliably guess at runtime.
## Recovery From Config Errors
When `write_config` or `patch_config` rejects a dynamic selector using
`$fromAI`:
When `build_agent` rejects a dynamic selector using `$fromAI`:
1. Read the error path to find the offending node parameter.
2. Inspect the node metadata if needed.
3. Resolve the parameter with `agent_builder` (`action:
"get_resource_locator_options"`).
4. Patch the config (`agent_builder` `action: "patch_config"`) by replacing only
that parameter with the returned `parameterValue`.
4. In the config file, replace only that parameter with the returned
`parameterValue`, then call `build_agent` again.
## Example
@@ -46,10 +46,11 @@ subagent.
a follow-up before patching `subAgents.agents`. Do not invent vague routing
guidance.
6. Call `agent_builder` (`action: "read_config"`).
7. Patch selected saved agents into `subAgents.agents`
(`agent_builder` `action: "patch_config"`). Avoid duplicates.
7. Add the selected saved agents to `subAgents.agents` in the config file and
persist with `agent_builder` (`action: "build_agent"`; see the config
editing flow in SKILL.md). Avoid duplicates.
Example patch flow:
Example flow:
1. `agent_builder({ action: "list_sub_agents" })`.
2. If it returns one or more agents and the user has not named exact ones, use
@@ -57,9 +58,9 @@ Example patch flow:
3. If the user's request does not make the routing rule clear, ask when each
selected saved subagent should be used.
4. `agent_builder({ action: "read_config" })`.
5. `agent_builder({ action: "patch_config", ... })` adding selected
5. In the config file, add the selected
`{ "agentId": "<returned-agent-id>", "useWhen": "Use for ..." }` refs to
`/subAgents/agents`.
`subAgents.agents`, then `agent_builder({ action: "build_agent", ... })`.
## Rules
@@ -63,8 +63,9 @@ placeholder or vague skill.
description, never in the body (the body is invisible until the skill loads).
- Call `agent_builder` (`action: "create_skill"`) with `name`, `description`, and `body`.
- The `create_skill` action stores the body only; it does not attach the skill.
- After it returns an id, call `agent_builder` (`action: "read_config"`).
- Use `agent_builder` (`action: "patch_config"` or `"write_config"`) to add `{ "type": "skill", "id": "<returned id>" }` to `skills`.
- After it returns an id, follow the config editing flow in SKILL.md: `read_config`,
add `{ "type": "skill", "id": "<returned id>" }` to `skills` in the config file,
then `agent_builder` (`action: "build_agent"`).
## Rules
@@ -81,8 +81,8 @@ cadence. Never create a placeholder or "refine-it-later" task.
- `create_task` adds a `{ type: "task", id, enabled }` ref to `config.tasks` and
creates the task body. The task is enabled by default and only starts running
once the agent is (re)published; tell the user this when relevant.
- To disable or remove a task, edit `config.tasks` with `agent_builder`
(`action: "patch_config"`; set `enabled: false`, or drop the ref). Changes take
effect on the next publish.
- To disable or remove a task, edit `config.tasks` in the config file (set
`enabled: false`, or drop the ref) and persist with `agent_builder`
(`action: "build_agent"`). Changes take effect on the next publish.
- `create_task` does NOT add tools — if the task needs a tool the agent lacks,
add it to the config yourself first.
@@ -0,0 +1,116 @@
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { ThreadRecord } from '../../../storage/thread-patch';
import type { InstanceAiAgentBuilderService, InstanceAiContext } from '../../../types';
import { resolveAgentBuilderTarget, saveAgentBuilderTarget } from '../agent-target-binding';
import { createReadConfigTool } from '../config-tools';
import { createListAgentsTool, createListWorkflowsTool } from '../creation-tools';
/** In-memory thread store shared across "turns" (fresh contexts). */
function createThreadMemory(initialMetadata: Record<string, unknown> = {}) {
const thread: ThreadRecord = {
id: 'thread-1',
metadata: initialMetadata,
resourceId: 'resource-1',
createdAt: new Date(),
updatedAt: new Date(),
};
return {
getThread: vi.fn().mockResolvedValue(thread),
patchThread: vi.fn().mockImplementation(
async (args: {
update: (current: ThreadRecord) => { metadata?: Record<string, unknown> };
}) => {
const patch = args.update({ ...thread, metadata: { ...(thread.metadata ?? {}) } });
if (patch?.metadata) thread.metadata = patch.metadata;
return await Promise.resolve(thread);
},
),
};
}
function createContext(overrides: Partial<InstanceAiContext> = {}): InstanceAiContext {
return {
userId: 'user-1',
threadId: 'thread-1',
logger: { debug: vi.fn(), warn: vi.fn() },
...overrides,
} as unknown as InstanceAiContext;
}
const TARGET = { agentId: 'agent-1', projectId: 'project-1' };
describe('agent-builder target binding', () => {
it('round-trips the target through thread metadata across contexts', async () => {
const threadMemory = createThreadMemory();
await saveAgentBuilderTarget(createContext({ threadMemory }), TARGET);
// A fresh context (next turn) resolves the persisted target and hydrates itself.
const nextTurn = createContext({ threadMemory });
await expect(resolveAgentBuilderTarget(nextTurn)).resolves.toEqual(TARGET);
expect(nextTurn.agentBuilderTarget).toEqual(TARGET);
});
it('prefers the in-memory context target over the persisted binding', async () => {
const threadMemory = createThreadMemory({
instanceAiAgentBuilderTarget: { agentId: 'agent-old', projectId: 'project-1' },
});
const context = createContext({ threadMemory, agentBuilderTarget: TARGET });
await expect(resolveAgentBuilderTarget(context)).resolves.toEqual(TARGET);
});
it('returns undefined for missing or invalid metadata', async () => {
const missing = createContext({ threadMemory: createThreadMemory() });
await expect(resolveAgentBuilderTarget(missing)).resolves.toBeUndefined();
const invalid = createContext({
threadMemory: createThreadMemory({ instanceAiAgentBuilderTarget: { agentId: 42 } }),
});
await expect(resolveAgentBuilderTarget(invalid)).resolves.toBeUndefined();
});
it('falls back to in-memory storage when thread memory is unavailable', async () => {
const context = createContext();
await saveAgentBuilderTarget(context, TARGET);
context.agentBuilderTarget = undefined;
await expect(resolveAgentBuilderTarget(context)).resolves.toEqual(TARGET);
});
it('lets read_config resolve the agent in a later turn from the persisted binding', async () => {
const threadMemory = createThreadMemory({ instanceAiAgentBuilderTarget: TARGET });
const getConfigSnapshot = vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null });
const context = createContext({
threadMemory,
agentBuilderService: { getConfigSnapshot } as unknown as InstanceAiAgentBuilderService,
});
const result = await executeTool<{ ok: boolean }>(createReadConfigTool(context), {}, {});
expect(result.ok).toBe(true);
expect(getConfigSnapshot).toHaveBeenCalledWith('agent-1', 'project-1');
});
it('scopes list_workflows and list_agents to the binding project on a later turn', async () => {
// Binding says project-1; the fresh run's context.projectId is a different
// project. Both listing tools must use the binding's project, not the run's.
const threadMemory = createThreadMemory({ instanceAiAgentBuilderTarget: TARGET });
const listAttachableWorkflows = vi.fn().mockResolvedValue([]);
const listAllProjectAgents = vi.fn().mockResolvedValue([]);
const service = {
listAttachableWorkflows,
listAllProjectAgents,
} as unknown as InstanceAiAgentBuilderService;
const context = createContext({
threadMemory,
projectId: 'other-project',
agentBuilderService: service,
});
await executeTool(createListWorkflowsTool(context), {}, {});
expect(listAttachableWorkflows).toHaveBeenCalledWith('project-1', undefined);
await executeTool(createListAgentsTool(context), {}, {});
expect(listAllProjectAgents).toHaveBeenCalledWith('project-1');
});
});
@@ -0,0 +1,232 @@
import type { AgentJsonConfig } from '@n8n/api-types';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { InstanceAiAgentBuilderService, InstanceAiContext } from '../../../types';
import { createBuildAgentTool } from '../build-agent.tool';
import { getAgentConfigHash } from '../config-helpers';
const FILE_PATH = 'src/agents/support-agent.agent.json';
const VALID_CONFIG: AgentJsonConfig = {
name: 'Support Agent',
model: 'anthropic/claude-sonnet-4-5',
credential: 'cred-1',
instructions: 'Answer support questions.',
};
function createService(
overrides: Partial<InstanceAiAgentBuilderService> = {},
): InstanceAiAgentBuilderService {
return {
createAgent: vi.fn(),
getConfigSnapshot: vi.fn(),
updateConfig: vi.fn(),
createSkill: vi.fn(),
createTask: vi.fn(),
describeCustomTool: vi.fn(),
buildCustomTool: vi.fn(),
listChatIntegrations: vi.fn(),
listProjectAgents: vi.fn(),
listAllProjectAgents: vi.fn(),
listModels: vi.fn(),
searchMcpServers: vi.fn(),
verifyMcpServer: vi.fn(),
searchNodes: vi.fn(),
resolveResourceLocatorOptions: vi.fn(),
listAttachableWorkflows: vi.fn(),
...overrides,
};
}
function createContext(
service: InstanceAiAgentBuilderService,
options: { files?: Record<string, string>; workspace?: boolean } = {},
): InstanceAiContext {
const files = options.files ?? {};
const workspace =
options.workspace === false
? undefined
: {
filesystem: {
readFile: vi.fn(async (path: string) => {
if (path in files) return await Promise.resolve(files[path]);
throw new Error(`ENOENT: ${path}`);
}),
writeFile: vi.fn(),
},
};
return {
userId: 'user-1',
agentBuilderService: service,
agentBuilderTarget: { agentId: 'agent-1', projectId: 'project-1' },
workspace,
logger: { debug: vi.fn(), warn: vi.fn() },
// nodeTypesProvider intentionally omitted → dynamic-selector check is skipped.
} as unknown as InstanceAiContext;
}
describe('build_agent tool', () => {
it('persists a valid config file when there is no prior config', async () => {
const updateConfig = vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't2', versionId: 'v2' });
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null }),
updateConfig,
});
const context = createContext(service, {
files: { [FILE_PATH]: JSON.stringify(VALID_CONFIG) },
});
const result = await executeTool<{ ok: boolean; configHash?: string }>(
createBuildAgentTool(context),
{ filePath: FILE_PATH, baseConfigHash: null },
{},
);
expect(result.ok).toBe(true);
expect(result.configHash).toBe(getAgentConfigHash(VALID_CONFIG));
// Native-web-search normalization may enrich the persisted config; assert the core fields.
expect(updateConfig).toHaveBeenCalledWith(
'agent-1',
'project-1',
expect.objectContaining({
name: VALID_CONFIG.name,
model: VALID_CONFIG.model,
credential: VALID_CONFIG.credential,
instructions: VALID_CONFIG.instructions,
}),
);
});
it('updates an existing config with a fresh baseConfigHash', async () => {
const updated = { ...VALID_CONFIG, instructions: 'Updated instructions.' };
const updateConfig = vi
.fn()
.mockResolvedValue({ config: updated, updatedAt: 't3', versionId: 'v3' });
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't', versionId: 'v1' }),
updateConfig,
});
const context = createContext(service, { files: { [FILE_PATH]: JSON.stringify(updated) } });
const result = await executeTool<{ ok: boolean }>(
createBuildAgentTool(context),
{ filePath: FILE_PATH, baseConfigHash: getAgentConfigHash(VALID_CONFIG) },
{},
);
expect(result.ok).toBe(true);
expect(updateConfig).toHaveBeenCalledWith(
'agent-1',
'project-1',
expect.objectContaining({ instructions: 'Updated instructions.' }),
);
});
it('rejects a stale baseConfigHash without persisting', async () => {
const updateConfig = vi.fn();
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't', versionId: 'v1' }),
updateConfig,
});
const context = createContext(service, {
files: { [FILE_PATH]: JSON.stringify(VALID_CONFIG) },
});
const result = await executeTool<{ ok: boolean; stage?: string }>(
createBuildAgentTool(context),
{ filePath: FILE_PATH, baseConfigHash: 'stale-hash' },
{},
);
expect(result).toMatchObject({ ok: false, stage: 'stale' });
expect(updateConfig).not.toHaveBeenCalled();
});
it('rejects invalid JSON with a parse stage', async () => {
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null }),
});
const context = createContext(service, { files: { [FILE_PATH]: '{ not json' } });
const result = await executeTool<{ ok: boolean; stage?: string }>(
createBuildAgentTool(context),
{ filePath: FILE_PATH, baseConfigHash: null },
{},
);
expect(result).toMatchObject({ ok: false, stage: 'parse' });
});
it('rejects empty instructions without persisting', async () => {
const updateConfig = vi.fn();
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null }),
updateConfig,
});
const context = createContext(service, {
files: { [FILE_PATH]: JSON.stringify({ ...VALID_CONFIG, instructions: ' ' }) },
});
const result = await executeTool<{
ok: boolean;
stage?: string;
errors?: Array<{ path: string }>;
}>(createBuildAgentTool(context), { filePath: FILE_PATH, baseConfigHash: null }, {});
expect(result.ok).toBe(false);
expect(result.stage).toBe('validation');
expect(result.errors?.some((e) => e.path === '/instructions')).toBe(true);
expect(updateConfig).not.toHaveBeenCalled();
});
it('reports a structured error when the workspace is unavailable', async () => {
const context = createContext(createService(), { workspace: false });
const result = await executeTool<{ ok: boolean; errors?: Array<{ message: string }> }>(
createBuildAgentTool(context),
{ filePath: FILE_PATH, baseConfigHash: null },
{},
);
expect(result.ok).toBe(false);
expect(result.errors?.[0].message).toContain('workspace is unavailable');
});
it('reports a structured error when the config file is missing', async () => {
const context = createContext(createService(), { files: {} });
const result = await executeTool<{ ok: boolean; errors?: Array<{ message: string }> }>(
createBuildAgentTool(context),
{ filePath: FILE_PATH, baseConfigHash: null },
{},
);
expect(result.ok).toBe(false);
expect(result.errors?.[0].message).toContain('Agent config file not found');
});
it('rejects a path escaping the workspace root', async () => {
const context = createContext(createService(), { files: {} });
const result = await executeTool<{ ok: boolean; errors?: Array<{ message: string }> }>(
createBuildAgentTool(context),
{ filePath: '../outside.json', baseConfigHash: null },
{},
);
expect(result.ok).toBe(false);
expect(result.errors?.[0].message).toContain('workspace root');
});
});
@@ -3,11 +3,7 @@ import type { AgentJsonConfig } from '@n8n/api-types';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { InstanceAiAgentBuilderService, InstanceAiContext } from '../../../types';
import { getAgentConfigHash } from '../config-helpers';
import {
createPatchConfigTool,
createReadConfigTool,
createWriteConfigTool,
} from '../config-tools';
import { createReadConfigTool } from '../config-tools';
const VALID_CONFIG: AgentJsonConfig = {
name: 'Support Agent',
@@ -29,6 +25,7 @@ function createService(
buildCustomTool: vi.fn(),
listChatIntegrations: vi.fn(),
listProjectAgents: vi.fn(),
listAllProjectAgents: vi.fn(),
listModels: vi.fn(),
searchMcpServers: vi.fn(),
verifyMcpServer: vi.fn(),
@@ -75,173 +72,4 @@ describe('agent-builder config tools', () => {
expect(result.ok).toBe(false);
});
});
describe('write_config', () => {
it('persists a valid config when there is no prior config', async () => {
const updateConfig = vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't2', versionId: 'v2' });
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null }),
updateConfig,
});
const result = await executeTool<{ ok: boolean; configHash?: string }>(
createWriteConfigTool(createContext(service)),
{ json: JSON.stringify(VALID_CONFIG), baseConfigHash: null },
{},
);
expect(result.ok).toBe(true);
expect(result.configHash).toBe(getAgentConfigHash(VALID_CONFIG));
// Native-web-search normalization may enrich the persisted config; assert the core fields.
expect(updateConfig).toHaveBeenCalledWith(
'agent-1',
'project-1',
expect.objectContaining({
name: VALID_CONFIG.name,
model: VALID_CONFIG.model,
credential: VALID_CONFIG.credential,
instructions: VALID_CONFIG.instructions,
}),
);
});
it('rejects a stale baseConfigHash without persisting', async () => {
const updateConfig = vi.fn();
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't', versionId: 'v1' }),
updateConfig,
});
const result = await executeTool<{ ok: boolean; stage?: string }>(
createWriteConfigTool(createContext(service)),
{ json: JSON.stringify(VALID_CONFIG), baseConfigHash: 'stale-hash' },
{},
);
expect(result).toMatchObject({ ok: false, stage: 'stale' });
expect(updateConfig).not.toHaveBeenCalled();
});
it('rejects empty instructions', async () => {
const updateConfig = vi.fn();
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null }),
updateConfig,
});
const result = await executeTool<{ ok: boolean; errors?: Array<{ path: string }> }>(
createWriteConfigTool(createContext(service)),
{ json: JSON.stringify({ ...VALID_CONFIG, instructions: ' ' }), baseConfigHash: null },
{},
);
expect(result.ok).toBe(false);
expect(result.errors?.some((e) => e.path === '/instructions')).toBe(true);
expect(updateConfig).not.toHaveBeenCalled();
});
it('rejects invalid JSON', async () => {
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: null, updatedAt: null, versionId: null }),
});
const result = await executeTool<{ ok: boolean }>(
createWriteConfigTool(createContext(service)),
{ json: '{ not json', baseConfigHash: null },
{},
);
expect(result.ok).toBe(false);
});
});
describe('patch_config', () => {
it('applies a replace operation and persists the patched config', async () => {
const patchedConfig: AgentJsonConfig = {
...VALID_CONFIG,
instructions: 'Updated instructions.',
};
const updateConfig = vi
.fn()
.mockResolvedValue({ config: patchedConfig, updatedAt: 't3', versionId: 'v3' });
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't', versionId: 'v1' }),
updateConfig,
});
const result = await executeTool<{ ok: boolean }>(
createPatchConfigTool(createContext(service)),
{
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Updated instructions.' },
]),
baseConfigHash: getAgentConfigHash(VALID_CONFIG),
},
{},
);
expect(result.ok).toBe(true);
expect(updateConfig).toHaveBeenCalledWith(
'agent-1',
'project-1',
expect.objectContaining({ instructions: 'Updated instructions.' }),
);
});
it('rejects a stale baseConfigHash', async () => {
const updateConfig = vi.fn();
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't', versionId: 'v1' }),
updateConfig,
});
const result = await executeTool<{ ok: boolean; stage?: string }>(
createPatchConfigTool(createContext(service)),
{
operations: JSON.stringify([{ op: 'replace', path: '/instructions', value: 'x' }]),
baseConfigHash: 'stale',
},
{},
);
expect(result).toMatchObject({ ok: false, stage: 'stale' });
expect(updateConfig).not.toHaveBeenCalled();
});
it('returns a structured patch error for a prototype-pollution op instead of throwing', async () => {
const updateConfig = vi.fn();
const service = createService({
getConfigSnapshot: vi
.fn()
.mockResolvedValue({ config: VALID_CONFIG, updatedAt: 't', versionId: 'v1' }),
updateConfig,
});
// The __proto__ guard in fast-json-patch throws a plain TypeError (not a
// JsonPatchError); without a try/catch this crashes the handler.
const result = await executeTool<{ ok: boolean; stage?: string }>(
createPatchConfigTool(createContext(service)),
{
operations: JSON.stringify([{ op: 'add', path: '/__proto__/polluted', value: true }]),
baseConfigHash: getAgentConfigHash(VALID_CONFIG),
},
{},
);
expect(result).toMatchObject({ ok: false, stage: 'patch' });
expect(updateConfig).not.toHaveBeenCalled();
});
});
});
@@ -16,6 +16,7 @@ function createService(
buildCustomTool: vi.fn(),
listChatIntegrations: vi.fn(),
listProjectAgents: vi.fn(),
listAllProjectAgents: vi.fn(),
listModels: vi.fn(),
searchMcpServers: vi.fn(),
verifyMcpServer: vi.fn(),
@@ -80,6 +81,36 @@ describe('create_agent tool', () => {
expect(service.getConfigSnapshot).toHaveBeenCalledWith('agent-9', 'project-7');
});
it('persists the target binding to thread metadata', async () => {
const service = createService({
createAgent: vi
.fn()
.mockResolvedValue({ agentId: 'agent-9', projectId: 'project-7', name: 'Triage' }),
});
const patchThread = vi.fn().mockResolvedValue({ id: 'thread-1' });
const context = {
userId: 'user-1',
projectId: 'project-7',
agentBuilderService: service,
threadId: 'thread-1',
threadMemory: { patchThread },
logger: { debug: vi.fn(), warn: vi.fn() },
} as unknown as InstanceAiContext;
await executeTool(createCreateAgentTool(context), { name: 'Triage' }, {});
expect(patchThread).toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-1' }));
const { update } = patchThread.mock.calls[0][0] as {
update: (current: { metadata: Record<string, unknown> }) => {
metadata: Record<string, unknown>;
};
};
expect(update({ metadata: {} }).metadata.instanceAiAgentBuilderTarget).toEqual({
agentId: 'agent-9',
projectId: 'project-7',
});
});
it('reports an error when the service throws', async () => {
const service = createService({
createAgent: vi.fn().mockRejectedValue(new Error('no project access')),
@@ -15,6 +15,7 @@ function createService(
buildCustomTool: vi.fn(),
listChatIntegrations: vi.fn(),
listProjectAgents: vi.fn(),
listAllProjectAgents: vi.fn(),
listModels: vi.fn(),
searchMcpServers: vi.fn(),
verifyMcpServer: vi.fn(),
@@ -15,6 +15,7 @@ function createService(
buildCustomTool: vi.fn(),
listChatIntegrations: vi.fn(),
listProjectAgents: vi.fn(),
listAllProjectAgents: vi.fn(),
listModels: vi.fn(),
searchMcpServers: vi.fn(),
verifyMcpServer: vi.fn(),
@@ -81,6 +82,42 @@ describe('agent_builder router', () => {
expect(result.workflows).toHaveLength(1);
});
it('routes list_agents to the host adapter with the resolved project', async () => {
const service = createService({
listAllProjectAgents: vi.fn().mockResolvedValue([{ agentId: 'agent-2', name: 'Triage' }]),
});
const result = await executeTool<{ agents: Array<{ agentId: string; name: string }> }>(
createAgentBuilderRouterTool(createContext(service)),
{ action: 'list_agents' },
{},
);
expect(service.listAllProjectAgents).toHaveBeenCalledWith('project-1');
expect(result.agents).toEqual([{ agentId: 'agent-2', name: 'Triage' }]);
});
it('routes build_agent to the build-agent handler', async () => {
// No workspace on the context → the handler reports it unavailable.
const result = await executeTool<{ ok: boolean; errors?: Array<{ message: string }> }>(
createAgentBuilderRouterTool(createContext(createService())),
{ action: 'build_agent', filePath: 'src/agents/a.agent.json', baseConfigHash: null },
{},
);
expect(result.ok).toBe(false);
expect(result.errors?.[0].message).toContain('workspace');
});
it('no longer exposes write_config or patch_config as actions', async () => {
for (const action of ['write_config', 'patch_config']) {
const result = await executeTool<{ ok: boolean; errors?: Array<{ message: string }> }>(
createAgentBuilderRouterTool(createContext(createService())),
{ action },
{},
);
expect(result.ok).toBe(false);
expect(result.errors?.[0].message).toContain('Unknown agent_builder action');
}
});
it('rejects an unknown action', async () => {
const result = await executeTool<{ ok: boolean; errors?: Array<{ message: string }> }>(
createAgentBuilderRouterTool(createContext(createService())),
@@ -0,0 +1,83 @@
/**
* Thread-persisted agent-builder target binding. Mirrors the workflow source
* file bindings, but as a single thread-level record: one agent is being
* built/edited per thread, matching `context.agentBuilderTarget` semantics.
* Persisting the target in thread metadata lets follow-up turns keep editing
* the same agent instead of creating a new one.
*/
import { z } from 'zod';
import { getThread, patchThread } from '../../storage/thread-patch';
import type { InstanceAiContext } from '../../types';
const METADATA_KEY = 'instanceAiAgentBuilderTarget';
const agentBuilderTargetSchema = z.object({
agentId: z.string(),
projectId: z.string(),
});
export type AgentBuilderTarget = z.infer<typeof agentBuilderTargetSchema>;
const fallbackTargets = new WeakMap<InstanceAiContext, AgentBuilderTarget>();
function parseTarget(raw: unknown): AgentBuilderTarget | undefined {
const parsed = agentBuilderTargetSchema.safeParse(raw);
return parsed.success ? parsed.data : undefined;
}
async function readThreadTarget(
context: InstanceAiContext,
): Promise<AgentBuilderTarget | undefined> {
if (!context.threadMemory || !context.threadId) return undefined;
try {
const thread = await getThread(context.threadMemory, context.threadId);
return parseTarget(thread?.metadata?.[METADATA_KEY]);
} catch (error) {
context.logger?.debug('Failed to read agent-builder target from thread metadata', {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
/**
* Resolve the active build target: in-memory context first (current run),
* then the thread-persisted binding (previous turns). Hydrates the context so
* subsequent calls in the same run skip the metadata read.
*/
export async function resolveAgentBuilderTarget(
context: InstanceAiContext,
): Promise<AgentBuilderTarget | undefined> {
if (context.agentBuilderTarget) return context.agentBuilderTarget;
const target = (await readThreadTarget(context)) ?? fallbackTargets.get(context);
if (target) context.agentBuilderTarget = target;
return target;
}
/** Persist the build target to thread metadata (in-memory fallback when unavailable). */
export async function saveAgentBuilderTarget(
context: InstanceAiContext,
target: AgentBuilderTarget,
): Promise<void> {
if (context.threadMemory && context.threadId) {
try {
const updatedThread = await patchThread(context.threadMemory, {
threadId: context.threadId,
update: ({ metadata = {} }) => ({
metadata: { ...metadata, [METADATA_KEY]: target },
}),
});
if (updatedThread) return;
} catch (error) {
context.logger?.warn('Failed to persist agent-builder target to thread metadata', {
agentId: target.agentId,
error: error instanceof Error ? error.message : String(error),
});
}
}
fallbackTargets.set(context, target);
}
@@ -0,0 +1,74 @@
/**
* build_agent persist the agent config from a JSON file in the runtime
* workspace. The file is the editable source of truth (mirroring how
* build-workflow consumes `.workflow.ts` sources); validation and persistence
* stay host-side via the shared config pipeline. No sandbox execution happens:
* the workspace is only the file medium.
*/
import { Tool } from '@n8n/agents';
import { z } from 'zod';
import { baseConfigHashSchema, persistConfigJson } from './config-tools';
import type { InstanceAiContext } from '../../types';
import { readWorkspaceFile } from '../../workspace/workspace-files';
import { normalizeWorkspaceRelativePath } from '../../workspace/workspace-paths';
import { AGENT_BUILDER_TOOL_IDS } from '../tool-ids';
export const buildAgentInputSchema = z.object({
filePath: z
.string()
.describe(
'Workspace-relative path to the agent config JSON file, e.g. src/agents/support-triage.agent.json',
),
baseConfigHash: baseConfigHashSchema,
});
function rootError(message: string) {
return { ok: false as const, errors: [{ path: '(root)', message }] };
}
export function createBuildAgentTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.BUILD_AGENT)
.description(
'Validate and persist the agent configuration from a JSON file in the workspace. ' +
'Write the complete agent config JSON to a file (convention: src/agents/<slug>.agent.json), ' +
'edit it with the file tools, then call this with its filePath. ' +
'Requires baseConfigHash from the immediately preceding read_config result, or from a stale ' +
'retry response; null only when the agent has no config yet. ' +
'Do not use a configHash copied from the prompt snapshot. ' +
'Returns { ok: true, config, configHash, updatedAt, versionId } on success or ' +
'{ ok: false, stage, errors } with path, message fields on failure. ' +
'stage is "parse", "stale", "schema", or "validation".',
)
.input(buildAgentInputSchema)
.handler(async ({ filePath, baseConfigHash }) => {
if (!context.workspace) {
return rootError(
'The runtime workspace is unavailable, so agent config files cannot be read. ' +
'Agent building requires the sandbox workspace.',
);
}
let normalizedFilePath: string;
try {
normalizedFilePath = normalizeWorkspaceRelativePath(filePath, {
resourceLabel: 'Agent config file',
});
} catch (e) {
return rootError(e instanceof Error ? e.message : String(e));
}
const json = await readWorkspaceFile(context.workspace, normalizedFilePath, {
logger: context.logger,
resourceLabel: 'Agent config file',
});
if (json === null) {
return rootError(
`Agent config file not found: ${normalizedFilePath}. Write the complete config JSON to the file first.`,
);
}
return await persistConfigJson(context, json, baseConfigHash);
})
.build();
}
@@ -1,9 +1,8 @@
/**
* read_config / write_config / patch_config the core agent-config mutation
* tools. Validation, freshness (hash) checks, RFC-6902 patching, `$fromAI`
* dynamic-selector enforcement, and native-web-search reject/normalize are
* reimplemented here; only the snapshot read and persist go through
* `context.agentBuilderService`.
* read_config plus the shared config persistence pipeline used by build_agent.
* Validation, freshness (hash) checks, `$fromAI` dynamic-selector enforcement,
* and native-web-search reject/normalize are reimplemented here; only the
* snapshot read and persist go through `context.agentBuilderService`.
*/
import { Tool } from '@n8n/agents';
import {
@@ -20,16 +19,16 @@ import {
tryParseConfigJson,
type AgentJsonConfig,
} from '@n8n/api-types';
import { applyPatch, deepClone, validate, type Operation } from 'fast-json-patch';
import { z } from 'zod';
import { resolveAgentBuilderTarget } from './agent-target-binding';
import { STALE_CONFIG_ERROR, withConfigHash, type HashedSnapshot } from './config-helpers';
import type { InstanceAiAgentBuilderService, InstanceAiContext } from '../../types';
import { AGENT_BUILDER_TOOL_IDS } from '../tool-ids';
/** LLM-facing follow-up guidance for the instance-ai agent-builder tools. */
const INSTANCE_AI_CONFIG_MESSAGES: AgentConfigValidationMessages = {
emptyInstructionsFollowUp: 'calling write_config or patch_config again.',
emptyInstructionsFollowUp: 'editing the config file and calling build_agent again.',
dynamicSelectorFollowUp:
'Load the agent-builder resource-locators reference, resolve a credential if missing ' +
'(credentials tool, action "list"), then call get_resource_locator_options and write the ' +
@@ -43,13 +42,15 @@ interface AgentBuilderDeps {
nodeTypesProvider: InstanceAiContext['nodeTypesProvider'];
}
/** Resolve the agent-builder deps from context, or null when not configured. */
function resolveDeps(context: InstanceAiContext): AgentBuilderDeps | null {
if (!context.agentBuilderService || !context.agentBuilderTarget) return null;
/** Resolve the agent-builder deps from context or thread binding, or null when not configured. */
async function resolveDeps(context: InstanceAiContext): Promise<AgentBuilderDeps | null> {
if (!context.agentBuilderService) return null;
const target = await resolveAgentBuilderTarget(context);
if (!target) return null;
return {
service: context.agentBuilderService,
agentId: context.agentBuilderTarget.agentId,
projectId: context.agentBuilderTarget.projectId,
agentId: target.agentId,
projectId: target.projectId,
nodeTypesProvider: context.nodeTypesProvider,
};
}
@@ -65,69 +66,35 @@ const NOT_CONFIGURED = {
],
};
/** JSON value — required (no `undefined`) so patch `value` matches `Operation`. */
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(jsonValueSchema),
z.record(jsonValueSchema),
]),
);
const readConfigInputSchema = z.object({});
/** RFC-6902 patch operations, validated structurally so no `as` cast is needed. */
const jsonPatchOperationSchema = z.discriminatedUnion('op', [
z.object({ op: z.literal('add'), path: z.string(), value: jsonValueSchema }),
z.object({ op: z.literal('remove'), path: z.string() }),
z.object({ op: z.literal('replace'), path: z.string(), value: jsonValueSchema }),
z.object({ op: z.literal('move'), from: z.string(), path: z.string() }),
z.object({ op: z.literal('copy'), from: z.string(), path: z.string() }),
z.object({ op: z.literal('test'), path: z.string(), value: jsonValueSchema }),
]);
const jsonPatchSchema = z.array(jsonPatchOperationSchema);
export const readConfigInputSchema = z.object({});
const baseConfigHashSchema = z
export const baseConfigHashSchema = z
.string()
.nullable()
.describe(
'configHash from the immediately preceding read_config result; null only if no config exists',
);
export const writeConfigInputSchema = z.object({
json: z.string().describe('Complete agent configuration as a JSON string'),
baseConfigHash: baseConfigHashSchema,
});
export const patchConfigInputSchema = z.object({
operations: z.string().describe('RFC 6902 JSON Patch operations array as a JSON string'),
baseConfigHash: baseConfigHashSchema,
});
async function getHashedSnapshot(deps: AgentBuilderDeps): Promise<HashedSnapshot> {
return withConfigHash(await deps.service.getConfigSnapshot(deps.agentId, deps.projectId));
}
/**
* Run the shared validation gauntlet on a candidate config and persist it.
* Returns the success/failure tool response.
* Returns the success/failure tool response. Post-schema content rejections
* are reported as stage `'validation'`.
*/
async function validateAndPersist(
deps: AgentBuilderDeps,
candidate: AgentJsonConfig,
previousConfig: AgentJsonConfig | null,
failureStage: string,
) {
const empty = rejectIfEmptyInstructions(candidate, INSTANCE_AI_CONFIG_MESSAGES);
if (empty) return { ok: false as const, stage: failureStage, errors: empty };
if (empty) return { ok: false as const, stage: 'validation', errors: empty };
const unsupportedWebSearch = rejectIfUnsupportedNativeWebSearch(candidate);
if (unsupportedWebSearch) {
return { ok: false as const, stage: failureStage, errors: unsupportedWebSearch };
return { ok: false as const, stage: 'validation', errors: unsupportedWebSearch };
}
const dynamicSelector = rejectIfDynamicSelectorUsesFromAi(
@@ -137,7 +104,7 @@ async function validateAndPersist(
INSTANCE_AI_CONFIG_MESSAGES,
);
if (dynamicSelector) {
return { ok: false as const, stage: failureStage, errors: dynamicSelector };
return { ok: false as const, stage: 'validation', errors: dynamicSelector };
}
// Seed the "native model gets web search by default" ergonomic as an explicit
@@ -155,15 +122,15 @@ async function validateAndPersist(
} catch (e) {
return {
ok: false as const,
stage: failureStage,
stage: 'validation',
errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }],
};
}
}
/** read_config handler — usable standalone or via the agent_builder router. */
export async function handleReadConfig(context: InstanceAiContext) {
const deps = resolveDeps(context);
async function handleReadConfig(context: InstanceAiContext) {
const deps = await resolveDeps(context);
if (!deps) return NOT_CONFIGURED;
try {
return { ok: true as const, ...(await getHashedSnapshot(deps)) };
@@ -175,16 +142,20 @@ export async function handleReadConfig(context: InstanceAiContext) {
}
}
/** write_config handler — usable standalone or via the agent_builder router. */
export async function handleWriteConfig(
/**
* Parse, freshness-check, validate, and persist a complete agent config JSON
* string. Shared pipeline behind `build_agent` (the file content is the JSON).
*/
export async function persistConfigJson(
context: InstanceAiContext,
input: z.infer<typeof writeConfigInputSchema>,
json: string,
baseConfigHash: string | null,
) {
const deps = resolveDeps(context);
const deps = await resolveDeps(context);
if (!deps) return NOT_CONFIGURED;
const parsed = tryParseConfigJson(input.json);
if (!parsed.ok) return { ok: false as const, errors: parsed.errors };
const parsed = tryParseConfigJson(json);
if (!parsed.ok) return { ok: false as const, stage: 'parse', errors: parsed.errors };
let snapshot: HashedSnapshot;
try {
@@ -196,92 +167,16 @@ export async function handleWriteConfig(
errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }],
};
}
if (input.baseConfigHash !== snapshot.configHash) {
if (baseConfigHash !== snapshot.configHash) {
return { ok: false as const, stage: 'stale', errors: [STALE_CONFIG_ERROR], ...snapshot };
}
const zodResult = RunnableAgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(parsed.data));
if (!zodResult.success) {
return { ok: false as const, errors: formatZodErrors(zodResult.error) };
}
return await validateAndPersist(deps, zodResult.data, snapshot.config, 'schema');
}
/** patch_config handler — usable standalone or via the agent_builder router. */
export async function handlePatchConfig(
context: InstanceAiContext,
input: z.infer<typeof patchConfigInputSchema>,
) {
const deps = resolveDeps(context);
if (!deps) return NOT_CONFIGURED;
const parsedJson = tryParseConfigJson(input.operations);
if (!parsedJson.ok) return { ok: false as const, stage: 'parse', errors: parsedJson.errors };
const parsedOps = jsonPatchSchema.safeParse(parsedJson.data);
if (!parsedOps.success) {
return { ok: false as const, stage: 'parse', errors: formatZodErrors(parsedOps.error) };
}
const ops: Operation[] = parsedOps.data;
let snapshot: HashedSnapshot;
try {
snapshot = await getHashedSnapshot(deps);
} catch (e) {
return {
ok: false as const,
stage: 'stale',
errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }],
};
}
if (input.baseConfigHash !== snapshot.configHash) {
return { ok: false as const, stage: 'stale', errors: [STALE_CONFIG_ERROR], ...snapshot };
}
if (!snapshot.config) {
return {
ok: false as const,
stage: 'patch',
errors: [{ path: '(root)', message: 'Agent has no JSON config yet.' }],
};
}
let patched: unknown;
try {
const patchError = validate(ops, snapshot.config);
if (patchError) {
// `JsonPatchError.operation` is typed `any` upstream — parse defensively.
const failedOp = z.object({ path: z.string() }).safeParse(patchError.operation);
return {
ok: false as const,
stage: 'patch',
errors: [
{
path: failedOp.success ? failedOp.data.path : '(root)',
message: patchError.message ?? 'Invalid patch operation',
},
],
};
}
patched = applyPatch(deepClone(snapshot.config), ops).newDocument;
} catch (e) {
// `validate`/`applyPatch` return a JsonPatchError for RFC-6902 violations but
// re-throw anything else — notably the TypeError from the built-in
// __proto__/constructor prototype-pollution guard. Surface those as a
// structured patch error instead of letting them crash the handler.
return {
ok: false as const,
stage: 'patch',
errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }],
};
}
const zodResult = RunnableAgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(patched));
if (!zodResult.success) {
return { ok: false as const, stage: 'schema', errors: formatZodErrors(zodResult.error) };
}
return await validateAndPersist(deps, zodResult.data, snapshot.config, 'schema');
return await validateAndPersist(deps, zodResult.data, snapshot.config);
}
export function createReadConfigTool(context: InstanceAiContext) {
@@ -289,39 +184,10 @@ export function createReadConfigTool(context: InstanceAiContext) {
.description(
'Read the latest persisted agent configuration and freshness metadata. ' +
'Returns { ok: true, config, configHash, updatedAt, versionId }. ' +
'Call this before every write_config or patch_config and use configHash as baseConfigHash.',
'Call this before every build_agent and use configHash as baseConfigHash. Use the returned ' +
'config to (re)write the agent config file in the workspace when editing an existing agent.',
)
.input(readConfigInputSchema)
.handler(async () => await handleReadConfig(context))
.build();
}
export function createWriteConfigTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.WRITE_CONFIG)
.description(
'Create or replace the agent configuration by writing a complete JSON string. ' +
'Requires baseConfigHash from the immediately preceding read_config result, or from a stale retry response. ' +
'Do not use a configHash copied from the prompt snapshot. ' +
'Returns { ok: true, config, configHash, updatedAt, versionId } on success or ' +
'{ ok: false, stage, errors } with path, message fields on failure.',
)
.input(writeConfigInputSchema)
.handler(async (input) => await handleWriteConfig(context, input))
.build();
}
export function createPatchConfigTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.PATCH_CONFIG)
.description(
'Apply RFC 6902 JSON Patch operations to the current agent configuration. ' +
'Pass an array of patch operations as a JSON string. ' +
'Requires baseConfigHash from the immediately preceding read_config result, or from a stale retry response. ' +
'Do not use a configHash copied from the prompt snapshot. ' +
'Supported ops: add, remove, replace, move, copy, test. ' +
'Returns { ok: true, ... } on success or { ok: false, stage, errors } on failure. ' +
'stage is "parse", "stale", "patch", or "schema".',
)
.input(patchConfigInputSchema)
.handler(async (input) => await handlePatchConfig(context, input))
.build();
}
@@ -1,13 +1,14 @@
/**
* create_agent create a brand-new (empty) n8n Agent and bind the rest of the
* run to it. Target-less: available before any agent exists. On success it sets
* `context.agentBuilderTarget` so subsequent config tools resolve the new agent
* within the same run; the host adapter is responsible for persisting the
* binding to thread state so later turns stay targeted.
* conversation to it. Target-less: available before any agent exists. On
* success it sets `context.agentBuilderTarget` for the current run and
* persists the binding to thread metadata so later turns keep editing the
* same agent.
*/
import { Tool } from '@n8n/agents';
import { z } from 'zod';
import { saveAgentBuilderTarget } from './agent-target-binding';
import type { InstanceAiContext } from '../../types';
import { AGENT_BUILDER_TOOL_IDS } from '../tool-ids';
@@ -15,10 +16,11 @@ export function createCreateAgentTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.CREATE_AGENT)
.description(
'Create a new, empty n8n agent and start building it. Call this first when there is no ' +
'agent to configure yet (read_config / write_config / patch_config report that no agent is ' +
'targeted). Pass a short human-readable name. After this succeeds the agent is the active ' +
'build target for the rest of the conversation, so follow up with read_config then ' +
'write_config to set its instructions, model, and tools. Returns { ok: true, agentId, name }.',
'agent to configure yet (read_config / build_agent report that no agent is targeted). ' +
'Pass a short human-readable name. After this succeeds the agent is the active build ' +
'target for the rest of the conversation, so follow up by writing the agent config JSON ' +
'to a workspace file and calling build_agent with its filePath. ' +
'Returns { ok: true, agentId, name }.',
)
.input(
z.object({
@@ -43,6 +45,8 @@ export function createCreateAgentTool(context: InstanceAiContext) {
agentId: created.agentId,
projectId: created.projectId,
};
// Persist so follow-up turns stay targeted at the same agent.
await saveAgentBuilderTarget(context, context.agentBuilderTarget);
return { ok: true as const, agentId: created.agentId, name: created.name };
} catch (e) {
return {
@@ -1,12 +1,13 @@
/**
* Creation + listing agent-builder tools: create_skill, create_task,
* build_custom_tool, list_integration_types, list_sub_agents, list_workflows.
* Thin wrappers over `agentBuilderService`.
* build_custom_tool, list_integration_types, list_sub_agents, list_agents,
* list_workflows. Thin wrappers over `agentBuilderService`.
*/
import { Tool } from '@n8n/agents';
import { agentSkillSchema, agentTaskSchema } from '@n8n/api-types';
import { z } from 'zod';
import { resolveAgentBuilderTarget } from './agent-target-binding';
import type { InstanceAiAgentBuilderService, InstanceAiContext } from '../../types';
import { AGENT_BUILDER_TOOL_IDS } from '../tool-ids';
@@ -16,15 +17,28 @@ interface CreationDeps {
projectId: string;
}
function resolveCreationDeps(context: InstanceAiContext): CreationDeps | null {
if (!context.agentBuilderService || !context.agentBuilderTarget) return null;
async function resolveCreationDeps(context: InstanceAiContext): Promise<CreationDeps | null> {
if (!context.agentBuilderService) return null;
const target = await resolveAgentBuilderTarget(context);
if (!target) return null;
return {
service: context.agentBuilderService,
agentId: context.agentBuilderTarget.agentId,
projectId: context.agentBuilderTarget.projectId,
agentId: target.agentId,
projectId: target.projectId,
};
}
/**
* Resolve the project id for project-scoped listing tools (list_workflows,
* list_agents). Prefers the persisted agent-builder binding so later turns stay
* scoped to the agent's project; falls back to the run's `projectId` so the
* tools still work before any agent is created.
*/
async function resolveProjectId(context: InstanceAiContext): Promise<string | undefined> {
const target = await resolveAgentBuilderTarget(context);
return target?.projectId ?? context.projectId;
}
const NOT_CONFIGURED = {
ok: false as const,
errors: [
@@ -43,9 +57,9 @@ export function createCreateSkillTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.CREATE_SKILL)
.description(
'Create and store a reusable, load-on-demand target-agent skill (name + routing ' +
'description + structured body). Does NOT attach the skill to the config — follow up with ' +
'patch_config (or write_config) to add a `{ type: "skill", id }` entry to `skills`. ' +
'Returns { ok: true, id, skill } or { ok: false, errors }.',
'description + structured body). Does NOT attach the skill to the config — follow up by ' +
'adding a `{ type: "skill", id }` entry to `skills` in the config file and calling ' +
'build_agent. Returns { ok: true, id, skill } or { ok: false, errors }.',
)
.systemInstruction(
'Never create a vague or placeholder skill. The description is the routing contract; the body ' +
@@ -62,7 +76,7 @@ export function createCreateSkillTool(context: InstanceAiContext) {
}),
)
.handler(async ({ name, description, body }) => {
const deps = resolveCreationDeps(context);
const deps = await resolveCreationDeps(context);
if (!deps) return NOT_CONFIGURED;
try {
const created = await deps.service.createSkill(deps.agentId, deps.projectId, {
@@ -89,7 +103,7 @@ export function createCreateTaskTool(context: InstanceAiContext) {
.systemInstruction(
'Never create a task with a vague or placeholder objective. A task can only use tools the ' +
'agent already has: if a step needs a tool/integration/web search the agent is missing, add ' +
'it via patch_config/write_config BEFORE calling create_task.',
'it to the config file and build_agent it BEFORE calling create_task.',
)
.input(
z.object({
@@ -101,7 +115,7 @@ export function createCreateTaskTool(context: InstanceAiContext) {
}),
)
.handler(async ({ name, objective, cronExpression }) => {
const deps = resolveCreationDeps(context);
const deps = await resolveCreationDeps(context);
if (!deps) return NOT_CONFIGURED;
try {
const task = await deps.service.createTask(deps.agentId, deps.projectId, {
@@ -124,8 +138,8 @@ export function createBuildCustomToolTool(context: InstanceAiContext) {
'Compile and store a custom tool. Pass the complete TypeScript source using ' +
'`export default new Tool(...)`. The code is validated in a sandbox and saved against the ' +
'agent. The returned `id` equals the tool name declared in the code. This does NOT register ' +
'the tool in the config — follow up with patch_config to add `{ type: "custom", id }` to ' +
'`tools`. Returns { ok: true, id, descriptor } or { ok: false, errors }.',
'the tool in the config — follow up by adding `{ type: "custom", id }` to `tools` in the ' +
'config file and calling build_agent. Returns { ok: true, id, descriptor } or { ok: false, errors }.',
)
.input(
z.object({
@@ -133,7 +147,7 @@ export function createBuildCustomToolTool(context: InstanceAiContext) {
}),
)
.handler(async ({ code }) => {
const deps = resolveCreationDeps(context);
const deps = await resolveCreationDeps(context);
if (!deps) return NOT_CONFIGURED;
try {
const descriptor = await deps.service.describeCustomTool(code);
@@ -162,7 +176,7 @@ export function createListIntegrationTypesTool(context: InstanceAiContext) {
)
.input(z.object({}))
.handler(async () => {
const deps = resolveCreationDeps(context);
const deps = await resolveCreationDeps(context);
if (!deps) return { integrations: [] };
return { integrations: await deps.service.listChatIntegrations() };
})
@@ -178,13 +192,30 @@ export function createListSubAgentsTool(context: InstanceAiContext) {
)
.input(z.object({}))
.handler(async () => {
const deps = resolveCreationDeps(context);
const deps = await resolveCreationDeps(context);
if (!deps) return { agents: [] };
return { agents: await deps.service.listProjectAgents(deps.projectId, deps.agentId) };
})
.build();
}
export function createListAgentsTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.LIST_AGENTS)
.description(
'List every agent in the target agent project (no published-only filter, does not ' +
'exclude the target agent). Use to discover which agents exist in the project — e.g. to ' +
'pick one to edit, or to see what is available before deciding to add a sub-agent. For the ' +
'narrower sub-agent candidate list, use list_sub_agents instead. Returns { agents: [{ agentId, name }] }.',
)
.input(z.object({}))
.handler(async () => {
if (!context.agentBuilderService) return { agents: [] };
const projectId = await resolveProjectId(context);
return { agents: await context.agentBuilderService.listAllProjectAgents(projectId) };
})
.build();
}
export function createListWorkflowsTool(context: InstanceAiContext) {
return new Tool(AGENT_BUILDER_TOOL_IDS.LIST_WORKFLOWS)
.description(
@@ -204,7 +235,7 @@ export function createListWorkflowsTool(context: InstanceAiContext) {
)
.handler(async ({ searchTerm }: { searchTerm?: string }) => {
if (!context.agentBuilderService) return { workflows: [] };
const projectId = context.agentBuilderTarget?.projectId ?? context.projectId;
const projectId = await resolveProjectId(context);
return {
workflows: await context.agentBuilderService.listAttachableWorkflows(projectId, searchTerm),
};
@@ -34,7 +34,8 @@ export function createVerifyMcpServerTool(context: InstanceAiContext) {
.description(
'Test connectivity to an MCP server and list its available tools before adding it to the ' +
'config. Call after resolving a credential (when authentication is not "none") and before ' +
'patch_config. Returns { ok: true, tools: [{ name, description }] } or { ok: false, error }.',
'adding the server entry to the config file and calling build_agent. ' +
'Returns { ok: true, tools: [{ name, description }] } or { ok: false, error }.',
)
.input(
z.object({
@@ -2,9 +2,9 @@
* resolve_llm resolve the target agent's main LLM (provider/model/credential)
* WITHOUT a picker, using the available LLM-provider credentials. Non-interactive:
* returns ok=false with options when the choice is missing/ambiguous, so the
* caller can fall back to the native ask-user tool and then write the choice via
* write_config. (Instance AI has no picker cards, so there is no interactive
* ask_llm tool here.)
* caller can fall back to the native ask-user tool and then write the choice
* into the config file and build_agent it. (Instance AI has no picker cards,
* so there is no interactive ask_llm tool here.)
*/
import { Tool } from '@n8n/agents';
import { z } from 'zod';
@@ -87,7 +87,7 @@ export function createResolveLlmTool(context: InstanceAiContext) {
'names a provider or model — do NOT call it proactively for fresh agents. If model is ' +
'omitted, uses the provider default. Returns ok=false with candidate credentials/models when ' +
'the choice is missing, unsupported, or ambiguous; ask the user (via the ask-user tool) to ' +
'pick, then write the choice with write_config.',
'pick, then write the choice into the config file and call build_agent.',
)
.input(
z.object({
@@ -14,12 +14,14 @@ import { Tool, type BuiltTool } from '@n8n/agents';
import { formatZodErrors } from '@n8n/api-types';
import { z } from 'zod';
import { createReadConfigTool, createWriteConfigTool, createPatchConfigTool } from './config-tools';
import { createBuildAgentTool } from './build-agent.tool';
import { createReadConfigTool } from './config-tools';
import { createCreateAgentTool } from './create-agent.tool';
import {
createBuildCustomToolTool,
createCreateSkillTool,
createCreateTaskTool,
createListAgentsTool,
createListIntegrationTypesTool,
createListSubAgentsTool,
createListWorkflowsTool,
@@ -38,8 +40,7 @@ import { AGENT_BUILDER_TOOL_IDS } from '../tool-ids';
const ROUTER_TOOL_FACTORIES = [
createCreateAgentTool,
createReadConfigTool,
createWriteConfigTool,
createPatchConfigTool,
createBuildAgentTool,
createSearchNodesTool,
createGetNodeTypesTool,
createGetResourceLocatorOptionsTool,
@@ -48,6 +49,7 @@ const ROUTER_TOOL_FACTORIES = [
createBuildCustomToolTool,
createListIntegrationTypesTool,
createListSubAgentsTool,
createListAgentsTool,
createListWorkflowsTool,
createSearchMcpServersTool,
createVerifyMcpServerTool,
@@ -96,10 +98,11 @@ export function createAgentBuilderRouterTool(context: InstanceAiContext): BuiltT
'Only use this tool when the user is explicitly creating or editing an n8n Agent — never ' +
'while building or editing a workflow (use the workflow-builder skill and build-workflow ' +
'for that), and never to fabricate file/utility tools during a workflow build. Actions: ' +
'create_agent (create the agent first if none exists), read_config / write_config / ' +
'patch_config (the agent JSON config), search_nodes / get_node_types / ' +
'create_agent (create the agent first if none exists), read_config (read the persisted ' +
'agent JSON config + configHash), build_agent (validate and persist the config from a ' +
'workspace JSON file), search_nodes / get_node_types / ' +
'get_resource_locator_options (node tools), create_skill, create_task, build_custom_tool, ' +
'list_integration_types, list_sub_agents, list_workflows, ' +
'list_integration_types, list_sub_agents, list_agents, list_workflows, ' +
'search_mcp_servers, verify_mcp_server, resolve_llm. To ask the user anything (a choice, ' +
'which credential, which model) use the native `ask-user` tool; to list credentials use ' +
'the native `credentials` tool (action `list`).',
@@ -45,8 +45,7 @@ export const AGENT_BUILDER_TOOL_IDS = {
AGENT_BUILDER: 'agent_builder',
CREATE_AGENT: 'create_agent',
READ_CONFIG: 'read_config',
WRITE_CONFIG: 'write_config',
PATCH_CONFIG: 'patch_config',
BUILD_AGENT: 'build_agent',
GET_RESOURCE_LOCATOR_OPTIONS: 'get_resource_locator_options',
SEARCH_NODES: 'search_nodes',
GET_NODE_TYPES: 'get_node_types',
@@ -56,6 +55,7 @@ export const AGENT_BUILDER_TOOL_IDS = {
BUILD_CUSTOM_TOOL: 'build_custom_tool',
LIST_INTEGRATION_TYPES: 'list_integration_types',
LIST_SUB_AGENTS: 'list_sub_agents',
LIST_AGENTS: 'list_agents',
LIST_WORKFLOWS: 'list_workflows',
SEARCH_MCP_SERVERS: 'search_mcp_servers',
VERIFY_MCP_SERVER: 'verify_mcp_server',
+7
View File
@@ -892,6 +892,13 @@ export interface InstanceAiAgentBuilderService {
): Promise<{ id: string }>;
listChatIntegrations(): Promise<ChatIntegrationInfo[]>;
listProjectAgents(projectId: string, excludeAgentId: string): Promise<ProjectAgentSummary[]>;
/**
* Every agent in the project (no exclude, no published-only filter), for
* discovery flows like "which agents exist here?" Resolves a default
* project when `projectId` is omitted (mirrors `listAttachableWorkflows`).
* Scoped to `agent:read`.
*/
listAllProjectAgents(projectId?: string): Promise<ProjectAgentSummary[]>;
/** Live model ids for a credential, via the provider's chat-model node lookup (drives resolve_llm). */
listModels(
credentialId: string,
@@ -6,6 +6,7 @@ import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import * as checkAccess from '@/permissions.ee/check-access';
import type { AgentConfigService } from '../agent-config.service';
@@ -194,8 +195,26 @@ describe('InstanceAiAgentBuilderAdapterService scope enforcement', () => {
expect(result).toEqual({ agentId: 'agent-1', projectId: 'project-1', name: 'My agent' });
});
it('createTask rejects an agent outside the scoped project', async () => {
const { adapter, agentsService, agentTaskService } = setup();
agentsService.findById.mockResolvedValue(null);
await expect(
adapter.createTask('agent-other-project', 'project-1', {
name: 'n',
objective: 'o',
cronExpression: '0 9 * * *',
enabled: true,
}),
).rejects.toThrow(NotFoundError);
expect(agentsService.findById).toHaveBeenCalledWith('agent-other-project', 'project-1');
expect(agentTaskService.create).not.toHaveBeenCalled();
});
it('createTask delegates to the task service with the agent id', async () => {
const { adapter, agentTaskService } = setup();
const { adapter, agentsService, agentTaskService } = setup();
agentsService.findById.mockResolvedValue(mock());
agentTaskService.create.mockResolvedValue({
id: 'task-1',
name: 'n',
@@ -21,6 +21,7 @@ import { type Scope } from '@n8n/permissions';
import { CredentialsService } from '@/credentials/credentials.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
import { NodeCatalogService } from '@/node-catalog/node-catalog.service';
import { NodeTypes } from '@/node-types';
@@ -146,6 +147,10 @@ export class InstanceAiAgentBuilderAdapterService {
createTask: async (agentId, projectId, task) => {
await assertProjectScope('agent:update', projectId);
// AgentTaskService.create looks the agent up by id only, so confirm the
// agent belongs to the scoped project before mutating it.
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const created = await this.agentTaskService.create(agentId, {
name: task.name,
objective: task.objective,
@@ -193,6 +198,13 @@ export class InstanceAiAgentBuilderAdapterService {
.map((agent) => ({ agentId: agent.id, name: agent.name }));
},
listAllProjectAgents: async (projectId): Promise<ProjectAgentSummary[]> => {
const resolvedProjectId = await resolveProjectId(projectId);
await assertProjectScope('agent:read', resolvedProjectId);
const agents = await this.agentsService.findByProjectId(resolvedProjectId);
return agents.map((agent) => ({ agentId: agent.id, name: agent.name }));
},
listModels: async (
credentialId,
credentialType,
@@ -765,7 +765,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
(loadInstanceAiRuntimeSkillSource as Mock).mockImplementation(() => ({
registry: {
skillsHash: 'runtime-skills-hash',
skills: [{ id: 'data-table-manager' }],
skills: [{ id: 'data-table-manager' }, { id: 'agent-builder' }],
},
loadSkill: vi.fn(),
}));
@@ -781,7 +781,10 @@ describe('InstanceAiService — runtime workspace setup', () => {
) => Promise<{
orchestrationContext: {
workspace?: unknown;
runtimeSkills?: { registry: { skills: Array<{ id: string }> } };
runtimeSkills?: {
registry: { skillsHash: string; skills: Array<{ id: string }> };
loadSkill: (skillId: string) => Promise<unknown>;
};
};
}>;
settingsService: {
@@ -918,6 +921,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
expect(loadInstanceAiRuntimeSkillSource).toHaveBeenCalledTimes(1);
expect(environment.orchestrationContext.runtimeSkills?.registry.skills).toEqual([
{ id: 'data-table-manager' },
{ id: 'agent-builder' },
]);
expect(createSandbox).not.toHaveBeenCalled();
const skillWorkspace = (createLazyWorkspaceRuntimeSkillSource as Mock).mock.calls[0]?.[0]
@@ -972,13 +976,49 @@ describe('InstanceAiService — runtime workspace setup', () => {
);
expect(unavailableEnvironment.orchestrationContext.workspace).toBeUndefined();
// The agent-builder skill needs the sandbox workspace (build_agent reads
// config files from it), so it is hidden when the sandbox is unavailable.
expect(unavailableEnvironment.orchestrationContext.runtimeSkills?.registry.skills).toEqual([
{ id: 'data-table-manager' },
]);
// Hidden means unloadable too, and the filtered catalog gets its own hash
// so workspace manifests keyed on it can't match the unfiltered set.
await expect(
unavailableEnvironment.orchestrationContext.runtimeSkills?.loadSkill('agent-builder'),
).resolves.toBeNull();
expect(unavailableEnvironment.orchestrationContext.runtimeSkills?.registry.skillsHash).not.toBe(
'runtime-skills-hash',
);
expect(createLazyRuntimeWorkspace).not.toHaveBeenCalled();
expect(createLazyWorkspaceRuntimeSkillSource).not.toHaveBeenCalled();
expect(createSandbox).not.toHaveBeenCalled();
expect(setupSandboxWorkspace).not.toHaveBeenCalled();
// Third phase: sandbox available again, but the agents module is inactive.
// The agent-builder skill must still be hidden via withoutAgentBuilderSkill.
(loadInstanceAiRuntimeSkillSource as Mock).mockClear();
(createLazyWorkspaceRuntimeSkillSource as Mock).mockClear();
service.settingsService.getSandboxStatus.mockReturnValue({
enabled: true,
provider: 'n8n-sandbox',
workflowBuilderAvailable: true,
unavailableReason: null,
});
service.moduleRegistry.isActive = vi.fn((mod: string) => mod !== 'agents');
const agentsInactiveEnvironment = await service.createExecutionEnvironment(
fakeUser,
'thread-3',
'run-3',
new AbortController().signal,
);
expect(agentsInactiveEnvironment.orchestrationContext.runtimeSkills?.registry.skills).toEqual([
{ id: 'data-table-manager' },
]);
await expect(
agentsInactiveEnvironment.orchestrationContext.runtimeSkills?.loadSkill('agent-builder'),
).resolves.toBeNull();
});
});
@@ -1,4 +1,4 @@
import { AgentEvent } from '@n8n/agents';
import { AgentEvent, filterRuntimeSkillSource } from '@n8n/agents';
import type { Message, Workspace, ScopedMemoryTaskEvent, AgentEventData } from '@n8n/agents';
import {
applyBranchReadOnlyOverrides,
@@ -1963,22 +1963,17 @@ export class InstanceAiService {
}
// The agent-builder tools are only registered when the agents module is
// active (InstanceAiAdapterService.getAgentBuilderAdapter), so hide its
// skill too when the module is off.
// active (InstanceAiAdapterService.getAgentBuilderAdapter), and the
// build_agent flow persists configs from workspace files, so the skill
// needs both the agents module and the sandbox workspace. Hide it when
// either is unavailable.
const allRuntimeSkills = loadInstanceAiRuntimeSkillSource();
const agentsModuleActive = this.moduleRegistry.isActive('agents');
const baseRuntimeSkills = agentsModuleActive
? allRuntimeSkills
: {
...allRuntimeSkills,
registry: {
...allRuntimeSkills.registry,
skills: allRuntimeSkills.registry.skills.filter(
(skill) => skill.id !== 'agent-builder',
),
},
};
let runtimeSkills = baseRuntimeSkills;
const withoutAgentBuilderSkill = filterRuntimeSkillSource(allRuntimeSkills, ['agent-builder']);
// Default assumes no workspace; the sandbox block below restores the
// agent-builder skill once the workspace is known to be available.
let availableRuntimeSkills = withoutAgentBuilderSkill;
let runtimeSkills = availableRuntimeSkills;
let runtimeWorkspace: Workspace | undefined;
let workspaceRoot: string | undefined;
@@ -2031,8 +2026,9 @@ export class InstanceAiService {
ensureWorkspace: async () =>
await scopeWorkspaceForAgent((await getSandboxEntry())?.workspace),
});
availableRuntimeSkills = agentsModuleActive ? allRuntimeSkills : withoutAgentBuilderSkill;
runtimeSkills = createLazyWorkspaceRuntimeSkillSource({
source: baseRuntimeSkills,
source: availableRuntimeSkills,
workspace: runtimeSkillWorkspace,
logger: this.logger,
});
@@ -2068,7 +2064,7 @@ export class InstanceAiService {
timeZone: this.defaultTimeZone,
localMcpServer: context.localMcpServer,
runtimeSkills,
runtimeSkillCatalog: baseRuntimeSkills,
runtimeSkillCatalog: availableRuntimeSkills,
oauth2CallbackUrl: this.oauth2CallbackUrl,
webhookBaseUrl: this.webhookBaseUrl,
formBaseUrl: this.formBaseUrl,
-3
View File
@@ -2383,9 +2383,6 @@ importers:
fast-glob:
specifier: 'catalog:'
version: 3.2.12
fast-json-patch:
specifier: 'catalog:'
version: 3.1.1
flatted:
specifier: 3.4.2
version: 3.4.2