mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 08:00:27 +02:00
feat(core): Agents as first class entities support (no-changelog) (#28017)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Michael Drury <michael.drury@n8n.io> Co-authored-by: Arvin A <51036481+DeveloperTheExplorer@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Arvin Ansari <arvin.ansari@n8n.io> Co-authored-by: bjorger <50590409+bjorger@users.noreply.github.com> Co-authored-by: Eugene <eugene@n8n.io> Co-authored-by: Michael Drury <me@michaeldrury.co.uk> Co-authored-by: Robin Braumann <robin.braumann@n8n.io> Co-authored-by: Rob Hough <robhough180@gmail.com>
This commit is contained in:
parent
6b1061386e
commit
64079ad98c
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -62,6 +62,7 @@ packages/cli/src/commands/export/outputs
|
|||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
.claude/worktrees/
|
||||
.claude/specs/
|
||||
.cursor/plans/
|
||||
.superset
|
||||
.conductor
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ At end of turn, `saveToMemory()` uses `list.turnDelta()` and
|
|||
`saveMessagesToThread`. If **semantic recall** is configured with an embedder
|
||||
and `memory.saveEmbeddings`, new messages are embedded and stored.
|
||||
|
||||
**Working memory:** when configured, the runtime injects an `updateWorkingMemory`
|
||||
**Working memory:** when configured, the runtime injects an `update_working_memory`
|
||||
tool into the agent's tool set. The current state is included in the system prompt
|
||||
so the model can read it; when new information should be persisted the model calls
|
||||
the tool, which validates the input and asynchronously persists via
|
||||
|
|
@ -415,7 +415,7 @@ src/
|
|||
tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend / agent-result guards
|
||||
stream.ts — convertChunk, toTokenUsage
|
||||
runtime-helpers.ts — normalizeInput, usage merge, stream error helpers, …
|
||||
working-memory.ts — instruction text, updateWorkingMemory tool builder
|
||||
working-memory.ts — instruction text, update_working_memory tool builder
|
||||
strip-orphaned-tool-messages.ts
|
||||
title-generation.ts
|
||||
logger.ts
|
||||
|
|
|
|||
|
|
@ -24,23 +24,31 @@
|
|||
"test:integration": "vitest run --config vitest.integration.config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "catalog:",
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
"@ai-sdk/azure": "catalog:",
|
||||
"@ai-sdk/cohere": "catalog:",
|
||||
"@ai-sdk/deepseek": "catalog:",
|
||||
"@ai-sdk/gateway": "catalog:",
|
||||
"@ai-sdk/google": "^3.0.43",
|
||||
"@ai-sdk/groq": "catalog:",
|
||||
"@ai-sdk/mistral": "catalog:",
|
||||
"@ai-sdk/openai": "^3.0.41",
|
||||
"@ai-sdk/xai": "^3.0.67",
|
||||
"@ai-sdk/provider-utils": "^4.0.21",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"ajv": "^8.18.0",
|
||||
"@ai-sdk/xai": "^3.0.67",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"@openrouter/ai-sdk-provider": "catalog:",
|
||||
"ai": "^6.0.116",
|
||||
"ajv": "^8.18.0",
|
||||
"pg": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"langsmith": ">=0.3.0",
|
||||
"@opentelemetry/sdk-trace-node": ">=1.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": ">=0.50.0",
|
||||
"@opentelemetry/sdk-trace-base": ">=1.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": ">=0.50.0"
|
||||
"@opentelemetry/sdk-trace-node": ">=1.0.0",
|
||||
"langsmith": ">=0.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"langsmith": {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { isLlmMessage } from '../sdk/message';
|
|||
import { Tool, Tool as ToolBuilder } from '../sdk/tool';
|
||||
import { AgentEvent } from '../types/runtime/event';
|
||||
import type { StreamChunk } from '../types/sdk/agent';
|
||||
import type { ContentToolResult, Message } from '../types/sdk/message';
|
||||
import type { BuiltMemory } from '../types/sdk/memory';
|
||||
import type { ContentToolCall, Message } from '../types/sdk/message';
|
||||
import type { BuiltTool, InterruptibleToolContext } from '../types/sdk/tool';
|
||||
import type { BuiltTelemetry } from '../types/telemetry';
|
||||
|
||||
|
|
@ -236,9 +237,9 @@ describe('AgentRuntime.generate() — graceful error contract', () => {
|
|||
generateText.mockRejectedValue(new Error('API failure'));
|
||||
|
||||
const { runtime } = createRuntime();
|
||||
const result = await runtime.generate('hello');
|
||||
await runtime.generate('hello');
|
||||
|
||||
expect(result.getState().status).toBe('failed');
|
||||
expect(runtime.getState().status).toBe('failed');
|
||||
});
|
||||
|
||||
it('emits AgentEvent.Error (not AgentEnd) when the LLM call throws', async () => {
|
||||
|
|
@ -266,10 +267,10 @@ describe('AgentRuntime.generate() — graceful error contract', () => {
|
|||
// Abort during AgentStart so the loop's first abort-check fires before generateText is called
|
||||
bus.on(AgentEvent.AgentStart, () => bus.abort());
|
||||
|
||||
const result = await runtime.generate('hello');
|
||||
await runtime.generate('hello');
|
||||
|
||||
expect(errorEvents.length).toBe(0);
|
||||
expect(result.getState().status).toBe('cancelled');
|
||||
expect(runtime.getState().status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('returns finishReason "error" and sets cancelled status on abort', async () => {
|
||||
|
|
@ -282,7 +283,7 @@ describe('AgentRuntime.generate() — graceful error contract', () => {
|
|||
const result = await runtime.generate('hello');
|
||||
|
||||
expect(result.finishReason).toBe('error');
|
||||
expect(result.getState().status).toBe('cancelled');
|
||||
expect(runtime.getState().status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('is reusable after an error — subsequent call with a good LLM response succeeds', async () => {
|
||||
|
|
@ -400,10 +401,10 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
|
|||
});
|
||||
|
||||
const { runtime } = createRuntime();
|
||||
const { stream: readableStream, getState } = await runtime.stream('hello');
|
||||
const { stream: readableStream } = await runtime.stream('hello');
|
||||
await collectChunks(readableStream);
|
||||
|
||||
expect(getState().status).toBe('failed');
|
||||
expect(runtime.getState().status).toBe('failed');
|
||||
});
|
||||
|
||||
it('yields error chunk and finishes cleanly on abort', async () => {
|
||||
|
|
@ -412,13 +413,13 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
|
|||
const { runtime, bus } = createRuntime();
|
||||
bus.on(AgentEvent.TurnStart, () => bus.abort());
|
||||
|
||||
const { stream: readableStream, getState } = await runtime.stream('hello');
|
||||
const { stream: readableStream } = await runtime.stream('hello');
|
||||
const chunks = await collectChunks(readableStream);
|
||||
|
||||
const errorChunks = chunks.filter((c) => c.type === 'error');
|
||||
expect(errorChunks.length).toBeGreaterThan(0);
|
||||
|
||||
expect(getState().status).toBe('cancelled');
|
||||
expect(runtime.getState().status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('stream is reusable after an error', async () => {
|
||||
|
|
@ -466,6 +467,120 @@ describe('AgentRuntime.stream() — graceful error contract', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stream() — working memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AgentRuntime.stream() — working memory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeMemory(savedWorkingMemory: string[]): BuiltMemory {
|
||||
return {
|
||||
getThread: jest.fn().mockResolvedValue(null),
|
||||
saveThread: jest.fn(async (thread) => {
|
||||
await Promise.resolve();
|
||||
return {
|
||||
...thread,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}),
|
||||
deleteThread: jest.fn(),
|
||||
getMessages: jest.fn().mockResolvedValue([]),
|
||||
saveMessages: jest.fn(),
|
||||
deleteMessages: jest.fn(),
|
||||
getWorkingMemory: jest.fn().mockResolvedValue(null),
|
||||
saveWorkingMemory: jest.fn(async (_params, content: string) => {
|
||||
await Promise.resolve();
|
||||
savedWorkingMemory.push(content);
|
||||
}),
|
||||
describe: jest
|
||||
.fn()
|
||||
.mockReturnValue({ name: 'test', constructorName: 'TestMemory', connectionParams: {} }),
|
||||
};
|
||||
}
|
||||
|
||||
it('persists working memory and streams the tool chunks unfiltered', async () => {
|
||||
const savedWorkingMemory: string[] = [];
|
||||
const memoryContent = '# Thread memory\n- User facts: Alice likes concise answers';
|
||||
const memory = makeMemory(savedWorkingMemory);
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'You are a test assistant.',
|
||||
memory,
|
||||
lastMessages: 5,
|
||||
workingMemory: {
|
||||
template: '# Thread memory\n- User facts:',
|
||||
structured: false,
|
||||
scope: 'thread',
|
||||
},
|
||||
});
|
||||
|
||||
streamText
|
||||
.mockReturnValueOnce({
|
||||
fullStream: makeChunkStream([
|
||||
{ type: 'tool-input-start', id: 'wm-1', toolName: 'update_working_memory' },
|
||||
{ type: 'tool-input-delta', id: 'wm-1', delta: memoryContent },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'wm-1',
|
||||
toolName: 'update_working_memory',
|
||||
input: { memory: memoryContent },
|
||||
},
|
||||
]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
|
||||
response: Promise.resolve({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'wm-1',
|
||||
toolName: 'update_working_memory',
|
||||
args: { memory: memoryContent },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
toolCalls: Promise.resolve([
|
||||
{
|
||||
toolCallId: 'wm-1',
|
||||
toolName: 'update_working_memory',
|
||||
input: { memory: memoryContent },
|
||||
},
|
||||
]),
|
||||
})
|
||||
.mockReturnValueOnce(makeStreamSuccess('Done'));
|
||||
|
||||
const { stream } = await runtime.stream('remember this', {
|
||||
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
|
||||
});
|
||||
const chunks = await collectChunks(stream);
|
||||
|
||||
expect(savedWorkingMemory).toEqual([memoryContent]);
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'wm-1',
|
||||
toolName: 'update_working_memory',
|
||||
}),
|
||||
);
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'wm-1',
|
||||
toolName: 'update_working_memory',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resume() — graceful error contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -497,37 +612,35 @@ describe('AgentRuntime — state transitions on error', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('starts idle before first run', () => {
|
||||
it('starts idle, then reflects running→failed after a generate error', async () => {
|
||||
const { runtime } = createRuntime();
|
||||
|
||||
expect(runtime.getState().status).toBe('idle');
|
||||
});
|
||||
|
||||
it('result.getState() reflects failed after a generate error', async () => {
|
||||
generateText.mockRejectedValue(new Error('oops'));
|
||||
const runDone = runtime.generate('hi');
|
||||
|
||||
const { runtime } = createRuntime();
|
||||
const result = await runtime.generate('hi');
|
||||
|
||||
expect(result.getState().status).toBe('failed');
|
||||
await runDone;
|
||||
expect(runtime.getState().status).toBe('failed');
|
||||
});
|
||||
|
||||
it('result.getState() reflects cancelled on abort', async () => {
|
||||
it('starts idle, then reflects running→cancelled on abort', async () => {
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
|
||||
const { runtime, bus } = createRuntime();
|
||||
bus.on(AgentEvent.AgentStart, () => bus.abort());
|
||||
|
||||
const result = await runtime.generate('hi');
|
||||
expect(result.getState().status).toBe('cancelled');
|
||||
await runtime.generate('hi');
|
||||
expect(runtime.getState().status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('result.getState() transitions to success on a clean run', async () => {
|
||||
it('transitions to success on a clean run', async () => {
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
|
||||
const { runtime } = createRuntime();
|
||||
const result = await runtime.generate('hi');
|
||||
await runtime.generate('hi');
|
||||
|
||||
expect(result.getState().status).toBe('success');
|
||||
expect(runtime.getState().status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -675,7 +788,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
|
|||
expect(result.pendingSuspend![0].toolCallId).toBe('tc-1');
|
||||
|
||||
// Verify tc-3 is in the persisted state as a pending tool call (without suspendPayload)
|
||||
const state = result.getState();
|
||||
const state = runtime.getState();
|
||||
expect(state.pendingToolCalls['tc-3']).toBeDefined();
|
||||
expect(state.pendingToolCalls['tc-3'].suspended).toBe(false);
|
||||
});
|
||||
|
|
@ -905,7 +1018,7 @@ describe('AgentRuntime — concurrent tool execution', () => {
|
|||
it('tool error produces an error tool-result in the message list and loop continues', async () => {
|
||||
type ToolOutputContent = {
|
||||
type: string;
|
||||
output?: { type: string; value?: { error?: string } };
|
||||
output?: { type: string; value?: unknown };
|
||||
};
|
||||
type ToolMessage = { role: string; content: ToolOutputContent[] };
|
||||
const receivedMessages: unknown[] = [];
|
||||
|
|
@ -932,13 +1045,15 @@ describe('AgentRuntime — concurrent tool execution', () => {
|
|||
expect(result.finishReason).toBe('stop');
|
||||
// LLM was called a second time — it saw the error tool result and continued
|
||||
expect(generateText).toHaveBeenCalledTimes(2);
|
||||
// The second LLM call received a tool message whose output carries the error description
|
||||
// The second LLM call received a tool message whose output carries the error description.
|
||||
const toolMsg = receivedMessages.find(
|
||||
(m): m is ToolMessage =>
|
||||
typeof m === 'object' && m !== null && (m as ToolMessage).role === 'tool',
|
||||
);
|
||||
expect(toolMsg).toBeDefined();
|
||||
const hasErrorOutput = toolMsg!.content.some((c) => !!c.output?.value?.error);
|
||||
const hasErrorOutput = toolMsg!.content.some(
|
||||
(c) => c.output?.type === 'error-text' || c.output?.type === 'error-json',
|
||||
);
|
||||
expect(hasErrorOutput).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -982,9 +1097,9 @@ describe('AgentRuntime — concurrent tool execution', () => {
|
|||
]),
|
||||
);
|
||||
|
||||
const result = await runtime.generate('run tools');
|
||||
await runtime.generate('run tools');
|
||||
|
||||
const state = result.getState();
|
||||
const state = runtime.getState();
|
||||
expect(state.pendingToolCalls['tc-1']).toBeDefined();
|
||||
expect(state.pendingToolCalls['tc-1'].toolName).toBe('suspend_tool');
|
||||
});
|
||||
|
|
@ -1007,9 +1122,9 @@ describe('AgentRuntime — concurrent tool execution', () => {
|
|||
]),
|
||||
);
|
||||
|
||||
const result = await runtime.generate('run tools');
|
||||
await runtime.generate('run tools');
|
||||
|
||||
const state = result.getState();
|
||||
const state = runtime.getState();
|
||||
expect(state.pendingToolCalls['tc-2']).toBeDefined();
|
||||
expect(state.pendingToolCalls['tc-2'].toolName).toBe('normal_tool');
|
||||
expect(state.pendingToolCalls['tc-2'].suspended).toBe(false);
|
||||
|
|
@ -1554,17 +1669,14 @@ describe('AgentRuntime — runtime input schema validation', () => {
|
|||
// the LLM responds with 'done' on the next turn.
|
||||
expect(result.finishReason).toBe('stop');
|
||||
|
||||
const toolErrorMessage = result.messages.find(
|
||||
(m) => isLlmMessage(m) && m.role === 'tool' && m.content[0].type === 'tool-result',
|
||||
const assistantMsg = result.messages.find(
|
||||
(m) =>
|
||||
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
) as Message;
|
||||
expect(toolErrorMessage).toBeDefined();
|
||||
const content = toolErrorMessage.content[0] as ContentToolResult;
|
||||
expect(content.result).toEqual(
|
||||
expect.objectContaining({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
error: expect.stringContaining('Expected string, received number'),
|
||||
}),
|
||||
);
|
||||
expect(assistantMsg).toBeDefined();
|
||||
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(call.state).toBe('rejected');
|
||||
expect(call.state === 'rejected' && call.error).toContain('Expected string, received number');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1603,13 +1715,14 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => {
|
|||
const result = await runtime.generate('go');
|
||||
expect(result.finishReason).toBe('stop');
|
||||
|
||||
// No tool-result error — the tool ran successfully
|
||||
const toolResultMsg = result.messages.find(
|
||||
(m) => isLlmMessage(m) && m.role === 'tool',
|
||||
// No error — the tool ran successfully
|
||||
const assistantMsg = result.messages.find(
|
||||
(m) =>
|
||||
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
) as Message;
|
||||
expect(toolResultMsg).toBeDefined();
|
||||
const content = toolResultMsg.content[0] as ContentToolResult;
|
||||
expect(content.isError).toBeFalsy();
|
||||
expect(assistantMsg).toBeDefined();
|
||||
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(call.state).toBe('resolved');
|
||||
});
|
||||
|
||||
it('surfaces a validation error as a tool error outcome when LLM provides the wrong type', async () => {
|
||||
|
|
@ -1639,14 +1752,14 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => {
|
|||
const result = await runtime.generate('go');
|
||||
expect(result.finishReason).toBe('stop');
|
||||
|
||||
const toolResultMsg = result.messages.find(
|
||||
(m) => isLlmMessage(m) && m.role === 'tool',
|
||||
const assistantMsg = result.messages.find(
|
||||
(m) =>
|
||||
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
) as Message;
|
||||
expect(toolResultMsg).toBeDefined();
|
||||
console.log('ToolResultMsg', toolResultMsg);
|
||||
const content = toolResultMsg.content[0] as ContentToolResult;
|
||||
expect(content.isError).toBe(true);
|
||||
expect(JSON.stringify(content.result)).toContain('Invalid tool input');
|
||||
expect(assistantMsg).toBeDefined();
|
||||
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(call.state).toBe('rejected');
|
||||
expect(call.state === 'rejected' && call.error).toContain('Invalid tool input');
|
||||
});
|
||||
|
||||
it('surfaces a validation error when a required property is missing', async () => {
|
||||
|
|
@ -1677,15 +1790,15 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => {
|
|||
});
|
||||
|
||||
const result = await runtime.generate('go');
|
||||
console.log('Result', result.error);
|
||||
expect(result.finishReason).toBe('stop');
|
||||
|
||||
const toolResultMsg = result.messages.find(
|
||||
(m) => isLlmMessage(m) && m.role === 'tool',
|
||||
const assistantMsg = result.messages.find(
|
||||
(m) =>
|
||||
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
) as Message;
|
||||
const content = toolResultMsg.content[0] as ContentToolResult;
|
||||
expect(content.isError).toBe(true);
|
||||
expect(JSON.stringify(content.result)).toContain('Invalid tool input');
|
||||
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(call.state).toBe('rejected');
|
||||
expect(call.state === 'rejected' && call.error).toContain('Invalid tool input');
|
||||
});
|
||||
|
||||
it('does not invoke the handler when JSON Schema validation fails', async () => {
|
||||
|
|
@ -1718,6 +1831,142 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool builder — JSON Schema input integration
|
||||
//
|
||||
// Mirrors the resolveNodeTool() code path in node-tool-factory.ts where the
|
||||
// input schema is a raw JSON Schema object (converted from Zod by ToolFromNode).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AgentRuntime — Tool builder with JSON Schema input', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('passes valid input to the handler when built via Tool builder', async () => {
|
||||
const handlerFn = jest.fn().mockResolvedValue({ found: true });
|
||||
|
||||
const tool = new Tool('lookup')
|
||||
.description('Look up a record by id')
|
||||
.input({
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id'],
|
||||
})
|
||||
.handler(handlerFn)
|
||||
.build();
|
||||
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'lookup', { id: 'abc-123' }))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('done'));
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'test',
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runtime.generate('go');
|
||||
|
||||
expect(result.finishReason).toBe('stop');
|
||||
expect(handlerFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'abc-123' }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const assistantMsg = result.messages.find(
|
||||
(m) =>
|
||||
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
) as Message;
|
||||
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(call.state).toBe('resolved');
|
||||
});
|
||||
|
||||
it('produces a tool error when the LLM sends input that fails JSON Schema validation', async () => {
|
||||
const handlerFn = jest.fn().mockResolvedValue({ found: true });
|
||||
|
||||
const tool = new Tool('lookup')
|
||||
.description('Look up a record by id')
|
||||
.input({
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
count: { type: 'integer', minimum: 1 },
|
||||
},
|
||||
required: ['id', 'count'],
|
||||
})
|
||||
.handler(handlerFn)
|
||||
.build();
|
||||
|
||||
generateText
|
||||
// LLM sends count: 0 (violates minimum: 1) and id as a number (wrong type)
|
||||
.mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'lookup', { id: 42, count: 0 }))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('corrected'));
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'test',
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runtime.generate('go');
|
||||
|
||||
expect(result.finishReason).toBe('stop');
|
||||
// Handler must not be called — validation should block execution
|
||||
expect(handlerFn).not.toHaveBeenCalled();
|
||||
|
||||
const assistantMsg = result.messages.find(
|
||||
(m) =>
|
||||
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
) as Message;
|
||||
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(call.state).toBe('rejected');
|
||||
expect(call.state === 'rejected' && call.error).toContain('Invalid tool input');
|
||||
});
|
||||
|
||||
it('validates enum and pattern constraints defined in JSON Schema', async () => {
|
||||
const handlerFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const tool = new Tool('set_status')
|
||||
.description('Set the status of a record')
|
||||
.input({
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['active', 'inactive', 'pending'] },
|
||||
},
|
||||
required: ['status'],
|
||||
})
|
||||
.handler(handlerFn)
|
||||
.build();
|
||||
|
||||
// First call: invalid enum value
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'set_status', { status: 'deleted' }))
|
||||
// Second call: valid enum value after self-correction
|
||||
.mockResolvedValueOnce(makeGenerateWithToolCall('tc-2', 'set_status', { status: 'inactive' }))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('done'));
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'test',
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runtime.generate('go');
|
||||
|
||||
expect(result.finishReason).toBe('stop');
|
||||
// Handler called exactly once — only for the valid input
|
||||
expect(handlerFn).toHaveBeenCalledTimes(1);
|
||||
expect(handlerFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'inactive' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime validation — resume data schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1953,6 +2202,114 @@ describe('provider options merging', () => {
|
|||
// Instruction providerOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tool systemInstruction merging', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function getSystemMessageText(): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const callArgs = generateText.mock.calls[0][0] as Record<string, unknown>;
|
||||
const messages = callArgs.messages as Array<Record<string, unknown>>;
|
||||
const systemMsg = messages[0];
|
||||
expect(systemMsg.role).toBe('system');
|
||||
return String(systemMsg.content);
|
||||
}
|
||||
|
||||
it("wraps a tool's systemInstruction in a built_in_rules block above user instructions", async () => {
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
|
||||
const toolWithDirective: BuiltTool = {
|
||||
name: 'show_card',
|
||||
description: 'show a card',
|
||||
systemInstruction: 'Prefer this tool over plain text when posting images.',
|
||||
inputSchema: z.object({ value: z.string().optional() }),
|
||||
handler: async () => await Promise.resolve('ok'),
|
||||
};
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'You are a helpful assistant.',
|
||||
tools: [toolWithDirective],
|
||||
});
|
||||
|
||||
await runtime.generate('hello');
|
||||
|
||||
const text = getSystemMessageText();
|
||||
expect(text).toContain('<built_in_rules>');
|
||||
expect(text).toContain('- Prefer this tool over plain text when posting images.');
|
||||
expect(text).toContain('</built_in_rules>');
|
||||
expect(text).toContain('You are a helpful assistant.');
|
||||
expect(text.indexOf('<built_in_rules>')).toBeLessThan(text.indexOf('You are a helpful'));
|
||||
});
|
||||
|
||||
it('joins multiple tools systemInstructions into a single block', async () => {
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
|
||||
const toolA: BuiltTool = {
|
||||
name: 'a',
|
||||
description: 'a',
|
||||
systemInstruction: 'Rule A.',
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => await Promise.resolve('ok'),
|
||||
};
|
||||
const toolB: BuiltTool = {
|
||||
name: 'b',
|
||||
description: 'b',
|
||||
systemInstruction: 'Rule B.',
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => await Promise.resolve('ok'),
|
||||
};
|
||||
const toolC: BuiltTool = {
|
||||
name: 'c',
|
||||
description: 'c',
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => await Promise.resolve('ok'),
|
||||
};
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'base',
|
||||
tools: [toolA, toolB, toolC],
|
||||
});
|
||||
|
||||
await runtime.generate('hello');
|
||||
|
||||
const text = getSystemMessageText();
|
||||
const block = text.match(/<built_in_rules>([\s\S]*?)<\/built_in_rules>/);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block![1]).toContain('- Rule A.');
|
||||
expect(block![1]).toContain('- Rule B.');
|
||||
expect(block![1]).not.toContain('Rule C');
|
||||
});
|
||||
|
||||
it('does not add a built_in_rules block when no tool sets a systemInstruction', async () => {
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
|
||||
const plainTool: BuiltTool = {
|
||||
name: 'plain',
|
||||
description: 'plain',
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => await Promise.resolve('ok'),
|
||||
};
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'You are a helpful assistant.',
|
||||
tools: [plainTool],
|
||||
});
|
||||
|
||||
await runtime.generate('hello');
|
||||
|
||||
const text = getSystemMessageText();
|
||||
expect(text).not.toContain('<built_in_rules>');
|
||||
expect(text).toContain('You are a helpful assistant.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instruction providerOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -1,445 +0,0 @@
|
|||
/**
|
||||
* Tests for the Agent builder focusing on per-run isolation guarantees introduced
|
||||
* by the "shared config, per-run runtime" refactor.
|
||||
*/
|
||||
|
||||
import { Agent } from '../sdk/agent';
|
||||
import { AgentEvent } from '../types/runtime/event';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks (same as agent-runtime.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock('@ai-sdk/openai', () => ({
|
||||
createOpenAI: () => () => ({ provider: 'openai', modelId: 'mock', specificationVersion: 'v3' }),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/anthropic', () => ({
|
||||
createAnthropic: () => () => ({
|
||||
provider: 'anthropic',
|
||||
modelId: 'mock',
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
type AiImport = typeof import('ai');
|
||||
|
||||
jest.mock('ai', () => {
|
||||
const actual = jest.requireActual<AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: jest.fn(),
|
||||
streamText: jest.fn(),
|
||||
tool: jest.fn((config: unknown) => config),
|
||||
Output: {
|
||||
object: jest.fn(({ schema }: { schema: unknown }) => ({ _type: 'object', schema })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Prevent real catalog HTTP calls
|
||||
jest.mock('../sdk/catalog', () => ({
|
||||
getModelCost: jest.fn().mockResolvedValue(undefined),
|
||||
computeCost: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { generateText, streamText } = require('ai') as {
|
||||
generateText: jest.Mock;
|
||||
streamText: jest.Mock;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGenerateSuccess(text = 'OK') {
|
||||
return {
|
||||
finishReason: 'stop',
|
||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||
response: {
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
|
||||
},
|
||||
toolCalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
function* makeChunkStream(chunks: Array<Record<string, unknown>>) {
|
||||
for (const c of chunks) yield c;
|
||||
}
|
||||
|
||||
function makeStreamSuccess(text = 'Hello') {
|
||||
return {
|
||||
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: text }]),
|
||||
finishReason: Promise.resolve('stop'),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
|
||||
response: Promise.resolve({
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
|
||||
}),
|
||||
toolCalls: Promise.resolve([]),
|
||||
};
|
||||
}
|
||||
|
||||
async function drainStream(stream: ReadableStream<unknown>): Promise<void> {
|
||||
const reader = stream.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgent() {
|
||||
return new Agent('test').model('openai/gpt-4o-mini').instructions('You are a test assistant.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Agent — per-run isolation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('concurrent generate() calls', () => {
|
||||
it('returns independent results for each call', async () => {
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('Result A'))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('Result B'));
|
||||
|
||||
const agent = buildAgent();
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
agent.generate('Prompt A'),
|
||||
agent.generate('Prompt B'),
|
||||
]);
|
||||
|
||||
const textA = resultA.messages
|
||||
.flatMap((m) => ('content' in m ? m.content : []))
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => ('text' in c ? c.text : ''))
|
||||
.join('');
|
||||
|
||||
const textB = resultB.messages
|
||||
.flatMap((m) => ('content' in m ? m.content : []))
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => ('text' in c ? c.text : ''))
|
||||
.join('');
|
||||
|
||||
expect(textA).toBe('Result A');
|
||||
expect(textB).toBe('Result B');
|
||||
expect(resultA.runId).not.toBe(resultB.runId);
|
||||
});
|
||||
|
||||
it('aborting one generate() does not cancel the other', async () => {
|
||||
const abortControllerA = new AbortController();
|
||||
|
||||
// Run A resolves only after a delay; we'll abort it via its signal.
|
||||
// Run B resolves immediately.
|
||||
let resolveA!: (v: unknown) => void;
|
||||
const pendingA = new Promise((res) => {
|
||||
resolveA = res;
|
||||
});
|
||||
|
||||
generateText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
if (abortSignal === abortControllerA.signal || abortSignal?.aborted) {
|
||||
// Simulate the AI SDK throwing on abort
|
||||
await new Promise((_, rej) =>
|
||||
abortSignal.addEventListener('abort', () => rej(new Error('aborted')), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Run B path — return immediately
|
||||
await pendingA;
|
||||
return makeGenerateSuccess('Result B');
|
||||
});
|
||||
|
||||
const agent = buildAgent();
|
||||
|
||||
// Start both runs; abort run A immediately
|
||||
const runAPromise = agent.generate('Prompt A', { abortSignal: abortControllerA.signal });
|
||||
abortControllerA.abort();
|
||||
resolveA(undefined);
|
||||
|
||||
const runA = await runAPromise;
|
||||
expect(runA.finishReason).toBe('error');
|
||||
|
||||
// Run B separately (no abort)
|
||||
generateText.mockResolvedValueOnce(makeGenerateSuccess('Result B'));
|
||||
const runB = await agent.generate('Prompt B');
|
||||
const textB = runB.messages
|
||||
.flatMap((m) => ('content' in m ? m.content : []))
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => ('text' in c ? c.text : ''))
|
||||
.join('');
|
||||
expect(textB).toBe('Result B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent stream() calls', () => {
|
||||
it('returns independent streams for each call', async () => {
|
||||
streamText
|
||||
.mockReturnValueOnce(makeStreamSuccess('Stream A'))
|
||||
.mockReturnValueOnce(makeStreamSuccess('Stream B'));
|
||||
|
||||
const agent = buildAgent();
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
agent.stream('Prompt A'),
|
||||
agent.stream('Prompt B'),
|
||||
]);
|
||||
|
||||
// Both streams should be distinct ReadableStream objects
|
||||
expect(resultA.stream).not.toBe(resultB.stream);
|
||||
expect(resultA.runId).not.toBe(resultB.runId);
|
||||
|
||||
// Drain both streams to completion
|
||||
await Promise.all([drainStream(resultA.stream), drainStream(resultB.stream)]);
|
||||
});
|
||||
|
||||
it('aborting one stream does not cancel the other', async () => {
|
||||
const abortControllerA = new AbortController();
|
||||
|
||||
streamText.mockImplementation(({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
if (abortSignal === abortControllerA.signal) {
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
// Wait until aborted then throw
|
||||
await new Promise<void>((_, rej) => {
|
||||
abortSignal.addEventListener('abort', () => rej(new Error('aborted')), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
yield 'something';
|
||||
})(),
|
||||
finishReason: Promise.resolve('error'),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
|
||||
response: Promise.resolve({ messages: [] }),
|
||||
toolCalls: Promise.resolve([]),
|
||||
};
|
||||
}
|
||||
return makeStreamSuccess('Stream B');
|
||||
});
|
||||
|
||||
const agent = buildAgent();
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
agent.stream('Prompt A', { abortSignal: abortControllerA.signal }),
|
||||
agent.stream('Prompt B'),
|
||||
]);
|
||||
|
||||
// Abort run A
|
||||
abortControllerA.abort();
|
||||
|
||||
// Drain stream B — it should complete successfully regardless of A being aborted
|
||||
await drainStream(resultB.stream);
|
||||
|
||||
// Drain stream A — it will error but shouldn't affect B
|
||||
await drainStream(resultA.stream).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('event handlers (on())', () => {
|
||||
it('fires registered handlers for every concurrent run', async () => {
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('A'))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('B'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const agentStartEvents: string[] = [];
|
||||
|
||||
agent.on(AgentEvent.AgentStart, () => {
|
||||
agentStartEvents.push('start');
|
||||
});
|
||||
|
||||
await Promise.all([agent.generate('Prompt A'), agent.generate('Prompt B')]);
|
||||
|
||||
// Handler should have fired once per run
|
||||
expect(agentStartEvents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handlers registered before first run still fire on every subsequent run', async () => {
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('First'))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('Second'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const events: string[] = [];
|
||||
|
||||
agent.on(AgentEvent.AgentEnd, () => {
|
||||
events.push('end');
|
||||
});
|
||||
|
||||
await agent.generate('First');
|
||||
await agent.generate('Second');
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort() broadcast', () => {
|
||||
it('aborts all active runs when agent.abort() is called', async () => {
|
||||
let resolveA!: (v: unknown) => void;
|
||||
|
||||
generateText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
// Each call waits until its resolver is called or the signal fires
|
||||
await new Promise((res, rej) => {
|
||||
abortSignal?.addEventListener('abort', () => rej(new Error('aborted')), {
|
||||
once: true,
|
||||
});
|
||||
resolveA ??= res;
|
||||
});
|
||||
return makeGenerateSuccess();
|
||||
});
|
||||
|
||||
const agent = buildAgent();
|
||||
|
||||
const runAPromise = agent.generate('A');
|
||||
const runBPromise = agent.generate('B');
|
||||
|
||||
// Give both calls time to reach the mock and register abort listeners
|
||||
await new Promise((res) => setTimeout(res, 10));
|
||||
|
||||
// Broadcast abort — both runs should be cancelled
|
||||
agent.abort();
|
||||
|
||||
const [runA, runB] = await Promise.all([runAPromise, runBPromise]);
|
||||
expect(runA.finishReason).toBe('error');
|
||||
expect(runB.finishReason).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('off() — event handler removal', () => {
|
||||
it('removes a specific handler so it no longer fires', async () => {
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('A'))
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('B'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const events: string[] = [];
|
||||
|
||||
const handler = () => events.push('end');
|
||||
agent.on(AgentEvent.AgentEnd, handler);
|
||||
await agent.generate('First');
|
||||
|
||||
agent.off(AgentEvent.AgentEnd, handler);
|
||||
await agent.generate('Second');
|
||||
|
||||
// Handler should have fired only for the first run
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removing one handler does not affect other handlers for the same event', async () => {
|
||||
generateText.mockResolvedValueOnce(makeGenerateSuccess('A'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const firedA: string[] = [];
|
||||
const firedB: string[] = [];
|
||||
|
||||
const handlerA = () => firedA.push('a');
|
||||
const handlerB = () => firedB.push('b');
|
||||
|
||||
agent.on(AgentEvent.AgentEnd, handlerA);
|
||||
agent.on(AgentEvent.AgentEnd, handlerB);
|
||||
|
||||
agent.off(AgentEvent.AgentEnd, handlerA);
|
||||
|
||||
await agent.generate('Hello');
|
||||
|
||||
expect(firedA).toHaveLength(0);
|
||||
expect(firedB).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('off() on a handler that was never registered is a no-op', () => {
|
||||
const agent = buildAgent();
|
||||
expect(() => agent.off(AgentEvent.AgentEnd, () => {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackStreamBus — cleanup on stream cancel', () => {
|
||||
it('removes the bus from active runs when the consumer cancels the stream', async () => {
|
||||
streamText.mockReturnValueOnce(makeStreamSuccess('Hello'));
|
||||
|
||||
const agent = buildAgent();
|
||||
|
||||
// Access the private set via casting so we can assert its size
|
||||
const getActiveBuses = () =>
|
||||
(agent as unknown as { activeEventBuses: Set<unknown> }).activeEventBuses;
|
||||
|
||||
const { stream } = await agent.stream('Hello');
|
||||
|
||||
// Bus is registered while the stream is live
|
||||
expect(getActiveBuses().size).toBe(1);
|
||||
|
||||
// Consumer cancels instead of draining
|
||||
await stream.cancel();
|
||||
|
||||
// Bus must be removed immediately after cancel
|
||||
expect(getActiveBuses().size).toBe(0);
|
||||
});
|
||||
|
||||
it('removes the bus from active runs when the consumer drains the stream normally', async () => {
|
||||
streamText.mockReturnValueOnce(makeStreamSuccess('Hello'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const getActiveBuses = () =>
|
||||
(agent as unknown as { activeEventBuses: Set<unknown> }).activeEventBuses;
|
||||
|
||||
const { stream } = await agent.stream('Hello');
|
||||
expect(getActiveBuses().size).toBe(1);
|
||||
|
||||
await drainStream(stream);
|
||||
|
||||
expect(getActiveBuses().size).toBe(0);
|
||||
});
|
||||
|
||||
it('abort() after stream cancel does not throw on a disposed bus', async () => {
|
||||
streamText.mockReturnValueOnce(makeStreamSuccess('Hello'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const { stream } = await agent.stream('Hello');
|
||||
|
||||
await stream.cancel();
|
||||
|
||||
// agent.abort() should be harmless — no active buses remain
|
||||
expect(() => agent.abort()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('result.getState()', () => {
|
||||
it('generate() result.getState() reports success after a clean run', async () => {
|
||||
generateText.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
|
||||
const agent = buildAgent();
|
||||
const result = await agent.generate('Hello');
|
||||
|
||||
expect(result.getState().status).toBe('success');
|
||||
});
|
||||
|
||||
it('generate() result.getState() reports failed after an error', async () => {
|
||||
generateText.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const agent = buildAgent();
|
||||
const result = await agent.generate('Hello');
|
||||
|
||||
expect(result.getState().status).toBe('failed');
|
||||
});
|
||||
|
||||
it('stream() result.getState() reports success after the stream is consumed', async () => {
|
||||
streamText.mockReturnValueOnce(makeStreamSuccess());
|
||||
|
||||
const agent = buildAgent();
|
||||
const { stream, getState } = await agent.stream('Hello');
|
||||
|
||||
// State is running while stream is open
|
||||
expect(getState().status).toBe('running');
|
||||
|
||||
await drainStream(stream);
|
||||
|
||||
expect(getState().status).toBe('success');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Agent } from '../sdk/agent';
|
||||
import { McpClient } from '../sdk/mcp-client';
|
||||
import { Telemetry } from '../sdk/telemetry';
|
||||
import { Tool } from '../sdk/tool';
|
||||
import type { BuiltEval, BuiltGuardrail, BuiltMemory, BuiltProviderTool } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockMemory(): BuiltMemory {
|
||||
return {
|
||||
getThread: jest.fn(),
|
||||
saveThread: jest.fn(),
|
||||
deleteThread: jest.fn(),
|
||||
getMessages: jest.fn(),
|
||||
saveMessages: jest.fn(),
|
||||
deleteMessages: jest.fn(),
|
||||
} as unknown as BuiltMemory;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Agent.describe()', () => {
|
||||
it('returns null/empty fields for an unconfigured agent', () => {
|
||||
const agent = new Agent('test-agent');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.model).toEqual({ provider: null, name: null });
|
||||
expect(schema.credential).toBeNull();
|
||||
expect(schema.instructions).toBeNull();
|
||||
expect(schema.description).toBeNull();
|
||||
expect(schema.tools).toEqual([]);
|
||||
expect(schema.providerTools).toEqual([]);
|
||||
expect(schema.memory).toBeNull();
|
||||
expect(schema.evaluations).toEqual([]);
|
||||
expect(schema.guardrails).toEqual([]);
|
||||
expect(schema.mcp).toBeNull();
|
||||
expect(schema.telemetry).toBeNull();
|
||||
expect(schema.checkpoint).toBeNull();
|
||||
expect(schema.config.structuredOutput).toEqual({ enabled: false, schemaSource: null });
|
||||
expect(schema.config.thinking).toBeNull();
|
||||
expect(schema.config.toolCallConcurrency).toBeNull();
|
||||
expect(schema.config.requireToolApproval).toBe(false);
|
||||
});
|
||||
|
||||
// --- Model parsing ---
|
||||
|
||||
it('parses two-arg model (provider, name)', () => {
|
||||
const agent = new Agent('test-agent').model('anthropic', 'claude-sonnet-4-5');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' });
|
||||
});
|
||||
|
||||
it('parses single-arg model with slash', () => {
|
||||
const agent = new Agent('test-agent').model('anthropic/claude-sonnet-4-5');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' });
|
||||
});
|
||||
|
||||
it('parses model without slash', () => {
|
||||
const agent = new Agent('test-agent').model('gpt-4o');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.model).toEqual({ provider: null, name: 'gpt-4o' });
|
||||
});
|
||||
|
||||
it('handles object model config', () => {
|
||||
const agent = new Agent('test-agent').model({
|
||||
id: 'anthropic/claude-sonnet-4-5',
|
||||
apiKey: 'sk-test',
|
||||
});
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.model).toEqual({ provider: null, name: null, raw: 'object' });
|
||||
});
|
||||
|
||||
// --- Credential ---
|
||||
|
||||
it('returns credential name', () => {
|
||||
const agent = new Agent('test-agent').credential('my-anthropic-key');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.credential).toBe('my-anthropic-key');
|
||||
});
|
||||
|
||||
// --- Instructions ---
|
||||
|
||||
it('returns instructions text', () => {
|
||||
const agent = new Agent('test-agent').instructions('You are helpful.');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.instructions).toBe('You are helpful.');
|
||||
});
|
||||
|
||||
// --- Custom tool ---
|
||||
|
||||
it('describes a custom tool with handler, input schema, and suspend/resume', () => {
|
||||
const suspendSchema = z.object({ reason: z.string() });
|
||||
const resumeSchema = z.object({ approved: z.boolean() });
|
||||
|
||||
const tool = new Tool('danger')
|
||||
.description('A dangerous action')
|
||||
.input(z.object({ target: z.string() }))
|
||||
.output(z.object({ result: z.string() }))
|
||||
.suspend(suspendSchema)
|
||||
.resume(resumeSchema)
|
||||
.handler(async ({ target }) => await Promise.resolve({ result: target }))
|
||||
.build();
|
||||
|
||||
const agent = new Agent('test-agent').tool(tool);
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.tools).toHaveLength(1);
|
||||
const ts = schema.tools[0];
|
||||
expect(ts.name).toBe('danger');
|
||||
expect(ts.editable).toBe(true);
|
||||
expect(ts.hasSuspend).toBe(true);
|
||||
expect(ts.hasResume).toBe(true);
|
||||
expect(ts.hasToMessage).toBe(false);
|
||||
expect(ts.inputSchema).toBeTruthy();
|
||||
expect(ts.outputSchema).toBeTruthy();
|
||||
// handlerSource is a fallback (compiled JS), CLI overrides with real TypeScript
|
||||
expect(ts.handlerSource).toContain('target');
|
||||
// Source string fields are null — CLI patches with original TypeScript
|
||||
expect(ts.inputSchemaSource).toBeNull();
|
||||
expect(ts.outputSchemaSource).toBeNull();
|
||||
expect(ts.suspendSchemaSource).toBeNull();
|
||||
expect(ts.resumeSchemaSource).toBeNull();
|
||||
expect(ts.toMessageSource).toBeNull();
|
||||
expect(ts.requireApproval).toBe(false);
|
||||
expect(ts.needsApprovalFnSource).toBeNull();
|
||||
expect(ts.providerOptions).toBeNull();
|
||||
});
|
||||
|
||||
// --- Provider tool ---
|
||||
|
||||
it('describes a provider tool in providerTools array', () => {
|
||||
const providerTool: BuiltProviderTool = {
|
||||
name: 'anthropic.web_search_20250305',
|
||||
args: { maxResults: 5 },
|
||||
};
|
||||
|
||||
const agent = new Agent('test-agent').providerTool(providerTool);
|
||||
const schema = agent.describe();
|
||||
|
||||
// Provider tools are now in a separate array
|
||||
expect(schema.tools).toHaveLength(0);
|
||||
expect(schema.providerTools).toHaveLength(1);
|
||||
expect(schema.providerTools[0].name).toBe('anthropic.web_search_20250305');
|
||||
expect(schema.providerTools[0].source).toBe('');
|
||||
});
|
||||
|
||||
// --- MCP servers ---
|
||||
|
||||
it('describes MCP servers in mcp field', () => {
|
||||
const client = new McpClient([
|
||||
{ name: 'browser', url: 'http://localhost:9222/mcp', transport: 'streamableHttp' },
|
||||
{ name: 'fs', command: 'echo', args: ['test'] },
|
||||
]);
|
||||
|
||||
const agent = new Agent('test-agent').mcp(client);
|
||||
const schema = agent.describe();
|
||||
|
||||
// MCP servers are now in a separate mcp field
|
||||
expect(schema.tools).toHaveLength(0);
|
||||
expect(schema.mcp).toHaveLength(2);
|
||||
expect(schema.mcp![0].name).toBe('browser');
|
||||
expect(schema.mcp![0].configSource).toBe('');
|
||||
expect(schema.mcp![1].name).toBe('fs');
|
||||
expect(schema.mcp![1].configSource).toBe('');
|
||||
});
|
||||
|
||||
it('returns null mcp when no clients are configured', () => {
|
||||
const agent = new Agent('test-agent');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.mcp).toBeNull();
|
||||
});
|
||||
|
||||
// --- Guardrails ---
|
||||
|
||||
it('describes input and output guardrails', () => {
|
||||
const inputGuard: BuiltGuardrail = {
|
||||
name: 'pii-filter',
|
||||
guardType: 'pii',
|
||||
strategy: 'redact',
|
||||
_config: { types: ['email', 'phone'] },
|
||||
};
|
||||
const outputGuard: BuiltGuardrail = {
|
||||
name: 'moderation-check',
|
||||
guardType: 'moderation',
|
||||
strategy: 'block',
|
||||
_config: {},
|
||||
};
|
||||
|
||||
const agent = new Agent('test-agent').inputGuardrail(inputGuard).outputGuardrail(outputGuard);
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.guardrails).toHaveLength(2);
|
||||
expect(schema.guardrails[0]).toEqual({
|
||||
name: 'pii-filter',
|
||||
guardType: 'pii',
|
||||
strategy: 'redact',
|
||||
position: 'input',
|
||||
config: { types: ['email', 'phone'] },
|
||||
source: '',
|
||||
});
|
||||
expect(schema.guardrails[1]).toEqual({
|
||||
name: 'moderation-check',
|
||||
guardType: 'moderation',
|
||||
strategy: 'block',
|
||||
position: 'output',
|
||||
config: {},
|
||||
source: '',
|
||||
});
|
||||
});
|
||||
|
||||
// --- Telemetry ---
|
||||
|
||||
it('returns telemetry schema when telemetry builder is set', () => {
|
||||
const agent = new Agent('test-agent').telemetry(new Telemetry());
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.telemetry).toEqual({ source: '' });
|
||||
});
|
||||
|
||||
it('returns null telemetry when not configured', () => {
|
||||
const agent = new Agent('test-agent');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.telemetry).toBeNull();
|
||||
});
|
||||
|
||||
// --- Checkpoint ---
|
||||
|
||||
it('returns memory checkpoint when checkpoint is memory', () => {
|
||||
const agent = new Agent('test-agent').checkpoint('memory');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.checkpoint).toBe('memory');
|
||||
});
|
||||
|
||||
it('returns null checkpoint when not configured', () => {
|
||||
const agent = new Agent('test-agent');
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.checkpoint).toBeNull();
|
||||
});
|
||||
|
||||
// --- Memory ---
|
||||
|
||||
it('describes memory configuration', () => {
|
||||
const agent = new Agent('test-agent').memory({
|
||||
memory: makeMockMemory(),
|
||||
lastMessages: 20,
|
||||
semanticRecall: {
|
||||
topK: 5,
|
||||
messageRange: { before: 2, after: 2 },
|
||||
embedder: 'openai/text-embedding-3-small',
|
||||
},
|
||||
workingMemory: {
|
||||
template: 'Current state: {{state}}',
|
||||
structured: false,
|
||||
scope: 'resource' as const,
|
||||
},
|
||||
});
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.memory).toBeTruthy();
|
||||
expect(schema.memory!.source).toBeNull();
|
||||
expect(schema.memory!.lastMessages).toBe(20);
|
||||
expect(schema.memory!.semanticRecall).toEqual({
|
||||
topK: 5,
|
||||
messageRange: { before: 2, after: 2 },
|
||||
embedder: 'openai/text-embedding-3-small',
|
||||
});
|
||||
expect(schema.memory!.workingMemory).toEqual({
|
||||
type: 'freeform',
|
||||
template: 'Current state: {{state}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('describes structured working memory', () => {
|
||||
const agent = new Agent('test-agent').memory({
|
||||
memory: makeMockMemory(),
|
||||
lastMessages: 10,
|
||||
workingMemory: {
|
||||
template: '',
|
||||
structured: true,
|
||||
schema: z.object({ notes: z.string() }),
|
||||
scope: 'resource' as const,
|
||||
},
|
||||
});
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.memory!.workingMemory!.type).toBe('structured');
|
||||
expect(schema.memory!.workingMemory!.schema).toBeTruthy();
|
||||
});
|
||||
|
||||
// --- Evaluations ---
|
||||
|
||||
it('describes evaluations with evalType, modelId, and handlerSource', () => {
|
||||
const checkEval: BuiltEval = {
|
||||
name: 'has-greeting',
|
||||
description: 'Checks for greeting',
|
||||
evalType: 'check',
|
||||
modelId: null,
|
||||
credentialName: null,
|
||||
_run: jest.fn(),
|
||||
};
|
||||
const judgeEval: BuiltEval = {
|
||||
name: 'quality-judge',
|
||||
description: undefined,
|
||||
evalType: 'judge',
|
||||
modelId: 'anthropic/claude-haiku-4-5',
|
||||
credentialName: 'anthropic-key',
|
||||
_run: jest.fn(),
|
||||
};
|
||||
|
||||
const agent = new Agent('test-agent').eval(checkEval).eval(judgeEval);
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.evaluations).toHaveLength(2);
|
||||
expect(schema.evaluations[0]).toEqual({
|
||||
name: 'has-greeting',
|
||||
description: 'Checks for greeting',
|
||||
type: 'check',
|
||||
modelId: null,
|
||||
hasCredential: false,
|
||||
credentialName: null,
|
||||
handlerSource: null,
|
||||
});
|
||||
expect(schema.evaluations[1]).toEqual({
|
||||
name: 'quality-judge',
|
||||
description: null,
|
||||
type: 'judge',
|
||||
modelId: 'anthropic/claude-haiku-4-5',
|
||||
hasCredential: true,
|
||||
credentialName: 'anthropic-key',
|
||||
handlerSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Thinking config ---
|
||||
|
||||
it('describes anthropic thinking config', () => {
|
||||
const agent = new Agent('test-agent')
|
||||
.model('anthropic', 'claude-sonnet-4-5')
|
||||
.thinking('anthropic', { budgetTokens: 10000 });
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.config.thinking).toEqual({
|
||||
provider: 'anthropic',
|
||||
budgetTokens: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('describes openai thinking config', () => {
|
||||
const agent = new Agent('test-agent')
|
||||
.model('openai', 'o3-mini')
|
||||
.thinking('openai', { reasoningEffort: 'high' });
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.config.thinking).toEqual({
|
||||
provider: 'openai',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
// --- requireToolApproval ---
|
||||
|
||||
it('reflects requireToolApproval flag', () => {
|
||||
const agent = new Agent('test-agent').requireToolApproval();
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.config.requireToolApproval).toBe(true);
|
||||
});
|
||||
|
||||
// --- toolCallConcurrency ---
|
||||
|
||||
it('reflects toolCallConcurrency', () => {
|
||||
const agent = new Agent('test-agent').toolCallConcurrency(5);
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.config.toolCallConcurrency).toBe(5);
|
||||
});
|
||||
|
||||
// --- Structured output ---
|
||||
|
||||
it('describes structured output with schemaSource null', () => {
|
||||
const outputSchema = z.object({ code: z.string(), explanation: z.string() });
|
||||
const agent = new Agent('test-agent').structuredOutput(outputSchema);
|
||||
const schema = agent.describe();
|
||||
|
||||
expect(schema.config.structuredOutput.enabled).toBe(true);
|
||||
expect(schema.config.structuredOutput.schemaSource).toBeNull();
|
||||
});
|
||||
});
|
||||
150
packages/@n8n/agents/src/__tests__/from-json-config.test.ts
Normal file
150
packages/@n8n/agents/src/__tests__/from-json-config.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Tool } from '../sdk/tool';
|
||||
import type { AgentMessage } from '../types/sdk/message';
|
||||
import type { InterruptibleToolContext } from '../types/sdk/tool';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool.describe() tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool.describe()', () => {
|
||||
it('returns a ToolDescriptor with name, description, and inputSchema', () => {
|
||||
const tool = new Tool('search')
|
||||
.description('Search the web')
|
||||
.input(z.object({ query: z.string() }))
|
||||
.handler(async ({ query }) => await Promise.resolve({ result: query }));
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.name).toBe('search');
|
||||
expect(descriptor.description).toBe('Search the web');
|
||||
expect(descriptor.inputSchema).toBeDefined();
|
||||
expect(descriptor.outputSchema).toBeNull();
|
||||
expect(descriptor.hasSuspend).toBe(false);
|
||||
expect(descriptor.hasResume).toBe(false);
|
||||
expect(descriptor.hasToMessage).toBe(false);
|
||||
expect(descriptor.requireApproval).toBe(false);
|
||||
expect(descriptor.providerOptions).toBeNull();
|
||||
expect(descriptor.systemInstruction).toBeNull();
|
||||
});
|
||||
|
||||
it('persists systemInstruction through describe() so it survives JSON-config round-trip', () => {
|
||||
const directive = 'Always cite a URL when summarising web search results.';
|
||||
const tool = new Tool('search')
|
||||
.description('Search the web')
|
||||
.systemInstruction(directive)
|
||||
.input(z.object({ query: z.string() }))
|
||||
.handler(async ({ query }) => await Promise.resolve({ result: query }));
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.systemInstruction).toBe(directive);
|
||||
});
|
||||
|
||||
it('sets hasSuspend/hasResume when suspend/resume are defined', () => {
|
||||
const tool = new Tool('approve')
|
||||
.description('Approve an action')
|
||||
.input(z.object({ action: z.string() }))
|
||||
.suspend(z.object({ message: z.string() }))
|
||||
.resume(z.object({ approved: z.boolean() }))
|
||||
.handler(async (input, ctx) => {
|
||||
const interruptCtx = ctx as InterruptibleToolContext;
|
||||
if (!interruptCtx.resumeData) {
|
||||
return await interruptCtx.suspend({ message: `Approve: ${input.action}?` });
|
||||
}
|
||||
return {
|
||||
result: (interruptCtx.resumeData as { approved: boolean }).approved
|
||||
? 'approved'
|
||||
: 'denied',
|
||||
};
|
||||
});
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.hasSuspend).toBe(true);
|
||||
expect(descriptor.hasResume).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hasToMessage when toMessage is defined', () => {
|
||||
const tool = new Tool('get_data')
|
||||
.description('Get data')
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.object({ value: z.string() }))
|
||||
.toMessage(
|
||||
(output) =>
|
||||
({
|
||||
type: 'custom',
|
||||
data: {
|
||||
components: [{ type: 'section', text: output.value }],
|
||||
},
|
||||
}) as unknown as AgentMessage,
|
||||
)
|
||||
.handler(async ({ id }) => await Promise.resolve({ value: id }));
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.hasToMessage).toBe(true);
|
||||
});
|
||||
|
||||
it('sets requireApproval when .requireApproval() is called', () => {
|
||||
const tool = new Tool('delete')
|
||||
.description('Delete a record')
|
||||
.input(z.object({ id: z.string() }))
|
||||
.requireApproval()
|
||||
.handler(async ({ id }) => await Promise.resolve({ deleted: id }));
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.requireApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('sets outputSchema when output schema is defined', () => {
|
||||
const tool = new Tool('compute')
|
||||
.description('Compute something')
|
||||
.input(z.object({ value: z.number() }))
|
||||
.output(z.object({ result: z.number() }))
|
||||
.handler(async ({ value }) => await Promise.resolve({ result: value * 2 }));
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.outputSchema).toBeDefined();
|
||||
expect(descriptor.outputSchema).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throws if name is missing', () => {
|
||||
const tool = new Tool('');
|
||||
expect(() => tool.describe()).toThrow('Tool name is required');
|
||||
});
|
||||
|
||||
it('throws if description is missing', () => {
|
||||
const tool = new Tool('no-desc')
|
||||
.input(z.object({ x: z.string() }))
|
||||
.handler(async ({ x }) => await Promise.resolve({ x }));
|
||||
expect(() => tool.describe()).toThrow('"no-desc" requires a description');
|
||||
});
|
||||
|
||||
it('throws if input schema is missing', () => {
|
||||
const tool = new Tool('no-input').description('No input');
|
||||
expect(() => tool.describe()).toThrow('"no-input" requires an input schema');
|
||||
});
|
||||
|
||||
it('inputSchema conforms to JSON Schema format', () => {
|
||||
const tool = new Tool('typed')
|
||||
.description('Typed tool')
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().describe('The name'),
|
||||
count: z.number().int().min(1),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ name, count }) => await Promise.resolve({ name, count }));
|
||||
|
||||
const descriptor = tool.describe();
|
||||
|
||||
expect(descriptor.inputSchema).toBeDefined();
|
||||
const schema = descriptor.inputSchema as Record<string, unknown>;
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,606 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Agent } from '../sdk/agent';
|
||||
import { isSuspendResult } from '../sdk/from-schema';
|
||||
import type { HandlerExecutor } from '../types/sdk/handler-executor';
|
||||
import type { AgentSchema, ToolSchema } from '../types/sdk/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockExecutor(): HandlerExecutor {
|
||||
return {
|
||||
executeTool: jest.fn().mockResolvedValue({ result: 'mocked' }),
|
||||
executeToMessage: jest.fn().mockResolvedValue(undefined),
|
||||
executeEval: jest.fn().mockResolvedValue({ score: 1 }),
|
||||
evaluateSchema: jest.fn().mockResolvedValue(undefined),
|
||||
evaluateExpression: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function minimalSchema(overrides: Partial<AgentSchema> = {}): AgentSchema {
|
||||
return {
|
||||
model: { provider: 'anthropic', name: 'claude-sonnet-4-5' },
|
||||
credential: 'my-credential',
|
||||
instructions: 'You are helpful.',
|
||||
description: null,
|
||||
tools: [],
|
||||
providerTools: [],
|
||||
memory: null,
|
||||
evaluations: [],
|
||||
guardrails: [],
|
||||
mcp: null,
|
||||
telemetry: null,
|
||||
checkpoint: null,
|
||||
config: {
|
||||
structuredOutput: { enabled: false, schemaSource: null },
|
||||
thinking: null,
|
||||
toolCallConcurrency: null,
|
||||
requireToolApproval: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolSchema(overrides: Partial<ToolSchema> = {}): ToolSchema {
|
||||
return {
|
||||
name: 'test-tool',
|
||||
description: 'A test tool',
|
||||
type: 'custom',
|
||||
editable: true,
|
||||
inputSchemaSource: null,
|
||||
outputSchemaSource: null,
|
||||
handlerSource: null,
|
||||
suspendSchemaSource: null,
|
||||
resumeSchemaSource: null,
|
||||
toMessageSource: null,
|
||||
requireApproval: false,
|
||||
needsApprovalFnSource: null,
|
||||
providerOptions: null,
|
||||
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
outputSchema: null,
|
||||
hasSuspend: false,
|
||||
hasResume: false,
|
||||
hasToMessage: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Agent.fromSchema()', () => {
|
||||
it('reconstructs basic agent config', async () => {
|
||||
const schema = minimalSchema();
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' });
|
||||
expect(described.credential).toBe('my-credential');
|
||||
expect(described.instructions).toBe('You are helpful.');
|
||||
});
|
||||
|
||||
it('reconstructs model with only name (no provider)', async () => {
|
||||
const schema = minimalSchema({
|
||||
model: { provider: null, name: 'gpt-4o' },
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.model).toEqual({ provider: null, name: 'gpt-4o' });
|
||||
});
|
||||
|
||||
it('reconstructs thinking config with correct provider arg', async () => {
|
||||
const schema = minimalSchema({
|
||||
config: {
|
||||
structuredOutput: { enabled: false, schemaSource: null },
|
||||
thinking: { provider: 'anthropic', budgetTokens: 10000 },
|
||||
toolCallConcurrency: null,
|
||||
requireToolApproval: false,
|
||||
},
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.config.thinking).toEqual({
|
||||
provider: 'anthropic',
|
||||
budgetTokens: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('reconstructs openai thinking config', async () => {
|
||||
const schema = minimalSchema({
|
||||
model: { provider: 'openai', name: 'o3-mini' },
|
||||
config: {
|
||||
structuredOutput: { enabled: false, schemaSource: null },
|
||||
thinking: { provider: 'openai', reasoningEffort: 'high' },
|
||||
toolCallConcurrency: null,
|
||||
requireToolApproval: false,
|
||||
},
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.config.thinking).toEqual({
|
||||
provider: 'openai',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates proxy handlers for custom tools', async () => {
|
||||
const toolSchema = makeToolSchema({
|
||||
name: 'search',
|
||||
description: 'Search the web',
|
||||
});
|
||||
const schema = minimalSchema({ tools: [toolSchema] });
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.tools).toHaveLength(1);
|
||||
expect(described.tools[0].name).toBe('search');
|
||||
expect(described.tools[0].description).toBe('Search the web');
|
||||
expect(described.tools[0].editable).toBe(true);
|
||||
});
|
||||
|
||||
it('adds WorkflowTool markers for non-editable tools', async () => {
|
||||
const toolSchema = makeToolSchema({ name: 'Send Email', type: 'workflow', editable: false });
|
||||
const schema = minimalSchema({ tools: [toolSchema] });
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
// Non-editable tools become WorkflowTool markers in declaredTools
|
||||
const markers = agent.declaredTools.filter(
|
||||
(t) => '__workflowTool' in t && (t as Record<string, unknown>).__workflowTool === true,
|
||||
);
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0].name).toBe('Send Email');
|
||||
});
|
||||
|
||||
it('reconstructs memory from schema fields', async () => {
|
||||
const schema = minimalSchema({
|
||||
memory: {
|
||||
source: null,
|
||||
storage: 'memory',
|
||||
lastMessages: 20,
|
||||
semanticRecall: null,
|
||||
workingMemory: null,
|
||||
},
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.memory).toBeTruthy();
|
||||
expect(described.memory!.lastMessages).toBe(20);
|
||||
expect(described.memory!.storage).toBe('memory');
|
||||
});
|
||||
|
||||
it('sets toolCallConcurrency when specified', async () => {
|
||||
const schema = minimalSchema({
|
||||
config: {
|
||||
structuredOutput: { enabled: false, schemaSource: null },
|
||||
thinking: null,
|
||||
toolCallConcurrency: 5,
|
||||
requireToolApproval: false,
|
||||
},
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.config.toolCallConcurrency).toBe(5);
|
||||
});
|
||||
|
||||
it('sets requireToolApproval when true', async () => {
|
||||
const schema = minimalSchema({
|
||||
config: {
|
||||
structuredOutput: { enabled: false, schemaSource: null },
|
||||
thinking: null,
|
||||
toolCallConcurrency: null,
|
||||
requireToolApproval: true,
|
||||
},
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.config.requireToolApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('sets checkpoint when specified', async () => {
|
||||
const schema = minimalSchema({ checkpoint: 'memory' });
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.checkpoint).toBe('memory');
|
||||
});
|
||||
|
||||
it('delegates tool execution to handlerExecutor', async () => {
|
||||
const executor = mockExecutor();
|
||||
const toolSchema = makeToolSchema({ name: 'my-tool' });
|
||||
const schema = minimalSchema({ tools: [toolSchema] });
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
// Access the built tool's handler via declaredTools
|
||||
const tools = agent.declaredTools;
|
||||
expect(tools).toHaveLength(1);
|
||||
|
||||
const result = await tools[0].handler!({ query: 'test' }, { parentTelemetry: undefined });
|
||||
expect(executor.executeTool).toHaveBeenCalledWith(
|
||||
'my-tool',
|
||||
{ query: 'test' },
|
||||
{ parentTelemetry: undefined },
|
||||
);
|
||||
expect(result).toEqual({ result: 'mocked' });
|
||||
});
|
||||
|
||||
it('reconstructs guardrails with correct position', async () => {
|
||||
const schema = minimalSchema({
|
||||
guardrails: [
|
||||
{
|
||||
name: 'pii-guard',
|
||||
guardType: 'pii',
|
||||
strategy: 'redact',
|
||||
position: 'input',
|
||||
config: { detectionTypes: ['email', 'phone'] },
|
||||
source: '',
|
||||
},
|
||||
{
|
||||
name: 'mod-guard',
|
||||
guardType: 'moderation',
|
||||
strategy: 'block',
|
||||
position: 'output',
|
||||
config: {},
|
||||
source: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.guardrails).toHaveLength(2);
|
||||
expect(described.guardrails[0].name).toBe('pii-guard');
|
||||
expect(described.guardrails[0].position).toBe('input');
|
||||
expect(described.guardrails[0].guardType).toBe('pii');
|
||||
expect(described.guardrails[1].name).toBe('mod-guard');
|
||||
expect(described.guardrails[1].position).toBe('output');
|
||||
});
|
||||
|
||||
it('reconstructs evals with proxy _run', async () => {
|
||||
const executor = mockExecutor();
|
||||
const schema = minimalSchema({
|
||||
evaluations: [
|
||||
{
|
||||
name: 'accuracy',
|
||||
description: 'Check accuracy',
|
||||
type: 'check',
|
||||
modelId: null,
|
||||
credentialName: null,
|
||||
hasCredential: false,
|
||||
handlerSource: null,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
description: 'Judge quality',
|
||||
type: 'judge',
|
||||
modelId: 'anthropic/claude-sonnet-4-5',
|
||||
credentialName: 'anthropic',
|
||||
hasCredential: true,
|
||||
handlerSource: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.evaluations).toHaveLength(2);
|
||||
expect(described.evaluations[0].name).toBe('accuracy');
|
||||
expect(described.evaluations[0].type).toBe('check');
|
||||
expect(described.evaluations[1].name).toBe('quality');
|
||||
expect(described.evaluations[1].type).toBe('judge');
|
||||
});
|
||||
|
||||
it('reconstructs provider tools', async () => {
|
||||
const schema = minimalSchema({
|
||||
providerTools: [{ name: 'anthropic.web_search_20250305', source: '' }],
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
const described = agent.describe();
|
||||
|
||||
expect(described.providerTools).toHaveLength(1);
|
||||
expect(described.providerTools[0].name).toBe('anthropic.web_search_20250305');
|
||||
});
|
||||
|
||||
it('evaluates provider tool source via evaluateExpression', async () => {
|
||||
const executor = mockExecutor();
|
||||
(executor.evaluateExpression as jest.Mock).mockResolvedValue({
|
||||
name: 'anthropic.web_search_20250305',
|
||||
args: { maxUses: 5 },
|
||||
});
|
||||
const schema = minimalSchema({
|
||||
providerTools: [
|
||||
{
|
||||
name: 'anthropic.web_search_20250305',
|
||||
source: 'providerTools.anthropicWebSearch({ maxUses: 5 })',
|
||||
},
|
||||
],
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
const described = agent.describe();
|
||||
|
||||
expect(executor.evaluateExpression).toHaveBeenCalledWith(
|
||||
'providerTools.anthropicWebSearch({ maxUses: 5 })',
|
||||
);
|
||||
expect(described.providerTools).toHaveLength(1);
|
||||
expect(described.providerTools[0].name).toBe('anthropic.web_search_20250305');
|
||||
});
|
||||
|
||||
it('evaluates structuredOutput schema via evaluateSchema', async () => {
|
||||
const zodSchema = z.object({ answer: z.string() });
|
||||
const executor = mockExecutor();
|
||||
(executor.evaluateSchema as jest.Mock).mockResolvedValue(zodSchema);
|
||||
const schema = minimalSchema({
|
||||
config: {
|
||||
structuredOutput: { enabled: true, schemaSource: 'z.object({ answer: z.string() })' },
|
||||
thinking: null,
|
||||
toolCallConcurrency: null,
|
||||
requireToolApproval: false,
|
||||
},
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
|
||||
expect(executor.evaluateSchema).toHaveBeenCalledWith('z.object({ answer: z.string() })');
|
||||
expect(described.config.structuredOutput.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('handles suspend result detection via isSuspendResult', () => {
|
||||
const suspendMarker = Symbol.for('n8n.agent.suspend');
|
||||
const suspendResult = { [suspendMarker]: true, payload: { message: 'approve?' } };
|
||||
const nonSuspend = { result: 42 };
|
||||
|
||||
expect(isSuspendResult(suspendResult)).toBe(true);
|
||||
expect(isSuspendResult(nonSuspend)).toBe(false);
|
||||
expect(isSuspendResult(null)).toBe(false);
|
||||
expect(isSuspendResult(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('delegates interruptible tool execution with suspend detection', async () => {
|
||||
const suspendMarker = Symbol.for('n8n.agent.suspend');
|
||||
const executor = {
|
||||
...mockExecutor(),
|
||||
executeTool: jest.fn().mockResolvedValue({
|
||||
[suspendMarker]: true,
|
||||
payload: { message: 'Please approve' },
|
||||
}),
|
||||
};
|
||||
|
||||
const toolSchema = makeToolSchema({
|
||||
name: 'suspend-tool',
|
||||
hasSuspend: true,
|
||||
});
|
||||
const schema = minimalSchema({ tools: [toolSchema] });
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
const tools = agent.declaredTools;
|
||||
expect(tools).toHaveLength(1);
|
||||
|
||||
// Call with an interruptible context
|
||||
let suspendedPayload: unknown;
|
||||
const ctx = {
|
||||
parentTelemetry: undefined,
|
||||
resumeData: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
suspend: jest.fn().mockImplementation(async (payload: unknown) => {
|
||||
suspendedPayload = payload;
|
||||
return { suspended: true };
|
||||
}),
|
||||
};
|
||||
|
||||
await tools[0].handler!({ query: 'test' }, ctx);
|
||||
|
||||
expect(ctx.suspend).toHaveBeenCalledWith({ message: 'Please approve' });
|
||||
expect(suspendedPayload).toEqual({ message: 'Please approve' });
|
||||
});
|
||||
|
||||
it('reconstructs requireApproval on individual tools', async () => {
|
||||
const toolSchema = makeToolSchema({
|
||||
name: 'danger-tool',
|
||||
requireApproval: true,
|
||||
});
|
||||
const schema = minimalSchema({
|
||||
tools: [toolSchema],
|
||||
checkpoint: 'memory',
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: mockExecutor(),
|
||||
});
|
||||
|
||||
// The tool should be wrapped for approval, which adds suspendSchema
|
||||
const tools = agent.declaredTools;
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].suspendSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it('reconstructs MCP servers by evaluating configSource', async () => {
|
||||
const executor = mockExecutor();
|
||||
(executor.evaluateExpression as jest.Mock).mockResolvedValue({
|
||||
name: 'browser',
|
||||
url: 'http://localhost:9222/mcp',
|
||||
transport: 'streamableHttp',
|
||||
});
|
||||
|
||||
const schema = minimalSchema({
|
||||
mcp: [
|
||||
{
|
||||
name: 'browser',
|
||||
configSource:
|
||||
'({ name: "browser", url: "http://localhost:9222/mcp", transport: "streamableHttp" })',
|
||||
},
|
||||
],
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
expect(executor.evaluateExpression).toHaveBeenCalledWith(
|
||||
'({ name: "browser", url: "http://localhost:9222/mcp", transport: "streamableHttp" })',
|
||||
);
|
||||
|
||||
const described = agent.describe();
|
||||
expect(described.mcp).toHaveLength(1);
|
||||
expect(described.mcp![0].name).toBe('browser');
|
||||
});
|
||||
|
||||
it('reconstructs multiple MCP servers', async () => {
|
||||
const executor = mockExecutor();
|
||||
(executor.evaluateExpression as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
name: 'browser',
|
||||
url: 'http://localhost:9222/mcp',
|
||||
transport: 'streamableHttp',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
name: 'fs',
|
||||
command: 'npx',
|
||||
args: ['@anthropic/mcp-fs', '/tmp'],
|
||||
});
|
||||
|
||||
const schema = minimalSchema({
|
||||
mcp: [
|
||||
{ name: 'browser', configSource: 'browserConfig' },
|
||||
{ name: 'fs', configSource: 'fsConfig' },
|
||||
],
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
expect(described.mcp).toHaveLength(2);
|
||||
expect(described.mcp![0].name).toBe('browser');
|
||||
expect(described.mcp![1].name).toBe('fs');
|
||||
});
|
||||
|
||||
it('skips MCP servers with empty configSource', async () => {
|
||||
const schema = minimalSchema({
|
||||
mcp: [{ name: 'browser', configSource: '' }],
|
||||
});
|
||||
const executor = mockExecutor();
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
expect(executor.evaluateExpression).not.toHaveBeenCalled();
|
||||
// No MCP configs evaluated means no client is added
|
||||
const described = agent.describe();
|
||||
expect(described.mcp).toBeNull();
|
||||
});
|
||||
|
||||
it('reconstructs telemetry by evaluating source', async () => {
|
||||
const executor = mockExecutor();
|
||||
(executor.evaluateExpression as jest.Mock).mockResolvedValue({
|
||||
enabled: true,
|
||||
functionId: 'my-agent',
|
||||
recordInputs: true,
|
||||
recordOutputs: true,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
const schema = minimalSchema({
|
||||
telemetry: { source: 'new Telemetry().functionId("my-agent").build()' },
|
||||
});
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
expect(executor.evaluateExpression).toHaveBeenCalledWith(
|
||||
'new Telemetry().functionId("my-agent").build()',
|
||||
);
|
||||
|
||||
const described = agent.describe();
|
||||
expect(described.telemetry).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not set telemetry when schema has no telemetry', async () => {
|
||||
const schema = minimalSchema({ telemetry: null });
|
||||
const executor = mockExecutor();
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
const described = agent.describe();
|
||||
expect(described.telemetry).toBeNull();
|
||||
expect(executor.evaluateExpression).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evaluates suspend/resume schemas via evaluateSchema', async () => {
|
||||
const suspendSchema = z.object({ reason: z.string() });
|
||||
const resumeSchema = z.object({ approved: z.boolean() });
|
||||
|
||||
const executor = mockExecutor();
|
||||
(executor.evaluateSchema as jest.Mock)
|
||||
.mockResolvedValueOnce(suspendSchema)
|
||||
.mockResolvedValueOnce(resumeSchema);
|
||||
|
||||
const toolSchema = makeToolSchema({
|
||||
name: 'interruptible-tool',
|
||||
hasSuspend: true,
|
||||
hasResume: true,
|
||||
suspendSchemaSource: 'z.object({ reason: z.string() })',
|
||||
resumeSchemaSource: 'z.object({ approved: z.boolean() })',
|
||||
});
|
||||
const schema = minimalSchema({ tools: [toolSchema] });
|
||||
|
||||
const agent = await Agent.fromSchema(schema, 'test-agent', {
|
||||
handlerExecutor: executor,
|
||||
});
|
||||
|
||||
const tools = agent.declaredTools;
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].suspendSchema).toBe(suspendSchema);
|
||||
expect(tools[0].resumeSchema).toBe(resumeSchema);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { InMemoryMemory } from '../runtime/memory-store';
|
||||
import type { AgentDbMessage } from '../types/sdk/message';
|
||||
import type { AgentDbMessage, Message } from '../types/sdk/message';
|
||||
|
||||
describe('InMemoryMemory working memory', () => {
|
||||
it('returns null for unknown key', async () => {
|
||||
|
|
@ -117,3 +117,59 @@ describe('InMemoryMemory — message createdAt', () => {
|
|||
expect(loaded[1].createdAt.getTime()).toBe(t2.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upsert contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('InMemoryMemory — saveMessages upsert by id', () => {
|
||||
it('upserts by id (no duplicate rows after a re-save)', async () => {
|
||||
const mem = new InMemoryMemory();
|
||||
const t1 = new Date('2020-01-01T00:00:01.000Z');
|
||||
|
||||
await mem.saveMessages({
|
||||
threadId: 't1',
|
||||
messages: [makeDbMsg('msg-1', t1, 'original')],
|
||||
});
|
||||
|
||||
const updated = { ...makeDbMsg('msg-1', t1, 'updated content') };
|
||||
await mem.saveMessages({ threadId: 't1', messages: [updated] });
|
||||
|
||||
const result = await mem.getMessages('t1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(((result[0] as Message).content[0] as { type: string; text: string }).text).toBe(
|
||||
'updated content',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves insertion order on upsert', async () => {
|
||||
const mem = new InMemoryMemory();
|
||||
const t1 = new Date('2020-01-01T00:00:01.000Z');
|
||||
const t2 = new Date('2020-01-01T00:00:02.000Z');
|
||||
const t3 = new Date('2020-01-01T00:00:03.000Z');
|
||||
|
||||
await mem.saveMessages({
|
||||
threadId: 't1',
|
||||
messages: [
|
||||
makeDbMsg('m1', t1, 'first'),
|
||||
makeDbMsg('m2', t2, 'second'),
|
||||
makeDbMsg('m3', t3, 'third'),
|
||||
],
|
||||
});
|
||||
|
||||
// Update m2 in place
|
||||
await mem.saveMessages({
|
||||
threadId: 't1',
|
||||
messages: [makeDbMsg('m2', t2, 'second-updated')],
|
||||
});
|
||||
|
||||
const result = await mem.getMessages('t1');
|
||||
expect(result).toHaveLength(3);
|
||||
// Original order preserved
|
||||
expect(result[0].id).toBe('m1');
|
||||
expect(result[1].id).toBe('m2');
|
||||
expect(result[2].id).toBe('m3');
|
||||
// Updated content
|
||||
expect(((result[1] as Message).content[0] as { text: string }).text).toBe('second-updated');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* Round-trip conversion tests: toAiMessages ↔ fromAiMessages
|
||||
*
|
||||
* These tests exercise the message split/merge logic without making real LLM
|
||||
* calls. They lock down the structural invariants that the agent runtime relies
|
||||
* on, including the key interim-message ordering guarantee described in the
|
||||
* plan:
|
||||
*
|
||||
* input: [assistant{tool-call resolved}, user{x}, assistant{y}]
|
||||
* output: [assistant{tool-call}, tool{tool-result}, user{x}, assistant{y}]
|
||||
*
|
||||
* The tool-result is inserted right after its tool-call, regardless of what
|
||||
* messages follow it in the n8n list.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { toAiMessages, fromAiMessages } from '../../runtime/messages';
|
||||
import type { Message } from '../../types/sdk/message';
|
||||
|
||||
describe('toAiMessages + fromAiMessages — round-trip', () => {
|
||||
it('splits a resolved tool-call into assistant + tool ModelMessages', () => {
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'add',
|
||||
input: { a: 1, b: 2 },
|
||||
state: 'resolved',
|
||||
output: { result: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
|
||||
expect(aiMessages).toHaveLength(2);
|
||||
expect(aiMessages[0].role).toBe('assistant');
|
||||
expect(aiMessages[1].role).toBe('tool');
|
||||
|
||||
const toolCallPart = (
|
||||
aiMessages[0] as { role: string; content: Array<{ type: string; toolCallId: string }> }
|
||||
).content[0];
|
||||
expect(toolCallPart.type).toBe('tool-call');
|
||||
expect(toolCallPart.toolCallId).toBe('tc-1');
|
||||
|
||||
const toolResultPart = (
|
||||
aiMessages[1] as {
|
||||
role: string;
|
||||
content: Array<{
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
output: { type: string; value: unknown };
|
||||
}>;
|
||||
}
|
||||
).content[0];
|
||||
expect(toolResultPart.type).toBe('tool-result');
|
||||
expect(toolResultPart.toolCallId).toBe('tc-1');
|
||||
expect(toolResultPart.output.type).toBe('json');
|
||||
expect(toolResultPart.output.value).toEqual({ result: 3 });
|
||||
});
|
||||
|
||||
it('encodes rejected tool-call as error-text in the tool ModelMessage', () => {
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'do_it',
|
||||
input: {},
|
||||
state: 'rejected',
|
||||
error: 'Error: something went wrong',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
expect(aiMessages).toHaveLength(2);
|
||||
|
||||
const toolResultPart = (
|
||||
aiMessages[1] as { role: string; content: Array<{ output: { type: string; value: string } }> }
|
||||
).content[0];
|
||||
expect(toolResultPart.output.type).toBe('error-text');
|
||||
expect(toolResultPart.output.value).toBe('Error: something went wrong');
|
||||
});
|
||||
|
||||
it('drops pending tool-call blocks from both assistant and tool ModelMessages', () => {
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Thinking...' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'do_it',
|
||||
input: {},
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
|
||||
// Only the assistant text part remains; no tool-result emitted for pending
|
||||
expect(aiMessages).toHaveLength(1);
|
||||
expect(aiMessages[0].role).toBe('assistant');
|
||||
const content = (aiMessages[0] as { role: string; content: Array<{ type: string }> }).content;
|
||||
expect(content).toHaveLength(1);
|
||||
expect(content[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('emits nothing for an assistant message whose only blocks are all pending', () => {
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'do_it',
|
||||
input: {},
|
||||
state: 'pending',
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-2',
|
||||
toolName: 'do_more',
|
||||
input: {},
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
|
||||
// No empty-content assistant message — the whole message is suppressed
|
||||
expect(aiMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips legacy tool-call blocks that have no state field and emits nothing when they are the only content', () => {
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
// Simulate a DB row written before the state field was introduced
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-legacy',
|
||||
toolName: 'old_tool',
|
||||
input: {},
|
||||
} as unknown as Message['content'][number],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
|
||||
// No empty-content assistant message and no spurious error-json tool message
|
||||
expect(aiMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits one tool ModelMessage per settled block in the same assistant turn', () => {
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'add',
|
||||
input: { a: 1, b: 2 },
|
||||
state: 'resolved',
|
||||
output: { result: 3 },
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-2',
|
||||
toolName: 'mul',
|
||||
input: { a: 4, b: 5 },
|
||||
state: 'resolved',
|
||||
output: { result: 20 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
|
||||
// assistant{tc-1, tc-2} + tool{tc-1} + tool{tc-2}
|
||||
expect(aiMessages).toHaveLength(3);
|
||||
expect(aiMessages[0].role).toBe('assistant');
|
||||
const assistantContent = (
|
||||
aiMessages[0] as { content: Array<{ type: string; toolCallId: string }> }
|
||||
).content;
|
||||
expect(assistantContent).toHaveLength(2);
|
||||
expect(assistantContent[0].toolCallId).toBe('tc-1');
|
||||
expect(assistantContent[1].toolCallId).toBe('tc-2');
|
||||
|
||||
expect(aiMessages[1].role).toBe('tool');
|
||||
expect(aiMessages[2].role).toBe('tool');
|
||||
});
|
||||
|
||||
it('merges role:tool ModelMessages into the preceding assistant tool-call block', () => {
|
||||
// Simulate AI SDK output: [assistant{tool-call}, tool{tool-result}]
|
||||
const aiMessages = [
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'add',
|
||||
input: { a: 1, b: 2 },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'add',
|
||||
output: { type: 'json' as const, value: { result: 3 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const n8nMessages = fromAiMessages(aiMessages);
|
||||
|
||||
// Should produce a single assistant message with the resolved block
|
||||
expect(n8nMessages).toHaveLength(1);
|
||||
expect((n8nMessages[0] as Message).role).toBe('assistant');
|
||||
const block = (n8nMessages[0] as Message).content[0];
|
||||
expect(block.type).toBe('tool-call');
|
||||
expect((block as { state: string }).state).toBe('resolved');
|
||||
expect((block as { output: unknown }).output).toEqual({ result: 3 });
|
||||
});
|
||||
|
||||
it('round-trip is structurally equivalent for a resolved tool-call', () => {
|
||||
const original: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'echo',
|
||||
input: { text: 'hello' },
|
||||
state: 'resolved',
|
||||
output: { echoed: 'hello' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(original);
|
||||
const roundTripped = fromAiMessages(aiMessages);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect((roundTripped[0] as Message).role).toBe('assistant');
|
||||
const block = (roundTripped[0] as Message).content[0];
|
||||
expect(block.type).toBe('tool-call');
|
||||
expect((block as { state: string }).state).toBe('resolved');
|
||||
expect((block as { output: unknown }).output).toEqual({ echoed: 'hello' });
|
||||
expect((block as { toolCallId: string }).toolCallId).toBe('tc-1');
|
||||
});
|
||||
|
||||
it('interim-message ordering: tool-result is inserted right after its tool-call', () => {
|
||||
// This is the key regression test for the interim-message scenario.
|
||||
// Input n8n list: [assistant{tool-call resolved}, user{x}, assistant{y}]
|
||||
// Expected AI SDK output: [assistant{tc}, tool{tr}, user{x}, assistant{y}]
|
||||
const input: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'delete_file',
|
||||
input: { path: 'foo.txt' },
|
||||
state: 'resolved',
|
||||
output: { deleted: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Actually, what is 2+2?' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'It is 4.' }],
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessages = toAiMessages(input);
|
||||
|
||||
// 4 messages: assistant{tool-call}, tool{tool-result}, user, assistant
|
||||
expect(aiMessages).toHaveLength(4);
|
||||
expect(aiMessages[0].role).toBe('assistant');
|
||||
expect(aiMessages[1].role).toBe('tool');
|
||||
expect(aiMessages[2].role).toBe('user');
|
||||
expect(aiMessages[3].role).toBe('assistant');
|
||||
|
||||
// tool-result is immediately after the assistant tool-call message
|
||||
const toolResultContent = (aiMessages[1] as { content: Array<{ toolCallId: string }> })
|
||||
.content[0];
|
||||
expect(toolResultContent.toolCallId).toBe('tc-1');
|
||||
|
||||
// user interim message is after the tool-result
|
||||
const userContent = (aiMessages[2] as { content: Array<{ type: string; text: string }> })
|
||||
.content[0];
|
||||
expect(userContent.text).toBe('Actually, what is 2+2?');
|
||||
});
|
||||
});
|
||||
|
|
@ -106,7 +106,7 @@ describe('batched tool execution integration', () => {
|
|||
const resumedStream = await agent.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: next.runId!, toolCallId: next.toolCallId! },
|
||||
{ runId: next.runId, toolCallId: next.toolCallId },
|
||||
);
|
||||
|
||||
const resumedChunks = await collectStreamChunks(resumedStream.stream);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
createAgentWithConcurrentMixedTools,
|
||||
collectTextDeltas,
|
||||
} from './helpers';
|
||||
import { isLlmMessage, type StreamChunk } from '../../index';
|
||||
import type { StreamChunk } from '../../index';
|
||||
|
||||
const describe = describeIf('anthropic');
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ describe('concurrent tool execution integration', () => {
|
|||
const resumedStream = await agent.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: next.runId!, toolCallId: next.toolCallId! },
|
||||
{ runId: next.runId, toolCallId: next.toolCallId },
|
||||
);
|
||||
|
||||
const resumedChunks = await collectStreamChunks(resumedStream.stream);
|
||||
|
|
@ -147,13 +147,8 @@ describe('concurrent tool execution integration', () => {
|
|||
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
|
||||
// list_files should auto-execute — its result should appear as a message chunk
|
||||
const toolResultChunks = chunks.filter(
|
||||
(c) =>
|
||||
c.type === 'message' &&
|
||||
isLlmMessage(c.message) &&
|
||||
c.message.content.some((p) => p.type === 'tool-result'),
|
||||
);
|
||||
// list_files should auto-execute — its result should appear as a discrete tool-result chunk
|
||||
const toolResultChunks = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
// delete_file should be suspended
|
||||
const suspendedChunks = chunksOfType(chunks, 'tool-call-suspended');
|
||||
|
|
@ -170,12 +165,7 @@ describe('concurrent tool execution integration', () => {
|
|||
);
|
||||
|
||||
// list_files result should be present even though delete_file suspended
|
||||
const listResult = toolResultChunks.find(
|
||||
(c) =>
|
||||
c.type === 'message' &&
|
||||
isLlmMessage(c.message) &&
|
||||
c.message.content.some((p) => p.type === 'tool-result' && p.toolName === 'list_files'),
|
||||
);
|
||||
const listResult = toolResultChunks.find((c) => c.toolName === 'list_files');
|
||||
expect(listResult).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
|
@ -204,7 +194,7 @@ describe('concurrent tool execution integration', () => {
|
|||
'content' in m
|
||||
? m.content
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => ({ type: 'text-delta' as const, delta: c.text }))
|
||||
.map((c) => ({ type: 'text-delta' as const, id: '', delta: c.text }))
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -175,42 +175,53 @@ describe('event system — stream', () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// result.getState()
|
||||
// getState()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('result.getState()', () => {
|
||||
it('generate() result reports success after a successful run', async () => {
|
||||
describe('getState()', () => {
|
||||
it('returns idle before first run', () => {
|
||||
const agent = createSimpleAgent();
|
||||
const result = await agent.generate('Say hello');
|
||||
expect(result.getState().status).toBe('success');
|
||||
const state = agent.getState();
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.messageList.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stream() result reports success after the stream is fully consumed', async () => {
|
||||
it('returns success after a successful generate()', async () => {
|
||||
const agent = createSimpleAgent();
|
||||
const { stream, getState } = await agent.stream('Say hello');
|
||||
await agent.generate('Say hello');
|
||||
const state = agent.getState();
|
||||
expect(state.status).toBe('success');
|
||||
});
|
||||
|
||||
it('returns success after a completed stream()', async () => {
|
||||
const agent = createSimpleAgent();
|
||||
const { stream } = await agent.stream('Say hello');
|
||||
await collectStreamChunks(stream);
|
||||
expect(getState().status).toBe('success');
|
||||
const state = agent.getState();
|
||||
expect(state.status).toBe('success');
|
||||
});
|
||||
|
||||
it('stream() getState() is running while the stream is being consumed', async () => {
|
||||
it('state is running during the generate loop (observed via event)', async () => {
|
||||
const agent = createSimpleAgent();
|
||||
const { stream, getState } = await agent.stream('Say hello');
|
||||
|
||||
// State is running before the stream is consumed
|
||||
expect(getState().status).toBe('running');
|
||||
let stateWhileRunning: string | undefined;
|
||||
agent.on(AgentEvent.TurnStart, () => {
|
||||
stateWhileRunning = agent.getState().status;
|
||||
});
|
||||
|
||||
await collectStreamChunks(stream);
|
||||
await agent.generate('Say hello');
|
||||
|
||||
expect(getState().status).toBe('success');
|
||||
expect(stateWhileRunning).toBe('running');
|
||||
});
|
||||
|
||||
it('generate() result reflects resourceId and threadId from RunOptions', async () => {
|
||||
it('reflects resourceId and threadId from RunOptions', async () => {
|
||||
const agent = createSimpleAgent();
|
||||
const result = await agent.generate('Say hello', {
|
||||
await agent.generate('Say hello', {
|
||||
persistence: { resourceId: 'user-123', threadId: 'thread-abc' },
|
||||
});
|
||||
expect(result.getState().persistence?.resourceId).toBe('user-123');
|
||||
expect(result.getState().persistence?.threadId).toBe('thread-abc');
|
||||
const state = agent.getState();
|
||||
expect(state.persistence?.resourceId).toBe('user-123');
|
||||
expect(state.persistence?.threadId).toBe('thread-abc');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { z } from 'zod';
|
|||
import {
|
||||
Agent,
|
||||
type ContentToolCall,
|
||||
type ContentToolResult,
|
||||
filterLlmMessages,
|
||||
Tool,
|
||||
type StreamChunk,
|
||||
|
|
@ -404,10 +403,10 @@ export const findAllToolCalls = (messages: AgentMessage[]): ContentToolCall[] =>
|
|||
.map((m) => m.content.filter((c) => c.type === 'tool-call'))
|
||||
.flat();
|
||||
};
|
||||
export const findAllToolResults = (messages: AgentMessage[]): ContentToolResult[] => {
|
||||
return filterLlmMessages(messages)
|
||||
.filter((m) => m.content.find((c) => c.type === 'tool-result'))
|
||||
.map((m) => m.content.find((c) => c.type === 'tool-result') as ContentToolResult);
|
||||
export const findAllToolResults = (messages: AgentMessage[]): ContentToolCall[] => {
|
||||
return filterLlmMessages(messages).flatMap((m) =>
|
||||
m.content.filter((c): c is ContentToolCall => c.type === 'tool-call' && c.state !== 'pending'),
|
||||
);
|
||||
};
|
||||
export const collectTextDeltas = (chunks: StreamChunk[]): string => {
|
||||
return chunks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Regression test: interim user message while a tool-call is suspended.
|
||||
*
|
||||
* Old architecture bug: if a user sent a new message between a tool-call
|
||||
* suspension and its eventual resume, the message list would contain:
|
||||
*
|
||||
* assistant{tool-call} → user{interim} → tool{tool-result}
|
||||
*
|
||||
* This order is invalid for AI SDK providers (tool-result must immediately
|
||||
* follow its tool-call). The new architecture stores the result ON the
|
||||
* tool-call block, so toAiMessages always emits:
|
||||
*
|
||||
* assistant{tool-call} → tool{tool-result} → user{interim} → assistant{reply}
|
||||
*
|
||||
* The tool-result is always adjacent to its tool-call regardless of what n8n
|
||||
* messages come after it in the list.
|
||||
*
|
||||
* This test drives the full scenario end-to-end and asserts that:
|
||||
* 1. The final result has finishReason 'stop' (no provider error).
|
||||
* 2. The tool-call block on the originating assistant message transitions to
|
||||
* state 'resolved' with the expected output.
|
||||
* 3. The interim user/assistant messages are still present in memory.
|
||||
*/
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { describeIf, createSqliteMemory, getModel } from './helpers';
|
||||
import { Agent, filterLlmMessages, Memory, Tool } from '../../index';
|
||||
import type { AgentDbMessage } from '../../index';
|
||||
import type { ContentToolCall, Message } from '../../types/sdk/message';
|
||||
|
||||
const describe = describeIf('anthropic');
|
||||
|
||||
describe('interim user message during tool suspension', () => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups.length = 0;
|
||||
});
|
||||
|
||||
function buildInterruptibleAgent(mem: Memory): Agent {
|
||||
const deleteTool = new Tool('delete_file')
|
||||
.description('Delete a file at the given path')
|
||||
.input(z.object({ path: z.string().describe('File path to delete') }))
|
||||
.output(z.object({ deleted: z.boolean(), path: z.string() }))
|
||||
.suspend(z.object({ message: z.string(), severity: z.string() }))
|
||||
.resume(z.object({ approved: z.boolean() }))
|
||||
.handler(async ({ path }, ctx) => {
|
||||
if (!ctx.resumeData) {
|
||||
return await ctx.suspend({ message: `Delete "${path}"?`, severity: 'destructive' });
|
||||
}
|
||||
if (!ctx.resumeData.approved) return { deleted: false, path };
|
||||
return { deleted: true, path };
|
||||
});
|
||||
|
||||
return new Agent('interim-test-agent')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions(
|
||||
'You are a file manager. When asked to delete a file, use the delete_file tool. Be concise.',
|
||||
)
|
||||
.tool(deleteTool)
|
||||
.memory(mem)
|
||||
.checkpoint('memory');
|
||||
}
|
||||
|
||||
for (const method of ['generate', 'stream'] as const) {
|
||||
it(`[${method}] interim message does not break provider message ordering`, async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const threadId = `thread-interim-${method}`;
|
||||
const resourceId = 'res-interim';
|
||||
const persistence = { threadId, resourceId };
|
||||
const mem = new Memory().storage(memory);
|
||||
|
||||
const agent = buildInterruptibleAgent(mem);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Turn 1: trigger the tool suspension
|
||||
// ----------------------------------------------------------------
|
||||
const suspendResult = await agent.generate('Please delete /tmp/interim-test.txt', {
|
||||
persistence,
|
||||
});
|
||||
|
||||
expect(suspendResult.finishReason).toBe('tool-calls');
|
||||
expect(suspendResult.pendingSuspend).toBeDefined();
|
||||
const { runId, toolCallId } = suspendResult.pendingSuspend![0];
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Interim turn: send a new message while the tool is suspended.
|
||||
// Build a fresh agent instance to simulate a separate request.
|
||||
// ----------------------------------------------------------------
|
||||
const interimAgent = new Agent('interim-agent')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions('You are helpful. Answer questions concisely.')
|
||||
.memory(mem);
|
||||
|
||||
const interimResult = await interimAgent.generate('What is 1 + 1?', { persistence });
|
||||
expect(interimResult.finishReason).toBe('stop');
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Resume turn: approve the suspended tool call
|
||||
// ----------------------------------------------------------------
|
||||
let resumeFinishReason: string;
|
||||
if (method === 'generate') {
|
||||
const result = await agent.resume(
|
||||
'generate',
|
||||
{ approved: true },
|
||||
{
|
||||
runId,
|
||||
toolCallId,
|
||||
},
|
||||
);
|
||||
resumeFinishReason = result.finishReason ?? 'stop';
|
||||
} else {
|
||||
const { stream } = await agent.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{
|
||||
runId,
|
||||
toolCallId,
|
||||
},
|
||||
);
|
||||
// Drain the stream
|
||||
const reader = stream.getReader();
|
||||
let finishReason = 'stop';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if ((value as { type: string }).type === 'finish') {
|
||||
finishReason = (value as { finishReason?: string }).finishReason ?? 'stop';
|
||||
}
|
||||
}
|
||||
resumeFinishReason = finishReason;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Assertions
|
||||
// ----------------------------------------------------------------
|
||||
// 1. No provider error — the ordering was valid
|
||||
expect(resumeFinishReason).toBe('stop');
|
||||
|
||||
// 2. The originating assistant message's tool-call block is resolved
|
||||
const allMessages = await memory.getMessages(threadId);
|
||||
const llmMessages = filterLlmMessages(allMessages);
|
||||
|
||||
const ourBlock = llmMessages
|
||||
.flatMap((m) => m.content.filter((c): c is ContentToolCall => c.type === 'tool-call'))
|
||||
.find((b) => b.toolCallId === toolCallId);
|
||||
|
||||
expect(ourBlock).toBeDefined();
|
||||
expect(ourBlock!.state).toBe('resolved');
|
||||
|
||||
// 3. The interim user/assistant exchange is present in memory
|
||||
const userMessages = allMessages.filter(
|
||||
(m): m is AgentDbMessage & Message => 'role' in m && m.role === 'user',
|
||||
);
|
||||
// Turn-1 user + interim user (at minimum)
|
||||
expect(userMessages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
|
||||
it('preserves chronological ordering of messages in memory after resume', async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const threadId = 'thread-interim-ordering';
|
||||
const resourceId = 'res-ordering';
|
||||
const persistence = { threadId, resourceId };
|
||||
const mem = new Memory().storage(memory);
|
||||
|
||||
const agent = buildInterruptibleAgent(mem);
|
||||
|
||||
// Turn 1: suspend
|
||||
const suspendResult = await agent.generate('Delete /tmp/order-test.txt', { persistence });
|
||||
expect(suspendResult.finishReason).toBe('tool-calls');
|
||||
const { runId, toolCallId } = suspendResult.pendingSuspend![0];
|
||||
|
||||
// Interim turn
|
||||
const interimAgent = new Agent('interim-ordering')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions('Answer concisely.')
|
||||
.memory(mem);
|
||||
await interimAgent.generate('Say hi', { persistence });
|
||||
|
||||
// Resume
|
||||
const resumeResult = await agent.resume(
|
||||
'generate',
|
||||
{ approved: true },
|
||||
{
|
||||
runId,
|
||||
toolCallId,
|
||||
},
|
||||
);
|
||||
expect(resumeResult.finishReason).toBe('stop');
|
||||
|
||||
// The tool-call is resolved
|
||||
const allMessages = await memory.getMessages(threadId);
|
||||
const llmMessages = filterLlmMessages(allMessages);
|
||||
const ourBlock = llmMessages
|
||||
.flatMap((m) => m.content.filter((c): c is ContentToolCall => c.type === 'tool-call'))
|
||||
.find((b) => b.toolCallId === toolCallId);
|
||||
|
||||
expect(ourBlock).toBeDefined();
|
||||
expect(ourBlock!.state).toBe('resolved');
|
||||
|
||||
// Messages are in chronological order (createdAt ascending)
|
||||
const timestamps = allMessages.map((m) => m.createdAt.getTime());
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -72,12 +72,12 @@ describe('JSON Schema validation — non-MCP tools with raw JSON Schema', () =>
|
|||
// The handler should have been called with valid data
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ age: 25 }), expect.anything());
|
||||
|
||||
// No tool-result should carry an error flag
|
||||
// No tool-call block should have state 'rejected'
|
||||
const allMessages = filterLlmMessages(result.messages);
|
||||
const toolResults = allMessages.flatMap((m) =>
|
||||
m.content.filter((c) => c.type === 'tool-result'),
|
||||
const toolCallBlocks = allMessages.flatMap((m) =>
|
||||
m.content.filter((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(toolResults.every((r) => !r.isError)).toBe(true);
|
||||
expect(toolCallBlocks.every((c) => (c as { state: string }).state !== 'rejected')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows the LLM to self-correct after receiving a JSON Schema validation error', async () => {
|
||||
|
|
@ -105,12 +105,12 @@ describe('JSON Schema validation — non-MCP tools with raw JSON Schema', () =>
|
|||
expect(result.finishReason).toBe('stop');
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// There should be at least two tool-result messages: one error, one success
|
||||
// There should be at least two tool-call messages: one rejected, one resolved
|
||||
const allMessages = filterLlmMessages(result.messages);
|
||||
const toolResultMessages = allMessages.filter((m) =>
|
||||
m.content.some((c) => c.type === 'tool-result'),
|
||||
const toolCallMessages = allMessages.filter((m) =>
|
||||
m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(toolResultMessages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(toolCallMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The successful handler call should have received a valid age
|
||||
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
chunksOfType,
|
||||
} from './helpers';
|
||||
import { startSseServer, type TestServer } from './mcp-server-helpers';
|
||||
import { Agent, McpClient, Tool, isLlmMessage } from '../../index';
|
||||
import { Agent, McpClient, Tool } from '../../index';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// McpClient constructor validation — no MCP server required
|
||||
|
|
@ -234,13 +234,10 @@ describe_llm('agent stream() with MCP tool', () => {
|
|||
const { stream } = await agent.stream('Echo "stream works" using tools_echo.');
|
||||
|
||||
const chunks = await collectStreamChunks(stream);
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const messages = messageChunks.map((c) => c.message);
|
||||
|
||||
const hasToolCall = messages.some(
|
||||
(m) => isLlmMessage(m) && m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(hasToolCall).toBe(true);
|
||||
// Tool calls now ride their own discrete `tool-call` chunks rather than
|
||||
// being wrapped in `message` envelopes.
|
||||
const toolCallChunks = chunksOfType(chunks, 'tool-call');
|
||||
expect(toolCallChunks.length).toBeGreaterThan(0);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import { expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { Agent, Memory, type AgentDbMessage } from '../../../index';
|
||||
import type { BuiltMemory, Thread } from '../../../types/sdk/memory';
|
||||
import type { BuiltMemory, MemoryDescriptor, Thread } from '../../../types/sdk/memory';
|
||||
import { describeIf, findLastTextContent, getModel } from '../helpers';
|
||||
|
||||
const describe = describeIf('anthropic');
|
||||
|
|
@ -17,6 +17,9 @@ const describe = describeIf('anthropic');
|
|||
// Custom in-memory BuiltMemory implementation (simulates Redis, DynamoDB, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
class CustomMapMemory implements BuiltMemory {
|
||||
describe(): MemoryDescriptor {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
readonly threads = new Map<string, Thread>();
|
||||
readonly messages = new Map<string, AgentDbMessage[]>();
|
||||
readonly workingMemory = new Map<string, string>();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ afterAll(async () => {
|
|||
}
|
||||
}, 30_000);
|
||||
|
||||
/**
|
||||
* Create a PostgresMemory instance backed by the test container connection string.
|
||||
* Uses a simple inline CredentialProvider that returns the raw URL.
|
||||
*/
|
||||
function makePostgresMemory(namespace: string): PostgresMemory {
|
||||
return new PostgresMemory({
|
||||
type: 'connection',
|
||||
connection: { connectionType: 'url', connection: { url: connectionString } },
|
||||
options: { namespace },
|
||||
});
|
||||
}
|
||||
|
||||
/** describe that requires Docker — tests are no-ops without it. */
|
||||
function describeWithDocker(name: string, fn: () => void) {
|
||||
describe(name, () => {
|
||||
|
|
@ -74,7 +86,7 @@ function describeWithDocker(name: string, fn: () => void) {
|
|||
|
||||
describeWithDocker('PostgresMemory saveThread upsert', () => {
|
||||
it('preserves existing title and metadata when not provided', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'upsert_test' });
|
||||
const mem = makePostgresMemory('upsert_test');
|
||||
|
||||
await mem.saveThread({
|
||||
id: 'upsert-t1',
|
||||
|
|
@ -95,7 +107,7 @@ describeWithDocker('PostgresMemory saveThread upsert', () => {
|
|||
});
|
||||
|
||||
it('overwrites title and metadata when explicitly provided', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'upsert_ow' });
|
||||
const mem = makePostgresMemory('upsert_ow');
|
||||
|
||||
await mem.saveThread({
|
||||
id: 'upsert-t2',
|
||||
|
|
@ -121,7 +133,7 @@ describeWithDocker('PostgresMemory saveThread upsert', () => {
|
|||
|
||||
describeWithDocker('PostgresMemory unit tests', () => {
|
||||
it('creates tables on first use and round-trips a thread', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString });
|
||||
const mem = makePostgresMemory('default');
|
||||
|
||||
const thread = await mem.saveThread({
|
||||
id: 'thread-1',
|
||||
|
|
@ -141,7 +153,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('saves and retrieves messages with limit', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'msg_test' });
|
||||
const mem = makePostgresMemory('msg_test');
|
||||
|
||||
await mem.saveThread({ id: 't1', resourceId: 'u1' });
|
||||
|
||||
|
|
@ -180,7 +192,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('saves and retrieves working memory keyed by resourceId', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_test' });
|
||||
const mem = makePostgresMemory('wm_test');
|
||||
|
||||
expect(
|
||||
await mem.getWorkingMemory({ threadId: 'thread-1', resourceId: 'user-1', scope: 'resource' }),
|
||||
|
|
@ -207,7 +219,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('saves and retrieves working memory keyed by threadId (no resourceId)', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_thread_test' });
|
||||
const mem = makePostgresMemory('wm_thread_test');
|
||||
|
||||
expect(
|
||||
await mem.getWorkingMemory({ threadId: 'thread-1', resourceId: 'user-1', scope: 'thread' }),
|
||||
|
|
@ -225,7 +237,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('isolates working memory by resourceId', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_iso_test' });
|
||||
const mem = makePostgresMemory('wm_iso_test');
|
||||
|
||||
await mem.saveWorkingMemory(
|
||||
{ threadId: 'thread-a', resourceId: 'user-a', scope: 'resource' },
|
||||
|
|
@ -247,7 +259,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('stores scope=resource when resourceId is provided', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_scope_test' });
|
||||
const mem = makePostgresMemory('wm_scope_test');
|
||||
|
||||
await mem.saveWorkingMemory(
|
||||
{ threadId: 'thread-1', resourceId: 'res-1', scope: 'resource' },
|
||||
|
|
@ -266,10 +278,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('stores scope=thread when only threadId is provided', async () => {
|
||||
const mem = new PostgresMemory({
|
||||
connection: connectionString,
|
||||
namespace: 'wm_scope_thread_test',
|
||||
});
|
||||
const mem = makePostgresMemory('wm_scope_thread_test');
|
||||
|
||||
await mem.saveWorkingMemory(
|
||||
{ threadId: 'thread-1', resourceId: 'user-1', scope: 'thread' },
|
||||
|
|
@ -288,10 +297,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('does not mix resource-scoped and thread-scoped entries with the same key value', async () => {
|
||||
const mem = new PostgresMemory({
|
||||
connection: connectionString,
|
||||
namespace: 'wm_scope_iso_test',
|
||||
});
|
||||
const mem = makePostgresMemory('wm_scope_iso_test');
|
||||
const sharedKey = 'same-id';
|
||||
|
||||
await mem.saveWorkingMemory(
|
||||
|
|
@ -318,7 +324,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('deletes thread and cascades to messages', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'del_test' });
|
||||
const mem = makePostgresMemory('del_test');
|
||||
|
||||
await mem.saveThread({ id: 'del-t1', resourceId: 'u1' });
|
||||
await mem.saveMessages({
|
||||
|
|
@ -342,7 +348,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('stores and queries embeddings with pgvector', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_test' });
|
||||
const mem = makePostgresMemory('vec_test');
|
||||
|
||||
await mem.saveThread({ id: 'vec-t1', resourceId: 'u1' });
|
||||
|
||||
|
|
@ -375,7 +381,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('filters embeddings by resourceId with scope=resource (default)', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_res' });
|
||||
const mem = makePostgresMemory('vec_res');
|
||||
|
||||
await mem.saveEmbeddings({
|
||||
threadId: 't1',
|
||||
|
|
@ -410,7 +416,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('filters embeddings by threadId with scope=thread', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_thr' });
|
||||
const mem = makePostgresMemory('vec_thr');
|
||||
|
||||
await mem.saveEmbeddings({
|
||||
threadId: 't1',
|
||||
|
|
@ -443,7 +449,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('resource scope excludes embeddings from other resources', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_iso' });
|
||||
const mem = makePostgresMemory('vec_iso');
|
||||
|
||||
await mem.saveEmbeddings({
|
||||
threadId: 't1',
|
||||
|
|
@ -470,7 +476,7 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('stores resourceId in the embeddings table', async () => {
|
||||
const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_col' });
|
||||
const mem = makePostgresMemory('vec_col');
|
||||
|
||||
await mem.saveEmbeddings({
|
||||
threadId: 't1',
|
||||
|
|
@ -492,8 +498,8 @@ describeWithDocker('PostgresMemory unit tests', () => {
|
|||
});
|
||||
|
||||
it('isolates namespaces', async () => {
|
||||
const mem1 = new PostgresMemory({ connection: connectionString, namespace: 'ns_a' });
|
||||
const mem2 = new PostgresMemory({ connection: connectionString, namespace: 'ns_b' });
|
||||
const mem1 = makePostgresMemory('ns_a');
|
||||
const mem2 = makePostgresMemory('ns_b');
|
||||
|
||||
await mem1.saveThread({ id: 'shared-id', resourceId: 'u1', title: 'From A' });
|
||||
await mem2.saveThread({ id: 'shared-id', resourceId: 'u1', title: 'From B' });
|
||||
|
|
@ -520,7 +526,7 @@ function describeWithDockerAndApi(name: string, fn: () => void) {
|
|||
|
||||
describeWithDockerAndApi('PostgresMemory agent integration', () => {
|
||||
it('recalls previous messages across turns', async () => {
|
||||
const store = new PostgresMemory({ connection: connectionString, namespace: 'agent_recall' });
|
||||
const store = makePostgresMemory('agent_recall');
|
||||
const memory = new Memory().storage(store).lastMessages(10);
|
||||
|
||||
const agent = new Agent('pg-recall-test')
|
||||
|
|
@ -540,7 +546,7 @@ describeWithDockerAndApi('PostgresMemory agent integration', () => {
|
|||
});
|
||||
|
||||
it('persists resource-scoped working memory via Postgres backend', async () => {
|
||||
const store = new PostgresMemory({ connection: connectionString, namespace: 'agent_wm' });
|
||||
const store = makePostgresMemory('agent_wm');
|
||||
const memory = new Memory()
|
||||
.storage(store)
|
||||
.lastMessages(10)
|
||||
|
|
@ -574,10 +580,7 @@ describeWithDockerAndApi('PostgresMemory agent integration', () => {
|
|||
});
|
||||
|
||||
it('persists thread-scoped working memory via Postgres backend', async () => {
|
||||
const store = new PostgresMemory({
|
||||
connection: connectionString,
|
||||
namespace: 'agent_thread_wm',
|
||||
});
|
||||
const store = makePostgresMemory('agent_thread_wm');
|
||||
const memory = new Memory()
|
||||
.storage(store)
|
||||
.lastMessages(10)
|
||||
|
|
@ -617,7 +620,7 @@ describeWithDockerAndApi('PostgresMemory agent integration', () => {
|
|||
});
|
||||
|
||||
it('works with stream() path', async () => {
|
||||
const store = new PostgresMemory({ connection: connectionString, namespace: 'agent_stream' });
|
||||
const store = makePostgresMemory('agent_stream');
|
||||
const memory = new Memory().storage(store).lastMessages(10);
|
||||
|
||||
const agent = new Agent('pg-stream-test')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
collectStreamChunks,
|
||||
getModel,
|
||||
chunksOfType,
|
||||
findAllToolResults,
|
||||
collectTextDeltas,
|
||||
} from './helpers';
|
||||
import { Agent, Tool } from '../../index';
|
||||
|
|
@ -43,15 +42,14 @@ describe('multi-tool-calls integration', () => {
|
|||
);
|
||||
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const toolCallResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
const toolCallResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
// Should have called the tool multiple times
|
||||
const priceCalls = toolCallResults.filter((tc) => tc.toolName === 'lookup_price');
|
||||
expect(priceCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Each call should have its own correct output (not all pointing to the first result)
|
||||
const outputs = priceCalls.map((tc) => tc.result as { product: string; price: number });
|
||||
const outputs = priceCalls.map((tc) => tc.output as { product: string; price: number });
|
||||
|
||||
// Verify that different products got different prices (index-based merging works)
|
||||
const uniquePrices = new Set(outputs.map((o) => o.price));
|
||||
|
|
@ -90,8 +88,7 @@ describe('multi-tool-calls integration', () => {
|
|||
const { stream: fullStream } = await agent.stream('What is 3 + 4 and also what is 5 * 6?');
|
||||
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const toolCallResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
const toolCallResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
const toolCalls = toolCallResults.filter(
|
||||
(tc) => tc.toolName === 'add' || tc.toolName === 'multiply',
|
||||
|
|
@ -104,8 +101,8 @@ describe('multi-tool-calls integration', () => {
|
|||
expect(addCall).toBeDefined();
|
||||
expect(multiplyCall).toBeDefined();
|
||||
|
||||
expect((addCall!.result as { result: number }).result).toBe(7);
|
||||
expect((multiplyCall!.result as { result: number }).result).toBe(30);
|
||||
expect((addCall!.output as { result: number }).result).toBe(7);
|
||||
expect((multiplyCall!.output as { result: number }).result).toBe(30);
|
||||
});
|
||||
|
||||
it('correctly merges results via the run() path', async () => {
|
||||
|
|
@ -126,15 +123,14 @@ describe('multi-tool-calls integration', () => {
|
|||
'What are the lengths of "hello" and "world"? Look up each one separately.',
|
||||
);
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const toolCallResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
const toolCallResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
const lengthCalls = toolCallResults.filter((tc) => tc.toolName === 'get_length');
|
||||
expect(lengthCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Each should have correct output
|
||||
for (const call of lengthCalls) {
|
||||
const output = call.result as { text: string; length: number };
|
||||
const output = call.output as { text: string; length: number };
|
||||
expect(output.length).toBe(output.text.length);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,95 +28,92 @@ describe('orphaned tool messages in memory', () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Seed memory with a conversation that has tool-call / tool-result pairs
|
||||
* surrounded by plain user/assistant exchanges.
|
||||
* Seed memory with a conversation that has settled tool-call blocks
|
||||
* (state: 'resolved') surrounded by plain user/assistant exchanges.
|
||||
*
|
||||
* Message layout (indices 0–7):
|
||||
* 0: user "How many widgets?"
|
||||
* 1: assistant text + tool-call(call_1)
|
||||
* 2: tool tool-result(call_1)
|
||||
* 3: assistant "There are 10 widgets"
|
||||
* 4: user "What about gadgets?"
|
||||
* 5: assistant text + tool-call(call_2)
|
||||
* 6: tool tool-result(call_2)
|
||||
* 7: assistant "There are 5 gadgets"
|
||||
* Message layout (indices 0–5):
|
||||
* 0: user "How many widgets?"
|
||||
* 1: assistant text + tool-call(call_1, state:'resolved', output:{count:10})
|
||||
* 2: assistant "There are 10 widgets"
|
||||
* 3: user "What about gadgets?"
|
||||
* 4: assistant text + tool-call(call_2, state:'resolved', output:{count:5})
|
||||
* 5: assistant "There are 5 gadgets"
|
||||
*/
|
||||
function buildSeedMessages(): AgentDbMessage[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
id: 'm1',
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date(now),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'How many widgets do we have?' }],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date(now + 1),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me look that up.' },
|
||||
{ type: 'tool-call', toolCallId: 'call_1', toolName: 'lookup', input: { id: 'widgets' } },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call_1',
|
||||
toolName: 'lookup',
|
||||
input: { id: 'widgets' },
|
||||
state: 'resolved',
|
||||
output: { count: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
createdAt: new Date(),
|
||||
role: 'tool',
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: 'call_1', toolName: 'lookup', result: { count: 10 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date(now + 2),
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'There are 10 widgets in stock.' }],
|
||||
},
|
||||
{
|
||||
id: 'm5',
|
||||
createdAt: new Date(),
|
||||
id: 'm4',
|
||||
createdAt: new Date(now + 3),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'What about gadgets?' }],
|
||||
},
|
||||
{
|
||||
id: 'm6',
|
||||
createdAt: new Date(),
|
||||
id: 'm5',
|
||||
createdAt: new Date(now + 4),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me check.' },
|
||||
{ type: 'tool-call', toolCallId: 'call_2', toolName: 'lookup', input: { id: 'gadgets' } },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call_2',
|
||||
toolName: 'lookup',
|
||||
input: { id: 'gadgets' },
|
||||
state: 'resolved',
|
||||
output: { count: 5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm7',
|
||||
createdAt: new Date(),
|
||||
role: 'tool',
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: 'call_2', toolName: 'lookup', result: { count: 5 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm8',
|
||||
createdAt: new Date(),
|
||||
id: 'm6',
|
||||
createdAt: new Date(now + 5),
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'There are 5 gadgets in stock.' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it('handles orphaned tool results when tool-call message is truncated from history', async () => {
|
||||
it('handles partial history window when earlier messages are truncated', async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const threadId = 'thread-orphan-result';
|
||||
|
||||
// Seed 8 messages into the thread
|
||||
// Seed 6 messages into the thread
|
||||
await memory.saveMessages({ threadId, messages: buildSeedMessages() });
|
||||
|
||||
// lastMessages=6 → loads messages 2–7
|
||||
// Message at index 2 is a tool-result for call_1, but the matching
|
||||
// assistant+tool-call (index 1) is truncated. This is an orphaned tool result.
|
||||
const mem = new Memory().storage(memory).lastMessages(6);
|
||||
// lastMessages=4 → loads messages 2–5
|
||||
// Each tool-call block carries its own result (state:'resolved'), so there
|
||||
// are no orphan issues regardless of window boundaries.
|
||||
const mem = new Memory().storage(memory).lastMessages(4);
|
||||
|
||||
const agent = new Agent('orphan-result-test')
|
||||
.model(getModel('anthropic'))
|
||||
|
|
@ -132,7 +129,7 @@ describe('orphaned tool messages in memory', () => {
|
|||
expect(result.finishReason).toBe('stop');
|
||||
});
|
||||
|
||||
it('handles orphaned tool calls when tool-result message is truncated from history', async () => {
|
||||
it('handles pending tool-call blocks (interrupted turn) in history', async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
|
|
@ -140,8 +137,9 @@ describe('orphaned tool messages in memory', () => {
|
|||
const now = Date.now();
|
||||
|
||||
// Store a conversation where the last saved message is an assistant
|
||||
// with a tool-call but the tool-result was never persisted (simulating
|
||||
// a partial save / interrupted turn).
|
||||
// with a pending tool-call block (simulating a partial save / interrupted turn).
|
||||
// stripOrphanedToolMessages will drop the pending block so the LLM receives
|
||||
// only the user message.
|
||||
const messages: AgentDbMessage[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
|
|
@ -160,6 +158,7 @@ describe('orphaned tool messages in memory', () => {
|
|||
toolCallId: 'call_orphan',
|
||||
toolName: 'lookup',
|
||||
input: { id: 'widgets' },
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ describe('external abort signal', () => {
|
|||
});
|
||||
|
||||
expect(result.finishReason).toBe('error');
|
||||
expect(result.getState().status).toBe('cancelled');
|
||||
expect(agent.getState().status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('cancels a stream() call via external AbortSignal', async () => {
|
||||
|
|
|
|||
|
|
@ -55,10 +55,8 @@ describe('provider tools integration', () => {
|
|||
const lastFinish = finishChunks[finishChunks.length - 1];
|
||||
expect(lastFinish?.type === 'finish' && lastFinish.finishReason).toBe('stop');
|
||||
|
||||
// Collect tool calls from message chunks
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const allMessages = messageChunks.map((c) => c.message);
|
||||
const toolCalls = findAllToolCalls(allMessages);
|
||||
// Tool calls now ride their own discrete `tool-call` chunks
|
||||
const toolCalls = chunksOfType(chunks, 'tool-call');
|
||||
const webSearchCall = toolCalls.find((tc) => tc.toolName.includes('web_search'));
|
||||
expect(webSearchCall).toBeDefined();
|
||||
|
||||
|
|
@ -104,9 +102,8 @@ describe('provider tools integration', () => {
|
|||
expect(suspended.runId).toBeTruthy();
|
||||
expect(suspended.toolCallId).toBeTruthy();
|
||||
|
||||
// The web search provider tool call should appear in the message history
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const toolCalls = findAllToolCalls(messageChunks.map((c) => c.message));
|
||||
// The web search provider tool call should appear as a discrete tool-call chunk
|
||||
const toolCalls = chunksOfType(chunks, 'tool-call');
|
||||
const webSearchCall = toolCalls.find((tc) => tc.toolName.includes('web_search'));
|
||||
expect(webSearchCall).toBeDefined();
|
||||
|
||||
|
|
@ -115,8 +112,8 @@ describe('provider tools integration', () => {
|
|||
'stream',
|
||||
{ approved: true },
|
||||
{
|
||||
runId: suspended.runId!,
|
||||
toolCallId: suspended.toolCallId!,
|
||||
runId: suspended.runId,
|
||||
toolCallId: suspended.toolCallId,
|
||||
},
|
||||
);
|
||||
const resumeChunks = await collectStreamChunks(resumeStream.stream);
|
||||
|
|
|
|||
|
|
@ -155,16 +155,8 @@ describe('state restore after suspension', () => {
|
|||
const errorChunks = resumedChunks.filter((c) => c.type === 'error');
|
||||
expect(errorChunks).toHaveLength(0);
|
||||
|
||||
// Stream must contain the tool result message
|
||||
const toolResultChunks = resumedChunks.filter(
|
||||
(c) =>
|
||||
c.type === 'message' &&
|
||||
'message' in c &&
|
||||
'content' in (c.message as object) &&
|
||||
(c.message as { content: Array<{ type: string }> }).content.some(
|
||||
(part) => part.type === 'tool-result',
|
||||
),
|
||||
);
|
||||
// Stream must contain a discrete tool-result chunk for the resumed call
|
||||
const toolResultChunks = chunksOfType(resumedChunks, 'tool-result');
|
||||
expect(toolResultChunks.length).toBeGreaterThan(0);
|
||||
|
||||
// Stream must end with a finish chunk (not error)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Agent, Tool } from '../../index';
|
|||
const describe = describeIf('anthropic');
|
||||
|
||||
describe('stream timing', () => {
|
||||
it('tool-call-delta chunks arrive incrementally (not all buffered)', async () => {
|
||||
it('tool-input-delta chunks arrive incrementally (not all buffered)', async () => {
|
||||
const agent = new Agent('timing-test')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions(
|
||||
|
|
@ -31,16 +31,21 @@ describe('stream timing', () => {
|
|||
|
||||
const reader = result.stream.getReader();
|
||||
|
||||
// Track timestamps of each reader.read() that returns a tool-call-delta
|
||||
// Track timestamps of each reader.read() that returns a tool-input-delta
|
||||
// for the set_code tool. We seed `setCodeToolCallId` from the matching
|
||||
// tool-input-start so subsequent deltas can be filtered by toolCallId.
|
||||
// This measures when the reader YIELDS each chunk, not when the agent enqueues it.
|
||||
const deltaReadTimes: number[] = [];
|
||||
const start = Date.now();
|
||||
let setCodeToolCallId: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = value;
|
||||
if (chunk.type === 'tool-call-delta' && (chunk as { name?: string }).name === 'set_code') {
|
||||
if (chunk.type === 'tool-input-start' && chunk.toolName === 'set_code') {
|
||||
setCodeToolCallId = chunk.toolCallId;
|
||||
} else if (chunk.type === 'tool-input-delta' && chunk.toolCallId === setCodeToolCallId) {
|
||||
deltaReadTimes.push(Date.now() - start);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import {
|
|||
collectStreamChunks,
|
||||
collectTextDeltas,
|
||||
describeIf,
|
||||
findAllToolResults,
|
||||
getModel,
|
||||
} from './helpers';
|
||||
import type { StreamChunk } from '../../index';
|
||||
import { Agent } from '../../index';
|
||||
|
||||
const describe = describeIf('anthropic');
|
||||
|
|
@ -33,10 +31,7 @@ describe('sub-agent (asTool) integration', () => {
|
|||
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
const text = collectTextDeltas(chunks);
|
||||
const messageChunks = chunksOfType(chunks, 'message') as Array<
|
||||
StreamChunk & { type: 'message' }
|
||||
>;
|
||||
const toolResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
const toolResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
// The orchestrator should have called the sub-agent tool
|
||||
expect(toolResults.length).toBeGreaterThan(0);
|
||||
|
|
@ -44,7 +39,7 @@ describe('sub-agent (asTool) integration', () => {
|
|||
expect(mathCall).toBeDefined();
|
||||
|
||||
// The output should contain the sub-agent's response
|
||||
expect(mathCall!.result).toBeDefined();
|
||||
expect(mathCall!.output).toBeDefined();
|
||||
|
||||
// The final text should reference 60
|
||||
expect(text).toBeTruthy();
|
||||
|
|
@ -80,10 +75,7 @@ describe('sub-agent (asTool) integration', () => {
|
|||
'Translate "hello" to French and then make it uppercase.',
|
||||
);
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
const messageChunks = chunksOfType(chunks, 'message') as Array<
|
||||
StreamChunk & { type: 'message' }
|
||||
>;
|
||||
const toolResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
const toolResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
// Should have called both tools
|
||||
expect(toolResults.length).toBeGreaterThanOrEqual(2);
|
||||
|
|
|
|||
|
|
@ -63,11 +63,12 @@ describe('toModelOutput integration', () => {
|
|||
expect(rawOutput.total).toBe(3);
|
||||
expect(rawOutput.records[0].data).toBe('x'.repeat(200));
|
||||
|
||||
// ContentToolResult in messages stores the transformed output (what the LLM saw)
|
||||
// Tool-call block in messages stores the transformed output (what the LLM saw)
|
||||
const toolResults = findAllToolResults(result.messages);
|
||||
const searchToolResult = toolResults.find((tr) => tr.toolName === 'search_db');
|
||||
expect(searchToolResult).toBeDefined();
|
||||
const modelOutput = searchToolResult!.result as { summary: string };
|
||||
expect(searchToolResult!.state).toBe('resolved');
|
||||
const modelOutput = (searchToolResult as unknown as { output: { summary: string } }).output;
|
||||
expect(modelOutput.summary).toContain('Found 3 records');
|
||||
expect(modelOutput.summary).toContain('Widget A');
|
||||
});
|
||||
|
|
@ -106,15 +107,14 @@ describe('toModelOutput integration', () => {
|
|||
const { stream } = await agent.stream('Get report RPT-001');
|
||||
const chunks = await collectStreamChunks(stream);
|
||||
|
||||
// The tool result messages in the stream contain the transformed output
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const toolResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
// The discrete tool-result chunks in the stream contain the transformed output
|
||||
const toolResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
const reportResult = toolResults.find((tr) => tr.toolName === 'fetch_report');
|
||||
expect(reportResult).toBeDefined();
|
||||
|
||||
// The model output (transformed) should have the truncated fields
|
||||
const modelOutput = reportResult!.result as { id: string; title: string; pageCount: number };
|
||||
const modelOutput = reportResult!.output as { id: string; title: string; pageCount: number };
|
||||
expect(modelOutput.id).toBe('RPT-001');
|
||||
expect(modelOutput.title).toBe('Q4 Sales Report');
|
||||
expect(modelOutput.pageCount).toBe(42);
|
||||
|
|
@ -140,11 +140,14 @@ describe('toModelOutput integration', () => {
|
|||
|
||||
const result = await agent.generate('Echo the message "hello world"');
|
||||
|
||||
// Without toModelOutput, tool result in messages should have the raw output
|
||||
// Without toModelOutput, tool-call block in messages has the raw output
|
||||
const toolResults = findAllToolResults(result.messages);
|
||||
const echoResult = toolResults.find((tr) => tr.toolName === 'echo');
|
||||
expect(echoResult).toBeDefined();
|
||||
expect((echoResult!.result as { echoed: string }).echoed).toBe('hello world');
|
||||
expect(echoResult!.state).toBe('resolved');
|
||||
expect((echoResult as unknown as { output: { echoed: string } }).output.echoed).toBe(
|
||||
'hello world',
|
||||
);
|
||||
|
||||
// And toolCalls should also have the same raw output
|
||||
expect(result.toolCalls).toBeDefined();
|
||||
|
|
@ -196,11 +199,14 @@ describe('toModelOutput integration', () => {
|
|||
expect(multiplyEntry).toBeDefined();
|
||||
expect((multiplyEntry!.output as { result: number }).result).toBe(56);
|
||||
|
||||
// Tool result in messages stores the transformed output for the LLM
|
||||
// Tool-call block in messages stores the transformed output for the LLM
|
||||
const toolResults = findAllToolResults(result.messages);
|
||||
const multiplyToolResult = toolResults.find((tr) => tr.toolName === 'multiply');
|
||||
expect(multiplyToolResult).toBeDefined();
|
||||
const modelOutput = multiplyToolResult!.result as { answer: number; note: string };
|
||||
expect(multiplyToolResult!.state).toBe('resolved');
|
||||
const modelOutput = (
|
||||
multiplyToolResult as unknown as { output: { answer: number; note: string } }
|
||||
).output;
|
||||
expect(modelOutput.answer).toBe(56);
|
||||
expect(modelOutput.note).toBe('multiplication complete');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Upsert contract: after a HITL suspend/resume cycle backed by SqliteMemory,
|
||||
* the thread must contain exactly ONE assistant message with the tool-call
|
||||
* block (no duplicate rows), and that block must have state: 'resolved'.
|
||||
*
|
||||
* The upsert matters because on resume the runtime calls saveToMemory with
|
||||
* turnDelta() which includes the now-resolved assistant message restored from
|
||||
* the checkpoint. Without upsert-by-id, a second row would be inserted for
|
||||
* the same message, breaking the thread ordering contract.
|
||||
*
|
||||
* Note: messages with state:'pending' are transient and are NOT written to
|
||||
* memory during suspension — they only live in the checkpoint. Memory only
|
||||
* receives the final settled state after resume completes.
|
||||
*/
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { describeIf, createSqliteMemory, getModel } from './helpers';
|
||||
import { Agent, filterLlmMessages, Memory, Tool } from '../../index';
|
||||
import type { AgentDbMessage } from '../../index';
|
||||
import type { ContentToolCall, Message } from '../../types/sdk/message';
|
||||
|
||||
const describe = describeIf('anthropic');
|
||||
|
||||
describe('tool-call upsert via suspend/resume (SqliteMemory)', () => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups.length = 0;
|
||||
});
|
||||
|
||||
function extractToolCallBlocks(messages: AgentDbMessage[]): ContentToolCall[] {
|
||||
return filterLlmMessages(messages).flatMap((m) =>
|
||||
m.content.filter((c): c is ContentToolCall => c.type === 'tool-call'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildInterruptibleAgent(memory: ReturnType<typeof createSqliteMemory>['memory']): Agent {
|
||||
const deleteTool = new Tool('delete_file')
|
||||
.description('Delete a file at the given path')
|
||||
.input(z.object({ path: z.string().describe('File path to delete') }))
|
||||
.output(z.object({ deleted: z.boolean(), path: z.string() }))
|
||||
.suspend(z.object({ message: z.string(), severity: z.string() }))
|
||||
.resume(z.object({ approved: z.boolean() }))
|
||||
.handler(async ({ path }, ctx) => {
|
||||
if (!ctx.resumeData) {
|
||||
return await ctx.suspend({ message: `Delete "${path}"?`, severity: 'destructive' });
|
||||
}
|
||||
if (!ctx.resumeData.approved) return { deleted: false, path };
|
||||
return { deleted: true, path };
|
||||
});
|
||||
|
||||
return new Agent('upsert-test-agent')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions(
|
||||
'You are a file manager. When asked to delete a file, use the delete_file tool. Be concise.',
|
||||
)
|
||||
.tool(deleteTool)
|
||||
.memory(new Memory().storage(memory))
|
||||
.checkpoint('memory');
|
||||
}
|
||||
|
||||
it('after resume, thread has exactly one resolved tool-call block (no duplicate rows)', async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const threadId = 'thread-upsert-resolved';
|
||||
const resourceId = 'res-1';
|
||||
const persistence = { threadId, resourceId };
|
||||
|
||||
const agent = buildInterruptibleAgent(memory);
|
||||
|
||||
// Turn 1: trigger the suspend — messages with pending tool-call are
|
||||
// stored in the checkpoint only, NOT in SqliteMemory yet.
|
||||
const suspendResult = await agent.generate('Please delete /tmp/foo.txt', {
|
||||
persistence,
|
||||
});
|
||||
|
||||
expect(suspendResult.finishReason).toBe('tool-calls');
|
||||
expect(suspendResult.pendingSuspend).toBeDefined();
|
||||
const { runId, toolCallId } = suspendResult.pendingSuspend![0];
|
||||
|
||||
// Before resume: no tool-call blocks in memory (pending stays in checkpoint)
|
||||
const msgsBefore = await memory.getMessages(threadId);
|
||||
const blocksBefore = extractToolCallBlocks(msgsBefore);
|
||||
expect(blocksBefore).toHaveLength(0);
|
||||
|
||||
// Turn 2: resume with approval — on completion saveToMemory is called and
|
||||
// the assistant message (now resolved) is written for the first time.
|
||||
const resumeResult = await agent.resume(
|
||||
'generate',
|
||||
{ approved: true },
|
||||
{
|
||||
runId,
|
||||
toolCallId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resumeResult.finishReason).toBe('stop');
|
||||
|
||||
// After resume: exactly one resolved tool-call block, no duplicate rows
|
||||
const msgsAfter = await memory.getMessages(threadId);
|
||||
const blocksAfter = extractToolCallBlocks(msgsAfter);
|
||||
|
||||
expect(blocksAfter).toHaveLength(1);
|
||||
expect(blocksAfter[0].state).toBe('resolved');
|
||||
expect(blocksAfter[0].toolCallId).toBe(toolCallId);
|
||||
expect((blocksAfter[0] as ContentToolCall & { state: 'resolved' }).output).toMatchObject({
|
||||
deleted: true,
|
||||
});
|
||||
|
||||
// No duplicate assistant messages with tool-call blocks
|
||||
const assistantMsgsWithToolCalls = filterLlmMessages(msgsAfter).filter(
|
||||
(m) => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(assistantMsgsWithToolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('after resume with denial, thread has exactly one resolved tool-call block', async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const threadId = 'thread-upsert-denied';
|
||||
const resourceId = 'res-2';
|
||||
const persistence = { threadId, resourceId };
|
||||
|
||||
const agent = buildInterruptibleAgent(memory);
|
||||
|
||||
const suspendResult = await agent.generate('Please delete /tmp/bar.txt', {
|
||||
persistence,
|
||||
});
|
||||
expect(suspendResult.finishReason).toBe('tool-calls');
|
||||
const { runId, toolCallId } = suspendResult.pendingSuspend![0];
|
||||
|
||||
// Before resume: no messages in memory
|
||||
const msgsBefore = await memory.getMessages(threadId);
|
||||
expect(extractToolCallBlocks(msgsBefore)).toHaveLength(0);
|
||||
|
||||
const resumeResult = await agent.resume(
|
||||
'generate',
|
||||
{ approved: false },
|
||||
{
|
||||
runId,
|
||||
toolCallId,
|
||||
},
|
||||
);
|
||||
expect(resumeResult.finishReason).toBe('stop');
|
||||
|
||||
const msgsAfter = await memory.getMessages(threadId);
|
||||
const blocksAfter = extractToolCallBlocks(msgsAfter);
|
||||
|
||||
// Tool ran and returned {deleted: false} — still resolved, not rejected
|
||||
expect(blocksAfter).toHaveLength(1);
|
||||
expect(blocksAfter[0].state).toBe('resolved');
|
||||
const output = (blocksAfter[0] as ContentToolCall & { state: 'resolved' }).output;
|
||||
expect(output).toMatchObject({ deleted: false });
|
||||
|
||||
// No duplicate rows
|
||||
const assistantMsgsWithToolCalls = filterLlmMessages(msgsAfter).filter(
|
||||
(m) => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(assistantMsgsWithToolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('if same thread is resumed twice (re-suspend then resume again), still no duplicate rows', async () => {
|
||||
const { memory, cleanup } = createSqliteMemory();
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const threadId = 'thread-upsert-double';
|
||||
const resourceId = 'res-3';
|
||||
const persistence = { threadId, resourceId };
|
||||
|
||||
// Use a tool that always re-suspends on first call and approves on second
|
||||
let callCount = 0;
|
||||
const confirmTool = new Tool('confirm')
|
||||
.description('Confirm an action')
|
||||
.input(z.object({ action: z.string() }))
|
||||
.output(z.object({ done: z.boolean() }))
|
||||
.suspend(z.object({ question: z.string() }))
|
||||
.resume(z.object({ yes: z.boolean() }))
|
||||
.handler(async ({ action }, ctx) => {
|
||||
callCount++;
|
||||
if (!ctx.resumeData) {
|
||||
return await ctx.suspend({ question: `Confirm: ${action}?` });
|
||||
}
|
||||
return { done: ctx.resumeData.yes };
|
||||
});
|
||||
|
||||
const agent = new Agent('double-upsert-agent')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions('Use confirm tool for every action. Be concise.')
|
||||
.tool(confirmTool)
|
||||
.memory(new Memory().storage(memory))
|
||||
.checkpoint('memory');
|
||||
|
||||
// Turn 1: suspend
|
||||
const r1 = await agent.generate('confirm action: foo', { persistence });
|
||||
expect(r1.finishReason).toBe('tool-calls');
|
||||
const { runId, toolCallId } = r1.pendingSuspend![0];
|
||||
|
||||
// No messages in memory yet
|
||||
expect(await memory.getMessages(threadId)).toHaveLength(0);
|
||||
|
||||
// Resume: completes
|
||||
const r2 = await agent.resume('generate', { yes: true }, { runId, toolCallId });
|
||||
expect(r2.finishReason).toBe('stop');
|
||||
|
||||
const finalMessages = await memory.getMessages(threadId);
|
||||
const toolCallBlocks = extractToolCallBlocks(finalMessages);
|
||||
|
||||
// Exactly one tool-call block, no duplicates
|
||||
expect(toolCallBlocks).toHaveLength(1);
|
||||
expect(toolCallBlocks[0].state).toBe('resolved');
|
||||
|
||||
// And the assistant message with the tool-call appears exactly once
|
||||
const assistantMsgsWithCalls = filterLlmMessages(finalMessages).filter(
|
||||
(m): m is Message => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(assistantMsgsWithCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
collectStreamChunks,
|
||||
chunksOfType,
|
||||
collectTextDeltas,
|
||||
findAllToolResults,
|
||||
createAgentWithAlwaysErrorTool,
|
||||
createAgentWithFlakyTool,
|
||||
} from './helpers';
|
||||
|
|
@ -55,20 +54,20 @@ describe('tool error handling integration', () => {
|
|||
expect(mentionsFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('error tool-result appears in the message list', async () => {
|
||||
it('error tool-result appears in the stream', async () => {
|
||||
const agent = createAgentWithAlwaysErrorTool('anthropic');
|
||||
|
||||
const { stream } = await agent.stream('Fetch the data for id "abc123".');
|
||||
const chunks = await collectStreamChunks(stream);
|
||||
|
||||
// There should be a tool-result message in the stream
|
||||
const messageChunks = chunksOfType(chunks, 'message');
|
||||
const toolResults = findAllToolResults(messageChunks.map((c) => c.message));
|
||||
// There should be a discrete tool-result chunk for the failed call
|
||||
const toolResults = chunksOfType(chunks, 'tool-result');
|
||||
|
||||
// The tool should have been called and produced a result (even if it errored)
|
||||
expect(toolResults.length).toBeGreaterThan(0);
|
||||
const brokenResult = toolResults.find((r) => r.toolName === 'broken_tool');
|
||||
expect(brokenResult).toBeDefined();
|
||||
expect(brokenResult!.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('LLM can self-correct by retrying a flaky tool', async () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
createAgentWithMixedTools,
|
||||
createAgentWithParallelInterruptibleCalls,
|
||||
} from './helpers';
|
||||
import { isLlmMessage, type StreamChunk } from '../../index';
|
||||
import type { StreamChunk } from '../../index';
|
||||
|
||||
const describe = describeIf('anthropic');
|
||||
|
||||
|
|
@ -36,13 +36,8 @@ describe('tool interrupt integration', () => {
|
|||
);
|
||||
|
||||
// No tool-result should appear (tool is suspended)
|
||||
const contentChunks = chunks.filter(
|
||||
(c) =>
|
||||
c.type === 'message' &&
|
||||
'content' in c &&
|
||||
(c.content as { type: string }).type === 'tool-result',
|
||||
);
|
||||
expect(contentChunks).toHaveLength(0);
|
||||
const toolResultChunks = chunksOfType(chunks, 'tool-result');
|
||||
expect(toolResultChunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resumes the stream after resume with approval', async () => {
|
||||
|
|
@ -58,19 +53,14 @@ describe('tool interrupt integration', () => {
|
|||
const resumedStream = await agent.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: suspended.runId!, toolCallId: suspended.toolCallId! },
|
||||
{ runId: suspended.runId, toolCallId: suspended.toolCallId },
|
||||
);
|
||||
|
||||
const resumedChunks = await collectStreamChunks(resumedStream.stream);
|
||||
const resumedTypes = resumedChunks.map((c) => c.type);
|
||||
|
||||
// After approval, tool-result should appear as content chunk
|
||||
const toolResultChunks = resumedChunks.filter(
|
||||
(c) =>
|
||||
c.type === 'message' &&
|
||||
isLlmMessage(c.message) &&
|
||||
c.message.content.some((c) => c.type === 'tool-result'),
|
||||
);
|
||||
// After approval, a discrete tool-result chunk should appear
|
||||
const toolResultChunks = chunksOfType(resumedChunks, 'tool-result');
|
||||
expect(toolResultChunks.length).toBeGreaterThan(0);
|
||||
|
||||
expect(resumedTypes).toContain('text-delta');
|
||||
|
|
@ -89,7 +79,7 @@ describe('tool interrupt integration', () => {
|
|||
const resumedStream = await agent.resume(
|
||||
'stream',
|
||||
{ approved: false },
|
||||
{ runId: suspended.runId!, toolCallId: suspended.toolCallId! },
|
||||
{ runId: suspended.runId, toolCallId: suspended.toolCallId },
|
||||
);
|
||||
|
||||
const resumedChunks = await collectStreamChunks(resumedStream.stream);
|
||||
|
|
@ -119,7 +109,7 @@ describe('tool interrupt integration', () => {
|
|||
const stream2 = await agent.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: suspended1.runId!, toolCallId: suspended1.toolCallId! },
|
||||
{ runId: suspended1.runId, toolCallId: suspended1.toolCallId },
|
||||
);
|
||||
|
||||
const chunks2 = await collectStreamChunks(stream2.stream);
|
||||
|
|
@ -136,7 +126,7 @@ describe('tool interrupt integration', () => {
|
|||
const stream3 = await agent.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId: suspended2.runId!, toolCallId: suspended2.toolCallId! },
|
||||
{ runId: suspended2.runId, toolCallId: suspended2.toolCallId },
|
||||
);
|
||||
|
||||
const chunks3 = await collectStreamChunks(stream3.stream);
|
||||
|
|
@ -162,13 +152,8 @@ describe('tool interrupt integration', () => {
|
|||
|
||||
const chunks = await collectStreamChunks(fullStream);
|
||||
|
||||
// list_files should auto-execute — its result should appear as content
|
||||
const toolResultChunks = chunks.filter(
|
||||
(c) =>
|
||||
c.type === 'message' &&
|
||||
isLlmMessage(c.message) &&
|
||||
c.message.content.some((c) => c.type === 'tool-result'),
|
||||
);
|
||||
// list_files should auto-execute — its result should appear as a discrete tool-result chunk
|
||||
const toolResultChunks = chunksOfType(chunks, 'tool-result');
|
||||
expect(toolResultChunks.length).toBeGreaterThan(0);
|
||||
|
||||
// delete_file should be suspended
|
||||
|
|
|
|||
|
|
@ -69,7 +69,10 @@ describe('workspace agent integration', () => {
|
|||
|
||||
const readResult = toolResults.find((tr) => tr.toolName === 'workspace_read_file');
|
||||
expect(readResult).toBeDefined();
|
||||
expect((readResult!.result as { content: string }).content).toContain('Hello from n8n!');
|
||||
expect(readResult!.state).toBe('resolved');
|
||||
expect((readResult as unknown as { output: { content: string } }).output.content).toContain(
|
||||
'Hello from n8n!',
|
||||
);
|
||||
|
||||
expect(memFs.getFileContent('/greeting.txt')).toBe('Hello from n8n!');
|
||||
});
|
||||
|
|
@ -103,7 +106,8 @@ describe('workspace agent integration', () => {
|
|||
const toolResults = findAllToolResults(result.messages);
|
||||
const execResult = toolResults.find((tr) => tr.toolName === 'workspace_execute_command');
|
||||
expect(execResult).toBeDefined();
|
||||
expect((execResult!.result as { success: boolean }).success).toBe(true);
|
||||
expect(execResult!.state).toBe('resolved');
|
||||
expect((execResult as unknown as { output: { success: boolean } }).output.success).toBe(true);
|
||||
});
|
||||
|
||||
it('agent uses workspace_mkdir and workspace_list_files together', async () => {
|
||||
|
|
@ -130,7 +134,8 @@ describe('workspace agent integration', () => {
|
|||
const toolResults = findAllToolResults(result.messages);
|
||||
const listResult = toolResults.find((tr) => tr.toolName === 'workspace_list_files');
|
||||
expect(listResult).toBeDefined();
|
||||
const entries = (listResult!.result as unknown as { entries: FileEntry[] }).entries;
|
||||
expect(listResult!.state).toBe('resolved');
|
||||
const entries = (listResult as unknown as { output: { entries: FileEntry[] } }).output.entries;
|
||||
const names = entries.map((e) => e.name);
|
||||
expect(names).toContain('index.ts');
|
||||
expect(names).toContain('README.md');
|
||||
|
|
@ -201,7 +206,8 @@ describe('workspace agent integration', () => {
|
|||
const toolResults = findAllToolResults(result.messages);
|
||||
const statResult = toolResults.find((tr) => tr.toolName === 'workspace_file_stat');
|
||||
expect(statResult).toBeDefined();
|
||||
const stat = statResult!.result as { type: string; size: number };
|
||||
expect(statResult!.state).toBe('resolved');
|
||||
const stat = (statResult as unknown as { output: { type: string; size: number } }).output;
|
||||
expect(stat.type).toBe('file');
|
||||
expect(stat.size).toBe(29);
|
||||
});
|
||||
|
|
@ -233,7 +239,10 @@ describe('workspace agent integration', () => {
|
|||
|
||||
const readResult = toolResults.find((tr) => tr.toolName === 'workspace_read_file');
|
||||
expect(readResult).toBeDefined();
|
||||
expect((readResult!.result as { content: string }).content).toContain('export default {}');
|
||||
expect(readResult!.state).toBe('resolved');
|
||||
expect((readResult as unknown as { output: { content: string } }).output.content).toContain(
|
||||
'export default {}',
|
||||
);
|
||||
|
||||
expect(memFs.getFileContent('/app/config.ts')).toBe('export default {}');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,12 +45,12 @@ describe('Zod validation errors surface to LLM and allow self-correction', () =>
|
|||
expect(result.finishReason).toBe('stop');
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// At least two tool-result messages: one error, one success
|
||||
// At least two tool-call messages: one rejected, one resolved
|
||||
const allMessages = filterLlmMessages(result.messages);
|
||||
const toolResultMessages = allMessages.filter((m) =>
|
||||
m.content.some((c) => c.type === 'tool-result'),
|
||||
const toolCallMessages = allMessages.filter((m) =>
|
||||
m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(toolResultMessages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(toolCallMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The final response should mention a user (age 25 or similar)
|
||||
const text = findLastTextContent(result.messages);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { AgentMessageList } from '../runtime/message-list';
|
||||
import { isLlmMessage } from '../sdk/message';
|
||||
import type { AgentDbMessage, AgentMessage, Message } from '../types/sdk/message';
|
||||
import type { AgentDbMessage, AgentMessage, ContentToolCall, Message } from '../types/sdk/message';
|
||||
|
||||
function makeUserMsg(text: string): AgentMessage {
|
||||
return { role: 'user', content: [{ type: 'text', text }] };
|
||||
|
|
@ -174,3 +174,118 @@ describe('AgentMessageList — deserialize', () => {
|
|||
expect(newMsg.createdAt.getTime()).toBeGreaterThan(futureTs.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setToolCallResult / setToolCallError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makePendingToolCallMsg(toolCallId: string): AgentMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId,
|
||||
toolName: 'my_tool',
|
||||
input: { x: 1 },
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentMessageList — setToolCallResult', () => {
|
||||
it('sets state and output on the matching tool-call block', () => {
|
||||
const list = new AgentMessageList();
|
||||
list.addResponse([makePendingToolCallMsg('id-1')]);
|
||||
|
||||
const host = list.setToolCallResult('id-1', { ok: true });
|
||||
expect(host).toBeDefined();
|
||||
|
||||
const block = (host as Message).content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(block.state).toBe('resolved');
|
||||
expect((block as ContentToolCall & { state: 'resolved' }).output).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('promotes a history-only message into responseDelta after setToolCallResult', () => {
|
||||
const list = new AgentMessageList();
|
||||
const histMsg: AgentDbMessage = {
|
||||
id: 'hist-1',
|
||||
createdAt: new Date('2024-01-01T00:00:01.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-hist',
|
||||
toolName: 'my_tool',
|
||||
input: {},
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
list.addHistory([histMsg]);
|
||||
|
||||
// Before: not in responseDelta (history only)
|
||||
expect(list.responseDelta()).toHaveLength(0);
|
||||
|
||||
list.setToolCallResult('tc-hist', { done: true });
|
||||
|
||||
// After: promoted to responseDelta
|
||||
const delta = list.responseDelta();
|
||||
expect(delta).toHaveLength(1);
|
||||
const block = (delta[0] as Message).content.find(
|
||||
(c) => c.type === 'tool-call',
|
||||
) as ContentToolCall;
|
||||
expect(block.state).toBe('resolved');
|
||||
});
|
||||
|
||||
it('is a no-op when toolCallId is unknown', () => {
|
||||
const list = new AgentMessageList();
|
||||
list.addResponse([makePendingToolCallMsg('id-1')]);
|
||||
|
||||
const result = list.setToolCallResult('unknown-id', { x: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
// List unchanged
|
||||
expect(list.responseDelta()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Set semantics make repeated calls idempotent (no duplicate messages)', () => {
|
||||
const list = new AgentMessageList();
|
||||
list.addResponse([makePendingToolCallMsg('id-1')]);
|
||||
|
||||
list.setToolCallResult('id-1', { ok: true });
|
||||
list.setToolCallResult('id-1', { ok: true });
|
||||
|
||||
expect(list.responseDelta()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentMessageList — setToolCallError', () => {
|
||||
it('stringifies errors and clears any prior output', () => {
|
||||
const list = new AgentMessageList();
|
||||
list.addResponse([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'id-1',
|
||||
toolName: 'my_tool',
|
||||
input: {},
|
||||
state: 'resolved',
|
||||
output: { prev: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const host = list.setToolCallError('id-1', new Error('boom'));
|
||||
expect(host).toBeDefined();
|
||||
|
||||
const block = (host as Message).content.find((c) => c.type === 'tool-call') as ContentToolCall;
|
||||
expect(block.state).toBe('rejected');
|
||||
expect((block as ContentToolCall & { state: 'rejected' }).error).toBe('Error: boom');
|
||||
// output should be gone
|
||||
expect((block as unknown as { output?: unknown }).output).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ type ProviderOpts = {
|
|||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
// All providers are mocked via jest.mock so require() inside the registry entries
|
||||
// returns these stubs instead of the real packages.
|
||||
jest.mock('@ai-sdk/anthropic', () => ({
|
||||
createAnthropic: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'anthropic',
|
||||
|
|
@ -33,6 +35,119 @@ jest.mock('@ai-sdk/openai', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/google', () => ({
|
||||
createGoogleGenerativeAI: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'google',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/xai', () => ({
|
||||
createXai: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'xai',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/groq', () => ({
|
||||
createGroq: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'groq',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/deepseek', () => ({
|
||||
createDeepSeek: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'deepseek',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/cohere', () => ({
|
||||
createCohere: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'cohere',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/mistral', () => ({
|
||||
createMistral: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'mistral',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/gateway', () => ({
|
||||
createGateway: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'vercel',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
baseURL: opts?.baseURL,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/azure', () => ({
|
||||
createAzure:
|
||||
(opts?: { apiKey?: string; resourceName?: string; apiVersion?: string; baseURL?: string }) =>
|
||||
(model: string) => ({
|
||||
provider: 'azure-openai',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
resourceName: opts?.resourceName,
|
||||
apiVersion: opts?.apiVersion,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@openrouter/ai-sdk-provider', () => ({
|
||||
createOpenRouter: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'openrouter',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
baseURL: opts?.baseURL,
|
||||
fetch: opts?.fetch,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/amazon-bedrock', () => ({
|
||||
createAmazonBedrock:
|
||||
(opts?: {
|
||||
region?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
sessionToken?: string;
|
||||
}) =>
|
||||
(model: string) => ({
|
||||
provider: 'aws-bedrock',
|
||||
modelId: model,
|
||||
region: opts?.region,
|
||||
accessKeyId: opts?.accessKeyId,
|
||||
secretAccessKey: opts?.secretAccessKey,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockProxyAgent = jest.fn();
|
||||
jest.mock('undici', () => ({
|
||||
ProxyAgent: mockProxyAgent,
|
||||
|
|
@ -58,15 +173,13 @@ describe('createModel', () => {
|
|||
expect(model.modelId).toBe('claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
it('should accept an object config with url', () => {
|
||||
it('should accept an object config with baseURL', () => {
|
||||
const model = createModel({
|
||||
id: 'openai/gpt-4o',
|
||||
apiKey: 'sk-test',
|
||||
url: 'https://custom.endpoint.com/v1',
|
||||
baseURL: 'https://custom.endpoint.com/v1',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('openai');
|
||||
expect(model.modelId).toBe('gpt-4o');
|
||||
expect(model.apiKey).toBe('sk-test');
|
||||
expect(model.baseURL).toBe('https://custom.endpoint.com/v1');
|
||||
});
|
||||
|
||||
|
|
@ -130,4 +243,113 @@ describe('createModel', () => {
|
|||
createModel('anthropic/claude-sonnet-4-5');
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith('http://https-proxy:8080');
|
||||
});
|
||||
|
||||
describe('standard providers', () => {
|
||||
it.each(['groq', 'deepseek', 'cohere', 'mistral', 'google', 'xai'])(
|
||||
'should create model for %s',
|
||||
(provider) => {
|
||||
const model = createModel({
|
||||
id: `${provider}/some-model`,
|
||||
apiKey: 'test-key',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe(provider);
|
||||
expect(model.modelId).toBe('some-model');
|
||||
expect(model.apiKey).toBe('test-key');
|
||||
},
|
||||
);
|
||||
|
||||
it('should create model for vercel gateway', () => {
|
||||
const model = createModel({
|
||||
id: 'vercel/gpt-4o',
|
||||
apiKey: 'vk-test',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('vercel');
|
||||
expect(model.modelId).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('should create model for openrouter', () => {
|
||||
const model = createModel({
|
||||
id: 'openrouter/openai/gpt-4o',
|
||||
apiKey: 'or-test',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('openrouter');
|
||||
expect(model.modelId).toBe('openai/gpt-4o');
|
||||
expect(model.apiKey).toBe('or-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('azure-openai', () => {
|
||||
it('should create model with resourceName', () => {
|
||||
const model = createModel({
|
||||
id: 'azure-openai/gpt-4o',
|
||||
apiKey: 'az-key',
|
||||
resourceName: 'my-resource',
|
||||
apiVersion: '2024-02-01',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('azure-openai');
|
||||
expect(model.modelId).toBe('gpt-4o');
|
||||
expect(model.apiKey).toBe('az-key');
|
||||
expect(model.resourceName).toBe('my-resource');
|
||||
expect(model.apiVersion).toBe('2024-02-01');
|
||||
});
|
||||
|
||||
it('should throw if resourceName is missing', () => {
|
||||
expect(() => createModel({ id: 'azure-openai/gpt-4o', apiKey: 'az-key' })).toThrow(
|
||||
/Invalid credentials for provider "azure-openai"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aws-bedrock', () => {
|
||||
it('should create model with AWS credentials', () => {
|
||||
const model = createModel({
|
||||
id: 'aws-bedrock/amazon.titan-text-lite-v1',
|
||||
region: 'us-east-1',
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('aws-bedrock');
|
||||
expect(model.modelId).toBe('amazon.titan-text-lite-v1');
|
||||
expect(model.region).toBe('us-east-1');
|
||||
expect(model.accessKeyId).toBe('AKIAIOSFODNN7EXAMPLE');
|
||||
});
|
||||
|
||||
it('should throw if region is missing', () => {
|
||||
expect(() =>
|
||||
createModel({
|
||||
id: 'aws-bedrock/amazon.titan-text-lite-v1',
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'secret',
|
||||
}),
|
||||
).toThrow(/Invalid credentials for provider "aws-bedrock"/);
|
||||
});
|
||||
|
||||
it('should throw if accessKeyId is missing', () => {
|
||||
expect(() =>
|
||||
createModel({
|
||||
id: 'aws-bedrock/amazon.titan-text-lite-v1',
|
||||
region: 'us-east-1',
|
||||
secretAccessKey: 'secret',
|
||||
}),
|
||||
).toThrow(/Invalid credentials for provider "aws-bedrock"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported provider', () => {
|
||||
it('should throw for ollama', () => {
|
||||
expect(() => createModel('ollama/llama3')).toThrow(/Unsupported provider: "ollama"/);
|
||||
});
|
||||
|
||||
it('should include supported providers in the error message', () => {
|
||||
expect(() => createModel('unknown-provider/some-model')).toThrow(/Supported providers:/);
|
||||
});
|
||||
|
||||
it('should throw when no model ID is provided', () => {
|
||||
expect(() => createModel('')).toThrow(/Model ID is required/);
|
||||
});
|
||||
|
||||
it('should throw when model has no slash', () => {
|
||||
expect(() => createModel('anthropic-only')).toThrow(/expected "provider\/model-name"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
146
packages/@n8n/agents/src/__tests__/parse.test.ts
Normal file
146
packages/@n8n/agents/src/__tests__/parse.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { parseWithSchema } from '../utils/parse';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseWithSchema — Zod schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseWithSchema — Zod schemas', () => {
|
||||
it('returns success with parsed data for valid input', async () => {
|
||||
const schema = z.object({ name: z.string(), age: z.number() });
|
||||
const result = await parseWithSchema(schema, { name: 'Alice', age: 30 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).toEqual({ name: 'Alice', age: 30 });
|
||||
});
|
||||
|
||||
it('coerces and transforms values as defined in the schema', async () => {
|
||||
const schema = z.object({ id: z.string().transform((s) => s.toUpperCase()) });
|
||||
const result = await parseWithSchema(schema, { id: 'abc' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect((result.data as { id: string }).id).toBe('ABC');
|
||||
});
|
||||
|
||||
it('returns failure with an error message for wrong type', async () => {
|
||||
const schema = z.object({ count: z.number() });
|
||||
const result = await parseWithSchema(schema, { count: 'not-a-number' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns failure when a required field is missing', async () => {
|
||||
const schema = z.object({ name: z.string(), age: z.number() });
|
||||
const result = await parseWithSchema(schema, { name: 'Alice' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('returns failure when a value violates a refinement', async () => {
|
||||
const schema = z.object({ age: z.number().min(18, 'must be at least 18') });
|
||||
const result = await parseWithSchema(schema, { age: 5 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.error).toContain('must be at least 18');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseWithSchema — JSON Schema (AJV)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseWithSchema — JSON Schema', () => {
|
||||
it('returns success with the original data for valid input', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: { name: { type: 'string' }, age: { type: 'integer' } },
|
||||
required: ['name', 'age'],
|
||||
} as JSONSchema7;
|
||||
const result = await parseWithSchema(schema, { name: 'Bob', age: 25 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).toEqual({ name: 'Bob', age: 25 });
|
||||
});
|
||||
|
||||
it('returns failure when a property has the wrong type', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id'],
|
||||
} as JSONSchema7;
|
||||
const result = await parseWithSchema(schema, { id: 42 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns failure when a required property is missing', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' },
|
||||
},
|
||||
required: ['name', 'age'],
|
||||
} as JSONSchema7;
|
||||
const result = await parseWithSchema(schema, { name: 'Alice' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns failure when a numeric constraint is violated', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: { age: { type: 'integer', minimum: 18, maximum: 99 } },
|
||||
required: ['age'],
|
||||
} as JSONSchema7;
|
||||
|
||||
const tooLow = await parseWithSchema(schema, { age: 5 });
|
||||
expect(tooLow.success).toBe(false);
|
||||
|
||||
const tooHigh = await parseWithSchema(schema, { age: 150 });
|
||||
expect(tooHigh.success).toBe(false);
|
||||
|
||||
const valid = await parseWithSchema(schema, { age: 30 });
|
||||
expect(valid.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns failure for an enum constraint violation', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: { status: { type: 'string', enum: ['active', 'inactive'] } },
|
||||
required: ['status'],
|
||||
} as JSONSchema7;
|
||||
|
||||
const invalid = await parseWithSchema(schema, { status: 'pending' });
|
||||
expect(invalid.success).toBe(false);
|
||||
|
||||
const valid = await parseWithSchema(schema, { status: 'active' });
|
||||
expect(valid.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates nested object properties', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
address: {
|
||||
type: 'object',
|
||||
properties: { zip: { type: 'string' } },
|
||||
required: ['zip'],
|
||||
},
|
||||
},
|
||||
required: ['address'],
|
||||
} as JSONSchema7;
|
||||
|
||||
const valid = await parseWithSchema(schema, { address: { zip: '10001' } });
|
||||
expect(valid.success).toBe(true);
|
||||
|
||||
const invalid = await parseWithSchema(schema, { address: { zip: 12345 } });
|
||||
expect(invalid.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -578,7 +578,7 @@ describe('SqliteMemory — queryEmbeddings', () => {
|
|||
describe('SqliteMemory — namespace', () => {
|
||||
it('rejects invalid namespace characters', () => {
|
||||
expect(() => new SqliteMemory({ url: 'file::memory:', namespace: 'bad-ns!' })).toThrow(
|
||||
/Invalid namespace/,
|
||||
/invalid_string/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,52 +2,38 @@ import { stripOrphanedToolMessages } from '../runtime/strip-orphaned-tool-messag
|
|||
import type { AgentMessage, Message } from '../types/sdk/message';
|
||||
|
||||
describe('stripOrphanedToolMessages', () => {
|
||||
it('returns messages unchanged when all tool pairs are complete', () => {
|
||||
it('returns messages unchanged when all tool-calls are settled', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Looking up...' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'lookup', input: {} },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'lookup',
|
||||
input: {},
|
||||
state: 'resolved',
|
||||
output: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'lookup', result: 42 }],
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'Done.' }] },
|
||||
];
|
||||
|
||||
const result = stripOrphanedToolMessages(messages);
|
||||
expect(result).toBe(messages);
|
||||
expect(result).toEqual(messages);
|
||||
});
|
||||
|
||||
it('strips orphaned tool-result when matching tool-call is missing', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'lookup', result: 42 }],
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'There are 42.' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Thanks' }] },
|
||||
];
|
||||
|
||||
const result = stripOrphanedToolMessages(messages) as Message[];
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].role).toBe('assistant');
|
||||
expect(result[1].role).toBe('user');
|
||||
});
|
||||
|
||||
it('strips orphaned tool-call when matching tool-result is missing', () => {
|
||||
it('drops pending tool-call blocks while preserving sibling content', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Check it' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Checking...' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'lookup', input: {} },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'lookup', input: {}, state: 'pending' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -61,12 +47,14 @@ describe('stripOrphanedToolMessages', () => {
|
|||
expect(assistantMsg.content[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('drops assistant message entirely if it only contained an orphaned tool-call', () => {
|
||||
it('drops empty messages after pending strip', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Do it' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'action', input: {} }],
|
||||
content: [
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'action', input: {}, state: 'pending' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -76,44 +64,45 @@ describe('stripOrphanedToolMessages', () => {
|
|||
expect(result[0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('handles mixed scenario: one complete pair and one orphaned result', () => {
|
||||
it('mixed scenario — only pending blocks are removed', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: 'orphan', toolName: 'lookup', result: 'stale' },
|
||||
],
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'Old result' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'New question' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Looking up...' },
|
||||
{ type: 'tool-call', toolCallId: 'c2', toolName: 'lookup', input: {} },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'lookup',
|
||||
input: {},
|
||||
state: 'resolved',
|
||||
output: 99,
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c2',
|
||||
toolName: 'delete',
|
||||
input: {},
|
||||
state: 'pending',
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c3',
|
||||
toolName: 'create',
|
||||
input: {},
|
||||
state: 'rejected',
|
||||
error: 'boom',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'c2', toolName: 'lookup', result: 99 }],
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: '99 items' }] },
|
||||
];
|
||||
|
||||
const result = stripOrphanedToolMessages(messages) as Message[];
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].role).toBe('assistant');
|
||||
expect(result[0].content[0]).toEqual(
|
||||
expect.objectContaining({ type: 'text', text: 'Old result' }),
|
||||
);
|
||||
|
||||
const toolCallMsg = result.find(
|
||||
(m) => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
|
||||
);
|
||||
expect(toolCallMsg).toBeDefined();
|
||||
const toolResultMsg = result.find((m) => m.role === 'tool');
|
||||
expect(toolResultMsg).toBeDefined();
|
||||
expect(result).toHaveLength(1);
|
||||
const blocks = result[0].content;
|
||||
// c2 (pending) should be removed; c1 (resolved) and c3 (rejected) stay
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks.map((b) => (b as { toolCallId: string }).toolCallId)).toEqual(['c1', 'c3']);
|
||||
});
|
||||
|
||||
it('preserves custom (non-LLM) messages', () => {
|
||||
|
|
@ -127,8 +116,16 @@ describe('stripOrphanedToolMessages', () => {
|
|||
const messages: AgentMessage[] = [
|
||||
customMsg,
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{ type: 'tool-result', toolCallId: 'orphan', toolName: 'x', result: null }],
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'x',
|
||||
input: {},
|
||||
state: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -137,14 +134,4 @@ describe('stripOrphanedToolMessages', () => {
|
|||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(customMsg);
|
||||
});
|
||||
|
||||
it('returns same array reference when no orphans exist (no-op fast path)', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'Hello!' }] },
|
||||
];
|
||||
|
||||
const result = stripOrphanedToolMessages(messages);
|
||||
expect(result).toBe(messages);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,39 +120,4 @@ describe('generateTitleFromMessage', () => {
|
|||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.messages[0].content).toBe('Custom system prompt');
|
||||
});
|
||||
|
||||
it('wraps the user message in a title-generation instruction so the model does not answer it', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert' });
|
||||
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow');
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.messages[1].role).toBe('user');
|
||||
expect(call.messages[1].content).toContain('Generate a title');
|
||||
expect(call.messages[1].content).toContain('<message>');
|
||||
expect(call.messages[1].content).toContain('Build a daily Berlin rain alert workflow');
|
||||
expect(call.messages[1].content).toContain('</message>');
|
||||
});
|
||||
|
||||
it('drops a streamed code fence and everything after it', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: 'Here\'s your chat workflow with the requested configuration:\n\n```json\n{\n "nodes": []\n}\n```',
|
||||
});
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'build me a chat workflow with openai',
|
||||
);
|
||||
expect(result).toBe("Here's your chat workflow with the requested configuration");
|
||||
expect(result).not.toContain('```');
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('collapses embedded newlines and stray backticks into a single-line title', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: 'Scryfall\nrandom `card` workflow',
|
||||
});
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'build a workflow that queries Scryfall for a random card',
|
||||
);
|
||||
expect(result).toBe('Scryfall random card workflow');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,6 +123,37 @@ describe('Tool builder — without approval', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool builder — .systemInstruction()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool builder — .systemInstruction()', () => {
|
||||
it('build() carries the systemInstruction onto the BuiltTool', () => {
|
||||
const tool = new Tool('fetch')
|
||||
.description('Fetch data')
|
||||
.systemInstruction('Always fetch with the cache disabled.')
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ id }) => {
|
||||
return await Promise.resolve({ data: id });
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(tool.systemInstruction).toBe('Always fetch with the cache disabled.');
|
||||
});
|
||||
|
||||
it('build() leaves systemInstruction undefined when not set', () => {
|
||||
const tool = new Tool('fetch')
|
||||
.description('Fetch data')
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ id }) => {
|
||||
return await Promise.resolve({ data: id });
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(tool.systemInstruction).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapToolForApproval — requireApproval: true
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
import type prettier from 'prettier';
|
||||
|
||||
import type {
|
||||
AgentSchema,
|
||||
EvalSchema,
|
||||
GuardrailSchema,
|
||||
MemorySchema,
|
||||
ToolSchema,
|
||||
} from '../types/sdk/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeTemplateLiteral(str: string): string {
|
||||
return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
}
|
||||
|
||||
function escapeSingleQuote(str: string): string {
|
||||
return JSON.stringify(str).slice(1, -1).replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
let prettierInstance: typeof prettier | undefined;
|
||||
|
||||
/**
|
||||
* Format TypeScript source code using Prettier.
|
||||
* Loaded lazily to avoid startup cost when not generating code.
|
||||
*/
|
||||
async function formatCode(code: string): Promise<string> {
|
||||
prettierInstance ??= await import('prettier');
|
||||
return await prettierInstance.format(code, {
|
||||
parser: 'typescript',
|
||||
singleQuote: true,
|
||||
useTabs: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile-time exhaustive check. If a new property is added to AgentSchema
|
||||
* but not handled in generateAgentCode(), TypeScript will report an error
|
||||
* here because the destructured rest object won't be empty.
|
||||
*/
|
||||
function assertAllHandled(_: Record<string, never>): void {
|
||||
// intentionally empty — this is a compile-time-only check
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section builders — each returns `.method(...)` chain fragments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function modelParts(model: AgentSchema['model']): string[] {
|
||||
if (model.provider && model.name) {
|
||||
return [`.model('${escapeSingleQuote(model.provider)}', '${escapeSingleQuote(model.name)}')`];
|
||||
}
|
||||
if (model.name) {
|
||||
return [`.model('${escapeSingleQuote(model.name)}')`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toolPart(tool: ToolSchema): { part: string; usesWorkflowTool: boolean } {
|
||||
if (!tool.editable) {
|
||||
return {
|
||||
part: `.tool(new WorkflowTool('${escapeSingleQuote(tool.name)}'))`,
|
||||
usesWorkflowTool: true,
|
||||
};
|
||||
}
|
||||
const parts = [`new Tool('${escapeSingleQuote(tool.name)}')`];
|
||||
parts.push(`.description('${escapeSingleQuote(tool.description)}')`);
|
||||
if (tool.inputSchemaSource) parts.push(`.input(${tool.inputSchemaSource})`);
|
||||
if (tool.outputSchemaSource) parts.push(`.output(${tool.outputSchemaSource})`);
|
||||
if (tool.suspendSchemaSource) parts.push(`.suspend(${tool.suspendSchemaSource})`);
|
||||
if (tool.resumeSchemaSource) parts.push(`.resume(${tool.resumeSchemaSource})`);
|
||||
if (tool.handlerSource) parts.push(`.handler(${tool.handlerSource})`);
|
||||
if (tool.toMessageSource) parts.push(`.toMessage(${tool.toMessageSource})`);
|
||||
if (tool.requireApproval) parts.push('.requireApproval()');
|
||||
if (tool.needsApprovalFnSource) parts.push(`.needsApprovalFn(${tool.needsApprovalFnSource})`);
|
||||
return { part: `.tool(${parts.join('')})`, usesWorkflowTool: false };
|
||||
}
|
||||
|
||||
function evalPart(ev: EvalSchema): string {
|
||||
const parts = [`new Eval('${escapeSingleQuote(ev.name)}')`];
|
||||
if (ev.description) parts.push(`.description('${escapeSingleQuote(ev.description)}')`);
|
||||
if (ev.modelId) parts.push(`.model('${escapeSingleQuote(ev.modelId)}')`);
|
||||
if (ev.credentialName) parts.push(`.credential('${escapeSingleQuote(ev.credentialName)}')`);
|
||||
if (ev.handlerSource) {
|
||||
parts.push(ev.type === 'check' ? `.check(${ev.handlerSource})` : `.judge(${ev.handlerSource})`);
|
||||
}
|
||||
return `.eval(${parts.join('')})`;
|
||||
}
|
||||
|
||||
function guardrailPart(g: GuardrailSchema): string {
|
||||
const method = g.position === 'input' ? 'inputGuardrail' : 'outputGuardrail';
|
||||
return `.${method}(${g.source})`;
|
||||
}
|
||||
|
||||
function memoryPart(memory: MemorySchema): string {
|
||||
if (memory.source) {
|
||||
return `.memory(${memory.source})`;
|
||||
}
|
||||
return `.memory(new Memory().lastMessages(${memory.lastMessages ?? 10}))`;
|
||||
}
|
||||
|
||||
function thinkingPart(thinking: NonNullable<AgentSchema['config']['thinking']>): string {
|
||||
const props: string[] = [];
|
||||
if (thinking.budgetTokens !== undefined) props.push(`budgetTokens: ${thinking.budgetTokens}`);
|
||||
if (thinking.reasoningEffort) props.push(`reasoningEffort: '${thinking.reasoningEffort}'`);
|
||||
if (props.length > 0) {
|
||||
return `.thinking('${thinking.provider}', { ${props.join(', ')} })`;
|
||||
}
|
||||
return `.thinking('${thinking.provider}')`;
|
||||
}
|
||||
|
||||
function buildImports(schema: AgentSchema, needsWorkflowTool: boolean): string {
|
||||
const agentImports = new Set<string>(['Agent']);
|
||||
if (schema.tools.some((t) => t.editable)) agentImports.add('Tool');
|
||||
if (needsWorkflowTool) agentImports.add('WorkflowTool');
|
||||
if (schema.memory) agentImports.add('Memory');
|
||||
if (schema.mcp && schema.mcp.length > 0) agentImports.add('McpClient');
|
||||
if (schema.evaluations.length > 0) agentImports.add('Eval');
|
||||
|
||||
const toolsNeedZod = schema.tools.some(
|
||||
(t) =>
|
||||
(t.inputSchemaSource?.includes('z.') ?? false) ||
|
||||
(t.outputSchemaSource?.includes('z.') ?? false),
|
||||
);
|
||||
const structuredOutputNeedsZod =
|
||||
schema.config.structuredOutput.schemaSource?.includes('z.') ?? false;
|
||||
|
||||
let imports = `import { ${Array.from(agentImports).sort().join(', ')} } from '@n8n/agents';`;
|
||||
if (toolsNeedZod || structuredOutputNeedsZod) imports += "\nimport { z } from 'zod';";
|
||||
return imports;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateAgentCode(schema: AgentSchema, agentName: string): Promise<string> {
|
||||
// Destructure every top-level property. If a new property is added to
|
||||
// AgentSchema, TypeScript will error on assertAllHandled below until
|
||||
// you handle it here AND add it to the destructure.
|
||||
const {
|
||||
model,
|
||||
credential,
|
||||
instructions,
|
||||
description: _description, // entity-level, not in code
|
||||
tools,
|
||||
providerTools,
|
||||
memory,
|
||||
evaluations,
|
||||
guardrails,
|
||||
mcp,
|
||||
telemetry,
|
||||
checkpoint,
|
||||
config,
|
||||
...rest
|
||||
} = schema;
|
||||
|
||||
// If this errors, you added a property to AgentSchema but didn't
|
||||
// destructure it above. Add it to the destructure and handle it below.
|
||||
assertAllHandled(rest);
|
||||
|
||||
const { thinking, toolCallConcurrency, requireToolApproval, structuredOutput, ...configRest } =
|
||||
config;
|
||||
assertAllHandled(configRest);
|
||||
|
||||
// No manual indentation — Prettier formats at the end.
|
||||
const parts: string[] = [];
|
||||
let needsWorkflowTool = false;
|
||||
|
||||
parts.push(`export default new Agent('${escapeSingleQuote(agentName)}')`);
|
||||
parts.push(...modelParts(model));
|
||||
|
||||
if (credential) parts.push(`.credential('${escapeSingleQuote(credential)}')`);
|
||||
if (instructions) parts.push(`.instructions(\`${escapeTemplateLiteral(instructions)}\`)`);
|
||||
|
||||
for (const tool of tools) {
|
||||
const { part, usesWorkflowTool } = toolPart(tool);
|
||||
if (usesWorkflowTool) needsWorkflowTool = true;
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
for (const pt of providerTools) {
|
||||
parts.push(`.providerTool(${pt.source})`);
|
||||
}
|
||||
|
||||
if (memory) parts.push(memoryPart(memory));
|
||||
|
||||
for (const ev of evaluations) {
|
||||
parts.push(evalPart(ev));
|
||||
}
|
||||
|
||||
for (const g of guardrails) {
|
||||
parts.push(guardrailPart(g));
|
||||
}
|
||||
|
||||
if (mcp && mcp.length > 0) {
|
||||
const configs = mcp.map((s) => s.configSource).join(', ');
|
||||
parts.push(`.mcp(new McpClient([${configs}]))`);
|
||||
}
|
||||
|
||||
if (telemetry) parts.push(`.telemetry(${telemetry.source})`);
|
||||
if (checkpoint) parts.push(`.checkpoint('${escapeSingleQuote(checkpoint)}')`);
|
||||
if (thinking) parts.push(thinkingPart(thinking));
|
||||
if (toolCallConcurrency) parts.push(`.toolCallConcurrency(${toolCallConcurrency})`);
|
||||
if (requireToolApproval) parts.push('.requireToolApproval()');
|
||||
if (structuredOutput.enabled && structuredOutput.schemaSource) {
|
||||
parts.push(`.structuredOutput(${structuredOutput.schemaSource})`);
|
||||
}
|
||||
|
||||
const imports = buildImports(schema, needsWorkflowTool);
|
||||
const raw = `${imports}\n\n${parts.join('')};\n`;
|
||||
return await formatCode(raw);
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ export type {
|
|||
SerializableAgentState,
|
||||
AgentRunState,
|
||||
MemoryConfig,
|
||||
MemoryDescriptor,
|
||||
TitleGenerationConfig,
|
||||
Thread,
|
||||
SemanticRecallConfig,
|
||||
|
|
@ -44,7 +45,7 @@ export type { ProviderOptions } from '@ai-sdk/provider-utils';
|
|||
export { AgentEvent } from './types';
|
||||
export type { AgentEventData, AgentEventHandler } from './types';
|
||||
|
||||
export { Tool } from './sdk/tool';
|
||||
export { Tool, wrapToolForApproval } from './sdk/tool';
|
||||
export { Memory } from './sdk/memory';
|
||||
export { Guardrail } from './sdk/guardrail';
|
||||
export { Eval } from './sdk/eval';
|
||||
|
|
@ -55,6 +56,7 @@ export { Telemetry } from './sdk/telemetry';
|
|||
export { LangSmithTelemetry } from './integrations/langsmith';
|
||||
export type { LangSmithTelemetryConfig } from './integrations/langsmith';
|
||||
export { Agent } from './sdk/agent';
|
||||
export type { AgentSnapshot } from './sdk/agent';
|
||||
export type {
|
||||
AgentBuilder,
|
||||
CredentialProvider,
|
||||
|
|
@ -73,7 +75,6 @@ export type {
|
|||
ContentReasoning,
|
||||
ContentText,
|
||||
ContentToolCall,
|
||||
ContentToolResult,
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageRole,
|
||||
|
|
@ -82,19 +83,10 @@ export type {
|
|||
AgentDbMessage,
|
||||
} from './types/sdk/message';
|
||||
export type { HandlerExecutor } from './types/sdk/handler-executor';
|
||||
export type {
|
||||
AgentSchema,
|
||||
ToolSchema,
|
||||
MemorySchema,
|
||||
EvalSchema,
|
||||
ThinkingSchema,
|
||||
ProviderToolSchema,
|
||||
GuardrailSchema,
|
||||
McpServerSchema,
|
||||
TelemetrySchema,
|
||||
} from './types/sdk/schema';
|
||||
export { generateAgentCode } from './codegen/generate-agent-code';
|
||||
export { filterLlmMessages, isLlmMessage } from './sdk/message';
|
||||
export {
|
||||
filterLlmMessages,
|
||||
isLlmMessage,
|
||||
} from './sdk/message';
|
||||
export { fetchProviderCatalog } from './sdk/catalog';
|
||||
export { providerCapabilities } from './sdk/provider-capabilities';
|
||||
export type { ProviderCapability } from './sdk/provider-capabilities';
|
||||
|
|
@ -105,14 +97,19 @@ export type {
|
|||
ModelCost,
|
||||
ModelLimits,
|
||||
} from './sdk/catalog';
|
||||
export { SqliteMemory } from './storage/sqlite-memory';
|
||||
export { SqliteMemory, SqliteMemoryConfigSchema } from './storage/sqlite-memory';
|
||||
export {
|
||||
UPDATE_WORKING_MEMORY_TOOL_NAME,
|
||||
WORKING_MEMORY_DEFAULT_INSTRUCTION,
|
||||
} from './runtime/working-memory';
|
||||
export type { SqliteMemoryConfig } from './storage/sqlite-memory';
|
||||
export { PostgresMemory } from './storage/postgres-memory';
|
||||
export type { PostgresMemoryConfig } from './storage/postgres-memory';
|
||||
export type {
|
||||
PostgresConnectionOptions,
|
||||
PostgresConstructorOptions,
|
||||
} from './storage/postgres-memory';
|
||||
export { BaseMemory } from './storage/base-memory';
|
||||
export type { ToolDescriptor } from './types/sdk/tool-descriptor';
|
||||
|
||||
export { createModel } from './runtime/model-factory';
|
||||
export { generateTitleFromMessage } from './runtime/title-generation';
|
||||
|
|
@ -151,3 +148,7 @@ export type {
|
|||
SpawnProcessOptions,
|
||||
ProcessInfo,
|
||||
} from './workspace';
|
||||
|
||||
export type { JSONObject, JSONArray, JSONValue } from './types/utils/json';
|
||||
|
||||
export { isZodSchema, zodToJsonSchema } from './utils/zod';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -32,6 +32,10 @@ export class AgentEventBus {
|
|||
set.add(handler);
|
||||
}
|
||||
|
||||
off(event: AgentEvent, handler: AgentEventHandler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
emit(data: AgentEventData): void {
|
||||
const set = this.handlers.get(data.type);
|
||||
if (!set) return;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { BuiltMemory, Thread } from '../types';
|
||||
import type { BuiltMemory, MemoryDescriptor, Thread } from '../types';
|
||||
import type { AgentDbMessage } from '../types/sdk/message';
|
||||
|
||||
interface StoredMessage {
|
||||
|
|
@ -78,6 +78,8 @@ export class InMemoryMemory implements BuiltMemory {
|
|||
/**
|
||||
* Save messages to the thread established by the most recent `saveThread` call.
|
||||
* Always call `saveThread` before `saveMessages` to set the thread context.
|
||||
* Upserts by message id — if a message with the same id already exists, it is
|
||||
* replaced in place (preserving insertion order). New messages are appended.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async saveMessages(args: {
|
||||
|
|
@ -86,8 +88,16 @@ export class InMemoryMemory implements BuiltMemory {
|
|||
messages: AgentDbMessage[];
|
||||
}): Promise<void> {
|
||||
const existing = this.messagesByThread.get(args.threadId) ?? [];
|
||||
const byId = new Map(existing.map((s, i) => [s.message.id, i]));
|
||||
for (const msg of args.messages) {
|
||||
existing.push({ message: msg, createdAt: msg.createdAt });
|
||||
const entry: StoredMessage = { message: msg, createdAt: msg.createdAt };
|
||||
const idx = byId.get(msg.id);
|
||||
if (idx !== undefined) {
|
||||
existing[idx] = entry;
|
||||
} else {
|
||||
byId.set(msg.id, existing.length);
|
||||
existing.push(entry);
|
||||
}
|
||||
}
|
||||
this.messagesByThread.set(args.threadId, existing);
|
||||
}
|
||||
|
|
@ -102,6 +112,10 @@ export class InMemoryMemory implements BuiltMemory {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe(): MemoryDescriptor {
|
||||
return { name: 'memory', constructorName: this.constructor.name, connectionParams: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils';
|
|||
import type { ModelMessage } from 'ai';
|
||||
|
||||
import { toAiMessages } from './messages';
|
||||
import { stringifyError } from './runtime-helpers';
|
||||
import { stripOrphanedToolMessages } from './strip-orphaned-tool-messages';
|
||||
import { buildWorkingMemoryInstruction } from './working-memory';
|
||||
import { filterLlmMessages, getCreatedAt } from '../sdk/message';
|
||||
import type { SerializedMessageList } from '../types/runtime/message-list';
|
||||
import type { AgentDbMessage, AgentMessage } from '../types/sdk/message';
|
||||
import type { AgentDbMessage, AgentMessage, ContentToolCall } from '../types/sdk/message';
|
||||
import type { JSONValue } from '../types/utils/json';
|
||||
|
||||
export type { SerializedMessageList };
|
||||
|
||||
|
|
@ -134,6 +136,76 @@ export class AgentMessageList {
|
|||
this.sortAllByCreatedAt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the assistant message hosting the given toolCallId and mark the
|
||||
* block as resolved with the supplied output.
|
||||
*
|
||||
* Returns the mutated host message, or `undefined` if the toolCallId is
|
||||
* not found (internal invariant violation — caller should log/throw).
|
||||
*/
|
||||
setToolCallResult(toolCallId: string, output: JSONValue): AgentDbMessage | undefined {
|
||||
const host = this.findToolCallHost(toolCallId);
|
||||
if (!host) return undefined;
|
||||
|
||||
const block = this.findToolCallBlock(host, toolCallId);
|
||||
if (!block) return undefined;
|
||||
|
||||
const mutableBlock = block;
|
||||
mutableBlock.state = 'resolved';
|
||||
(mutableBlock as Extract<ContentToolCall, { state: 'resolved' }>).output = output;
|
||||
if ('error' in mutableBlock) {
|
||||
delete (mutableBlock as { error: unknown }).error;
|
||||
}
|
||||
|
||||
this.responseSet.add(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the assistant message hosting the given toolCallId and mark the
|
||||
* block as rejected with the supplied error.
|
||||
*
|
||||
* Returns the mutated host message, or `undefined` if the toolCallId is
|
||||
* not found (internal invariant violation — caller should log/throw).
|
||||
*/
|
||||
setToolCallError(toolCallId: string, error: unknown): AgentDbMessage | undefined {
|
||||
const host = this.findToolCallHost(toolCallId);
|
||||
if (!host) return undefined;
|
||||
|
||||
const block = this.findToolCallBlock(host, toolCallId)!;
|
||||
const mutableBlock = block;
|
||||
mutableBlock.state = 'rejected';
|
||||
(mutableBlock as Extract<ContentToolCall, { state: 'rejected' }>).error = stringifyError(error);
|
||||
if ('output' in mutableBlock) {
|
||||
delete (mutableBlock as { output: unknown }).output;
|
||||
}
|
||||
|
||||
this.responseSet.add(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
private findToolCallHost(toolCallId: string): AgentDbMessage | undefined {
|
||||
// Start from the last message and go backwards to find the host message
|
||||
for (let i = this.all.length - 1; i >= 0; i--) {
|
||||
const m = this.all[i];
|
||||
if (
|
||||
'content' in m &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((c) => c.type === 'tool-call' && c.toolCallId === toolCallId)
|
||||
) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private findToolCallBlock(host: AgentDbMessage, toolCallId: string): ContentToolCall | undefined {
|
||||
if (!('content' in host) || !Array.isArray(host.content)) return undefined;
|
||||
return host.content.find(
|
||||
(c): c is ContentToolCall => c.type === 'tool-call' && c.toolCallId === toolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full LLM context for a generateText / streamText call.
|
||||
* Prepends the system prompt (with working memory appended if configured),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import type {
|
|||
ContentReasoning,
|
||||
ContentText,
|
||||
ContentToolCall,
|
||||
ContentToolResult,
|
||||
Message,
|
||||
MessageContent,
|
||||
} from '../types/sdk/message';
|
||||
|
|
@ -54,10 +53,6 @@ function isToolCall(block: MessageContent): block is ContentToolCall {
|
|||
return block.type === 'tool-call';
|
||||
}
|
||||
|
||||
function isToolResult(block: MessageContent): block is ContentToolResult {
|
||||
return block.type === 'tool-result';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSONValue that may be a stringified JSON object back into
|
||||
* its parsed form. Non-string values pass through unchanged.
|
||||
|
|
@ -92,32 +87,6 @@ function toAiContent(block: MessageContent): AiContentPart | undefined {
|
|||
input: parseJsonValue(block.input),
|
||||
providerExecuted: block.providerExecuted,
|
||||
};
|
||||
}
|
||||
if (isToolResult(block)) {
|
||||
if (block.isError) {
|
||||
if (typeof block.result === 'string') {
|
||||
base = {
|
||||
type: 'tool-result',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
output: { type: 'error-text', value: block.result },
|
||||
};
|
||||
} else {
|
||||
base = {
|
||||
type: 'tool-result',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
output: { type: 'error-json', value: block.result },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
base = {
|
||||
type: 'tool-result',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
output: { type: 'json', value: block.result },
|
||||
};
|
||||
}
|
||||
} else if (isReasoning(block)) {
|
||||
base = { type: 'reasoning', text: block.text };
|
||||
}
|
||||
|
|
@ -128,6 +97,36 @@ function toAiContent(block: MessageContent): AiContentPart | undefined {
|
|||
return base;
|
||||
}
|
||||
|
||||
/** Build an AI SDK ToolResultPart from a resolved/rejected ContentToolCall. */
|
||||
function toolCallToResultPart(
|
||||
block: ContentToolCall & { state: 'resolved' | 'rejected' },
|
||||
): ToolResultPart {
|
||||
if (block.state === 'resolved') {
|
||||
return {
|
||||
type: 'tool-result',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
output: { type: 'json', value: block.output },
|
||||
};
|
||||
}
|
||||
// rejected
|
||||
const errorValue = block.error;
|
||||
if (typeof errorValue === 'string') {
|
||||
return {
|
||||
type: 'tool-result',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
output: { type: 'error-text', value: errorValue },
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'tool-result',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
output: { type: 'error-json', value: errorValue as JSONValue },
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a single AI SDK content part to an n8n MessageContent block. */
|
||||
function fromAiContent(part: AiContentPart): MessageContent | undefined {
|
||||
const providerOptions = 'providerOptions' in part ? part.providerOptions : undefined;
|
||||
|
|
@ -159,35 +158,11 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined {
|
|||
toolName: part.toolName,
|
||||
input: part.input as JSONValue,
|
||||
providerExecuted: part.providerExecuted,
|
||||
state: 'pending',
|
||||
};
|
||||
break;
|
||||
case 'tool-result': {
|
||||
const { output } = part;
|
||||
let result: JSONValue;
|
||||
let isError: boolean | undefined;
|
||||
if (output.type === 'json') {
|
||||
result = output.value;
|
||||
} else if (output.type === 'text') {
|
||||
result = output.value;
|
||||
} else if (output.type === 'error-json') {
|
||||
result = output.value;
|
||||
isError = true;
|
||||
} else if (output.type === 'error-text') {
|
||||
result = output.value;
|
||||
isError = true;
|
||||
} else {
|
||||
result = null;
|
||||
isError = true;
|
||||
}
|
||||
base = {
|
||||
type: 'tool-result',
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
result,
|
||||
isError,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'tool-result':
|
||||
return undefined;
|
||||
// Ignore these types, because HITL is handled by our runtime
|
||||
case 'tool-approval-request':
|
||||
case 'tool-approval-response':
|
||||
|
|
@ -201,82 +176,172 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined {
|
|||
return base;
|
||||
}
|
||||
|
||||
/** Convert a single n8n Message to an AI SDK ModelMessage. */
|
||||
export function toAiMessage(msg: Message): ModelMessage {
|
||||
let base: ModelMessage;
|
||||
/**
|
||||
* Convert a single n8n Message to one or more AI SDK ModelMessages.
|
||||
*
|
||||
* For assistant messages with resolved/rejected tool-call blocks, this emits:
|
||||
* 1. The assistant ModelMessage (tool-call parts only, no result fields)
|
||||
* 2. One tool ModelMessage per settled tool-call block (resolved or rejected)
|
||||
*
|
||||
* Pending tool-call blocks are silently skipped (defense-in-depth; the strip
|
||||
* step should already have removed them before forLlm() calls toAiMessages).
|
||||
*/
|
||||
function toAiMessageList(msg: Message): ModelMessage[] {
|
||||
switch (msg.role) {
|
||||
case 'system': {
|
||||
const text = msg.content
|
||||
.filter(isText)
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
base = { role: 'system', content: text };
|
||||
break;
|
||||
const base: ModelMessage = { role: 'system', content: text };
|
||||
return [msg.providerOptions ? { ...base, providerOptions: msg.providerOptions } : base];
|
||||
}
|
||||
|
||||
case 'user': {
|
||||
const parts = msg.content
|
||||
.map(toAiContent)
|
||||
.filter((p): p is TextPart | FilePart => p?.type === 'text' || p?.type === 'file');
|
||||
base = { role: 'user', content: parts };
|
||||
break;
|
||||
const base: ModelMessage = { role: 'user', content: parts };
|
||||
return [msg.providerOptions ? { ...base, providerOptions: msg.providerOptions } : base];
|
||||
}
|
||||
|
||||
case 'assistant': {
|
||||
const parts = msg.content
|
||||
.map(toAiContent)
|
||||
.filter(
|
||||
(p): p is TextPart | ReasoningPart | ToolCallPart | ToolResultPart | FilePart =>
|
||||
p?.type === 'text' ||
|
||||
p?.type === 'reasoning' ||
|
||||
p?.type === 'tool-call' ||
|
||||
p?.type === 'tool-result' ||
|
||||
p?.type === 'file',
|
||||
);
|
||||
base = { role: 'assistant', content: parts };
|
||||
break;
|
||||
const assistantParts: AiContentPart[] = [];
|
||||
const resultMessages: ModelMessage[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === 'tool-call') {
|
||||
if (!('state' in block)) {
|
||||
// Legacy DB block - skip it
|
||||
continue;
|
||||
}
|
||||
if (block.state === 'pending') {
|
||||
// Skip pending blocks — defense-in-depth (strip step removes them first)
|
||||
continue;
|
||||
}
|
||||
// Emit tool-call part (without result fields)
|
||||
assistantParts.push({
|
||||
type: 'tool-call',
|
||||
toolCallId: block.toolCallId,
|
||||
toolName: block.toolName,
|
||||
input: parseJsonValue(block.input),
|
||||
providerExecuted: block.providerExecuted,
|
||||
});
|
||||
// Emit corresponding tool-result message immediately after
|
||||
const resultPart = toolCallToResultPart(block);
|
||||
resultMessages.push({ role: 'tool', content: [resultPart] });
|
||||
} else {
|
||||
const part = toAiContent(block);
|
||||
if (part) assistantParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
const transformedMessages: ModelMessage[] = [];
|
||||
|
||||
if (assistantParts.length > 0) {
|
||||
const assistantBase: ModelMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantParts as Array<
|
||||
TextPart | ReasoningPart | ToolCallPart | ToolResultPart | FilePart
|
||||
>,
|
||||
};
|
||||
const assistantMsg: ModelMessage = msg.providerOptions
|
||||
? { ...assistantBase, providerOptions: msg.providerOptions }
|
||||
: assistantBase;
|
||||
transformedMessages.push(assistantMsg);
|
||||
}
|
||||
if (resultMessages.length > 0) {
|
||||
transformedMessages.push(...resultMessages);
|
||||
}
|
||||
|
||||
return transformedMessages;
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
const parts = msg.content
|
||||
.map(toAiContent)
|
||||
.filter((p): p is ToolResultPart => p?.type === 'tool-result');
|
||||
base = { role: 'tool', content: parts };
|
||||
break;
|
||||
// Legacy role: 'tool' messages (from old DB rows). Don't emit them.
|
||||
return [];
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown role: ${msg.role as string}`);
|
||||
}
|
||||
|
||||
if (msg.providerOptions) {
|
||||
return { ...base, providerOptions: msg.providerOptions };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Convert n8n Messages to AI SDK ModelMessages for passing to stream/generateText. */
|
||||
export function toAiMessages(messages: Message[]): ModelMessage[] {
|
||||
return messages.map(toAiMessage);
|
||||
return messages.flatMap(toAiMessageList);
|
||||
}
|
||||
|
||||
/** Convert a single AI SDK ModelMessage to an n8n AgentDbMessage (with a generated id). */
|
||||
export function fromAiMessage(msg: ModelMessage): AgentMessage {
|
||||
const rawContent = msg.content;
|
||||
const content: MessageContent[] =
|
||||
typeof rawContent === 'string'
|
||||
? [{ type: 'text', text: rawContent }]
|
||||
: rawContent.map(fromAiContent).filter((p): p is MessageContent => p !== undefined);
|
||||
const message: AgentMessage = { role: msg.role, content };
|
||||
if ('providerOptions' in msg && msg.providerOptions) {
|
||||
message.providerOptions = msg.providerOptions;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Convert AI SDK ModelMessages to n8n AgentDbMessages (each with a generated id). */
|
||||
/**
|
||||
* Convert AI SDK ModelMessages to n8n AgentMessages.
|
||||
*
|
||||
* This is a stateful walk: when a role:'tool' ModelMessage is encountered,
|
||||
* the matching tool-call block on the preceding assistant message is mutated
|
||||
* to 'resolved' or 'rejected'. The tool message itself is not emitted as a
|
||||
* separate n8n message.
|
||||
*
|
||||
* If a tool-result references a toolCallId not in the index (orphan), it is
|
||||
* silently dropped.
|
||||
*/
|
||||
export function fromAiMessages(messages: ModelMessage[]): AgentMessage[] {
|
||||
return messages.map(fromAiMessage);
|
||||
// Map from toolCallId → ContentToolCall block (mutable ref inside the n8n message)
|
||||
const toolCallIndex = new Map<string, ContentToolCall>();
|
||||
const result: AgentMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'tool') {
|
||||
// Merge tool results back into the matching tool-call blocks
|
||||
const toolParts = msg.content as ToolResultPart[];
|
||||
for (const part of toolParts) {
|
||||
const block = toolCallIndex.get(part.toolCallId);
|
||||
if (!block) continue; // orphan — drop
|
||||
|
||||
const { output } = part;
|
||||
if (output.type === 'json' || output.type === 'text') {
|
||||
const mutableBlock = block as Extract<ContentToolCall, { state: 'resolved' }>;
|
||||
mutableBlock.state = 'resolved';
|
||||
mutableBlock.output = output.value as JSONValue;
|
||||
} else if (output.type === 'error-json') {
|
||||
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
|
||||
mutableBlock.state = 'rejected';
|
||||
mutableBlock.error = JSON.stringify(output.value);
|
||||
} else if (output.type === 'error-text') {
|
||||
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
|
||||
mutableBlock.state = 'rejected';
|
||||
mutableBlock.error = output.value;
|
||||
} else {
|
||||
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
|
||||
mutableBlock.state = 'rejected';
|
||||
mutableBlock.error = JSON.stringify(output);
|
||||
}
|
||||
}
|
||||
// Do not emit a separate n8n message for tool results
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawContent = msg.content;
|
||||
const content: MessageContent[] =
|
||||
typeof rawContent === 'string'
|
||||
? [{ type: 'text', text: rawContent }]
|
||||
: rawContent.map(fromAiContent).filter((p): p is MessageContent => p !== undefined);
|
||||
|
||||
const agentMsg: AgentMessage = { role: msg.role, content };
|
||||
if ('providerOptions' in msg && msg.providerOptions) {
|
||||
agentMsg.providerOptions = msg.providerOptions;
|
||||
}
|
||||
result.push(agentMsg);
|
||||
|
||||
// Index any tool-call blocks for later merging with tool-result messages
|
||||
if (msg.role === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool-call' && block.toolCallId) {
|
||||
toolCallIndex.set(block.toolCallId, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function fromAiFinishReason(reason: AiFinishReason): FinishReason {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
/* eslint-disable @typescript-eslint/consistent-type-imports */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
import type { EmbeddingModel, LanguageModel } from 'ai';
|
||||
import type * as Undici from 'undici';
|
||||
|
||||
import {
|
||||
PROVIDER_CREDENTIAL_SCHEMAS,
|
||||
type ProviderId,
|
||||
type ProviderCredentials,
|
||||
} from './provider-credentials';
|
||||
import type { ModelConfig } from '../types/sdk/agent';
|
||||
|
||||
type FetchFn = typeof globalThis.fetch;
|
||||
type CreateProviderFn = (opts?: {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
fetch?: FetchFn;
|
||||
headers?: Record<string, string>;
|
||||
}) => (model: string) => LanguageModel;
|
||||
type CreateEmbeddingProviderFn = (opts?: { apiKey?: string }) => {
|
||||
embeddingModel(model: string): EmbeddingModel;
|
||||
};
|
||||
|
|
@ -39,6 +39,124 @@ function getProxyFetch(): FetchFn | undefined {
|
|||
})) as FetchFn;
|
||||
}
|
||||
|
||||
type EntryBuilder<P extends ProviderId> = (
|
||||
creds: ProviderCredentials<P>,
|
||||
modelName: string,
|
||||
fetch: FetchFn | undefined,
|
||||
) => LanguageModel;
|
||||
|
||||
type RegistryEntry<P extends ProviderId = ProviderId> = {
|
||||
build: EntryBuilder<P>;
|
||||
};
|
||||
|
||||
type ProviderRegistry = {
|
||||
[P in ProviderId]: RegistryEntry<P>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry of language model providers.
|
||||
* Each entry maps a provider id to a builder that loads its @ai-sdk/* package
|
||||
* and instantiates the model. Credentials are Zod-validated before being passed in.
|
||||
*/
|
||||
const LANGUAGE_PROVIDERS: ProviderRegistry = {
|
||||
openai: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createOpenAI } = require('@ai-sdk/openai') as typeof import('@ai-sdk/openai');
|
||||
return createOpenAI({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createAnthropic } =
|
||||
require('@ai-sdk/anthropic') as typeof import('@ai-sdk/anthropic');
|
||||
return createAnthropic({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
google: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createGoogleGenerativeAI } =
|
||||
require('@ai-sdk/google') as typeof import('@ai-sdk/google');
|
||||
return createGoogleGenerativeAI({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
xai: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createXai } = require('@ai-sdk/xai') as typeof import('@ai-sdk/xai');
|
||||
return createXai({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
groq: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createGroq } = require('@ai-sdk/groq') as typeof import('@ai-sdk/groq');
|
||||
return createGroq({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
deepseek: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createDeepSeek } = require('@ai-sdk/deepseek') as typeof import('@ai-sdk/deepseek');
|
||||
return createDeepSeek({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
cohere: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createCohere } = require('@ai-sdk/cohere') as typeof import('@ai-sdk/cohere');
|
||||
return createCohere({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
mistral: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createMistral } = require('@ai-sdk/mistral') as typeof import('@ai-sdk/mistral');
|
||||
return createMistral({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
vercel: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createGateway } = require('@ai-sdk/gateway') as typeof import('@ai-sdk/gateway');
|
||||
return createGateway({ ...creds, fetch })(model);
|
||||
},
|
||||
},
|
||||
openrouter: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createOpenRouter } =
|
||||
require('@openrouter/ai-sdk-provider') as typeof import('@openrouter/ai-sdk-provider');
|
||||
return createOpenRouter({ apiKey: creds.apiKey, baseURL: creds.baseURL, fetch })(model);
|
||||
},
|
||||
},
|
||||
'azure-openai': {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createAzure } = require('@ai-sdk/azure') as typeof import('@ai-sdk/azure');
|
||||
const { baseURL, resourceName, apiVersion, apiKey } = creds;
|
||||
let normalizedBaseURL = baseURL;
|
||||
// SDK expects url like `https://resourceName.openai.azure.com/openai`
|
||||
if (normalizedBaseURL) {
|
||||
const url = new URL(normalizedBaseURL);
|
||||
if (!url.pathname.endsWith('/openai')) {
|
||||
url.pathname = url.pathname.replace(/\/?$/, '/openai');
|
||||
normalizedBaseURL = url.toString();
|
||||
}
|
||||
}
|
||||
return createAzure({ resourceName, apiKey, baseURL: normalizedBaseURL, apiVersion, fetch })(
|
||||
model,
|
||||
);
|
||||
},
|
||||
},
|
||||
'aws-bedrock': {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createAmazonBedrock } =
|
||||
require('@ai-sdk/amazon-bedrock') as typeof import('@ai-sdk/amazon-bedrock');
|
||||
return createAmazonBedrock({
|
||||
region: creds.region,
|
||||
accessKeyId: creds.accessKeyId,
|
||||
secretAccessKey: creds.secretAccessKey,
|
||||
sessionToken: creds.sessionToken,
|
||||
fetch,
|
||||
})(model);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SUPPORTED_PROVIDERS = Object.keys(LANGUAGE_PROVIDERS).join(', ');
|
||||
|
||||
/**
|
||||
* Provider packages are loaded dynamically via require() so only the
|
||||
* provider needed at runtime must be installed.
|
||||
|
|
@ -48,55 +166,44 @@ export function createModel(config: ModelConfig): LanguageModel {
|
|||
return config;
|
||||
}
|
||||
|
||||
const stripEmpty = <T>(value: T | undefined): T | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'string' && value.trim() === '') return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
const modelId = stripEmpty(typeof config === 'string' ? config : config.id);
|
||||
const apiKey = stripEmpty(typeof config === 'string' ? undefined : config.apiKey);
|
||||
const baseURL = stripEmpty(typeof config === 'string' ? undefined : config.url);
|
||||
const headers = typeof config === 'string' ? undefined : config.headers;
|
||||
|
||||
if (!modelId) {
|
||||
const rawId = typeof config === 'string' ? config : config.id;
|
||||
if (!rawId || rawId.trim() === '') {
|
||||
throw new Error('Model ID is required');
|
||||
}
|
||||
|
||||
const [provider, ...rest] = modelId.split('/');
|
||||
const modelName = rest.join('/');
|
||||
const fetch = getProxyFetch();
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic': {
|
||||
const { createAnthropic } = require('@ai-sdk/anthropic') as {
|
||||
createAnthropic: CreateProviderFn;
|
||||
};
|
||||
return createAnthropic({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
case 'openai': {
|
||||
const { createOpenAI } = require('@ai-sdk/openai') as {
|
||||
createOpenAI: CreateProviderFn;
|
||||
};
|
||||
return createOpenAI({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
case 'google': {
|
||||
const { createGoogleGenerativeAI } = require('@ai-sdk/google') as {
|
||||
createGoogleGenerativeAI: CreateProviderFn;
|
||||
};
|
||||
return createGoogleGenerativeAI({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
case 'xai': {
|
||||
const { createXai } = require('@ai-sdk/xai') as {
|
||||
createXai: CreateProviderFn;
|
||||
};
|
||||
return createXai({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported provider: "${provider}". Supported: anthropic, openai, google, xai`,
|
||||
);
|
||||
const slashIndex = rawId.indexOf('/');
|
||||
if (slashIndex <= 0) {
|
||||
throw new Error(`Invalid model ID "${rawId}": expected "provider/model-name" format`);
|
||||
}
|
||||
const provider = rawId.slice(0, slashIndex) as ProviderId;
|
||||
const modelName = rawId.slice(slashIndex + 1);
|
||||
|
||||
const entry = LANGUAGE_PROVIDERS[provider];
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unsupported provider: "${provider}". Supported providers: ${SUPPORTED_PROVIDERS}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Collect credential fields: strip `id`, pass the rest to Zod validation.
|
||||
let credFields: Record<string, unknown> = {};
|
||||
if (typeof config !== 'string') {
|
||||
const { id: _id, ...rest } = config as { id: string; [k: string]: unknown };
|
||||
credFields = rest;
|
||||
}
|
||||
|
||||
const schema = PROVIDER_CREDENTIAL_SCHEMAS[provider];
|
||||
const parsed = schema.safeParse(credFields);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid credentials for provider "${provider}":\n${issues}`);
|
||||
}
|
||||
|
||||
const fetch = getProxyFetch();
|
||||
// Type cast: the registry guarantees the schema and builder are aligned per provider.
|
||||
return (entry.build as EntryBuilder<typeof provider>)(parsed.data as never, modelName, fetch);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
42
packages/@n8n/agents/src/runtime/provider-credentials.ts
Normal file
42
packages/@n8n/agents/src/runtime/provider-credentials.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const apiKeyCreds = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-provider Zod schemas for credential validation.
|
||||
* Keys are the provider prefixes used in model IDs (e.g. 'anthropic' in 'anthropic/claude-sonnet-4-5').
|
||||
*/
|
||||
export const PROVIDER_CREDENTIAL_SCHEMAS = {
|
||||
openai: apiKeyCreds,
|
||||
anthropic: apiKeyCreds,
|
||||
google: apiKeyCreds,
|
||||
xai: apiKeyCreds,
|
||||
groq: apiKeyCreds,
|
||||
deepseek: apiKeyCreds,
|
||||
cohere: apiKeyCreds,
|
||||
mistral: apiKeyCreds,
|
||||
vercel: apiKeyCreds,
|
||||
openrouter: apiKeyCreds,
|
||||
|
||||
'azure-openai': z.object({
|
||||
apiKey: z.string().optional(),
|
||||
resourceName: z.string().min(1, 'Azure resourceName is required'),
|
||||
apiVersion: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
}),
|
||||
'aws-bedrock': z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS accessKeyId is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secretAccessKey is required'),
|
||||
sessionToken: z.string().optional(),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export type ProviderId = keyof typeof PROVIDER_CREDENTIAL_SCHEMAS;
|
||||
export type ProviderCredentials<P extends ProviderId> = z.infer<
|
||||
(typeof PROVIDER_CREDENTIAL_SCHEMAS)[P]
|
||||
>;
|
||||
|
|
@ -4,8 +4,7 @@
|
|||
*/
|
||||
import type { GenerateResult, StreamChunk, TokenUsage } from '../types';
|
||||
import { toTokenUsage } from './stream';
|
||||
import type { AgentMessage, ContentToolResult } from '../types/sdk/message';
|
||||
import type { JSONValue } from '../types/utils/json';
|
||||
import type { AgentMessage, ContentToolCall } from '../types/sdk/message';
|
||||
|
||||
/**
|
||||
* Normalize caller input to `AgentMessage[]` for the runtime. String input becomes a
|
||||
|
|
@ -18,55 +17,16 @@ export function normalizeInput(input: AgentMessage[] | string): AgentMessage[] {
|
|||
return input;
|
||||
}
|
||||
|
||||
/** Build an AI SDK tool ModelMessage for a tool execution result. */
|
||||
export function makeToolResultMessage(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
): AgentMessage {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId,
|
||||
toolName,
|
||||
result: result as JSONValue,
|
||||
},
|
||||
],
|
||||
};
|
||||
/** Stringify an error value for use in a rejected tool-call block. */
|
||||
export function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AI SDK tool ModelMessage for a tool execution error.
|
||||
* The LLM receives this as a tool result so it can self-correct on the next iteration.
|
||||
* The error is surfaced via the output json value so the LLM can read and reason about it.
|
||||
*/
|
||||
export function makeErrorToolResultMessage(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
error: unknown,
|
||||
): AgentMessage {
|
||||
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
return {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId,
|
||||
toolName,
|
||||
result: { error: message } as JSONValue,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract all tool-result content parts from a flat list of agent messages. */
|
||||
export function extractToolResults(messages: AgentMessage[]): ContentToolResult[] {
|
||||
/** Extract all settled (resolved or rejected) tool-call blocks from a flat list of agent messages. */
|
||||
export function extractSettledToolCalls(messages: AgentMessage[]): ContentToolCall[] {
|
||||
return messages
|
||||
.flatMap((m) => ('content' in m ? m.content : []))
|
||||
.filter((c): c is ContentToolResult => c.type === 'tool-result');
|
||||
.filter((c): c is ContentToolCall => c.type === 'tool-call' && c.state !== 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,72 +39,62 @@ export function toTokenUsage(
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Convert a single AI SDK v6 fullStream chunk to an n8n StreamChunk (or undefined to skip). */
|
||||
/**
|
||||
* Convert a single AI SDK v6 fullStream chunk to an n8n StreamChunk
|
||||
*/
|
||||
export function convertChunk(c: TextStreamPart<ToolSet>): StreamChunk | undefined {
|
||||
switch (c.type) {
|
||||
case 'start-step':
|
||||
return { type: 'start-step' };
|
||||
|
||||
case 'finish-step':
|
||||
return { type: 'finish-step' };
|
||||
|
||||
case 'text-start':
|
||||
return { type: 'text-start', id: c.id };
|
||||
|
||||
case 'text-delta':
|
||||
return { type: 'text-delta', delta: c.text ?? '' };
|
||||
return { type: 'text-delta', id: c.id, delta: c.text ?? '' };
|
||||
|
||||
case 'text-end':
|
||||
return { type: 'text-end', id: c.id };
|
||||
|
||||
case 'reasoning-start':
|
||||
return { type: 'reasoning-start', id: c.id };
|
||||
|
||||
case 'reasoning-delta':
|
||||
return { type: 'reasoning-delta', delta: c.text ?? '' };
|
||||
return { type: 'reasoning-delta', id: c.id, delta: c.text ?? '' };
|
||||
|
||||
case 'reasoning-end':
|
||||
return { type: 'reasoning-end', id: c.id };
|
||||
|
||||
case 'tool-input-start':
|
||||
// AI SDK uses `id` to carry the toolCallId on tool-input-* chunks.
|
||||
return { type: 'tool-input-start', toolCallId: c.id, toolName: c.toolName };
|
||||
|
||||
case 'tool-input-delta':
|
||||
return { type: 'tool-input-delta', toolCallId: c.id, delta: c.delta };
|
||||
|
||||
case 'tool-call':
|
||||
return {
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: c.toolCallId,
|
||||
toolName: c.toolName ?? '',
|
||||
input: c.input as JSONValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
case 'tool-input-start':
|
||||
return {
|
||||
type: 'tool-call-delta',
|
||||
name: c.toolName,
|
||||
};
|
||||
|
||||
case 'tool-input-delta':
|
||||
return {
|
||||
type: 'tool-call-delta',
|
||||
...(c.delta !== undefined && { argumentsDelta: c.delta }),
|
||||
type: 'tool-call',
|
||||
toolCallId: c.toolCallId,
|
||||
toolName: c.toolName ?? '',
|
||||
input: c.input as JSONValue,
|
||||
};
|
||||
|
||||
case 'tool-result':
|
||||
return {
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: c.toolCallId ?? '',
|
||||
toolName: c.toolName ?? '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
result: c.output && 'value' in c.output ? (c.output.value as JSONValue) : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'tool-result',
|
||||
toolCallId: c.toolCallId ?? '',
|
||||
toolName: c.toolName ?? '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
output: c.output && 'value' in c.output ? (c.output.value as JSONValue) : null,
|
||||
};
|
||||
|
||||
case 'error':
|
||||
return { type: 'error', error: c.error };
|
||||
|
||||
case 'finish-step': {
|
||||
const usage = toTokenUsage(c.usage);
|
||||
return {
|
||||
type: 'finish',
|
||||
finishReason: (c.finishReason ?? 'stop') as FinishReason,
|
||||
...(usage && { usage }),
|
||||
};
|
||||
}
|
||||
|
||||
case 'finish': {
|
||||
const usage = toTokenUsage(c.totalUsage);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,43 +2,15 @@ import { isLlmMessage } from '../sdk/message';
|
|||
import type { AgentMessage, MessageContent } from '../types/sdk/message';
|
||||
|
||||
/**
|
||||
* Strip orphaned tool-call and tool-result content from a message list.
|
||||
*
|
||||
* When memory loads the last N messages, the window boundary can split
|
||||
* tool-call / tool-result pairs, leaving one side without its counterpart.
|
||||
* Sending these orphans to the LLM causes provider errors because tool
|
||||
* calls and results must always be paired.
|
||||
* Strip pending tool-call blocks from a message list before sending to the LLM.
|
||||
*
|
||||
* This function:
|
||||
* 1. Collects all toolCallIds present in tool-call and tool-result blocks.
|
||||
* 2. Identifies orphans — calls without a matching result and vice-versa.
|
||||
* 3. Strips orphaned content blocks from their messages.
|
||||
* 4. Drops messages that become empty after stripping (e.g. a tool message
|
||||
* whose only content was the orphaned result).
|
||||
* 5. Preserves non-tool content (text, reasoning, files) in mixed messages.
|
||||
* 1. Drops any tool-call block whose state is 'pending'.
|
||||
* 2. If a message becomes empty after stripping, drops the message entirely.
|
||||
* 3. Preserves all other content (text, reasoning, files, resolved/rejected
|
||||
* tool-call blocks, and non-LLM custom messages).
|
||||
*/
|
||||
export function stripOrphanedToolMessages<T extends AgentMessage>(messages: T[]): T[] {
|
||||
const callIds = new Set<string>();
|
||||
const resultIds = new Set<string>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!isLlmMessage(msg)) continue;
|
||||
for (const block of msg.content) {
|
||||
if (block.type === 'tool-call' && block.toolCallId) {
|
||||
callIds.add(block.toolCallId);
|
||||
} else if (block.type === 'tool-result' && block.toolCallId) {
|
||||
resultIds.add(block.toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orphanedCallIds = new Set([...callIds].filter((id) => !resultIds.has(id)));
|
||||
const orphanedResultIds = new Set([...resultIds].filter((id) => !callIds.has(id)));
|
||||
|
||||
if (orphanedCallIds.size === 0 && orphanedResultIds.size === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
|
|
@ -48,14 +20,7 @@ export function stripOrphanedToolMessages<T extends AgentMessage>(messages: T[])
|
|||
}
|
||||
|
||||
const filtered = msg.content.filter((block: MessageContent) => {
|
||||
if (block.type === 'tool-call' && block.toolCallId && orphanedCallIds.has(block.toolCallId)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
block.type === 'tool-result' &&
|
||||
block.toolCallId &&
|
||||
orphanedResultIds.has(block.toolCallId)
|
||||
) {
|
||||
if (block.type === 'tool-call' && block.state === 'pending') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ const DEFAULT_TITLE_INSTRUCTIONS = [
|
|||
'Title: Scryfall random card workflow',
|
||||
].join('\n');
|
||||
|
||||
const DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS = [
|
||||
'Generate a short title and a single emoji for a conversation based on the user message.',
|
||||
'Respond with ONLY a JSON object: {"title": "...", "emoji": "..."}',
|
||||
'Rules:',
|
||||
'- Title: 2 to 6 words, max 50 characters, sentence case',
|
||||
'- Title must not contain emoji, quotes, colons, or markdown formatting',
|
||||
'- Emoji: exactly one emoji that represents the topic',
|
||||
'- Respond with the JSON object only, no other text',
|
||||
].join('\n');
|
||||
|
||||
const TRIVIAL_MESSAGE_MAX_CHARS = 15;
|
||||
const TRIVIAL_MESSAGE_MAX_WORDS = 3;
|
||||
const MAX_TITLE_LENGTH = 80;
|
||||
|
|
@ -117,7 +127,67 @@ export async function generateTitleFromMessage(
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate a title for a thread if it doesn't already have one.
|
||||
* Generate a sanitized title and a representative emoji from a user message.
|
||||
*
|
||||
* Asks the LLM for a `{"title": "...", "emoji": "..."}` JSON object and parses
|
||||
* it; falls back to treating the whole response as a plain title if the model
|
||||
* ignores the JSON format.
|
||||
*
|
||||
* Returns `null` on empty/trivial input or empty LLM output.
|
||||
*/
|
||||
export async function generateTitleAndEmojiFromMessage(
|
||||
model: LanguageModel,
|
||||
userMessage: string,
|
||||
opts?: { instructions?: string },
|
||||
): Promise<{ title: string; emoji?: string } | null> {
|
||||
const trimmed = userMessage.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (isTrivialMessage(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: opts?.instructions ?? DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS },
|
||||
{ role: 'user', content: trimmed },
|
||||
],
|
||||
});
|
||||
|
||||
let text = result.text?.trim();
|
||||
if (!text) return null;
|
||||
|
||||
// Strip <think>...</think> blocks (e.g. from DeepSeek R1) before JSON parsing.
|
||||
text = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
if (!text) return null;
|
||||
|
||||
let rawTitle = '';
|
||||
let emoji: string | undefined;
|
||||
|
||||
const jsonMatch = /\{[\s\S]*\}/.exec(text);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as { title?: string; emoji?: string };
|
||||
rawTitle = parsed.title?.trim() ?? '';
|
||||
emoji = parsed.emoji?.trim() ?? undefined;
|
||||
} catch {
|
||||
// Model returned something that looked like JSON but wasn't parseable —
|
||||
// fall back to using the whole response as the title.
|
||||
rawTitle = text;
|
||||
}
|
||||
} else {
|
||||
rawTitle = text;
|
||||
}
|
||||
|
||||
const title = sanitizeTitle(rawTitle);
|
||||
if (!title) return null;
|
||||
|
||||
return { title, emoji };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title and emoji for a thread if it doesn't already have one.
|
||||
*
|
||||
* Designed to run fire-and-forget after the agent response is complete.
|
||||
* All errors are caught and logged — title generation failures never
|
||||
|
|
@ -148,16 +218,21 @@ export async function generateThreadTitle(opts: {
|
|||
|
||||
const titleModelId = opts.titleConfig.model ?? opts.agentModel;
|
||||
const titleModel = createModel(titleModelId);
|
||||
const title = await generateTitleFromMessage(titleModel, userText, {
|
||||
const generated = await generateTitleAndEmojiFromMessage(titleModel, userText, {
|
||||
instructions: opts.titleConfig.instructions,
|
||||
});
|
||||
if (!title) return;
|
||||
if (!generated) return;
|
||||
|
||||
const { title, emoji } = generated;
|
||||
|
||||
// Store emoji in thread metadata
|
||||
const metadata = { ...(thread?.metadata ?? {}), ...(emoji && { emoji }) };
|
||||
|
||||
await opts.memory.saveThread({
|
||||
id: opts.threadId,
|
||||
resourceId: opts.resourceId,
|
||||
title,
|
||||
metadata: thread?.metadata,
|
||||
metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to generate thread title', { error });
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { BuiltTool } from '../types';
|
|||
|
||||
type ZodObjectSchema = z.ZodObject<z.ZodRawShape>;
|
||||
|
||||
export const UPDATE_WORKING_MEMORY_TOOL_NAME = 'updateWorkingMemory';
|
||||
export const UPDATE_WORKING_MEMORY_TOOL_NAME = 'update_working_memory';
|
||||
|
||||
/**
|
||||
* The default instruction block injected into the system prompt when working memory
|
||||
|
|
@ -19,7 +19,7 @@ export const WORKING_MEMORY_DEFAULT_INSTRUCTION = [
|
|||
|
||||
/**
|
||||
* Generate the system prompt instruction for working memory.
|
||||
* Tells the LLM to call the updateWorkingMemory tool when it has new information to persist.
|
||||
* Tells the LLM to call the update_working_memory tool when it has new information to persist.
|
||||
*
|
||||
* @param template - The working memory template or schema.
|
||||
* @param structured - Whether the working memory is structured (JSON schema).
|
||||
|
|
@ -73,7 +73,7 @@ export interface WorkingMemoryToolConfig {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the updateWorkingMemory BuiltTool that the agent calls to persist working memory.
|
||||
* Build the update_working_memory BuiltTool that the agent calls to persist working memory.
|
||||
*
|
||||
* For freeform working memory the input schema is `{ memory: string }`.
|
||||
* For structured working memory the input schema is the configured Zod object schema,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ModelCost } from './catalog';
|
||||
import type { AgentRuntimeConfig } from '../runtime/agent-runtime';
|
||||
import type { Eval } from './eval';
|
||||
import type { McpClient } from './mcp-client';
|
||||
import { Memory } from './memory';
|
||||
import { Telemetry } from './telemetry';
|
||||
import { Tool, wrapToolForApproval } from './tool';
|
||||
import { AgentRuntime } from '../runtime/agent-runtime';
|
||||
import { AgentEventBus } from '../runtime/event-bus';
|
||||
import { InMemoryMemory } from '../runtime/memory-store';
|
||||
import { RunStateManager } from '../runtime/run-state';
|
||||
import { createAgentToolResult } from '../runtime/tool-adapter';
|
||||
import type {
|
||||
AgentEvent,
|
||||
|
|
@ -17,57 +18,60 @@ import type {
|
|||
BuiltGuardrail,
|
||||
BuiltMemory,
|
||||
BuiltProviderTool,
|
||||
BuiltTelemetry,
|
||||
BuiltTool,
|
||||
BuiltTelemetry,
|
||||
CheckpointStore,
|
||||
ExecutionOptions,
|
||||
GenerateResult,
|
||||
MemoryConfig,
|
||||
ModelConfig,
|
||||
Provider,
|
||||
ResumeOptions,
|
||||
RunOptions,
|
||||
SerializableAgentState,
|
||||
StreamResult,
|
||||
SubAgentUsage,
|
||||
ThinkingConfig,
|
||||
ThinkingConfigFor,
|
||||
ResumeOptions,
|
||||
} from '../types';
|
||||
import { getModelCost } from './catalog';
|
||||
import type { Eval } from './eval';
|
||||
import { fromSchema, type FromSchemaOptions } from './from-schema';
|
||||
import type { McpClient } from './mcp-client';
|
||||
import { Memory } from './memory';
|
||||
import { Telemetry } from './telemetry';
|
||||
import { Tool, wrapToolForApproval } from './tool';
|
||||
import type { StreamChunk } from '../types/sdk/agent';
|
||||
import type { AgentBuilder } from '../types/sdk/agent-builder';
|
||||
import type { CredentialProvider } from '../types/sdk/credential-provider';
|
||||
import type { AgentMessage } from '../types/sdk/message';
|
||||
import type {
|
||||
AgentSchema,
|
||||
EvalSchema,
|
||||
GuardrailSchema,
|
||||
McpServerSchema,
|
||||
MemorySchema,
|
||||
ProviderToolSchema,
|
||||
ThinkingSchema,
|
||||
ToolSchema,
|
||||
} from '../types/sdk/schema';
|
||||
import { zodToJsonSchema } from '../utils/zod';
|
||||
import type { Workspace } from '../workspace/workspace';
|
||||
|
||||
const DEFAULT_LAST_MESSAGES = 10;
|
||||
|
||||
type ToolParameter = BuiltTool | { build(): BuiltTool };
|
||||
|
||||
/**
|
||||
* Lightweight read-only view of an agent's configured state.
|
||||
* Returned by `Agent.snapshot` for testing and debugging purposes.
|
||||
*/
|
||||
export interface AgentSnapshot {
|
||||
/** Agent name. */
|
||||
name: string;
|
||||
/** Parsed model identifier. Both fields are null if no model has been set. */
|
||||
model: { provider: string | null; name: string | null };
|
||||
/** Instruction text passed to `.instructions()`, or null if not set. */
|
||||
instructions: string | null;
|
||||
/** Minimal description of each registered tool. */
|
||||
tools: ReadonlyArray<{ name: string; description: string | undefined }>;
|
||||
/** True when `.memory()` has been configured. */
|
||||
hasMemory: boolean;
|
||||
/** The thinking config if set, otherwise null. */
|
||||
thinking: ThinkingConfig | null;
|
||||
/** Tool-call concurrency limit if set, otherwise null. */
|
||||
toolCallConcurrency: number | null;
|
||||
/** Whether `.requireToolApproval()` was called. */
|
||||
requireToolApproval: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating AI agents with a fluent API.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const agent = new Agent('assistant')
|
||||
* .model('anthropic', 'claude-sonnet-4') // typed: Agent<'anthropic'>
|
||||
* .credential('anthropic')
|
||||
* .model('anthropic', 'claude-sonnet-4')
|
||||
* .instructions('You are a helpful assistant.')
|
||||
* .tool(searchTool);
|
||||
*
|
||||
|
|
@ -78,9 +82,7 @@ type ToolParameter = BuiltTool | { build(): BuiltTool };
|
|||
export class Agent implements BuiltAgent, AgentBuilder {
|
||||
readonly name: string;
|
||||
|
||||
private modelId?: string;
|
||||
|
||||
private modelConfigObj?: ModelConfig;
|
||||
private modelConfig?: ModelConfig;
|
||||
|
||||
private instructionProviderOpts?: ProviderOptions;
|
||||
|
||||
|
|
@ -106,11 +108,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
|
||||
private thinkingConfig?: ThinkingConfig;
|
||||
|
||||
private credentialName?: string;
|
||||
|
||||
private credProvider?: CredentialProvider;
|
||||
|
||||
private resolvedKey?: string;
|
||||
private runtime?: AgentRuntime;
|
||||
|
||||
private concurrencyValue?: number;
|
||||
|
||||
|
|
@ -124,13 +122,9 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
|
||||
private mcpClients: McpClient[] = [];
|
||||
|
||||
private buildPromise: Promise<AgentRuntimeConfig> | undefined;
|
||||
private buildPromise: Promise<AgentRuntime> | undefined;
|
||||
|
||||
/** Handlers registered via on() — copied into each per-run event bus at creation time. */
|
||||
private agentHandlers = new Map<AgentEvent, Set<AgentEventHandler>>();
|
||||
|
||||
/** Event buses for all currently active runs, used to broadcast abort(). */
|
||||
private activeEventBuses = new Set<AgentEventBus>();
|
||||
private eventBus = new AgentEventBus();
|
||||
|
||||
private workspaceInstance?: Workspace;
|
||||
|
||||
|
|
@ -138,22 +132,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a live Agent from an AgentSchema JSON.
|
||||
* Custom tool handlers are proxied through the injected HandlerExecutor.
|
||||
*
|
||||
* This is the inverse of `Agent.describe()`.
|
||||
*/
|
||||
static async fromSchema(
|
||||
schema: AgentSchema,
|
||||
name: string,
|
||||
options: FromSchemaOptions,
|
||||
): Promise<Agent> {
|
||||
const agent = new Agent(name);
|
||||
await fromSchema(agent, schema, options);
|
||||
return agent;
|
||||
}
|
||||
|
||||
hasCheckpointStorage(): boolean {
|
||||
return this.checkpointStore !== undefined;
|
||||
}
|
||||
|
|
@ -176,11 +154,9 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
*/
|
||||
model(providerOrIdOrConfig: string | ModelConfig, modelName?: string): this {
|
||||
if (typeof providerOrIdOrConfig === 'string') {
|
||||
this.modelId = modelName ? `${providerOrIdOrConfig}/${modelName}` : providerOrIdOrConfig;
|
||||
this.modelConfigObj = undefined;
|
||||
this.modelConfig = modelName ? `${providerOrIdOrConfig}/${modelName}` : providerOrIdOrConfig;
|
||||
} else {
|
||||
this.modelConfigObj = providerOrIdOrConfig;
|
||||
this.modelId = undefined;
|
||||
this.modelConfig = providerOrIdOrConfig;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
|
@ -211,7 +187,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
/** @internal Read the declared tools (used by the compile step to detect workflow tool markers). */
|
||||
/** Read the declared tools. Lists only tools added via tool() */
|
||||
get declaredTools(): BuiltTool[] {
|
||||
return this.tools;
|
||||
}
|
||||
|
|
@ -289,54 +265,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a credential this agent requires. The execution engine resolves
|
||||
* the credential name to an API key at build time and injects it into the
|
||||
* model configuration — user code never handles raw keys.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new Agent('assistant')
|
||||
* .model('anthropic/claude-sonnet-4-5')
|
||||
* .credential('anthropic')
|
||||
* .instructions('You are helpful.');
|
||||
* ```
|
||||
*/
|
||||
credential(name: string): this {
|
||||
this.credentialName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a credential provider that resolves credential identifiers to
|
||||
* decrypted API keys at build time. When both `.credential()` and
|
||||
* `.credentialProvider()` are set, the provider resolves the credential
|
||||
* before model creation — no subclassing required.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new Agent('assistant')
|
||||
* .model('anthropic', 'claude-sonnet-4')
|
||||
* .credential('credential-id-123')
|
||||
* .credentialProvider(myProvider)
|
||||
* .instructions('You are helpful.');
|
||||
* ```
|
||||
*/
|
||||
credentialProvider(provider: CredentialProvider): this {
|
||||
this.credProvider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @internal Read the declared credential name (used by the execution engine). */
|
||||
protected get declaredCredential(): string | undefined {
|
||||
return this.credentialName;
|
||||
}
|
||||
|
||||
/** @internal Set the resolved API key (called by the execution engine before super.build()). */
|
||||
protected set resolvedApiKey(key: string) {
|
||||
this.resolvedKey = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a structured output schema. When set, the agent's response will be
|
||||
* parsed into a typed object matching the schema, available as `result.output`.
|
||||
|
|
@ -463,29 +391,10 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
|
||||
/**
|
||||
* Register a handler for an agent lifecycle event.
|
||||
* Handlers are forwarded into every per-run event bus so they fire for all concurrent runs.
|
||||
* Use off() to remove the handler when it is no longer needed.
|
||||
* Handlers are called synchronously during the agentic loop.
|
||||
*/
|
||||
on(event: AgentEvent, handler: AgentEventHandler): void {
|
||||
let set = this.agentHandlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.agentHandlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a previously registered event handler.
|
||||
* A no-op if the handler was never registered.
|
||||
*/
|
||||
off(event: AgentEvent, handler: AgentEventHandler): void {
|
||||
const set = this.agentHandlers.get(event);
|
||||
if (!set) return;
|
||||
set.delete(handler);
|
||||
if (set.size === 0) {
|
||||
this.agentHandlers.delete(event);
|
||||
}
|
||||
this.eventBus.on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -550,216 +459,63 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return a schema object describing the agent's declared configuration.
|
||||
* This is a synchronous introspection method — it does not build the agent
|
||||
* or connect to any external services.
|
||||
* Return a lightweight read-only snapshot of the agent's configured state.
|
||||
* Useful for testing and debugging — does not trigger a build.
|
||||
*/
|
||||
describe(): AgentSchema {
|
||||
// --- Model ---
|
||||
let model: AgentSchema['model'];
|
||||
if (this.modelConfigObj) {
|
||||
model = { provider: null, name: null, raw: 'object' };
|
||||
} else if (this.modelId) {
|
||||
const slashIdx = this.modelId.indexOf('/');
|
||||
get snapshot(): AgentSnapshot {
|
||||
let model: AgentSnapshot['model'];
|
||||
const rawModelId =
|
||||
typeof this.modelConfig === 'string'
|
||||
? this.modelConfig
|
||||
: this.modelConfig && typeof this.modelConfig === 'object' && 'id' in this.modelConfig
|
||||
? this.modelConfig.id
|
||||
: undefined;
|
||||
|
||||
if (rawModelId) {
|
||||
const slashIdx = rawModelId.indexOf('/');
|
||||
if (slashIdx === -1) {
|
||||
model = { provider: null, name: this.modelId };
|
||||
model = { provider: null, name: rawModelId };
|
||||
} else {
|
||||
model = {
|
||||
provider: this.modelId.slice(0, slashIdx),
|
||||
name: this.modelId.slice(slashIdx + 1),
|
||||
provider: rawModelId.slice(0, slashIdx),
|
||||
name: rawModelId.slice(slashIdx + 1),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
model = { provider: null, name: null };
|
||||
}
|
||||
|
||||
// --- Tools (custom / workflow) ---
|
||||
const toolSchemas: ToolSchema[] = this.tools.map((tool) => {
|
||||
const isWorkflow = '__workflowTool' in tool && Boolean(tool.__workflowTool);
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
type: isWorkflow ? ('workflow' as const) : ('custom' as const),
|
||||
editable: !isWorkflow,
|
||||
// Source strings — null, CLI patches with original TypeScript
|
||||
inputSchemaSource: null,
|
||||
outputSchemaSource: null,
|
||||
handlerSource: tool.handler?.toString() ?? null,
|
||||
suspendSchemaSource: null,
|
||||
resumeSchemaSource: null,
|
||||
toMessageSource: null,
|
||||
requireApproval: tool.withDefaultApproval ?? false,
|
||||
needsApprovalFnSource: null,
|
||||
providerOptions: tool.providerOptions ?? null,
|
||||
// Display fields — JSON Schema for UI rendering
|
||||
inputSchema: zodToJsonSchema(tool.inputSchema),
|
||||
outputSchema: zodToJsonSchema(tool.outputSchema),
|
||||
// UI badge indicators — for approval-wrapped tools, hasSuspend/hasResume
|
||||
// reflect the approval mechanism, not user-declared suspend/resume
|
||||
hasSuspend: Boolean(tool.suspendSchema),
|
||||
hasResume: Boolean(tool.resumeSchema),
|
||||
hasToMessage: Boolean(tool.toMessage),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Provider tools ---
|
||||
const providerToolSchemas: ProviderToolSchema[] = this.providerTools.map((pt) => ({
|
||||
name: pt.name,
|
||||
source: '',
|
||||
}));
|
||||
|
||||
// --- Guardrails ---
|
||||
const guardrails: GuardrailSchema[] = [
|
||||
...this.inputGuardrails.map((g) => ({
|
||||
name: g.name,
|
||||
guardType: g.guardType,
|
||||
strategy: g.strategy,
|
||||
position: 'input' as const,
|
||||
config: g._config,
|
||||
source: '',
|
||||
})),
|
||||
...this.outputGuardrails.map((g) => ({
|
||||
name: g.name,
|
||||
guardType: g.guardType,
|
||||
strategy: g.strategy,
|
||||
position: 'output' as const,
|
||||
config: g._config,
|
||||
source: '',
|
||||
})),
|
||||
];
|
||||
|
||||
// --- MCP servers ---
|
||||
let mcp: McpServerSchema[] | null = null;
|
||||
if (this.mcpClients.length > 0) {
|
||||
mcp = [];
|
||||
for (const client of this.mcpClients) {
|
||||
for (const serverName of client.serverNames) {
|
||||
mcp.push({
|
||||
name: serverName,
|
||||
configSource: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Telemetry ---
|
||||
const telemetry = this.telemetryBuilder || this.telemetryConfig ? { source: '' } : null;
|
||||
|
||||
// --- Checkpoint ---
|
||||
const checkpoint = this.checkpointStore === 'memory' ? 'memory' : null;
|
||||
|
||||
// --- Memory ---
|
||||
let memory: MemorySchema | null = null;
|
||||
if (this.memoryConfig) {
|
||||
const mc = this.memoryConfig;
|
||||
let semanticRecall: MemorySchema['semanticRecall'] = null;
|
||||
if (mc.semanticRecall) {
|
||||
semanticRecall = {
|
||||
topK: mc.semanticRecall.topK,
|
||||
messageRange: mc.semanticRecall.messageRange
|
||||
? {
|
||||
before: mc.semanticRecall.messageRange.before,
|
||||
after: mc.semanticRecall.messageRange.after,
|
||||
}
|
||||
: null,
|
||||
embedder: mc.semanticRecall.embedder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
let workingMemory: MemorySchema['workingMemory'] = null;
|
||||
if (mc.workingMemory) {
|
||||
workingMemory = {
|
||||
type: mc.workingMemory.structured ? 'structured' : 'freeform',
|
||||
...(mc.workingMemory.schema
|
||||
? { schema: zodToJsonSchema(mc.workingMemory.schema) ?? undefined }
|
||||
: {}),
|
||||
...(mc.workingMemory.template ? { template: mc.workingMemory.template } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
memory = {
|
||||
// TODO: each BuiltMemory should have describe() method to return a config showing connection params and other metadata
|
||||
// this config must have enough information to rebuild the memory instance
|
||||
source: null,
|
||||
storage: mc.memory instanceof InMemoryMemory ? 'memory' : 'custom',
|
||||
lastMessages: mc.lastMessages ?? null,
|
||||
semanticRecall,
|
||||
workingMemory,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Evaluations ---
|
||||
const evaluations: EvalSchema[] = this.agentEvals.map((e) => ({
|
||||
name: e.name,
|
||||
description: e.description ?? null,
|
||||
type: e.evalType,
|
||||
modelId: e.modelId ?? null,
|
||||
hasCredential: e.credentialName !== null,
|
||||
credentialName: e.credentialName,
|
||||
handlerSource: null,
|
||||
}));
|
||||
|
||||
// --- Structured output ---
|
||||
// TODO: define structured output schema handling better
|
||||
const structuredOutput = {
|
||||
enabled: Boolean(this.outputSchema),
|
||||
schemaSource: null as string | null,
|
||||
};
|
||||
|
||||
// --- Thinking ---
|
||||
let thinking: ThinkingSchema | null = null;
|
||||
if (this.thinkingConfig) {
|
||||
const provider = this.modelId?.split('/')[0];
|
||||
if (provider === 'anthropic') {
|
||||
thinking = {
|
||||
provider: 'anthropic',
|
||||
budgetTokens:
|
||||
'budgetTokens' in this.thinkingConfig
|
||||
? (this.thinkingConfig as { budgetTokens?: number }).budgetTokens
|
||||
: undefined,
|
||||
};
|
||||
} else if (provider === 'openai') {
|
||||
thinking = {
|
||||
provider: 'openai',
|
||||
reasoningEffort:
|
||||
'reasoningEffort' in this.thinkingConfig
|
||||
? String((this.thinkingConfig as { reasoningEffort?: string }).reasoningEffort)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
model,
|
||||
credential: this.credentialName ?? null,
|
||||
instructions: this.instructionsText ?? null,
|
||||
description: null,
|
||||
tools: toolSchemas,
|
||||
providerTools: providerToolSchemas,
|
||||
memory,
|
||||
evaluations,
|
||||
guardrails,
|
||||
mcp,
|
||||
telemetry,
|
||||
checkpoint,
|
||||
config: {
|
||||
structuredOutput,
|
||||
thinking,
|
||||
toolCallConcurrency: this.concurrencyValue ?? null,
|
||||
requireToolApproval: this.requireToolApprovalValue,
|
||||
},
|
||||
tools: this.tools.map((t) => ({ name: t.name, description: t.description })),
|
||||
hasMemory: this.memoryConfig !== undefined,
|
||||
thinking: this.thinkingConfig ?? null,
|
||||
toolCallConcurrency: this.concurrencyValue ?? null,
|
||||
requireToolApproval: this.requireToolApprovalValue,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the latest state snapshot of the agent. Returns `{ status: 'idle' }` before first run. */
|
||||
getState(): SerializableAgentState {
|
||||
if (!this.runtime) {
|
||||
return {
|
||||
persistence: undefined,
|
||||
status: 'idle',
|
||||
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
|
||||
pendingToolCalls: {},
|
||||
};
|
||||
}
|
||||
return this.runtime.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all currently active runs on this agent.
|
||||
* Synchronous — sets an abort flag on every active event bus;
|
||||
* the agentic loop in each run checks it asynchronously.
|
||||
* Cancel the currently running agent.
|
||||
* Synchronous — sets an abort flag; the agentic loop checks it asynchronously.
|
||||
*/
|
||||
abort(): void {
|
||||
for (const bus of this.activeEventBuses) {
|
||||
bus.abort();
|
||||
}
|
||||
this.eventBus.abort();
|
||||
}
|
||||
|
||||
/** Generate a response (non-streaming). Lazy-builds on first call. */
|
||||
|
|
@ -767,13 +523,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
input: AgentMessage[] | string,
|
||||
options?: RunOptions & ExecutionOptions,
|
||||
): Promise<GenerateResult> {
|
||||
const config = await this.ensureBuilt();
|
||||
const { runtime, bus } = this.createRuntime(config);
|
||||
try {
|
||||
return await runtime.generate(this.toMessages(input), options);
|
||||
} finally {
|
||||
this.cleanupBus(bus);
|
||||
}
|
||||
const runtime = await this.ensureBuilt();
|
||||
return await runtime.generate(this.toMessages(input), options);
|
||||
}
|
||||
|
||||
/** Stream a response. Lazy-builds on first call. */
|
||||
|
|
@ -781,15 +532,8 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
input: AgentMessage[] | string,
|
||||
options?: RunOptions & ExecutionOptions,
|
||||
): Promise<StreamResult> {
|
||||
const config = await this.ensureBuilt();
|
||||
const { runtime, bus } = this.createRuntime(config);
|
||||
try {
|
||||
const result = await runtime.stream(this.toMessages(input), options);
|
||||
return { ...result, stream: this.trackStreamBus(result.stream, bus) };
|
||||
} catch (error) {
|
||||
this.cleanupBus(bus);
|
||||
throw error;
|
||||
}
|
||||
const runtime = await this.ensureBuilt();
|
||||
return await runtime.stream(this.toMessages(input), options);
|
||||
}
|
||||
|
||||
/** Resume a suspended tool call with data. Lazy-builds on first call. */
|
||||
|
|
@ -808,23 +552,11 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
data: unknown,
|
||||
options: ResumeOptions & ExecutionOptions,
|
||||
): Promise<GenerateResult | StreamResult> {
|
||||
const config = await this.ensureBuilt();
|
||||
const runtime = await this.ensureBuilt();
|
||||
if (method === 'generate') {
|
||||
const { runtime, bus } = this.createRuntime(config);
|
||||
try {
|
||||
return await runtime.resume('generate', data, options);
|
||||
} finally {
|
||||
this.cleanupBus(bus);
|
||||
}
|
||||
}
|
||||
const { runtime, bus } = this.createRuntime(config);
|
||||
try {
|
||||
const result = await runtime.resume('stream', data, options);
|
||||
return { ...result, stream: this.trackStreamBus(result.stream, bus) };
|
||||
} catch (error) {
|
||||
this.cleanupBus(bus);
|
||||
throw error;
|
||||
return await runtime.resume('generate', data, options);
|
||||
}
|
||||
return await runtime.resume('stream', data, options);
|
||||
}
|
||||
|
||||
approve(method: 'generate', options: ResumeOptions & ExecutionOptions): Promise<GenerateResult>;
|
||||
|
|
@ -856,7 +588,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
* concurrent callers share one build operation. On error the promise is
|
||||
* cleared so the caller can retry.
|
||||
*/
|
||||
private async ensureBuilt(): Promise<AgentRuntimeConfig> {
|
||||
private async ensureBuilt(): Promise<AgentRuntime> {
|
||||
if (!this.buildPromise) {
|
||||
const p = this.build();
|
||||
this.buildPromise = p;
|
||||
|
|
@ -867,90 +599,14 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
return await this.buildPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh AgentRuntime from the shared config, wiring a new event bus
|
||||
* with all registered agent-level handlers copied in. The bus is registered
|
||||
* in activeEventBuses so that abort() can reach it. Callers are responsible
|
||||
* for deregistering the bus when the run finishes.
|
||||
*
|
||||
* AgentRuntime is not supposed to be reused across runs, it gets created and destroyed for each run.
|
||||
*/
|
||||
private createRuntime(config: AgentRuntimeConfig): { runtime: AgentRuntime; bus: AgentEventBus } {
|
||||
const bus = new AgentEventBus();
|
||||
for (const [event, handlers] of this.agentHandlers) {
|
||||
for (const handler of handlers) {
|
||||
bus.on(event, handler);
|
||||
}
|
||||
}
|
||||
this.activeEventBuses.add(bus);
|
||||
const runtime = new AgentRuntime({ ...config, eventBus: bus });
|
||||
return { runtime, bus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a stream so that the bus is deregistered from activeEventBuses
|
||||
* when the stream closes — whether the consumer drains it, cancels it, or
|
||||
* the source errors.
|
||||
*
|
||||
* The bus is cleaned up in all three observable terminal states:
|
||||
* - `pull` reaches `done` (source finished normally)
|
||||
* - `pull` throws (source errored)
|
||||
* - `cancel` is called (consumer explicitly cancelled the stream)
|
||||
*
|
||||
* The one case that cannot be detected without GC hooks is a consumer that
|
||||
* holds a reference to the stream but never reads or cancels it. Callers
|
||||
* should always drain or cancel the returned stream.
|
||||
*/
|
||||
private trackStreamBus(
|
||||
stream: ReadableStream<StreamChunk>,
|
||||
bus: AgentEventBus,
|
||||
): ReadableStream<StreamChunk> {
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (!cleanedUp) {
|
||||
cleanedUp = true;
|
||||
this.cleanupBus(bus);
|
||||
}
|
||||
};
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
return new ReadableStream<StreamChunk>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
cleanup();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel().catch(() => {});
|
||||
cleanup();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupBus(bus: AgentEventBus): void {
|
||||
this.activeEventBuses.delete(bus);
|
||||
bus.dispose();
|
||||
}
|
||||
|
||||
private toMessages(input: string | AgentMessage[]): AgentMessage[] {
|
||||
if (Array.isArray(input)) return input;
|
||||
return [{ role: 'user', content: [{ type: 'text', text: input }] }];
|
||||
}
|
||||
|
||||
/** @internal Validate configuration and produce a shared AgentRuntimeConfig. Overridden by the execution engine. */
|
||||
protected async build(): Promise<AgentRuntimeConfig> {
|
||||
const hasModel = this.modelId ?? this.modelConfigObj;
|
||||
if (!hasModel) {
|
||||
/** @internal Validate configuration and produce an AgentRuntime. Overridden by the execution engine. */
|
||||
protected async build(): Promise<AgentRuntime> {
|
||||
if (!this.modelConfig) {
|
||||
throw new Error(`Agent "${this.name}" requires a model`);
|
||||
}
|
||||
if (!this.instructionsText) {
|
||||
|
|
@ -1019,28 +675,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
);
|
||||
}
|
||||
|
||||
// Resolve credential via provider before building the model config.
|
||||
if (this.credProvider && this.credentialName) {
|
||||
const resolved = await this.credProvider.resolve(this.credentialName);
|
||||
this.resolvedKey = resolved.apiKey;
|
||||
}
|
||||
|
||||
let modelConfig: ModelConfig;
|
||||
if (this.modelConfigObj) {
|
||||
if (
|
||||
this.resolvedKey &&
|
||||
typeof this.modelConfigObj === 'object' &&
|
||||
'id' in this.modelConfigObj
|
||||
) {
|
||||
modelConfig = { ...this.modelConfigObj, apiKey: this.resolvedKey };
|
||||
} else {
|
||||
modelConfig = this.modelConfigObj;
|
||||
}
|
||||
} else if (this.resolvedKey) {
|
||||
modelConfig = { id: this.modelId!, apiKey: this.resolvedKey };
|
||||
} else {
|
||||
modelConfig = this.modelId!;
|
||||
}
|
||||
const modelConfig: ModelConfig = this.modelConfig;
|
||||
|
||||
let instructions = this.instructionsText;
|
||||
if (this.workspaceInstance) {
|
||||
|
|
@ -1050,24 +685,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
// Prefetch model cost once — shared across all per-run runtimes.
|
||||
let modelCost: ModelCost | undefined;
|
||||
try {
|
||||
const modelId =
|
||||
typeof modelConfig === 'string'
|
||||
? modelConfig
|
||||
: 'id' in modelConfig
|
||||
? modelConfig.id
|
||||
: undefined;
|
||||
modelCost = modelId ? await getModelCost(modelId) : undefined;
|
||||
} catch {
|
||||
// Catalog unavailable — proceed without cost data
|
||||
}
|
||||
|
||||
// Shared RunStateManager so resume() can find state from a prior stream()/generate() call.
|
||||
const runState = new RunStateManager(this.checkpointStore);
|
||||
|
||||
return {
|
||||
this.runtime = new AgentRuntime({
|
||||
name: this.name,
|
||||
model: modelConfig,
|
||||
instructions,
|
||||
|
|
@ -1081,11 +699,12 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
structuredOutput: this.outputSchema,
|
||||
checkpointStorage: this.checkpointStore,
|
||||
thinking: this.thinkingConfig,
|
||||
eventBus: this.eventBus,
|
||||
toolCallConcurrency: this.concurrencyValue,
|
||||
titleGeneration: this.memoryConfig?.titleGeneration,
|
||||
telemetry: this.telemetryConfig ?? (await this.telemetryBuilder?.build()),
|
||||
modelCost,
|
||||
runState,
|
||||
};
|
||||
});
|
||||
|
||||
return this.runtime;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { filterLlmMessages } from './message';
|
||||
import { AgentRuntime } from '../runtime/agent-runtime';
|
||||
import type { BuiltEval, CheckFn, EvalInput, EvalScore, JudgeFn, JudgeHandlerFn } from '../types';
|
||||
import type { ModelConfig } from '../types/sdk/agent';
|
||||
import type { AgentMessage } from '../types/sdk/message';
|
||||
|
||||
/** Extract text content from LLM messages (custom messages are skipped). */
|
||||
|
|
@ -54,8 +55,6 @@ export class Eval {
|
|||
|
||||
private credentialName?: string;
|
||||
|
||||
private _resolvedApiKey?: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.evalName = name;
|
||||
}
|
||||
|
|
@ -68,6 +67,7 @@ export class Eval {
|
|||
|
||||
/** Set the judge model (LLM-as-judge mode). */
|
||||
model(modelId: string): this {
|
||||
// TODO: support full model config
|
||||
this.modelId = modelId;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -78,16 +78,6 @@ export class Eval {
|
|||
return this;
|
||||
}
|
||||
|
||||
/** @internal Read the declared credential name (used by the execution engine). */
|
||||
protected get declaredCredential(): string | undefined {
|
||||
return this.credentialName;
|
||||
}
|
||||
|
||||
/** @internal Set the resolved API key for the judge model. */
|
||||
protected set resolvedApiKey(key: string) {
|
||||
this._resolvedApiKey = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a deterministic check function.
|
||||
* Mutually exclusive with `.judge()`.
|
||||
|
|
@ -146,9 +136,10 @@ export class Eval {
|
|||
|
||||
// LLM-as-judge mode
|
||||
const judgeFn = this.judgeFn!;
|
||||
const modelConfig: string | { id: `${string}/${string}`; apiKey: string } = this._resolvedApiKey
|
||||
? { id: this.modelId! as `${string}/${string}`, apiKey: this._resolvedApiKey }
|
||||
: this.modelId!;
|
||||
if (!this.modelId) {
|
||||
throw new Error(`Eval "${this.evalName}" uses .judge() but no .model() was set`);
|
||||
}
|
||||
const modelConfig: ModelConfig = this.modelId;
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
name: `${name}-judge`,
|
||||
|
|
|
|||
|
|
@ -1,364 +0,0 @@
|
|||
import type { JSONSchema7 } from 'json-schema';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import type { BuiltEval, BuiltGuardrail, BuiltTelemetry, BuiltTool } from '../types';
|
||||
import { McpClient } from './mcp-client';
|
||||
import { Memory } from './memory';
|
||||
import { wrapToolForApproval } from './tool';
|
||||
import type { AgentBuilder } from '../types/sdk/agent-builder';
|
||||
import type { CredentialProvider } from '../types/sdk/credential-provider';
|
||||
import type { EvalInput, EvalScore, JudgeInput } from '../types/sdk/eval';
|
||||
import type { HandlerExecutor } from '../types/sdk/handler-executor';
|
||||
import type { McpServerConfig } from '../types/sdk/mcp';
|
||||
import type { AgentMessage } from '../types/sdk/message';
|
||||
import type {
|
||||
AgentSchema,
|
||||
EvalSchema,
|
||||
GuardrailSchema,
|
||||
McpServerSchema,
|
||||
ProviderToolSchema,
|
||||
TelemetrySchema,
|
||||
ToolSchema,
|
||||
} from '../types/sdk/schema';
|
||||
import type { InterruptibleToolContext, ToolContext } from '../types/sdk/tool';
|
||||
import type { JSONObject } from '../types/utils/json';
|
||||
|
||||
export interface FromSchemaOptions {
|
||||
handlerExecutor: HandlerExecutor;
|
||||
credentialProvider?: CredentialProvider;
|
||||
}
|
||||
|
||||
/** Sentinel used to signal that a sandboxed handler called ctx.suspend(). */
|
||||
const SUSPEND_MARKER = Symbol.for('n8n.agent.suspend');
|
||||
|
||||
interface SuspendResult {
|
||||
[key: symbol]: true;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export function isSuspendResult(value: unknown): value is SuspendResult {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as Record<symbol, unknown>)[SUSPEND_MARKER] === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a live Agent from an AgentSchema JSON.
|
||||
*
|
||||
* This is the inverse of `Agent.describe()` — it takes a serialised schema
|
||||
* (produced by `describe()` or stored in the database) and rebuilds a
|
||||
* fully-configured Agent instance with proxy handlers that delegate tool
|
||||
* execution to the provided `HandlerExecutor`.
|
||||
*
|
||||
* All source expressions in the schema (provider tools, MCP configs,
|
||||
* telemetry, structured output, suspend/resume schemas) are evaluated
|
||||
* via `HandlerExecutor.evaluateExpression()` / `evaluateSchema()`.
|
||||
*
|
||||
* The `agent` parameter is the Agent instance to configure (avoids circular import).
|
||||
*/
|
||||
export async function fromSchema(
|
||||
agent: AgentBuilder,
|
||||
schema: AgentSchema,
|
||||
options: FromSchemaOptions,
|
||||
): Promise<void> {
|
||||
const { handlerExecutor } = options;
|
||||
|
||||
applyModel(agent, schema.model);
|
||||
|
||||
if (schema.credential !== null) {
|
||||
agent.credential(schema.credential);
|
||||
}
|
||||
|
||||
if (schema.instructions !== null) {
|
||||
agent.instructions(schema.instructions);
|
||||
}
|
||||
|
||||
await applyTools(agent, schema.tools, handlerExecutor);
|
||||
await applyProviderTools(agent, schema.providerTools, handlerExecutor);
|
||||
applyConfig(agent, schema.config);
|
||||
applyMemory(agent, schema);
|
||||
applyGuardrails(agent, schema.guardrails);
|
||||
applyEvals(agent, schema.evaluations, handlerExecutor);
|
||||
await applyStructuredOutput(agent, schema.config.structuredOutput, handlerExecutor);
|
||||
|
||||
if (options.credentialProvider) {
|
||||
agent.credentialProvider(options.credentialProvider);
|
||||
}
|
||||
|
||||
await applyMcpServers(agent, schema.mcp, handlerExecutor);
|
||||
await applyTelemetry(agent, schema.telemetry, handlerExecutor);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers – each handles one section of the AgentSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyModel(agent: AgentBuilder, model: AgentSchema['model']): void {
|
||||
if (model.provider && model.name) {
|
||||
agent.model(model.provider, model.name);
|
||||
} else if (model.name) {
|
||||
agent.model(model.name);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTools(
|
||||
agent: AgentBuilder,
|
||||
tools: ToolSchema[],
|
||||
executor: HandlerExecutor,
|
||||
): Promise<void> {
|
||||
const addedTools = new Set<string>();
|
||||
for (const ts of tools) {
|
||||
if (addedTools.has(ts.name)) {
|
||||
throw new Error(`Schema has multiple definitions of tool ${ts.name}`);
|
||||
}
|
||||
addedTools.add(ts.name);
|
||||
|
||||
if (!ts.editable) {
|
||||
agent.tool({
|
||||
name: ts.name,
|
||||
description: ts.description,
|
||||
__workflowTool: true,
|
||||
workflowName: ts.name,
|
||||
} as unknown as BuiltTool);
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemas: { suspend?: ZodType; resume?: ZodType } = {};
|
||||
if (ts.suspendSchemaSource) {
|
||||
schemas.suspend = await executor.evaluateSchema(ts.suspendSchemaSource);
|
||||
}
|
||||
if (ts.resumeSchemaSource) {
|
||||
schemas.resume = await executor.evaluateSchema(ts.resumeSchemaSource);
|
||||
}
|
||||
|
||||
const builtTool = buildToolFromSchema(ts, executor, schemas);
|
||||
agent.tool(builtTool);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProviderTools(
|
||||
agent: AgentBuilder,
|
||||
providerTools: ProviderToolSchema[],
|
||||
executor: HandlerExecutor,
|
||||
): Promise<void> {
|
||||
for (const pt of providerTools) {
|
||||
if (pt.source) {
|
||||
const evaluated = (await executor.evaluateExpression(pt.source)) as {
|
||||
name: `${string}.${string}`;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
agent.providerTool({
|
||||
name: evaluated.name,
|
||||
args: evaluated.args ?? {},
|
||||
});
|
||||
} else {
|
||||
agent.providerTool({
|
||||
name: pt.name as `${string}.${string}`,
|
||||
args: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfig(agent: AgentBuilder, config: AgentSchema['config']): void {
|
||||
if (config.thinking !== null) {
|
||||
const { provider, ...thinkingConfig } = config.thinking;
|
||||
agent.thinking(provider, thinkingConfig);
|
||||
}
|
||||
|
||||
if (config.toolCallConcurrency !== null) {
|
||||
agent.toolCallConcurrency(config.toolCallConcurrency);
|
||||
}
|
||||
|
||||
if (config.requireToolApproval) {
|
||||
agent.requireToolApproval();
|
||||
}
|
||||
}
|
||||
|
||||
function applyMemory(agent: AgentBuilder, schema: AgentSchema): void {
|
||||
if (schema.memory !== null) {
|
||||
const memory = new Memory();
|
||||
if (schema.memory.lastMessages !== null) {
|
||||
memory.lastMessages(schema.memory.lastMessages);
|
||||
}
|
||||
agent.memory(memory);
|
||||
}
|
||||
|
||||
if (schema.checkpoint !== null) {
|
||||
agent.checkpoint(schema.checkpoint);
|
||||
}
|
||||
}
|
||||
|
||||
function applyGuardrails(agent: AgentBuilder, guardrails: GuardrailSchema[]): void {
|
||||
for (const g of guardrails) {
|
||||
const builtGuardrail: BuiltGuardrail = {
|
||||
name: g.name,
|
||||
guardType: g.guardType,
|
||||
strategy: g.strategy,
|
||||
_config: g.config,
|
||||
};
|
||||
if (g.position === 'input') {
|
||||
agent.inputGuardrail(builtGuardrail);
|
||||
} else {
|
||||
agent.outputGuardrail(builtGuardrail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyEvals(
|
||||
agent: AgentBuilder,
|
||||
evaluations: EvalSchema[],
|
||||
executor: HandlerExecutor,
|
||||
): void {
|
||||
for (const evalSchema of evaluations) {
|
||||
const builtEval = buildEvalFromSchema(evalSchema, executor);
|
||||
agent.eval(builtEval);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyStructuredOutput(
|
||||
agent: AgentBuilder,
|
||||
structuredOutput: AgentSchema['config']['structuredOutput'],
|
||||
executor: HandlerExecutor,
|
||||
): Promise<void> {
|
||||
if (structuredOutput.enabled && structuredOutput.schemaSource) {
|
||||
const outputSchema = await executor.evaluateSchema(structuredOutput.schemaSource);
|
||||
agent.structuredOutput(outputSchema);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMcpServers(
|
||||
agent: AgentBuilder,
|
||||
mcp: McpServerSchema[] | null,
|
||||
executor: HandlerExecutor,
|
||||
): Promise<void> {
|
||||
if (!mcp || mcp.length === 0) return;
|
||||
|
||||
const mcpConfigs: McpServerConfig[] = [];
|
||||
for (const m of mcp) {
|
||||
if (m.configSource) {
|
||||
const config = (await executor.evaluateExpression(m.configSource)) as McpServerConfig;
|
||||
mcpConfigs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
if (mcpConfigs.length > 0) {
|
||||
agent.mcp(new McpClient(mcpConfigs));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTelemetry(
|
||||
agent: AgentBuilder,
|
||||
telemetry: TelemetrySchema | null,
|
||||
executor: HandlerExecutor,
|
||||
): Promise<void> {
|
||||
if (telemetry?.source) {
|
||||
const built = (await executor.evaluateExpression(telemetry.source)) as BuiltTelemetry;
|
||||
agent.telemetry(built);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool & Eval builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a `BuiltTool` from a `ToolSchema` with a proxy handler that
|
||||
* delegates execution to the `HandlerExecutor`.
|
||||
*
|
||||
* For interruptible tools (hasSuspend), the proxy handles ctx.suspend at
|
||||
* the host level: the sandbox receives a stub suspend that records the
|
||||
* payload, and the proxy calls the real ctx.suspend on the host.
|
||||
*/
|
||||
function buildToolFromSchema(
|
||||
toolSchema: ToolSchema,
|
||||
executor: HandlerExecutor,
|
||||
preEvaluated?: { suspend?: ZodType; resume?: ZodType },
|
||||
): BuiltTool {
|
||||
const handler = async (
|
||||
input: unknown,
|
||||
ctx: ToolContext | InterruptibleToolContext,
|
||||
): Promise<unknown> => {
|
||||
if (toolSchema.hasSuspend && 'suspend' in ctx) {
|
||||
// Interruptible tool: the real ctx.suspend is a host-side function.
|
||||
// We pass serialisable ctx data into the sandbox, and the sandbox
|
||||
// returns a marker if suspend was called. Then we call the real
|
||||
// ctx.suspend on the host.
|
||||
const interruptCtx = ctx;
|
||||
const result = await executor.executeTool(toolSchema.name, input, {
|
||||
resumeData: interruptCtx.resumeData,
|
||||
parentTelemetry: ctx.parentTelemetry,
|
||||
});
|
||||
|
||||
if (isSuspendResult(result)) {
|
||||
return await interruptCtx.suspend(result.payload);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Non-interruptible tool: pass ctx through directly (only serialisable
|
||||
// fields like parentTelemetry).
|
||||
return await executor.executeTool(toolSchema.name, input, {
|
||||
parentTelemetry: ctx.parentTelemetry,
|
||||
});
|
||||
};
|
||||
|
||||
// toMessage: The runtime calls toMessage synchronously (agent-runtime.ts).
|
||||
// When the executor provides a sync variant (executeToMessageSync), use it
|
||||
// directly for an immediate result. Otherwise fall back to async with a
|
||||
// stale-cache workaround.
|
||||
let toMessage: ((output: unknown) => AgentMessage | undefined) | undefined;
|
||||
if (toolSchema.hasToMessage) {
|
||||
if (executor.executeToMessageSync) {
|
||||
const syncExecutor = executor.executeToMessageSync.bind(executor);
|
||||
toMessage = (output: unknown): AgentMessage | undefined => {
|
||||
return syncExecutor(toolSchema.name, output);
|
||||
};
|
||||
} else {
|
||||
throw new Error('Executor does not support executeToMessageSync');
|
||||
}
|
||||
}
|
||||
|
||||
const built: BuiltTool = {
|
||||
name: toolSchema.name,
|
||||
description: toolSchema.description,
|
||||
inputSchema: (toolSchema.inputSchema as JSONSchema7) ?? undefined,
|
||||
handler,
|
||||
toMessage,
|
||||
suspendSchema: preEvaluated?.suspend,
|
||||
resumeSchema: preEvaluated?.resume,
|
||||
providerOptions: toolSchema.providerOptions
|
||||
? (toolSchema.providerOptions as Record<string, JSONObject>)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// If the tool requires approval, wrap it with the approval gate.
|
||||
// This re-applies the same wrapping that Tool.build() does at define time.
|
||||
if (toolSchema.requireApproval) {
|
||||
return wrapToolForApproval(built, { requireApproval: true });
|
||||
}
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `BuiltEval` from an `EvalSchema` with a proxy _run function
|
||||
* that delegates execution to the `HandlerExecutor`.
|
||||
*/
|
||||
function buildEvalFromSchema(evalSchema: EvalSchema, executor: HandlerExecutor): BuiltEval {
|
||||
return {
|
||||
name: evalSchema.name,
|
||||
description: evalSchema.description ?? undefined,
|
||||
evalType: evalSchema.type,
|
||||
modelId: evalSchema.modelId ?? null,
|
||||
credentialName: evalSchema.credentialName ?? null,
|
||||
_run: async (evalInput: EvalInput): Promise<EvalScore> => {
|
||||
// For judge evals, the llm function is bound inside the module
|
||||
// when the full module runs in the sandbox. The executor passes
|
||||
// the input to _run() which already has llm in its closure.
|
||||
return await executor.executeEval(evalSchema.name, evalInput as EvalInput | JudgeInput);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -29,7 +29,9 @@ export const providerCapabilities: Record<
|
|||
groq: {},
|
||||
deepseek: {},
|
||||
mistral: {},
|
||||
openrouter: {},
|
||||
cohere: {},
|
||||
ollama: {},
|
||||
vercel: {},
|
||||
openrouter: {},
|
||||
'azure-openai': {},
|
||||
'aws-bedrock': {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { BuiltProviderTool } from '../types';
|
||||
|
||||
interface WebSearchConfig {
|
||||
interface AnthropicWebSearchConfig {
|
||||
maxUses?: number;
|
||||
allowedDomains?: string[];
|
||||
blockedDomains?: string[];
|
||||
|
|
@ -13,6 +13,30 @@ interface WebSearchConfig {
|
|||
};
|
||||
}
|
||||
|
||||
interface OpenAIWebSearchConfig {
|
||||
/**
|
||||
* When set to `true`, lets the model fetch page content from the open web.
|
||||
* Defaults to OpenAI's own defaults when omitted.
|
||||
*/
|
||||
externalWebAccess?: boolean;
|
||||
/** Restrict results to the given domains (allow-list). */
|
||||
filters?: {
|
||||
allowedDomains?: string[];
|
||||
};
|
||||
/**
|
||||
* How much surrounding page content to include per result. Trades off
|
||||
* token cost against answer quality. Defaults to OpenAI's own default.
|
||||
*/
|
||||
searchContextSize?: 'low' | 'medium' | 'high';
|
||||
userLocation?: {
|
||||
type: 'approximate';
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating provider-defined tools.
|
||||
*
|
||||
|
|
@ -36,7 +60,7 @@ export const providerTools = {
|
|||
* .build();
|
||||
* ```
|
||||
*/
|
||||
anthropicWebSearch(config?: WebSearchConfig): BuiltProviderTool {
|
||||
anthropicWebSearch(config?: AnthropicWebSearchConfig): BuiltProviderTool {
|
||||
const args: Record<string, unknown> = {};
|
||||
|
||||
if (config?.maxUses !== undefined) {
|
||||
|
|
@ -53,11 +77,57 @@ export const providerTools = {
|
|||
}
|
||||
|
||||
return {
|
||||
// Intentionally on the pre-dynamic-filtering version. The newer
|
||||
// `web_search_20260209` forces a server-side code-execution pipeline
|
||||
// (the AI SDK auto-adds the `code-execution-web-tools-2026-02-09`
|
||||
// beta) which is slower, emits code_execution tool results on every
|
||||
// search, and is only officially supported on Sonnet 4.6 / Opus 4.6
|
||||
// / Opus 4.7. The 20250305 version is fast, stable, and works
|
||||
// across all Claude models that support web search.
|
||||
// See https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
|
||||
name: 'anthropic.web_search_20250305',
|
||||
args,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* OpenAI's web search tool — available via the Responses API. Gives the
|
||||
* model access to real-time web content with automatic citations.
|
||||
*
|
||||
* Only works on models that the Responses API supports (e.g. GPT-4o
|
||||
* and successors). Older chat-completions-only models will reject it.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new Agent('researcher')
|
||||
* .model('openai/gpt-4o')
|
||||
* .instructions('Research topics using web search.')
|
||||
* .providerTool(providerTools.openaiWebSearch({ searchContextSize: 'medium' }))
|
||||
* .build();
|
||||
* ```
|
||||
*/
|
||||
openaiWebSearch(config?: OpenAIWebSearchConfig): BuiltProviderTool {
|
||||
const args: Record<string, unknown> = {};
|
||||
|
||||
if (config?.externalWebAccess !== undefined) {
|
||||
args.externalWebAccess = config.externalWebAccess;
|
||||
}
|
||||
if (config?.filters) {
|
||||
args.filters = config.filters;
|
||||
}
|
||||
if (config?.searchContextSize) {
|
||||
args.searchContextSize = config.searchContextSize;
|
||||
}
|
||||
if (config?.userLocation) {
|
||||
args.userLocation = config.userLocation;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'openai.web_search',
|
||||
args,
|
||||
};
|
||||
},
|
||||
|
||||
openaiImageGeneration(): BuiltProviderTool {
|
||||
return {
|
||||
name: 'openai.image_generation',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../types';
|
||||
import type { AgentMessage } from '../types/sdk/message';
|
||||
import type { ToolDescriptor } from '../types/sdk/tool-descriptor';
|
||||
import type { JSONObject } from '../types/utils/json';
|
||||
import { isZodSchema, zodToJsonSchema } from '../utils/zod';
|
||||
|
||||
const APPROVAL_SUSPEND_SCHEMA = z.object({
|
||||
type: z.literal('approval'),
|
||||
|
|
@ -14,6 +17,10 @@ const APPROVAL_RESUME_SCHEMA = z.object({
|
|||
approved: z.boolean(),
|
||||
});
|
||||
|
||||
type ZodOrJsonSchema = z.ZodType | JSONSchema7;
|
||||
|
||||
type OutputType<TOutput> = TOutput extends z.ZodType ? z.infer<TOutput> : unknown;
|
||||
|
||||
export interface ApprovalConfig {
|
||||
requireApproval?: boolean;
|
||||
needsApprovalFn?: (args: unknown) => Promise<boolean> | boolean;
|
||||
|
|
@ -65,8 +72,8 @@ export function wrapToolForApproval(tool: BuiltTool, config: ApprovalConfig): Bu
|
|||
};
|
||||
}
|
||||
|
||||
type HandlerContext<S, R> = S extends z.ZodTypeAny
|
||||
? R extends z.ZodTypeAny
|
||||
type HandlerContext<S, R> = S extends z.ZodType
|
||||
? R extends z.ZodType
|
||||
? InterruptibleToolContext<z.infer<S>, z.infer<R>>
|
||||
: ToolContext
|
||||
: ToolContext;
|
||||
|
|
@ -90,10 +97,10 @@ type HandlerContext<S, R> = S extends z.ZodTypeAny
|
|||
* @template TResume - Zod schema type for the resume payload
|
||||
*/
|
||||
export class Tool<
|
||||
TInput extends z.ZodTypeAny = z.ZodTypeAny,
|
||||
TOutput extends z.ZodTypeAny = z.ZodTypeAny,
|
||||
TSuspend extends z.ZodTypeAny | undefined = undefined,
|
||||
TResume extends z.ZodTypeAny | undefined = undefined,
|
||||
TInput extends ZodOrJsonSchema = z.ZodTypeAny,
|
||||
TOutput extends ZodOrJsonSchema = z.ZodTypeAny,
|
||||
TSuspend extends ZodOrJsonSchema | undefined = undefined,
|
||||
TResume extends ZodOrJsonSchema | undefined = undefined,
|
||||
> {
|
||||
private name: string;
|
||||
|
||||
|
|
@ -103,18 +110,18 @@ export class Tool<
|
|||
|
||||
private outputSchema?: TOutput;
|
||||
|
||||
private suspendSchemaValue?: z.ZodTypeAny;
|
||||
private suspendSchemaValue?: TSuspend;
|
||||
|
||||
private resumeSchemaValue?: z.ZodTypeAny;
|
||||
private resumeSchemaValue?: TResume;
|
||||
|
||||
private handlerFn?: (
|
||||
input: z.infer<TInput>,
|
||||
input: OutputType<TInput>,
|
||||
ctx: HandlerContext<TSuspend, TResume>,
|
||||
) => Promise<z.infer<TOutput>>;
|
||||
) => Promise<OutputType<TOutput>>;
|
||||
|
||||
private toMessageFn?: (output: z.infer<TOutput>) => AgentMessage;
|
||||
private toMessageFn?: (output: OutputType<TOutput>) => AgentMessage;
|
||||
|
||||
private toModelOutputFn?: (output: z.infer<TOutput>) => unknown;
|
||||
private toModelOutputFn?: (output: OutputType<TOutput>) => unknown;
|
||||
|
||||
private providerOptionsValue?: Record<string, JSONObject>;
|
||||
|
||||
|
|
@ -122,6 +129,8 @@ export class Tool<
|
|||
|
||||
private needsApprovalFnValue?: (args: unknown) => Promise<boolean> | boolean;
|
||||
|
||||
private systemInstructionText?: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
|
@ -132,29 +141,43 @@ export class Tool<
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a behavioural directive to this tool. When the tool is registered
|
||||
* with an agent, the runtime injects this text into the agent's system
|
||||
* prompt under a `<built_in_rules>` block, where the LLM weighs it heavily
|
||||
* for "should I call this tool?" decisions.
|
||||
*
|
||||
* Use sparingly — only for guidance the description alone doesn't reliably
|
||||
* convey (e.g. "prefer this tool over plain text when X").
|
||||
*/
|
||||
systemInstruction(text: string): this {
|
||||
this.systemInstructionText = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the input Zod schema. Required before building. */
|
||||
input<S extends z.ZodTypeAny>(schema: S): Tool<S, TOutput, TSuspend, TResume> {
|
||||
input<S extends ZodOrJsonSchema>(schema: S): Tool<S, TOutput, TSuspend, TResume> {
|
||||
const self = this as unknown as Tool<S, TOutput, TSuspend, TResume>;
|
||||
self.inputSchema = schema;
|
||||
return self;
|
||||
}
|
||||
|
||||
/** Set the output Zod schema. Optional. */
|
||||
output<S extends z.ZodTypeAny>(schema: S): Tool<TInput, S, TSuspend, TResume> {
|
||||
output<S extends ZodOrJsonSchema>(schema: S): Tool<TInput, S, TSuspend, TResume> {
|
||||
const self = this as unknown as Tool<TInput, S, TSuspend, TResume>;
|
||||
self.outputSchema = schema;
|
||||
return self;
|
||||
}
|
||||
|
||||
/** Set the suspend payload schema. Must be paired with .resume(). */
|
||||
suspend<S extends z.ZodTypeAny>(schema: S): Tool<TInput, TOutput, S, TResume> {
|
||||
suspend<S extends ZodOrJsonSchema>(schema: S): Tool<TInput, TOutput, S, TResume> {
|
||||
const self = this as unknown as Tool<TInput, TOutput, S, TResume>;
|
||||
self.suspendSchemaValue = schema;
|
||||
return self;
|
||||
}
|
||||
|
||||
/** Set the resume payload schema. Must be paired with .suspend(). */
|
||||
resume<R extends z.ZodTypeAny>(schema: R): Tool<TInput, TOutput, TSuspend, R> {
|
||||
resume<R extends ZodOrJsonSchema>(schema: R): Tool<TInput, TOutput, TSuspend, R> {
|
||||
const self = this as unknown as Tool<TInput, TOutput, TSuspend, R>;
|
||||
self.resumeSchemaValue = schema;
|
||||
return self;
|
||||
|
|
@ -166,15 +189,15 @@ export class Tool<
|
|||
*/
|
||||
handler(
|
||||
fn: (
|
||||
input: z.infer<TInput>,
|
||||
input: OutputType<TInput>,
|
||||
ctx: HandlerContext<TSuspend, TResume>,
|
||||
) => Promise<z.infer<TOutput>>,
|
||||
) => Promise<OutputType<TOutput>>,
|
||||
): this {
|
||||
this.handlerFn = fn;
|
||||
return this;
|
||||
}
|
||||
|
||||
toMessage(toMessage: (output: z.infer<TOutput>) => AgentMessage): this {
|
||||
toMessage(toMessage: (output: OutputType<TOutput>) => AgentMessage): this {
|
||||
this.toMessageFn = toMessage;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -186,7 +209,7 @@ export class Tool<
|
|||
* Useful for truncating large outputs, redacting sensitive data, or reformatting
|
||||
* the result for better LLM comprehension.
|
||||
*/
|
||||
toModelOutput(fn: (output: z.infer<TOutput>) => unknown): this {
|
||||
toModelOutput(fn: (output: OutputType<TOutput>) => unknown): this {
|
||||
this.toModelOutputFn = fn;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -198,7 +221,7 @@ export class Tool<
|
|||
}
|
||||
|
||||
/** Conditionally require approval based on the tool's input. Mutually exclusive with .suspend()/.resume(). */
|
||||
needsApprovalFn(fn: (args: z.infer<TInput>) => Promise<boolean> | boolean): this {
|
||||
needsApprovalFn(fn: (args: OutputType<TInput>) => Promise<boolean> | boolean): this {
|
||||
this.needsApprovalFnValue = fn as (args: unknown) => Promise<boolean> | boolean;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -255,6 +278,7 @@ export class Tool<
|
|||
const built: BuiltTool = {
|
||||
name: this.name,
|
||||
description: this.desc,
|
||||
systemInstruction: this.systemInstructionText,
|
||||
suspendSchema: this.suspendSchemaValue,
|
||||
resumeSchema: this.resumeSchemaValue,
|
||||
toMessage: this.toMessageFn as (output: unknown) => AgentMessage | undefined,
|
||||
|
|
@ -277,4 +301,36 @@ export class Tool<
|
|||
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a lightweight JSON descriptor of this tool's metadata.
|
||||
* Does NOT require .build() to be called first.
|
||||
* Used by the JSON-config flow to store tool metadata without executing the handler.
|
||||
*/
|
||||
describe(): ToolDescriptor {
|
||||
if (!this.name) throw new Error('Tool name is required');
|
||||
if (!this.desc) throw new Error(`Tool "${this.name}" requires a description`);
|
||||
if (!this.inputSchema) throw new Error(`Tool "${this.name}" requires an input schema`);
|
||||
|
||||
const inputSchema = isZodSchema(this.inputSchema)
|
||||
? zodToJsonSchema(this.inputSchema)
|
||||
: this.inputSchema;
|
||||
const outputSchema = this.outputSchema
|
||||
? isZodSchema(this.outputSchema)
|
||||
? zodToJsonSchema(this.outputSchema)
|
||||
: this.outputSchema
|
||||
: null;
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.desc,
|
||||
systemInstruction: this.systemInstructionText ?? null,
|
||||
inputSchema: inputSchema as JSONSchema7,
|
||||
outputSchema: outputSchema as JSONSchema7,
|
||||
hasSuspend: this.suspendSchemaValue !== undefined,
|
||||
hasResume: this.resumeSchemaValue !== undefined,
|
||||
hasToMessage: this.toMessageFn !== undefined,
|
||||
requireApproval: this.requireApprovalValue ?? false,
|
||||
providerOptions: this.providerOptionsValue ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,15 +45,7 @@ export function verify(source: string): VerifyResult {
|
|||
}
|
||||
|
||||
if (/process\.env\b/.test(source)) {
|
||||
errors.push(
|
||||
'process.env is not available. Use .credential() for API keys, or const variables for configuration.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!/\.credential\s*\(/.test(source)) {
|
||||
errors.push(
|
||||
"No .credential() found. Every agent must declare a credential (e.g. .credential('anthropic')).",
|
||||
);
|
||||
errors.push('process.env is not available. Use const variables for configuration.');
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
|
|
|
|||
93
packages/@n8n/agents/src/storage/base-memory.ts
Normal file
93
packages/@n8n/agents/src/storage/base-memory.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/* eslint-disable @typescript-eslint/promise-function-async */
|
||||
import type { BuiltMemory, MemoryDescriptor, Thread } from '../types/sdk/memory';
|
||||
import type { AgentDbMessage } from '../types/sdk/message';
|
||||
import type { JSONObject } from '../types/utils/json';
|
||||
|
||||
export abstract class BaseMemory<TConstructorOptions extends JSONObject = JSONObject>
|
||||
implements BuiltMemory
|
||||
{
|
||||
constructor(
|
||||
protected readonly name: string,
|
||||
protected readonly constructorOptions: TConstructorOptions,
|
||||
) {}
|
||||
|
||||
getThread(_threadId: string): Promise<Thread | null> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
saveThread(_thread: Omit<Thread, 'createdAt' | 'updatedAt'>): Promise<Thread> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
deleteThread(_threadId: string): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getMessages(
|
||||
_threadId: string,
|
||||
_opts?: { limit?: number; before?: Date },
|
||||
): Promise<AgentDbMessage[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
saveMessages(_args: {
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
messages: AgentDbMessage[];
|
||||
}): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
deleteMessages(_messageIds: string[]): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
search?(
|
||||
_query: string,
|
||||
_opts?: {
|
||||
scope?: 'thread' | 'resource';
|
||||
threadId?: string;
|
||||
resourceId?: string;
|
||||
topK?: number;
|
||||
messageRange?: { before: number; after: number };
|
||||
},
|
||||
): Promise<AgentDbMessage[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getWorkingMemory?(_params: {
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
scope: 'resource' | 'thread';
|
||||
}): Promise<string | null> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
saveWorkingMemory?(
|
||||
_params: { threadId: string; resourceId: string; scope: 'resource' | 'thread' },
|
||||
_content: string,
|
||||
): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
saveEmbeddings?(_opts: {
|
||||
scope?: 'thread' | 'resource';
|
||||
threadId?: string;
|
||||
resourceId?: string;
|
||||
entries: Array<{ id: string; vector: number[]; text: string; model: string }>;
|
||||
}): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
queryEmbeddings?(_opts: {
|
||||
scope?: 'thread' | 'resource';
|
||||
threadId?: string;
|
||||
resourceId?: string;
|
||||
vector: number[];
|
||||
topK: number;
|
||||
}): Promise<Array<{ id: string; score: number }>> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
close?(): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
describe(): MemoryDescriptor<TConstructorOptions> {
|
||||
return {
|
||||
name: this.name,
|
||||
constructorName: this.constructor.name,
|
||||
connectionParams: this.constructorOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { ConnectionOptions } from 'tls';
|
||||
|
||||
import type { BuiltMemory, Thread } from '../types/sdk/memory';
|
||||
import { BaseMemory } from './base-memory';
|
||||
import type { Thread } from '../types/sdk/memory';
|
||||
import type { AgentDbMessage, AgentMessage } from '../types/sdk/message';
|
||||
|
||||
interface ThreadRow {
|
||||
|
|
@ -40,24 +40,24 @@ function parseJsonSafe(text: string): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
export interface PostgresConnectionConfig {
|
||||
/** Postgres host. Defaults to 'localhost'. */
|
||||
host?: string;
|
||||
/** Postgres port. Defaults to 5432. */
|
||||
port?: number;
|
||||
/** Database name. */
|
||||
database?: string;
|
||||
/** Database user. */
|
||||
user?: string;
|
||||
/** Database password. */
|
||||
password?: string | (() => string | Promise<string>);
|
||||
}
|
||||
|
||||
export interface PostgresMemoryConfig {
|
||||
// --- Connection ---
|
||||
/** Connection URL string (e.g. 'postgresql://user:pass@localhost:5432/db') or individual connection parameters. */
|
||||
connection: string | PostgresConnectionConfig;
|
||||
export type PostgresConnectionOptions =
|
||||
| { connectionType: 'url'; connection: { url: string } }
|
||||
| {
|
||||
connectionType: 'config';
|
||||
connection: {
|
||||
/** Postgres host. Defaults to 'localhost'. */ host?: string;
|
||||
/** Postgres port. Defaults to 5432. */
|
||||
port?: number;
|
||||
/** Database name. */
|
||||
database?: string;
|
||||
/** Database user. */
|
||||
user?: string;
|
||||
/** Database password. Always credential-backed — never a raw string. */
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PostgresMemoryOptions = {
|
||||
// --- Pool settings ---
|
||||
/** Connection pool configuration. */
|
||||
pool?: {
|
||||
|
|
@ -75,32 +75,47 @@ export interface PostgresMemoryConfig {
|
|||
|
||||
// --- Security ---
|
||||
/** SSL configuration. `true` for default SSL, or a TLS ConnectionOptions object. */
|
||||
ssl?: boolean | ConnectionOptions;
|
||||
ssl?: boolean;
|
||||
|
||||
// --- SDK options ---
|
||||
/** Table name prefix for multi-tenant isolation. Alphanumeric and underscores only. */
|
||||
namespace?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export class PostgresMemory implements BuiltMemory {
|
||||
export type PostgresConstructorOptions = (
|
||||
| {
|
||||
type: 'credential';
|
||||
credential: string;
|
||||
}
|
||||
| {
|
||||
type: 'connection';
|
||||
connection: PostgresConnectionOptions;
|
||||
}
|
||||
) & {
|
||||
options?: PostgresMemoryOptions;
|
||||
};
|
||||
|
||||
export class PostgresMemory extends BaseMemory<PostgresConstructorOptions> {
|
||||
private initPromise: Promise<Pool> | null = null;
|
||||
|
||||
private embeddingsInitPromise: Promise<void> | null = null;
|
||||
|
||||
private readonly config: PostgresMemoryConfig;
|
||||
|
||||
private readonly ns: string;
|
||||
|
||||
constructor(config: PostgresMemoryConfig) {
|
||||
if (config.namespace !== undefined) {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(config.namespace)) {
|
||||
constructor(
|
||||
protected readonly constructorOptions: PostgresConstructorOptions,
|
||||
private readonly resolveConfig?: (credential: string) => Promise<PostgresConnectionOptions>,
|
||||
) {
|
||||
super('postgres', constructorOptions);
|
||||
const namespace = constructorOptions.options?.namespace;
|
||||
if (namespace !== undefined) {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(namespace)) {
|
||||
throw new Error(
|
||||
`Invalid namespace "${config.namespace}": must be alphanumeric and underscores only`,
|
||||
`Invalid namespace "${namespace}": must be alphanumeric and underscores only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.config = config;
|
||||
this.ns = config.namespace ? `${config.namespace}_` : '';
|
||||
this.ns = namespace ? `${namespace}_` : '';
|
||||
}
|
||||
|
||||
// ── Lazy initialisation ──────────────────────────────────────────────
|
||||
|
|
@ -115,34 +130,53 @@ export class PostgresMemory implements BuiltMemory {
|
|||
|
||||
private async _initialize(): Promise<Pool> {
|
||||
const { Pool: PgPool } = await import('pg');
|
||||
const conn = this.config.connection;
|
||||
const connectionOpts =
|
||||
typeof conn === 'string'
|
||||
? { connectionString: conn }
|
||||
: {
|
||||
...(conn.host && { host: conn.host }),
|
||||
...(conn.port && { port: conn.port }),
|
||||
...(conn.database && { database: conn.database }),
|
||||
...(conn.user && { user: conn.user }),
|
||||
...(conn.password !== undefined && { password: conn.password }),
|
||||
};
|
||||
let connectionOpts: Record<string, unknown>;
|
||||
|
||||
if (this.constructorOptions.type === 'credential' && !this.resolveConfig) {
|
||||
throw new Error('resolveConfig() was not provided in constructor options');
|
||||
}
|
||||
|
||||
const config =
|
||||
this.constructorOptions.type === 'credential'
|
||||
? await this.resolveConfig!(this.constructorOptions.credential)
|
||||
: this.constructorOptions.connection;
|
||||
|
||||
if (config.connectionType === 'url') {
|
||||
const url = config.connection.url;
|
||||
connectionOpts = { connectionString: url };
|
||||
} else {
|
||||
const cfg = config.connection;
|
||||
const host = cfg.host;
|
||||
const port = cfg.port;
|
||||
const database = cfg.database;
|
||||
const user = cfg.user;
|
||||
const password = cfg.password;
|
||||
connectionOpts = {
|
||||
...(host !== undefined && { host }),
|
||||
...(port !== undefined && { port }),
|
||||
...(database !== undefined && { database }),
|
||||
...(user !== undefined && { user }),
|
||||
...(password !== undefined && { password }),
|
||||
};
|
||||
}
|
||||
|
||||
const opts = this.constructorOptions.options;
|
||||
const pool = new PgPool({
|
||||
...connectionOpts,
|
||||
// Pool
|
||||
...(this.config.pool?.max !== undefined && { max: this.config.pool.max }),
|
||||
...(this.config.pool?.min !== undefined && { min: this.config.pool.min }),
|
||||
...(this.config.pool?.idleTimeoutMillis !== undefined && {
|
||||
idleTimeoutMillis: this.config.pool.idleTimeoutMillis,
|
||||
...(opts?.pool?.max !== undefined && { max: opts.pool.max }),
|
||||
...(opts?.pool?.min !== undefined && { min: opts.pool.min }),
|
||||
...(opts?.pool?.idleTimeoutMillis !== undefined && {
|
||||
idleTimeoutMillis: opts.pool.idleTimeoutMillis,
|
||||
}),
|
||||
...(this.config.pool?.connectionTimeoutMillis !== undefined && {
|
||||
connectionTimeoutMillis: this.config.pool.connectionTimeoutMillis,
|
||||
...(opts?.pool?.connectionTimeoutMillis !== undefined && {
|
||||
connectionTimeoutMillis: opts.pool.connectionTimeoutMillis,
|
||||
}),
|
||||
...(this.config.pool?.allowExitOnIdle !== undefined && {
|
||||
allowExitOnIdle: this.config.pool.allowExitOnIdle,
|
||||
...(opts?.pool?.allowExitOnIdle !== undefined && {
|
||||
allowExitOnIdle: opts.pool.allowExitOnIdle,
|
||||
}),
|
||||
// Security
|
||||
...(this.config.ssl !== undefined && { ssl: this.config.ssl }),
|
||||
...(opts?.ssl !== undefined && { ssl: opts.ssl }),
|
||||
});
|
||||
|
||||
await pool.query(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { Client, InArgs } from '@libsql/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuiltMemory, Thread } from '../types/sdk/memory';
|
||||
import { BaseMemory } from './base-memory';
|
||||
import type { Thread } from '../types/sdk/memory';
|
||||
import type { AgentDbMessage } from '../types/sdk/message';
|
||||
|
||||
/** Safe JSON.parse wrapper — returns undefined on failure. */
|
||||
|
|
@ -18,12 +20,19 @@ function float32ToBuffer(arr: number[]): Buffer {
|
|||
return Buffer.from(f32.buffer);
|
||||
}
|
||||
|
||||
export interface SqliteMemoryConfig {
|
||||
url: string; // e.g. 'file:./data.db'
|
||||
namespace?: string; // table name prefix
|
||||
}
|
||||
export const SqliteMemoryConfigSchema = z.object({
|
||||
/** libsql connection URL. Use `'file:./path/to/db.sqlite'` for a local file. */
|
||||
url: z.string().min(1),
|
||||
/** Optional table name prefix for multi-tenant isolation. Alphanumeric and underscores only. */
|
||||
namespace: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_]+$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export class SqliteMemory implements BuiltMemory {
|
||||
export type SqliteMemoryConfig = z.infer<typeof SqliteMemoryConfigSchema>;
|
||||
|
||||
export class SqliteMemory extends BaseMemory<SqliteMemoryConfig> {
|
||||
private initPromise: Promise<Client> | null = null;
|
||||
|
||||
private embeddingsInitPromise: Promise<void> | null = null;
|
||||
|
|
@ -32,16 +41,10 @@ export class SqliteMemory implements BuiltMemory {
|
|||
|
||||
private readonly ns: string;
|
||||
|
||||
constructor(config: SqliteMemoryConfig) {
|
||||
if (config.namespace !== undefined) {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(config.namespace)) {
|
||||
throw new Error(
|
||||
`Invalid namespace "${config.namespace}": must be alphanumeric and underscores only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.config = config;
|
||||
this.ns = config.namespace ? `${config.namespace}_` : '';
|
||||
constructor(protected readonly constructorOptions: SqliteMemoryConfig) {
|
||||
super('sqlite', constructorOptions);
|
||||
this.config = SqliteMemoryConfigSchema.parse(constructorOptions);
|
||||
this.ns = constructorOptions.namespace ? `${constructorOptions.namespace}_` : '';
|
||||
}
|
||||
|
||||
// ── Lazy initialisation ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export type {
|
|||
ContentReasoning,
|
||||
ContentFile,
|
||||
ContentToolCall,
|
||||
ContentToolResult,
|
||||
ContentInvalidToolCall,
|
||||
ContentProvider,
|
||||
Message,
|
||||
|
|
@ -61,6 +60,7 @@ export type {
|
|||
export type {
|
||||
Thread,
|
||||
BuiltMemory,
|
||||
MemoryDescriptor,
|
||||
SemanticRecallConfig,
|
||||
MemoryConfig,
|
||||
CheckpointStore,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { AgentPersistenceOptions } from '../sdk/agent';
|
||||
import type { AgentMessage, ContentToolResult } from '../sdk/message';
|
||||
import type { AgentMessage, ContentToolCall } from '../sdk/message';
|
||||
|
||||
export const enum AgentEvent {
|
||||
AgentStart = 'agent_start',
|
||||
|
|
@ -11,16 +10,11 @@ export const enum AgentEvent {
|
|||
Error = 'error',
|
||||
}
|
||||
|
||||
export type SharedAgentEventData = {
|
||||
runId: string;
|
||||
persistence?: AgentPersistenceOptions;
|
||||
};
|
||||
|
||||
export type AgentEventSpecificData =
|
||||
export type AgentEventData =
|
||||
| { type: AgentEvent.AgentStart }
|
||||
| { type: AgentEvent.AgentEnd; messages: AgentMessage[] }
|
||||
| { type: AgentEvent.TurnStart }
|
||||
| { type: AgentEvent.TurnEnd; message: AgentMessage; toolResults: ContentToolResult[] }
|
||||
| { type: AgentEvent.TurnEnd; message: AgentMessage; toolResults: ContentToolCall[] }
|
||||
| { type: AgentEvent.ToolExecutionStart; toolCallId: string; toolName: string; args: unknown }
|
||||
| {
|
||||
type: AgentEvent.ToolExecutionEnd;
|
||||
|
|
@ -31,8 +25,6 @@ export type AgentEventSpecificData =
|
|||
}
|
||||
| { type: AgentEvent.Error; message: string; error: unknown };
|
||||
|
||||
export type AgentEventData = SharedAgentEventData & AgentEventSpecificData;
|
||||
|
||||
export type AgentEventHandler = (data: AgentEventData) => void;
|
||||
|
||||
// Can be used for observability or controlling the agent. The idea that HITL, guardrails, logging, etc. can be done as middleware and single point of entry.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ModelConfig } from './agent';
|
||||
import type { CredentialProvider } from './credential-provider';
|
||||
import type { BuiltEval } from './eval';
|
||||
import type { BuiltGuardrail } from './guardrail';
|
||||
import type { CheckpointStore } from './memory';
|
||||
|
|
@ -16,7 +15,6 @@ import type { BuiltProviderTool, BuiltTool } from './tool';
|
|||
*/
|
||||
export interface AgentBuilder {
|
||||
model(providerOrIdOrConfig: string | ModelConfig, modelName?: string): this;
|
||||
credential(name: string): this;
|
||||
instructions(text: string): this;
|
||||
tool(t: BuiltTool | BuiltTool[]): this;
|
||||
providerTool(t: BuiltProviderTool): this;
|
||||
|
|
@ -25,7 +23,6 @@ export interface AgentBuilder {
|
|||
requireToolApproval(): this;
|
||||
memory(m: unknown): this;
|
||||
checkpoint(storage: 'memory' | CheckpointStore): this;
|
||||
credentialProvider(p: CredentialProvider): this;
|
||||
inputGuardrail(g: BuiltGuardrail): this;
|
||||
outputGuardrail(g: BuiltGuardrail): this;
|
||||
eval(e: BuiltEval): this;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { JsonSchema7Type } from 'zod-to-json-schema';
|
|||
|
||||
import type { AgentMessage, ContentMetadata } from './message';
|
||||
import type { BuiltTool } from './tool';
|
||||
import type { ProviderId, ProviderCredentials } from '../../runtime/provider-credentials';
|
||||
import type { AgentEvent, AgentEventHandler } from '../runtime/event';
|
||||
import type { SerializedMessageList } from '../runtime/message-list';
|
||||
import type { BuiltTelemetry } from '../telemetry';
|
||||
|
|
@ -27,12 +28,20 @@ export type TokenUsage<T extends Record<string, unknown> = Record<string, unknow
|
|||
additionalMetadata?: T;
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string */
|
||||
/**
|
||||
* Typed model config for known providers — gives IDE autocompletion for
|
||||
* provider-specific credential fields based on the model id prefix.
|
||||
*/
|
||||
export type TypedModelConfig = {
|
||||
[P in ProviderId]: { id: `${P}/${string}` } & ProviderCredentials<P>;
|
||||
}[ProviderId];
|
||||
|
||||
export type ModelConfig =
|
||||
| string
|
||||
| { id: string; apiKey?: string; url?: string; headers?: Record<string, string> }
|
||||
| TypedModelConfig
|
||||
| { id: string; [k: string]: unknown }
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string
|
||||
| LanguageModel;
|
||||
/* eslint-enable @typescript-eslint/no-redundant-type-constituents */
|
||||
|
||||
export interface AgentResult {
|
||||
id?: string;
|
||||
|
|
@ -53,6 +62,47 @@ export interface AgentResult {
|
|||
|
||||
export type StreamChunk = ContentMetadata &
|
||||
(
|
||||
| { type: 'start-step' }
|
||||
| { type: 'finish-step' }
|
||||
| { type: 'text-start'; id: string }
|
||||
| { type: 'text-delta'; id: string; delta: string }
|
||||
| { type: 'text-end'; id: string }
|
||||
| { type: 'reasoning-start'; id: string }
|
||||
| { type: 'reasoning-delta'; id: string; delta: string }
|
||||
| { type: 'reasoning-end'; id: string }
|
||||
| { type: 'tool-input-start'; toolCallId: string; toolName: string }
|
||||
| { type: 'tool-input-delta'; toolCallId: string; delta: string }
|
||||
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
| {
|
||||
/**
|
||||
* Emitted just before a tool handler starts executing. Bridged from
|
||||
* the runtime event bus (not part of the AI SDK fullStream). Pairs
|
||||
* with the subsequent `tool-result` to let consumers show a
|
||||
* mid-flight indicator between "LLM picked a tool" and "result arrived".
|
||||
*/
|
||||
type: 'tool-execution-start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'tool-call-suspended';
|
||||
runId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
suspendPayload?: unknown;
|
||||
/** JSON Schema describing the shape of data to send when resuming. */
|
||||
resumeSchema?: JsonSchema7Type;
|
||||
}
|
||||
// `message` is reserved for sub-agent / app-defined `CustomAgentMessage`
|
||||
| { type: 'message'; message: AgentMessage }
|
||||
| {
|
||||
type: 'finish';
|
||||
finishReason: FinishReason;
|
||||
|
|
@ -62,41 +112,7 @@ export type StreamChunk = ContentMetadata &
|
|||
subAgentUsage?: SubAgentUsage[];
|
||||
totalCost?: number;
|
||||
}
|
||||
| {
|
||||
type: 'text-delta';
|
||||
id?: string;
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: 'reasoning-delta';
|
||||
id?: string;
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool-call-delta';
|
||||
id?: string;
|
||||
name?: string;
|
||||
argumentsDelta?: string;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'message';
|
||||
message: AgentMessage;
|
||||
id?: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool-call-suspended';
|
||||
runId?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
suspendPayload?: unknown;
|
||||
/** JSON Schema describing the shape of data to send when resuming. */
|
||||
resumeSchema?: JsonSchema7Type;
|
||||
}
|
||||
| { type: 'error'; error: unknown }
|
||||
);
|
||||
|
||||
export interface RunOptions {
|
||||
|
|
@ -167,8 +183,6 @@ export interface GenerateResult {
|
|||
* callers can handle them without try/catch.
|
||||
*/
|
||||
error?: unknown;
|
||||
/** Return a snapshot of the agent state at the end of this run. */
|
||||
getState(): SerializableAgentState;
|
||||
}
|
||||
|
||||
export interface StreamResult {
|
||||
|
|
@ -176,12 +190,6 @@ export interface StreamResult {
|
|||
runId: string;
|
||||
/** The readable stream of chunks. */
|
||||
stream: ReadableStream<StreamChunk>;
|
||||
/**
|
||||
* Return the current agent state for this run.
|
||||
* May be called at any time — during streaming to observe live status,
|
||||
* or after the stream closes to confirm the terminal state.
|
||||
*/
|
||||
getState(): SerializableAgentState;
|
||||
}
|
||||
|
||||
export interface ResumeOptions {
|
||||
|
|
@ -205,6 +213,8 @@ export interface BuiltAgent {
|
|||
|
||||
asTool(description: string): BuiltTool;
|
||||
|
||||
getState(): SerializableAgentState;
|
||||
|
||||
/** Cancel the currently running agent. Synchronous — sets an abort flag that the agentic loop checks asynchronously. */
|
||||
abort(): void;
|
||||
|
||||
|
|
@ -256,6 +266,7 @@ export type PendingToolCall = {
|
|||
suspended: true;
|
||||
suspendPayload: unknown;
|
||||
resumeSchema: JsonSchema7Type;
|
||||
runId: string;
|
||||
}
|
||||
| {
|
||||
suspended: false;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
/** A resolved credential containing at minimum an API key. */
|
||||
export interface ResolvedCredential {
|
||||
apiKey: string;
|
||||
/** A resolved credential. May contain an API key and/or provider-specific fields. */
|
||||
export type ResolvedCredential = {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
};
|
||||
|
||||
/** Summary of a credential available for use. */
|
||||
export interface CredentialListItem {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@ import type { z } from 'zod';
|
|||
|
||||
import type { ModelConfig, SerializableAgentState } from './agent';
|
||||
import type { AgentDbMessage } from './message';
|
||||
import type { JSONObject } from '../utils/json';
|
||||
|
||||
/**
|
||||
* Serializable descriptor returned by BuiltMemory.describe().
|
||||
* Contains enough information to reconstruct the backend from a schema without exposing secrets.
|
||||
*/
|
||||
export interface MemoryDescriptor<TParams extends JSONObject = JSONObject> {
|
||||
/** Backend name (e.g. 'postgres', 'sqlite', 'memory'). Used as key in memoryRegistry. */
|
||||
name: string;
|
||||
/** Constructor name (e.g. 'PostgresMemory', 'SqliteMemory'). Used to construct the backend. */
|
||||
constructorName: string;
|
||||
/** Non-secret, serializable connection parameters. CredentialConfig refs are safe to store. */
|
||||
connectionParams: TParams | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
|
|
@ -84,6 +98,8 @@ export interface BuiltMemory {
|
|||
// --- Lifecycle (optional) ---
|
||||
/** Close the connection pool / release resources. No-op for in-memory backends. */
|
||||
close?(): Promise<void>;
|
||||
/** Return a serializable descriptor of this backend for schema persistence. */
|
||||
describe(): MemoryDescriptor;
|
||||
}
|
||||
|
||||
// --- Semantic Recall Config ---
|
||||
|
|
@ -103,6 +119,8 @@ export interface TitleGenerationConfig {
|
|||
model?: ModelConfig;
|
||||
/** Custom instructions for the title generation prompt. Replaces the defaults entirely. */
|
||||
instructions?: string;
|
||||
/** When true, title generation is awaited before returning the result. Default: false (fire-and-forget). */
|
||||
sync?: boolean;
|
||||
}
|
||||
|
||||
/** Full memory configuration bundle passed from builder to runtime. */
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export type MessageContent =
|
|||
| ContentText
|
||||
| ContentToolCall
|
||||
| ContentInvalidToolCall
|
||||
| ContentToolResult
|
||||
| ContentReasoning
|
||||
| ContentFile
|
||||
| ContentCitation
|
||||
|
|
@ -90,7 +89,7 @@ export type ContentToolCall = ContentMetadata & {
|
|||
/**
|
||||
* The identifier of the tool call. It must be unique across all tool calls.
|
||||
*/
|
||||
toolCallId?: string;
|
||||
toolCallId: string;
|
||||
|
||||
/**
|
||||
* The name of the tool that should be called.
|
||||
|
|
@ -104,31 +103,11 @@ export type ContentToolCall = ContentMetadata & {
|
|||
input: JSONValue;
|
||||
|
||||
providerExecuted?: boolean;
|
||||
};
|
||||
|
||||
export type ContentToolResult = ContentMetadata & {
|
||||
type: 'tool-result';
|
||||
|
||||
/**
|
||||
* The name of the tool that was called.
|
||||
*/
|
||||
toolName: string;
|
||||
|
||||
/**
|
||||
* The ID of the tool call that this result is associated with.
|
||||
*/
|
||||
toolCallId: string;
|
||||
|
||||
/**
|
||||
* Result of the tool call. This is a JSON-serializable object.
|
||||
*/
|
||||
result: JSONValue;
|
||||
|
||||
/**
|
||||
* Optional flag if the result is an error or an error message.
|
||||
*/
|
||||
isError?: boolean;
|
||||
};
|
||||
} & (
|
||||
| { state: 'pending' }
|
||||
| { state: 'resolved'; output: JSONValue }
|
||||
| { state: 'rejected'; error: string }
|
||||
);
|
||||
|
||||
export type ContentInvalidToolCall = ContentMetadata & {
|
||||
type: 'invalid-tool-call';
|
||||
|
|
|
|||
21
packages/@n8n/agents/src/types/sdk/tool-descriptor.ts
Normal file
21
packages/@n8n/agents/src/types/sdk/tool-descriptor.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
export interface ToolDescriptor {
|
||||
name: string;
|
||||
description: string;
|
||||
/**
|
||||
* Behavioural directive paired with the tool. Persisted on the descriptor
|
||||
* so it survives the JSON-config save → publish → reconstruct cycle for
|
||||
* custom tools — without this, `Tool.systemInstruction(...)` would only
|
||||
* apply to in-memory/runtime-injected tools and would silently drop on
|
||||
* reload.
|
||||
*/
|
||||
systemInstruction: string | null;
|
||||
inputSchema: JSONSchema7 | null;
|
||||
outputSchema: JSONSchema7 | null;
|
||||
hasSuspend: boolean;
|
||||
hasResume: boolean;
|
||||
hasToMessage: boolean;
|
||||
requireApproval: boolean;
|
||||
providerOptions: Record<string, unknown> | null;
|
||||
}
|
||||
|
|
@ -26,8 +26,16 @@ export interface InterruptibleToolContext<S = unknown, R = unknown> {
|
|||
export interface BuiltTool {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly suspendSchema?: ZodType;
|
||||
readonly resumeSchema?: ZodType;
|
||||
/**
|
||||
* Behavioural directive paired with the tool, injected into the agent's
|
||||
* system prompt under a `<built_in_rules>` block when the tool is added.
|
||||
* Use for guidance the LLM needs to *decide whether to call* the tool —
|
||||
* tool descriptions answer "what does this do?" but are weighted lower
|
||||
* than system instructions for usage decisions.
|
||||
*/
|
||||
readonly systemInstruction?: string;
|
||||
readonly suspendSchema?: ZodType | JSONSchema7;
|
||||
readonly resumeSchema?: ZodType | JSONSchema7;
|
||||
readonly withDefaultApproval?: boolean;
|
||||
readonly toMessage?: (output: unknown) => AgentMessage | undefined;
|
||||
/**
|
||||
|
|
@ -44,7 +52,7 @@ export interface BuiltTool {
|
|||
* (MCP tools). Use `isZodSchema()` to distinguish between the two at runtime.
|
||||
*/
|
||||
readonly inputSchema?: ZodType | JSONSchema7;
|
||||
readonly outputSchema?: ZodType;
|
||||
readonly outputSchema?: ZodType | JSONSchema7;
|
||||
/** True for tools sourced from an MCP server. */
|
||||
readonly mcpTool?: boolean;
|
||||
/** Name of the MCP server this tool belongs to. Set when mcpTool is true. */
|
||||
|
|
@ -56,6 +64,17 @@ export interface BuiltTool {
|
|||
* Example: `{ anthropic: { eagerInputStreaming: true } }`
|
||||
*/
|
||||
readonly providerOptions?: Record<string, JSONObject>;
|
||||
/**
|
||||
* Arbitrary platform-specific metadata attached to the tool.
|
||||
*/
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Whether the tool has source code that can be introspected.
|
||||
* When `false`, the tool is treated as a platform-managed marker (e.g. an
|
||||
* externally-resolved tool) and its source is not introspected.
|
||||
* Defaults to `true` when absent.
|
||||
*/
|
||||
readonly editable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
40
packages/@n8n/agents/src/utils/parse.ts
Normal file
40
packages/@n8n/agents/src/utils/parse.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type AjvType from 'ajv';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import { isZodSchema } from './zod';
|
||||
|
||||
export type ParseResult<T = unknown> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
let ajvInstance: InstanceType<typeof AjvType> | undefined;
|
||||
|
||||
function getAjv(): InstanceType<typeof AjvType> {
|
||||
if (!ajvInstance) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { default: Ajv } = require('ajv') as { default: typeof AjvType };
|
||||
ajvInstance = new Ajv({ strict: false });
|
||||
}
|
||||
return ajvInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `data` against a Zod schema or a raw JSON Schema.
|
||||
* Returns a unified success/failure result, with parsed data on success.
|
||||
*/
|
||||
export async function parseWithSchema(
|
||||
schema: ZodType | JSONSchema7,
|
||||
data: unknown,
|
||||
): Promise<ParseResult> {
|
||||
if (isZodSchema(schema)) {
|
||||
const result = await schema.safeParseAsync(data);
|
||||
if (result.success) return { success: true, data: result.data };
|
||||
return { success: false, error: result.error.message };
|
||||
}
|
||||
|
||||
const ajv = getAjv();
|
||||
const validate = ajv.compile(schema);
|
||||
if (validate(data)) return { success: true, data };
|
||||
return { success: false, error: ajv.errorsText(validate.errors) };
|
||||
}
|
||||
|
|
@ -2,12 +2,12 @@ import type { JSONSchema7 } from 'json-schema';
|
|||
import type { ZodType } from 'zod';
|
||||
import { zodToJsonSchema as zodToJsonSchemaImpl } from 'zod-to-json-schema';
|
||||
|
||||
/** Type guard: returns true when a tool input schema is a Zod schema (as opposed to raw JSON Schema). */
|
||||
export function isZodSchema(schema: ZodType | JSONSchema7): schema is ZodType {
|
||||
/** Type guard: returns true when a value is a Zod schema (as opposed to raw JSON Schema or any other shape). */
|
||||
export function isZodSchema(schema: unknown): schema is ZodType {
|
||||
return (
|
||||
typeof schema === 'object' &&
|
||||
schema !== null &&
|
||||
typeof (schema as ZodType).safeParse === 'function'
|
||||
typeof (schema as { safeParse?: unknown }).safeParse === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,19 +115,23 @@ export class CodeBuilderNodeSearchEngine {
|
|||
* @param limit - Maximum number of results to return
|
||||
* @returns Array of matching nodes sorted by relevance
|
||||
*/
|
||||
searchByName(query: string, limit: number = 20): CodeBuilderNodeSearchResult[] {
|
||||
searchByName(
|
||||
query: string,
|
||||
limit: number = 20,
|
||||
nodeFilter?: (nodeId: string) => boolean,
|
||||
): CodeBuilderNodeSearchResult[] {
|
||||
const nodeTypes = nodeFilter
|
||||
? this.nodeTypes.filter((node) => nodeFilter(node.name))
|
||||
: this.nodeTypes;
|
||||
|
||||
// Use sublimeSearch for fuzzy matching
|
||||
const searchResults = sublimeSearch<INodeTypeDescription>(
|
||||
query,
|
||||
this.nodeTypes,
|
||||
NODE_SEARCH_KEYS,
|
||||
);
|
||||
const searchResults = sublimeSearch<INodeTypeDescription>(query, nodeTypes, NODE_SEARCH_KEYS);
|
||||
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name));
|
||||
|
||||
// Direct type name match on all nodeTypes (catches nodes sublimeSearch ranked too low)
|
||||
const typeNameMatches = this.nodeTypes
|
||||
const typeNameMatches = nodeTypes
|
||||
.filter((node) => {
|
||||
if (fuzzyResultNames.has(node.name)) return false;
|
||||
return getTypeName(node.name).toLowerCase() === queryLower;
|
||||
|
|
|
|||
|
|
@ -141,6 +141,23 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should apply nodeFilter before ranking and limiting results', () => {
|
||||
const httpToolNode = createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequestTool',
|
||||
displayName: 'HTTP Request Tool',
|
||||
description: 'Makes HTTP requests as an AI tool',
|
||||
group: ['output'],
|
||||
inputs: [],
|
||||
outputs: ['ai_tool'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, httpToolNode]);
|
||||
|
||||
const results = engine.searchByName('http', 1, (nodeId) => nodeId.endsWith('Tool'));
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('n8n-nodes-base.httpRequestTool');
|
||||
});
|
||||
|
||||
it('should combine scores for multiple matches', () => {
|
||||
const results = searchEngine.searchByName('request');
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export { generateCodeBuilderThreadId } from './utils/code-builder-session';
|
|||
export { NodeTypeParser } from './utils/node-type-parser';
|
||||
export { ParseValidateHandler, WorkflowCodeParseError } from './handlers/parse-validate-handler';
|
||||
export { createCodeBuilderSearchTool } from './tools/code-builder-search.tool';
|
||||
export type { CodeBuilderSearchToolOptions } from './tools/code-builder-search.tool';
|
||||
export { createCodeBuilderGetTool } from './tools/code-builder-get.tool';
|
||||
export type { CodeBuilderGetToolOptions } from './tools/code-builder-get.tool';
|
||||
export { createGetSuggestedNodesTool } from './tools/get-suggested-nodes.tool';
|
||||
|
|
|
|||
|
|
@ -44,9 +44,10 @@ export function isValidPathComponent(component: string): boolean {
|
|||
export function validatePathWithinBase(filePath: string, baseDir: string): boolean {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const resolvedBase = resolve(baseDir);
|
||||
const separator = process.platform === 'win32' ? '\\' : '/';
|
||||
|
||||
// Path must start with base directory (with trailing separator to prevent prefix attacks)
|
||||
return resolvedPath.startsWith(resolvedBase + '/') || resolvedPath === resolvedBase;
|
||||
return resolvedPath.startsWith(resolvedBase + separator) || resolvedPath === resolvedBase;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { IParameterBuilderHint, IRelatedNode } from 'n8n-workflow';
|
||||
import { isTriggerNodeType, type IParameterBuilderHint, type IRelatedNode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
|
|
@ -25,28 +25,6 @@ import {
|
|||
} from '../utils/discriminator-utils';
|
||||
import type { NodeTypeParser, ParsedNodeType } from '../utils/node-type-parser';
|
||||
|
||||
/**
|
||||
* Trigger node types that don't have "trigger" in their name
|
||||
* but still function as workflow entry points
|
||||
*/
|
||||
const TRIGGER_NODE_TYPES = new Set([
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.cron', // Legacy schedule trigger
|
||||
'n8n-nodes-base.emailReadImap', // Email polling trigger
|
||||
'n8n-nodes-base.telegramBot', // Can act as webhook trigger
|
||||
'n8n-nodes-base.start', // Legacy trigger
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a node type is a trigger
|
||||
*/
|
||||
export function isTriggerNodeType(type: string): boolean {
|
||||
if (TRIGGER_NODE_TYPES.has(type)) {
|
||||
return true;
|
||||
}
|
||||
return type.toLowerCase().includes('trigger');
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified operation info for discriminator display
|
||||
*/
|
||||
|
|
@ -112,9 +90,10 @@ function getRelatedNodesWithHints(
|
|||
nodeTypeParser: NodeTypeParser,
|
||||
nodeId: string,
|
||||
version: number,
|
||||
nodeFilter?: (nodeId: string) => boolean,
|
||||
): IRelatedNode[] | undefined {
|
||||
const nodeType = nodeTypeParser.getNodeType(nodeId, version);
|
||||
return nodeType?.builderHint?.relatedNodes;
|
||||
return nodeType?.builderHint?.relatedNodes?.filter((r) => nodeFilter?.(r.nodeType) ?? true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -447,112 +426,128 @@ export function formatNodeResult(
|
|||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export interface CodeBuilderSearchToolOptions {
|
||||
/** Optional predicate to exclude nodes from results. Return `false` to filter a node out. */
|
||||
nodeFilter?: (nodeId: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a single query and return the formatted result block.
|
||||
* Extracted to keep the tool handler's cyclomatic complexity within limits.
|
||||
*/
|
||||
function searchForQuery(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
query: string,
|
||||
nodeFilter?: (nodeId: string) => boolean,
|
||||
): string {
|
||||
const results = nodeTypeParser.searchNodeTypes(query, 5, nodeFilter);
|
||||
|
||||
if (results.length === 0) {
|
||||
return `## "${query}"\nNo nodes found. Try a different search term.`;
|
||||
}
|
||||
|
||||
// Track which node IDs have been shown to avoid duplicates
|
||||
const shownNodeIds = new Set<string>(results.map((node: ParsedNodeType) => node.id));
|
||||
|
||||
const allNodeLines: string[] = [];
|
||||
let totalRelatedCount = 0;
|
||||
|
||||
for (const node of results) {
|
||||
// Format the search result node
|
||||
const triggerTag = node.isTrigger ? ' [TRIGGER]' : '';
|
||||
const basicInfo = `- ${node.id}${triggerTag}\n Display Name: ${node.displayName}\n Version: ${node.version}\n Description: ${node.description}`;
|
||||
|
||||
// Get builder hint
|
||||
const builderHint = formatBuilderHint(nodeTypeParser, node.id, node.version);
|
||||
|
||||
// Check for new relatedNodes format with hints
|
||||
const relatedNodesWithHints = getRelatedNodesWithHints(
|
||||
nodeTypeParser,
|
||||
node.id,
|
||||
node.version,
|
||||
nodeFilter,
|
||||
);
|
||||
|
||||
// Get discriminator info
|
||||
const discInfo = getDiscriminatorInfo(nodeTypeParser, node.id, node.version);
|
||||
const discStr = formatDiscriminatorInfo(discInfo, node.id);
|
||||
|
||||
const parts = [basicInfo];
|
||||
if (builderHint) parts.push(builderHint);
|
||||
|
||||
// If using new format with hints, display @relatedNodes section instead of expanding
|
||||
if (relatedNodesWithHints && relatedNodesWithHints.length > 0) {
|
||||
const relatedNodesStr = formatRelatedNodesWithHints(relatedNodesWithHints);
|
||||
if (relatedNodesStr) parts.push(relatedNodesStr);
|
||||
} else {
|
||||
// Legacy format: expand related nodes as [RELATED] entries
|
||||
const relatedNodeIds = collectAllRelatedNodeIds(
|
||||
nodeTypeParser,
|
||||
[{ id: node.id, version: node.version }],
|
||||
shownNodeIds,
|
||||
);
|
||||
|
||||
// Add discriminator info to current node, then push it
|
||||
if (discStr) parts.push(discStr);
|
||||
allNodeLines.push(parts.join('\n'));
|
||||
|
||||
for (const relatedId of relatedNodeIds) {
|
||||
if (nodeFilter && !nodeFilter(relatedId)) continue;
|
||||
|
||||
const nodeType = nodeTypeParser.getNodeType(relatedId);
|
||||
if (nodeType) {
|
||||
const version = Array.isArray(nodeType.version)
|
||||
? nodeType.version[nodeType.version.length - 1]
|
||||
: nodeType.version;
|
||||
const relatedTriggerTag = isTriggerNodeType(relatedId) ? ' [TRIGGER]' : '';
|
||||
const relatedBasicInfo = `- ${relatedId}${relatedTriggerTag} [RELATED]\n Display Name: ${nodeType.displayName}\n Version: ${version}\n Description: ${nodeType.description}`;
|
||||
|
||||
// Get builder hint for related node too
|
||||
const relatedBuilderHint = formatBuilderHint(nodeTypeParser, relatedId, version);
|
||||
|
||||
// Get discriminator info for related node
|
||||
const relatedDiscInfo = getDiscriminatorInfo(nodeTypeParser, relatedId, version);
|
||||
const relatedDiscStr = formatDiscriminatorInfo(relatedDiscInfo, relatedId);
|
||||
|
||||
const relatedParts = [relatedBasicInfo];
|
||||
if (relatedBuilderHint) relatedParts.push(relatedBuilderHint);
|
||||
if (relatedDiscStr) relatedParts.push(relatedDiscStr);
|
||||
|
||||
allNodeLines.push(relatedParts.join('\n'));
|
||||
|
||||
// Mark as shown to prevent duplicates
|
||||
shownNodeIds.add(relatedId);
|
||||
totalRelatedCount++;
|
||||
}
|
||||
}
|
||||
continue; // Skip the common push below since we handled it in the legacy branch
|
||||
}
|
||||
|
||||
if (discStr) parts.push(discStr);
|
||||
allNodeLines.push(parts.join('\n'));
|
||||
}
|
||||
|
||||
const countSuffix = totalRelatedCount > 0 ? ` (+ ${totalRelatedCount} related)` : '';
|
||||
return `## "${query}"\nFound ${results.length} nodes${countSuffix}:\n\n${allNodeLines.join('\n\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the simplified node search tool for code builder
|
||||
* Accepts multiple queries and returns separate results for each
|
||||
* Includes discriminator information for nodes with resource/operation or mode patterns
|
||||
*/
|
||||
export function createCodeBuilderSearchTool(nodeTypeParser: NodeTypeParser) {
|
||||
export function createCodeBuilderSearchTool(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
options?: CodeBuilderSearchToolOptions,
|
||||
) {
|
||||
const { nodeFilter } = options ?? {};
|
||||
|
||||
return tool(
|
||||
async (input: { queries: string[] }) => {
|
||||
const allResults: string[] = [];
|
||||
|
||||
for (const query of input.queries) {
|
||||
const results = nodeTypeParser.searchNodeTypes(query, 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
allResults.push(`## "${query}"\nNo nodes found. Try a different search term.`);
|
||||
} else {
|
||||
// Track which node IDs have been shown to avoid duplicates
|
||||
const shownNodeIds = new Set<string>(results.map((node: ParsedNodeType) => node.id));
|
||||
|
||||
const allNodeLines: string[] = [];
|
||||
let totalRelatedCount = 0;
|
||||
|
||||
for (const node of results) {
|
||||
// Format the search result node
|
||||
const triggerTag = node.isTrigger ? ' [TRIGGER]' : '';
|
||||
const basicInfo = `- ${node.id}${triggerTag}\n Display Name: ${node.displayName}\n Version: ${node.version}\n Description: ${node.description}`;
|
||||
|
||||
// Get builder hint
|
||||
const builderHint = formatBuilderHint(nodeTypeParser, node.id, node.version);
|
||||
|
||||
// Check for new relatedNodes format with hints
|
||||
const relatedNodesWithHints = getRelatedNodesWithHints(
|
||||
nodeTypeParser,
|
||||
node.id,
|
||||
node.version,
|
||||
);
|
||||
|
||||
// Get discriminator info
|
||||
const discInfo = getDiscriminatorInfo(nodeTypeParser, node.id, node.version);
|
||||
const discStr = formatDiscriminatorInfo(discInfo, node.id);
|
||||
|
||||
const parts = [basicInfo];
|
||||
if (builderHint) parts.push(builderHint);
|
||||
|
||||
// If using new format with hints, display @relatedNodes section instead of expanding
|
||||
if (relatedNodesWithHints && relatedNodesWithHints.length > 0) {
|
||||
const relatedNodesStr = formatRelatedNodesWithHints(relatedNodesWithHints);
|
||||
if (relatedNodesStr) parts.push(relatedNodesStr);
|
||||
} else {
|
||||
// Legacy format: expand related nodes as [RELATED] entries
|
||||
const relatedNodeIds = collectAllRelatedNodeIds(
|
||||
nodeTypeParser,
|
||||
[{ id: node.id, version: node.version }],
|
||||
shownNodeIds,
|
||||
);
|
||||
|
||||
// Add related nodes immediately after their parent search result
|
||||
// First, add discriminator info to current node
|
||||
if (discStr) parts.push(discStr);
|
||||
allNodeLines.push(parts.join('\n'));
|
||||
|
||||
for (const relatedId of relatedNodeIds) {
|
||||
const nodeType = nodeTypeParser.getNodeType(relatedId);
|
||||
if (nodeType) {
|
||||
const version = Array.isArray(nodeType.version)
|
||||
? nodeType.version[nodeType.version.length - 1]
|
||||
: nodeType.version;
|
||||
const relatedTriggerTag = isTriggerNodeType(relatedId) ? ' [TRIGGER]' : '';
|
||||
const relatedBasicInfo = `- ${relatedId}${relatedTriggerTag} [RELATED]\n Display Name: ${nodeType.displayName}\n Version: ${version}\n Description: ${nodeType.description}`;
|
||||
|
||||
// Get builder hint for related node too
|
||||
const relatedBuilderHint = formatBuilderHint(nodeTypeParser, relatedId, version);
|
||||
|
||||
// Get discriminator info for related node
|
||||
const relatedDiscInfo = getDiscriminatorInfo(nodeTypeParser, relatedId, version);
|
||||
const relatedDiscStr = formatDiscriminatorInfo(relatedDiscInfo, relatedId);
|
||||
|
||||
const relatedParts = [relatedBasicInfo];
|
||||
if (relatedBuilderHint) relatedParts.push(relatedBuilderHint);
|
||||
if (relatedDiscStr) relatedParts.push(relatedDiscStr);
|
||||
|
||||
allNodeLines.push(relatedParts.join('\n'));
|
||||
|
||||
// Mark as shown to prevent duplicates
|
||||
shownNodeIds.add(relatedId);
|
||||
totalRelatedCount++;
|
||||
}
|
||||
}
|
||||
continue; // Skip the common push below since we handled it in the legacy branch
|
||||
}
|
||||
|
||||
if (discStr) parts.push(discStr);
|
||||
allNodeLines.push(parts.join('\n'));
|
||||
}
|
||||
|
||||
const countSuffix = totalRelatedCount > 0 ? ` (+ ${totalRelatedCount} related)` : '';
|
||||
|
||||
allResults.push(
|
||||
`## "${query}"\nFound ${results.length} nodes${countSuffix}:\n\n${allNodeLines.join('\n\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = allResults.join('\n\n---\n\n');
|
||||
|
||||
return response;
|
||||
const allResults = input.queries.map((query) =>
|
||||
searchForQuery(nodeTypeParser, query, nodeFilter),
|
||||
);
|
||||
return allResults.join('\n\n---\n\n');
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
|
|
|
|||
|
|
@ -68,8 +68,12 @@ export class NodeTypeParser {
|
|||
* Search for nodes by name or description
|
||||
* Returns up to `limit` results
|
||||
*/
|
||||
searchNodeTypes(query: string, limit: number = 5): ParsedNodeType[] {
|
||||
const results = this.searchEngine.searchByName(query, limit);
|
||||
searchNodeTypes(
|
||||
query: string,
|
||||
limit: number = 5,
|
||||
nodeFilter?: (nodeId: string) => boolean,
|
||||
): ParsedNodeType[] {
|
||||
const results = this.searchEngine.searchByName(query, limit, nodeFilter);
|
||||
|
||||
return results.map((result) => {
|
||||
// Find the full node type to check if it's a trigger
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export {
|
|||
ParseValidateHandler,
|
||||
WorkflowCodeParseError,
|
||||
createCodeBuilderSearchTool,
|
||||
type CodeBuilderSearchToolOptions,
|
||||
createCodeBuilderGetTool,
|
||||
createGetSuggestedNodesTool,
|
||||
stripImportStatements,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { AgentBuilderAdminSettingsUpdateDto, agentBuilderAdminSettingsSchema } from '../agents';
|
||||
|
||||
describe('AgentBuilderAdminSettingsUpdateDto', () => {
|
||||
describe('valid payloads', () => {
|
||||
test.each([
|
||||
{ name: 'mode=default', payload: { mode: 'default' } },
|
||||
{
|
||||
name: 'mode=custom anthropic',
|
||||
payload: {
|
||||
mode: 'custom',
|
||||
provider: 'anthropic',
|
||||
credentialId: 'cred-1',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mode=custom openai with arbitrary provider id (api-types stays runtime-agnostic)',
|
||||
payload: {
|
||||
mode: 'custom',
|
||||
provider: 'openai',
|
||||
credentialId: 'cred-1',
|
||||
modelName: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mode=custom aws-bedrock',
|
||||
payload: {
|
||||
mode: 'custom',
|
||||
provider: 'aws-bedrock',
|
||||
credentialId: 'cred-1',
|
||||
modelName: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||
},
|
||||
},
|
||||
])('parses $name', ({ payload }) => {
|
||||
expect(AgentBuilderAdminSettingsUpdateDto.safeParse(payload).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid payloads', () => {
|
||||
test.each([
|
||||
{ name: 'missing mode', payload: {} },
|
||||
{ name: 'unknown mode', payload: { mode: 'foo' } },
|
||||
{
|
||||
name: 'mode=custom missing provider',
|
||||
payload: { mode: 'custom', credentialId: 'cred-1', modelName: 'm' },
|
||||
},
|
||||
{
|
||||
name: 'mode=custom missing credentialId',
|
||||
payload: { mode: 'custom', provider: 'anthropic', modelName: 'm' },
|
||||
},
|
||||
{
|
||||
name: 'mode=custom missing modelName',
|
||||
payload: { mode: 'custom', provider: 'anthropic', credentialId: 'cred-1' },
|
||||
},
|
||||
{
|
||||
name: 'mode=custom empty modelName',
|
||||
payload: {
|
||||
mode: 'custom',
|
||||
provider: 'anthropic',
|
||||
credentialId: 'cred-1',
|
||||
modelName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mode=custom empty provider',
|
||||
payload: {
|
||||
mode: 'custom',
|
||||
provider: '',
|
||||
credentialId: 'cred-1',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mode=default with extra custom fields is silently stripped (still parses)',
|
||||
payload: { mode: 'default', provider: 'anthropic' },
|
||||
expectsSuccess: true,
|
||||
},
|
||||
])('$name', ({ payload, expectsSuccess = false }) => {
|
||||
expect(AgentBuilderAdminSettingsUpdateDto.safeParse(payload).success).toBe(expectsSuccess);
|
||||
});
|
||||
});
|
||||
|
||||
it('inferred type alias matches the schema', () => {
|
||||
// Compile-time assertion: the discriminator narrows the type.
|
||||
const sample = AgentBuilderAdminSettingsUpdateDto.parse({ mode: 'default' });
|
||||
if (sample.mode === 'default') {
|
||||
// no extra fields
|
||||
expect(Object.keys(sample)).toEqual(['mode']);
|
||||
}
|
||||
});
|
||||
|
||||
it('agentBuilderAdminSettingsSchema is the same schema as the DTO export', () => {
|
||||
expect(AgentBuilderAdminSettingsUpdateDto).toBe(agentBuilderAdminSettingsSchema);
|
||||
});
|
||||
});
|
||||
114
packages/@n8n/api-types/src/agent-builder-interactive.ts
Normal file
114
packages/@n8n/api-types/src/agent-builder-interactive.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Canonical names of the interactive agent-builder tools.
|
||||
*
|
||||
* `toolName` is the discriminator on the wire: SSE `toolSuspended` events,
|
||||
* persisted tool-call parts, and the FE InteractivePayload union all dispatch
|
||||
* 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_QUESTION_TOOL_NAME = 'ask_question' as const;
|
||||
|
||||
export const interactiveToolNameSchema = z.union([
|
||||
z.literal(ASK_LLM_TOOL_NAME),
|
||||
z.literal(ASK_CREDENTIAL_TOOL_NAME),
|
||||
z.literal(ASK_QUESTION_TOOL_NAME),
|
||||
]);
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const askCredentialInputSchema = z.object({
|
||||
purpose: z.string().describe('One short sentence describing what this credential is used for'),
|
||||
nodeType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The n8n node type requiring this credential, e.g. "n8n-nodes-base.slack"'),
|
||||
credentialType: z
|
||||
.string()
|
||||
.describe(
|
||||
'The credential type name to request for this slot, e.g. "slackApi". When the slot accepts multiple credential types, pick the single best match (typically the OAuth or first listed type).',
|
||||
),
|
||||
slot: z.string().optional().describe('Credential slot name on the node, e.g. "slackApi"'),
|
||||
});
|
||||
|
||||
export const askCredentialResumeSchema = z.union([
|
||||
z.object({ credentialId: z.string(), credentialName: z.string() }),
|
||||
z.object({ skipped: z.literal(true) }),
|
||||
]);
|
||||
|
||||
export type AskCredentialInput = z.infer<typeof askCredentialInputSchema>;
|
||||
export type AskCredentialResume = z.infer<typeof askCredentialResumeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ask_question
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const askQuestionOptionSchema = z.object({
|
||||
label: z.string().describe('Display label for this option'),
|
||||
value: z.string().describe('Internal value for this option'),
|
||||
description: z.string().optional().describe('Optional additional explanation'),
|
||||
});
|
||||
|
||||
export const askQuestionInputSchema = z.object({
|
||||
question: z.string().describe('The question to display to the user'),
|
||||
options: z
|
||||
.array(askQuestionOptionSchema)
|
||||
.min(1)
|
||||
.describe(
|
||||
'Choices to present. With a single option the tool auto-resolves to that option without rendering a card.',
|
||||
),
|
||||
allowMultiple: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('If true the user may select more than one option; defaults to false'),
|
||||
});
|
||||
|
||||
export const askQuestionResumeSchema = z.object({
|
||||
values: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export type AskQuestionOption = z.infer<typeof askQuestionOptionSchema>;
|
||||
export type AskQuestionInput = z.infer<typeof askQuestionInputSchema>;
|
||||
export type AskQuestionResume = z.infer<typeof askQuestionResumeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discriminated union of all resume payloads (used by AgentBuildResumeDto)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const interactiveResumeDataSchema = z.union([
|
||||
askLlmResumeSchema,
|
||||
askCredentialResumeSchema,
|
||||
askQuestionResumeSchema,
|
||||
]);
|
||||
|
||||
export type InteractiveResumeData = z.infer<typeof interactiveResumeDataSchema>;
|
||||
96
packages/@n8n/api-types/src/agent-sse.ts
Normal file
96
packages/@n8n/api-types/src/agent-sse.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Wire format for the agent builder/chat SSE stream. Each SSE `data:` line is
|
||||
* exactly one `AgentSseEvent` JSON object.
|
||||
*
|
||||
* Per-turn events carry the SDK's natural block ids:
|
||||
*
|
||||
* - `text-*` and `reasoning-*` events carry the SDK's per-block `id`.
|
||||
* - `tool-*` events carry the SDK's `toolCallId`.
|
||||
* - `start-step` / `finish-step` mark LLM iteration boundaries.
|
||||
*
|
||||
* The frontend groups deltas by these ids and uses `start-step` / `finish-step`
|
||||
* to decide when a new ChatMessage cursor should open. There is no
|
||||
* server-minted `messageId` — the FE generates its own UUID per ChatMessage
|
||||
* for v-for keys only.
|
||||
*
|
||||
* `runId` is included on `ToolSuspendedPayload` and echoed back by the
|
||||
* frontend on resume. The SDK stores `runId` on each `PendingToolCall` and
|
||||
* surfaces it on every suspended-tool chunk; the FE doesn't need to derive it
|
||||
* server-side.
|
||||
*
|
||||
* Note: there is no separate "resumed" event. After the user resumes a
|
||||
* suspended tool, the SDK runs the tool's handler (which returns
|
||||
* `ctx.resumeData`) and emits a normal `tool-result` event. Consumers see the
|
||||
* resume payload as the `output` on that `tool-result`.
|
||||
*
|
||||
*/
|
||||
|
||||
import type { AgentPersistedMessageContentPart } from './agents';
|
||||
|
||||
export interface ToolSuspendedPayload {
|
||||
toolCallId: string;
|
||||
/** Run id of the suspended turn; FE echoes this back on `POST /build/resume`. */
|
||||
runId: string;
|
||||
/** Also the discriminator on the wire (no separate interactionType field). */
|
||||
toolName: string;
|
||||
/** Shape determined by toolName via the corresponding Ask*InputSchema. */
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom (sub-agent / app-defined) message envelope. Tool-call and tool-result
|
||||
* events ride their own discrete chunk types — only `CustomAgentMessage`-style
|
||||
* payloads use this shape.
|
||||
*/
|
||||
export interface AgentSseMessage {
|
||||
role: string;
|
||||
content: AgentPersistedMessageContentPart[];
|
||||
}
|
||||
|
||||
export type AgentSseEvent =
|
||||
| { type: 'start-step' }
|
||||
| { type: 'finish-step' }
|
||||
| { type: 'text-start'; id: string }
|
||||
| { type: 'text-delta'; id: string; delta: string }
|
||||
| { type: 'text-end'; id: string }
|
||||
| { type: 'reasoning-start'; id: string }
|
||||
| { type: 'reasoning-delta'; id: string; delta: string }
|
||||
| { type: 'reasoning-end'; id: string }
|
||||
| { type: 'tool-input-start'; toolCallId: string; toolName: string }
|
||||
| { type: 'tool-input-delta'; toolCallId: string; delta: string }
|
||||
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
| {
|
||||
/**
|
||||
* Mid-flight indicator: the LLM has finished emitting the tool call and
|
||||
* the runtime has started invoking the handler. Sent between `tool-call`
|
||||
* and the eventual `tool-result` so the FE can flip the indicator from
|
||||
* "pending" (LLM committed) to "running" (handler in flight).
|
||||
*/
|
||||
type: 'tool-execution-start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
| { type: 'tool-call-suspended'; payload: ToolSuspendedPayload }
|
||||
| { type: 'message'; message: AgentSseMessage }
|
||||
| { type: 'working-memory-update'; toolName: string }
|
||||
| { type: 'code-delta'; delta: string }
|
||||
| { type: 'config-updated' }
|
||||
| { type: 'tool-updated' }
|
||||
| {
|
||||
type: 'error';
|
||||
message: string;
|
||||
/**
|
||||
* Optional discriminator for distinct error classes.
|
||||
*/
|
||||
errorCode?: string;
|
||||
/** Backend-emitted ids of the missing config slots; only set when `errorCode` is `agent_misconfigured`. */
|
||||
missing?: string[];
|
||||
}
|
||||
| { type: 'done'; sessionId?: string };
|
||||
311
packages/@n8n/api-types/src/agents.ts
Normal file
311
packages/@n8n/api-types/src/agents.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import {
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Describes a chat platform integration that agents can connect to.
|
||||
* Source of truth: the backend `ChatIntegrationRegistry`.
|
||||
*/
|
||||
export interface ChatIntegrationDescriptor {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
credentialTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node types a workflow can use as its trigger to be eligible as an agent
|
||||
* tool. Single source of truth for both the backend compatibility check
|
||||
* (`workflow-tool-factory.ts:SUPPORTED_TRIGGERS`) and the frontend Available
|
||||
* list's pre-filter. Body-node incompatibility (Wait / RespondToWebhook) is
|
||||
* enforced separately at save time.
|
||||
*/
|
||||
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Node types in a workflow's body that disqualify it from being used as an
|
||||
* agent tool (execution model can't handle pause/respond-style nodes). Single
|
||||
* source of truth for the backend `validateCompatibility` check in
|
||||
* `workflow-tool-factory.ts` and the frontend pre-check in
|
||||
* `AgentToolsModal.vue` so the two sides can't drift.
|
||||
*/
|
||||
export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [
|
||||
'n8n-nodes-base.wait',
|
||||
'n8n-nodes-base.form',
|
||||
'n8n-nodes-base.respondToWebhook',
|
||||
] as const;
|
||||
|
||||
export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule';
|
||||
|
||||
export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT =
|
||||
'Automated message: you were triggered on schedule.';
|
||||
|
||||
export interface AgentCredentialIntegration {
|
||||
type: string;
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
}
|
||||
|
||||
export interface AgentScheduleIntegration {
|
||||
type: typeof AGENT_SCHEDULE_TRIGGER_TYPE;
|
||||
active: boolean;
|
||||
cronExpression: string;
|
||||
wakeUpPrompt: string;
|
||||
}
|
||||
|
||||
export type AgentIntegration = AgentCredentialIntegration | AgentScheduleIntegration;
|
||||
|
||||
export interface AgentScheduleConfig {
|
||||
active: boolean;
|
||||
cronExpression: string;
|
||||
wakeUpPrompt: string;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationStatusEntry {
|
||||
type: string;
|
||||
credentialId?: string;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationStatusResponse {
|
||||
status: 'connected' | 'disconnected';
|
||||
integrations: AgentIntegrationStatusEntry[];
|
||||
}
|
||||
|
||||
export function isAgentScheduleIntegration(
|
||||
integration: AgentIntegration | null | undefined,
|
||||
): integration is AgentScheduleIntegration {
|
||||
return integration?.type === AGENT_SCHEDULE_TRIGGER_TYPE;
|
||||
}
|
||||
|
||||
export function isAgentCredentialIntegration(
|
||||
integration: AgentIntegration | null | undefined,
|
||||
): integration is AgentCredentialIntegration {
|
||||
return (
|
||||
integration !== null &&
|
||||
integration !== undefined &&
|
||||
integration.type !== AGENT_SCHEDULE_TRIGGER_TYPE &&
|
||||
'credentialId' in integration &&
|
||||
typeof integration.credentialId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export interface NodeToolConfig {
|
||||
nodeType: string;
|
||||
nodeTypeVersion: number;
|
||||
nodeParameters?: Record<string, unknown>;
|
||||
credentials?: Record<string, { id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface BaseAgentJsonToolRef {
|
||||
name?: string;
|
||||
description?: string;
|
||||
workflow?: string;
|
||||
node?: NodeToolConfig;
|
||||
requireApproval?: boolean;
|
||||
allOutputs?: boolean;
|
||||
}
|
||||
|
||||
export type AgentJsonToolRef =
|
||||
| (BaseAgentJsonToolRef & {
|
||||
type: 'custom';
|
||||
id: string;
|
||||
})
|
||||
| (BaseAgentJsonToolRef & {
|
||||
type: 'workflow';
|
||||
id?: never;
|
||||
})
|
||||
| (BaseAgentJsonToolRef & {
|
||||
type: 'node';
|
||||
id?: never;
|
||||
});
|
||||
|
||||
export interface AgentJsonSkillRef {
|
||||
type: 'skill';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AgentJsonConfigRef = AgentJsonToolRef | AgentJsonSkillRef;
|
||||
|
||||
export interface AgentSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface AgentSkillMutationResponse {
|
||||
id: string;
|
||||
skill: AgentSkill;
|
||||
versionId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentJsonConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Optional icon/emoji shown in the agent builder header. */
|
||||
icon?: { type: 'icon' | 'emoji'; value: string };
|
||||
model: string;
|
||||
credential?: string;
|
||||
instructions: string;
|
||||
memory?: {
|
||||
enabled: boolean;
|
||||
storage: 'n8n' | 'sqlite' | 'postgres';
|
||||
connection?: Record<string, unknown>;
|
||||
lastMessages?: number;
|
||||
semanticRecall?: {
|
||||
topK: number;
|
||||
scope?: 'thread' | 'resource';
|
||||
messageRange?: { before: number; after: number };
|
||||
embedder?: string;
|
||||
};
|
||||
};
|
||||
tools?: AgentJsonToolRef[];
|
||||
skills?: AgentJsonSkillRef[];
|
||||
providerTools?: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Triggers (scheduled execution + chat integrations) attached to this agent.
|
||||
* Mirrors the contents of `agent.integrations` storage column so the builder
|
||||
* can read and modify triggers through the same JSON config flow as tools.
|
||||
*/
|
||||
integrations?: AgentIntegration[];
|
||||
config?: {
|
||||
thinking?: {
|
||||
provider: 'anthropic' | 'openai';
|
||||
budgetTokens?: number;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
toolCallConcurrency?: number;
|
||||
requireToolApproval?: boolean;
|
||||
nodeTools?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot of an agent at publish time. Returned by publish/unpublish
|
||||
* endpoints as part of the agent payload so the UI can derive publish state
|
||||
* (`not-published` / `published-no-changes` / `published-with-changes`) from
|
||||
* `agent.versionId` vs `publishedVersion.publishedFromVersionId`.
|
||||
*/
|
||||
export interface AgentPublishedVersionDto {
|
||||
schema: AgentJsonConfig | null;
|
||||
skills: Record<string, AgentSkill> | null;
|
||||
publishedFromVersionId: string;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
credentialId: string | null;
|
||||
publishedById: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single part inside a persisted chat/builder message. Mirrors the content
|
||||
* parts emitted by the agents SDK; known `type` values are enumerated for
|
||||
* autocomplete but the field is left open because new SDK versions may
|
||||
* introduce additional kinds.
|
||||
*/
|
||||
export interface AgentPersistedMessageContentPart {
|
||||
type: 'text' | 'reasoning' | 'tool-call' | (string & {});
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
input?: unknown;
|
||||
state?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted chat/builder message shape returned by
|
||||
* `GET /projects/:projectId/agents/v2/:agentId/chat/messages` and
|
||||
* `GET /projects/:projectId/agents/v2/:agentId/build/messages`. The UI
|
||||
* converts these into its own display-oriented representation.
|
||||
*
|
||||
* Distinct from the request-body `AgentChatMessageDto` (a single outbound
|
||||
* message) — this is the history shape, one entry per persisted turn.
|
||||
*/
|
||||
export interface AgentPersistedMessageDto {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | (string & {});
|
||||
content: AgentPersistedMessageContentPart[];
|
||||
}
|
||||
// ─── Agent builder admin settings ─────────────────────────────────────────
|
||||
// The agent builder uses a model picked by the instance admin. By default it
|
||||
// runs through the n8n AI assistant proxy; admins can switch to a custom
|
||||
// provider + credential at any time.
|
||||
|
||||
/** Default model name used when the builder runs through the proxy or the env-var backstop. */
|
||||
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const;
|
||||
|
||||
export const agentBuilderModeSchema = z.enum(['default', 'custom']);
|
||||
export type AgentBuilderMode = z.infer<typeof agentBuilderModeSchema>;
|
||||
|
||||
/**
|
||||
* Discriminated union of the persisted admin settings.
|
||||
*
|
||||
* The builder defaults to the n8n AI assistant proxy. An admin can switch to
|
||||
* a custom provider/credential at any time. Provider id values must come from
|
||||
* the agent runtime's supported list (see `mapCredentialForProvider` on the
|
||||
* backend) — the schema accepts any non-empty string here so the api-types
|
||||
* package doesn't need to know the runtime list; the backend validates the
|
||||
* provider against the runtime mapper.
|
||||
*/
|
||||
export const agentBuilderAdminSettingsSchema = z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('default') }),
|
||||
z.object({
|
||||
mode: z.literal('custom'),
|
||||
provider: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
modelName: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
export type AgentBuilderAdminSettings = z.infer<typeof agentBuilderAdminSettingsSchema>;
|
||||
|
||||
export const agentBuilderAdminSettingsResponseSchema = z.object({
|
||||
settings: agentBuilderAdminSettingsSchema,
|
||||
isConfigured: z.boolean(),
|
||||
});
|
||||
export type AgentBuilderAdminSettingsResponse = z.infer<
|
||||
typeof agentBuilderAdminSettingsResponseSchema
|
||||
>;
|
||||
|
||||
/** Body schema for the PATCH /agent-builder/settings endpoint. */
|
||||
export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema;
|
||||
export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings;
|
||||
|
||||
export const agentBuilderStatusResponseSchema = z.object({
|
||||
isConfigured: z.boolean(),
|
||||
});
|
||||
export type AgentBuilderStatusResponse = z.infer<typeof agentBuilderStatusResponseSchema>;
|
||||
|
||||
/**
|
||||
* One still-open interactive tool call, surfaced alongside persisted messages
|
||||
* so the FE can re-attach a `runId` to suspended interactive cards after a
|
||||
* page refresh.
|
||||
*/
|
||||
export interface AgentBuilderOpenSuspension {
|
||||
toolCallId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response body of `GET /projects/:projectId/agents/v2/:agentId/build/messages`.
|
||||
*
|
||||
* `messages` is the merged history (persisted memory + any in-flight checkpoint
|
||||
* messages). `openSuspensions` carries the runIds for every still-open
|
||||
* interactive tool call so the FE can resume them.
|
||||
*/
|
||||
export interface AgentBuilderMessagesResponse {
|
||||
messages: AgentPersistedMessageDto[];
|
||||
openSuspensions: AgentBuilderOpenSuspension[];
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { CreateAgentSkillDto } from '../create-agent-skill.dto';
|
||||
import { UpdateAgentSkillDto } from '../update-agent-skill.dto';
|
||||
|
||||
describe('agent skill DTOs', () => {
|
||||
const validSkill = {
|
||||
name: 'Summarize Notes',
|
||||
description: 'Summarizes meeting notes',
|
||||
instructions: 'Extract decisions and action items.',
|
||||
};
|
||||
|
||||
it('accepts natural-language descriptions', () => {
|
||||
expect(CreateAgentSkillDto.safeParse(validSkill).success).toBe(true);
|
||||
expect(
|
||||
UpdateAgentSkillDto.safeParse({
|
||||
description: 'Extracts decisions from notes',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { interactiveResumeDataSchema } from '../../agent-builder-interactive';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/**
|
||||
* Body of `POST /:agentId/build/resume`.
|
||||
*
|
||||
* `runId` is sent by the frontend; it originates from the
|
||||
* `tool-call-suspended` chunk (live) or the `openSuspensions` sidecar
|
||||
* returned by `GET /build/messages` (history reload).
|
||||
*/
|
||||
export class AgentBuildResumeDto extends Z.class({
|
||||
runId: z.string().min(1),
|
||||
toolCallId: z.string().min(1),
|
||||
resumeData: interactiveResumeDataSchema,
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AgentChatMessageDto extends Z.class({
|
||||
message: z.string().min(1),
|
||||
sessionId: z.string().min(1).optional(),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AgentIntegrationDto extends Z.class({
|
||||
type: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/** Hard cap on a skill body. Large enough for serious playbooks, small enough
|
||||
* to keep a single skill from blowing past the LLM's context window when loaded. */
|
||||
export const AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH = 10_000;
|
||||
|
||||
export const agentSkillSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().min(1).max(512),
|
||||
instructions: z.string().min(1).max(AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH),
|
||||
});
|
||||
|
||||
export class CreateAgentSkillDto extends Z.class({
|
||||
...agentSkillSchema.shape,
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateAgentDto extends Z.class({
|
||||
name: z.string().min(1),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateAgentConfigDto extends Z.class({
|
||||
config: z.record(z.unknown()),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateAgentScheduleDto extends Z.class({
|
||||
cronExpression: z.string(),
|
||||
wakeUpPrompt: z.string().optional(),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { agentSkillSchema } from './create-agent-skill.dto';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateAgentSkillDto extends Z.class({
|
||||
name: agentSkillSchema.shape.name.optional(),
|
||||
description: agentSkillSchema.shape.description.optional(),
|
||||
instructions: agentSkillSchema.shape.instructions.optional(),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateAgentDto extends Z.class({
|
||||
name: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) {}
|
||||
|
|
@ -235,6 +235,20 @@ export {
|
|||
export { VersionSinceDateQueryDto } from './instance-version-history/version-since-date-query.dto';
|
||||
export { VersionQueryDto } from './instance-version-history/version-query.dto';
|
||||
|
||||
export { CreateAgentDto } from './agents/create-agent.dto';
|
||||
export { UpdateAgentDto } from './agents/update-agent.dto';
|
||||
export { UpdateAgentConfigDto } from './agents/update-agent-config.dto';
|
||||
export { UpdateAgentScheduleDto } from './agents/update-agent-schedule.dto';
|
||||
export {
|
||||
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
|
||||
CreateAgentSkillDto,
|
||||
agentSkillSchema,
|
||||
} from './agents/create-agent-skill.dto';
|
||||
export { UpdateAgentSkillDto } from './agents/update-agent-skill.dto';
|
||||
export { AgentIntegrationDto } from './agents/agent-integration.dto';
|
||||
export { AgentChatMessageDto } from './agents/agent-chat-message.dto';
|
||||
export { AgentBuildResumeDto } from './agents/agent-build-resume.dto';
|
||||
|
||||
export { CreateEncryptionKeyDto } from './encryption/create-encryption-key.dto';
|
||||
export {
|
||||
ListEncryptionKeysQueryDto,
|
||||
|
|
|
|||
|
|
@ -316,6 +316,19 @@ export type FrontendModuleSettings = {
|
|||
/** Whether system roles (admin, editor) have external secrets scopes. */
|
||||
systemRolesEnabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client settings for the agents module.
|
||||
*/
|
||||
agents?: {
|
||||
/**
|
||||
* Enabled agent sub-feature modules. Each token unlocks a specific
|
||||
* capability inside the agents module (see the backend's
|
||||
* `AGENTS_MODULE_NAMES` for the known set). Controlled via
|
||||
* `N8N_AGENTS_MODULES` (comma-separated).
|
||||
*/
|
||||
modules: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type N8nEnvFeatFlagValue = boolean | string | number | undefined;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@ export type * from './user';
|
|||
export type * from './api-keys';
|
||||
export type * from './community-node-types';
|
||||
export type * from './quick-connect';
|
||||
export * from './agents';
|
||||
export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from './agent-sse';
|
||||
export {
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_QUESTION_TOOL_NAME,
|
||||
interactiveToolNameSchema,
|
||||
askLlmInputSchema,
|
||||
askLlmResumeSchema,
|
||||
askCredentialInputSchema,
|
||||
askCredentialResumeSchema,
|
||||
askQuestionOptionSchema,
|
||||
askQuestionInputSchema,
|
||||
askQuestionResumeSchema,
|
||||
interactiveResumeDataSchema,
|
||||
type InteractiveToolName,
|
||||
type AskLlmInput,
|
||||
type AskLlmResume,
|
||||
type AskCredentialInput,
|
||||
type AskCredentialResume,
|
||||
type AskQuestionOption,
|
||||
type AskQuestionInput,
|
||||
type AskQuestionResume,
|
||||
type InteractiveResumeData,
|
||||
} from './agent-builder-interactive';
|
||||
export * from './instance-registry-types';
|
||||
export {
|
||||
chatHubConversationModelSchema,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CommaSeparatedStringArray, Config, Env } from '@n8n/config';
|
|||
import { UnknownModuleError } from './errors/unknown-module.error';
|
||||
|
||||
export const MODULE_NAMES = [
|
||||
'agents',
|
||||
'insights',
|
||||
'external-secrets',
|
||||
'community-packages',
|
||||
|
|
|
|||
44
packages/@n8n/config/src/configs/agents.config.ts
Normal file
44
packages/@n8n/config/src/configs/agents.config.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { CommaSeparatedStringArray } from '../custom-types';
|
||||
import { Config, Env } from '../decorators';
|
||||
|
||||
/**
|
||||
* Known agent sub-feature modules. Add a token here to make it valid in
|
||||
* `N8N_AGENTS_MODULES`. The backend fails fast on unknown tokens so typos
|
||||
* surface at startup instead of silently disabling a feature.
|
||||
*/
|
||||
export const AGENTS_MODULE_NAMES = ['node-tools-searcher'] as const;
|
||||
|
||||
export type AgentsModuleName = (typeof AGENTS_MODULE_NAMES)[number];
|
||||
|
||||
class AgentsModuleArray extends CommaSeparatedStringArray<AgentsModuleName> {
|
||||
constructor(str: string) {
|
||||
super(str);
|
||||
|
||||
for (const name of this) {
|
||||
if (!AGENTS_MODULE_NAMES.includes(name)) {
|
||||
throw new Error(
|
||||
`Unknown agents module: "${name}". Valid tokens: ${AGENTS_MODULE_NAMES.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Config
|
||||
export class AgentsConfig {
|
||||
/** TTL in seconds for agent checkpoint records. Stale checkpoints older than this are pruned. */
|
||||
@Env('N8N_AGENTS_CHECKPOINT_TTL')
|
||||
checkpointTtlSeconds: number = 345600; // 96 hours
|
||||
|
||||
/**
|
||||
* Comma-separated list of agent sub-feature modules to enable. Each entry
|
||||
* gates a specific frontend/runtime capability inside the agents module.
|
||||
* Currently known: `node-tools-searcher` (surfaces the "Built-in node tools"
|
||||
* toggle in the agent editor).
|
||||
*
|
||||
* Gates the UI surface only — existing agents persisted with a given
|
||||
* capability turned on continue to run even if its token is removed here.
|
||||
*/
|
||||
@Env('N8N_AGENTS_MODULES')
|
||||
modules: AgentsModuleArray = [];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user