diff --git a/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md b/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md index 3249b54dbc3..70afe5bcb9b 100644 --- a/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md @@ -176,7 +176,8 @@ continue one, pass the exact `sessionId` from an earlier When setup is finished and the target Agent is runnable, call `agent_builder_call_agent` once with a representative message to verify that it -works as intended. If the test exposes errors, tell the user what failed and +works as intended. If the user skipped a required credential or the model, +do not call it. The test cannot pass until that setup is done. If the test exposes errors, tell the user what failed and ask whether you should fix the problems before you change the Agent. Use `agent_builder_call_agent` to verify the Agent's behavior, not its channel @@ -284,9 +285,11 @@ confirm those selections before you finish the build. discovery lookups the plan names: `agent-context({ type: "integrations" })` for a chat channel, and one `agent-context({ type: "integrations", queries })` call for each callable service. -4. In the next response, call `agent_builder_get_node_types` for the node - results you will use, and resolve the model in the same response (see - Model selection). +4. In the next response, call `agent_builder_get_node_types` once for every + node and operation the build uses, and resolve the model in the same + response (see Model selection). Use the node results from the discovery + lookups. Do not search again with `agent_builder_search_nodes` for a service + that a lookup already returned, and do not fetch one service per call. 5. Write the config once, with everything discovery found: the name, the target-Agent `instructions`, the model, every node tool, and the drafts for setup that the user must finish. Do not write a partial config and patch it @@ -316,7 +319,10 @@ confirm those selections before you finish the build. `agent_builder_call_agent` once with a representative message before your final response. If the test exposes errors, report them and ask whether you should fix them. Do not claim the Agent is ready without this test, or - without an explanation of why it could not run. + without an explanation of why it could not run. The Agent is not runnable + when `agent_builder_finish_setup` returned a required credential or model + as skipped or dismissed. Then do not call `agent_builder_call_agent`. Say + that the test runs after that setup is complete. 11. When the user asks to publish, activate, or make the Agent live or usable, call `agent_builder_publish_agent`. Never tell them to click Publish in the editor. Do not publish without that intent. Use diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts index d35429957a4..2ccfab95060 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts @@ -266,7 +266,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { expect(materialized.skills.map(({ id }) => id)).not.toContain('post-build-flow'); }); - it('materializes skills into the workspace before load_skill reads them', async () => { + it('materializes skills into the workspace when load_skill first runs', async () => { const source = loadInstanceAiRuntimeSkillSource(); const { workspace, writes, executeCommand } = createMockWorkspace(); const runtimeSource = createLazyWorkspaceRuntimeSkillSource({ @@ -277,6 +277,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { const loadTool = createSkillLoadTool(runtimeSource); const result = await loadTool.handler?.({ skillId: 'data-table-manager' }, {}); + await runtimeSource.whenFilesWritten?.(); const root = '/home/daytona/workspace'; const skillDir = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager`; const skillPath = `${skillDir}/SKILL.md`; @@ -292,6 +293,57 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { expect(executeCommand).toHaveBeenCalledTimes(1); }); + it('returns the skill before the workspace writes finish', async () => { + const source = loadInstanceAiRuntimeSkillSource(); + const { workspace, writes, writeFile } = createMockWorkspace(); + let releaseWrites: () => void = () => {}; + const writesReleased = new Promise((resolve) => { + releaseWrites = resolve; + }); + writeFile.mockImplementation(async (path: string, content: string | Buffer) => { + await writesReleased; + writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content); + }); + const runtimeSource = createLazyWorkspaceRuntimeSkillSource({ + logger: mockLogger, + source, + workspace, + }); + + const result = await createSkillLoadTool(runtimeSource).handler?.( + { skillId: 'data-table-manager' }, + {}, + ); + + const skillPath = `/home/daytona/workspace/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager/SKILL.md`; + expect(skillLoadText(result)).toContain(skillPath); + expect(writes.has(skillPath)).toBe(false); + + let filesWritten = false; + const pending = runtimeSource.whenFilesWritten?.().then(() => { + filesWritten = true; + }); + await Promise.resolve(); + expect(filesWritten).toBe(false); + + releaseWrites(); + await pending; + expect(writes.get(skillPath)).toContain('workflow_builder_data_tables'); + }); + + it('does not wait for skill files when no skill was loaded', async () => { + const { workspace, writeFile } = createMockWorkspace(); + const runtimeSource = createLazyWorkspaceRuntimeSkillSource({ + logger: mockLogger, + source: loadInstanceAiRuntimeSkillSource(), + workspace, + }); + + await runtimeSource.whenFilesWritten?.(); + + expect(writeFile).not.toHaveBeenCalled(); + }); + it('uses prebaked runtime skills when the manifest matches the source hash', async () => { const source = loadInstanceAiRuntimeSkillSource(); const { workspace, writes, executeCommand, writeFile } = createMockWorkspace(); @@ -309,6 +361,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { }); const loadTool = createSkillLoadTool(runtimeSource); const result = await loadTool.handler?.({ skillId: 'data-table-manager' }, {}); + await runtimeSource.whenFilesWritten?.(); const skillDir = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager`; const skillPath = `${skillDir}/SKILL.md`; @@ -341,6 +394,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { const loadTool = createSkillLoadTool(runtimeSource); await loadTool.handler?.({ skillId: 'data-table-manager' }, {}); + await runtimeSource.whenFilesWritten?.(); const skillPath = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager/SKILL.md`; expect(writeFile).toHaveBeenCalled(); @@ -362,6 +416,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { const loadTool = createSkillLoadTool(runtimeSource); await loadTool.handler?.({ skillId: 'data-table-manager' }, {}); + await runtimeSource.whenFilesWritten?.(); const skillPath = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager/SKILL.md`; expect(writeFile).toHaveBeenCalled(); diff --git a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts index dd1ed46989a..424e2eedf65 100644 --- a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts +++ b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts @@ -13,6 +13,7 @@ import { type Workspace, } from '@n8n/agents'; import { getWorkspaceRoot } from '@n8n/agents/sandbox'; +import { getErrorMessage } from '@n8n/utils/errors/get-error-message'; import { join as posixJoin, normalize as posixNormalize } from 'node:path/posix'; import type { Logger } from '../logger'; @@ -73,6 +74,16 @@ interface MaterializeRuntimeSkillsOptions { root: string; workspaceRoot?: string; logger: Logger; + /** A bundle already built for the same source and root. */ + bundle?: RuntimeSkillWorkspaceBundle; +} + +export interface WorkspaceRuntimeSkillSource extends RuntimeSkillSource { + /** + * Resolves when the skill files are in the workspace. Skill loads do not + * wait for the files, so sandbox commands that read them must wait here. + */ + whenFilesWritten?: () => Promise; } interface LazyWorkspaceRuntimeSkillSourceOptions { @@ -390,6 +401,7 @@ export async function loadPrebakedRuntimeSkillsBundle({ root, workspaceRoot = root, logger, + bundle, }: MaterializeRuntimeSkillsOptions): Promise { if (source.registry.skills.length === 0) return undefined; @@ -420,13 +432,14 @@ export async function loadPrebakedRuntimeSkillsBundle({ count: bundle.skills.length, }), buildBundle: async () => - await buildRuntimeSkillWorkspaceBundle({ + bundle ?? + (await buildRuntimeSkillWorkspaceBundle({ source, root, workspaceRoot, skillsRoot, logger, - }), + })), }); } @@ -596,6 +609,7 @@ export async function materializeRuntimeSkillsIntoWorkspace({ workspace, root, logger, + bundle, }: MaterializeRuntimeSkillsOptions): Promise { return await traceSandboxOperation( 'sync-skills', @@ -613,13 +627,14 @@ export async function materializeRuntimeSkillsIntoWorkspace({ resourceLabel: RUNTIME_SKILL_FILE_LABEL, logger, loadPrebaked: async () => - await loadPrebakedRuntimeSkillsBundle({ source, workspace, root, logger }), + await loadPrebakedRuntimeSkillsBundle({ source, workspace, root, logger, bundle }), buildBundle: async () => { - const bundle = await buildRuntimeSkillWorkspaceBundle({ source, root, logger }); - if (!bundle) { + const built = + bundle ?? (await buildRuntimeSkillWorkspaceBundle({ source, root, logger })); + if (!built) { throw new Error('Expected runtime skill bundle after registry validation'); } - return bundle; + return built; }, materializedLogMessage: 'Materialized runtime skills into workspace', materializedLogContext: (bundle) => ({ @@ -638,17 +653,23 @@ export function createLazyWorkspaceRuntimeSkillSource({ source, workspace, logger, -}: LazyWorkspaceRuntimeSkillSourceOptions): RuntimeSkillSource { +}: LazyWorkspaceRuntimeSkillSourceOptions): WorkspaceRuntimeSkillSource { if (!workspace || source.registry.skills.length === 0) return source; const runtimeWorkspace = workspace; - let materialized: MaterializedRuntimeSkills | undefined; - let materializePromise: Promise | undefined; + interface BuiltSkills { + bundle: RuntimeSkillWorkspaceBundle; + root: string; + } + let built: BuiltSkills | undefined; + let buildPromise: Promise | undefined; + let filesWritten = false; + let writePromise: Promise | undefined; - const workspaceSource: RuntimeSkillSource = { + const workspaceSource: WorkspaceRuntimeSkillSource = { registry: source.registry, prepare: async () => { - await ensureMaterialized(); + await ensureBuilt(); }, loadSkill: async (skillId: string) => await (await ensureSource()).loadSkill(skillId), ...(source.loadFile @@ -659,30 +680,60 @@ export function createLazyWorkspaceRuntimeSkillSource({ }, } : {}), + whenFilesWritten: async () => { + if (!built) return; + await writeFiles(built).catch(logWriteFailure); + }, }; - async function ensureMaterialized(): Promise { - if (materialized) return materialized; + // Skill text comes from the in-memory bundle, so a load returns before the + // sandbox writes finish. The writes continue in the background. + async function ensureBuilt(): Promise { + if (!built) { + buildPromise ??= (async () => { + const root = await getWorkspaceRoot(runtimeWorkspace); + const bundle = await buildRuntimeSkillWorkspaceBundle({ source, root, logger }); + if (!bundle) return undefined; + built = { bundle, root }; + workspaceSource.registry = bundle.source.registry; + return built; + })().catch((error: unknown) => { + buildPromise = undefined; + throw error; + }); + await buildPromise; + } + if (built) void writeFiles(built).catch(logWriteFailure); + return built?.bundle; + } - materializePromise ??= (async () => { - const root = await getWorkspaceRoot(runtimeWorkspace); - const options = { source, workspace: runtimeWorkspace, root, logger }; - const result = await materializeRuntimeSkillsIntoWorkspace(options); - if (result) { - materialized = result; - workspaceSource.registry = result.source.registry; - } - return result; - })().catch((error: unknown) => { - materializePromise = undefined; - throw error; + async function writeFiles({ bundle, root }: BuiltSkills): Promise { + if (filesWritten) return; + writePromise ??= materializeRuntimeSkillsIntoWorkspace({ + source, + workspace: runtimeWorkspace, + root, + logger, + bundle, + }) + .then(() => { + filesWritten = true; + }) + .catch((error: unknown) => { + writePromise = undefined; + throw error; + }); + await writePromise; + } + + function logWriteFailure(error: unknown): void { + logger.warn('Failed to write runtime skills into the workspace', { + error: getErrorMessage(error), }); - - return await materializePromise; } async function ensureSource(): Promise { - return (await ensureMaterialized())?.source ?? source; + return (await ensureBuilt())?.source ?? source; } return workspaceSource; diff --git a/packages/@n8n/instance-ai/src/skills/runtime-skills.ts b/packages/@n8n/instance-ai/src/skills/runtime-skills.ts index a6e7dc52d46..3a4ffda6727 100644 --- a/packages/@n8n/instance-ai/src/skills/runtime-skills.ts +++ b/packages/@n8n/instance-ai/src/skills/runtime-skills.ts @@ -67,6 +67,12 @@ export function hasRuntimeSkills( return (source?.registry.skills.length ?? 0) > 0; } +function hasPendingSkillFiles( + source: RuntimeSkillSource, +): source is RuntimeSkillSource & { whenFilesWritten: () => Promise } { + return 'whenFilesWritten' in source && typeof source.whenFilesWritten === 'function'; +} + /** * Prepare the skill source in the background so it overlaps with the first model * call. load_skill awaits the same promise and retries if this one fails. It runs @@ -83,7 +89,11 @@ export function warmRuntimeSkills( const preparation = tracing ? tracing.withActiveSpan(tracing.actorRun, async () => await prepare.call(source)) : prepare.call(source); - tracing?.keepOpenUntilSettled?.(preparation); + tracing?.keepOpenUntilSettled?.( + hasPendingSkillFiles(source) + ? preparation.then(async () => await source.whenFilesWritten()) + : preparation, + ); preparation.catch((error: unknown) => { logger?.warn('Failed to warm runtime skills in the background', { error: getErrorMessage(error), diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts index 2920fba7478..a9a554fc1d4 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts @@ -129,6 +129,23 @@ describe('createLazyRuntimeWorkspace', () => { await expect(operation).rejects.toMatchObject({ name: 'AbortError' }); }); + it('runs commands only after the beforeUse hook settles', async () => { + const { workspace, executeCommand } = createMockWorkspace(); + const pendingWrites = createDeferredPromise(); + const lazyWorkspace = createLazyRuntimeWorkspace({ + ensureWorkspace: async () => await Promise.resolve(workspace), + beforeUse: async () => await pendingWrites.promise, + }); + + const operation = lazyWorkspace.sandbox!.executeCommand!('ls', []); + await new Promise((resolve) => setImmediate(resolve)); + expect(executeCommand).not.toHaveBeenCalled(); + + pendingWrites.resolve(); + await operation; + expect(executeCommand).toHaveBeenCalledTimes(1); + }); + it('retries workspace creation after the first lazy initialization fails', async () => { const { workspace } = createMockWorkspace(); const ensureWorkspace = vi diff --git a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts index c37e49410c4..b5c82f6bf8c 100644 --- a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts +++ b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts @@ -45,6 +45,8 @@ export interface LazyRuntimeWorkspaceOptions { */ sandboxInstructions?: string; filesystemInstructions?: string; + /** Awaited before each file or command operation, e.g. to finish pending skill writes. */ + beforeUse?: () => Promise; } type WorkspaceResolvedListener = (workspace: Workspace) => void; @@ -56,8 +58,9 @@ export function createLazyRuntimeWorkspace({ name = 'Instance AI runtime workspace', sandboxInstructions, filesystemInstructions, + beforeUse, }: LazyRuntimeWorkspaceOptions): Workspace { - const resolver = new LazyRuntimeWorkspaceResolver(ensureWorkspace); + const resolver = new LazyRuntimeWorkspaceResolver(ensureWorkspace, beforeUse); const workspace = new Workspace({ id, @@ -80,7 +83,10 @@ class LazyRuntimeWorkspaceResolver { private readonly resolvedListeners = new Set(); private readonly destroyedListeners = new Set(); - constructor(private readonly ensureWorkspace: RuntimeWorkspaceResolver) {} + constructor( + private readonly ensureWorkspace: RuntimeWorkspaceResolver, + private readonly beforeUse?: () => Promise, + ) {} get current(): Workspace | undefined { return this.resolvedWorkspace; @@ -112,6 +118,7 @@ class LazyRuntimeWorkspaceResolver { throw new Error('Instance AI runtime workspace is unavailable.'); } + await this.beforeUse?.(); return workspace; } diff --git a/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts b/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts index f03bd7a0669..fce8728acf6 100644 --- a/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts +++ b/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts @@ -65,6 +65,9 @@ conventions. Never add an incomplete tool or use a placeholder URL. follow its MCP Servers section, and stop this node-tool workflow. - If it returns \`kind: "node"\`, use its returned node results and call \`agent_builder_get_node_types\`; do not repeat the same search with \`agent_builder_search_nodes\`. +- Fetch every node definition that the build needs in one + \`agent_builder_get_node_types\` call, with all nodes and operations together. + Do not fetch one service per call. - Call \`agent_builder_search_nodes\` directly only when the user explicitly requests an n8n node, when refining node results, or when a verified MCP server lacks the requested capability. diff --git a/packages/cli/src/modules/agents/builder/skills/resource-locators.skill.ts b/packages/cli/src/modules/agents/builder/skills/resource-locators.skill.ts index 9f772189007..c172fd347af 100644 --- a/packages/cli/src/modules/agents/builder/skills/resource-locators.skill.ts +++ b/packages/cli/src/modules/agents/builder/skills/resource-locators.skill.ts @@ -57,7 +57,9 @@ agent, resolve the credential and any ambiguity immediately instead. ## Workflow -1. Discover and inspect the node with \`agent_builder_search_nodes\`, then \`agent_builder_get_node_types\`. +1. Inspect the node with \`agent_builder_get_node_types\`. Use the node results + from the \`agent-context\` integration lookup and the definitions you already + fetched. Call \`agent_builder_search_nodes\` only when no lookup returned the node. 2. Identify dynamic selectors from node metadata. Treat these as build-time lookup fields: - \`type: "resourceLocator"\` diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 4477ff9f0ff..39da804d487 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -1077,9 +1077,13 @@ describe('InstanceAiService — runtime workspace setup', () => { expect(createLazyRuntimeWorkspace).toHaveBeenCalledTimes(2); expect(createLazyRuntimeWorkspace).toHaveBeenNthCalledWith( - 2, + 1, expect.objectContaining({ id: 'instance-ai-runtime-skill-workspace' }), ); + expect(createLazyRuntimeWorkspace).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ beforeUse: expect.any(Function) }), + ); expect(createLazyWorkspaceRuntimeSkillSource).toHaveBeenCalledTimes(1); expect(loadInstanceAiPromptSkills).toHaveBeenCalledTimes(1); expect(environment.orchestrationContext.runtimeSkills?.registry.skills).toEqual([ diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 2514cabed2f..27c68117b79 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -2786,6 +2786,18 @@ export class InstanceAiService { return createScopedWorkspace(workspace, root); }; + const runtimeSkillWorkspace = createLazyRuntimeWorkspace({ + id: 'instance-ai-runtime-skill-workspace', + name: 'Instance AI runtime skill workspace', + ensureWorkspace: async () => + await scopeWorkspaceForAgent((await getSandboxEntry())?.workspace), + }); + const workspaceSkills = createLazyWorkspaceRuntimeSkillSource({ + source: allRuntimeSkills, + workspace: runtimeSkillWorkspace, + logger: this.logger, + }); + runtimeSkills = workspaceSkills; runtimeWorkspace = createLazyRuntimeWorkspace({ // Empty + stable across resumes: sandbox/filesystem guidance lives in // the system prompt's `## Sandbox workspace` section. Passing '' here @@ -2796,17 +2808,8 @@ export class InstanceAiService { filesystemInstructions: '', ensureWorkspace: async () => await scopeWorkspaceForAgent((await getSetupSandboxEntry())?.workspace), - }); - const runtimeSkillWorkspace = createLazyRuntimeWorkspace({ - id: 'instance-ai-runtime-skill-workspace', - name: 'Instance AI runtime skill workspace', - ensureWorkspace: async () => - await scopeWorkspaceForAgent((await getSandboxEntry())?.workspace), - }); - runtimeSkills = createLazyWorkspaceRuntimeSkillSource({ - source: allRuntimeSkills, - workspace: runtimeSkillWorkspace, - logger: this.logger, + // Commands can run skill scripts, so they wait for the skill files. + beforeUse: async () => await workspaceSkills.whenFilesWritten?.(), }); } }