mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
refactor(core): drop ask_llm in favor of the shared question flow
Model selection now flows the same way on both chat surfaces: ask_questions (batched question card) resolves the user's choice, then resolve_llm validates provider/model and resolves the credential. The dedicated ask_llm picker tool, its FE card, and the instance-AI session-exclusion/bounce plumbing built around it are removed as redundant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
aa4f936329
commit
fe2d547860
|
|
@ -13,7 +13,6 @@ import {
|
|||
* by it. There is no separate `interactionType` field — the tool name IS the
|
||||
* interaction kind.
|
||||
*/
|
||||
export const ASK_LLM_TOOL_NAME = 'ask_llm' as const;
|
||||
export const ASK_CREDENTIAL_TOOL_NAME = 'ask_credential' as const;
|
||||
export const ASK_EMBEDDING_CREDENTIAL_TOOL_NAME = 'ask_embedding_credential' as const;
|
||||
export { ASK_QUESTIONS_TOOL_NAME, CONFIGURE_CHANNEL_TOOL_NAME };
|
||||
|
|
@ -26,7 +25,6 @@ export { ASK_QUESTIONS_TOOL_NAME, CONFIGURE_CHANNEL_TOOL_NAME };
|
|||
export const APPROVAL_TOOL_NAME = 'approval' as const;
|
||||
|
||||
export const interactiveToolNameSchema = z.union([
|
||||
z.literal(ASK_LLM_TOOL_NAME),
|
||||
z.literal(ASK_CREDENTIAL_TOOL_NAME),
|
||||
z.literal(ASK_EMBEDDING_CREDENTIAL_TOOL_NAME),
|
||||
z.literal(ASK_QUESTIONS_TOOL_NAME),
|
||||
|
|
@ -35,29 +33,6 @@ export const interactiveToolNameSchema = z.union([
|
|||
|
||||
export type InteractiveToolName = z.infer<typeof interactiveToolNameSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ask_llm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const askLlmInputSchema = z.object({
|
||||
purpose: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Short sentence describing why the model is needed, e.g. "Main LLM for the Slack triage agent"',
|
||||
),
|
||||
});
|
||||
|
||||
export const askLlmResumeSchema = z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
credentialId: z.string(),
|
||||
credentialName: z.string(),
|
||||
});
|
||||
|
||||
export type AskLlmInput = z.infer<typeof askLlmInputSchema>;
|
||||
export type AskLlmResume = z.infer<typeof askLlmResumeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ask_credential
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -17,18 +17,13 @@ export {
|
|||
// ASK_QUESTIONS_TOOL_NAME / CONFIGURE_CHANNEL_TOOL_NAME come from
|
||||
// ./agent-interaction.schema (re-exported below via `export *`).
|
||||
export {
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
|
||||
APPROVAL_TOOL_NAME,
|
||||
interactiveToolNameSchema,
|
||||
askLlmInputSchema,
|
||||
askLlmResumeSchema,
|
||||
askCredentialInputSchema,
|
||||
cancellationResumeSchema,
|
||||
type InteractiveToolName,
|
||||
type AskLlmInput,
|
||||
type AskLlmResume,
|
||||
type AskCredentialInput,
|
||||
type CancellationResumeData,
|
||||
} from '../agent-builder-interactive';
|
||||
|
|
|
|||
|
|
@ -698,14 +698,17 @@ builder UI.
|
|||
**Returns**: `{ ok: true, builderReply, configUpdated }` on success, or
|
||||
`{ ok: false, error }`.
|
||||
|
||||
`configure_channel` (mandatory channel-setup UX for chat integrations) and
|
||||
`ask_questions` (batched multi-question card) are standard builder tools —
|
||||
available in the agents-module builder's own UI chat too, not injected extras
|
||||
for sub-agent runs. Their suspend/resume payloads already match instance AI's
|
||||
`configure_channel` (mandatory channel-setup UX for chat integrations),
|
||||
`ask_questions` (batched multi-question card), and `resolve_llm` (non-interactive
|
||||
model/credential resolution) are standard builder tools — available in the
|
||||
agents-module builder's own UI chat too, not injected extras for sub-agent
|
||||
runs. Model selection goes through `ask_questions` followed by `resolve_llm`,
|
||||
so there is no dedicated model-picker tool to special-case. Interactive
|
||||
suspend/resume payloads already match instance AI's
|
||||
confirmation-request/confirm-response wire contract
|
||||
(`@n8n/api-types` `agent-interaction.schema.ts`), so `build-agent` cascades
|
||||
them through unchanged. A slim session prompt addendum steers the builder
|
||||
away from the one tool this surface can't show a card for (`ask_llm`).
|
||||
them through unchanged — including any suspension this surface has no
|
||||
dedicated card for, which falls back to the generic approval card.
|
||||
|
||||
## Other Domain Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
cancellationResumeSchema,
|
||||
channelConfigSchema,
|
||||
channelSuspendPayloadSchema,
|
||||
credentialSuspendPayloadSchema,
|
||||
|
|
@ -674,149 +672,41 @@ describe('build-agent tool — interactive suspend/resume cascade', () => {
|
|||
expect(publishedEvents.filter((event) => event.type === 'agent-completed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounces an ask_llm suspension back into the builder with a cancellation resume, without suspending', async () => {
|
||||
const { context, delegate } = makeContext();
|
||||
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
|
||||
vi.mocked(delegate.streamBuild).mockResolvedValue(
|
||||
fakeStream(
|
||||
[
|
||||
// `agentRunId` on cascading suspension results is always '' for
|
||||
// builder turns — the stream never carries a real run id (see
|
||||
// resumable-stream-executor.ts). Resume routing must come from
|
||||
// `findOpenSuspension`, not this chunk.
|
||||
suspensionChunk({
|
||||
toolCallId: 'call-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: { purpose: 'Main LLM' },
|
||||
}),
|
||||
],
|
||||
'',
|
||||
),
|
||||
);
|
||||
vi.mocked(delegate.findOpenSuspension).mockResolvedValue({
|
||||
runId: 'builder-run-1',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: {},
|
||||
});
|
||||
vi.mocked(delegate.resumeBuild).mockResolvedValue(fakeStream([], 'Got it, using Anthropic.'));
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const result = await runToolWithCtx(
|
||||
context,
|
||||
{ message: 'Build it', name: 'New Agent' },
|
||||
{ resumeData: undefined, suspend: suspendFn },
|
||||
);
|
||||
|
||||
expect(delegate.resumeBuild).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
{
|
||||
runId: 'builder-run-1',
|
||||
toolCallId: 'tc-1',
|
||||
resumeData: {
|
||||
_type: 'agent.cancellation',
|
||||
// Steers the builder to the ask_questions HITL card instead of a
|
||||
// plain-text question that would end the turn.
|
||||
message: expect.stringContaining('via the ask_questions tool') as string,
|
||||
},
|
||||
},
|
||||
{ threadId: 'ia-builder:thread-1:agent-1', modelConfig: context.modelId },
|
||||
);
|
||||
const [, resumeCall] = vi.mocked(delegate.resumeBuild).mock.calls[0];
|
||||
expect(cancellationResumeSchema.parse(resumeCall.resumeData)).toEqual(resumeCall.resumeData);
|
||||
expect(suspendFn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
builderReply: 'Got it, using Anthropic.',
|
||||
configUpdated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a friendly error and stops the loop when the ask_llm bounce cannot recover an open suspension', async () => {
|
||||
it('cascades a suspension from an unrecognized tool through unchanged, with no special-casing by tool name', async () => {
|
||||
const { context, delegate, publishedEvents } = makeContext();
|
||||
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
|
||||
const payload = {
|
||||
message: 'The agent builder needs something this chat has no dedicated card for',
|
||||
severity: 'info' as const,
|
||||
somethingToolSpecific: { arbitrary: true },
|
||||
};
|
||||
vi.mocked(delegate.streamBuild).mockResolvedValue(
|
||||
fakeStream(
|
||||
[
|
||||
suspensionChunk({
|
||||
toolCallId: 'call-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: { purpose: 'Main LLM' },
|
||||
toolName: 'some_future_interactive_tool',
|
||||
suspendPayload: payload,
|
||||
}),
|
||||
],
|
||||
'',
|
||||
),
|
||||
);
|
||||
vi.mocked(delegate.findOpenSuspension).mockResolvedValue(null);
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const result = await runToolWithCtx(
|
||||
await runToolWithCtx(
|
||||
context,
|
||||
{ message: 'Build it', name: 'New Agent' },
|
||||
{ resumeData: undefined, suspend: suspendFn },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/builder's question could not be recovered/);
|
||||
expect(suspendFn).toHaveBeenCalledTimes(1);
|
||||
expect(suspendFn.mock.calls[0][0]).toEqual({
|
||||
...payload,
|
||||
requestId: expect.any(String) as unknown,
|
||||
});
|
||||
expect(delegate.resumeBuild).not.toHaveBeenCalled();
|
||||
expect(suspendFn).not.toHaveBeenCalled();
|
||||
expect(publishedEvents.filter((event) => event.type === 'agent-spawned')).toHaveLength(1);
|
||||
const completedEvents = publishedEvents.filter((event) => event.type === 'agent-completed');
|
||||
expect(completedEvents).toHaveLength(1);
|
||||
expect('payload' in completedEvents[0] ? completedEvents[0].payload : undefined).toMatchObject({
|
||||
role: 'agent-builder',
|
||||
error: result.error,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops bouncing ask_llm after 3 cancellations and returns a friendly error, publishing one agent-spawned and one agent-completed', async () => {
|
||||
const { context, delegate, publishedEvents } = makeContext();
|
||||
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
|
||||
const askLlmSuspension = () =>
|
||||
fakeStream(
|
||||
[
|
||||
suspensionChunk({
|
||||
toolCallId: 'call-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: { purpose: 'Main LLM' },
|
||||
}),
|
||||
],
|
||||
'',
|
||||
);
|
||||
vi.mocked(delegate.streamBuild).mockResolvedValue(askLlmSuspension());
|
||||
vi.mocked(delegate.findOpenSuspension).mockResolvedValue({
|
||||
runId: 'builder-run-1',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: {},
|
||||
});
|
||||
// A delegate that never accepts the "ask in plain text" cancellation and
|
||||
// keeps re-suspending ask_llm on every resume.
|
||||
vi.mocked(delegate.resumeBuild).mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return askLlmSuspension();
|
||||
});
|
||||
|
||||
const result = await runTool(context, { message: 'Build it', name: 'New Agent' });
|
||||
|
||||
expect(delegate.resumeBuild).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'The agent builder repeatedly requested a model picker this chat cannot show. ' +
|
||||
'Tell the user to configure the builder model in the agents module settings.',
|
||||
});
|
||||
expect(publishedEvents.filter((event) => event.type === 'agent-spawned')).toHaveLength(1);
|
||||
const completedEvents = publishedEvents.filter((event) => event.type === 'agent-completed');
|
||||
expect(completedEvents).toHaveLength(1);
|
||||
expect(completedEvents[0]).toMatchObject({
|
||||
type: 'agent-completed',
|
||||
agentId: 'agent-builder:agent-1',
|
||||
});
|
||||
expect('payload' in completedEvents[0] ? completedEvents[0].payload : undefined).toMatchObject({
|
||||
role: 'agent-builder',
|
||||
error: result.error,
|
||||
});
|
||||
expect(publishedEvents.map((event) => event.type)).toEqual(['agent-spawned']);
|
||||
});
|
||||
|
||||
it('returns a friendly error when resuming and the builder question is no longer open', async () => {
|
||||
|
|
@ -853,59 +743,6 @@ describe('build-agent tool — interactive suspend/resume cascade', () => {
|
|||
expect(delegate.findOpenSuspension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suspends on a second interactive question after bouncing an ask_llm suspension internally', async () => {
|
||||
const { context, delegate } = makeContext();
|
||||
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
|
||||
vi.mocked(delegate.streamBuild).mockResolvedValue(
|
||||
fakeStream(
|
||||
[
|
||||
suspensionChunk({
|
||||
toolCallId: 'call-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: {},
|
||||
}),
|
||||
],
|
||||
'',
|
||||
),
|
||||
);
|
||||
vi.mocked(delegate.findOpenSuspension).mockResolvedValue({
|
||||
runId: 'builder-run-1',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
suspendPayload: {},
|
||||
});
|
||||
const secondPayload = askQuestionsSuspendPayload({
|
||||
message: 'Continue?',
|
||||
questions: [{ id: 'q1', question: 'Continue?', type: 'text' }],
|
||||
});
|
||||
vi.mocked(delegate.resumeBuild).mockResolvedValue(
|
||||
fakeStream(
|
||||
[
|
||||
suspensionChunk({
|
||||
toolCallId: 'call-2',
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
suspendPayload: secondPayload,
|
||||
}),
|
||||
],
|
||||
'',
|
||||
),
|
||||
);
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
await runToolWithCtx(
|
||||
context,
|
||||
{ message: 'Build it', name: 'New Agent' },
|
||||
{ resumeData: undefined, suspend: suspendFn },
|
||||
);
|
||||
|
||||
expect(delegate.resumeBuild).toHaveBeenCalledTimes(1);
|
||||
expect(suspendFn).toHaveBeenCalledTimes(1);
|
||||
expect(suspendFn.mock.calls[0][0]).toEqual({
|
||||
...secondPayload,
|
||||
requestId: expect.any(String) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes agent-completed and rethrows when consumeStreamCascading itself throws mid-loop', async () => {
|
||||
const { context, delegate, publishedEvents } = makeContext();
|
||||
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
* builder UI — it is a private sub-agent conversation.
|
||||
*/
|
||||
import { Tool, type InterruptibleToolContext } from '@n8n/agents';
|
||||
import { ASK_LLM_TOOL_NAME, type CancellationResumeData } from '@n8n/api-types';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -79,11 +78,6 @@ function buildOutboundMessage(message: string, workflowContext?: SessionWorkflow
|
|||
return `${message}\n\n${formatWorkflowContextEnvelope(workflowContext)}`;
|
||||
}
|
||||
|
||||
/** Cancellation resume for interactions the builder can't surface yet (e.g. `ask_llm`, until its FE kind ships). */
|
||||
function builderCancellationResume(message: string): CancellationResumeData {
|
||||
return { _type: 'agent.cancellation', message };
|
||||
}
|
||||
|
||||
const buildAgentInputSchema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
|
|
@ -205,14 +199,6 @@ async function finishTurn(
|
|||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap on ask_llm cancellation bounces (see below) per invocation of the
|
||||
* consume loop. Guards against a builder that keeps re-suspending ask_llm
|
||||
* (e.g. it never accepts the "ask via ask_questions" cancellation) bouncing
|
||||
* forever instead of terminating the turn.
|
||||
*/
|
||||
const ASK_LLM_BOUNCE_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* Drain one builder turn, cascading any interactive suspension into this
|
||||
* tool's own `ctx.suspend()`. Shared by the first-call and resume legs so
|
||||
|
|
@ -227,88 +213,45 @@ async function runBuilderConsumeLoop(params: {
|
|||
builderAgentId: string;
|
||||
initialTurn: BuilderTurnStream;
|
||||
}): Promise<BuildAgentOutput> {
|
||||
const { context, delegate, ctx, target, session, builderAgentId } = params;
|
||||
let turn = params.initialTurn;
|
||||
let askLlmBounceCount = 0;
|
||||
const { context, ctx, builderAgentId, initialTurn: turn } = params;
|
||||
|
||||
for (;;) {
|
||||
let result: ConsumeStreamCascadingResult;
|
||||
try {
|
||||
result = await consumeStreamCascading({
|
||||
agent: undefined,
|
||||
stream: turn,
|
||||
runId: context.runId,
|
||||
agentId: builderAgentId,
|
||||
eventBus: context.eventBus,
|
||||
logger: context.logger,
|
||||
threadId: context.threadId,
|
||||
abortSignal: context.abortSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
publishAgentBuilderFailure(context, builderAgentId, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.status !== 'suspended') return await finishTurn(context, builderAgentId, result);
|
||||
|
||||
const toolName = result.suspension.toolName ?? '';
|
||||
if (toolName === ASK_LLM_TOOL_NAME) {
|
||||
askLlmBounceCount += 1;
|
||||
if (askLlmBounceCount > ASK_LLM_BOUNCE_LIMIT) {
|
||||
const message =
|
||||
'The agent builder repeatedly requested a model picker this chat cannot show. ' +
|
||||
'Tell the user to configure the builder model in the agents module settings.';
|
||||
publishAgentBuilderFailure(context, builderAgentId, new Error(message));
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
// The stream never carries a real runId for builder turns (see
|
||||
// resumable-stream-executor.ts), so resume routing must go through the
|
||||
// same server-side checkpoint lookup the resume leg uses, not
|
||||
// `result.agentRunId` (always `''`).
|
||||
const open = await delegate.findOpenSuspension(target.agentId, session);
|
||||
if (!open) {
|
||||
const message = "The builder's question could not be recovered; the build turn was lost.";
|
||||
publishAgentBuilderFailure(context, builderAgentId, new Error(message));
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
// Temporary until the llm-picker FE kind ships: bounce back into the
|
||||
// builder so it asks conversationally instead of dead-ending.
|
||||
turn = await delegate.resumeBuild(
|
||||
target.agentId,
|
||||
{
|
||||
runId: open.runId,
|
||||
toolCallId: open.toolCallId,
|
||||
resumeData: builderCancellationResume(
|
||||
'This chat cannot show the model picker. Ask which provider and model to use ' +
|
||||
'via the ask_questions tool (single-select options with a recommended ' +
|
||||
'default), then call resolve_llm with the choice to resolve the credential.',
|
||||
),
|
||||
},
|
||||
session,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The builder's interactive tools now natively emit the shared
|
||||
// instance-AI confirmation payload shape, so this cascades the suspend
|
||||
// payload straight through — only the requestId is re-minted at this
|
||||
// (orchestrator) level. Still validated defensively: a malformed payload
|
||||
// fails the turn instead of throwing out of the tool handler.
|
||||
const suspendPayload = result.suspension.suspendPayload;
|
||||
const parsedSuspend = isRecord(suspendPayload)
|
||||
? buildAgentSuspendSchema.safeParse({ ...suspendPayload, requestId: nanoid() })
|
||||
: undefined;
|
||||
if (!parsedSuspend?.success) {
|
||||
const message =
|
||||
"The agent builder's confirmation request was malformed; the build turn was lost.";
|
||||
publishAgentBuilderFailure(context, builderAgentId, new Error(message));
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
return await ctx.suspend(parsedSuspend.data);
|
||||
let result: ConsumeStreamCascadingResult;
|
||||
try {
|
||||
result = await consumeStreamCascading({
|
||||
agent: undefined,
|
||||
stream: turn,
|
||||
runId: context.runId,
|
||||
agentId: builderAgentId,
|
||||
eventBus: context.eventBus,
|
||||
logger: context.logger,
|
||||
threadId: context.threadId,
|
||||
abortSignal: context.abortSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
publishAgentBuilderFailure(context, builderAgentId, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.status !== 'suspended') return await finishTurn(context, builderAgentId, result);
|
||||
|
||||
// Any interactive suspension — including one the FE has no dedicated card
|
||||
// for — cascades through the generic passthrough: the suspend payload is
|
||||
// forwarded as-is (only the requestId is re-minted at this orchestrator
|
||||
// level) and the FE's approval-card fallback renders it. Still validated
|
||||
// defensively: a malformed payload fails the turn instead of throwing out
|
||||
// of the tool handler.
|
||||
const suspendPayload = result.suspension.suspendPayload;
|
||||
const parsedSuspend = isRecord(suspendPayload)
|
||||
? buildAgentSuspendSchema.safeParse({ ...suspendPayload, requestId: nanoid() })
|
||||
: undefined;
|
||||
if (!parsedSuspend?.success) {
|
||||
const message =
|
||||
"The agent builder's confirmation request was malformed; the build turn was lost.";
|
||||
publishAgentBuilderFailure(context, builderAgentId, new Error(message));
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
return await ctx.suspend(parsedSuspend.data);
|
||||
}
|
||||
|
||||
export function createBuildAgentTool(context: OrchestrationContext) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type { AgentsService } from '../agents.service';
|
|||
import type { AgentsBuilderService } from '../builder/agents-builder.service';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import {
|
||||
BUILDER_EXCLUDED_TOOL_NAMES,
|
||||
INSTANCE_AI_BUILDER_ADDENDUM,
|
||||
InstanceAiBuilderDelegateAdapterService,
|
||||
} from '../instance-ai-builder-delegate.adapter';
|
||||
|
|
@ -33,23 +32,17 @@ function setup() {
|
|||
};
|
||||
}
|
||||
|
||||
/** Extract the extraTools/addendum/excludeTools from a builder-service session-arg call. */
|
||||
/** Extract the addendum from a builder-service session-arg call. */
|
||||
function sessionArg(call: unknown[]): {
|
||||
extraTools?: Array<{ name: string }>;
|
||||
instructionsAddendum?: string;
|
||||
excludeTools?: string[];
|
||||
} {
|
||||
const session = call.at(-1) as
|
||||
| {
|
||||
extraTools?: Array<{ name: string }>;
|
||||
instructionsAddendum?: string;
|
||||
excludeTools?: string[];
|
||||
}
|
||||
| undefined;
|
||||
return {
|
||||
extraTools: session?.extraTools,
|
||||
instructionsAddendum: session?.instructionsAddendum,
|
||||
excludeTools: session?.excludeTools,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -118,20 +111,15 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
|
|||
expect(session?.modelConfig).toBe('anthropic/claude-sonnet-host-resolved');
|
||||
});
|
||||
|
||||
it('does not inject any extraTools — ask_questions/configure_channel are now standard builder tools', async () => {
|
||||
it('passes the instance-AI session addendum through to the builder service', async () => {
|
||||
const { delegate, agentsBuilderService } = setup();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentsBuilderService.buildAgent.mockReturnValue(asAsyncGenerator<StreamChunk>([]));
|
||||
|
||||
await delegate.streamBuild('agent-1', 'hi', { threadId: 'ia-builder:t:agent-1' });
|
||||
|
||||
const { extraTools, instructionsAddendum, excludeTools } = sessionArg(
|
||||
agentsBuilderService.buildAgent.mock.calls[0],
|
||||
);
|
||||
expect(extraTools).toBeUndefined();
|
||||
const { instructionsAddendum } = sessionArg(agentsBuilderService.buildAgent.mock.calls[0]);
|
||||
expect(instructionsAddendum).toBe(INSTANCE_AI_BUILDER_ADDENDUM);
|
||||
expect(excludeTools).toEqual(BUILDER_EXCLUDED_TOOL_NAMES);
|
||||
expect(excludeTools).toContain('ask_llm');
|
||||
});
|
||||
|
||||
it('rejects when the user lacks agent:update scope', async () => {
|
||||
|
|
@ -186,7 +174,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
|
|||
expect(session?.modelConfig).toBe('anthropic/claude-sonnet-host-resolved');
|
||||
});
|
||||
|
||||
it('does not inject any extraTools — ask_questions/configure_channel are now standard builder tools', async () => {
|
||||
it('passes the instance-AI session addendum through to the builder service', async () => {
|
||||
const { delegate, agentsBuilderService } = setup();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentsBuilderService.resumeBuild.mockReturnValue(asAsyncGenerator<StreamChunk>([]));
|
||||
|
|
@ -197,13 +185,8 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
|
|||
{ threadId: 'ia-builder:t:agent-1' },
|
||||
);
|
||||
|
||||
const { extraTools, instructionsAddendum, excludeTools } = sessionArg(
|
||||
agentsBuilderService.resumeBuild.mock.calls[0],
|
||||
);
|
||||
expect(extraTools).toBeUndefined();
|
||||
const { instructionsAddendum } = sessionArg(agentsBuilderService.resumeBuild.mock.calls[0]);
|
||||
expect(instructionsAddendum).toBe(INSTANCE_AI_BUILDER_ADDENDUM);
|
||||
expect(excludeTools).toEqual(BUILDER_EXCLUDED_TOOL_NAMES);
|
||||
expect(excludeTools).toContain('ask_llm');
|
||||
});
|
||||
|
||||
it('rejects when the user lacks agent:update scope', async () => {
|
||||
|
|
|
|||
|
|
@ -258,35 +258,16 @@ describe('AgentsBuilderService session isolation', () => {
|
|||
expect(agentsSdkMocks.instructionsCalls[0]).not.toContain('Extra sub-agent rules');
|
||||
});
|
||||
|
||||
it('omits standard tools named in session.excludeTools while still registering the rest', async () => {
|
||||
it('registers all standard tools returned by the tools service', async () => {
|
||||
const { service, user, credentialProvider } = setup({
|
||||
json: [fakeTool('ask_llm'), fakeTool('read_config')],
|
||||
json: [fakeTool('resolve_llm'), fakeTool('read_config')],
|
||||
shared: [fakeTool('ask_credential')],
|
||||
});
|
||||
|
||||
await drain(
|
||||
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
|
||||
threadId: 'ia-builder:t:agent-1',
|
||||
excludeTools: ['ask_llm'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(agentsSdkMocks.registeredToolNames).not.toContain('ask_llm');
|
||||
expect(agentsSdkMocks.registeredToolNames).toEqual(
|
||||
expect.arrayContaining(['read_config', 'ask_credential']),
|
||||
);
|
||||
});
|
||||
|
||||
it('registers all standard tools when session.excludeTools is absent', async () => {
|
||||
const { service, user, credentialProvider } = setup({
|
||||
json: [fakeTool('ask_llm'), fakeTool('read_config')],
|
||||
shared: [],
|
||||
});
|
||||
|
||||
await drain(service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user));
|
||||
|
||||
expect(agentsSdkMocks.registeredToolNames).toEqual(
|
||||
expect.arrayContaining(['ask_llm', 'read_config']),
|
||||
expect.arrayContaining(['resolve_llm', 'read_config', 'ask_credential']),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -82,10 +82,11 @@ tool-capable.
|
|||
Treat this list as authoritative for model recommendations. Use these models
|
||||
when the user does not know what model to pick. Prefer a recommended model for
|
||||
a provider the user has credentials for; then call resolve_llm with that
|
||||
provider and model, or ask_llm if the user needs to choose a credential.
|
||||
provider and model, or ask via ask_questions if the user needs to choose a
|
||||
credential.
|
||||
Do not mention models outside this list unless the user explicitly names one
|
||||
and resolve_llm validates it. Do not write a model or credential directly
|
||||
without a resolve_llm or ask_llm result.
|
||||
without a resolve_llm result.
|
||||
|
||||
${rows.join('\n')}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,25 +101,27 @@ must be persisted exactly as returned.
|
|||
|
||||
Once you are building, ask for any specific decision, choice, value, or
|
||||
clarification through one of these tools rather than in plain prose. Use
|
||||
\`ask_llm\` for the model/credential, \`ask_credential\` for node-tool credentials,
|
||||
\`configure_channel\` for chat-channel connections, and \`ask_questions\` for
|
||||
everything else. Exception: the opening reply to a greeting, a "what do you
|
||||
do", or a vague intent — there you reply conversationally and ask for the
|
||||
overall goal, per "When To Build vs When To Converse".
|
||||
\`ask_credential\` for node-tool credentials, \`configure_channel\` for
|
||||
chat-channel connections, and \`ask_questions\` for everything else, including
|
||||
the model/credential choice — resolve the answer with \`resolve_llm\`.
|
||||
Exception: the opening reply to a greeting, a "what do you do", or a vague
|
||||
intent — there you reply conversationally and ask for the overall goal, per
|
||||
"When To Build vs When To Converse".
|
||||
|
||||
- \`ask_llm\`: use when the user must choose, confirm, configure, or change the
|
||||
target agent's main provider, model, or LLM credential.
|
||||
- \`ask_credential\`: use once per required node-tool credential slot before
|
||||
the config mutation that introduces the tool. NEVER use it for a chat-channel
|
||||
credential — use \`configure_channel\` instead.
|
||||
- \`configure_channel\`: ALWAYS use this to connect a chat platform (Slack,
|
||||
Telegram, ...) as an agent channel, with a type from \`list_integration_types\`.
|
||||
The setup UI creates and persists the credential itself.
|
||||
- \`ask_questions\`: the default way to ask the user anything that isn't a model,
|
||||
node-tool credential, or channel choice. Batch every question you currently
|
||||
need into a single call instead of asking one at a time. Each question is
|
||||
single-select, multi-select, or free-text; pass discrete \`options\` for a
|
||||
known small set of choices, or \`type: "text"\` for an open-ended question.
|
||||
- \`ask_questions\`: the default way to ask the user anything that isn't a
|
||||
node-tool credential or channel choice, including when the user must choose,
|
||||
confirm, configure, or change the target agent's main provider, model, or
|
||||
LLM credential — resolve the answer with \`resolve_llm\`. Batch every
|
||||
question you currently need into a single call instead of asking one at a
|
||||
time. Each question is single-select, multi-select, or free-text; pass
|
||||
discrete \`options\` for a known small set of choices, or \`type: "text"\` for
|
||||
an open-ended question.
|
||||
- Never call two interactive tools in parallel. The run suspends on the first.
|
||||
- Never re-ask a question the user already answered in this thread.
|
||||
- After resume, continue with the next concrete tool action. Do not narrate the
|
||||
|
|
@ -173,17 +175,18 @@ change, call \`read_config\` again.`;
|
|||
export const IMPORTANT_SECTION = `\
|
||||
## Important
|
||||
|
||||
- Credentials are user-controlled. Use \`resolve_llm\` or \`ask_llm\` for the
|
||||
target agent's main model, \`ask_credential\` for node-tool or Episodic Memory
|
||||
credentials, and \`configure_channel\` (never \`ask_credential\`) for chat-channel
|
||||
- Credentials are user-controlled. Use \`resolve_llm\` (asking via
|
||||
\`ask_questions\` first when the user must choose) for the target agent's
|
||||
main model, \`ask_credential\` for node-tool or Episodic Memory credentials,
|
||||
and \`configure_channel\` (never \`ask_credential\`) for chat-channel
|
||||
credentials. Never copy credential IDs from \`list_credentials\` into config.
|
||||
- To get a specific decision, choice, or value for a build step, use
|
||||
\`ask_questions\` (discrete options for a known set, \`type: "text"\` for
|
||||
open-ended; batch multiple questions into one call), or
|
||||
\`ask_llm\`/\`ask_credential\`/\`configure_channel\` for model, credential, and
|
||||
channel choices — not plain prose. Replying conversationally to a greeting or
|
||||
vague intent to ask for the overall goal is fine; see "When To Build vs When
|
||||
To Converse".
|
||||
open-ended; batch multiple questions into one call) for model, credential,
|
||||
and other choices, or \`ask_credential\`/\`configure_channel\` for node-tool
|
||||
and channel credentials — not plain prose. Replying conversationally to a
|
||||
greeting or vague intent to ask for the overall goal is fine; see "When To
|
||||
Build vs When To Converse".
|
||||
- Tool preference order for real-world integrations:
|
||||
1. MCP servers (\`search_mcp_servers\`) — always check first
|
||||
2. Node tools (\`search_nodes\`)
|
||||
|
|
@ -224,7 +227,8 @@ export const WORKFLOW_SECTION = `\
|
|||
## Workflow
|
||||
|
||||
1. If the agent has no \`instructions\` and \`credential\` yet, call
|
||||
\`resolve_llm\` when the user specified a provider/model, or call \`ask_llm\` if user didn't specify a provider/model.
|
||||
\`resolve_llm\` when the user specified a provider/model, or ask via
|
||||
\`ask_questions\` and call \`resolve_llm\` with the answer if they didn't.
|
||||
2. Draft real target-agent \`instructions\`; never write empty placeholders.
|
||||
3. Use \`ask_questions\` for clarifying questions with discrete options, batching
|
||||
multiple questions into one call.
|
||||
|
|
@ -242,7 +246,8 @@ export const FEW_SHOT_FLOWS_SECTION = `\
|
|||
## Example flows
|
||||
|
||||
### New agent: "Build me a Slack triage agent"
|
||||
1. \`ask_llm({ purpose: "Choose a model" })\` -> resolved provider, model, and credential.
|
||||
1. \`ask_questions({ ... })\` for the model choice, then
|
||||
\`resolve_llm({ provider, model })\` -> resolved provider, model, and credential.
|
||||
2. \`search_nodes({ query: "slack" })\`, then \`get_node_types(...)\`.
|
||||
3. \`ask_credential(...)\` for the Slack credential slot.
|
||||
4. \`read_config()\`.
|
||||
|
|
@ -255,7 +260,8 @@ export const FEW_SHOT_FLOWS_SECTION = `\
|
|||
\`credential\`, and requested instructions.
|
||||
|
||||
### Change the existing model
|
||||
1. \`ask_llm({ purpose: "Choose a different model" })\`.
|
||||
1. \`ask_questions({ ... })\` for the new model choice, then
|
||||
\`resolve_llm({ provider, model })\`.
|
||||
2. \`read_config()\`.
|
||||
3. \`patch_config(...)\` replacing \`/model\` and \`/credential\`.
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ import { buildGetResourceLocatorOptionsTool } from './get-resource-locator-optio
|
|||
import {
|
||||
buildAskCredentialTool,
|
||||
buildAskEmbeddingCredentialTool,
|
||||
buildAskLlmTool,
|
||||
buildAskQuestionsTool,
|
||||
buildConfigureChannelTool,
|
||||
buildResolveLlmTool,
|
||||
|
|
@ -514,7 +513,6 @@ export class AgentsBuilderToolsService {
|
|||
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
|
||||
isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(),
|
||||
}),
|
||||
buildAskLlmTool(),
|
||||
buildAskQuestionsTool(),
|
||||
buildConfigureChannelTool({
|
||||
agentId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type {
|
||||
BuiltTool,
|
||||
CredentialProvider,
|
||||
ModelConfig,
|
||||
SerializableAgentState,
|
||||
|
|
@ -41,16 +40,8 @@ interface FindSuspendedCheckpointOptions {
|
|||
export interface BuilderSessionOptions {
|
||||
/** Overrides the persistence thread id. Default: `builder:<agentId>`. */
|
||||
threadId?: string;
|
||||
/** Extra tools registered on the builder agent (e.g. instance-AI-injected). */
|
||||
extraTools?: BuiltTool[];
|
||||
/** Extra text appended to the builder prompt (e.g. instance-AI sub-agent rules). */
|
||||
instructionsAddendum?: string;
|
||||
/**
|
||||
* Tool names to omit for this session, e.g. interactive tools with no UI on
|
||||
* the host surface. Only filters the standard tool set — `extraTools` are
|
||||
* always registered regardless of this list.
|
||||
*/
|
||||
excludeTools?: string[];
|
||||
/**
|
||||
* Overrides model resolution for this session — when set, the builder runs
|
||||
* on this model directly instead of `AgentsBuilderSettingsService.resolveModelConfig`.
|
||||
|
|
@ -276,13 +267,7 @@ export class AgentsBuilderService {
|
|||
});
|
||||
if (telemetry) builder.telemetry(telemetry);
|
||||
|
||||
const excludeTools = new Set(session?.excludeTools ?? []);
|
||||
for (const tool of [...tools.json, ...tools.shared]) {
|
||||
if (excludeTools.has(tool.name)) continue;
|
||||
builder.tool(tool);
|
||||
}
|
||||
|
||||
for (const tool of session?.extraTools ?? []) {
|
||||
builder.tool(tool);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Tool names used by the agent builder. Centralised so prompts, the SSE event
|
||||
* routing, and tests can't drift on string typos.
|
||||
*
|
||||
* The interactive tools (`ask_llm`, `ask_credential`, `ask_embedding_credential`,
|
||||
* The interactive tools (`ask_credential`, `ask_embedding_credential`,
|
||||
* `ask_questions`, `configure_channel`) are NOT listed here — their names live
|
||||
* in `@n8n/api-types` (`agent-builder-interactive.ts` / `agents/agent-interaction.schema.ts`)
|
||||
* alongside the suspend/resume schemas they share with instance AI's FE cards.
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import { buildAskLlmTool } from '../ask-llm.tool';
|
||||
|
||||
interface TestCtx {
|
||||
resumeData?: unknown;
|
||||
suspend: Mock;
|
||||
}
|
||||
|
||||
function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
|
||||
return { resumeData: overrides?.resumeData, suspend: vi.fn(async (x: unknown) => x) };
|
||||
}
|
||||
|
||||
describe('ask_llm tool', () => {
|
||||
it('instructs the builder to render the picker instead of asking in prose', () => {
|
||||
const tool = buildAskLlmTool();
|
||||
expect(tool.systemInstruction).toContain('Never ask the user in plain text');
|
||||
expect(tool.systemInstruction).toContain('call ask_llm');
|
||||
});
|
||||
|
||||
it('suspends on first invocation so the user can choose', async () => {
|
||||
const tool = buildAskLlmTool();
|
||||
const ctx = makeCtx();
|
||||
await tool.handler!({ purpose: 'Main LLM' }, ctx as never);
|
||||
expect(ctx.suspend).toHaveBeenCalledWith({ purpose: 'Main LLM' });
|
||||
});
|
||||
|
||||
it('returns resumeData verbatim after resume', async () => {
|
||||
const tool = buildAskLlmTool();
|
||||
const ctx = makeCtx({
|
||||
resumeData: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
credentialId: 'cX',
|
||||
credentialName: 'Picked',
|
||||
},
|
||||
});
|
||||
const result = await tool.handler!({ purpose: 'Main LLM' }, ctx as never);
|
||||
expect(ctx.suspend).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
credentialId: 'cX',
|
||||
credentialName: 'Picked',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import {
|
||||
ASK_LLM_TOOL_NAME,
|
||||
askLlmInputSchema,
|
||||
askLlmResumeSchema,
|
||||
type AskLlmInput,
|
||||
type AskLlmResume,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export function buildAskLlmTool(): BuiltTool {
|
||||
return new Tool(ASK_LLM_TOOL_NAME)
|
||||
.description(
|
||||
'Show a model + credential picker card in the chat UI and suspend until the user ' +
|
||||
'selects a provider, model and credential. ' +
|
||||
'After resume: set model = "{provider}/{model}" and credential = credentialId ' +
|
||||
'via write_config or patch_config.',
|
||||
)
|
||||
.systemInstruction(
|
||||
'Never ask the user in plain text to choose, confirm, configure, or change the agent ' +
|
||||
'main LLM, provider, model, or main LLM credential. If the user needs to make that ' +
|
||||
'choice, call ask_llm so the picker card is shown.',
|
||||
)
|
||||
.input(askLlmInputSchema)
|
||||
.suspend(askLlmInputSchema)
|
||||
.resume(askLlmResumeSchema)
|
||||
.handler(
|
||||
async (input: AskLlmInput, ctx: InterruptibleToolContext<AskLlmInput, AskLlmResume>) => {
|
||||
if (ctx.resumeData !== undefined) return ctx.resumeData;
|
||||
return await ctx.suspend(input);
|
||||
},
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
export { buildAskCredentialTool, buildAskEmbeddingCredentialTool } from './ask-credential.tool';
|
||||
export { buildAskLlmTool } from './ask-llm.tool';
|
||||
export { buildAskQuestionsTool } from './ask-questions.tool';
|
||||
export { buildConfigureChannelTool } from './configure-channel.tool';
|
||||
export { buildResolveLlmTool } from './resolve-llm.tool';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Canonical "if you have one of THIS credential type, this is the LLM provider
|
||||
* + model the builder may select when auto-resolving." Used by the ask_llm tool
|
||||
* when there's exactly one LLM-provider credential available.
|
||||
* + model the builder may select when auto-resolving." Used by the resolve_llm
|
||||
* tool when there's exactly one LLM-provider credential available.
|
||||
*
|
||||
* Provider strings match the provider IDs used by `@n8n/agents`'s
|
||||
* `.model(provider, model)` call.
|
||||
|
|
|
|||
|
|
@ -97,8 +97,8 @@ export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool {
|
|||
'If provider is given, resolves only that provider; if model is omitted, uses the ' +
|
||||
'provider default model. For "Anthropic via OpenRouter", pass provider="openrouter" ' +
|
||||
'and omit model unless the user named a concrete OpenRouter model id. Returns ok=false ' +
|
||||
'when credentials are missing, unsupported, or ambiguous; use ask_llm only when the ' +
|
||||
'user must choose.',
|
||||
'when credentials are missing, unsupported, or ambiguous; use ask_questions to let the ' +
|
||||
'user choose, then call resolve_llm again with the choice.',
|
||||
)
|
||||
.input(
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ ${getSchemaReferenceSection()}
|
|||
- Keep each feature in the schema path where it belongs.
|
||||
- Preserve unrelated existing config unless the user asked to change it.
|
||||
- Never write placeholder instructions, tool descriptions, or skill descriptions.
|
||||
- Never copy credential IDs from \`list_credentials\`; use \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
|
||||
- Never copy credential IDs from \`list_credentials\`; use \`resolve_llm\` or \`ask_credential\`.
|
||||
- Valid provider tool keys are complete provider tool IDs documented in the Tool Guidance section.
|
||||
- \`providerTools\` keys must be complete provider tool IDs from the valid key list.
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function getConfigRulesSection(): string {
|
|||
#### Agent Config Rules
|
||||
|
||||
- \`model\` must be "provider/model-name".
|
||||
- \`credential\` must be the id returned by \`resolve_llm\` or \`ask_llm\`.
|
||||
- \`credential\` must be the id returned by \`resolve_llm\`.
|
||||
- Fresh agents must include
|
||||
\`memory: { "enabled": true, "storage": "n8n" }\`
|
||||
unless the user explicitly asks to disable memory.
|
||||
|
|
@ -58,7 +58,7 @@ export function getConfigRulesSection(): string {
|
|||
- \`memory.episodicMemory\` requires \`ask_embedding_credential\` with
|
||||
\`credentialType: "openAiApi"\`; use its returned \`credentialId\` value.
|
||||
- Memory worker model fields use \`{ "model": "provider/model-name", "credential": "<credentialId>" }\`;
|
||||
use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
|
||||
use only credential IDs returned by \`resolve_llm\` or \`ask_credential\`.
|
||||
- Sub-agent configuration lives at top level under \`subAgents\`. Load
|
||||
\`agent-builder-sub-agents\` before adding refs or changing
|
||||
\`subAgents.maxChildren\`.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export function getLlmSelectionPrompt(modelRecommendationsSection: string | null): string {
|
||||
const recommendationGuidance = modelRecommendationsSection
|
||||
? `\n\n${modelRecommendationsSection}`
|
||||
: '\n\nNo Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Call ask_llm when the user needs model guidance or choice.';
|
||||
: '\n\nNo Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Ask via `ask_questions` when the user needs model guidance or choice.';
|
||||
|
||||
return `\
|
||||
## LLM Selection Guidance
|
||||
|
|
@ -12,17 +12,17 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
|
|||
|
||||
### Workflow
|
||||
|
||||
1. Use \`resolve_llm\` when the request contains enough provider/model detail, otherwise call \`ask_llm\`.
|
||||
1. Use \`resolve_llm\` when the request contains enough provider/model detail, otherwise ask via \`ask_questions\` and call \`resolve_llm\` with the answer.
|
||||
2. If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`.
|
||||
3. If the user asks to pick, change, confirm, or configure a model or main credential, call \`ask_llm\`; do not ask in prose.
|
||||
4. If \`resolve_llm\` reports missing or ambiguous credentials/provider, call \`ask_llm\`.
|
||||
5. If \`resolve_llm\` reports \`unknown_model\`, retry with a plausible returned model value or call \`ask_llm\`.
|
||||
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.
|
||||
4. If \`resolve_llm\` reports missing or ambiguous credentials/provider, ask via \`ask_questions\` then retry \`resolve_llm\` with the answer.
|
||||
5. If \`resolve_llm\` reports \`unknown_model\`, retry with a plausible returned model value or ask via \`ask_questions\`.
|
||||
|
||||
### Rules
|
||||
|
||||
- Fresh agents need a resolved \`model\` and \`credential\` before config is written.
|
||||
- Explicit provider/model requests go to \`resolve_llm\` first.
|
||||
- If the user asks to pick, change, confirm, or configure a model or main credential, call \`ask_llm\`; do not ask in prose.
|
||||
- If the user asks to pick, change, confirm, or configure a model or main credential, ask via \`ask_questions\`; do not ask in prose.
|
||||
- If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`.
|
||||
- Only OpenAI and Anthropic models support native web search. Use native web
|
||||
search by default for those providers only, and only for
|
||||
|
|
@ -39,15 +39,15 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
|
|||
\`provider: "searxng"\`.
|
||||
- If the user explicitly asks for Brave or SearXNG, keep that provider even
|
||||
when the selected model also supports native search.
|
||||
- If \`resolve_llm\` reports missing or ambiguous credentials/provider, call \`ask_llm\`.
|
||||
- If it reports \`unknown_model\`, retry with a plausible returned model value or call \`ask_llm\`.
|
||||
- If \`resolve_llm\` reports missing or ambiguous credentials/provider, ask via \`ask_questions\` then retry \`resolve_llm\`.
|
||||
- If it reports \`unknown_model\`, retry with a plausible returned model value or ask via \`ask_questions\`.
|
||||
- For "Anthropic via OpenRouter", pass \`provider: "openrouter"\`; if the user names a routed model, pass the routed id without adding another provider prefix.
|
||||
- Prefer a provider the user already has credentials for when choosing from recommendations.
|
||||
- Never copy main LLM credential IDs from \`list_credentials\`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Use \`resolve_llm\` or \`ask_llm\` only for the target agent's main model credential.
|
||||
- Use \`resolve_llm\` only for the target agent's main model credential.
|
||||
- Use \`ask_credential\` for node tools, integrations, and Episodic Memory.
|
||||
- For OpenRouter, \`provider\` is \`"openrouter"\`; the model can be a routed id such as \`anthropic/...\`.
|
||||
- Model changes must not silently replace existing Brave or SearXNG web search with native search.
|
||||
|
|
@ -56,6 +56,6 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
|
|||
### Verify
|
||||
|
||||
- The persisted \`model\` is in \`provider/model\` form.
|
||||
- The persisted \`credential\` came from \`resolve_llm\` or \`ask_llm\`.
|
||||
- The persisted \`credential\` came from \`resolve_llm\`.
|
||||
- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.${recommendationGuidance}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ separate user-facing memory product.
|
|||
- Supported observational memory tuning fields: \`enabled\`, \`observerModel\`, \`reflectorModel\`, \`observerThresholdTokens\`, \`reflectorThresholdTokens\`, \`renderTokenBudget\`, \`observationLogTailLimit\`, and \`lockTtlMs\`.
|
||||
- Memory worker model fields must use object shape: \`{ "model": "provider/model-name", "credential": "<credentialId>" }\`.
|
||||
- Only set \`observerModel\`, \`reflectorModel\`, \`extractorModel\`, or \`episodicMemory.reflectorModel\` when the user explicitly asks to use a specific model for memory work.
|
||||
- Use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\` for memory worker model fields. Do not invent IDs or copy a main-model credential unless one of those tools returned it for that worker model provider.
|
||||
- Use only credential IDs returned by \`resolve_llm\` or \`ask_credential\` for memory worker model fields. Do not invent IDs or copy a main-model credential unless one of those tools returned it for that worker model provider.
|
||||
|
||||
### Episodic Memory
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { CredentialProvider, StreamChunk } from '@n8n/agents';
|
||||
import { ASK_LLM_TOOL_NAME } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type {
|
||||
|
|
@ -18,26 +17,17 @@ import type { BuilderSessionOptions } from './builder/agents-builder.service';
|
|||
import { getSuspendedToolCalls } from './utils/messages-envelope';
|
||||
|
||||
/**
|
||||
* Standard builder tool names to omit for instance-AI sub-agent sessions.
|
||||
* `ask_llm` has no card UI in instance-AI chat (a bounce workaround exists in
|
||||
* instance-ai's build-agent tool instead), so it must never be offered here.
|
||||
*/
|
||||
export const BUILDER_EXCLUDED_TOOL_NAMES: string[] = [ASK_LLM_TOOL_NAME];
|
||||
|
||||
/**
|
||||
* Prompt addendum for sub-agent runs; exported for tests. `ask_questions` and
|
||||
* `configure_channel` are now part of the builder's own standard toolset and
|
||||
* carry their own usage guidance in the builder prompts/skills — only the
|
||||
* rules that are genuinely specific to running inside instance AI's chat
|
||||
* remain here.
|
||||
* Prompt addendum for sub-agent runs; exported for tests. `ask_questions`,
|
||||
* `resolve_llm`, and `configure_channel` are all part of the builder's own
|
||||
* standard toolset and carry their own usage guidance in the builder
|
||||
* prompts/skills — only the rule that is genuinely specific to running
|
||||
* inside instance AI's chat remains here.
|
||||
*/
|
||||
export const INSTANCE_AI_BUILDER_ADDENDUM = `## Instance AI session rules
|
||||
|
||||
You are running as a sub-agent inside n8n's instance AI chat; the user sees your questions as chat cards.
|
||||
|
||||
The agent preview link is not visible in this chat; describe outcomes in text instead of linking the preview.
|
||||
|
||||
\`ask_llm\` is NOT available in this chat. For model selection: ask via \`ask_questions\`, then call \`resolve_llm\` with the choice to resolve the credential.`;
|
||||
The agent preview link is not visible in this chat; describe outcomes in text instead of linking the preview.`;
|
||||
|
||||
function isTextDeltaChunk(
|
||||
chunk: StreamChunk,
|
||||
|
|
@ -90,16 +80,14 @@ export class InstanceAiBuilderDelegateAdapterService {
|
|||
) {}
|
||||
|
||||
/**
|
||||
* Prompt rules that only apply to the sub-agent surface: `ask_llm` (no
|
||||
* card UI in this chat) is excluded from the standard tool set.
|
||||
* `ask_questions` and `configure_channel` are now part of the builder's
|
||||
* Prompt rules that only apply to the sub-agent surface. `ask_questions`,
|
||||
* `resolve_llm`, and `configure_channel` are all part of the builder's
|
||||
* own standard toolset — no per-session tool injection needed anymore.
|
||||
*/
|
||||
private buildSubAgentSession(session: BuilderDelegateSession): BuilderSessionOptions {
|
||||
return {
|
||||
threadId: session.threadId,
|
||||
instructionsAddendum: INSTANCE_AI_BUILDER_ADDENDUM,
|
||||
excludeTools: BUILDER_EXCLUDED_TOOL_NAMES,
|
||||
modelConfig: session.modelConfig,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4822,7 +4822,6 @@
|
|||
"agents.modelSelector.freeCredits.label": "Use free OpenAI credits",
|
||||
"agents.modelSelector.freeCredits.badge": "free credits",
|
||||
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
|
||||
"agents.askLlm.chooseModel": "Choose a model",
|
||||
"agents.builder.unconfigured.title": "Set up the agent builder",
|
||||
"agents.builder.unconfigured.description.admin": "Choose a provider and credential so the agent builder can help you design agents.",
|
||||
"agents.builder.unconfigured.description.nonAdmin": "Ask an instance admin to configure the agent builder before you can build agents here.",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { flushPromises, mount } from '@vue/test-utils';
|
|||
import { computed, h, ref } from 'vue';
|
||||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
type InteractiveToolName,
|
||||
} from '@n8n/api-types';
|
||||
|
|
@ -122,31 +121,24 @@ describe('AgentChatPanel', () => {
|
|||
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['slack'] }],
|
||||
},
|
||||
}
|
||||
: toolName === ASK_LLM_TOOL_NAME
|
||||
? {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: { purpose: 'Choose a model' },
|
||||
}
|
||||
: {
|
||||
toolName: ASK_CREDENTIAL_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: {
|
||||
requestId: 'req-1',
|
||||
message: 'Choose Slack credentials',
|
||||
severity: 'info',
|
||||
credentialRequests: [
|
||||
{
|
||||
credentialType: 'slackApi',
|
||||
reason: 'Choose Slack credentials',
|
||||
existingCredentials: [],
|
||||
},
|
||||
],
|
||||
credentialFlow: { stage: 'generic' },
|
||||
},
|
||||
: {
|
||||
toolName: ASK_CREDENTIAL_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: {
|
||||
requestId: 'req-1',
|
||||
message: 'Choose Slack credentials',
|
||||
severity: 'info',
|
||||
credentialRequests: [
|
||||
{
|
||||
credentialType: 'slackApi',
|
||||
reason: 'Choose Slack credentials',
|
||||
existingCredentials: [],
|
||||
},
|
||||
],
|
||||
credentialFlow: { stage: 'generic' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -392,7 +384,7 @@ describe('AgentChatPanel', () => {
|
|||
expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
|
||||
});
|
||||
|
||||
it.each([ASK_LLM_TOOL_NAME, ASK_CREDENTIAL_TOOL_NAME])(
|
||||
it.each([ASK_QUESTIONS_TOOL_NAME, ASK_CREDENTIAL_TOOL_NAME])(
|
||||
'enables chat input while %s is unresolved (cancel-and-steer mode)',
|
||||
(toolName) => {
|
||||
messagesMock.value = [openInteractiveMessage(toolName)];
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-explicit-any -- test-only */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const selectorState = vi.hoisted(() => ({
|
||||
selection: { provider: 'anthropic', model: 'claude-sonnet-4-5' },
|
||||
}));
|
||||
|
||||
const credentialsByProvider = ref<Record<string, string> | undefined>({ anthropic: 'cred-1' });
|
||||
const selectCredential = vi.fn();
|
||||
const ensureLoaded = vi.fn();
|
||||
const getModelsForPicker = vi.fn(() => ({
|
||||
anthropic: { models: [] },
|
||||
}));
|
||||
const isLoading = ref(false);
|
||||
const personalProject = ref<{ id: string } | null>({ id: 'p1' });
|
||||
const allCredentials = ref<Array<{ id: string; name: string }>>([
|
||||
{ id: 'cred-1', name: 'My Anthropic' },
|
||||
]);
|
||||
const currentUserId = ref('user-1');
|
||||
|
||||
vi.mock('@/features/settings/users/users.store', () => ({
|
||||
useUsersStore: () => ({
|
||||
get currentUserId() {
|
||||
return currentUserId.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
get allCredentials() {
|
||||
return allCredentials.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/collaboration/projects/projects.store', () => ({
|
||||
useProjectsStore: () => ({
|
||||
get personalProject() {
|
||||
return personalProject.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, useRoute: () => ({ params: {} }) };
|
||||
});
|
||||
|
||||
vi.mock('../composables/useModelCatalog', () => ({
|
||||
useModelCatalog: () => ({
|
||||
ensureLoaded,
|
||||
getModelsForPicker,
|
||||
isLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentModelCredentials', () => ({
|
||||
useAgentModelCredentials: () => ({ credentialsByProvider, selectCredential }),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AgentModelSelector.vue', () => ({
|
||||
default: {
|
||||
name: 'AgentModelSelector',
|
||||
props: [
|
||||
'selectedModel',
|
||||
'credentials',
|
||||
'modelsByProvider',
|
||||
'isLoading',
|
||||
'warnMissingCredentials',
|
||||
],
|
||||
emits: ['change', 'select-credential'],
|
||||
setup(_props: unknown, { emit }: { emit: (event: string, payload: unknown) => void }) {
|
||||
return {
|
||||
selectModel: () => emit('change', selectorState.selection),
|
||||
};
|
||||
},
|
||||
template: '<div data-testid="model-selector" @click="selectModel" />',
|
||||
},
|
||||
}));
|
||||
|
||||
import AskLlmCard from '../components/interactive/AskLlmCard.vue';
|
||||
|
||||
function mountCard(props: Record<string, unknown> = {}) {
|
||||
return mount(AskLlmCard, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
N8nText: { template: '<span><slot/></span>', props: ['size', 'bold', 'color', 'tag'] },
|
||||
N8nIcon: { template: '<i v-bind="$attrs"></i>', props: ['icon', 'size', 'color'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectorState.selection = { provider: 'anthropic', model: 'claude-sonnet-4-5' };
|
||||
credentialsByProvider.value = { anthropic: 'cred-1' };
|
||||
personalProject.value = { id: 'p1' };
|
||||
allCredentials.value = [{ id: 'cred-1', name: 'My Anthropic' }];
|
||||
});
|
||||
|
||||
describe('AskLlmCard', () => {
|
||||
it('renders the purpose text', async () => {
|
||||
const wrapper = mountCard({ purpose: 'Pick a main model' });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Pick a main model');
|
||||
});
|
||||
|
||||
it('falls back to the default heading when no purpose is supplied', async () => {
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Choose a model');
|
||||
});
|
||||
|
||||
it('fetches the model catalog on mount', async () => {
|
||||
mountCard();
|
||||
await flushPromises();
|
||||
expect(ensureLoaded).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('emits a complete resume payload when the model selector emits a change', async () => {
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="model-selector"]').trigger('click');
|
||||
|
||||
const emitted = wrapper.emitted('submit') as unknown[][];
|
||||
expect(emitted).toBeTruthy();
|
||||
expect(emitted[0][0]).toEqual({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
credentialId: 'cred-1',
|
||||
credentialName: 'My Anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit when disabled — guards against accidental commits in resolved state', async () => {
|
||||
const wrapper = mountCard({ disabled: true });
|
||||
await flushPromises();
|
||||
|
||||
const selector = wrapper.find('[data-testid="model-selector"]');
|
||||
if (selector.exists()) await selector.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders the resolved provider/model and credential name when given resolvedValue', async () => {
|
||||
const wrapper = mountCard({
|
||||
disabled: true,
|
||||
resolvedValue: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
credentialId: 'cred-1',
|
||||
credentialName: 'My Anthropic',
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('anthropic/claude-sonnet-4-5');
|
||||
expect(text).toContain('My Anthropic');
|
||||
});
|
||||
|
||||
it('strips the "models/" prefix from Google model ids before emitting', async () => {
|
||||
selectorState.selection = { provider: 'google', model: 'models/gemini-2.5-pro' };
|
||||
credentialsByProvider.value = { google: 'cred-g' };
|
||||
allCredentials.value = [{ id: 'cred-g', name: 'My Google' }];
|
||||
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="model-selector"]').trigger('click');
|
||||
|
||||
const emitted = wrapper.emitted('submit') as unknown[][];
|
||||
expect((emitted[0][0] as { model: string }).model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
APPROVAL_TOOL_NAME,
|
||||
N8N_CHAT_ACTION_TOOL_NAME,
|
||||
|
|
@ -28,46 +27,18 @@ function credentialSuspendInput(credentialType: string, reason: string) {
|
|||
};
|
||||
}
|
||||
|
||||
/** A full `questionsSuspendPayloadSchema`-shaped input, for fixtures that construct `InteractivePayload` literals directly. */
|
||||
function questionsSuspendInput(question: string) {
|
||||
return {
|
||||
requestId: 'req-q1',
|
||||
message: question,
|
||||
severity: 'info' as const,
|
||||
inputType: 'questions' as const,
|
||||
questions: [{ id: 'q1', question, type: 'text' as const }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('rebuildInteractiveFromHistory', () => {
|
||||
it('rebuilds an OPEN ask_llm card when output is missing', () => {
|
||||
const result = rebuildInteractiveFromHistory({
|
||||
tool: ASK_LLM_TOOL_NAME,
|
||||
toolCallId: 'call-1',
|
||||
input: { purpose: 'pick a model' },
|
||||
state: 'suspended',
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.toolName).toBe(ASK_LLM_TOOL_NAME);
|
||||
expect(result?.resolvedAt).toBeUndefined();
|
||||
expect(result?.resolvedValue).toBeUndefined();
|
||||
// runId is the sidecar's responsibility — raw history doesn't carry it.
|
||||
expect(result?.runId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rebuilds a RESOLVED ask_llm card when output is present', () => {
|
||||
const result = rebuildInteractiveFromHistory({
|
||||
tool: ASK_LLM_TOOL_NAME,
|
||||
toolCallId: 'call-1',
|
||||
input: { purpose: 'pick a model' },
|
||||
output: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4',
|
||||
credentialId: 'cred-1',
|
||||
credentialName: 'My Anthropic',
|
||||
},
|
||||
state: 'done',
|
||||
});
|
||||
|
||||
expect(result?.resolvedAt).toBeGreaterThan(0);
|
||||
expect(result?.resolvedValue).toEqual({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4',
|
||||
credentialId: 'cred-1',
|
||||
credentialName: 'My Anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
it('rebuilds an ask_credential card with skipped resolved value', () => {
|
||||
const result = rebuildInteractiveFromHistory({
|
||||
tool: ASK_CREDENTIAL_TOOL_NAME,
|
||||
|
|
@ -149,9 +120,11 @@ describe('convertDbMessages — interactive turn synthesis', () => {
|
|||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolCallId: 'call-llm-1',
|
||||
input: { purpose: 'main' },
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'call-q-1',
|
||||
input: {
|
||||
questions: [{ id: 'q1', question: 'Which model?', type: 'text' }],
|
||||
},
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
|
|
@ -163,7 +136,7 @@ describe('convertDbMessages — interactive turn synthesis', () => {
|
|||
const assistant = chat[1];
|
||||
expect(assistant.role).toBe('assistant');
|
||||
expect(assistant.status).toBe('awaitingUser');
|
||||
expect(assistant.interactive?.toolName).toBe(ASK_LLM_TOOL_NAME);
|
||||
expect(assistant.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
|
||||
expect(assistant.interactive?.resolvedAt).toBeUndefined();
|
||||
expect(assistant.toolCalls?.[0].state).toBe('suspended');
|
||||
});
|
||||
|
|
@ -470,11 +443,11 @@ describe('isGroupable', () => {
|
|||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [{ tool: ASK_LLM_TOOL_NAME, toolCallId: 'c1', state: 'suspended' }],
|
||||
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c1', state: 'suspended' }],
|
||||
interactive: {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'c1',
|
||||
input: {},
|
||||
input: questionsSuspendInput('Which model?'),
|
||||
},
|
||||
status: 'awaitingUser',
|
||||
});
|
||||
|
|
@ -496,22 +469,20 @@ describe('isGroupable', () => {
|
|||
describe('buildDisplayGroups — interactive payloads', () => {
|
||||
it('collects interactive payloads from each grouped message into the toolRun group', () => {
|
||||
const groups = buildDisplayGroups([
|
||||
// First grouped turn: a resolved ask_llm card
|
||||
// First grouped turn: a resolved ask_questions card
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [{ tool: ASK_LLM_TOOL_NAME, toolCallId: 'c1', state: 'done' }],
|
||||
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c1', state: 'done' }],
|
||||
interactive: {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'c1',
|
||||
input: {},
|
||||
input: questionsSuspendInput('Which model?'),
|
||||
resolvedAt: 1,
|
||||
resolvedValue: {
|
||||
provider: 'a',
|
||||
model: 'b',
|
||||
credentialId: 'x',
|
||||
credentialName: 'y',
|
||||
answered: true,
|
||||
answers: [{ questionId: 'q1', selectedOptions: ['gpt-4'] }],
|
||||
},
|
||||
},
|
||||
status: 'success',
|
||||
|
|
@ -545,7 +516,7 @@ describe('buildDisplayGroups — interactive payloads', () => {
|
|||
if (grouped.kind !== 'toolRun') return;
|
||||
expect(grouped.toolCalls).toHaveLength(3);
|
||||
expect(grouped.interactives).toHaveLength(2);
|
||||
expect(grouped.interactives[0].toolName).toBe(ASK_LLM_TOOL_NAME);
|
||||
expect(grouped.interactives[0].toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
|
||||
expect(grouped.interactives[0].resolvedAt).toBeDefined();
|
||||
expect(grouped.interactives[1].toolName).toBe(ASK_CREDENTIAL_TOOL_NAME);
|
||||
expect(grouped.interactives[1].resolvedAt).toBeUndefined();
|
||||
|
|
@ -665,17 +636,15 @@ describe('applyOpenSuspensions', () => {
|
|||
id: 'm2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [{ tool: ASK_LLM_TOOL_NAME, toolCallId: 'c-resolved', state: 'done' }],
|
||||
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c-resolved', state: 'done' }],
|
||||
interactive: {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'c-resolved',
|
||||
input: { purpose: 'main' },
|
||||
input: questionsSuspendInput('Which model?'),
|
||||
resolvedAt: 1,
|
||||
resolvedValue: {
|
||||
provider: 'a',
|
||||
model: 'b',
|
||||
credentialId: 'x',
|
||||
credentialName: 'y',
|
||||
answered: true,
|
||||
answers: [{ questionId: 'q1', selectedOptions: ['gpt-4'] }],
|
||||
},
|
||||
},
|
||||
status: 'success',
|
||||
|
|
@ -696,9 +665,9 @@ describe('applyOpenSuspensions', () => {
|
|||
role: 'assistant',
|
||||
content: '',
|
||||
interactive: {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'c1',
|
||||
input: { purpose: 'main' },
|
||||
input: questionsSuspendInput('Which model?'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -714,9 +683,9 @@ describe('applyOpenSuspensions', () => {
|
|||
role: 'assistant',
|
||||
content: '',
|
||||
interactive: {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'c1',
|
||||
input: { purpose: 'main' },
|
||||
input: questionsSuspendInput('Which model?'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
N8N_CHAT_ACTION_TOOL_NAME,
|
||||
|
|
@ -26,7 +25,6 @@ describe('summariseInteractiveOutput', () => {
|
|||
'returns undefined for non-object output (%p)',
|
||||
(value) => {
|
||||
expect(summariseInteractiveOutput(ASK_CREDENTIAL_TOOL_NAME, value)).toBeUndefined();
|
||||
expect(summariseInteractiveOutput(ASK_LLM_TOOL_NAME, value)).toBeUndefined();
|
||||
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
|
@ -63,17 +61,6 @@ describe('summariseInteractiveOutput', () => {
|
|||
expect(summariseInteractiveOutput(ASK_CREDENTIAL_TOOL_NAME, { skipped: true })).toBe('Skipped');
|
||||
});
|
||||
|
||||
it('renders ask_llm provider/model + credential', () => {
|
||||
expect(
|
||||
summariseInteractiveOutput(ASK_LLM_TOOL_NAME, {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
credentialId: 'c1',
|
||||
credentialName: 'My Anthropic',
|
||||
}),
|
||||
).toBe('anthropic/claude-sonnet-4-6 · My Anthropic');
|
||||
});
|
||||
|
||||
it('renders configure_channel connected/skipped', () => {
|
||||
expect(summariseInteractiveOutput(CONFIGURE_CHANNEL_TOOL_NAME, { connected: true })).toBe(
|
||||
'Connected',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
describe('formatToolNameForDisplay', () => {
|
||||
it('formats snake_case builder tool names as readable labels', () => {
|
||||
expect(formatToolNameForDisplay('create_skill')).toBe('Create skill');
|
||||
expect(formatToolNameForDisplay('ask_llm')).toBe('Ask LLM');
|
||||
expect(formatToolNameForDisplay('resolve_llm')).toBe('Resolve LLM');
|
||||
expect(formatToolNameForDisplay('build_custom_tool')).toBe('Build custom tool');
|
||||
expect(formatToolNameForDisplay('update_memory')).toBe('Update memory');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { ref, nextTick } from 'vue';
|
|||
import {
|
||||
APPROVAL_TOOL_NAME,
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
N8N_CHAT_ACTION_TOOL_NAME,
|
||||
type AgentSseEvent,
|
||||
|
|
@ -79,15 +78,15 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders an interactive ask_llm card and stamps the runId from the suspended event', async () => {
|
||||
it('renders an interactive ask_questions card and stamps the runId from the suspended event', async () => {
|
||||
const events: AgentSseEvent[] = [
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-42',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
input: { purpose: 'main model' },
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
input: { questions: [{ question: 'Which model?', type: 'text' }] },
|
||||
},
|
||||
},
|
||||
{ type: 'done' },
|
||||
|
|
@ -104,7 +103,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
|
|||
expect(assistant.status).toBe('awaitingUser');
|
||||
expect(assistant.toolCalls).toHaveLength(1);
|
||||
expect(assistant.toolCalls?.[0].state).toBe('suspended');
|
||||
expect(assistant.interactive?.toolName).toBe(ASK_LLM_TOOL_NAME);
|
||||
expect(assistant.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
|
||||
expect(assistant.interactive?.runId).toBe('run-42');
|
||||
expect(assistant.interactive?.resolvedValue).toBeUndefined();
|
||||
expect(assistant.interactive?.resolvedAt).toBeUndefined();
|
||||
|
|
@ -160,16 +159,16 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
|
|||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
input: { purpose: 'main' },
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
input: { questions: [{ question: 'Which model?', type: 'text' }] },
|
||||
},
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
payload: {
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-7',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
input: { purpose: 'main' },
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
input: { questions: [{ question: 'Which model?', type: 'text' }] },
|
||||
},
|
||||
},
|
||||
{ type: 'done' },
|
||||
|
|
@ -356,18 +355,18 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
|
|||
// in place, not push a duplicate into a freshly-minted ChatMessage.
|
||||
const events: AgentSseEvent[] = [
|
||||
{ type: 'start-step' },
|
||||
{ type: 'tool-input-start', toolCallId: 'tc-1', toolName: ASK_LLM_TOOL_NAME },
|
||||
{ type: 'tool-input-start', toolCallId: 'tc-1', toolName: ASK_QUESTIONS_TOOL_NAME },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
input: { purpose: 'main' },
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
input: { questions: [{ question: 'Which model?', type: 'text' }] },
|
||||
},
|
||||
{ type: 'finish-step' },
|
||||
{
|
||||
type: 'tool-execution-start',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
startTime: 1_000,
|
||||
},
|
||||
{
|
||||
|
|
@ -375,8 +374,8 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
|
|||
payload: {
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-9',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
input: { purpose: 'main' },
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
input: { questions: [{ question: 'Which model?', type: 'text' }] },
|
||||
},
|
||||
},
|
||||
{ type: 'done' },
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { N8nCard, N8nText, N8nIcon } from '@n8n/design-system';
|
||||
import type { AskLlmResume } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useAgentModelCredentials } from '../../composables/useAgentModelCredentials';
|
||||
import { useAgentProjectId } from '../../composables/useAgentProjectId';
|
||||
import AgentModelSelector from '../AgentModelSelector.vue';
|
||||
import { sanitizeModelId } from '../../utils/model-string';
|
||||
import { useModelCatalog } from '../../composables/useModelCatalog';
|
||||
import {
|
||||
type AgentModelProvider,
|
||||
type AgentModelSelection,
|
||||
type AgentModelsByProvider,
|
||||
} from '../../model-providers';
|
||||
|
||||
const props = defineProps<{
|
||||
purpose?: string;
|
||||
disabled?: boolean;
|
||||
resolvedValue?: AskLlmResume;
|
||||
projectId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [
|
||||
resumeData: {
|
||||
provider: string;
|
||||
model: string;
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const usersStore = useUsersStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const { ensureLoaded, getModelsForPicker, isLoading } = useModelCatalog();
|
||||
|
||||
const projectId = useAgentProjectId(() => props.projectId);
|
||||
|
||||
const { credentialsByProvider, selectCredential } = useAgentModelCredentials(
|
||||
usersStore.currentUserId ?? 'anonymous',
|
||||
projectId,
|
||||
);
|
||||
|
||||
watch(
|
||||
projectId,
|
||||
(id) => {
|
||||
if (id) void ensureLoaded(id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const filteredAgents = computed<AgentModelsByProvider>(() =>
|
||||
getModelsForPicker(credentialsByProvider.value),
|
||||
);
|
||||
|
||||
function onModelChange(selection: AgentModelSelection) {
|
||||
if (props.disabled) return;
|
||||
|
||||
const credentialId = credentialsByProvider.value?.[selection.provider] ?? '';
|
||||
if (!credentialId) return;
|
||||
|
||||
const credentialName =
|
||||
credentialsStore.allCredentials.find((c) => c.id === credentialId)?.name ?? '';
|
||||
const model = sanitizeModelId(selection.provider, selection.model);
|
||||
|
||||
emit('submit', { provider: selection.provider, model, credentialId, credentialName });
|
||||
}
|
||||
|
||||
function onSelectCredential(provider: AgentModelProvider, credentialId: string | null) {
|
||||
selectCredential(provider, credentialId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCard :class="[$style.card, disabled && $style.disabled]" data-testid="ask-llm-card">
|
||||
<div :class="$style.cardBody">
|
||||
<N8nText tag="p" bold :class="$style.purpose">
|
||||
{{ purpose ?? i18n.baseText('agents.askLlm.chooseModel') }}
|
||||
</N8nText>
|
||||
|
||||
<!-- Resolved state: show what was selected instead of the live picker -->
|
||||
<div v-if="disabled && resolvedValue" :class="$style.resolved">
|
||||
<N8nIcon icon="circle-check" size="small" color="success" />
|
||||
<div :class="$style.resolvedDetails">
|
||||
<N8nText bold size="small"
|
||||
>{{ resolvedValue.provider }}/{{ resolvedValue.model }}</N8nText
|
||||
>
|
||||
<N8nText size="small" color="text-light">{{ resolvedValue.credentialName }}</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live picker: shown when not yet resolved -->
|
||||
<AgentModelSelector
|
||||
v-else
|
||||
:selected-model="null"
|
||||
:credentials="credentialsByProvider"
|
||||
:models-by-provider="filteredAgents"
|
||||
:is-loading="isLoading"
|
||||
:project-id="projectId"
|
||||
:warn-missing-credentials="true"
|
||||
@change="onModelChange"
|
||||
@select-credential="onSelectCredential"
|
||||
/>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
--card--padding: var(--spacing--sm);
|
||||
|
||||
gap: var(--spacing--xs);
|
||||
width: 90%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.resolved {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
color: var(--color--success);
|
||||
}
|
||||
|
||||
.resolvedDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.purpose {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--sm);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,7 +4,6 @@ import {
|
|||
APPROVAL_TOOL_NAME,
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
N8N_CHAT_ACTION_TOOL_NAME,
|
||||
|
|
@ -13,15 +12,14 @@ import type { AgentsChatInteractionRenderer } from '@/features/ai/shared/agentsC
|
|||
import InteractionRenderer from '@/features/ai/shared/agentsChat/components/InteractionRenderer.vue';
|
||||
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
|
||||
import AskCredentialCard from './AskCredentialCard.vue';
|
||||
import AskLlmCard from './AskLlmCard.vue';
|
||||
import AskQuestionsCard from './AskQuestionsCard.vue';
|
||||
import ApprovalCard from './ApprovalCard.vue';
|
||||
import ConfigureChannelCard from './ConfigureChannelCard.vue';
|
||||
import N8nChatActionCard from './N8nChatActionCard.vue';
|
||||
|
||||
/**
|
||||
* Single dispatch point for the interactive cards. `ask_llm`, `approval`,
|
||||
* and `chat_action` still dispatch by `toolName` (their payload shape isn't
|
||||
* Single dispatch point for the interactive cards. `approval` and
|
||||
* `chat_action` still dispatch by `toolName` (their payload shape isn't
|
||||
* shared with any other surface). `ask_questions`, `ask_credential` /
|
||||
* `ask_embedding_credential`, and `configure_channel` MATCH by the PAYLOAD
|
||||
* FIELD that's unique to their suspend schema
|
||||
|
|
@ -143,19 +141,6 @@ const interactiveRenderers = [
|
|||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ask_llm',
|
||||
component: AskLlmCard,
|
||||
matches: (payload) => payload.toolName === ASK_LLM_TOOL_NAME,
|
||||
getProps: (payload, context) => {
|
||||
if (payload.toolName !== ASK_LLM_TOOL_NAME) return {};
|
||||
return {
|
||||
purpose: payload.input.purpose,
|
||||
resolvedValue: payload.resolvedValue,
|
||||
projectId: context?.projectId,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'chat_action',
|
||||
component: N8nChatActionCard,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Model identifier helpers. The canonical storage format is `"<provider>/<name>"`.
|
||||
* Centralised here because three callers (Agent panel, Advanced panel, AskLlm
|
||||
* card) used to roll their own and drifted on naming + edge cases.
|
||||
* Centralised here because multiple callers (Agent info panel, memory panel,
|
||||
* sub-agents panel) used to roll their own and drifted on naming + edge cases.
|
||||
*/
|
||||
|
||||
export interface ParsedModel {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
type AgentPersistedMessageDto,
|
||||
} from '@n8n/api-types';
|
||||
import { ASK_QUESTIONS_TOOL_NAME, type AgentPersistedMessageDto } from '@n8n/api-types';
|
||||
|
||||
import {
|
||||
applyOpenSuspensions,
|
||||
|
|
@ -20,9 +16,9 @@ describe('shared agents chat message mapping', () => {
|
|||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolName: ASK_QUESTIONS_TOOL_NAME,
|
||||
toolCallId: 'call-1',
|
||||
input: { purpose: 'main model' },
|
||||
input: { questions: [{ question: 'Which model?', type: 'text' }] },
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
|
|
@ -32,7 +28,7 @@ describe('shared agents chat message mapping', () => {
|
|||
const chat = convertDbMessages(dbMessages);
|
||||
|
||||
expect(chat[0].status).toBe('awaitingUser');
|
||||
expect(chat[0].interactive?.toolName).toBe(ASK_LLM_TOOL_NAME);
|
||||
expect(chat[0].interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
|
||||
expect(chat[0].toolCalls?.[0].state).toBe('suspended');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
N8N_CHAT_ACTION_TOOL_NAME,
|
||||
type AskLlmResume,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import {
|
||||
|
|
@ -73,13 +71,6 @@ export function summariseInteractiveOutput(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
if (toolName === ASK_LLM_TOOL_NAME) {
|
||||
const resume = output as AskLlmResume;
|
||||
if (!resume.provider || !resume.model) return undefined;
|
||||
const slug = `${resume.provider}/${resume.model}`;
|
||||
return resume.credentialName ? `${slug} · ${resume.credentialName}` : slug;
|
||||
}
|
||||
|
||||
if (toolName === CONFIGURE_CHANNEL_TOOL_NAME) {
|
||||
if (typeof output.connected !== 'boolean') return undefined;
|
||||
return output.connected ? 'Connected' : 'Skipped';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
APPROVAL_TOOL_NAME,
|
||||
CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
N8N_CHAT_ACTION_TOOL_NAME,
|
||||
askCredentialInputSchema,
|
||||
askLlmInputSchema,
|
||||
askLlmResumeSchema,
|
||||
channelResumeSchema,
|
||||
channelSuspendPayloadSchema,
|
||||
credentialResumeSchema,
|
||||
|
|
@ -49,7 +46,6 @@ import type {
|
|||
const INTERACTIVE_TOOL_NAMES = [
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
] as readonly InteractiveToolName[];
|
||||
|
|
@ -156,7 +152,7 @@ function isDeclinedToolOutput(value: unknown): boolean {
|
|||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These three tools transform their resume payload into a distinct tool
|
||||
// OUTPUT shape rather than echoing it back (unlike ask_llm). `tc.input` is
|
||||
// OUTPUT shape rather than echoing it back. `tc.input` is
|
||||
// similarly dual-shaped: live suspensions overwrite it with the full suspend
|
||||
// payload (see `useAgentChatStream`'s `tool-call-suspended` handler), but
|
||||
// persisted history only ever carries the tool's original call args (the
|
||||
|
|
@ -300,9 +296,8 @@ function parseAskEmbeddingCredentialOutput(value: unknown) {
|
|||
* reconstruct an `InteractivePayload` for it. The result is:
|
||||
*
|
||||
* - **resolved**: when `output` is present — `resolvedValue` is parsed from it
|
||||
* via the matching zod schema. The output IS the user's resume payload (the
|
||||
* tool handler returns `ctx.resumeData` after a resume) for `ask_llm`; the
|
||||
* other interactive tools transform it, so `resolvedValue` is parsed
|
||||
* via the matching zod schema. Interactive tools transform the resume
|
||||
* payload into a distinct output shape, so `resolvedValue` is parsed
|
||||
* defensively (see the `parse*ResolvedValue` helpers above).
|
||||
* - **open**: when `output` is absent — the card renders as an active
|
||||
* awaiting-user prompt. Used when a refresh during a suspension restored the
|
||||
|
|
@ -373,18 +368,6 @@ export function rebuildInteractiveFromHistory(
|
|||
};
|
||||
}
|
||||
|
||||
if (tc.tool === ASK_LLM_TOOL_NAME) {
|
||||
const input = askLlmInputSchema.safeParse(tc.input ?? {});
|
||||
if (!input.success) return undefined;
|
||||
const resolved = tc.output !== undefined ? askLlmResumeSchema.safeParse(tc.output) : null;
|
||||
return {
|
||||
...base,
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
input: input.data,
|
||||
...(resolved?.success && { resolvedValue: resolved.data }),
|
||||
};
|
||||
}
|
||||
|
||||
if (tc.tool === CONFIGURE_CHANNEL_TOOL_NAME) {
|
||||
const input = buildChannelPayloadFromInput(tc.input, context);
|
||||
if (!input) return undefined;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@ import {
|
|||
type APPROVAL_TOOL_NAME,
|
||||
type ASK_CREDENTIAL_TOOL_NAME,
|
||||
type ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
|
||||
type ASK_LLM_TOOL_NAME,
|
||||
type ASK_QUESTIONS_TOOL_NAME,
|
||||
type CONFIGURE_CHANNEL_TOOL_NAME,
|
||||
type N8N_CHAT_ACTION_TOOL_NAME,
|
||||
type AskLlmInput,
|
||||
type AskLlmResume,
|
||||
type ChannelResumeData,
|
||||
type ChannelSuspendPayload,
|
||||
type CredentialResumeData,
|
||||
|
|
@ -78,8 +75,8 @@ export interface ApprovalResume {
|
|||
/**
|
||||
* `ask_questions` / `ask_credential` / `configure_channel` each transform
|
||||
* the FE's resume payload into a distinct tool-output shape rather than
|
||||
* echoing it back verbatim (unlike `ask_llm`). `resolvedValue` can therefore
|
||||
* be EITHER shape depending on where it came from:
|
||||
* echoing it back verbatim. `resolvedValue` can therefore be EITHER shape
|
||||
* depending on where it came from:
|
||||
*
|
||||
* - the resume payload itself (optimistic UI update in `useAgentChatStream`'s
|
||||
* `resume()`, applied before the backend confirms), or
|
||||
|
|
@ -113,11 +110,6 @@ export type InteractivePayload =
|
|||
input: ApprovalInput;
|
||||
resolvedValue?: ApprovalResume;
|
||||
})
|
||||
| (InteractivePayloadBase & {
|
||||
toolName: typeof ASK_LLM_TOOL_NAME;
|
||||
input: AskLlmInput;
|
||||
resolvedValue?: AskLlmResume;
|
||||
})
|
||||
| (InteractivePayloadBase & {
|
||||
toolName: typeof ASK_QUESTIONS_TOOL_NAME;
|
||||
input: QuestionsSuspendPayload;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user