prompt nudges

This commit is contained in:
Riqwan Thamir 2026-10-12 21:55:44 +02:00
parent af01bf6062
commit bc7ef3fb5b
No known key found for this signature in database
10 changed files with 208 additions and 50 deletions

View File

@ -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

View File

@ -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<void>((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();

View File

@ -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<void>;
}
interface LazyWorkspaceRuntimeSkillSourceOptions {
@ -390,6 +401,7 @@ export async function loadPrebakedRuntimeSkillsBundle({
root,
workspaceRoot = root,
logger,
bundle,
}: MaterializeRuntimeSkillsOptions): Promise<RuntimeSkillWorkspaceBundle | undefined> {
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<MaterializedRuntimeSkills | undefined> {
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<MaterializedRuntimeSkills | undefined> | undefined;
interface BuiltSkills {
bundle: RuntimeSkillWorkspaceBundle;
root: string;
}
let built: BuiltSkills | undefined;
let buildPromise: Promise<BuiltSkills | undefined> | undefined;
let filesWritten = false;
let writePromise: Promise<void> | 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<MaterializedRuntimeSkills | undefined> {
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<RuntimeSkillWorkspaceBundle | undefined> {
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<void> {
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<RuntimeSkillSource> {
return (await ensureMaterialized())?.source ?? source;
return (await ensureBuilt())?.source ?? source;
}
return workspaceSource;

View File

@ -67,6 +67,12 @@ export function hasRuntimeSkills(
return (source?.registry.skills.length ?? 0) > 0;
}
function hasPendingSkillFiles(
source: RuntimeSkillSource,
): source is RuntimeSkillSource & { whenFilesWritten: () => Promise<void> } {
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),

View File

@ -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

View File

@ -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<void>;
}
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<WorkspaceResolvedListener>();
private readonly destroyedListeners = new Set<WorkspaceDestroyedListener>();
constructor(private readonly ensureWorkspace: RuntimeWorkspaceResolver) {}
constructor(
private readonly ensureWorkspace: RuntimeWorkspaceResolver,
private readonly beforeUse?: () => Promise<void>,
) {}
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;
}

View File

@ -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.

View File

@ -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"\`

View File

@ -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([

View File

@ -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?.(),
});
}
}