perf(core): Reduce Agent Builder round trips in an initial build (no-changelog) (#40719)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir 2026-10-13 13:29:11 +00:00 committed by GitHub
parent d8a06666da
commit 38e4aebfc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 90 additions and 107 deletions

View File

@ -125,11 +125,6 @@ const agentsSdkMocks = vi.hoisted(() => {
function createObservationLogReflectFn(model: unknown, options: unknown) {
return { model, options, kind: 'reflect' };
}
function createPlannerTodosTool(): BuiltTool {
return { name: 'write_todos', description: 'planner todos tool' } as BuiltTool;
}
return {
streamCalls,
resumeCalls,
@ -147,7 +142,6 @@ const agentsSdkMocks = vi.hoisted(() => {
MockMemory,
createObservationLogObserveFn,
createObservationLogReflectFn,
createPlannerTodosTool,
};
});
@ -157,7 +151,6 @@ vi.mock('@n8n/agents', async (importOriginal) => ({
Memory: agentsSdkMocks.MockMemory,
createObservationLogObserveFn: agentsSdkMocks.createObservationLogObserveFn,
createObservationLogReflectFn: agentsSdkMocks.createObservationLogReflectFn,
createPlannerTodosTool: agentsSdkMocks.createPlannerTodosTool,
}));
// Avoid a real `models.dev` catalog fetch — irrelevant to thread isolation and
@ -406,6 +399,7 @@ describe('AgentsBuilderService session isolation', () => {
expect(agentsSdkMocks.registeredToolNames).toEqual(
expect.arrayContaining(['resolve_llm', 'agent-context', 'ask_credential']),
);
expect(agentsSdkMocks.registeredToolNames).not.toContain('write_todos');
});
it('registers the parent MCP tools for an initial builder turn', async () => {

View File

@ -126,7 +126,7 @@ mutation, batching what you can) and follow-up turns where the user asked to
do setup in chat.
- \`finish_setup\`: use ONCE, only in the trailing step of an initial build
when only blocked tasks remain — the model choice and every open decision
when only pending setup remains — the model choice and every open decision
as \`questions\`, one \`credentialRequests\` entry per credential slot, and
one \`channels\` entry per drafted channel integration. It shows the setup
cards back-to-back without returning control to you between them —
@ -245,35 +245,54 @@ item.`;
export const WORKFLOW_SECTION = `\
## Workflow
1. For every request that builds or changes the agent, call \`write_todos\`
with the full plan first — even short ones. Mark tasks that cannot
proceed without user input as \`blocked\`, stating exactly what is
missing.
2. For fresh agents, call \`agent-context({ type: "config" })\` first. If \`model\` and \`credential\` are
already set (the system auto-selected a sensible default at creation), keep
them and mention the choice as changeable in your summary — do not call
\`resolve_llm\`. If \`model\` is empty, call \`resolve_llm\` once, silently. If it
resolves — including an auto-picked provider or newly provisioned free OpenAI
credits — use the result and mention the choice in your summary. If it
reports missing or ambiguous credentials, mark the model
task \`blocked\` and keep building: write the config with \`model: ""\` and
no \`credential\`.
3. Draft lightweight target-agent \`instructions\` containing its identity,
overall purpose, and universal rules, then write the config early; never
1. For every request that builds or changes the agent, plan the full build
before your first tool call, even for short requests. The plan names every
config change, tool, skill, task, and integration that the agent's
functions need. It also lists the pending setup: each item that cannot
proceed without user input, with exactly what is missing. Do not write the
plan out. Keep to it until every item is done or pending.
2. For fresh agents, start with one discovery response. Call these tools in
parallel in that response: \`agent-context({ type: "config" })\`,
\`load_skill\` for each runtime skill the plan needs, and the 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. A discovery lookup does not need the skill
text first.
3. In the next response, call \`get_node_types\` for the node results you will
use, and resolve the model in the same response. If \`model\` and
\`credential\` are already set (the system auto-selected a sensible default
at creation), keep them and mention the choice as changeable in your
summary — do not call \`resolve_llm\`. If \`model\` is empty, call
\`resolve_llm\` once, silently. If it resolves — including an auto-picked
provider or newly provisioned free OpenAI credits — use the result and
mention the choice in your summary. If it reports missing or ambiguous
credentials, add the model choice to the pending setup and keep building:
write the config with \`model: ""\` and no \`credential\`.
4. Write the config once, with everything discovery found: the name,
lightweight target-agent \`instructions\` (identity, overall purpose, and
universal rules), the model, the node tools, and the drafts for setup the
user must finish (channel integrations, MCP servers, empty credential
slots). Do not write a partial config and patch it in the next step. Never
write empty placeholders, and never wait for setup answers before writing
instructions, tools, skills, or tasks.
4. Load relevant runtime skills before specialized discovery or asset work.
5. Perform discovery and create or update the tools, focused skills, and tasks
required by the target agent's functions, whether or not the user named
those artifact types explicitly.
5. Create or update the focused skills and tasks required by the target
agent's functions, whether or not the user named those artifact types
explicitly. Use \`patch_config\` only for changes that discovery could not
know before the config write.
6. Follow Config Freshness for every config mutation: chain each write from
the \`configHash\` your previous write returned.
7. When both skill and task batches are fully specified, call \`create_skills\`
and \`create_tasks\` in the same assistant response. Do not combine either
with an interactive tool or \`write_config\`/\`patch_config\` in that response.
8. When only blocked tasks remain, call \`finish_setup\` once with every
pending item, per the Initial Build section, then resolve its results and
finish the plan. Base any follow-up patch on the \`config\` and
8. When only pending setup remains, call \`finish_setup\` once with every
pending item, per the Initial Build section. Before that call, check the
plan: every item that does not need user input must be done. The
\`finish_setup\` input is your record of the pending items. After it
returns, resolve each item from its results and finish every build step
that waited on that input. Outside an initial build, if pending setup
remains at the end of the turn, end with a summary of what is missing, and
finish those items in later turns as the user gives the input. Base any
follow-up patch on the \`config\` and
\`configHash\` that \`finish_setup\` returns (or on your last \`configHash\`
when it showed no card and returned none); do not read the config again.
9. After setup is complete and the agent is runnable, call \`call_agent\` once
@ -290,20 +309,22 @@ export const FEW_SHOT_FLOWS_SECTION = `\
## Example flows
### New agent: "Build me an agent teammates can @mention in Slack to triage messages"
1. \`write_todos\` with the plan. \`agent-context({ type: "config" })\` first — if a model and
credential are already set (system auto-selected default), keep them and
mention the choice as changeable; otherwise \`resolve_llm({})\` once,
silently; if it reports missing credentials, mark the model task \`blocked\`.
2. \`write_config(...)\` with the \`configHash\` from step 1, the name and
instructions, and the model and credential only if \`resolve_llm\` ran —
or \`model: ""\` and no \`credential\` while the model task is blocked.
Omit fields that are already stored and unchanged.
3. Load \`agent-builder-external-services\` and call \`agent-context({ type: "integrations" })\`.
4. \`patch_config(...)\` with the \`configHash\` that \`write_config\` returned,
adding the returned Slack type to \`/integrations/-\` with \`credentialId: ""\`.
5. \`finish_setup({ channels: [{ integrationType: "slack" }] })\` — include
\`questions: [<model choice>]\` only if the model task is blocked; when
\`resolve_llm\` already resolved in step 1, pass only the channel. For a
1. In one response, in parallel: \`agent-context({ type: "config" })\`,
\`load_skill\` for \`agent-builder-external-services\`, and
\`agent-context({ type: "integrations" })\`.
2. If a model and credential are already set (system auto-selected default),
keep them and mention the choice as changeable; otherwise
\`resolve_llm({})\` once, silently; if it reports missing credentials, add
the model choice to the pending setup.
3. \`write_config(...)\` with the \`configHash\` from step 1, the name and
instructions, the returned Slack type in \`integrations\` with
\`credentialId: ""\`, and the model and credential only if
\`resolve_llm\` ran — or \`model: ""\` and no \`credential\` while the
model choice is pending. Omit fields that are already stored and unchanged.
4. \`finish_setup({ channels: [{ integrationType: "slack" }] })\` — include
\`questions: [<model choice>]\` only if the model choice is pending; when
the model is already set or \`resolve_llm\` resolved it in step 2, pass
only the channel. For a
model answer, call \`resolve_llm\` with it, then \`patch_config(...)\` with
the \`configHash\` that \`finish_setup\` returned, replacing \`/model\` and
\`/credential\`. The channel
@ -314,18 +335,16 @@ export const FEW_SHOT_FLOWS_SECTION = `\
the agent panel.
### New agent: "Use Anthropic via OpenRouter"
1. \`write_todos\` with the plan.
2. \`resolve_llm({ provider: "openrouter" })\`.
3. \`agent-context({ type: "config" })\`.
4. \`write_config(...)\` with \`model: "openrouter/{resolvedModel}"\`,
1. \`resolve_llm({ provider: "openrouter" })\`.
2. \`agent-context({ type: "config" })\`.
3. \`write_config(...)\` with \`model: "openrouter/{resolvedModel}"\`,
\`credential\`, and requested instructions.
### Change the existing model
1. \`write_todos\` with the plan.
2. \`ask_questions({ ... })\` for the new model choice, then
1. \`ask_questions({ ... })\` for the new model choice, then
\`resolve_llm({ provider, model })\`.
3. \`agent-context({ type: "config" })\`.
4. \`patch_config(...)\` replacing \`/model\` and \`/credential\`.
2. \`agent-context({ type: "config" })\`.
3. \`patch_config(...)\` replacing \`/model\` and \`/credential\`.
### Add an explicitly requested n8n node tool to an existing agent
1. Load \`agent-builder-node-tools\`, then call \`search_nodes\` and

View File

@ -38,10 +38,6 @@ import { getModelRecommendationsSection } from './agents-builder-model-recommend
import { buildBuilderPrompt, buildBuilderSessionContext } from './agents-builder-prompts';
import { AgentsBuilderToolsService, type BuilderTools } from './agents-builder-tools.service';
import { BuilderCheckpointUnavailableError } from './errors';
import {
BUILDER_PLANNER_TODOS_DESCRIPTION,
BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
} from './prompts/planner-todos.prompt';
import { getBuilderRuntimeSkills } from './skills';
import { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import { N8nMemory } from '../integrations/n8n-memory';
@ -376,16 +372,10 @@ export class AgentsBuilderService {
agentId: string,
session: InstanceAiBuilderSessionOptions,
): Promise<void> {
const { createPlannerTodosTool } = await import('@n8n/agents');
const plannerTodosTool = createPlannerTodosTool({
description: BUILDER_PLANNER_TODOS_DESCRIPTION,
systemInstruction: BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
});
const reportRequiredArtifactTool = await this.createRequiredArtifactTool(session);
const builderTools = [
...tools.json,
...tools.shared,
plannerTodosTool,
...(reportRequiredArtifactTool ? [reportRequiredArtifactTool] : []),
];
const claimedToolNames = new Set(builderTools.map((tool) => tool.name));

View File

@ -439,7 +439,7 @@ export function buildFinishSetupTool(deps: FinishSetupToolDeps): BuiltTool {
'Collect everything still needed to finish the initial build in ONE guided flow: open ' +
'questions (including the model choice), credential slots, and chat-channel ' +
'setup. Call it at most once, only in the trailing step of an initial build ' +
'when only blocked tasks remain, and never together with another interactive tool. ' +
'when only pending setup remains, and never together with another interactive tool. ' +
'It shows setup cards back-to-back without returning control between them: questions, ' +
'then credentials, then one card per requested channel. Channel cards always run last ' +
'after every credential phase, including when earlier setup was skipped or dismissed. Pass ' +

View File

@ -49,14 +49,15 @@ During an initial build:
\`credential\` omitted (skip verification), node tools with credential
slots omitted. Leave Episodic Memory disabled while its credential is
missing.
- Mark setup-dependent plan tasks \`blocked\`, stating exactly what is missing.
- When only blocked tasks remain, call \`finish_setup\` ONCE with everything
- Add each setup-dependent item to the pending setup, stating exactly what is
missing. Keep building everything that does not depend on it.
- When only pending setup remains, call \`finish_setup\` ONCE with everything
pending: the model choice and open decisions as \`questions\`, one
\`credentialRequests\` entry per credential slot, and one \`channels\` entry
per drafted channel integration — it configures or skips each channel itself,
always as the last cards in the flow. Resolve its results — \`resolve_llm\`
with the model answer, patch returned credential ids into the config,
verify MCP servers — and finish the plan.
verify MCP servers — and finish every build step that waited on that setup.
- Do not call \`configure_channel\` again after \`finish_setup\` handles a channel card.
- After \`finish_setup\`, end your reply with a short setup checklist for
skipped or dismissed setup that remains unresolved — one line per item naming

View File

@ -15,7 +15,7 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
2. If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`. If the result has \`claimedFreeOpenAiCredits: true\`, tell the user you set them up with free OpenAI credits. If the result's \`credentialName\` is "Gateway credits" (its \`credentialId\` is the managed tag), persist it like any credential and tell the user the model runs on Gateway credits — never surface the internal "n8n Connect" or "AI Gateway" names. If it has \`autoPicked: true\`, tell the user which provider and model you picked and that they can ask to change it — do not ask for confirmation and do not raise a trailing model question.
3. If the user asks to pick, change, confirm, or configure a model or main credential, ask via \`ask_questions\`; do not ask in prose.
- Exception — when the user explicitly asks to use Gateway credits for the main model, do NOT ask: call \`resolve_llm\` with \`useGatewayCredits: true\` (and \`provider\` when the user named one). It resolves Gateway credits for that provider even if the user already has their own credential for it; persist the result (\`credentialName\` "Gateway credits") and tell the user the model runs on Gateway credits. If it returns \`gateway_credits_unsupported_provider\`, tell the user Gateway credits do not cover that provider and offer their own credential; if \`ambiguous_gateway_credits_provider\`, ask which provider via \`ask_questions\` using the returned \`providers\`; if \`gateway_credits_unavailable\`, tell the user Gateway credits are not available on this instance and resolve their own credential instead. On \`unknown_model\`, retry with a value from the returned \`availableModels\` or surface those options.
4. During an initial build, if \`resolve_llm\` reports missing or ambiguous credentials/provider, do not ask: mark the model task \`blocked\`, keep building with \`model: ""\` and no \`credential\`, and include the model choice as a question in the trailing \`finish_setup\` call — for ambiguity between multiple credentials of one provider, use the credential names from the resolve_llm result as the question's options — when it resolves, call \`resolve_llm\` with the answer (pass \`credentialId\` when the user picked a specific credential) and patch \`/model\` and \`/credential\` — base the patch on the \`config\` and \`configHash\` that \`finish_setup\` returned, and check that config first, since the user may have already set the model in the panel. For a model change on an existing agent, ask immediately instead, and never write \`model: ""\` over an existing model — keep the current model and credential until the new one is resolved.
4. During an initial build, if \`resolve_llm\` reports missing or ambiguous credentials/provider, do not ask: add the model choice to the pending setup, keep building with \`model: ""\` and no \`credential\`, and include the model choice as a question in the trailing \`finish_setup\` call — for ambiguity between multiple credentials of one provider, use the credential names from the resolve_llm result as the question's options — when it resolves, call \`resolve_llm\` with the answer (pass \`credentialId\` when the user picked a specific credential) and patch \`/model\` and \`/credential\` — base the patch on the \`config\` and \`configHash\` that \`finish_setup\` returned, and check that config first, since the user may have already set the model in the panel. For a model change on an existing agent, ask immediately instead, and never write \`model: ""\` over an existing model — keep the current model and credential until the new one is resolved.
5. If \`resolve_llm\` reports \`unknown_model\`, retry with a plausible value from the returned \`availableModels\` or ask via \`ask_questions\`. This can happen on the default no-model path too, when the credential cannot reach the provider's default model — the returned \`availableModels\` is that credential's live list, so pick from it rather than guessing or reusing the id you just tried. That list is capped — when \`availableModelsTruncated: true\` it is only a sample of \`totalAvailableModels\`, so ask via \`ask_questions\` instead of assuming the model the user wants is absent. If it reports \`model_lookup_failed\` (the provider's model list could not be fetched, e.g. a transient error — possible even on the default no-model path), retry \`resolve_llm\`; do not guess a model.
6. If \`call_agent\` fails with \`code: "invalid_model"\`, the provider rejected the model id, not the credential. Do not tell the user to check, revoke, or regenerate their API key. Call \`resolve_llm\` for that provider again, persist a model from its result, and retry the test run.
7. If the model is still unresolved when the user asks to run or publish the agent, leave the draft with \`model: ""\`, do not guess a model, and tell the user the agent needs a model and credential first — in the panel or here in chat. If they dismiss a model-change question on an existing agent, keep the current model and credential unchanged.

View File

@ -1,26 +0,0 @@
/**
* Builder-specific model-visible text for the SDK's `createPlannerTodosTool`.
* The SDK ships domain-neutral defaults; these strings keep the agent
* builder's `write_todos` tool description/instructions unchanged from
* before the SDK text was made generic.
*/
export const BUILDER_PLANNER_TODOS_DESCRIPTION =
'Create or update a structured task list for the current build. Use it to decompose the work, ' +
'track progress, and record which tasks are blocked on user input. Use it for every build or ' +
'change request. This tool only updates the task list; it does not perform work or ask the user.';
export const BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION = [
'write_todos maintains your plan for the current build. It never asks the user or performs work itself.',
'WHEN TO USE write_todos:',
'- Every request that builds or changes the agent, before other tool calls.',
'- You need to track progress or record tasks blocked on user input.',
'WHEN NOT TO USE write_todos:',
'- Purely conversational replies with no build work.',
'HOW TO USE write_todos:',
'- Write concrete, self-contained tasks; mark the first active task in_progress immediately.',
'- Mark a task blocked when it cannot proceed without user input, and state exactly what input is missing in the task content.',
'- Marking a task blocked IS the action for now. Do not ask the user about it while any non-blocked task remains; continue with unblocked tasks.',
'- When only blocked tasks remain: during an initial build, follow the Initial Build rules (call finish_setup); otherwise end your turn with a summary of what is missing, and unblock and finish the tasks in later turns as the user provides input.',
'- Update task status as soon as work completes; do not batch completions at the end.',
'- Do not call write_todos multiple times in parallel; send one full list update at a time.',
].join('\n');

View File

@ -133,10 +133,13 @@ The \`integrations\` array controls how the target agent is triggered.
agent; do not follow up with \`patch_config\`/\`write_config\` to write the
credential.
- ${INITIAL_BUILD_NOTE} Instead of \`configure_channel\`: after
\`agent-context({ type: "integrations" })\` returns the matching type,
\`patch_config\` (with the \`configHash\` from your latest config read or write) adding \`{ "type": "<integrationType>", "credentialId": "" }\`
to \`/integrations/-\` (include a minimal valid draft \`settings\` object for
telegram) so the channel appears in the agent panel as needing setup. Pass
\`agent-context({ type: "integrations" })\` returns the matching type, add
\`{ "type": "<integrationType>", "credentialId": "" }\` to \`integrations\`
in your single initial config write (include a minimal valid draft
\`settings\` object for telegram) so the channel appears in the agent panel
as needing setup. If the config is already written, \`patch_config\` (with
the \`configHash\` from your latest config read or write) adding it to
\`/integrations/-\` instead. Pass
the same \`integrationType\` in the trailing \`finish_setup\` call's
\`channels\` array — its card configures or skips the channel itself. Do not
call \`configure_channel\` again after \`finish_setup\` handles the card. If
@ -227,8 +230,10 @@ Follow these steps for the selected MCP result:
credential is already persisted.
${INITIAL_BUILD_NOTE} For MCP that means: pick the best candidate as an
assumption (above), then \`patch_config\` (with the \`configHash\` from your latest config read or write) a draft
\`/mcpServers/-\` entry using \`name\`, \`url\`, \`transport\`,
assumption (above), then add a draft \`mcpServers\` entry to your single
initial config write (or \`patch_config\` it to \`/mcpServers/-\`, with the
\`configHash\` from your latest config read or write, if the config is
already written) using \`name\`, \`url\`, \`transport\`,
\`authentication\`, and \`metadata.nodeTypeName\` from \`selectedResult\` with
\`credential\` omitted, and skip \`verify_mcp_server\` — there is nothing to
authenticate yet. Include the credential in the trailing \`finish_setup\` call;

View File

@ -48,8 +48,8 @@ specific-page fetching, use only \`n8n-nodes-base.httpRequestTool\`.
Use only an exact URL explicitly supplied by the user for
\`n8n-nodes-base.httpRequestTool\`. If the user has not supplied one, you MUST
ask which URL the tool should fetch. During an initial build, mark this setup
as blocked and include the URL question in the single trailing \`finish_setup\`
ask which URL the tool should fetch. During an initial build, add this to the
pending setup and include the URL question in the single trailing \`finish_setup\`
call; add the tool with the returned URL after the user answers. On later
turns, call \`${ASK_QUESTIONS_TOOL_NAME}\` and wait for the answer before
mutating the config. Do not search for, derive, infer, guess, or invent a URL

View File

@ -68,9 +68,9 @@ applicable sections are filled with real content (the actual steps, rules,
examples, and edge cases). Derive missing domain detail from the user's
stated goal as stated assumptions, and list them in your summary. Use
\`ask_questions\` only when even a reasonable assumption is impossible — never
during an initial build: mark the task \`blocked\` instead, per the Initial
Build rules in your system prompt. Never create a placeholder or vague
skill.
during an initial build: add the open decision to the pending setup
instead, per the Initial Build rules in your system prompt. Never create a
placeholder or vague skill.
## Workflow

View File

@ -74,9 +74,9 @@ BOTH of these are true for it:
as an assumption.
Use \`ask_questions\` only when even a reasonable assumption is impossible —
never during an initial build: mark the task \`blocked\` instead, per the
Initial Build rules in your system prompt. Never create a placeholder or
"refine-it-later" task.
never during an initial build: add the open decision to the pending setup
instead, per the Initial Build rules in your system prompt. Never create a
placeholder or "refine-it-later" task.
## Workflow