mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
refactor(core): Simplify agent service functions (no-changelog) (#39331)
This commit is contained in:
@@ -537,6 +537,7 @@ export const RunnableAgentJsonConfigSchema = AgentJsonConfigBaseSchema.extend({
|
||||
});
|
||||
|
||||
export type AgentJsonConfig = z.infer<typeof AgentJsonConfigSchema>;
|
||||
export type AgentModelCredentialConfig = Required<Pick<AgentJsonConfig, 'model' | 'credential'>>;
|
||||
export type RunnableAgentJsonConfig = z.infer<typeof RunnableAgentJsonConfigSchema>;
|
||||
export type AgentJsonToolConfig = z.infer<typeof AgentJsonToolConfigSchema>;
|
||||
export type AgentJsonWorkflowToolConfig = Extract<AgentJsonToolConfig, { type: 'workflow' }>;
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { AgentBackgroundJobSignal } from './background-job';
|
||||
|
||||
export type AgentActor = 'user' | 'builder' | 'mcp';
|
||||
|
||||
export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
|
||||
|
||||
export interface AgentSessionPreviewAccess {
|
||||
canContinueInPreview: boolean;
|
||||
}
|
||||
@@ -361,7 +363,7 @@ export interface AgentPersistedMessageDto {
|
||||
/** Agent-execution turn id when this message was produced from an execution transcript. */
|
||||
executionId?: string;
|
||||
/** Outcome of the execution that produced this message. */
|
||||
executionStatus?: 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
|
||||
executionStatus?: AgentExecutionStatus;
|
||||
/**
|
||||
* The recorded run error for a turn that ended in `error` or `interrupted`,
|
||||
* so history renders the same error bubble the live stream showed.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Serialize work per key. A failed operation does not block later work. */
|
||||
export async function runSerially<T>(
|
||||
pending: Map<string, Promise<unknown>>,
|
||||
key: string,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = pending.get(key) ?? Promise.resolve();
|
||||
const run = previous.catch(() => {}).then(work);
|
||||
pending.set(key, run);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (pending.get(key) === run) pending.delete(key);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -7,6 +7,7 @@ import type { Push } from '@/push';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import { AgentPushRecipientsService } from '../agent-push-recipients.service';
|
||||
import { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster';
|
||||
import type { AgentExecutionThreadRepository } from '../repositories/agent-execution-thread.repository';
|
||||
import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
|
||||
@@ -46,12 +47,11 @@ describe('AgentExecutionUpdateBroadcaster', () => {
|
||||
});
|
||||
broadcaster = new AgentExecutionUpdateBroadcaster(
|
||||
logger,
|
||||
userRepository,
|
||||
new AgentPushRecipientsService(userRepository, roleService),
|
||||
push,
|
||||
publisher,
|
||||
instanceSettings,
|
||||
threadRepository,
|
||||
roleService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OperationalError } from 'n8n-workflow';
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import { AgentChangePublisher } from '../agent-change-publisher.service';
|
||||
import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
|
||||
import { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
|
||||
import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
|
||||
@@ -62,8 +63,7 @@ function makeService({
|
||||
const service = new AgentRuntimeCacheService(
|
||||
mockLogger(),
|
||||
agentRepository,
|
||||
publisher,
|
||||
globalConfig,
|
||||
new AgentChangePublisher(publisher, globalConfig, mockLogger()),
|
||||
reconstructionService,
|
||||
credentialsService,
|
||||
sandboxRuntimeService,
|
||||
@@ -510,8 +510,9 @@ describe('AgentRuntimeCacheService', () => {
|
||||
const first = service.getRuntime({ agentId, projectId });
|
||||
const second = service.getRuntime({ agentId, projectId });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() =>
|
||||
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
resolveRuntime(runtime);
|
||||
const [firstRuntime, secondRuntime] = await Promise.all([first, second]);
|
||||
|
||||
+2
-2
@@ -27,6 +27,7 @@ import type { AiService } from '@/services/ai.service';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { AgentChangePublisher } from '../agent-change-publisher.service';
|
||||
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
|
||||
import { AgentConfigService } from '../agent-config.service';
|
||||
import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service';
|
||||
@@ -237,8 +238,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
||||
runtimeCacheService = new AgentRuntimeCacheService(
|
||||
logger,
|
||||
agentRepository,
|
||||
publisher,
|
||||
globalConfig,
|
||||
new AgentChangePublisher(publisher, globalConfig, logger),
|
||||
agentRuntimeReconstructionService,
|
||||
credentialsService,
|
||||
agentSandboxRuntimeService,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import { AgentChangePublisher } from '../agent-change-publisher.service';
|
||||
import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service';
|
||||
import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service';
|
||||
import { AgentTaskService } from '../agent-task.service';
|
||||
@@ -151,7 +152,7 @@ describe('AgentTaskService', () => {
|
||||
agentExecutionOrchestratorService,
|
||||
mock<InstanceSettings>({ isLeader }),
|
||||
agentTaskScheduler,
|
||||
publisher,
|
||||
new AgentChangePublisher(publisher, globalConfig, logger),
|
||||
modificationTelemetry,
|
||||
durableJobRegistrar,
|
||||
mock<AgentUpdateBroadcaster>(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Push } from '@/push';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
|
||||
import { AgentPushRecipientsService } from '../agent-push-recipients.service';
|
||||
import { AgentUpdateBroadcaster } from '../agent-update-broadcaster';
|
||||
|
||||
const update = { projectId: 'project-1', agentId: 'agent-1', source: 'mcp' as const };
|
||||
@@ -29,8 +30,7 @@ describe('AgentUpdateBroadcaster', () => {
|
||||
});
|
||||
broadcaster = new AgentUpdateBroadcaster(
|
||||
logger,
|
||||
userRepository,
|
||||
roleService,
|
||||
new AgentPushRecipientsService(userRepository, roleService),
|
||||
push,
|
||||
publisher,
|
||||
instanceSettings,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CredentialProvider, ResolvedCredential, CredentialListItem } from '@n8n/agents';
|
||||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
@@ -9,6 +8,7 @@ import { CredentialsHelper } from '@/credentials-helper';
|
||||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
|
||||
import type { AiGatewayModelCredentialResolver } from '../json-config/model-config';
|
||||
import { decryptAgentCredential } from '../utils/decrypt-agent-credential';
|
||||
|
||||
function toResolvedCredential(data: unknown): ResolvedCredential {
|
||||
const resolved = data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
||||
@@ -70,42 +70,11 @@ export class AgentsCredentialProvider
|
||||
throw new Error(`Credential "${credentialId}" not found or not accessible`);
|
||||
}
|
||||
|
||||
const data = await this.decryptWithExpressions(credential);
|
||||
return toResolvedCredential(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt through `CredentialsHelper.getDecrypted` — the path node execution
|
||||
* takes — so expressions in credential fields are evaluated instead of
|
||||
* reaching the caller as raw `={{ ... }}` strings. An external-secret-backed
|
||||
* field would otherwise be sent verbatim and fail auth.
|
||||
*
|
||||
* Agents have no workflow or execution, so `additionalData` comes from
|
||||
* `getBase()`, which supplies the secrets proxy and the project's variables.
|
||||
*
|
||||
* `internal` mode skips dynamic-credential resolution, which needs an
|
||||
* execution context agents don't have. A per-user resolvable credential
|
||||
* therefore falls back to its static stored data rather than failing — the
|
||||
* same behaviour as before this path existed.
|
||||
*/
|
||||
private async decryptWithExpressions(
|
||||
credential: CredentialsEntity,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
// Imported lazily: `workflow-execute-additional-data` reaches back into
|
||||
// this module to run agents from workflows.
|
||||
// eslint-disable-next-line import-x/no-cycle
|
||||
const { getBase } = await import('@/workflow-execute-additional-data.js');
|
||||
const additionalData = await getBase({
|
||||
const data = await decryptAgentCredential(Container.get(CredentialsHelper), credential, {
|
||||
userId: this.user?.id,
|
||||
projectId: this.projectId,
|
||||
});
|
||||
|
||||
return await Container.get(CredentialsHelper).getDecrypted(
|
||||
additionalData,
|
||||
{ id: credential.id, name: credential.name },
|
||||
credential.type,
|
||||
'internal',
|
||||
);
|
||||
return toResolvedCredential(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { PubSub } from '@/scaling/pubsub/pubsub.types';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import { agentChannelRef } from './utils/agent-channel';
|
||||
|
||||
type AgentChange = Extract<
|
||||
PubSub.Command,
|
||||
{
|
||||
command:
|
||||
| 'agent-config-changed'
|
||||
| 'agent-tasks-changed'
|
||||
| 'agent-chat-integration-changed'
|
||||
| 'agent-chat-subscription-changed';
|
||||
}
|
||||
>;
|
||||
|
||||
@Service()
|
||||
export class AgentChangePublisher {
|
||||
constructor(
|
||||
private readonly publisher: Publisher,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async publish(change: AgentChange): Promise<void> {
|
||||
if (!this.globalConfig.multiMainSetup.enabled) return;
|
||||
try {
|
||||
await this.publisher.publishCommand(change);
|
||||
} catch (error) {
|
||||
const { payload } = change;
|
||||
this.logger.warn(`Failed to publish ${change.command}`, {
|
||||
agentId: payload.agentId,
|
||||
...('integration' in payload && agentChannelRef(payload.agentId, payload.integration)),
|
||||
...('threadId' in payload && { threadId: payload.threadId }),
|
||||
...('action' in payload && { action: payload.action }),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,6 @@ export interface StoreInboundAttachmentParams {
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
/** Reference passed from ingestion (controller/bridge) to the orchestrator. */
|
||||
export interface StoredAttachmentRef {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One directory per attachment, so deleting a single file never touches
|
||||
* others. Inline agents share the `agents/inline/` directory (a real agent id
|
||||
|
||||
@@ -21,6 +21,12 @@ import { getDelegatedChildCheckpoints } from './utils/delegated-child-checkpoint
|
||||
|
||||
type ExecutionContext = PubSubCommandMap['cancel-agent-chat-execution'];
|
||||
|
||||
export interface CancelSuspendedRunParams {
|
||||
agentId: string;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export class AgentTurnAlreadyRunningError extends UserError {
|
||||
constructor() {
|
||||
super('A turn is already running in this conversation.');
|
||||
@@ -200,11 +206,7 @@ export class AgentChatExecutionService {
|
||||
});
|
||||
}
|
||||
|
||||
async cancelSuspended(params: {
|
||||
agentId: string;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
}): Promise<boolean> {
|
||||
async cancelSuspended(params: CancelSuspendedRunParams): Promise<boolean> {
|
||||
const checkpointStatus = await this.checkpointStorage.getStatus(params.runId, params.agentId);
|
||||
if (checkpointStatus.status === 'not-found' || checkpointStatus.checkpoint === undefined)
|
||||
return false;
|
||||
|
||||
@@ -21,10 +21,8 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { AgentsCredentialProvider } from './adapters/agents-credential-provider';
|
||||
import {
|
||||
AgentChatAttachmentService,
|
||||
type StoredAttachmentRef,
|
||||
} from './agent-chat-attachment.service';
|
||||
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
|
||||
import type { StoredAttachmentRef } from './types/agent-chat-attachment';
|
||||
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
|
||||
import { AgentExecutionRecordingError } from './agent-execution-recording.error';
|
||||
import {
|
||||
|
||||
@@ -16,10 +16,10 @@ import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
import {
|
||||
type AgentConfigPart,
|
||||
AgentModificationTelemetryService,
|
||||
diffAgentConfigParts,
|
||||
isUnconfiguredAgent,
|
||||
@@ -36,6 +36,7 @@ import { NodeToolAiGatewayService } from './json-config/node-tool-ai-gateway.ser
|
||||
import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials';
|
||||
import { AgentTaskRepository } from './repositories/agent-task.repository';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import { normalizeWorkflowToolRefs } from './tools/workflow-tool-workflow-resolver';
|
||||
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
|
||||
import { getAgentConfigHash } from './utils/agent-config-hash';
|
||||
@@ -46,6 +47,24 @@ import {
|
||||
validateNodeToolExpressions,
|
||||
} from './utils/node-tool-validation';
|
||||
import { resolveUniqueSubAgents, type ResolvedSubAgentRef } from './utils/sub-agent-resolver';
|
||||
import type { AgentsCredentialProvider } from './adapters/agents-credential-provider';
|
||||
|
||||
interface AgentConfigUpdateOptions {
|
||||
/** Hash of the config the caller read before editing; `null` when the agent had none. */
|
||||
baseConfigHash: string | null;
|
||||
clearOmittedOptionalFields?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
/** Push connection of the tab that made the change; excluded from the `agentUpdated` broadcast. */
|
||||
pushRef?: string;
|
||||
}
|
||||
|
||||
interface ConfigReplacement {
|
||||
nextSchema: AgentJsonConfig;
|
||||
nextIntegrations: NonNullable<Agent['integrations']>;
|
||||
previousSchema: AgentJsonConfig | null;
|
||||
previousIntegrations: NonNullable<Agent['integrations']>;
|
||||
changedParts: AgentConfigPart[];
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AgentConfigService {
|
||||
@@ -68,8 +87,12 @@ export class AgentConfigService {
|
||||
* Get the JSON config for an agent.
|
||||
*/
|
||||
async getConfig(agentId: string, projectId: string): Promise<AgentJsonConfig> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
const config = composeJsonConfig(entity);
|
||||
if (!config) {
|
||||
throw new UserError('Agent has no JSON config yet.');
|
||||
@@ -148,23 +171,232 @@ export class AgentConfigService {
|
||||
projectId: string,
|
||||
config: unknown,
|
||||
user: User,
|
||||
options: {
|
||||
/** Hash of the config the caller read before editing; `null` when the agent had none. */
|
||||
baseConfigHash: string | null;
|
||||
clearOmittedOptionalFields?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
/** Push connection of the tab that made the change; excluded from the `agentUpdated` broadcast. */
|
||||
pushRef?: string;
|
||||
},
|
||||
options: AgentConfigUpdateOptions,
|
||||
): Promise<AgentConfigMutationResponse> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
if (options.baseConfigHash !== getAgentConfigHash(composeJsonConfig(entity))) {
|
||||
throw new ConflictError(
|
||||
'Agent config was changed elsewhere; reload to get the latest version',
|
||||
);
|
||||
}
|
||||
|
||||
const { validatedConfig, credentialProvider, existingTaskIds } = await this.prepareConfig(
|
||||
entity,
|
||||
config,
|
||||
user,
|
||||
);
|
||||
const replacement = this.buildConfigReplacement(entity, validatedConfig, config, options);
|
||||
entity.schema = replacement.nextSchema;
|
||||
entity.name = validatedConfig.name;
|
||||
entity.integrations = replacement.nextIntegrations;
|
||||
markAgentDraftDirty(entity);
|
||||
this.removeUnreferencedResources(entity, validatedConfig);
|
||||
|
||||
const saved = await this.saveConfig(entity, credentialProvider, user, options, replacement);
|
||||
return await this.finishConfigUpdate(saved, validatedConfig, existingTaskIds, replacement);
|
||||
}
|
||||
|
||||
private async finishConfigUpdate(
|
||||
saved: Agent,
|
||||
validatedConfig: AgentJsonConfig,
|
||||
existingTaskIds: string[],
|
||||
replacement: ConfigReplacement,
|
||||
): Promise<AgentConfigMutationResponse> {
|
||||
await this.removeUnreferencedTasks(validatedConfig, existingTaskIds);
|
||||
if (validatedConfig.integrations !== undefined) {
|
||||
await syncAgentIntegrations(
|
||||
saved,
|
||||
replacement.previousIntegrations,
|
||||
replacement.nextIntegrations,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
const savedConfig = composeJsonConfig(saved) ?? validatedConfig;
|
||||
return {
|
||||
config: savedConfig,
|
||||
configHash: getAgentConfigHash(savedConfig),
|
||||
updatedAt: saved.updatedAt.toISOString(),
|
||||
versionId: saved.versionId,
|
||||
};
|
||||
}
|
||||
|
||||
private async removeUnreferencedTasks(
|
||||
config: AgentJsonConfig,
|
||||
existingTaskIds: string[],
|
||||
): Promise<void> {
|
||||
if (config.tasks === undefined) return;
|
||||
const referencedTaskIds = new Set(config.tasks.map((ref) => ref.id));
|
||||
const orphanTaskIds = existingTaskIds.filter((id) => !referencedTaskIds.has(id));
|
||||
if (orphanTaskIds.length > 0) await this.agentTaskRepository.delete(orphanTaskIds);
|
||||
}
|
||||
|
||||
private async saveConfig(
|
||||
entity: Agent,
|
||||
credentialProvider: AgentsCredentialProvider,
|
||||
user: User,
|
||||
options: AgentConfigUpdateOptions,
|
||||
replacement: ConfigReplacement,
|
||||
) {
|
||||
const { id: agentId, projectId } = entity;
|
||||
const { changedParts, previousSchema, previousIntegrations } = replacement;
|
||||
this.runtimeCacheService.clearRuntimes(agentId);
|
||||
|
||||
// Gate evaluated against the state about to be written; the marker is
|
||||
// claimed and reported only once that write succeeded.
|
||||
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
|
||||
entity,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
user,
|
||||
);
|
||||
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.eventService.emit('agent-saved', { agentId });
|
||||
// Every config writer (editor, builder, MCP) lands here, so this is where
|
||||
// other open Agent Builder tabs learn that their loaded config is stale.
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: options.modifiedBy },
|
||||
options.pushRef,
|
||||
);
|
||||
this.logger.debug('Updated agent JSON config', { agentId, projectId });
|
||||
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user,
|
||||
by: options.modifiedBy,
|
||||
changedParts,
|
||||
wasUnconfigured: isUnconfiguredAgent(previousSchema, previousIntegrations),
|
||||
});
|
||||
await emitSetupCompleted?.();
|
||||
return saved;
|
||||
}
|
||||
|
||||
private removeUnreferencedResources(entity: Agent, config: AgentJsonConfig): void {
|
||||
if (config.tools !== undefined) this.removeUnreferencedCustomTools(entity, config);
|
||||
if (config.skills !== undefined)
|
||||
this.agentSkillsService.removeUnreferencedSkills(entity, config);
|
||||
}
|
||||
|
||||
private removeUnreferencedCustomTools(entity: Agent, config: AgentJsonConfig): void {
|
||||
const referencedIds = new Set(
|
||||
(config.tools ?? [])
|
||||
.filter(
|
||||
(tool): tool is Extract<AgentJsonToolConfig, { type: 'custom' }> =>
|
||||
tool.type === 'custom',
|
||||
)
|
||||
.map((tool) => tool.id),
|
||||
);
|
||||
const orphanIds = Object.keys(entity.tools).filter((id) => !referencedIds.has(id));
|
||||
if (orphanIds.length === 0) return;
|
||||
const tools = { ...entity.tools };
|
||||
for (const id of orphanIds) delete tools[id];
|
||||
entity.tools = tools;
|
||||
}
|
||||
|
||||
private buildConfigReplacement(
|
||||
entity: Agent,
|
||||
validatedConfig: AgentJsonConfig,
|
||||
rawConfig: unknown,
|
||||
options: AgentConfigUpdateOptions,
|
||||
): ConfigReplacement {
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const previousSchema = entity.schema ?? null;
|
||||
const { schemaConfig, integrations } = decomposeJsonConfig(validatedConfig);
|
||||
const nextIntegrations =
|
||||
validatedConfig.integrations !== undefined ? integrations : previousIntegrations;
|
||||
const nextSchema = this.mergeConfigSchema(
|
||||
schemaConfig,
|
||||
previousSchema,
|
||||
rawConfig,
|
||||
options.clearOmittedOptionalFields,
|
||||
);
|
||||
// Compare before the entity is changed.
|
||||
const changedParts = diffAgentConfigParts(
|
||||
previousSchema,
|
||||
nextSchema,
|
||||
previousIntegrations,
|
||||
nextIntegrations,
|
||||
);
|
||||
return { nextSchema, nextIntegrations, previousSchema, previousIntegrations, changedParts };
|
||||
}
|
||||
|
||||
private mergeConfigSchema(
|
||||
decomposedSchema: AgentJsonConfig,
|
||||
previousSchema: AgentJsonConfig | null,
|
||||
config: unknown,
|
||||
clearOmitted: boolean | undefined,
|
||||
): AgentJsonConfig {
|
||||
// Under clearOmittedOptionalFields an omitted gradient is a deliberate
|
||||
// removal, so the schema default wins instead of the previous gradient.
|
||||
let nextPersonalisation = decomposedSchema.personalisation;
|
||||
if (decomposedSchema.personalisation !== undefined && !clearOmitted) {
|
||||
nextPersonalisation = mergePersonalisationWithPreviousGradient(
|
||||
decomposedSchema.personalisation,
|
||||
previousSchema,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
const nextSchema: AgentJsonConfig = {
|
||||
...omitLegacyAgentDescription(previousSchema),
|
||||
name: decomposedSchema.name,
|
||||
model: decomposedSchema.model,
|
||||
instructions: decomposedSchema.instructions,
|
||||
...(decomposedSchema.credential !== undefined
|
||||
? { credential: decomposedSchema.credential }
|
||||
: {}),
|
||||
...(decomposedSchema.personalisation !== undefined
|
||||
? { personalisation: nextPersonalisation }
|
||||
: {}),
|
||||
...(decomposedSchema.memory !== undefined ? { memory: decomposedSchema.memory } : {}),
|
||||
...(decomposedSchema.subAgents !== undefined
|
||||
? { subAgents: decomposedSchema.subAgents }
|
||||
: {}),
|
||||
...(decomposedSchema.tools !== undefined ? { tools: decomposedSchema.tools } : {}),
|
||||
...(decomposedSchema.skills !== undefined ? { skills: decomposedSchema.skills } : {}),
|
||||
...(decomposedSchema.tasks !== undefined ? { tasks: decomposedSchema.tasks } : {}),
|
||||
...(decomposedSchema.providerTools !== undefined
|
||||
? { providerTools: decomposedSchema.providerTools }
|
||||
: {}),
|
||||
...(decomposedSchema.config !== undefined ? { config: decomposedSchema.config } : {}),
|
||||
...(decomposedSchema.mcpServers !== undefined
|
||||
? { mcpServers: decomposedSchema.mcpServers }
|
||||
: {}),
|
||||
...(decomposedSchema.vectorStores !== undefined
|
||||
? { vectorStores: decomposedSchema.vectorStores }
|
||||
: {}),
|
||||
};
|
||||
this.normalizeOptionalConfigFields(nextSchema, decomposedSchema, clearOmitted);
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
private normalizeOptionalConfigFields(
|
||||
nextSchema: AgentJsonConfig,
|
||||
validatedConfig: AgentJsonConfig,
|
||||
clearOmitted: boolean | undefined,
|
||||
): void {
|
||||
if (validatedConfig.modelDeploymentName !== undefined) {
|
||||
const deploymentName = validatedConfig.modelDeploymentName?.trim();
|
||||
if (deploymentName) {
|
||||
nextSchema.modelDeploymentName = deploymentName;
|
||||
} else {
|
||||
delete nextSchema.modelDeploymentName;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearOmitted) {
|
||||
clearOmittedOptionalFields(nextSchema, validatedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareConfig(entity: Agent, config: unknown, user: User) {
|
||||
const { id: agentId, projectId } = entity;
|
||||
const credentialProvider = createAgentCredentialProvider(
|
||||
this.credentialsService,
|
||||
projectId,
|
||||
@@ -209,153 +441,7 @@ export class AgentConfigService {
|
||||
new Set(existingTaskIds),
|
||||
);
|
||||
this.validateSubAgentRefs(resolvedSubAgents, entity);
|
||||
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const previousSchema = entity.schema ?? null;
|
||||
|
||||
const integrationsProvided = validatedConfig.integrations !== undefined;
|
||||
const toolsProvided = validatedConfig.tools !== undefined;
|
||||
const skillsProvided = validatedConfig.skills !== undefined;
|
||||
const credentialProvided = validatedConfig.credential !== undefined;
|
||||
const modelDeploymentNameProvided = validatedConfig.modelDeploymentName !== undefined;
|
||||
const personalisationProvided = validatedConfig.personalisation !== undefined;
|
||||
const memoryProvided = validatedConfig.memory !== undefined;
|
||||
const subAgentsProvided = validatedConfig.subAgents !== undefined;
|
||||
const providerToolsProvided = validatedConfig.providerTools !== undefined;
|
||||
const configBlockProvided = validatedConfig.config !== undefined;
|
||||
const mcpServersProvided = validatedConfig.mcpServers !== undefined;
|
||||
const vectorStoresProvided = validatedConfig.vectorStores !== undefined;
|
||||
|
||||
const { schemaConfig: decomposedSchema, integrations: decomposedIntegrations } =
|
||||
decomposeJsonConfig(validatedConfig);
|
||||
|
||||
const nextIntegrations = integrationsProvided ? decomposedIntegrations : previousIntegrations;
|
||||
// Under clearOmittedOptionalFields an omitted gradient is a deliberate
|
||||
// removal, so the schema default wins instead of the previous gradient.
|
||||
const nextPersonalisation = personalisationProvided
|
||||
? options?.clearOmittedOptionalFields
|
||||
? decomposedSchema.personalisation
|
||||
: mergePersonalisationWithPreviousGradient(
|
||||
decomposedSchema.personalisation,
|
||||
previousSchema,
|
||||
config,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const nextSchema: AgentJsonConfig = {
|
||||
...omitLegacyAgentDescription(previousSchema),
|
||||
name: decomposedSchema.name,
|
||||
model: decomposedSchema.model,
|
||||
instructions: decomposedSchema.instructions,
|
||||
...(credentialProvided ? { credential: decomposedSchema.credential } : {}),
|
||||
...(personalisationProvided ? { personalisation: nextPersonalisation } : {}),
|
||||
...(memoryProvided ? { memory: decomposedSchema.memory } : {}),
|
||||
...(subAgentsProvided ? { subAgents: decomposedSchema.subAgents } : {}),
|
||||
...(toolsProvided ? { tools: decomposedSchema.tools } : {}),
|
||||
...(skillsProvided ? { skills: decomposedSchema.skills } : {}),
|
||||
...(tasksProvided ? { tasks: decomposedSchema.tasks } : {}),
|
||||
...(providerToolsProvided ? { providerTools: decomposedSchema.providerTools } : {}),
|
||||
...(configBlockProvided ? { config: decomposedSchema.config } : {}),
|
||||
...(mcpServersProvided ? { mcpServers: decomposedSchema.mcpServers } : {}),
|
||||
...(vectorStoresProvided ? { vectorStores: decomposedSchema.vectorStores } : {}),
|
||||
};
|
||||
|
||||
if (modelDeploymentNameProvided) {
|
||||
const deploymentName = decomposedSchema.modelDeploymentName?.trim();
|
||||
if (deploymentName) {
|
||||
nextSchema.modelDeploymentName = deploymentName;
|
||||
} else {
|
||||
delete nextSchema.modelDeploymentName;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.clearOmittedOptionalFields) {
|
||||
clearOmittedOptionalFields(nextSchema, validatedConfig);
|
||||
}
|
||||
|
||||
// Diffed against what is about to be written, before `entity` is mutated.
|
||||
const changedParts = diffAgentConfigParts(
|
||||
previousSchema,
|
||||
nextSchema,
|
||||
previousIntegrations,
|
||||
nextIntegrations,
|
||||
);
|
||||
|
||||
entity.schema = nextSchema;
|
||||
entity.name = validatedConfig.name;
|
||||
entity.integrations = nextIntegrations;
|
||||
markAgentDraftDirty(entity);
|
||||
|
||||
if (toolsProvided) {
|
||||
const referencedIds = new Set(
|
||||
(validatedConfig.tools ?? [])
|
||||
.filter((t): t is Extract<AgentJsonToolConfig, { type: 'custom' }> => t.type === 'custom')
|
||||
.map((t) => t.id),
|
||||
);
|
||||
const orphanIds = Object.keys(entity.tools).filter((id) => !referencedIds.has(id));
|
||||
if (orphanIds.length > 0) {
|
||||
const tools = { ...entity.tools };
|
||||
for (const id of orphanIds) {
|
||||
delete tools[id];
|
||||
}
|
||||
entity.tools = tools;
|
||||
}
|
||||
}
|
||||
|
||||
if (skillsProvided) {
|
||||
this.agentSkillsService.removeUnreferencedSkills(entity, validatedConfig);
|
||||
}
|
||||
|
||||
this.runtimeCacheService.clearRuntimes(agentId);
|
||||
|
||||
// Gate evaluated against the state about to be written; the marker is
|
||||
// claimed and reported only once that write succeeded.
|
||||
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
|
||||
entity,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
user,
|
||||
);
|
||||
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.eventService.emit('agent-saved', { agentId });
|
||||
// Every config writer (editor, builder, MCP) lands here, so this is where
|
||||
// other open Agent Builder tabs learn that their loaded config is stale.
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: options.modifiedBy },
|
||||
options.pushRef,
|
||||
);
|
||||
this.logger.debug('Updated agent JSON config', { agentId, projectId });
|
||||
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user,
|
||||
by: options.modifiedBy,
|
||||
changedParts,
|
||||
wasUnconfigured: isUnconfiguredAgent(previousSchema, previousIntegrations),
|
||||
});
|
||||
await emitSetupCompleted?.();
|
||||
|
||||
if (tasksProvided) {
|
||||
const referencedTaskIds = new Set((validatedConfig.tasks ?? []).map((ref) => ref.id));
|
||||
const orphanTaskIds = existingTaskIds.filter((id) => !referencedTaskIds.has(id));
|
||||
if (orphanTaskIds.length > 0) {
|
||||
await this.agentTaskRepository.delete(orphanTaskIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (integrationsProvided) {
|
||||
await syncAgentIntegrations(saved, previousIntegrations, nextIntegrations, this.logger);
|
||||
}
|
||||
|
||||
const savedConfig = composeJsonConfig(saved) ?? validatedConfig;
|
||||
return {
|
||||
config: savedConfig,
|
||||
configHash: getAgentConfigHash(savedConfig),
|
||||
updatedAt: saved.updatedAt.toISOString(),
|
||||
versionId: saved.versionId,
|
||||
};
|
||||
return { validatedConfig, credentialProvider, existingTaskIds };
|
||||
}
|
||||
|
||||
private async removeMissingConfigRefs(
|
||||
|
||||
@@ -9,18 +9,17 @@ import { Service } from '@n8n/di';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import {
|
||||
AgentModificationTelemetryService,
|
||||
type AgentMutationTelemetryContext,
|
||||
diffAgentConfigParts,
|
||||
buildAgentMutationEvent,
|
||||
captureAgentMutation,
|
||||
} from './agent-modification-telemetry.service';
|
||||
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
|
||||
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { isUnconfiguredAgent } from './utils/agent-capabilities';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
|
||||
|
||||
type AgentToolEntries = Agent['tools'];
|
||||
@@ -48,8 +47,12 @@ export class AgentCustomToolsService {
|
||||
context: AgentMutationTelemetryContext,
|
||||
options: { recordTelemetry?: boolean } = {},
|
||||
): Promise<{ ok: boolean; id: string; descriptor: ToolDescriptor }> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
|
||||
if (!CUSTOM_TOOL_ID_REGEX.test(descriptor.name)) {
|
||||
throw new UserError(
|
||||
@@ -63,37 +66,18 @@ export class AgentCustomToolsService {
|
||||
return { ok: true, id: toolId, descriptor };
|
||||
}
|
||||
|
||||
const previousSchema = entity.schema ?? null;
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(entity);
|
||||
|
||||
entity.tools = {
|
||||
...entity.tools,
|
||||
[toolId]: nextEntry,
|
||||
};
|
||||
|
||||
markAgentDraftDirty(entity);
|
||||
this.runtimeCacheService.clearRuntimes(agentId);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
);
|
||||
const saved = await this.saveToolChanges(entity, projectId, context);
|
||||
if (options.recordTelemetry !== false) {
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
saved.schema,
|
||||
previousIntegrations,
|
||||
saved.integrations ?? [],
|
||||
{ tools: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
this.modificationTelemetry.record(
|
||||
buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug('Built custom tool', { agentId, projectId, toolId });
|
||||
@@ -110,13 +94,15 @@ export class AgentCustomToolsService {
|
||||
toolId: string,
|
||||
context: AgentMutationTelemetryContext,
|
||||
): Promise<void> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
if (!entity.tools?.[toolId]) return;
|
||||
|
||||
const previousSchema = entity.schema ?? null;
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(entity);
|
||||
|
||||
const tools = { ...entity.tools };
|
||||
delete tools[toolId];
|
||||
@@ -128,27 +114,10 @@ export class AgentCustomToolsService {
|
||||
);
|
||||
}
|
||||
|
||||
markAgentDraftDirty(entity);
|
||||
this.runtimeCacheService.clearRuntimes(agentId);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
const saved = await this.saveToolChanges(entity, projectId, context);
|
||||
this.modificationTelemetry.record(
|
||||
buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }),
|
||||
);
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
saved.schema,
|
||||
previousIntegrations,
|
||||
saved.integrations ?? [],
|
||||
{ tools: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
|
||||
this.logger.debug('Deleted custom tool', { agentId, projectId, toolId });
|
||||
}
|
||||
@@ -190,4 +159,18 @@ export class AgentCustomToolsService {
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
private async saveToolChanges(
|
||||
entity: Agent,
|
||||
projectId: string,
|
||||
context: AgentMutationTelemetryContext,
|
||||
): Promise<Agent> {
|
||||
markAgentDraftDirty(entity);
|
||||
this.runtimeCacheService.clearRuntimes(entity.id);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId: entity.id, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CredentialListItem } from '@n8n/agents';
|
||||
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
|
||||
import { AI_GATEWAY_MANAGED_TAG, type AgentModelCredentialConfig } from '@n8n/api-types';
|
||||
import { isModelDiscoveryProvider } from '@n8n/ai-utilities/model-discovery';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
@@ -12,11 +12,6 @@ import { LLM_PROVIDER_DEFAULTS, LLM_PROVIDER_PRIORITY } from './llm-provider-def
|
||||
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
|
||||
import { findVerifiedModelId } from './utils/provider-model-id';
|
||||
|
||||
export interface ResolvedAgentDefaultModel {
|
||||
model: string;
|
||||
credential: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a sensible default model+credential for a new agent, and answers
|
||||
* "is the maintained default still in this provider's verified model list?"
|
||||
@@ -42,7 +37,7 @@ export class AgentDefaultModelResolverService {
|
||||
* personal LLM credential. Returns `null` when the choice is ambiguous
|
||||
* or the default is not live — the caller keeps the agent as a draft.
|
||||
*/
|
||||
async resolve(user: User, projectId: string): Promise<ResolvedAgentDefaultModel | null> {
|
||||
async resolve(user: User, projectId: string): Promise<AgentModelCredentialConfig | null> {
|
||||
const credentials = await createAgentCredentialProvider(
|
||||
this.credentialsService,
|
||||
projectId,
|
||||
@@ -84,7 +79,7 @@ export class AgentDefaultModelResolverService {
|
||||
provider: string,
|
||||
credentialId: string,
|
||||
verifiedModelIds: readonly string[],
|
||||
): ResolvedAgentDefaultModel | null {
|
||||
): AgentModelCredentialConfig | null {
|
||||
const defaults = this.findProviderDefault(provider);
|
||||
if (!defaults) return null;
|
||||
|
||||
@@ -110,7 +105,7 @@ export class AgentDefaultModelResolverService {
|
||||
private async resolveManagedOpenAi(
|
||||
user: User,
|
||||
projectId: string,
|
||||
): Promise<ResolvedAgentDefaultModel | null> {
|
||||
): Promise<AgentModelCredentialConfig | null> {
|
||||
const defaults = LLM_PROVIDER_DEFAULTS.openAiApi;
|
||||
let credentialType: string | undefined;
|
||||
try {
|
||||
@@ -133,7 +128,7 @@ export class AgentDefaultModelResolverService {
|
||||
user: User,
|
||||
projectId: string,
|
||||
credential: CredentialListItem | undefined,
|
||||
): Promise<ResolvedAgentDefaultModel | null> {
|
||||
): Promise<AgentModelCredentialConfig | null> {
|
||||
if (!credential) return null;
|
||||
|
||||
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import type { PushPayload } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
@@ -8,9 +7,9 @@ import { InstanceSettings } from 'n8n-core';
|
||||
import { Push } from '@/push';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
import type { AgentExecutionThread } from './entities/agent-execution-thread.entity';
|
||||
import { AgentPushRecipientsService } from './agent-push-recipients.service';
|
||||
import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository';
|
||||
import { threadBelongsTo } from './utils/agent-thread-access';
|
||||
|
||||
@@ -20,12 +19,11 @@ type AgentExecutionUpdate = PushPayload<'agentExecutionUpdated'>;
|
||||
export class AgentExecutionUpdateBroadcaster {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly recipients: AgentPushRecipientsService,
|
||||
private readonly push: Push,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly threadRepository: AgentExecutionThreadRepository,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
this.logger = this.logger.scoped('agents');
|
||||
}
|
||||
@@ -80,15 +78,7 @@ export class AgentExecutionUpdateBroadcaster {
|
||||
}
|
||||
|
||||
private async getRecipients(thread: AgentExecutionThread): Promise<string[]> {
|
||||
const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
|
||||
this.roleService.rolesWithScope('global', ['agent:read']),
|
||||
this.roleService.rolesWithScope('project', ['agent:read']),
|
||||
]);
|
||||
const userIds = await this.userRepository.findIdsWithGlobalOrProjectRoles({
|
||||
projectIds: [thread.projectId],
|
||||
projectRoleSlugs,
|
||||
globalRoleSlugs,
|
||||
});
|
||||
const userIds = await this.recipients.getProjectReaders(thread.projectId);
|
||||
return userIds.filter((id) => threadBelongsTo(thread, thread.projectId, thread.agentId, id));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentExecutionStatus,
|
||||
AgentMessageAuthor,
|
||||
AgentSessionPreviewAccess,
|
||||
AgentSessionQueryFilters,
|
||||
@@ -16,18 +17,20 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import {
|
||||
AgentChatAttachmentService,
|
||||
type StoredAttachmentRef,
|
||||
} from './agent-chat-attachment.service';
|
||||
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
|
||||
import type { StoredAttachmentRef } from './types/agent-chat-attachment';
|
||||
import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster';
|
||||
import { buildAgentTurnMetrics } from './agent-telemetry';
|
||||
import {
|
||||
AgentExecutionThread,
|
||||
type AgentThreadAccess,
|
||||
} from './entities/agent-execution-thread.entity';
|
||||
import { AgentExecution, type AgentExecutionStatus } from './entities/agent-execution.entity';
|
||||
import { AgentExecution, type AgentExecutionHitlStatus } from './entities/agent-execution.entity';
|
||||
import type { MessageRecord, TimelineEvent } from './execution-recorder';
|
||||
import { AgentExecutionLogStore } from './execution-log/agent-execution-log-store';
|
||||
import {
|
||||
AgentExecutionLogStore,
|
||||
type AgentExecutionLogRef,
|
||||
} from './execution-log/agent-execution-log-store';
|
||||
import { N8nMemory } from './integrations/n8n-memory';
|
||||
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
|
||||
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
|
||||
@@ -60,7 +63,7 @@ export interface RecordMessageParams {
|
||||
attachments?: StoredAttachmentRef[];
|
||||
record: MessageRecord;
|
||||
/** Set to 'suspended' or 'resumed' for HITL tool call flows. */
|
||||
hitlStatus?: 'suspended' | 'resumed';
|
||||
hitlStatus?: AgentExecutionHitlStatus;
|
||||
/** Where the message originated from, e.g. 'chat', 'slack', 'task'. */
|
||||
source?: string;
|
||||
/** Optional metadata persisted on the thread when it is first created. */
|
||||
@@ -83,11 +86,8 @@ export interface StartExecutionParams extends Omit<RecordMessageParams, 'record'
|
||||
initialTimeline?: TimelineEvent[];
|
||||
}
|
||||
|
||||
interface TimelineSnapshotParams {
|
||||
executionId: string;
|
||||
interface TimelineSnapshotParams extends AgentExecutionLogRef {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
threadId: string;
|
||||
timeline: TimelineEvent[];
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ export class AgentExecutionService {
|
||||
async finalizeExecution(executionId: string, params: RecordMessageParams): Promise<string> {
|
||||
this.stopHeartbeat(executionId);
|
||||
this.pendingTimelineSnapshots.delete(executionId);
|
||||
const { record, hitlStatus } = params;
|
||||
const { record } = params;
|
||||
const status = executionStatus(record);
|
||||
const stoppedAt = new Date(record.startTime + record.duration);
|
||||
const failureSummary = computeExecutionFailureSummary({
|
||||
@@ -209,41 +209,10 @@ export class AgentExecutionService {
|
||||
record.timeline.length > 0 ? this.storageConfig.modeTag : 'db';
|
||||
|
||||
try {
|
||||
await this.timelineSnapshotWrites.get(executionId);
|
||||
const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, {
|
||||
status,
|
||||
stoppedAt,
|
||||
duration: record.duration,
|
||||
model: record.model,
|
||||
promptTokens: record.usage?.promptTokens ?? null,
|
||||
completionTokens: record.usage?.completionTokens ?? null,
|
||||
totalTokens: record.usage?.totalTokens ?? null,
|
||||
cost: record.totalCost,
|
||||
timeline: record.timeline.length > 0 ? record.timeline : null,
|
||||
storedAt: 'db',
|
||||
error: record.error,
|
||||
failureSummary,
|
||||
hitlStatus: hitlStatus ?? null,
|
||||
});
|
||||
if (!finalized) {
|
||||
throw new OperationalError('Agent execution is no longer running', {
|
||||
extra: { executionId },
|
||||
});
|
||||
}
|
||||
await this.writeTerminalExecution(executionId, params, status, stoppedAt, failureSummary);
|
||||
|
||||
// Save the terminal row first. A rejected finalization must not replace a stored blob.
|
||||
if (storedAt !== 'db') {
|
||||
const logRef = { agentId: params.agentId, threadId: params.threadId, executionId };
|
||||
let blobWritten = false;
|
||||
try {
|
||||
await this.agentExecutionLogStore.write(logRef, { timeline: record.timeline }, storedAt);
|
||||
blobWritten = true;
|
||||
await this.agentExecutionRepository.moveTimelineToBlob(executionId, storedAt);
|
||||
} catch (error) {
|
||||
this.errorReporter.error(error);
|
||||
if (blobWritten) await this.deleteUnreferencedTimelineBlob(logRef, storedAt);
|
||||
}
|
||||
}
|
||||
await this.moveFinalTimelineToBlob(executionId, params, storedAt);
|
||||
|
||||
this.executionUpdateBroadcaster.notify({
|
||||
projectId: params.projectId,
|
||||
@@ -262,7 +231,7 @@ export class AgentExecutionService {
|
||||
}
|
||||
|
||||
private async deleteUnreferencedTimelineBlob(
|
||||
ref: { agentId: string; threadId: string; executionId: string },
|
||||
ref: AgentExecutionLogRef,
|
||||
storedAt: StorageLocation,
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -361,36 +330,7 @@ export class AgentExecutionService {
|
||||
const snapshot = this.pendingTimelineSnapshots.get(executionId);
|
||||
if (!snapshot) return;
|
||||
this.pendingTimelineSnapshots.delete(executionId);
|
||||
try {
|
||||
if (
|
||||
!(await this.agentExecutionRepository.updateTimelineIfRunning(
|
||||
executionId,
|
||||
snapshot.timeline,
|
||||
))
|
||||
) {
|
||||
this.pendingTimelineSnapshots.delete(executionId);
|
||||
return;
|
||||
}
|
||||
this.executionUpdateBroadcaster.notify({
|
||||
projectId: snapshot.projectId,
|
||||
agentId: snapshot.agentId,
|
||||
threadId: snapshot.threadId,
|
||||
executionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.heartbeatTimers.has(executionId)) return;
|
||||
if (!this.pendingTimelineSnapshots.has(executionId)) {
|
||||
this.pendingTimelineSnapshots.set(executionId, snapshot);
|
||||
}
|
||||
this.logger.warn('Failed to persist an agent execution timeline snapshot; retrying', {
|
||||
executionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, TIMELINE_SNAPSHOT_RETRY_DELAY_MS);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
if (!(await this.writeTimelineSnapshot(executionId, snapshot))) return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,53 +359,10 @@ export class AgentExecutionService {
|
||||
executionId: string,
|
||||
status: AgentExecution['status'],
|
||||
): Promise<void> {
|
||||
const { threadId, agentId, record, hitlStatus } = params;
|
||||
const updates: Array<Promise<unknown>> = [];
|
||||
if (hitlStatus === 'resumed' && record.model) {
|
||||
updates.push(this.backfillSuspendedExecutions(threadId, record.model));
|
||||
}
|
||||
if (record.usage) {
|
||||
updates.push(
|
||||
this.agentExecutionThreadRepository.incrementUsage(
|
||||
threadId,
|
||||
record.usage.promptTokens,
|
||||
record.usage.completionTokens,
|
||||
record.totalCost ?? 0,
|
||||
record.duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const result of await Promise.allSettled(updates)) {
|
||||
if (result.status === 'rejected') {
|
||||
this.logger.warn('Failed to update agent execution thread metadata', {
|
||||
executionId,
|
||||
threadId,
|
||||
error: result.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (params.telemetry) {
|
||||
try {
|
||||
this.telemetry.trackAgentTurnFinished({
|
||||
agent_id: agentId,
|
||||
user_id: params.telemetry.userId,
|
||||
thread_id: threadId,
|
||||
run_type: params.telemetry.runType,
|
||||
turn_status: status === 'success' ? 'succeeded' : 'failed',
|
||||
configuration: params.telemetry.configuration,
|
||||
latency_ms: record.duration,
|
||||
cost: record.totalCost ?? 0,
|
||||
token_count: record.usage?.totalTokens ?? 0,
|
||||
tool_call_count: record.timeline.filter((event) => event.type === 'tool-call').length,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to track agent execution telemetry', {
|
||||
agentId,
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
const { threadId, agentId, record } = params;
|
||||
await this.updateThreadAfterExecution(params, executionId);
|
||||
|
||||
this.trackFinishedTurn(params, status);
|
||||
this.logger.debug('Recorded agent execution', {
|
||||
executionId,
|
||||
threadId,
|
||||
@@ -754,6 +651,146 @@ export class AgentExecutionService {
|
||||
private toBlobRefs<T extends { storedAt: AgentExecution['storedAt'] }>(refs: T[]) {
|
||||
return refs.filter((r): r is T & { storedAt: StorageLocation } => r.storedAt !== 'db');
|
||||
}
|
||||
|
||||
private async writeTerminalExecution(
|
||||
executionId: string,
|
||||
params: RecordMessageParams,
|
||||
status: AgentExecutionStatus,
|
||||
stoppedAt: Date,
|
||||
failureSummary: ReturnType<typeof computeExecutionFailureSummary>,
|
||||
): Promise<void> {
|
||||
const { record, hitlStatus } = params;
|
||||
await this.timelineSnapshotWrites.get(executionId);
|
||||
const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, {
|
||||
status,
|
||||
stoppedAt,
|
||||
duration: record.duration,
|
||||
model: record.model,
|
||||
promptTokens: record.usage?.promptTokens ?? null,
|
||||
completionTokens: record.usage?.completionTokens ?? null,
|
||||
totalTokens: record.usage?.totalTokens ?? null,
|
||||
cost: record.totalCost,
|
||||
timeline: record.timeline.length > 0 ? record.timeline : null,
|
||||
storedAt: 'db',
|
||||
error: record.error,
|
||||
failureSummary,
|
||||
hitlStatus: hitlStatus ?? null,
|
||||
});
|
||||
if (!finalized) {
|
||||
throw new OperationalError('Agent execution is no longer running', {
|
||||
extra: { executionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async moveFinalTimelineToBlob(
|
||||
executionId: string,
|
||||
params: RecordMessageParams,
|
||||
storedAt: AgentExecution['storedAt'],
|
||||
): Promise<void> {
|
||||
if (storedAt === 'db') return;
|
||||
const { record } = params;
|
||||
const logRef = { agentId: params.agentId, threadId: params.threadId, executionId };
|
||||
let blobWritten = false;
|
||||
try {
|
||||
await this.agentExecutionLogStore.write(logRef, { timeline: record.timeline }, storedAt);
|
||||
blobWritten = true;
|
||||
await this.agentExecutionRepository.moveTimelineToBlob(executionId, storedAt);
|
||||
} catch (error) {
|
||||
this.errorReporter.error(error);
|
||||
if (blobWritten) await this.deleteUnreferencedTimelineBlob(logRef, storedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeTimelineSnapshot(
|
||||
executionId: string,
|
||||
snapshot: Omit<TimelineSnapshotParams, 'executionId'>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (
|
||||
!(await this.agentExecutionRepository.updateTimelineIfRunning(
|
||||
executionId,
|
||||
snapshot.timeline,
|
||||
))
|
||||
) {
|
||||
this.pendingTimelineSnapshots.delete(executionId);
|
||||
return false;
|
||||
}
|
||||
this.executionUpdateBroadcaster.notify({
|
||||
projectId: snapshot.projectId,
|
||||
agentId: snapshot.agentId,
|
||||
threadId: snapshot.threadId,
|
||||
executionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.heartbeatTimers.has(executionId)) return false;
|
||||
if (!this.pendingTimelineSnapshots.has(executionId)) {
|
||||
this.pendingTimelineSnapshots.set(executionId, snapshot);
|
||||
}
|
||||
this.logger.warn('Failed to persist an agent execution timeline snapshot; retrying', {
|
||||
executionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, TIMELINE_SNAPSHOT_RETRY_DELAY_MS);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async updateThreadAfterExecution(
|
||||
params: RecordMessageParams,
|
||||
executionId: string,
|
||||
): Promise<void> {
|
||||
const { threadId, record, hitlStatus } = params;
|
||||
const updates: Array<Promise<unknown>> = [];
|
||||
if (hitlStatus === 'resumed' && record.model) {
|
||||
updates.push(this.backfillSuspendedExecutions(threadId, record.model));
|
||||
}
|
||||
if (record.usage) {
|
||||
updates.push(
|
||||
this.agentExecutionThreadRepository.incrementUsage(
|
||||
threadId,
|
||||
record.usage.promptTokens,
|
||||
record.usage.completionTokens,
|
||||
record.totalCost ?? 0,
|
||||
record.duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const result of await Promise.allSettled(updates)) {
|
||||
if (result.status === 'rejected') {
|
||||
this.logger.warn('Failed to update agent execution thread metadata', {
|
||||
executionId,
|
||||
threadId,
|
||||
error: result.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trackFinishedTurn(params: RecordMessageParams, status: AgentExecutionStatus): void {
|
||||
if (!params.telemetry) return;
|
||||
const { agentId, threadId, record } = params;
|
||||
try {
|
||||
this.telemetry.trackAgentTurnFinished({
|
||||
agent_id: agentId,
|
||||
user_id: params.telemetry.userId,
|
||||
thread_id: threadId,
|
||||
run_type: params.telemetry.runType,
|
||||
turn_status: status === 'success' ? 'succeeded' : 'failed',
|
||||
configuration: params.telemetry.configuration,
|
||||
...buildAgentTurnMetrics(record),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to track agent execution telemetry', {
|
||||
agentId,
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionStatus(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { runSerially } from '@n8n/utils/run-serially';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
AgentIntegrationPersistenceService,
|
||||
matchesIntegrationRef,
|
||||
type IntegrationDeltaResult,
|
||||
type IntegrationRef,
|
||||
type IntegrationDelta,
|
||||
} from './agent-integration-persistence.service';
|
||||
import type { AgentActor } from './agent-modification-telemetry.service';
|
||||
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
|
||||
@@ -23,10 +24,26 @@ import type { Agent } from './entities/agent.entity';
|
||||
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
|
||||
import { ChatIntegrationService } from './integrations/chat-integration.service';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import type { IntegrationRef } from './utils/agent-channel';
|
||||
|
||||
interface IntegrationChangeOptions extends IntegrationDelta {
|
||||
agent: Agent;
|
||||
user: User;
|
||||
cleanupRemovedIntegration?: boolean;
|
||||
deleteExternalResource?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
pushRef?: string;
|
||||
}
|
||||
|
||||
interface IntegrationRuntimeState {
|
||||
published: boolean;
|
||||
wasLive: boolean;
|
||||
persisted?: AgentIntegrationConfig;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AgentIntegrationManagementService {
|
||||
/** One in-flight channel mutation per agent; see {@link serializePerAgent}. */
|
||||
/** Serialize channel changes per agent within this process. */
|
||||
private readonly mutations = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(
|
||||
@@ -112,127 +129,35 @@ export class AgentIntegrationManagementService {
|
||||
* 3. release the replaced connection — only once the write is durable, so a
|
||||
* failed write leaves the existing channel live.
|
||||
*/
|
||||
private async applyChange(options: {
|
||||
agent: Agent;
|
||||
user: User;
|
||||
add?: AgentIntegrationConfig;
|
||||
remove?: IntegrationRef;
|
||||
cleanupRemovedIntegration?: boolean;
|
||||
deleteExternalResource?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
pushRef?: string;
|
||||
}): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
|
||||
return await this.serializePerAgent(
|
||||
private async applyChange(
|
||||
options: IntegrationChangeOptions,
|
||||
): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
|
||||
// Keep startup, persistence, and teardown in one queue to prevent interleaved changes.
|
||||
return await runSerially(
|
||||
this.mutations,
|
||||
options.agent.id,
|
||||
async () => await this.runChange(options),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one channel mutation per agent at a time.
|
||||
*
|
||||
* Steps 1 and 3 straddle the write, so two mutations on the same channel can
|
||||
* interleave: a removal releasing after its write can tear down the
|
||||
* connection a concurrent re-connect established before its own write, leaving
|
||||
* the channel persisted with nothing running. Queueing them removes the
|
||||
* interleaving outright. This is per-process — durable state is still
|
||||
* protected by the compare-and-set, and runtime state is per-main anyway.
|
||||
*/
|
||||
private async serializePerAgent<T>(agentId: string, work: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(agentId) ?? Promise.resolve();
|
||||
const run = previous.catch(() => {}).then(work);
|
||||
this.mutations.set(agentId, run);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.mutations.get(agentId) === run) this.mutations.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async runChange(options: {
|
||||
agent: Agent;
|
||||
user: User;
|
||||
add?: AgentIntegrationConfig;
|
||||
remove?: IntegrationRef;
|
||||
cleanupRemovedIntegration?: boolean;
|
||||
deleteExternalResource?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
pushRef?: string;
|
||||
}): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
|
||||
private async runChange(
|
||||
options: IntegrationChangeOptions,
|
||||
): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
|
||||
const { agent, add } = options;
|
||||
// "Replace this channel with itself" is just a connect. Left as a removal,
|
||||
// step 3 would release the connection step 1 just brought up, because both
|
||||
// resolve to the same runtime connection.
|
||||
// A self-replacement must not release the connection that it just started.
|
||||
const remove =
|
||||
options.remove && add && matchesIntegrationRef(add, options.remove)
|
||||
? undefined
|
||||
: options.remove;
|
||||
|
||||
// The entity can predate a publish or another channel write, so read the row
|
||||
// for both decisions that depend on it: whether to connect at all, and what
|
||||
// a rollback would restore to. The write does its own read and reconciles
|
||||
// anything that lands after this one.
|
||||
const state =
|
||||
add || options.cleanupRemovedIntegration
|
||||
? await this.agentRepository.findIntegrationState(agent.id)
|
||||
: null;
|
||||
const publishedBefore = state ? state.activeVersionId !== null : agent.activeVersionId !== null;
|
||||
|
||||
// `connect` restarts a connection that is already live — a settings-only
|
||||
// save, or re-connecting the same credential — so a rollback has to know
|
||||
// whether step 1 created this connection or merely replaced it.
|
||||
//
|
||||
// An unpublished agent has no live channel to begin with: it never receives
|
||||
// events, so nothing was started for it to restore. The publication check is
|
||||
// what makes that explicit — `isChannelLive` reports a leader-routed channel
|
||||
// as live without being able to inspect the leader, so on its own it would
|
||||
// have a draft's failed pre-connect validation start a runtime.
|
||||
const wasLive = !!add && publishedBefore && this.chatService.isChannelLive(agent.id, add);
|
||||
const persistedBefore =
|
||||
add && state
|
||||
? (state.integrations ?? []).find((entry) => matchesIntegrationRef(entry, add))
|
||||
: undefined;
|
||||
|
||||
let connected: boolean;
|
||||
try {
|
||||
connected = add ? await this.startRuntime(agent, add, publishedBefore) : false;
|
||||
} catch (error) {
|
||||
// `connect` releases the live connection before it builds the new one, so
|
||||
// a failed rebuild leaves a still-persisted channel with nothing running.
|
||||
if (wasLive) await this.restorePersistedRuntime(agent, persistedBefore);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let result: IntegrationDeltaResult;
|
||||
try {
|
||||
result = await this.persistenceService.applyIntegrationDelta(
|
||||
agent,
|
||||
{ add, remove },
|
||||
{ user: options.user, modifiedBy: options.modifiedBy },
|
||||
);
|
||||
} catch (error) {
|
||||
// Undo step 1 on this main only. Nothing durable changed, so the cluster's
|
||||
// view is still correct — broadcasting a disconnect would have every peer
|
||||
// tear down a runtime that is still persisted and still healthy. Neither
|
||||
// path touches thread subscriptions, which the row still justifies.
|
||||
if (connected && add) {
|
||||
if (wasLive && persistedBefore) {
|
||||
// `connect` releases the existing key before rebuilding, so restoring
|
||||
// both drops the failed attempt and puts the previous entry back.
|
||||
await this.restorePersistedRuntime(agent, persistedBefore);
|
||||
} else {
|
||||
await this.releaseRuntimeQuietly(agent, add);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const previous = await this.loadRuntimeBeforeChange(options);
|
||||
let connected = await this.startAddedRuntime(options, previous);
|
||||
const result = await this.persistChange(options, remove, connected, previous);
|
||||
if (result.changed) {
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId: agent.projectId, agentId: agent.id, source: options.modifiedBy },
|
||||
options.pushRef,
|
||||
);
|
||||
}
|
||||
|
||||
if (add && result.published !== undefined) {
|
||||
connected = await this.reconcileRuntimeWithPublication(
|
||||
agent,
|
||||
@@ -241,33 +166,100 @@ export class AgentIntegrationManagementService {
|
||||
result.published,
|
||||
);
|
||||
}
|
||||
|
||||
const isPublished = result.published ?? publishedBefore;
|
||||
let warning: AgentIntegrationDisconnectWarning | undefined;
|
||||
try {
|
||||
warning =
|
||||
result.removed && options.cleanupRemovedIntegration
|
||||
? await this.registry.get(result.removed.type)?.onRemove?.({
|
||||
agentId: agent.id,
|
||||
projectId: agent.projectId,
|
||||
credentialId: result.removed.credentialId,
|
||||
user: options.user,
|
||||
deleteExternalResource:
|
||||
// if not published, by default delete the external resource
|
||||
options.deleteExternalResource ?? !isPublished,
|
||||
})
|
||||
: undefined;
|
||||
} finally {
|
||||
if (remove) await this.releaseRemoved(agent, remove, result);
|
||||
}
|
||||
|
||||
const warning = await this.cleanupRemovedIntegration(
|
||||
options,
|
||||
remove,
|
||||
result,
|
||||
previous.published,
|
||||
);
|
||||
if (connected && add) {
|
||||
await this.chatService.broadcastIntegrationChange(agent.id, add, 'connect');
|
||||
}
|
||||
|
||||
return { ...result, ...(warning ? { warning } : {}) };
|
||||
}
|
||||
|
||||
private async cleanupRemovedIntegration(
|
||||
options: IntegrationChangeOptions,
|
||||
remove: IntegrationRef | undefined,
|
||||
result: IntegrationDeltaResult,
|
||||
publishedBefore: boolean,
|
||||
): Promise<AgentIntegrationDisconnectWarning | undefined> {
|
||||
const { agent } = options;
|
||||
const isPublished = result.published ?? publishedBefore;
|
||||
try {
|
||||
if (!result.removed || !options.cleanupRemovedIntegration) return undefined;
|
||||
return await this.registry.get(result.removed.type)?.onRemove?.({
|
||||
agentId: agent.id,
|
||||
projectId: agent.projectId,
|
||||
credentialId: result.removed.credentialId,
|
||||
user: options.user,
|
||||
// Draft channels delete their external resource by default.
|
||||
deleteExternalResource: options.deleteExternalResource ?? !isPublished,
|
||||
});
|
||||
} finally {
|
||||
if (remove) await this.releaseRemoved(agent, remove, result);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistChange(
|
||||
options: IntegrationChangeOptions,
|
||||
remove: IntegrationRef | undefined,
|
||||
connected: boolean,
|
||||
previous: IntegrationRuntimeState,
|
||||
): Promise<IntegrationDeltaResult> {
|
||||
const { agent, add } = options;
|
||||
try {
|
||||
return await this.persistenceService.applyIntegrationDelta(
|
||||
agent,
|
||||
{ add, remove },
|
||||
{ user: options.user, modifiedBy: options.modifiedBy },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!connected || !add) throw error;
|
||||
// Roll back this main only. Other mains still match the durable state.
|
||||
if (previous.wasLive && previous.persisted) {
|
||||
await this.restorePersistedRuntime(agent, previous.persisted);
|
||||
} else {
|
||||
await this.releaseRuntimeQuietly(agent, add);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async startAddedRuntime(
|
||||
{ agent, add }: IntegrationChangeOptions,
|
||||
previous: IntegrationRuntimeState,
|
||||
): Promise<boolean> {
|
||||
if (!add) return false;
|
||||
try {
|
||||
return await this.startRuntime(agent, add, previous.published);
|
||||
} catch (error) {
|
||||
// A failed reconnect has already released the previous runtime.
|
||||
if (previous.wasLive) await this.restorePersistedRuntime(agent, previous.persisted);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRuntimeBeforeChange({
|
||||
agent,
|
||||
add,
|
||||
cleanupRemovedIntegration,
|
||||
}: IntegrationChangeOptions): Promise<IntegrationRuntimeState> {
|
||||
// The caller's entity can predate a publish or another channel write.
|
||||
const state =
|
||||
add || cleanupRemovedIntegration
|
||||
? await this.agentRepository.findIntegrationState(agent.id)
|
||||
: null;
|
||||
const published = state ? state.activeVersionId !== null : agent.activeVersionId !== null;
|
||||
// Leader-routed channels report live even for drafts. Check publication first.
|
||||
const wasLive = !!add && published && this.chatService.isChannelLive(agent.id, add);
|
||||
const persisted =
|
||||
add && state
|
||||
? (state.integrations ?? []).find((entry) => matchesIntegrationRef(entry, add))
|
||||
: undefined;
|
||||
return { published, wasLive, persisted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the runtime against the publication state the write actually read.
|
||||
*
|
||||
|
||||
@@ -24,18 +24,13 @@ import type { Agent } from './entities/agent.entity';
|
||||
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
|
||||
import type { IntegrationRef } from './utils/agent-channel';
|
||||
|
||||
export interface CredentialIntegrationMutationContext {
|
||||
user: User;
|
||||
modifiedBy: AgentActor;
|
||||
}
|
||||
|
||||
/** Reference to a persisted entry; `credentialId: ''` targets a builder draft entry. */
|
||||
export interface IntegrationRef {
|
||||
type: string;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One durable change to an agent's channels. Both fields together are a
|
||||
* replacement, and land in one write so a swap can't leave two entries or none.
|
||||
@@ -62,10 +57,7 @@ export interface IntegrationDeltaResult {
|
||||
/** Retries cover a lost compare-and-set, which needs a fresh read to resolve. */
|
||||
const MAX_WRITE_ATTEMPTS = 3;
|
||||
|
||||
export function matchesIntegrationRef(
|
||||
integration: { type: string; credentialId: string },
|
||||
ref: IntegrationRef,
|
||||
): boolean {
|
||||
export function matchesIntegrationRef(integration: IntegrationRef, ref: IntegrationRef): boolean {
|
||||
return integration.type === ref.type && integration.credentialId === ref.credentialId;
|
||||
}
|
||||
|
||||
@@ -75,7 +67,7 @@ export function matchesIntegrationRef(
|
||||
*/
|
||||
function projectIntegrations(
|
||||
current: AgentIntegrationConfig[],
|
||||
delta: { add?: AgentIntegrationConfig; remove?: IntegrationRef },
|
||||
delta: IntegrationDelta,
|
||||
): AgentIntegrationConfig[] {
|
||||
let next = delta.remove
|
||||
? current.filter((entry) => !matchesIntegrationRef(entry, delta.remove!))
|
||||
@@ -89,9 +81,8 @@ function projectIntegrations(
|
||||
// instead of leaving both the draft and the connected entry behind.
|
||||
next = next.filter((entry) => !(entry.type === add.type && isDraftIntegration(entry)));
|
||||
|
||||
return next.some((entry) => matchesIntegrationRef(entry, add))
|
||||
? next.map((entry) => (matchesIntegrationRef(entry, add) ? add : entry))
|
||||
: [...next, add];
|
||||
if (!next.some((entry) => matchesIntegrationRef(entry, add))) return [...next, add];
|
||||
return next.map((entry) => (matchesIntegrationRef(entry, add) ? add : entry));
|
||||
}
|
||||
|
||||
@Service()
|
||||
@@ -158,59 +149,13 @@ export class AgentIntegrationPersistenceService {
|
||||
);
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
|
||||
const state = await this.agentRepository.findIntegrationState(agent.id);
|
||||
if (!state) throw new UserError(`Agent "${agent.id}" no longer exists`);
|
||||
|
||||
const current = state.integrations ?? [];
|
||||
const removed = remove
|
||||
? current.find((entry) => matchesIntegrationRef(entry, remove))
|
||||
: undefined;
|
||||
|
||||
const published = state.activeVersionId !== null;
|
||||
// Callers derive their response and their runtime decisions from the
|
||||
// entity, so correct it to what was read. Scalar only — nothing here reads
|
||||
// the `activeVersion` relation, and fabricating one would be worse.
|
||||
agent.activeVersionId = state.activeVersionId;
|
||||
|
||||
// A removal of something already gone is not a failure — and with
|
||||
// nothing to add there is no write left to make.
|
||||
if (!add && !removed) {
|
||||
agent.integrations = current;
|
||||
agent.versionId = state.versionId;
|
||||
return { agent, changed: false, published };
|
||||
}
|
||||
|
||||
const integrations = projectIntegrations(current, { add, remove });
|
||||
// Always fresh: `versionId` is the compare-and-set token, so writing back
|
||||
// the value we guarded on would let two concurrent writes both match.
|
||||
// Consumers only compare it to `activeVersionId`, which a rotation keeps.
|
||||
const versionId = uuid();
|
||||
|
||||
// Gate evaluated against the state about to be written; the marker is
|
||||
// claimed and reported only once that write succeeded.
|
||||
agent.integrations = integrations;
|
||||
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
|
||||
const result = await this.applyIntegrationAttempt(
|
||||
agent,
|
||||
agent.projectId,
|
||||
{ add, remove },
|
||||
context,
|
||||
credentialProvider,
|
||||
context.user,
|
||||
);
|
||||
|
||||
const written = await this.agentRepository.updateIntegrations(
|
||||
agent.id,
|
||||
integrations,
|
||||
{ versionId: state.versionId, activeVersionId: state.activeVersionId },
|
||||
versionId,
|
||||
);
|
||||
if (!written) continue;
|
||||
|
||||
agent.versionId = versionId;
|
||||
this.runtimeCacheService.clearRuntimes(agent.id);
|
||||
this.eventService.emit('agent-saved', { agentId: agent.id });
|
||||
await emitSetupCompleted?.();
|
||||
this.recordIntegrationMutation(agent, current, context);
|
||||
|
||||
return { agent, changed: true, published, ...(removed ? { removed } : {}) };
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
throw new OperationalError(
|
||||
@@ -253,4 +198,82 @@ export class AgentIntegrationPersistenceService {
|
||||
wasUnconfigured,
|
||||
});
|
||||
}
|
||||
private async applyIntegrationAttempt(
|
||||
agent: Agent,
|
||||
{ add, remove }: IntegrationDelta,
|
||||
context: CredentialIntegrationMutationContext,
|
||||
credentialProvider: ReturnType<typeof createAgentCredentialProvider>,
|
||||
): Promise<IntegrationDeltaResult | undefined> {
|
||||
const state = await this.agentRepository.findIntegrationState(agent.id);
|
||||
if (!state) throw new UserError(`Agent "${agent.id}" no longer exists`);
|
||||
|
||||
const current = state.integrations ?? [];
|
||||
const removed = remove
|
||||
? current.find((entry) => matchesIntegrationRef(entry, remove))
|
||||
: undefined;
|
||||
|
||||
const published = state.activeVersionId !== null;
|
||||
// Callers derive their response and their runtime decisions from the
|
||||
// entity, so correct it to what was read. Scalar only — nothing here reads
|
||||
// the `activeVersion` relation, and fabricating one would be worse.
|
||||
agent.activeVersionId = state.activeVersionId;
|
||||
|
||||
// A removal of something already gone is not a failure — and with
|
||||
// nothing to add there is no write left to make.
|
||||
if (!add && !removed) {
|
||||
agent.integrations = current;
|
||||
agent.versionId = state.versionId;
|
||||
return { agent, changed: false, published };
|
||||
}
|
||||
|
||||
const integrations = projectIntegrations(current, { add, remove });
|
||||
const written = await this.persistIntegrations(
|
||||
agent,
|
||||
integrations,
|
||||
state,
|
||||
context,
|
||||
credentialProvider,
|
||||
);
|
||||
if (!written) return undefined;
|
||||
this.runtimeCacheService.clearRuntimes(agent.id);
|
||||
this.eventService.emit('agent-saved', { agentId: agent.id });
|
||||
await written.emitSetupCompleted?.();
|
||||
this.recordIntegrationMutation(agent, current, context);
|
||||
|
||||
return { agent, changed: true, published, ...(removed ? { removed } : {}) };
|
||||
}
|
||||
|
||||
private async persistIntegrations(
|
||||
agent: Agent,
|
||||
integrations: AgentIntegrationConfig[],
|
||||
state: Pick<Agent, 'versionId' | 'activeVersionId'>,
|
||||
context: CredentialIntegrationMutationContext,
|
||||
credentialProvider: ReturnType<typeof createAgentCredentialProvider>,
|
||||
) {
|
||||
// Always fresh: `versionId` is the compare-and-set token, so writing back
|
||||
// the value we guarded on would let two concurrent writes both match.
|
||||
// Consumers only compare it to `activeVersionId`, which a rotation keeps.
|
||||
const versionId = uuid();
|
||||
|
||||
// Gate evaluated against the state about to be written; the marker is
|
||||
// claimed and reported only once that write succeeded.
|
||||
agent.integrations = integrations;
|
||||
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
|
||||
agent,
|
||||
agent.projectId,
|
||||
credentialProvider,
|
||||
context.user,
|
||||
);
|
||||
|
||||
const written = await this.agentRepository.updateIntegrations(
|
||||
agent.id,
|
||||
integrations,
|
||||
{ versionId: state.versionId, activeVersionId: state.activeVersionId },
|
||||
versionId,
|
||||
);
|
||||
if (!written) return undefined;
|
||||
|
||||
agent.versionId = versionId;
|
||||
return { emitSetupCompleted };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,8 @@ const SEARCH_JSON_EVENT_OVERHEAD_CHARS = 1_500;
|
||||
const MAX_SEARCH_OPERATION_OUTPUT_CHARS = 500_000;
|
||||
export const KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE = 3;
|
||||
|
||||
interface ParsedRipgrepContentLine {
|
||||
interface ParsedRipgrepContentLine extends SearchKnowledgeContextLine {
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
text: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
interface SearchContextSourceLine {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandResult } from '@n8n/agents/sandbox';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { AgentsConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { runSerially } from '@n8n/utils/run-serially';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
import { OperationalError, safeRegex } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -119,7 +120,7 @@ function assertKnowledgeFilesDirectoryAvailable(
|
||||
|
||||
@Service()
|
||||
export class AgentKnowledgeMirrorService {
|
||||
private readonly pendingMirrorSyncs = new Map<string, Promise<void>>();
|
||||
private readonly pendingMirrorSyncs = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(
|
||||
private readonly agentsConfig: AgentsConfig,
|
||||
@@ -144,15 +145,7 @@ export class AgentKnowledgeMirrorService {
|
||||
return emptySearchKnowledgeResult(outputMode, limit);
|
||||
}
|
||||
|
||||
const scopedFilesByPath = new Map<string, AgentKnowledgeFileReference>();
|
||||
for (const path of validatedRequest.path ?? []) {
|
||||
const file = this.resolveOptionalFile({ file: path }, references);
|
||||
if (!file) {
|
||||
throw new BadRequestError('Knowledge file not found');
|
||||
}
|
||||
scopedFilesByPath.set(file.file, file);
|
||||
}
|
||||
const scopedFiles = [...scopedFilesByPath.values()];
|
||||
const scopedFiles = this.resolveSearchFiles(validatedRequest, references);
|
||||
const command = buildSearchKnowledgeCommand(
|
||||
validatedRequest,
|
||||
scopedFiles.map((file) => file.file),
|
||||
@@ -172,44 +165,7 @@ export class AgentKnowledgeMirrorService {
|
||||
throw new OperationalError(formatSandboxCommandFailure('search', result));
|
||||
}
|
||||
|
||||
if (outputMode === 'files_with_matches') {
|
||||
const parsed = parseRipgrepFilesOutput(result.stdout, references.byFile);
|
||||
const files = parsed.files.slice(0, limit);
|
||||
return {
|
||||
outputMode,
|
||||
files,
|
||||
limit,
|
||||
hasMore: parsed.files.length > limit,
|
||||
truncated: parsed.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
if (outputMode === 'count') {
|
||||
const parsed = parseRipgrepCountOutput(result.stdout, references.byFile);
|
||||
const counts = parsed.counts.slice(0, limit);
|
||||
return {
|
||||
outputMode,
|
||||
counts,
|
||||
limit,
|
||||
hasMore: parsed.counts.length > limit,
|
||||
truncated: parsed.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseRipgrepOutput(
|
||||
result.stdout,
|
||||
references.byFile,
|
||||
getSearchContextWindow(validatedRequest),
|
||||
);
|
||||
const matches = parsed.matches.slice(0, limit);
|
||||
|
||||
return {
|
||||
outputMode,
|
||||
matches,
|
||||
limit,
|
||||
hasMore: parsed.matches.length > limit,
|
||||
truncated: parsed.incomplete,
|
||||
};
|
||||
return this.parseSearchResult(result.stdout, validatedRequest, references, outputMode, limit);
|
||||
}
|
||||
|
||||
async globKnowledgeFiles(
|
||||
@@ -311,19 +267,11 @@ export class AgentKnowledgeMirrorService {
|
||||
runtime: AgentKnowledgeMirrorRuntime,
|
||||
files: AgentKnowledgeFileReference[],
|
||||
): Promise<void> {
|
||||
const previous = this.pendingMirrorSyncs.get(runtime.cacheKey) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => await this.syncMirror(runtime, files));
|
||||
this.pendingMirrorSyncs.set(runtime.cacheKey, next);
|
||||
|
||||
try {
|
||||
await next;
|
||||
} finally {
|
||||
if (this.pendingMirrorSyncs.get(runtime.cacheKey) === next) {
|
||||
this.pendingMirrorSyncs.delete(runtime.cacheKey);
|
||||
}
|
||||
}
|
||||
await runSerially(
|
||||
this.pendingMirrorSyncs,
|
||||
runtime.cacheKey,
|
||||
async () => await this.syncMirror(runtime, files),
|
||||
);
|
||||
}
|
||||
|
||||
private async syncMirror(
|
||||
@@ -351,44 +299,9 @@ export class AgentKnowledgeMirrorService {
|
||||
const stagingId = nanoid();
|
||||
const stagingDir = `${runtime.paths.stagingDir}/${stagingId}`;
|
||||
try {
|
||||
const copiedNames = await this.uploadMirrorFiles(runtime, toCopy, stagingId);
|
||||
const finalManifestFiles = files.filter(
|
||||
(file) => copiedNames.has(file.file) || present.get(file.file) === file.fileId,
|
||||
);
|
||||
const staleFiles = toCopy
|
||||
.filter((file) => present.has(file.file) && !copiedNames.has(file.file))
|
||||
.map((file) => file.file);
|
||||
|
||||
const syncResult = await this.agentSandboxRuntimeService.executeSandboxCommand(
|
||||
runtime.sandbox,
|
||||
buildMirrorFinalizeCommand(
|
||||
[...copiedNames],
|
||||
[...toDelete, ...staleFiles],
|
||||
finalManifestFiles,
|
||||
runtime.paths,
|
||||
stagingId,
|
||||
),
|
||||
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
|
||||
);
|
||||
if (syncResult.exitCode !== 0) {
|
||||
throw new OperationalError(
|
||||
`Agent knowledge mirror sync failed: exitCode=${syncResult.exitCode}; output=${sanitizeSandboxErrorDetail(syncResult.stdout)}`,
|
||||
);
|
||||
}
|
||||
await this.finalizeMirror(runtime, files, toCopy, toDelete, present, stagingId);
|
||||
} finally {
|
||||
if (toCopy.length > 0) {
|
||||
try {
|
||||
await runtime.filesystem.rmdir(stagingDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to clean agent knowledge mirror staging directory', {
|
||||
sandboxName: runtime.cacheKey,
|
||||
stagingId,
|
||||
error: sanitizeSandboxErrorDetail(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (toCopy.length > 0) await this.cleanMirrorStaging(runtime, stagingDir, stagingId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +447,118 @@ export class AgentKnowledgeMirrorService {
|
||||
);
|
||||
return { ...runtime, paths: getAgentKnowledgePaths(runtime.provider) };
|
||||
}
|
||||
|
||||
private resolveSearchFiles(
|
||||
request: SearchKnowledgeRequest,
|
||||
references: AgentKnowledgeReferenceLookup,
|
||||
): AgentKnowledgeFileReference[] {
|
||||
const scopedFilesByPath = new Map<string, AgentKnowledgeFileReference>();
|
||||
for (const path of request.path ?? []) {
|
||||
const file = this.resolveOptionalFile({ file: path }, references);
|
||||
if (!file) {
|
||||
throw new BadRequestError('Knowledge file not found');
|
||||
}
|
||||
scopedFilesByPath.set(file.file, file);
|
||||
}
|
||||
return [...scopedFilesByPath.values()];
|
||||
}
|
||||
|
||||
private parseSearchResult(
|
||||
stdout: string,
|
||||
validatedRequest: SearchKnowledgeRequest,
|
||||
references: AgentKnowledgeReferenceLookup,
|
||||
outputMode: NonNullable<SearchKnowledgeRequest['output_mode']>,
|
||||
limit: number,
|
||||
): SearchKnowledgeResult {
|
||||
if (outputMode === 'files_with_matches') {
|
||||
const parsed = parseRipgrepFilesOutput(stdout, references.byFile);
|
||||
const files = parsed.files.slice(0, limit);
|
||||
return {
|
||||
outputMode,
|
||||
files,
|
||||
limit,
|
||||
hasMore: parsed.files.length > limit,
|
||||
truncated: parsed.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
if (outputMode === 'count') {
|
||||
const parsed = parseRipgrepCountOutput(stdout, references.byFile);
|
||||
const counts = parsed.counts.slice(0, limit);
|
||||
return {
|
||||
outputMode,
|
||||
counts,
|
||||
limit,
|
||||
hasMore: parsed.counts.length > limit,
|
||||
truncated: parsed.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseRipgrepOutput(
|
||||
stdout,
|
||||
references.byFile,
|
||||
getSearchContextWindow(validatedRequest),
|
||||
);
|
||||
const matches = parsed.matches.slice(0, limit);
|
||||
|
||||
return {
|
||||
outputMode,
|
||||
matches,
|
||||
limit,
|
||||
hasMore: parsed.matches.length > limit,
|
||||
truncated: parsed.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
private async finalizeMirror(
|
||||
runtime: AgentKnowledgeMirrorRuntime,
|
||||
files: AgentKnowledgeFileReference[],
|
||||
toCopy: AgentKnowledgeFileReference[],
|
||||
toDelete: string[],
|
||||
present: Map<string, string>,
|
||||
stagingId: string,
|
||||
): Promise<void> {
|
||||
const copiedNames = await this.uploadMirrorFiles(runtime, toCopy, stagingId);
|
||||
const finalManifestFiles = files.filter(
|
||||
(file) => copiedNames.has(file.file) || present.get(file.file) === file.fileId,
|
||||
);
|
||||
const staleFiles = toCopy
|
||||
.filter((file) => present.has(file.file) && !copiedNames.has(file.file))
|
||||
.map((file) => file.file);
|
||||
|
||||
const syncResult = await this.agentSandboxRuntimeService.executeSandboxCommand(
|
||||
runtime.sandbox,
|
||||
buildMirrorFinalizeCommand(
|
||||
[...copiedNames],
|
||||
[...toDelete, ...staleFiles],
|
||||
finalManifestFiles,
|
||||
runtime.paths,
|
||||
stagingId,
|
||||
),
|
||||
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
|
||||
);
|
||||
if (syncResult.exitCode !== 0) {
|
||||
throw new OperationalError(
|
||||
`Agent knowledge mirror sync failed: exitCode=${syncResult.exitCode}; output=${sanitizeSandboxErrorDetail(syncResult.stdout)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanMirrorStaging(
|
||||
runtime: AgentKnowledgeMirrorRuntime,
|
||||
stagingDir: string,
|
||||
stagingId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runtime.filesystem.rmdir(stagingDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to clean agent knowledge mirror staging directory', {
|
||||
sandboxName: runtime.cacheKey,
|
||||
stagingId,
|
||||
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchKnowledgeFilesByGlob(
|
||||
@@ -636,10 +661,9 @@ function containsTokenSequence(fileTokens: string[], patternTokens: string[]): b
|
||||
|
||||
let patternIndex = 0;
|
||||
for (const fileToken of fileTokens) {
|
||||
if (fileToken === patternTokens[patternIndex]) {
|
||||
patternIndex++;
|
||||
if (patternIndex === patternTokens.length) return true;
|
||||
}
|
||||
if (fileToken !== patternTokens[patternIndex]) continue;
|
||||
patternIndex++;
|
||||
if (patternIndex === patternTokens.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -196,10 +196,12 @@ export interface AgentKnowledgeFileReference {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentKnowledgeCitation {
|
||||
file: string;
|
||||
fileId: string;
|
||||
displayName: string;
|
||||
type AgentKnowledgeFileIdentity = Pick<
|
||||
AgentKnowledgeFileReference,
|
||||
'file' | 'fileId' | 'displayName'
|
||||
>;
|
||||
|
||||
export interface AgentKnowledgeCitation extends AgentKnowledgeFileIdentity {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
@@ -210,10 +212,7 @@ export interface AgentKnowledgeLine {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeMatch {
|
||||
file: string;
|
||||
fileId: string;
|
||||
displayName: string;
|
||||
export interface SearchKnowledgeMatch extends AgentKnowledgeFileIdentity {
|
||||
lineNumber: number;
|
||||
text: string;
|
||||
textTruncated: boolean;
|
||||
@@ -226,10 +225,7 @@ export interface SearchKnowledgeContextLine {
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeCount {
|
||||
file: string;
|
||||
fileId: string;
|
||||
displayName: string;
|
||||
export interface SearchKnowledgeCount extends AgentKnowledgeFileIdentity {
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -276,10 +272,7 @@ export interface ReadKnowledgeRangeResult {
|
||||
citation: AgentKnowledgeCitation;
|
||||
}
|
||||
|
||||
export interface ReadKnowledgeResult {
|
||||
file: string;
|
||||
fileId: string;
|
||||
displayName: string;
|
||||
export interface ReadKnowledgeResult extends AgentKnowledgeFileIdentity {
|
||||
ranges: ReadKnowledgeRangeResult[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import { unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import {
|
||||
AgentKnowledgeFileStore,
|
||||
type StoredAgentKnowledgeFile,
|
||||
@@ -45,7 +45,7 @@ export class AgentKnowledgeService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<AgentFileDto[]> {
|
||||
try {
|
||||
await this.ensureAgentBelongsToProject(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
this.validateUploadMetadata(files);
|
||||
await this.validateUploadBatch(agentId, files);
|
||||
|
||||
@@ -68,20 +68,20 @@ export class AgentKnowledgeService {
|
||||
}
|
||||
|
||||
async listFiles(agentId: string, projectId: string): Promise<AgentFileDto[]> {
|
||||
await this.ensureAgentBelongsToProject(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
const files = await this.agentFileRepository.findByAgentId(agentId);
|
||||
return files.map((file) => toAgentFileDto(file));
|
||||
}
|
||||
|
||||
async warmKnowledgeSandbox(agentId: string, projectId: string): Promise<void> {
|
||||
await this.ensureAgentBelongsToProject(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
if (!(await this.agentFileRepository.hasFilesForAgent(agentId))) return;
|
||||
|
||||
await this.agentSandboxRuntimeService.warmKnowledgeSandbox(projectId, agentId);
|
||||
}
|
||||
|
||||
async deleteFile(agentId: string, projectId: string, fileId: string): Promise<void> {
|
||||
await this.ensureAgentBelongsToProject(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const file = await this.agentFileRepository.findByIdAndAgentId(fileId, agentId);
|
||||
if (!file) {
|
||||
@@ -276,14 +276,6 @@ export class AgentKnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAgentBelongsToProject(agentId: string, projectId: string) {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async cleanupUploadTempFiles(files: Express.Multer.File[]) {
|
||||
await Promise.all(files.map(async (file) => await this.cleanupUploadTempFile(file)));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ProjectScopeService } from '@/permissions.ee/project-scope.service';
|
||||
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { AgentRepository, type AgentListResult } from './repositories/agent.repository';
|
||||
|
||||
const BULK_CHUNK_SIZE = 500;
|
||||
|
||||
@@ -34,10 +34,7 @@ export class AgentMcpAccessService {
|
||||
* Paginated list of agents in projects where the user holds `agent:update`.
|
||||
* Defaults to agents that are not yet available to MCP.
|
||||
*/
|
||||
async getAgents(
|
||||
user: User,
|
||||
options: ListAgentsQueryDto,
|
||||
): Promise<{ count: number; data: Agent[] }> {
|
||||
async getAgents(user: User, options: ListAgentsQueryDto): Promise<AgentListResult> {
|
||||
const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:update']);
|
||||
return await this.agentRepository.findByProjectIdsPaginated(
|
||||
projectIds,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BuilderModelLiveLookupService,
|
||||
type LiveModelLookupResult,
|
||||
} from './builder/builder-model-live-lookup.service';
|
||||
import type { ModelChoice } from './builder/model-lookup.types';
|
||||
import { AgentDefaultModelResolverService } from './agent-default-model-resolver.service';
|
||||
import { stripSnapshotSuffix } from './utils/model-snapshot-alias';
|
||||
import { normalizeProviderModelId } from './utils/provider-model-id';
|
||||
@@ -60,11 +61,7 @@ export class AgentModelCatalogService {
|
||||
credentialId?: string,
|
||||
): Promise<AgentProviderModelsResponse> {
|
||||
const credentialType = getProviderCredentialType(provider);
|
||||
|
||||
if (!credentialId || !credentialType) {
|
||||
const catalogModels = await this.getCatalogModels(provider);
|
||||
return { provider, verified: false, models: Object.values(catalogModels) };
|
||||
}
|
||||
if (!credentialId || !credentialType) return await this.unverifiedCatalog(provider);
|
||||
|
||||
let lookup: LiveModelLookupResult;
|
||||
try {
|
||||
@@ -76,27 +73,43 @@ export class AgentModelCatalogService {
|
||||
provider,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logLiveLookupFailure(provider, error);
|
||||
// Managed slot: the gateway allowlist is the contract, so don't fall back
|
||||
// to the static catalog (it may list models the gateway won't serve).
|
||||
// Flagged unavailable so the picker distinguishes a failed lookup from a
|
||||
// gateway that genuinely allows nothing.
|
||||
if (credentialId === AI_GATEWAY_MANAGED_TAG) {
|
||||
return { provider, verified: true, unavailable: true, models: [] };
|
||||
}
|
||||
const catalogModels = await this.getCatalogModels(provider);
|
||||
return { provider, verified: false, models: Object.values(catalogModels) };
|
||||
return await this.unavailableModels(provider, credentialId, error);
|
||||
}
|
||||
return await this.modelsFromLookup(provider, credentialId, lookup);
|
||||
}
|
||||
|
||||
// A custom endpoint can expose any OpenAI-compatible model id. Its live
|
||||
// response is authoritative, so keep exact ids and attach no inferred or
|
||||
// models.dev metadata.
|
||||
private async unverifiedCatalog(provider: string): Promise<AgentProviderModelsResponse> {
|
||||
const models = await this.getCatalogModels(provider);
|
||||
return { provider, verified: false, models: Object.values(models) };
|
||||
}
|
||||
|
||||
private async unavailableModels(
|
||||
provider: string,
|
||||
credentialId: string,
|
||||
error: unknown,
|
||||
policy?: LiveModelLookupResult['policy'],
|
||||
): Promise<AgentProviderModelsResponse> {
|
||||
this.logLiveLookupFailure(provider, error);
|
||||
// Managed and custom endpoint lists are authoritative. Do not use a static fallback.
|
||||
if (
|
||||
policy === 'managed' ||
|
||||
policy === 'endpoint-only' ||
|
||||
credentialId === AI_GATEWAY_MANAGED_TAG
|
||||
) {
|
||||
return { provider, verified: true, unavailable: true, models: [] };
|
||||
}
|
||||
return await this.unverifiedCatalog(provider);
|
||||
}
|
||||
|
||||
private async modelsFromLookup(
|
||||
provider: string,
|
||||
credentialId: string,
|
||||
lookup: LiveModelLookupResult,
|
||||
): Promise<AgentProviderModelsResponse> {
|
||||
if (lookup.status === 'unavailable') {
|
||||
return await this.unavailableModels(provider, credentialId, lookup.error, lookup.policy);
|
||||
}
|
||||
if (lookup.policy === 'endpoint-only') {
|
||||
if (lookup.status === 'unavailable') {
|
||||
this.logLiveLookupFailure(provider, lookup.error);
|
||||
return { provider, verified: true, unavailable: true, models: [] };
|
||||
}
|
||||
|
||||
return this.withDefaultModel(provider, credentialId, {
|
||||
provider,
|
||||
verified: true,
|
||||
@@ -108,40 +121,38 @@ export class AgentModelCatalogService {
|
||||
});
|
||||
}
|
||||
|
||||
if (lookup.status === 'unavailable') {
|
||||
this.logLiveLookupFailure(provider, lookup.error);
|
||||
if (lookup.policy === 'managed' || credentialId === AI_GATEWAY_MANAGED_TAG) {
|
||||
return { provider, verified: true, unavailable: true, models: [] };
|
||||
}
|
||||
const catalogModels = await this.getCatalogModels(provider);
|
||||
return { provider, verified: false, models: Object.values(catalogModels) };
|
||||
}
|
||||
|
||||
const liveModels = lookup.models;
|
||||
const catalogModels = await this.getCatalogModels(provider);
|
||||
const models =
|
||||
lookup.policy === 'managed'
|
||||
? this.enrichManagedModels(provider, lookup.models, catalogModels)
|
||||
: this.verifyCuratedModels(provider, lookup.models, catalogModels);
|
||||
return this.withDefaultModel(provider, credentialId, { provider, verified: true, models });
|
||||
}
|
||||
|
||||
// n8n Connect managed slot: the gateway's `/models` is the authoritative,
|
||||
// allowlist-filtered set, and its exact ids are what the gateway's model
|
||||
// allowlist matches (e.g. a dated snapshot, not the catalog's versionless
|
||||
// alias). Use those ids verbatim, enriched with catalog display/metadata.
|
||||
if (lookup.policy === 'managed') {
|
||||
return this.withDefaultModel(provider, credentialId, {
|
||||
provider,
|
||||
verified: true,
|
||||
models: liveModels.map((live) => {
|
||||
const id = normalizeProviderModelId(provider, live.value);
|
||||
const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)];
|
||||
return catalogMatch
|
||||
? { ...catalogMatch, id }
|
||||
: {
|
||||
id,
|
||||
name: normalizeProviderModelId(provider, live.name) || id,
|
||||
toolCall: true,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
private enrichManagedModels(
|
||||
provider: string,
|
||||
liveModels: ModelChoice[],
|
||||
catalogModels: Record<string, AgentCatalogModel>,
|
||||
): AgentCatalogModel[] {
|
||||
// Keep exact gateway IDs. Catalog aliases supply display metadata only.
|
||||
return liveModels.map((live) => {
|
||||
const id = normalizeProviderModelId(provider, live.value);
|
||||
const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)];
|
||||
return catalogMatch
|
||||
? { ...catalogMatch, id }
|
||||
: {
|
||||
id,
|
||||
name: normalizeProviderModelId(provider, live.name) || id,
|
||||
toolCall: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private verifyCuratedModels(
|
||||
provider: string,
|
||||
liveModels: ModelChoice[],
|
||||
catalogModels: Record<string, AgentCatalogModel>,
|
||||
): AgentCatalogModel[] {
|
||||
const liveModelIds = new Set(
|
||||
liveModels.flatMap((live) =>
|
||||
liveModelIdVariants(normalizeProviderModelId(provider, live.value)),
|
||||
@@ -153,28 +164,16 @@ export class AgentModelCatalogService {
|
||||
// Provider `/models` endpoints return every variant/snapshot, so we never
|
||||
// add live-only models — we only prune catalog entries the provider no
|
||||
// longer reports (retired ids that would 404 at call time).
|
||||
if (catalogList.length > 0) {
|
||||
return this.withDefaultModel(provider, credentialId, {
|
||||
provider,
|
||||
verified: true,
|
||||
models: catalogList.filter((model) => liveModelIds.has(model.id)),
|
||||
});
|
||||
}
|
||||
if (catalogList.length > 0) return catalogList.filter((model) => liveModelIds.has(model.id));
|
||||
|
||||
// Catalog unavailable (models.dev down or no entry for this provider): there
|
||||
// is no curated list to prune against, so show the verified live list rather
|
||||
// than an empty picker.
|
||||
return this.withDefaultModel(provider, credentialId, {
|
||||
provider,
|
||||
verified: true,
|
||||
models: liveModels.map((live) => {
|
||||
const id = normalizeProviderModelId(provider, live.value);
|
||||
return {
|
||||
id,
|
||||
name: normalizeProviderModelId(provider, live.name) || id,
|
||||
toolCall: true,
|
||||
};
|
||||
}),
|
||||
// With no catalog, return the verified live list.
|
||||
return liveModels.map((live) => {
|
||||
const id = normalizeProviderModelId(provider, live.value);
|
||||
return {
|
||||
id,
|
||||
name: normalizeProviderModelId(provider, live.name) || id,
|
||||
toolCall: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,9 @@ import isEqual from 'lodash/isEqual';
|
||||
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { buildAgentConfigurationTelemetryFromConfig } from './agent-telemetry';
|
||||
import { buildAgentCapabilityTelemetryProperties } from './agent-telemetry';
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
import {
|
||||
capabilityCountTelemetryProperties,
|
||||
countAgentCapabilities,
|
||||
isUnconfiguredAgent,
|
||||
} from './utils/agent-capabilities';
|
||||
import { isUnconfiguredAgent } from './utils/agent-capabilities';
|
||||
|
||||
export { isUnconfiguredAgent };
|
||||
|
||||
@@ -72,7 +68,7 @@ export function diffAgentConfigParts(
|
||||
nextSchema: AgentJsonConfig | null,
|
||||
previousIntegrations: AgentIntegrationConfig[],
|
||||
nextIntegrations: AgentIntegrationConfig[],
|
||||
sidecarChanges: Partial<Record<'tools' | 'skills' | 'tasks', boolean>> = {},
|
||||
sidecarChanges: AgentSidecarChanges = {},
|
||||
): AgentConfigPart[] {
|
||||
return CONFIG_PARTS.filter((part) => {
|
||||
if (part === 'triggers') return !isEqual(previousIntegrations, nextIntegrations);
|
||||
@@ -83,6 +79,66 @@ export function diffAgentConfigParts(
|
||||
});
|
||||
}
|
||||
|
||||
interface AgentModificationEvent {
|
||||
/** Post-save entity, so the reported profile is the one that landed. */
|
||||
agent: Agent;
|
||||
projectId: string;
|
||||
user: User;
|
||||
by: AgentActor;
|
||||
changedParts: AgentConfigPart[];
|
||||
/**
|
||||
* Whether the agent was still unconfigured before this write. The write
|
||||
* that leaves it configured is what creates it as far as telemetry is
|
||||
* concerned, so it reports the creation event for `by` instead of the
|
||||
* modification one — a write is never counted as both.
|
||||
*/
|
||||
wasUnconfigured: boolean;
|
||||
}
|
||||
|
||||
export function captureAgentMutation(agent: Agent) {
|
||||
const schema = agent.schema ?? null;
|
||||
const integrations = agent.integrations ?? [];
|
||||
return { schema, integrations, wasUnconfigured: isUnconfiguredAgent(schema, integrations) };
|
||||
}
|
||||
|
||||
export type AgentMutationSnapshot = ReturnType<typeof captureAgentMutation>;
|
||||
|
||||
export type AgentSidecarChanges = Partial<Record<'tools' | 'skills' | 'tasks', boolean>>;
|
||||
|
||||
export function buildAgentMutationEvent(
|
||||
agent: Agent,
|
||||
projectId: string,
|
||||
context: AgentMutationTelemetryContext,
|
||||
previous: AgentMutationSnapshot,
|
||||
sidecarChanges: AgentSidecarChanges,
|
||||
): AgentModificationEvent {
|
||||
return {
|
||||
agent,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previous.schema,
|
||||
agent.schema,
|
||||
previous.integrations,
|
||||
agent.integrations ?? [],
|
||||
sidecarChanges,
|
||||
),
|
||||
wasUnconfigured: previous.wasUnconfigured,
|
||||
};
|
||||
}
|
||||
|
||||
function modificationProperties({ agent, projectId, user, changedParts }: AgentModificationEvent) {
|
||||
return {
|
||||
agent_id: agent.id,
|
||||
project_id: projectId,
|
||||
user_id: user.id,
|
||||
changed_parts: changedParts,
|
||||
...buildAgentCapabilityTelemetryProperties(agent.schema, agent.integrations),
|
||||
has_published_version: Boolean(agent.activeVersionId),
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single emitter for the six agent creation and modification events. Every
|
||||
* config write reports through here, so the only things that differ between
|
||||
@@ -93,81 +149,16 @@ export function diffAgentConfigParts(
|
||||
export class AgentModificationTelemetryService {
|
||||
constructor(private readonly telemetry: Telemetry) {}
|
||||
|
||||
record({
|
||||
agent,
|
||||
projectId,
|
||||
user,
|
||||
by,
|
||||
changedParts,
|
||||
wasUnconfigured,
|
||||
}: {
|
||||
/** Post-save entity, so the reported profile is the one that landed. */
|
||||
agent: Agent;
|
||||
projectId: string;
|
||||
user: User;
|
||||
by: AgentActor;
|
||||
changedParts: AgentConfigPart[];
|
||||
/**
|
||||
* Whether the agent was still unconfigured before this write. The write
|
||||
* that leaves it configured is what creates it as far as telemetry is
|
||||
* concerned, so it reports the creation event for `by` instead of the
|
||||
* modification one — a write is never counted as both.
|
||||
*/
|
||||
wasUnconfigured: boolean;
|
||||
}): void {
|
||||
if (changedParts.length === 0) return;
|
||||
|
||||
record(event: AgentModificationEvent): void {
|
||||
if (event.changedParts.length === 0) return;
|
||||
try {
|
||||
const stillUnconfigured = isUnconfiguredAgent(agent.schema, agent.integrations);
|
||||
// Blank-to-blank (rename/recolour of an unconfigured agent) stays silent
|
||||
// so a creation remains the first event any agent reports. Configured →
|
||||
// unconfigured (final capability removal) still reports modification.
|
||||
if (wasUnconfigured && stillUnconfigured) return;
|
||||
|
||||
const counts = countAgentCapabilities(agent.schema, agent.integrations);
|
||||
// Only model and tool_types: this helper's own tool_count folds in MCP
|
||||
// servers, provider tools, web search and sub-agents, which would
|
||||
// disagree with the per-kind counts above.
|
||||
const { model, tool_types } = buildAgentConfigurationTelemetryFromConfig(
|
||||
agent.schema,
|
||||
agent.integrations,
|
||||
);
|
||||
|
||||
const properties = {
|
||||
agent_id: agent.id,
|
||||
project_id: projectId,
|
||||
user_id: user.id,
|
||||
changed_parts: changedParts,
|
||||
...capabilityCountTelemetryProperties(counts),
|
||||
model,
|
||||
tool_types,
|
||||
has_published_version: Boolean(agent.activeVersionId),
|
||||
} as const;
|
||||
const { agent, by, wasUnconfigured } = event;
|
||||
if (wasUnconfigured && isUnconfiguredAgent(agent.schema, agent.integrations)) return;
|
||||
|
||||
const properties = modificationProperties(event);
|
||||
if (wasUnconfigured) {
|
||||
// Written out per surface rather than looked up, because the creation
|
||||
// events do not share one event_version and `Telemetry.track` types
|
||||
// its payload against the specific event passed.
|
||||
switch (by) {
|
||||
case 'user':
|
||||
this.telemetry.track(TELEMETRY_EVENT.AGENTS.USER_CREATED_AGENT, {
|
||||
...properties,
|
||||
event_version: '2',
|
||||
});
|
||||
return;
|
||||
case 'builder':
|
||||
this.telemetry.track(TELEMETRY_EVENT.AGENTS.BUILDER_CREATED_AGENT, {
|
||||
...properties,
|
||||
event_version: '2',
|
||||
});
|
||||
return;
|
||||
case 'mcp':
|
||||
this.telemetry.track(TELEMETRY_EVENT.AGENTS.MCP_CREATED_AGENT, {
|
||||
...properties,
|
||||
event_version: '1',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.recordCreation(by, properties);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
@@ -175,10 +166,36 @@ export class AgentModificationTelemetryService {
|
||||
builder: TELEMETRY_EVENT.AGENTS.BUILDER_MODIFIED_AGENT,
|
||||
mcp: TELEMETRY_EVENT.AGENTS.MCP_MODIFIED_AGENT,
|
||||
}[by];
|
||||
|
||||
this.telemetry.track(entry, { ...properties, event_version: '1' });
|
||||
} catch {
|
||||
// Telemetry must never fail a write that already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
private recordCreation(
|
||||
by: AgentActor,
|
||||
properties: ReturnType<typeof modificationProperties>,
|
||||
): void {
|
||||
// Creation events use different versions. Keep each payload tied to its event.
|
||||
switch (by) {
|
||||
case 'user':
|
||||
this.telemetry.track(TELEMETRY_EVENT.AGENTS.USER_CREATED_AGENT, {
|
||||
...properties,
|
||||
event_version: '2',
|
||||
});
|
||||
return;
|
||||
case 'builder':
|
||||
this.telemetry.track(TELEMETRY_EVENT.AGENTS.BUILDER_CREATED_AGENT, {
|
||||
...properties,
|
||||
event_version: '2',
|
||||
});
|
||||
return;
|
||||
case 'mcp':
|
||||
this.telemetry.track(TELEMETRY_EVENT.AGENTS.MCP_CREATED_AGENT, {
|
||||
...properties,
|
||||
event_version: '1',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,12 @@ import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { AgentsCredentialProvider } from './adapters/agents-credential-provider';
|
||||
import { AgentCustomToolsService } from './agent-custom-tools.service';
|
||||
import { buildAgentConfigurationTelemetryFromConfig } from './agent-telemetry';
|
||||
import { buildAgentCapabilityTelemetryProperties } from './agent-telemetry';
|
||||
import {
|
||||
AgentModificationTelemetryService,
|
||||
diffAgentConfigParts,
|
||||
type AgentActor,
|
||||
type AgentSidecarChanges,
|
||||
} from './agent-modification-telemetry.service';
|
||||
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
|
||||
import { AgentSetupCompletionService } from './agent-setup-completion.service';
|
||||
@@ -42,10 +43,7 @@ import { AgentHistoryRepository } from './repositories/agent-history.repository'
|
||||
import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository';
|
||||
import { AgentTaskRepository } from './repositories/agent-task.repository';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import {
|
||||
capabilityCountTelemetryProperties,
|
||||
countAgentCapabilities,
|
||||
} from './utils/agent-capabilities';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import { saveAgentDraftFenced } from './utils/agent-draft.utils';
|
||||
|
||||
export type AgentPublishTrigger = 'explicit' | 'republish';
|
||||
@@ -123,10 +121,7 @@ export class AgentPublishService {
|
||||
versionId?: string,
|
||||
pushRef?: string,
|
||||
): Promise<PublishAgentResult> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const expectedRevision = agent.revision;
|
||||
|
||||
@@ -138,14 +133,7 @@ export class AgentPublishService {
|
||||
return { agent };
|
||||
}
|
||||
|
||||
let targetHistory: AgentHistory | undefined;
|
||||
if (versionId) {
|
||||
const target = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agent.id);
|
||||
if (!target) {
|
||||
throw new NotFoundError(`Version "${versionId}" not found for agent "${agent.id}"`);
|
||||
}
|
||||
targetHistory = target;
|
||||
}
|
||||
const targetHistory = await this.findPublishTarget(versionId, agent.id);
|
||||
|
||||
const tasks = versionId
|
||||
? new Map<string, AgentTask>()
|
||||
@@ -164,66 +152,7 @@ export class AgentPublishService {
|
||||
targetHistory ? targetHistory.schema : agent.schema,
|
||||
);
|
||||
|
||||
await this.agentRepository.manager.transaction(async (trx) => {
|
||||
let nextActiveVersionId: string;
|
||||
let nextVersionId: string;
|
||||
let nextActiveVersion: AgentHistory | null | undefined;
|
||||
|
||||
if (targetHistory) {
|
||||
nextActiveVersionId = targetHistory.versionId;
|
||||
nextActiveVersion = targetHistory;
|
||||
nextVersionId = uuid();
|
||||
} else {
|
||||
nextVersionId = agent.versionId ?? uuid();
|
||||
try {
|
||||
nextActiveVersion = await this.agentHistoryRepository.saveVersion(
|
||||
{
|
||||
versionId: nextVersionId,
|
||||
agentId: agent.id,
|
||||
schema: agent.schema,
|
||||
tools: this.customToolsService.snapshotConfiguredTools(
|
||||
agent.schema,
|
||||
agent.tools ?? {},
|
||||
),
|
||||
skills: this.pickConfiguredSkillBodies(agent.schema, agent.skills ?? {}),
|
||||
publishedBy: user,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
} catch (error) {
|
||||
// Two concurrent publishes of the same draft share this
|
||||
// versionId, so the loser collides on the history primary
|
||||
// key before it can reach the revision fence. Surface the
|
||||
// same retryable conflict the fence would have produced.
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictError(
|
||||
'Agent was modified concurrently while publishing; please retry',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.snapshotConfiguredTasks(trx, nextVersionId, agent.schema, tasks);
|
||||
nextActiveVersionId = nextVersionId;
|
||||
}
|
||||
|
||||
const won = await this.agentRepository.setActiveVersionFenced(
|
||||
agent.id,
|
||||
expectedRevision,
|
||||
{ activeVersionId: nextActiveVersionId, versionId: nextVersionId },
|
||||
trx,
|
||||
);
|
||||
if (!won) {
|
||||
throw new ConflictError('Agent was modified concurrently while publishing; please retry');
|
||||
}
|
||||
|
||||
// Fence first: only mutate the in-memory entity once the row is ours,
|
||||
// so a losing caller never sees phantom published state on the entity
|
||||
// instance it still holds.
|
||||
agent.activeVersionId = nextActiveVersionId;
|
||||
agent.activeVersion = nextActiveVersion;
|
||||
agent.versionId = nextVersionId;
|
||||
agent.revision = expectedRevision + 1;
|
||||
});
|
||||
await this.commitPublication(agent, expectedRevision, user, tasks, targetHistory);
|
||||
this.eventService.emit('agent-saved', { agentId });
|
||||
this.agentUpdateBroadcaster.notify({ projectId, agentId, source: emitter.by }, pushRef);
|
||||
|
||||
@@ -232,24 +161,7 @@ export class AgentPublishService {
|
||||
this.trackPublished(agent, projectId, user, emitter, targetHistory);
|
||||
await emitSetupCompleted?.();
|
||||
|
||||
const credentialIntegrations = agent.integrations ?? [];
|
||||
if (credentialIntegrations.length > 0) {
|
||||
await Container.get(ChatIntegrationService)
|
||||
.syncToConfig(agent, [], credentialIntegrations)
|
||||
.catch((error) =>
|
||||
this.logger.warn('Failed to connect integrations on publish', {
|
||||
agentId,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { AgentTaskService } = await import('./agent-task.service.js');
|
||||
await Container.get(AgentTaskService)
|
||||
.requestReconcile(agentId)
|
||||
.catch((error) =>
|
||||
this.logger.warn('Failed to register agent tasks on publish', { agentId, error }),
|
||||
);
|
||||
await this.startPublishedServices(agent);
|
||||
|
||||
this.logger.debug('Published SDK agent', { agentId, projectId, userId: user.id });
|
||||
|
||||
@@ -327,37 +239,14 @@ export class AgentPublishService {
|
||||
by: AgentActor,
|
||||
pushRef?: string,
|
||||
): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
// Same optimistic revision fence as publish: a concurrent edit that bumped
|
||||
// `revision` after this load makes the unpublish lose the fence and surface
|
||||
// a user-retryable conflict instead of rolling back a newer active version.
|
||||
const expectedRevision = agent.revision;
|
||||
|
||||
await this.agentRepository.manager.transaction(async (trx) => {
|
||||
const nextVersionId = uuid();
|
||||
|
||||
// Fence first: only mutate the in-memory entity once the row is ours,
|
||||
// so a losing caller never sees phantom unpublished state on the
|
||||
// entity instance it still holds.
|
||||
const won = await this.agentRepository.setActiveVersionFenced(
|
||||
agent.id,
|
||||
expectedRevision,
|
||||
{ activeVersionId: null, versionId: nextVersionId },
|
||||
trx,
|
||||
);
|
||||
if (!won) {
|
||||
throw new ConflictError('Agent was modified concurrently while unpublishing; please retry');
|
||||
}
|
||||
|
||||
agent.activeVersionId = null;
|
||||
agent.activeVersion = null;
|
||||
agent.versionId = nextVersionId;
|
||||
agent.revision = expectedRevision + 1;
|
||||
});
|
||||
await this.commitUnpublication(agent, expectedRevision);
|
||||
this.eventService.emit('agent-saved', { agentId });
|
||||
this.agentUpdateBroadcaster.notify({ projectId, agentId, source: by }, pushRef);
|
||||
|
||||
@@ -399,15 +288,6 @@ export class AgentPublishService {
|
||||
// The snapshot that actually went live, which for a republish is the
|
||||
// version's schema rather than the draft.
|
||||
const published = targetHistory ? targetHistory.schema : agent.schema;
|
||||
const counts = countAgentCapabilities(published, agent.integrations);
|
||||
// Only model and tool_types: this helper's own tool_count folds in MCP
|
||||
// servers, provider tools, web search and sub-agents, which would
|
||||
// disagree with the per-kind counts above.
|
||||
const { model, tool_types } = buildAgentConfigurationTelemetryFromConfig(
|
||||
published,
|
||||
agent.integrations,
|
||||
);
|
||||
|
||||
const properties = {
|
||||
agent_id: agent.id,
|
||||
project_id: projectId,
|
||||
@@ -418,9 +298,7 @@ export class AgentPublishService {
|
||||
// Set by the transaction above to either targetHistory.versionId or
|
||||
// agent.versionId, so it is never null on this path.
|
||||
version_id: agent.activeVersionId!,
|
||||
...capabilityCountTelemetryProperties(counts),
|
||||
model,
|
||||
tool_types,
|
||||
...buildAgentCapabilityTelemetryProperties(published, agent.integrations),
|
||||
} as const;
|
||||
|
||||
switch (emitter.by) {
|
||||
@@ -475,10 +353,7 @@ export class AgentPublishService {
|
||||
modifiedBy: AgentActor,
|
||||
pushRef?: string,
|
||||
): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const activeVersion = agent.activeVersion;
|
||||
if (!activeVersion) {
|
||||
@@ -525,10 +400,7 @@ export class AgentPublishService {
|
||||
modifiedBy: AgentActor,
|
||||
pushRef?: string,
|
||||
): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const previousSchema = agent.schema;
|
||||
const previousTools = agent.tools ?? {};
|
||||
@@ -587,7 +459,7 @@ export class AgentPublishService {
|
||||
user: User,
|
||||
modifiedBy: AgentActor,
|
||||
previousSchema: AgentJsonConfig | null,
|
||||
sidecarChanges: Partial<Record<'tools' | 'skills' | 'tasks', boolean>>,
|
||||
sidecarChanges: AgentSidecarChanges,
|
||||
): Promise<void> {
|
||||
const integrations = agent.integrations ?? [];
|
||||
this.modificationTelemetry.record({
|
||||
@@ -625,10 +497,7 @@ export class AgentPublishService {
|
||||
projectId: string,
|
||||
versionId: string,
|
||||
): Promise<{ agent: Agent; version: AgentHistory; tasks: AgentTaskSnapshot[] }> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const version = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId);
|
||||
if (!version) {
|
||||
@@ -645,10 +514,7 @@ export class AgentPublishService {
|
||||
take: number,
|
||||
skip: number,
|
||||
): Promise<AgentVersionListItemDto[]> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const versions = await this.agentHistoryRepository.findByAgentId(agentId, take, skip);
|
||||
|
||||
@@ -738,27 +604,9 @@ export class AgentPublishService {
|
||||
const existing = await repo.findBy({ agentId });
|
||||
const snapshots = await this.agentTaskSnapshotRepository.findByVersionId(versionId, trx);
|
||||
|
||||
const existingBodies = Object.fromEntries(
|
||||
existing.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
name: row.name,
|
||||
objective: row.objective,
|
||||
cronExpression: row.cronExpression,
|
||||
timezone: row.timezone,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const existingBodies = Object.fromEntries(existing.map((row) => [row.id, taskBody(row)]));
|
||||
const snapshotBodies = Object.fromEntries(
|
||||
snapshots.map((snapshot) => [
|
||||
snapshot.taskId,
|
||||
{
|
||||
name: snapshot.name,
|
||||
objective: snapshot.objective,
|
||||
cronExpression: snapshot.cronExpression,
|
||||
timezone: snapshot.timezone,
|
||||
},
|
||||
]),
|
||||
snapshots.map((snapshot) => [snapshot.taskId, taskBody(snapshot)]),
|
||||
);
|
||||
const tasksChanged = !isEqual(existingBodies, snapshotBodies);
|
||||
|
||||
@@ -770,24 +618,144 @@ export class AgentPublishService {
|
||||
const existingIds = new Set(existing.map((row) => row.id));
|
||||
for (const snapshot of snapshots) {
|
||||
if (existingIds.has(snapshot.taskId)) {
|
||||
await repo.update(snapshot.taskId, {
|
||||
name: snapshot.name,
|
||||
objective: snapshot.objective,
|
||||
cronExpression: snapshot.cronExpression,
|
||||
timezone: snapshot.timezone,
|
||||
});
|
||||
await repo.update(snapshot.taskId, taskBody(snapshot));
|
||||
} else {
|
||||
await repo.insert({
|
||||
id: snapshot.taskId,
|
||||
agentId,
|
||||
name: snapshot.name,
|
||||
objective: snapshot.objective,
|
||||
cronExpression: snapshot.cronExpression,
|
||||
timezone: snapshot.timezone,
|
||||
...taskBody(snapshot),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tasksChanged;
|
||||
}
|
||||
|
||||
private async startPublishedServices(agent: Agent): Promise<void> {
|
||||
const agentId = agent.id;
|
||||
const credentialIntegrations = agent.integrations ?? [];
|
||||
if (credentialIntegrations.length > 0) {
|
||||
await Container.get(ChatIntegrationService)
|
||||
.syncToConfig(agent, [], credentialIntegrations)
|
||||
.catch((error) =>
|
||||
this.logger.warn('Failed to connect integrations on publish', {
|
||||
agentId,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { AgentTaskService } = await import('./agent-task.service.js');
|
||||
await Container.get(AgentTaskService)
|
||||
.requestReconcile(agentId)
|
||||
.catch((error) =>
|
||||
this.logger.warn('Failed to register agent tasks on publish', { agentId, error }),
|
||||
);
|
||||
}
|
||||
|
||||
private async commitUnpublication(agent: Agent, expectedRevision: number): Promise<void> {
|
||||
await this.agentRepository.manager.transaction(async (trx) => {
|
||||
const nextVersionId = uuid();
|
||||
|
||||
// Fence first: only mutate the in-memory entity once the row is ours,
|
||||
// so a losing caller never sees phantom unpublished state on the
|
||||
// entity instance it still holds.
|
||||
const won = await this.agentRepository.setActiveVersionFenced(
|
||||
agent.id,
|
||||
expectedRevision,
|
||||
{ activeVersionId: null, versionId: nextVersionId },
|
||||
trx,
|
||||
);
|
||||
if (!won) {
|
||||
throw new ConflictError('Agent was modified concurrently while unpublishing; please retry');
|
||||
}
|
||||
|
||||
agent.activeVersionId = null;
|
||||
agent.activeVersion = null;
|
||||
agent.versionId = nextVersionId;
|
||||
agent.revision = expectedRevision + 1;
|
||||
});
|
||||
}
|
||||
|
||||
private async findPublishTarget(
|
||||
versionId: string | undefined,
|
||||
agentId: string,
|
||||
): Promise<AgentHistory | undefined> {
|
||||
if (!versionId) return undefined;
|
||||
const target = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId);
|
||||
if (!target) throw new NotFoundError(`Version "${versionId}" not found for agent "${agentId}"`);
|
||||
return target;
|
||||
}
|
||||
|
||||
private async commitPublication(
|
||||
agent: Agent,
|
||||
expectedRevision: number,
|
||||
user: User,
|
||||
tasks: ReadonlyMap<string, AgentTask>,
|
||||
targetHistory?: AgentHistory,
|
||||
): Promise<void> {
|
||||
await this.agentRepository.manager.transaction(async (trx) => {
|
||||
const next = await this.preparePublishedVersion(trx, agent, user, tasks, targetHistory);
|
||||
const won = await this.agentRepository.setActiveVersionFenced(
|
||||
agent.id,
|
||||
expectedRevision,
|
||||
{ activeVersionId: next.activeVersionId, versionId: next.versionId },
|
||||
trx,
|
||||
);
|
||||
if (!won)
|
||||
throw new ConflictError('Agent was modified concurrently while publishing; please retry');
|
||||
// Update the caller's entity only after the revision fence succeeds.
|
||||
agent.activeVersionId = next.activeVersionId;
|
||||
agent.activeVersion = next.activeVersion;
|
||||
agent.versionId = next.versionId;
|
||||
agent.revision = expectedRevision + 1;
|
||||
});
|
||||
}
|
||||
|
||||
private async preparePublishedVersion(
|
||||
trx: EntityManager,
|
||||
agent: Agent,
|
||||
user: User,
|
||||
tasks: ReadonlyMap<string, AgentTask>,
|
||||
targetHistory?: AgentHistory,
|
||||
) {
|
||||
if (targetHistory) {
|
||||
return {
|
||||
activeVersionId: targetHistory.versionId,
|
||||
activeVersion: targetHistory,
|
||||
versionId: uuid(),
|
||||
};
|
||||
}
|
||||
const versionId = agent.versionId ?? uuid();
|
||||
const activeVersion = await this.saveDraftHistory(trx, agent, user, versionId);
|
||||
await this.snapshotConfiguredTasks(trx, versionId, agent.schema, tasks);
|
||||
return { activeVersionId: versionId, activeVersion, versionId };
|
||||
}
|
||||
|
||||
private async saveDraftHistory(trx: EntityManager, agent: Agent, user: User, versionId: string) {
|
||||
try {
|
||||
return await this.agentHistoryRepository.saveVersion(
|
||||
{
|
||||
versionId,
|
||||
agentId: agent.id,
|
||||
schema: agent.schema,
|
||||
tools: this.customToolsService.snapshotConfiguredTools(agent.schema, agent.tools ?? {}),
|
||||
skills: this.pickConfiguredSkillBodies(agent.schema, agent.skills ?? {}),
|
||||
publishedBy: user,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
} catch (error) {
|
||||
// Concurrent publishes of one draft can collide before they reach the revision fence.
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictError('Agent was modified concurrently while publishing; please retry');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function taskBody(task: Pick<AgentTask, 'name' | 'objective' | 'cronExpression' | 'timezone'>) {
|
||||
const { name, objective, cronExpression, timezone } = task;
|
||||
return { name, objective, cronExpression, timezone };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
@Service()
|
||||
export class AgentPushRecipientsService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly roleService: RoleService,
|
||||
) {}
|
||||
|
||||
async getProjectReaders(projectId: string): Promise<string[]> {
|
||||
const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
|
||||
this.roleService.rolesWithScope('global', ['agent:read']),
|
||||
this.roleService.rolesWithScope('project', ['agent:read']),
|
||||
]);
|
||||
return await this.userRepository.findIdsWithGlobalOrProjectRoles({
|
||||
projectIds: [projectId],
|
||||
projectRoleSlugs,
|
||||
globalRoleSlugs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AttributeValue, BuiltTelemetry } from '@n8n/agents';
|
||||
import type { AgentSnapshot, AttributeValue, BuiltTelemetry } from '@n8n/agents';
|
||||
import { Telemetry } from '@n8n/agents';
|
||||
import { AgentsConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
@@ -84,14 +84,12 @@ export class AgentRunTracingService {
|
||||
source: metadata.source,
|
||||
...(metadata.userId ? { user_id: metadata.userId } : {}),
|
||||
...(metadata.modelId ? { model_id: metadata.modelId } : {}),
|
||||
...(isWorkflowTracingMetadata(metadata)
|
||||
? {
|
||||
...(metadata.executionId ? { execution_id: metadata.executionId } : {}),
|
||||
...(metadata.workflowId ? { workflow_id: metadata.workflowId } : {}),
|
||||
...(metadata.nodeId ? { node_id: metadata.nodeId } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (isWorkflowTracingMetadata(metadata)) {
|
||||
if (metadata.executionId) attributes.execution_id = metadata.executionId;
|
||||
if (metadata.workflowId) attributes.workflow_id = metadata.workflowId;
|
||||
if (metadata.nodeId) attributes.node_id = metadata.nodeId;
|
||||
}
|
||||
|
||||
const built = await new Telemetry()
|
||||
.tracer(this.otelService.getTracer(AGENTS_TRACER_NAME))
|
||||
@@ -113,9 +111,6 @@ export class AgentRunTracingService {
|
||||
}
|
||||
|
||||
/** Format an agent snapshot's model as `provider/name`, or undefined if either is missing. */
|
||||
export function modelIdFromSnapshot(model: {
|
||||
provider: string | null;
|
||||
name: string | null;
|
||||
}): string | undefined {
|
||||
export function modelIdFromSnapshot(model: AgentSnapshot['model']): string | undefined {
|
||||
return model.provider && model.name ? `${model.provider}/${model.name}` : undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Agent as RuntimeAgent } from '@n8n/agents';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { User } from '@n8n/db';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
@@ -8,10 +7,8 @@ import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { IAgentConfigurationTelemetryProperties } from '@/interfaces';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { TtlMap } from '@/utils/ttl-map';
|
||||
|
||||
import {
|
||||
@@ -19,12 +16,16 @@ import {
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from './agent-sandbox-principal';
|
||||
import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service';
|
||||
import { AgentChangePublisher } from './agent-change-publisher.service';
|
||||
import { buildAgentConfigurationTelemetry } from './agent-telemetry';
|
||||
import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service';
|
||||
import type { UserToolAccessSnapshot } from './agent-runtime-reconstruction.service';
|
||||
import type {
|
||||
ReconstructedAgentRuntime,
|
||||
UserToolAccessSnapshot,
|
||||
} from './agent-runtime-reconstruction.service';
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import type { ToolRegistry } from './tool-registry';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
|
||||
import { getPublishedAgentSnapshot } from './utils/agent-published-snapshot';
|
||||
|
||||
@@ -59,12 +60,8 @@ export interface GetRuntimeParams {
|
||||
*/
|
||||
const TOOL_ACCESS_RECHECK_INTERVAL_MS = Time.minutes.toMilliseconds;
|
||||
|
||||
export interface AgentRuntime {
|
||||
agent: RuntimeAgent;
|
||||
export interface AgentRuntime extends ReconstructedAgentRuntime {
|
||||
agentId: string;
|
||||
toolRegistry: ToolRegistry;
|
||||
/** MCP server name -> registry attribution appended to replies that used its tools. */
|
||||
mcpServerAttributions: Map<string, string>;
|
||||
projectId: string;
|
||||
telemetryConfiguration: IAgentConfigurationTelemetryProperties;
|
||||
/**
|
||||
@@ -120,8 +117,7 @@ export class AgentRuntimeCacheService {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly changePublisher: AgentChangePublisher,
|
||||
private readonly agentRuntimeReconstructionService: AgentRuntimeReconstructionService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
|
||||
@@ -161,11 +157,10 @@ export class AgentRuntimeCacheService {
|
||||
*/
|
||||
clearRuntimes(agentId: string, options: { skipBroadcast?: boolean } = {}): void {
|
||||
for (const key of this.runtimes.keys()) {
|
||||
if (this.isRuntimeCacheKeyForAgent(key, agentId)) {
|
||||
const entry = this.runtimes.get(key);
|
||||
this.runtimes.delete(key);
|
||||
if (entry) this.closeAgentResources(entry.agent, agentId);
|
||||
}
|
||||
if (!this.isRuntimeCacheKeyForAgent(key, agentId)) continue;
|
||||
const entry = this.runtimes.get(key);
|
||||
this.runtimes.delete(key);
|
||||
if (entry) this.closeAgentResources(entry.agent, agentId);
|
||||
}
|
||||
|
||||
for (const key of this.runtimeInitializations.keys()) {
|
||||
@@ -175,21 +170,7 @@ export class AgentRuntimeCacheService {
|
||||
}
|
||||
|
||||
if (options.skipBroadcast) return;
|
||||
if (!this.globalConfig.multiMainSetup.enabled) return;
|
||||
|
||||
void this.publisher
|
||||
.publishCommand({
|
||||
command: 'agent-config-changed',
|
||||
payload: { agentId },
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
`[AgentRuntimeCacheService] Failed to publish agent-config-changed for ${agentId}`,
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
});
|
||||
void this.changePublisher.publish({ command: 'agent-config-changed', payload: { agentId } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,49 +240,14 @@ export class AgentRuntimeCacheService {
|
||||
|
||||
const cached = this.runtimes.get(cacheKey);
|
||||
if (cached) {
|
||||
const accessStillCurrent = await this.toolAccessStillCurrent(cached, params);
|
||||
const current = this.runtimes.get(cacheKey);
|
||||
// The awaited re-check may race with cache invalidation or replacement.
|
||||
if (current !== cached) {
|
||||
if (current) return this.acquireRuntimeLease(current);
|
||||
} else if (accessStillCurrent) {
|
||||
this.runtimes.touch(cacheKey);
|
||||
return this.acquireRuntimeLease(cached);
|
||||
} else {
|
||||
// Revoked grants: retire this runtime and rebuild below so the tool
|
||||
// list is re-filtered against the user's current access.
|
||||
this.runtimes.delete(cacheKey);
|
||||
this.closeAgentResources(cached.agent, params.agentId);
|
||||
}
|
||||
const runtime = await this.acquireCachedRuntime(cacheKey, cached, params);
|
||||
if (runtime) return runtime;
|
||||
}
|
||||
|
||||
const initialization = this.runtimeInitializations.get(cacheKey);
|
||||
if (initialization) return this.acquireRuntimeLease(await initialization.promise);
|
||||
|
||||
const token = Symbol(cacheKey);
|
||||
const runtimeInitialization: RuntimeInitialization = {
|
||||
token,
|
||||
promise: (async () => {
|
||||
const runtime = await this.reconstructRuntime(params);
|
||||
if (this.runtimeInitializations.get(cacheKey)?.token !== token) {
|
||||
this.closeAgentResources(runtime.agent, params.agentId);
|
||||
throw new Error(`Agent ${params.agentId} runtime initialization was invalidated`);
|
||||
}
|
||||
|
||||
this.runtimes.set(cacheKey, runtime);
|
||||
const cachedRuntime = this.runtimes.get(cacheKey);
|
||||
if (!cachedRuntime) throw new Error(`Agent ${params.agentId} failed to reconstruct`);
|
||||
return cachedRuntime;
|
||||
})(),
|
||||
};
|
||||
runtimeInitialization.promise = runtimeInitialization.promise.finally(() => {
|
||||
if (this.runtimeInitializations.get(cacheKey)?.token === token) {
|
||||
this.runtimeInitializations.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
this.runtimeInitializations.set(cacheKey, runtimeInitialization);
|
||||
|
||||
return this.acquireRuntimeLease(await runtimeInitialization.promise);
|
||||
return this.acquireRuntimeLease(await this.initializeRuntime(cacheKey, params).promise);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,8 +311,12 @@ export class AgentRuntimeCacheService {
|
||||
previewChat,
|
||||
} = params;
|
||||
|
||||
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agentEntity) throw new NotFoundError(`Agent ${agentId} not found`);
|
||||
const agentEntity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
`Agent ${agentId} not found`,
|
||||
);
|
||||
|
||||
const agentData: Agent = usePublishedVersion
|
||||
? getPublishedAgentSnapshot(agentEntity)
|
||||
@@ -413,4 +363,53 @@ export class AgentRuntimeCacheService {
|
||||
toolAccessCheckedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
private async acquireCachedRuntime(
|
||||
cacheKey: string,
|
||||
cached: AgentRuntime,
|
||||
params: GetRuntimeParams,
|
||||
): Promise<AgentRuntime | undefined> {
|
||||
const accessStillCurrent = await this.toolAccessStillCurrent(cached, params);
|
||||
const current = this.runtimes.get(cacheKey);
|
||||
// The access check can race with cache invalidation or replacement.
|
||||
if (current !== cached) {
|
||||
if (current) return this.acquireRuntimeLease(current);
|
||||
return undefined;
|
||||
}
|
||||
if (accessStillCurrent) {
|
||||
this.runtimes.touch(cacheKey);
|
||||
return this.acquireRuntimeLease(cached);
|
||||
}
|
||||
// Rebuild the tool list after a grant is revoked.
|
||||
this.runtimes.delete(cacheKey);
|
||||
this.closeAgentResources(cached.agent, params.agentId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private initializeRuntime(cacheKey: string, params: GetRuntimeParams): RuntimeInitialization {
|
||||
const token = Symbol(cacheKey);
|
||||
const runtimeInitialization: RuntimeInitialization = {
|
||||
token,
|
||||
promise: (async () => {
|
||||
const runtime = await this.reconstructRuntime(params);
|
||||
if (this.runtimeInitializations.get(cacheKey)?.token !== token) {
|
||||
this.closeAgentResources(runtime.agent, params.agentId);
|
||||
throw new Error(`Agent ${params.agentId} runtime initialization was invalidated`);
|
||||
}
|
||||
|
||||
this.runtimes.set(cacheKey, runtime);
|
||||
const cachedRuntime = this.runtimes.get(cacheKey);
|
||||
if (!cachedRuntime) throw new Error(`Agent ${params.agentId} failed to reconstruct`);
|
||||
return cachedRuntime;
|
||||
})(),
|
||||
};
|
||||
runtimeInitialization.promise = runtimeInitialization.promise.finally(() => {
|
||||
if (this.runtimeInitializations.get(cacheKey)?.token === token) {
|
||||
this.runtimeInitializations.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
this.runtimeInitializations.set(cacheKey, runtimeInitialization);
|
||||
|
||||
return runtimeInitialization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { FetchFn, McpToolCallSettledEvent } from '@n8n/agents';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
|
||||
import type { ReferencedToolKind } from './tool-registry';
|
||||
|
||||
export interface AgentToolInstrumentationContext {
|
||||
/** Sanitized tool name — the same identifier the model calls and `GenerateResult.toolCalls` reports. */
|
||||
toolName: string;
|
||||
toolKind: 'node' | 'workflow';
|
||||
toolKind: ReferencedToolKind;
|
||||
}
|
||||
|
||||
export interface AgentMcpToolCallSettledContext extends McpToolCallSettledEvent {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@ import { OperationalError } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { AiService } from '@/services/ai.service';
|
||||
import { SandboxSettingsService } from '@/services/sandbox-settings.service';
|
||||
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
|
||||
@@ -27,6 +26,7 @@ import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
|
||||
import { assertKnowledgePathSegment } from './agent-knowledge-storage';
|
||||
import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
|
||||
const WORKSPACE_SANDBOX_NAMESPACE = '38348f53-e947-42c7-8c04-83aa154be385';
|
||||
const KNOWLEDGE_SANDBOX_NAMESPACE = '51989c13-3ae7-4167-8e64-d874dd068795';
|
||||
@@ -353,10 +353,7 @@ export class AgentSandboxRuntimeService {
|
||||
lifecycle: SandboxLifecycle,
|
||||
startOptions: SandboxStartOptions,
|
||||
): Promise<AgentSandboxRuntime> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
}
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const config =
|
||||
provider === 'daytona'
|
||||
|
||||
@@ -318,99 +318,142 @@ function buildExecutionRun(
|
||||
function buildEventRun(event: TimelineEvent, execution: AgentExecution, path: string): DraftRun {
|
||||
switch (event.type) {
|
||||
case 'background-task-signal':
|
||||
return {
|
||||
path,
|
||||
name: 'Background task results received',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.timestamp,
|
||||
inputs: { tasks: event.signal.tasks },
|
||||
outputs: {},
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
return buildBackgroundTaskSignalRun(event, path);
|
||||
case 'text':
|
||||
return {
|
||||
path,
|
||||
name: 'Agent response',
|
||||
runType: 'llm',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.endTime ?? event.timestamp,
|
||||
inputs: {},
|
||||
outputs: { text: event.content },
|
||||
metadata: executionUsageMetadata(execution),
|
||||
children: [],
|
||||
};
|
||||
return buildTextRun(event, execution, path);
|
||||
case 'reasoning':
|
||||
return {
|
||||
path,
|
||||
name: 'Reasoning',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.endTime ?? event.timestamp,
|
||||
inputs: {},
|
||||
outputs: { content: event.content },
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
return buildReasoningRun(event, path);
|
||||
case 'suspension':
|
||||
return {
|
||||
path,
|
||||
name: 'Suspension',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.timestamp,
|
||||
inputs: {
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
outputs: { status: 'suspended' },
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
return buildSuspensionRun(event, path);
|
||||
case 'hitl-response':
|
||||
return {
|
||||
path,
|
||||
name: 'HITL response',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.timestamp,
|
||||
inputs: { toolCallId: event.toolCallId },
|
||||
outputs: toRecord(event.response),
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
return buildHitlResponseRun(event, path);
|
||||
case 'tool-call':
|
||||
return {
|
||||
path,
|
||||
name: event.name,
|
||||
runType: 'tool',
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
inputs: toRecord(event.input),
|
||||
outputs: {
|
||||
...toRecord(event.output),
|
||||
...(event.childTrace ? { childTrace: event.childTrace } : {}),
|
||||
},
|
||||
error: event.success ? undefined : toolError(event.output),
|
||||
metadata: {
|
||||
kind: event.kind,
|
||||
toolCallId: event.toolCallId,
|
||||
success: event.success,
|
||||
workflowId: event.workflowId,
|
||||
workflowName: event.workflowName,
|
||||
workflowExecutionId: event.workflowExecutionId,
|
||||
triggerType: event.triggerType,
|
||||
nodeType: event.nodeType,
|
||||
nodeTypeVersion: event.nodeTypeVersion,
|
||||
nodeDisplayName: event.nodeDisplayName,
|
||||
nodeParameters: event.nodeParameters,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
return buildToolCallRun(event, path);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBackgroundTaskSignalRun(
|
||||
event: Extract<TimelineEvent, { type: 'background-task-signal' }>,
|
||||
path: string,
|
||||
): DraftRun {
|
||||
return {
|
||||
path,
|
||||
name: 'Background task results received',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.timestamp,
|
||||
inputs: { tasks: event.signal.tasks },
|
||||
outputs: {},
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextRun(
|
||||
event: Extract<TimelineEvent, { type: 'text' }>,
|
||||
execution: AgentExecution,
|
||||
path: string,
|
||||
): DraftRun {
|
||||
return {
|
||||
path,
|
||||
name: 'Agent response',
|
||||
runType: 'llm',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.endTime ?? event.timestamp,
|
||||
inputs: {},
|
||||
outputs: { text: event.content },
|
||||
metadata: executionUsageMetadata(execution),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildReasoningRun(
|
||||
event: Extract<TimelineEvent, { type: 'reasoning' }>,
|
||||
path: string,
|
||||
): DraftRun {
|
||||
return {
|
||||
path,
|
||||
name: 'Reasoning',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.endTime ?? event.timestamp,
|
||||
inputs: {},
|
||||
outputs: { content: event.content },
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSuspensionRun(
|
||||
event: Extract<TimelineEvent, { type: 'suspension' }>,
|
||||
path: string,
|
||||
): DraftRun {
|
||||
return {
|
||||
path,
|
||||
name: 'Suspension',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.timestamp,
|
||||
inputs: {
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
outputs: { status: 'suspended' },
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildHitlResponseRun(
|
||||
event: Extract<TimelineEvent, { type: 'hitl-response' }>,
|
||||
path: string,
|
||||
): DraftRun {
|
||||
return {
|
||||
path,
|
||||
name: 'HITL response',
|
||||
runType: 'chain',
|
||||
startTime: event.timestamp,
|
||||
endTime: event.timestamp,
|
||||
inputs: { toolCallId: event.toolCallId },
|
||||
outputs: toRecord(event.response),
|
||||
metadata: {},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolCallRun(
|
||||
event: Extract<TimelineEvent, { type: 'tool-call' }>,
|
||||
path: string,
|
||||
): DraftRun {
|
||||
return {
|
||||
path,
|
||||
name: event.name,
|
||||
runType: 'tool',
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
inputs: toRecord(event.input),
|
||||
outputs: {
|
||||
...toRecord(event.output),
|
||||
...(event.childTrace ? { childTrace: event.childTrace } : {}),
|
||||
},
|
||||
error: event.success ? undefined : toolError(event.output),
|
||||
metadata: {
|
||||
kind: event.kind,
|
||||
toolCallId: event.toolCallId,
|
||||
success: event.success,
|
||||
workflowId: event.workflowId,
|
||||
workflowName: event.workflowName,
|
||||
workflowExecutionId: event.workflowExecutionId,
|
||||
triggerType: event.triggerType,
|
||||
nodeType: event.nodeType,
|
||||
nodeTypeVersion: event.nodeTypeVersion,
|
||||
nodeDisplayName: event.nodeDisplayName,
|
||||
nodeParameters: event.nodeParameters,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function executionMetadata(execution: AgentExecution): Record<string, unknown> {
|
||||
return {
|
||||
executionId: execution.id,
|
||||
|
||||
@@ -15,14 +15,16 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import {
|
||||
AgentModificationTelemetryService,
|
||||
type AgentMutationTelemetryContext,
|
||||
diffAgentConfigParts,
|
||||
buildAgentMutationEvent,
|
||||
captureAgentMutation,
|
||||
type AgentMutationSnapshot,
|
||||
} from './agent-modification-telemetry.service';
|
||||
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
|
||||
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
|
||||
import { Agent } from './entities/agent.entity';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import { getAgentSkillHash } from './utils/agent-config-hash';
|
||||
import { isUnconfiguredAgent } from './utils/agent-capabilities';
|
||||
import { generateAgentResourceId } from './utils/agent-resource-id';
|
||||
|
||||
@Service()
|
||||
@@ -35,8 +37,12 @@ export class AgentSkillsService {
|
||||
) {}
|
||||
|
||||
async listSkills(agentId: string, projectId: string): Promise<Record<string, AgentSkill>> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
|
||||
return entity.skills ?? {};
|
||||
}
|
||||
@@ -100,8 +106,12 @@ export class AgentSkillsService {
|
||||
throw new UserError('At least one skill is required.');
|
||||
}
|
||||
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
if (attach && !entity.schema) throw new UserError('Agent has no JSON config yet.');
|
||||
|
||||
for (const skill of skills) {
|
||||
@@ -109,36 +119,14 @@ export class AgentSkillsService {
|
||||
}
|
||||
this.assertBatchSkillNamesAreUnique(entity.skills ?? {}, skills);
|
||||
|
||||
const previousSchema = entity.schema ?? null;
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(entity);
|
||||
|
||||
const results = skills.map((skill) => ({ id: this.addSkill(entity, skill), skill }));
|
||||
if (attach) {
|
||||
for (const { id } of results) this.attachSkillRef(entity, id);
|
||||
}
|
||||
|
||||
markAgentDraftDirty(entity);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
);
|
||||
await this.clearRuntimes(agentId);
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
saved.schema,
|
||||
previousIntegrations,
|
||||
saved.integrations ?? [],
|
||||
{ skills: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
const saved = await this.saveSkillChanges(entity, projectId, context, previous);
|
||||
|
||||
this.logger.debug(attach ? 'Created and attached agent skill' : 'Created agent skills', {
|
||||
agentId,
|
||||
@@ -161,8 +149,12 @@ export class AgentSkillsService {
|
||||
context: AgentMutationTelemetryContext,
|
||||
baseSkillHash?: string,
|
||||
): Promise<AgentSkillMutationResponse> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
|
||||
const existing = entity.skills?.[skillId];
|
||||
if (!existing) throw new NotFoundError('Skill not found');
|
||||
@@ -185,36 +177,14 @@ export class AgentSkillsService {
|
||||
};
|
||||
}
|
||||
|
||||
const previousSchema = entity.schema ?? null;
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(entity);
|
||||
|
||||
entity.skills = {
|
||||
...(entity.skills ?? {}),
|
||||
[skillId]: updated,
|
||||
};
|
||||
|
||||
markAgentDraftDirty(entity);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
);
|
||||
await this.clearRuntimes(agentId);
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
saved.schema,
|
||||
previousIntegrations,
|
||||
saved.integrations ?? [],
|
||||
{ skills: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
const saved = await this.saveSkillChanges(entity, projectId, context, previous);
|
||||
|
||||
this.logger.debug('Updated agent skill', { agentId, projectId, skillId });
|
||||
|
||||
@@ -232,15 +202,17 @@ export class AgentSkillsService {
|
||||
skillId: string,
|
||||
context: AgentMutationTelemetryContext,
|
||||
): Promise<void> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
|
||||
const skills = { ...(entity.skills ?? {}) };
|
||||
if (!skills[skillId]) throw new NotFoundError('Skill not found');
|
||||
|
||||
const previousSchema = entity.schema ?? null;
|
||||
const previousIntegrations = entity.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(entity);
|
||||
|
||||
delete skills[skillId];
|
||||
entity.skills = skills;
|
||||
@@ -249,27 +221,7 @@ export class AgentSkillsService {
|
||||
entity.schema.skills = entity.schema.skills.filter((t) => t.id !== skillId);
|
||||
}
|
||||
|
||||
markAgentDraftDirty(entity);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
);
|
||||
await this.clearRuntimes(agentId);
|
||||
this.modificationTelemetry.record({
|
||||
agent: saved,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
saved.schema,
|
||||
previousIntegrations,
|
||||
saved.integrations ?? [],
|
||||
{ skills: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
await this.saveSkillChanges(entity, projectId, context, previous);
|
||||
|
||||
this.logger.debug('Deleted agent skill', { agentId, projectId, skillId });
|
||||
}
|
||||
@@ -355,4 +307,22 @@ export class AgentSkillsService {
|
||||
const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js');
|
||||
Container.get(AgentRuntimeCacheService).clearRuntimes(agentId);
|
||||
}
|
||||
private async saveSkillChanges(
|
||||
entity: Agent,
|
||||
projectId: string,
|
||||
context: AgentMutationTelemetryContext,
|
||||
previous: AgentMutationSnapshot,
|
||||
): Promise<Agent> {
|
||||
markAgentDraftDirty(entity);
|
||||
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
|
||||
this.agentUpdateBroadcaster.notify(
|
||||
{ projectId, agentId: entity.id, source: context.modifiedBy },
|
||||
context.pushRef,
|
||||
);
|
||||
await this.clearRuntimes(entity.id);
|
||||
this.modificationTelemetry.record(
|
||||
buildAgentMutationEvent(saved, projectId, context, previous, { skills: true }),
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@ import { InstanceSettings, ScheduledTaskManager, type ScheduledTaskGroup } from
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import {
|
||||
AgentModificationTelemetryService,
|
||||
type AgentMutationTelemetryContext,
|
||||
diffAgentConfigParts,
|
||||
buildAgentMutationEvent,
|
||||
captureAgentMutation,
|
||||
} from './agent-modification-telemetry.service';
|
||||
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
|
||||
import { AgentChangePublisher } from './agent-change-publisher.service';
|
||||
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
|
||||
import { AgentTaskJobRegistrar } from './scheduling/agent-task-job-registrar';
|
||||
import { knownTaskTimezone } from './scheduling/task-timezone';
|
||||
@@ -29,13 +30,13 @@ import { AgentTask } from './entities/agent-task.entity';
|
||||
import type { AgentTaskSnapshot } from './entities/agent-task-snapshot.entity';
|
||||
import { isValidCronExpression } from './integrations/cron-validation';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import {
|
||||
type AgentTaskRunLockHandle,
|
||||
AgentTaskRunLockRepository,
|
||||
} from './repositories/agent-task-run-lock.repository';
|
||||
import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository';
|
||||
import { AgentTaskRepository } from './repositories/agent-task.repository';
|
||||
import { isUnconfiguredAgent } from './utils/agent-capabilities';
|
||||
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
|
||||
import { taskRunMemoryResourceId } from './utils/agent-memory-scope';
|
||||
import { generateAgentResourceId } from './utils/agent-resource-id';
|
||||
@@ -73,7 +74,7 @@ export class AgentTaskService {
|
||||
private readonly agentExecutionOrchestratorService: AgentExecutionOrchestratorService,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly scheduledTaskManager: ScheduledTaskManager,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly changePublisher: AgentChangePublisher,
|
||||
private readonly modificationTelemetry: AgentModificationTelemetryService,
|
||||
private readonly durableJobRegistrar: AgentTaskJobRegistrar,
|
||||
private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster,
|
||||
@@ -138,13 +139,10 @@ export class AgentTaskService {
|
||||
this.assertValidTimezone(dto.timezone);
|
||||
}
|
||||
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
if (!agent.schema) throw new BadRequestError('Agent has no config yet');
|
||||
|
||||
const previousSchema = agent.schema;
|
||||
const previousIntegrations = agent.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(agent);
|
||||
|
||||
const tasks = dtos.map((dto) => {
|
||||
const taskId = generateAgentResourceId(
|
||||
@@ -175,20 +173,9 @@ export class AgentTaskService {
|
||||
context.pushRef,
|
||||
);
|
||||
|
||||
this.modificationTelemetry.record({
|
||||
agent,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
agent.schema,
|
||||
previousIntegrations,
|
||||
agent.integrations ?? [],
|
||||
{ tasks: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
this.modificationTelemetry.record(
|
||||
buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }),
|
||||
);
|
||||
|
||||
this.logger.debug('[AgentTaskService] Created tasks', {
|
||||
agentId,
|
||||
@@ -213,42 +200,14 @@ export class AgentTaskService {
|
||||
): Promise<AgentTaskDto> {
|
||||
const task = await this.getOrThrow(agentId, taskId);
|
||||
|
||||
let changed = false;
|
||||
if (dto.cronExpression !== undefined) {
|
||||
this.assertValidCron(dto.cronExpression);
|
||||
if (dto.cronExpression !== task.cronExpression) {
|
||||
task.cronExpression = dto.cronExpression;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// `null` resets to the instance timezone; omitting the field keeps the
|
||||
// current one, so an older client can still update name/objective/cron.
|
||||
if (dto.timezone !== undefined) {
|
||||
this.assertValidTimezone(dto.timezone);
|
||||
const timezone = dto.timezone ?? null;
|
||||
if (timezone !== task.timezone) {
|
||||
task.timezone = timezone;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (dto.name !== undefined && dto.name !== task.name) {
|
||||
task.name = dto.name;
|
||||
changed = true;
|
||||
}
|
||||
if (dto.objective !== undefined && dto.objective !== task.objective) {
|
||||
task.objective = dto.objective;
|
||||
changed = true;
|
||||
}
|
||||
const changed = this.applyTaskUpdates(task, dto);
|
||||
|
||||
// Nothing actually changed — skip the agent lookup, draft-dirty bump, and writes.
|
||||
if (!changed) return this.toDto(task);
|
||||
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const previousSchema = agent.schema ?? null;
|
||||
const previousIntegrations = agent.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(agent);
|
||||
|
||||
const saved = await this.agentRepository.manager.transaction(async (em) => {
|
||||
const savedTask = await em.save(task);
|
||||
@@ -261,20 +220,9 @@ export class AgentTaskService {
|
||||
context.pushRef,
|
||||
);
|
||||
|
||||
this.modificationTelemetry.record({
|
||||
agent,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
agent.schema,
|
||||
previousIntegrations,
|
||||
agent.integrations ?? [],
|
||||
{ tasks: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
this.modificationTelemetry.record(
|
||||
buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }),
|
||||
);
|
||||
|
||||
return this.toDto(saved);
|
||||
}
|
||||
@@ -287,12 +235,9 @@ export class AgentTaskService {
|
||||
context: AgentMutationTelemetryContext,
|
||||
): Promise<void> {
|
||||
const task = await this.getOrThrow(agentId, taskId);
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
|
||||
const previousSchema = agent.schema ?? null;
|
||||
const previousIntegrations = agent.integrations ?? [];
|
||||
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
|
||||
const previous = captureAgentMutation(agent);
|
||||
|
||||
if (agent.schema?.tasks) {
|
||||
agent.schema.tasks = agent.schema.tasks.filter((ref) => ref.id !== taskId);
|
||||
@@ -308,20 +253,9 @@ export class AgentTaskService {
|
||||
context.pushRef,
|
||||
);
|
||||
|
||||
this.modificationTelemetry.record({
|
||||
agent,
|
||||
projectId,
|
||||
user: context.user,
|
||||
by: context.modifiedBy,
|
||||
changedParts: diffAgentConfigParts(
|
||||
previousSchema,
|
||||
agent.schema,
|
||||
previousIntegrations,
|
||||
agent.integrations ?? [],
|
||||
{ tasks: true },
|
||||
),
|
||||
wasUnconfigured,
|
||||
});
|
||||
this.modificationTelemetry.record(
|
||||
buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }),
|
||||
);
|
||||
|
||||
this.logger.debug('[AgentTaskService] Deleted task', { agentId, taskId });
|
||||
}
|
||||
@@ -390,15 +324,7 @@ export class AgentTaskService {
|
||||
|
||||
/** Broadcast a task reconcile to peer mains (no-op outside multi-main). */
|
||||
private broadcastTasksChanged(agentId: string): void {
|
||||
if (!this.globalConfig.multiMainSetup.enabled) return;
|
||||
void this.publisher
|
||||
.publishCommand({ command: 'agent-tasks-changed', payload: { agentId } })
|
||||
.catch((error) =>
|
||||
this.logger.warn('[AgentTaskService] Failed to publish agent-tasks-changed', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
void this.changePublisher.publish({ command: 'agent-tasks-changed', payload: { agentId } });
|
||||
}
|
||||
|
||||
/** Stop all cron jobs belonging to an agent — called on unpublish/agent delete. */
|
||||
@@ -564,27 +490,7 @@ export class AgentTaskService {
|
||||
return 'skipped-active';
|
||||
}
|
||||
|
||||
const renewInterval = this.startTaskRunLockRenewal(lock);
|
||||
void (async () => {
|
||||
try {
|
||||
await this.runTask(agent, snapshot);
|
||||
} catch (error) {
|
||||
this.logger.error('[AgentTaskService] Scheduled task run failed', {
|
||||
taskId,
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearInterval(renewInterval);
|
||||
await this.taskRunLockRepository.release(lock).catch((error) => {
|
||||
this.logger.warn('[AgentTaskService] Failed to release task run lock', {
|
||||
taskId,
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
void this.runScheduledTask(agent, snapshot, lock);
|
||||
return 'started';
|
||||
}
|
||||
|
||||
@@ -772,4 +678,63 @@ export class AgentTaskService {
|
||||
updatedAt: task.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private applyTaskUpdates(task: AgentTask, dto: UpdateAgentTaskDto): boolean {
|
||||
let changed = false;
|
||||
if (dto.cronExpression !== undefined) {
|
||||
this.assertValidCron(dto.cronExpression);
|
||||
if (dto.cronExpression !== task.cronExpression) {
|
||||
task.cronExpression = dto.cronExpression;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// `null` resets to the instance timezone; omitting the field keeps the
|
||||
// current one, so an older client can still update name/objective/cron.
|
||||
if (dto.timezone !== undefined) {
|
||||
this.assertValidTimezone(dto.timezone);
|
||||
const timezone = dto.timezone ?? null;
|
||||
if (timezone !== task.timezone) {
|
||||
task.timezone = timezone;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (dto.name !== undefined && dto.name !== task.name) {
|
||||
task.name = dto.name;
|
||||
changed = true;
|
||||
}
|
||||
if (dto.objective !== undefined && dto.objective !== task.objective) {
|
||||
task.objective = dto.objective;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async runScheduledTask(
|
||||
agent: Agent,
|
||||
snapshot: AgentTaskSnapshot,
|
||||
lock: AgentTaskRunLockHandle,
|
||||
): Promise<void> {
|
||||
const agentId = agent.id;
|
||||
const taskId = snapshot.taskId;
|
||||
const renewInterval = this.startTaskRunLockRenewal(lock);
|
||||
try {
|
||||
await this.runTask(agent, snapshot);
|
||||
} catch (error) {
|
||||
this.logger.error('[AgentTaskService] Scheduled task run failed', {
|
||||
taskId,
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearInterval(renewInterval);
|
||||
await this.taskRunLockRepository.release(lock).catch((error) => {
|
||||
this.logger.warn('[AgentTaskService] Failed to release task run lock', {
|
||||
taskId,
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ import {
|
||||
} from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { AgentTaskService } from './agent-task.service';
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
|
||||
@RestController('/projects/:projectId/agents/v2')
|
||||
export class AgentTasksController {
|
||||
@@ -25,12 +23,6 @@ export class AgentTasksController {
|
||||
private readonly agentRepository: AgentRepository,
|
||||
) {}
|
||||
|
||||
private async getAgentOrThrow(agentId: string, projectId: string): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Get('/:agentId/tasks')
|
||||
@ProjectScope('agent:read')
|
||||
async listTasks(
|
||||
@@ -38,7 +30,7 @@ export class AgentTasksController {
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<AgentTaskDto[]> {
|
||||
await this.getAgentOrThrow(agentId, req.params.projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, req.params.projectId);
|
||||
return await this.agentTaskService.list(agentId);
|
||||
}
|
||||
|
||||
@@ -51,7 +43,7 @@ export class AgentTasksController {
|
||||
@Body payload: CreateAgentTaskDto,
|
||||
): Promise<AgentTaskDto> {
|
||||
const projectId = req.params.projectId;
|
||||
await this.getAgentOrThrow(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
return await this.agentTaskService.create(agentId, projectId, payload, {
|
||||
user: req.user,
|
||||
modifiedBy: 'user',
|
||||
@@ -69,7 +61,7 @@ export class AgentTasksController {
|
||||
@Body payload: UpdateAgentTaskDto,
|
||||
): Promise<AgentTaskDto> {
|
||||
const projectId = req.params.projectId;
|
||||
await this.getAgentOrThrow(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
return await this.agentTaskService.update(agentId, projectId, taskId, payload, {
|
||||
user: req.user,
|
||||
modifiedBy: 'user',
|
||||
@@ -86,7 +78,7 @@ export class AgentTasksController {
|
||||
@Param('taskId') taskId: string,
|
||||
): Promise<{ success: true }> {
|
||||
const projectId = req.params.projectId;
|
||||
await this.getAgentOrThrow(agentId, projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
await this.agentTaskService.delete(agentId, projectId, taskId, {
|
||||
user: req.user,
|
||||
modifiedBy: 'user',
|
||||
@@ -103,7 +95,7 @@ export class AgentTasksController {
|
||||
@Param('agentId') agentId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
): Promise<{ success: true }> {
|
||||
await this.getAgentOrThrow(agentId, req.params.projectId);
|
||||
await getAgentOrThrow(this.agentRepository, agentId, req.params.projectId);
|
||||
await this.agentTaskService.runNow(agentId, taskId, req.user);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import type { AgentIntegrationConfig, AgentJsonConfig } from '@n8n/api-types';
|
||||
|
||||
import type {
|
||||
AgentTelemetryMemoryType,
|
||||
@@ -6,6 +6,30 @@ import type {
|
||||
} from '@/interfaces';
|
||||
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
import type { MessageRecord } from './execution-recorder';
|
||||
import {
|
||||
capabilityCountTelemetryProperties,
|
||||
countAgentCapabilities,
|
||||
} from './utils/agent-capabilities';
|
||||
|
||||
export function buildAgentTurnMetrics(record: MessageRecord) {
|
||||
return {
|
||||
latency_ms: record.duration,
|
||||
cost: record.totalCost ?? 0,
|
||||
token_count: record.usage?.totalTokens ?? 0,
|
||||
tool_call_count: record.timeline.filter((event) => event.type === 'tool-call').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentCapabilityTelemetryProperties(
|
||||
config: AgentJsonConfig | null,
|
||||
integrations: AgentIntegrationConfig[],
|
||||
) {
|
||||
const counts = countAgentCapabilities(config, integrations);
|
||||
// Keep capability counts separate from the runtime tool count, which includes provider tools.
|
||||
const { model, tool_types } = buildAgentConfigurationTelemetryFromConfig(config, integrations);
|
||||
return { ...capabilityCountTelemetryProperties(counts), model, tool_types };
|
||||
}
|
||||
|
||||
export function buildAgentConfigurationTelemetry(
|
||||
agent: Agent,
|
||||
|
||||
@@ -43,6 +43,14 @@ export type PrepareDraftRunResult =
|
||||
| { status: 'session_not_found' }
|
||||
| { status: 'agent_misconfigured'; missing: string[] };
|
||||
|
||||
interface DraftRunState {
|
||||
response: string;
|
||||
suspensions: AgentTestRunSuspension[];
|
||||
errorChunk?: Extract<StreamChunk, { type: 'error' }>;
|
||||
observerFailed: boolean;
|
||||
observerError?: unknown;
|
||||
}
|
||||
|
||||
interface DraftRunConsumptionOptions {
|
||||
errorMode?: 'throw' | 'forward';
|
||||
onChunk?: (chunk: StreamChunk) => void;
|
||||
@@ -299,33 +307,7 @@ export class AgentTestRunService {
|
||||
const continuation = agentTestRunContinuationSchema.safeParse(input.continuation);
|
||||
if (!continuation.success) throw new InvalidAgentTestRunCheckpointError();
|
||||
|
||||
let checkpoint: SerializableAgentState | undefined;
|
||||
try {
|
||||
checkpoint = await this.n8nCheckpointStorage.load(continuation.data.runId, input.agentId);
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) throw new InvalidAgentTestRunCheckpointError();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pendingToolCall = checkpoint?.pendingToolCalls[continuation.data.toolCallId];
|
||||
const expectedResourceId = draftChatMemoryResourceId(input.user.id);
|
||||
if (
|
||||
checkpoint?.status !== 'suspended' ||
|
||||
checkpoint.persistence?.delegated === true ||
|
||||
checkpoint.persistence?.threadId !== continuation.data.sessionId ||
|
||||
checkpoint.persistence?.resourceId !== expectedResourceId ||
|
||||
!pendingToolCall?.suspended ||
|
||||
pendingToolCall.runId !== continuation.data.runId ||
|
||||
!parseStandardApprovalSuspension({
|
||||
runId: pendingToolCall.runId,
|
||||
toolCallId: pendingToolCall.toolCallId,
|
||||
toolName: pendingToolCall.toolName,
|
||||
suspendPayload: pendingToolCall.suspendPayload,
|
||||
resumeSchema: pendingToolCall.resumeSchema,
|
||||
})
|
||||
) {
|
||||
throw new InvalidAgentTestRunCheckpointError();
|
||||
}
|
||||
await this.validateDraftApprovalCheckpoint(continuation.data, input.agentId, input.user.id);
|
||||
|
||||
return await this.resumeDraftRun({
|
||||
agentId: input.agentId,
|
||||
@@ -374,56 +356,101 @@ export class AgentTestRunService {
|
||||
getExecutionId: () => string | undefined,
|
||||
{ errorMode = 'throw', onChunk }: DraftRunConsumptionOptions,
|
||||
): Promise<PreparedDraftRunResult> {
|
||||
let response = initialResponse;
|
||||
const suspensions: AgentTestRunSuspension[] = [];
|
||||
let errorChunk: Extract<StreamChunk, { type: 'error' }> | undefined;
|
||||
let observerFailed = false;
|
||||
let observerError: unknown;
|
||||
|
||||
const state: DraftRunState = {
|
||||
response: initialResponse,
|
||||
suspensions: [],
|
||||
observerFailed: false,
|
||||
};
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (!observerFailed && onChunk) {
|
||||
try {
|
||||
onChunk(chunk);
|
||||
} catch (error) {
|
||||
observerFailed = true;
|
||||
observerError = error;
|
||||
}
|
||||
}
|
||||
if (chunk.type === 'error' && errorMode === 'throw' && !observerFailed) {
|
||||
errorChunk ??= chunk;
|
||||
continue;
|
||||
}
|
||||
if (errorChunk) continue;
|
||||
if (chunk.type === 'text-delta') {
|
||||
response += chunk.delta;
|
||||
} else if (chunk.type === 'tool-call-suspended') {
|
||||
suspensions.push({
|
||||
runId: chunk.runId,
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
...(chunk.input !== undefined ? { input: chunk.input } : {}),
|
||||
...(chunk.suspendPayload !== undefined ? { suspendPayload: chunk.suspendPayload } : {}),
|
||||
...(chunk.resumeSchema !== undefined ? { resumeSchema: chunk.resumeSchema } : {}),
|
||||
});
|
||||
}
|
||||
this.observeDraftChunk(chunk, onChunk, state);
|
||||
this.collectDraftChunk(chunk, errorMode, state);
|
||||
}
|
||||
} catch (error) {
|
||||
if (observerFailed) throw observerError;
|
||||
if (state.observerFailed) throw state.observerError;
|
||||
throw error;
|
||||
}
|
||||
if (observerFailed) throw observerError;
|
||||
|
||||
// Draining preserves terminal usage and lets finalization failures take precedence.
|
||||
if (errorChunk) throw errorChunk.error;
|
||||
if (state.observerFailed) throw state.observerError;
|
||||
// Drain first so terminal usage and finalization errors are preserved.
|
||||
if (state.errorChunk) throw state.errorChunk.error;
|
||||
const executionId = getExecutionId();
|
||||
if (!executionId) throw new UnexpectedError('Agent execution completed without a recorded ID');
|
||||
const metadata = {
|
||||
response,
|
||||
executionId,
|
||||
};
|
||||
return suspensions.length > 0
|
||||
? { status: 'suspended', ...metadata, suspensions }
|
||||
: { status: 'completed', ...metadata };
|
||||
const metadata = { response: state.response, executionId };
|
||||
if (state.suspensions.length > 0) {
|
||||
return { status: 'suspended', ...metadata, suspensions: state.suspensions };
|
||||
}
|
||||
return { status: 'completed', ...metadata };
|
||||
}
|
||||
|
||||
private async validateDraftApprovalCheckpoint(
|
||||
continuation: AgentTestRunContinuation,
|
||||
agentId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
let checkpoint: SerializableAgentState | undefined;
|
||||
try {
|
||||
checkpoint = await this.n8nCheckpointStorage.load(continuation.runId, agentId);
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) throw new InvalidAgentTestRunCheckpointError();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pendingToolCall = checkpoint?.pendingToolCalls[continuation.toolCallId];
|
||||
const expectedResourceId = draftChatMemoryResourceId(userId);
|
||||
if (
|
||||
checkpoint?.status !== 'suspended' ||
|
||||
checkpoint.persistence?.delegated === true ||
|
||||
checkpoint.persistence?.threadId !== continuation.sessionId ||
|
||||
checkpoint.persistence?.resourceId !== expectedResourceId ||
|
||||
!pendingToolCall?.suspended ||
|
||||
pendingToolCall.runId !== continuation.runId ||
|
||||
!parseStandardApprovalSuspension({
|
||||
runId: pendingToolCall.runId,
|
||||
toolCallId: pendingToolCall.toolCallId,
|
||||
toolName: pendingToolCall.toolName,
|
||||
suspendPayload: pendingToolCall.suspendPayload,
|
||||
resumeSchema: pendingToolCall.resumeSchema,
|
||||
})
|
||||
) {
|
||||
throw new InvalidAgentTestRunCheckpointError();
|
||||
}
|
||||
}
|
||||
|
||||
private observeDraftChunk(
|
||||
chunk: StreamChunk,
|
||||
onChunk: DraftRunConsumptionOptions['onChunk'],
|
||||
state: DraftRunState,
|
||||
): void {
|
||||
if (state.observerFailed || !onChunk) return;
|
||||
try {
|
||||
onChunk(chunk);
|
||||
} catch (error) {
|
||||
state.observerFailed = true;
|
||||
state.observerError = error;
|
||||
}
|
||||
}
|
||||
|
||||
private collectDraftChunk(
|
||||
chunk: StreamChunk,
|
||||
errorMode: DraftRunConsumptionOptions['errorMode'],
|
||||
state: DraftRunState,
|
||||
): void {
|
||||
if (chunk.type === 'error' && errorMode === 'throw' && !state.observerFailed) {
|
||||
state.errorChunk ??= chunk;
|
||||
return;
|
||||
}
|
||||
if (state.errorChunk) return;
|
||||
if (chunk.type === 'text-delta') {
|
||||
state.response += chunk.delta;
|
||||
} else if (chunk.type === 'tool-call-suspended') {
|
||||
state.suspensions.push({
|
||||
runId: chunk.runId,
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
...(chunk.input !== undefined ? { input: chunk.input } : {}),
|
||||
...(chunk.suspendPayload !== undefined ? { suspendPayload: chunk.suspendPayload } : {}),
|
||||
...(chunk.resumeSchema !== undefined ? { resumeSchema: chunk.resumeSchema } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { AgentSessionMode } from './utils/agent-thread-access';
|
||||
import { AgentExecutionRecordingError } from './agent-execution-recording.error';
|
||||
import { AgentChatExecutionService } from './agent-chat-execution.service';
|
||||
import {
|
||||
@@ -25,7 +26,7 @@ import { createAttributionTracker } from './utils/mcp-attribution';
|
||||
|
||||
type RecordingContext = Pick<StartExecutionParams, 'projectId' | 'agentId' | 'threadId'>;
|
||||
|
||||
type AgentTurnRequest = { recording: StartExecutionParams } & (
|
||||
export type AgentTurnRequest = { recording: StartExecutionParams } & (
|
||||
| {
|
||||
type: 'start';
|
||||
input: Parameters<RuntimeAgent['stream']>[0];
|
||||
@@ -54,6 +55,7 @@ interface ExecuteTurnConfig {
|
||||
}
|
||||
|
||||
interface TurnExecutionState {
|
||||
executionId?: string;
|
||||
executionStarted: boolean;
|
||||
executionError?: unknown;
|
||||
receivedFinish: boolean;
|
||||
@@ -103,18 +105,17 @@ export class AgentTurnExecutionService {
|
||||
private readonly chatExecutionService: AgentChatExecutionService,
|
||||
) {}
|
||||
|
||||
async getSessionMode(threadId: string): Promise<'new' | 'existing'> {
|
||||
async getSessionMode(threadId: string): Promise<AgentSessionMode> {
|
||||
return await this.agentExecutionService.getSessionMode(threadId);
|
||||
}
|
||||
|
||||
async *execute(config: ExecuteTurnConfig): AsyncGenerator<StreamChunk> {
|
||||
let executionId: string | undefined;
|
||||
let turn: AgentTurnRequest | undefined;
|
||||
let previewControl: PreviewExecutionControl | undefined;
|
||||
const state: TurnExecutionState = { executionStarted: false, receivedFinish: false };
|
||||
const recorder = this.createRecorder(
|
||||
config.toolRegistry,
|
||||
() => executionId,
|
||||
() => state.executionId,
|
||||
config.context,
|
||||
config.backgroundJobSignal,
|
||||
);
|
||||
@@ -126,51 +127,7 @@ export class AgentTurnExecutionService {
|
||||
previewControl = this.createPreviewExecutionControl(preparedTurn);
|
||||
preparedTurn.options.abortSignal = previewControl.controller.signal;
|
||||
}
|
||||
const recordStart = async () => {
|
||||
preparedTurn.options.abortSignal?.throwIfAborted();
|
||||
const id = await this.startExecution(
|
||||
{
|
||||
...preparedTurn.recording,
|
||||
...(config.backgroundJobSignal
|
||||
? { initialTimeline: structuredClone(recorder.getMessageRecord().timeline) }
|
||||
: {}),
|
||||
},
|
||||
recorder.startedAt,
|
||||
);
|
||||
executionId = id;
|
||||
preparedTurn.options.abortSignal?.throwIfAborted();
|
||||
return id;
|
||||
};
|
||||
const startAcceptedTurn = async (id: string) => {
|
||||
if (previewControl) {
|
||||
this.chatExecutionService.register(
|
||||
{
|
||||
...config.context,
|
||||
userId: previewControl.userId,
|
||||
executionId: id,
|
||||
},
|
||||
previewControl.controller,
|
||||
);
|
||||
previewControl.detachRequest();
|
||||
}
|
||||
config.onExecutionStarted?.(id, config.context.threadId);
|
||||
preparedTurn.options.abortSignal?.throwIfAborted();
|
||||
return await this.startTurn(preparedTurn, config, recorder, state);
|
||||
};
|
||||
let stream: ReadableStream<StreamChunk>;
|
||||
if (previewControl && config.automaticPreviewContinuation && preparedTurn.type === 'resume') {
|
||||
stream = await this.chatExecutionService.admitAutomaticContinuation(
|
||||
config.context.threadId,
|
||||
config.context.agentId,
|
||||
preparedTurn.options.runId,
|
||||
async () => await startAcceptedTurn(await recordStart()),
|
||||
);
|
||||
} else {
|
||||
executionId = previewControl
|
||||
? await this.chatExecutionService.admit(config.context.threadId, recordStart)
|
||||
: await recordStart();
|
||||
stream = await startAcceptedTurn(executionId);
|
||||
}
|
||||
const stream = await this.admitTurn(preparedTurn, config, recorder, state, previewControl);
|
||||
yield* this.streamTurn(stream, preparedTurn, config, recorder, state);
|
||||
} catch (error) {
|
||||
state.executionError = error;
|
||||
@@ -179,8 +136,8 @@ export class AgentTurnExecutionService {
|
||||
throw error;
|
||||
} finally {
|
||||
previewControl?.detachRequest();
|
||||
if (turn && executionId) {
|
||||
await this.settleTurn(turn, config, recorder, executionId, state);
|
||||
if (turn && state.executionId) {
|
||||
await this.settleTurn(turn, config, recorder, state.executionId, state);
|
||||
await config.onSettled?.(recorder.suspended);
|
||||
}
|
||||
}
|
||||
@@ -246,19 +203,7 @@ export class AgentTurnExecutionService {
|
||||
if (chunk.type === 'finish') state.receivedFinish = true;
|
||||
|
||||
if (turn.type === 'start') {
|
||||
if (chunk.type === 'tool-call-suspended') {
|
||||
this.logger.info('Chat: tool-call-suspended chunk received', {
|
||||
agentId: config.context.agentId,
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
});
|
||||
}
|
||||
if (chunk.type === 'finish' && chunk.finishReason === 'max-iterations') {
|
||||
for (const maxIterationsChunk of getMaxIterationsChunks()) {
|
||||
recorder.record(maxIterationsChunk);
|
||||
yield maxIterationsChunk;
|
||||
}
|
||||
}
|
||||
yield* this.streamStartTurnNotices(chunk, config.context.agentId, recorder);
|
||||
}
|
||||
|
||||
for (const attributionChunk of attributionTracker.observe(chunk)) {
|
||||
@@ -406,4 +351,89 @@ export class AgentTurnExecutionService {
|
||||
await recordFailure(executionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async admitTurn(
|
||||
turn: AgentTurnRequest,
|
||||
config: ExecuteTurnConfig,
|
||||
recorder: ExecutionRecorder,
|
||||
state: TurnExecutionState,
|
||||
previewControl?: PreviewExecutionControl,
|
||||
): Promise<ReadableStream<StreamChunk>> {
|
||||
const recordStart = async () => await this.recordTurnStart(turn, config, recorder, state);
|
||||
const startAccepted = async (id: string) =>
|
||||
await this.startAcceptedTurn(id, turn, config, recorder, state, previewControl);
|
||||
if (previewControl && config.automaticPreviewContinuation && turn.type === 'resume') {
|
||||
return await this.chatExecutionService.admitAutomaticContinuation(
|
||||
config.context.threadId,
|
||||
config.context.agentId,
|
||||
turn.options.runId,
|
||||
async () => await startAccepted(await recordStart()),
|
||||
);
|
||||
}
|
||||
const executionId = previewControl
|
||||
? await this.chatExecutionService.admit(config.context.threadId, recordStart)
|
||||
: await recordStart();
|
||||
state.executionId = executionId;
|
||||
return await startAccepted(executionId);
|
||||
}
|
||||
|
||||
private async recordTurnStart(
|
||||
turn: AgentTurnRequest,
|
||||
config: ExecuteTurnConfig,
|
||||
recorder: ExecutionRecorder,
|
||||
state: TurnExecutionState,
|
||||
): Promise<string> {
|
||||
turn.options.abortSignal?.throwIfAborted();
|
||||
const id = await this.startExecution(
|
||||
{
|
||||
...turn.recording,
|
||||
...(config.backgroundJobSignal
|
||||
? { initialTimeline: structuredClone(recorder.getMessageRecord().timeline) }
|
||||
: {}),
|
||||
},
|
||||
recorder.startedAt,
|
||||
);
|
||||
state.executionId = id;
|
||||
turn.options.abortSignal?.throwIfAborted();
|
||||
return id;
|
||||
}
|
||||
|
||||
private async startAcceptedTurn(
|
||||
executionId: string,
|
||||
turn: AgentTurnRequest,
|
||||
config: ExecuteTurnConfig,
|
||||
recorder: ExecutionRecorder,
|
||||
state: TurnExecutionState,
|
||||
previewControl?: PreviewExecutionControl,
|
||||
): Promise<ReadableStream<StreamChunk>> {
|
||||
if (previewControl) {
|
||||
this.chatExecutionService.register(
|
||||
{ ...config.context, userId: previewControl.userId, executionId },
|
||||
previewControl.controller,
|
||||
);
|
||||
previewControl.detachRequest();
|
||||
}
|
||||
config.onExecutionStarted?.(executionId, config.context.threadId);
|
||||
turn.options.abortSignal?.throwIfAborted();
|
||||
return await this.startTurn(turn, config, recorder, state);
|
||||
}
|
||||
|
||||
private *streamStartTurnNotices(
|
||||
chunk: StreamChunk,
|
||||
agentId: string,
|
||||
recorder: ExecutionRecorder,
|
||||
): Generator<StreamChunk> {
|
||||
if (chunk.type === 'tool-call-suspended') {
|
||||
this.logger.info('Chat: tool-call-suspended chunk received', {
|
||||
agentId,
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
});
|
||||
}
|
||||
if (chunk.type !== 'finish' || chunk.finishReason !== 'max-iterations') return;
|
||||
for (const notice of getMaxIterationsChunks()) {
|
||||
recorder.record(notice);
|
||||
yield notice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PushPayload } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
@@ -8,7 +7,8 @@ import { InstanceSettings } from 'n8n-core';
|
||||
import { Push } from '@/push';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
import { AgentPushRecipientsService } from './agent-push-recipients.service';
|
||||
|
||||
type AgentUpdate = PushPayload<'agentUpdated'>;
|
||||
|
||||
@@ -16,8 +16,7 @@ type AgentUpdate = PushPayload<'agentUpdated'>;
|
||||
export class AgentUpdateBroadcaster {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly roleService: RoleService,
|
||||
private readonly recipients: AgentPushRecipientsService,
|
||||
private readonly push: Push,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
@@ -35,15 +34,7 @@ export class AgentUpdateBroadcaster {
|
||||
}
|
||||
|
||||
private async broadcast(data: AgentUpdate, excludePushRef?: string): Promise<void> {
|
||||
const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
|
||||
this.roleService.rolesWithScope('global', ['agent:read']),
|
||||
this.roleService.rolesWithScope('project', ['agent:read']),
|
||||
]);
|
||||
const userIds = await this.userRepository.findIdsWithGlobalOrProjectRoles({
|
||||
projectIds: [data.projectId],
|
||||
projectRoleSlugs,
|
||||
globalRoleSlugs,
|
||||
});
|
||||
const userIds = await this.recipients.getProjectReaders(data.projectId);
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
this.push.sendToUsers({ type: 'agentUpdated', data }, userIds, { excludePushRef });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type CredentialProvider, type ToolDescriptor } from '@n8n/agents';
|
||||
import type { CredentialProvider } from '@n8n/agents';
|
||||
import { getProviderPrefix } from '@n8n/ai-utilities/agent-config';
|
||||
import { getRequiredNodeCredentialSlots } from '@n8n/ai-utilities/node-catalog';
|
||||
import {
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
} from '@n8n/api-types';
|
||||
import { WorkflowRepository, type WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { isMcpOAuth2Authentication, NodeHelpers, type INodeParameters } from 'n8n-workflow';
|
||||
import {
|
||||
isMcpOAuth2Authentication,
|
||||
NodeHelpers,
|
||||
type INodeParameters,
|
||||
type INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
@@ -46,7 +51,7 @@ type FindCredential = (
|
||||
credentialId: string,
|
||||
) => Promise<Awaited<ReturnType<CredentialProvider['list']>>[number] | undefined>;
|
||||
|
||||
type CustomToolEntries = Record<string, { code: string; descriptor: ToolDescriptor }>;
|
||||
type CustomToolEntries = Agent['tools'];
|
||||
type TaskBody = AgentTaskConfig;
|
||||
|
||||
interface ConfigurationValidationContext {
|
||||
@@ -389,27 +394,7 @@ export class AgentValidationService {
|
||||
issues.push(agentIssue('incompatible_credential', 'credential'));
|
||||
}
|
||||
|
||||
// Azure OpenAI classic deployments are user-named in Azure and surfaced in
|
||||
// the deployment-based URL path. The catalog model id is not the deployment
|
||||
// id, so a classic endpoint without a deployment name 404s at run time
|
||||
// ("resource not found"). Surface that at save/publish time instead. Foundry
|
||||
// and other endpoint types are unaffected.
|
||||
const provider = model ? getProviderPrefix(model) : undefined;
|
||||
if (provider === 'azure-openai' && !config.modelDeploymentName?.trim()) {
|
||||
let endpointType: unknown;
|
||||
try {
|
||||
const data = await credentialProvider.resolve(credentialId);
|
||||
endpointType = data?.endpointType;
|
||||
} catch {
|
||||
// A credential that can't be resolved here will already be reported
|
||||
// elsewhere; don't let a transient resolve failure block publish
|
||||
// by assuming Classic and requiring a deployment name.
|
||||
return;
|
||||
}
|
||||
if (endpointType !== 'foundry') {
|
||||
issues.push(agentIssue('missing_required', 'modelDeploymentName'));
|
||||
}
|
||||
}
|
||||
await this.collectAzureDeploymentIssues(config, credentialId, credentialProvider, issues);
|
||||
}
|
||||
|
||||
private collectSubAgentRefIssues(
|
||||
@@ -538,16 +523,7 @@ export class AgentValidationService {
|
||||
const tool = tools[index];
|
||||
|
||||
if (tool.type === 'custom') {
|
||||
if (!ctx.customTools[tool.id]) {
|
||||
issues.push(
|
||||
issue('missing_reference', `tools.${index}.id`, {
|
||||
kind: 'tool',
|
||||
id: tool.id,
|
||||
index,
|
||||
toolType: 'custom',
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.collectCustomToolIssues(tool.id, index, ctx.customTools, issues);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -631,51 +607,16 @@ export class AgentValidationService {
|
||||
{ typeVersion: tool.node.nodeTypeVersion },
|
||||
nodeType.description,
|
||||
) ?? {};
|
||||
const requiredSlots = getRequiredNodeCredentialSlots(nodeType.description);
|
||||
for (const slot of requiredSlots) {
|
||||
const credentialDefinition = nodeType.description.credentials?.find(
|
||||
(credential) => credential.name === slot.credentialType,
|
||||
const visibleSlots = this.getVisibleCredentialSlots(tool, nodeType.description, nodeParameters);
|
||||
for (const slot of visibleSlots) {
|
||||
await this.collectNodeCredentialIssues(
|
||||
tool,
|
||||
index,
|
||||
slot.credentialType,
|
||||
nodeParameters,
|
||||
findCredential,
|
||||
issues,
|
||||
);
|
||||
|
||||
if (
|
||||
credentialDefinition &&
|
||||
!NodeHelpers.displayParameter(
|
||||
nodeParameters,
|
||||
credentialDefinition,
|
||||
{ typeVersion: tool.node.nodeTypeVersion },
|
||||
nodeType.description,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = `tools.${index}.node.credentials.${slot.credentialType}`;
|
||||
const credentialRef = tool.node.credentials?.[slot.credentialType];
|
||||
|
||||
if (credentialRef && '__aiGatewayManaged' in credentialRef) {
|
||||
// Only flag a definitive "gateway does not cover this slot". An
|
||||
// indeterminate gateway state must not fail closed, mirroring the
|
||||
// managed main-credential policy in collectMainCredentialIssues.
|
||||
if (
|
||||
(await this.gatewayCoversNodeToolSlot(tool.node, slot.credentialType, nodeParameters)) ===
|
||||
false
|
||||
) {
|
||||
issues.push(issue('invalid_credential', path, capabilityBase));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const credentialId = credentialRef?.id?.trim();
|
||||
|
||||
if (!credentialId) {
|
||||
issues.push(issue('missing_credential', path, capabilityBase));
|
||||
continue;
|
||||
}
|
||||
|
||||
const credential = await this.findCredentialSafe(findCredential, credentialId);
|
||||
if (!credential || credential.type !== slot.credentialType) {
|
||||
issues.push(issue('invalid_credential', path, capabilityBase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,4 +723,108 @@ export class AgentValidationService {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async collectAzureDeploymentIssues(
|
||||
config: AgentJsonConfig,
|
||||
credentialId: string,
|
||||
credentialProvider: CredentialProvider,
|
||||
issues: AgentConfigValidationIssue[],
|
||||
): Promise<void> {
|
||||
const model = config.model?.trim();
|
||||
const provider = model ? getProviderPrefix(model) : undefined;
|
||||
if (provider !== 'azure-openai' || config.modelDeploymentName?.trim()) return;
|
||||
let endpointType: unknown;
|
||||
try {
|
||||
const data = await credentialProvider.resolve(credentialId);
|
||||
endpointType = data?.endpointType;
|
||||
} catch {
|
||||
// A credential that can't be resolved here will already be reported
|
||||
// elsewhere; don't let a transient resolve failure block publish
|
||||
// by assuming Classic and requiring a deployment name.
|
||||
return;
|
||||
}
|
||||
if (endpointType !== 'foundry') {
|
||||
issues.push(agentIssue('missing_required', 'modelDeploymentName'));
|
||||
}
|
||||
}
|
||||
|
||||
private collectCustomToolIssues(
|
||||
id: string,
|
||||
index: number,
|
||||
customTools: CustomToolEntries,
|
||||
issues: AgentConfigValidationIssue[],
|
||||
): void {
|
||||
if (customTools[id]) return;
|
||||
issues.push(
|
||||
issue('missing_reference', `tools.${index}.id`, {
|
||||
kind: 'tool',
|
||||
id,
|
||||
index,
|
||||
toolType: 'custom',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async collectNodeCredentialIssues(
|
||||
tool: AgentJsonNodeToolConfig,
|
||||
index: number,
|
||||
credentialType: string,
|
||||
nodeParameters: INodeParameters,
|
||||
findCredential: FindCredential,
|
||||
issues: AgentConfigValidationIssue[],
|
||||
): Promise<void> {
|
||||
const capabilityBase: AgentConfigValidationIssue['capability'] = {
|
||||
kind: 'tool',
|
||||
id: tool.name,
|
||||
index,
|
||||
toolType: 'node',
|
||||
};
|
||||
const path = `tools.${index}.node.credentials.${credentialType}`;
|
||||
const credentialRef = tool.node.credentials?.[credentialType];
|
||||
|
||||
if (credentialRef && '__aiGatewayManaged' in credentialRef) {
|
||||
// Only flag a definitive "gateway does not cover this slot". An
|
||||
// indeterminate gateway state must not fail closed, mirroring the
|
||||
// managed main-credential policy in collectMainCredentialIssues.
|
||||
if (
|
||||
(await this.gatewayCoversNodeToolSlot(tool.node, credentialType, nodeParameters)) === false
|
||||
) {
|
||||
issues.push(issue('invalid_credential', path, capabilityBase));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialId = credentialRef?.id?.trim();
|
||||
|
||||
if (!credentialId) {
|
||||
issues.push(issue('missing_credential', path, capabilityBase));
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await this.findCredentialSafe(findCredential, credentialId);
|
||||
if (!credential || credential.type !== credentialType) {
|
||||
issues.push(issue('invalid_credential', path, capabilityBase));
|
||||
}
|
||||
}
|
||||
|
||||
private getVisibleCredentialSlots(
|
||||
tool: AgentJsonNodeToolConfig,
|
||||
description: INodeTypeDescription,
|
||||
nodeParameters: INodeParameters,
|
||||
) {
|
||||
return getRequiredNodeCredentialSlots(description).filter((slot) => {
|
||||
const definition = description.credentials?.find(
|
||||
(credential) => credential.name === slot.credentialType,
|
||||
);
|
||||
return (
|
||||
!definition ||
|
||||
NodeHelpers.displayParameter(
|
||||
nodeParameters,
|
||||
definition,
|
||||
{ typeVersion: tool.node.nodeTypeVersion },
|
||||
description,
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,13 @@ async function checkPineconeIndex(
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
if (config.namespace) {
|
||||
const stats = await pc.index(config.indexName).describeIndexStats();
|
||||
if (!(config.namespace in (stats.namespaces ?? {}))) {
|
||||
return {
|
||||
failure: null,
|
||||
warning: `Namespace "${config.namespace}" has no data yet in index "${config.indexName}". It will appear once data is indexed — double-check the name if you expected existing data.`,
|
||||
};
|
||||
}
|
||||
if (!config.namespace) return none;
|
||||
const stats = await pc.index(config.indexName).describeIndexStats();
|
||||
if (!(config.namespace in (stats.namespaces ?? {}))) {
|
||||
return {
|
||||
failure: null,
|
||||
warning: `Namespace "${config.namespace}" has no data yet in index "${config.indexName}". It will appear once data is indexed — double-check the name if you expected existing data.`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Introspection is best-effort; the real probe query below reports the actual failure.
|
||||
@@ -157,26 +156,7 @@ export class AgentVectorStoresService {
|
||||
throw new Error('Connection test timed out');
|
||||
}
|
||||
|
||||
const embeddingOptions = await resolveEmbeddingProviderOptionsFromCredential(
|
||||
vectorStore.embedding.credential,
|
||||
vectorStore.embedding.model,
|
||||
credentialProvider,
|
||||
);
|
||||
const embeddingModel = createEmbeddingModel(vectorStore.embedding.model, embeddingOptions);
|
||||
const { embed } = await import('ai');
|
||||
const { embedding } = await embed({ model: embeddingModel, value: TEST_QUERY });
|
||||
|
||||
const { failure, warning } = await this.checkProviderCompatibility(
|
||||
vectorStore,
|
||||
rawCredential,
|
||||
embedding.length,
|
||||
);
|
||||
if (failure) {
|
||||
return { success: false, message: failure };
|
||||
}
|
||||
|
||||
await backend.query(embedding, { topK: 1 });
|
||||
return { success: true, ...(warning ? { warning } : {}) };
|
||||
return await this.probeBackend(vectorStore, credentialProvider, rawCredential, backend);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
return { success: false, message: errorMessage(error) };
|
||||
@@ -210,4 +190,39 @@ export class AgentVectorStoresService {
|
||||
return { failure: null, warning: null };
|
||||
}
|
||||
}
|
||||
private async embedTestQuery(
|
||||
vectorStore: AgentJsonVectorStoreConfig,
|
||||
credentialProvider: AgentsCredentialProvider,
|
||||
): Promise<number[]> {
|
||||
const embeddingOptions = await resolveEmbeddingProviderOptionsFromCredential(
|
||||
vectorStore.embedding.credential,
|
||||
vectorStore.embedding.model,
|
||||
credentialProvider,
|
||||
);
|
||||
const embeddingModel = createEmbeddingModel(vectorStore.embedding.model, embeddingOptions);
|
||||
const { embed } = await import('ai');
|
||||
const { embedding } = await embed({ model: embeddingModel, value: TEST_QUERY });
|
||||
return embedding;
|
||||
}
|
||||
|
||||
private async probeBackend(
|
||||
vectorStore: AgentJsonVectorStoreConfig,
|
||||
credentialProvider: AgentsCredentialProvider,
|
||||
rawCredential: Record<string, unknown>,
|
||||
backend: BuiltVectorStoreBackend,
|
||||
): Promise<VectorStoreTestResult> {
|
||||
const embedding = await this.embedTestQuery(vectorStore, credentialProvider);
|
||||
|
||||
const { failure, warning } = await this.checkProviderCompatibility(
|
||||
vectorStore,
|
||||
rawCredential,
|
||||
embedding.length,
|
||||
);
|
||||
if (failure) {
|
||||
return { success: false, message: failure };
|
||||
}
|
||||
|
||||
await backend.query(embedding, { topK: 1 });
|
||||
return { success: true, ...(warning ? { warning } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { Agent as RuntimeAgent, BuiltAgent, BuiltTool, CredentialProvider } from '@n8n/agents';
|
||||
import type {
|
||||
Agent as RuntimeAgent,
|
||||
BuiltAgent,
|
||||
BuiltTool,
|
||||
CredentialProvider,
|
||||
StreamChunk,
|
||||
} from '@n8n/agents';
|
||||
import type { AgentJsonConfig, AgentSkill } from '@n8n/api-types';
|
||||
import {
|
||||
AGENT_WORKFLOW_TRIGGER_TYPE,
|
||||
@@ -35,6 +41,7 @@ import {
|
||||
import {
|
||||
buildAgentConfigurationTelemetry,
|
||||
buildAgentConfigurationTelemetryFromConfig,
|
||||
buildAgentTurnMetrics,
|
||||
} from './agent-telemetry';
|
||||
import { AgentTurnExecutionService } from './agent-turn-execution.service';
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
@@ -55,6 +62,7 @@ import { describeStructuredOutputError } from './utils/structured-output-error';
|
||||
import {
|
||||
WorkflowAgentStreamAdapter,
|
||||
type WorkflowAgentStreamObserver,
|
||||
type PendingTool,
|
||||
} from './workflow-agent-stream';
|
||||
|
||||
interface WorkflowSandboxScope {
|
||||
@@ -89,6 +97,27 @@ interface WorkflowAgentStreamConsumption {
|
||||
executionStarted: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowAgentStreamState extends WorkflowAgentStreamConsumption {
|
||||
toolInputs: Map<string, PendingTool>;
|
||||
}
|
||||
|
||||
interface WorkflowExecutionContext {
|
||||
message: string;
|
||||
executionId: string;
|
||||
threadId: string;
|
||||
projectId: string;
|
||||
telemetryUserId?: string;
|
||||
outputSchema?: JSONSchema7;
|
||||
workflowContext?: ExecuteAgentWorkflowContext;
|
||||
streamObserver?: WorkflowAgentStreamObserver;
|
||||
}
|
||||
|
||||
interface StoredWorkflowExecutionContext extends WorkflowExecutionContext {
|
||||
agentId: string;
|
||||
useDraftVersion?: boolean;
|
||||
sandboxScope?: WorkflowSandboxScope;
|
||||
}
|
||||
|
||||
function getFinalWorkflowResponse(messageRecord: MessageRecord): string {
|
||||
let lastToolCallIndex = -1;
|
||||
for (let index = messageRecord.timeline.length - 1; index >= 0; index--) {
|
||||
@@ -293,124 +322,147 @@ export class AgentWorkflowExecutionService {
|
||||
recorder: ExecutionRecorder,
|
||||
streamAdapter: WorkflowAgentStreamAdapter,
|
||||
): Promise<WorkflowAgentStreamConsumption> {
|
||||
const {
|
||||
agentInstance,
|
||||
message,
|
||||
threadId,
|
||||
telemetryAgentId,
|
||||
telemetryUserId,
|
||||
runType,
|
||||
outputSchema,
|
||||
tracing,
|
||||
recordingParams,
|
||||
sandboxScope,
|
||||
} = params;
|
||||
let structuredOutput: unknown = null;
|
||||
const toolCalls: ExecuteAgentData['toolCalls'] = [];
|
||||
const toolInputs = new Map<string, { toolName: string; input: unknown }>();
|
||||
let streamError: Error | undefined;
|
||||
let executionError: unknown;
|
||||
let executionStarted = false;
|
||||
|
||||
const state: WorkflowAgentStreamState = {
|
||||
structuredOutput: null,
|
||||
toolCalls: [],
|
||||
toolInputs: new Map(),
|
||||
streamError: undefined,
|
||||
executionError: undefined,
|
||||
executionStarted: false,
|
||||
};
|
||||
try {
|
||||
// Nest the agent's root span under the calling node's OTel span (rather
|
||||
// than a disconnected root trace) by running inside its active context.
|
||||
// Falls back to running unwrapped when no such span is tracked (e.g. otel
|
||||
// disabled) — `context.with` needs an actual parent context to nest under.
|
||||
const { tracing } = params;
|
||||
const parentCtx = tracing.executionId
|
||||
? this.executionLevelTracer.getActiveContext(tracing.executionId, tracing.nodeName)
|
||||
: undefined;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
// No model_id here: BuiltAgent (unlike the cached-runtime RuntimeAgent
|
||||
// used by the chat/task paths) doesn't expose a snapshot. The AI SDK's
|
||||
// own gen_ai.request.model attribute on its model spans still carries
|
||||
// this regardless.
|
||||
const telemetry = await this.agentRunTracingService.build({
|
||||
agentId: telemetryAgentId,
|
||||
projectId: tracing.projectId,
|
||||
threadId,
|
||||
userId: telemetryUserId,
|
||||
source: 'workflow',
|
||||
executionId: tracing.executionId,
|
||||
workflowId: tracing.workflowId,
|
||||
nodeId: tracing.nodeId,
|
||||
hasParentContext: parentCtx !== undefined,
|
||||
});
|
||||
|
||||
// Only stored agents have integration tools.
|
||||
const messageContext = recordingParams
|
||||
? await this.integrationMessageContextService.getLatest(threadId)
|
||||
: null;
|
||||
const options = {
|
||||
// The memory store scopes message reads by `resourceId` (the
|
||||
// "per-user scope"; chat integrations pass the chat user id there).
|
||||
// Workflow runs have no user, so key the scope by the thread
|
||||
// itself: it is stable across executions, which is what lets a
|
||||
// caller-supplied session id actually continue the conversation.
|
||||
// The previous key — the execution id — changed every run and hid
|
||||
// all prior messages of the thread from the model.
|
||||
persistence: {
|
||||
resourceId: threadId,
|
||||
threadId,
|
||||
hostMetadata: {
|
||||
...(sandboxScope ? encodeAgentSandboxHostMetadata(sandboxScope) : {}),
|
||||
...encodeIntegrationMessageContext(messageContext),
|
||||
},
|
||||
},
|
||||
executionCounter: createAgentExecutionCounter(this.telemetry, {
|
||||
agentId: telemetryAgentId,
|
||||
userId: telemetryUserId,
|
||||
runType,
|
||||
}),
|
||||
...modelStreamStallOptions(this.aiConfig),
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
};
|
||||
executionStarted = true;
|
||||
const resultStream = await agentInstance.stream(message, options);
|
||||
|
||||
for await (const value of streamAgentChunks(resultStream.stream)) {
|
||||
if (value.type === 'error') {
|
||||
executionError = value.error;
|
||||
if (outputSchema)
|
||||
streamError = this.normalizeWorkflowStreamError(value.error, outputSchema);
|
||||
}
|
||||
recorder.record(
|
||||
value.type === 'error' && streamError ? { ...value, error: streamError } : value,
|
||||
);
|
||||
await streamAdapter.observe(value);
|
||||
|
||||
if (value.type === 'tool-call') {
|
||||
toolInputs.set(value.toolCallId, { toolName: value.toolName, input: value.input });
|
||||
} else if (value.type === 'tool-result') {
|
||||
const pending = toolInputs.get(value.toolCallId);
|
||||
toolCalls.push({
|
||||
toolName: value.toolName,
|
||||
input: pending?.input ?? null,
|
||||
result: value.output,
|
||||
});
|
||||
toolInputs.delete(value.toolCallId);
|
||||
} else if (value.type === 'finish' && value.structuredOutput !== undefined) {
|
||||
structuredOutput = value.structuredOutput;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const run = async () =>
|
||||
await this.runWorkflowAgentStream(
|
||||
params,
|
||||
recorder,
|
||||
streamAdapter,
|
||||
state,
|
||||
parentCtx !== undefined,
|
||||
);
|
||||
if (parentCtx) {
|
||||
await context.with(parentCtx, run);
|
||||
} else {
|
||||
await run();
|
||||
}
|
||||
} catch (error) {
|
||||
executionError = error;
|
||||
const normalizedError = this.normalizeWorkflowStreamError(error, outputSchema);
|
||||
state.executionError = error;
|
||||
const normalizedError = this.normalizeWorkflowStreamError(error, params.outputSchema);
|
||||
recorder.record({ type: 'error', error: normalizedError });
|
||||
recorder.record({ type: 'finish', finishReason: 'error' });
|
||||
streamError = normalizedError;
|
||||
state.streamError = normalizedError;
|
||||
streamAdapter.fail();
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
return { structuredOutput, toolCalls, streamError, executionError, executionStarted };
|
||||
private async runWorkflowAgentStream(
|
||||
params: WorkflowAgentStreamParams,
|
||||
recorder: ExecutionRecorder,
|
||||
streamAdapter: WorkflowAgentStreamAdapter,
|
||||
state: WorkflowAgentStreamState,
|
||||
hasParentContext: boolean,
|
||||
): Promise<void> {
|
||||
const options = await this.getWorkflowStreamOptions(params, hasParentContext);
|
||||
state.executionStarted = true;
|
||||
const resultStream = await params.agentInstance.stream(params.message, options);
|
||||
for await (const value of streamAgentChunks(resultStream.stream)) {
|
||||
this.recordWorkflowChunk(value, params.outputSchema, recorder, state);
|
||||
await streamAdapter.observe(value);
|
||||
this.collectWorkflowChunk(value, state);
|
||||
}
|
||||
}
|
||||
|
||||
private async getWorkflowStreamOptions(
|
||||
params: WorkflowAgentStreamParams,
|
||||
hasParentContext: boolean,
|
||||
) {
|
||||
const {
|
||||
telemetryAgentId,
|
||||
telemetryUserId,
|
||||
runType,
|
||||
tracing,
|
||||
threadId,
|
||||
recordingParams,
|
||||
sandboxScope,
|
||||
} = params;
|
||||
const telemetry = await this.agentRunTracingService.build({
|
||||
agentId: telemetryAgentId,
|
||||
projectId: tracing.projectId,
|
||||
threadId,
|
||||
userId: telemetryUserId,
|
||||
source: 'workflow',
|
||||
executionId: tracing.executionId,
|
||||
workflowId: tracing.workflowId,
|
||||
nodeId: tracing.nodeId,
|
||||
hasParentContext,
|
||||
});
|
||||
|
||||
// Only stored agents have integration tools.
|
||||
const messageContext = recordingParams
|
||||
? await this.integrationMessageContextService.getLatest(threadId)
|
||||
: null;
|
||||
return {
|
||||
// The memory store scopes message reads by `resourceId` (the
|
||||
// "per-user scope"; chat integrations pass the chat user id there).
|
||||
// Workflow runs have no user, so key the scope by the thread
|
||||
// itself: it is stable across executions, which is what lets a
|
||||
// caller-supplied session id actually continue the conversation.
|
||||
// The previous key — the execution id — changed every run and hid
|
||||
// all prior messages of the thread from the model.
|
||||
persistence: {
|
||||
resourceId: threadId,
|
||||
threadId,
|
||||
hostMetadata: {
|
||||
...(sandboxScope ? encodeAgentSandboxHostMetadata(sandboxScope) : {}),
|
||||
...encodeIntegrationMessageContext(messageContext),
|
||||
},
|
||||
},
|
||||
executionCounter: createAgentExecutionCounter(this.telemetry, {
|
||||
agentId: telemetryAgentId,
|
||||
userId: telemetryUserId,
|
||||
runType,
|
||||
}),
|
||||
...modelStreamStallOptions(this.aiConfig),
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private recordWorkflowChunk(
|
||||
value: StreamChunk,
|
||||
outputSchema: JSONSchema7 | undefined,
|
||||
recorder: ExecutionRecorder,
|
||||
state: WorkflowAgentStreamState,
|
||||
): void {
|
||||
if (value.type === 'error') {
|
||||
state.executionError = value.error;
|
||||
if (outputSchema)
|
||||
state.streamError = this.normalizeWorkflowStreamError(value.error, outputSchema);
|
||||
}
|
||||
recorder.record(
|
||||
value.type === 'error' && state.streamError ? { ...value, error: state.streamError } : value,
|
||||
);
|
||||
}
|
||||
|
||||
private collectWorkflowChunk(value: StreamChunk, state: WorkflowAgentStreamState): void {
|
||||
switch (value.type) {
|
||||
case 'tool-call':
|
||||
state.toolInputs.set(value.toolCallId, { toolName: value.toolName, input: value.input });
|
||||
break;
|
||||
case 'tool-result':
|
||||
state.toolCalls.push({
|
||||
toolName: value.toolName,
|
||||
input: state.toolInputs.get(value.toolCallId)?.input ?? null,
|
||||
result: value.output,
|
||||
});
|
||||
state.toolInputs.delete(value.toolCallId);
|
||||
break;
|
||||
case 'finish':
|
||||
if (value.structuredOutput !== undefined) state.structuredOutput = value.structuredOutput;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream one workflow-invoked agent run and collect its outcome. */
|
||||
@@ -467,38 +519,8 @@ export class AgentWorkflowExecutionService {
|
||||
outputSchema?: JSONSchema7;
|
||||
}): ExecuteAgentData {
|
||||
const { run, session, outputSchema } = params;
|
||||
const { recorder, messageRecord, structuredOutput, toolCalls, streamError } = run;
|
||||
|
||||
if (streamError !== undefined) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
if (recorder.suspended) {
|
||||
throw createWorkflowAgentExecutionError(
|
||||
'Agent execution suspended waiting for tool approval. ' +
|
||||
'Suspend/resume is not supported in workflow execution context.',
|
||||
);
|
||||
}
|
||||
|
||||
if (messageRecord.error) {
|
||||
if (outputSchema) {
|
||||
const structuredOutputError = describeStructuredOutputError(messageRecord.error);
|
||||
if (structuredOutputError) {
|
||||
throw createWorkflowAgentExecutionError(structuredOutputError);
|
||||
}
|
||||
}
|
||||
throw createWorkflowAgentExecutionError(`Agent execution failed: ${messageRecord.error}`);
|
||||
}
|
||||
|
||||
if (messageRecord.finishReason === 'error') {
|
||||
throw createWorkflowAgentExecutionError(
|
||||
outputSchema
|
||||
? 'Agent execution finished with an error while producing structured output. ' +
|
||||
"The agent's model or provider may not support JSON Schema structured output."
|
||||
: 'Agent execution finished with an error.',
|
||||
);
|
||||
}
|
||||
|
||||
this.assertWorkflowRunSucceeded(run, outputSchema);
|
||||
const { messageRecord, structuredOutput, toolCalls } = run;
|
||||
return {
|
||||
response: messageRecord.assistantResponse,
|
||||
structuredOutput: structuredOutput ?? null,
|
||||
@@ -515,6 +537,46 @@ export class AgentWorkflowExecutionService {
|
||||
};
|
||||
}
|
||||
|
||||
private assertWorkflowRunSucceeded(
|
||||
run: WorkflowAgentRunOutcome,
|
||||
outputSchema?: JSONSchema7,
|
||||
): void {
|
||||
const { recorder, messageRecord, streamError } = run;
|
||||
if (streamError !== undefined) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
if (recorder.suspended) {
|
||||
throw createWorkflowAgentExecutionError(
|
||||
'Agent execution suspended waiting for tool approval. ' +
|
||||
'Suspend/resume is not supported in workflow execution context.',
|
||||
);
|
||||
}
|
||||
|
||||
if (messageRecord.error) {
|
||||
throw createWorkflowAgentExecutionError(
|
||||
this.workflowFailureMessage(messageRecord.error, outputSchema),
|
||||
);
|
||||
}
|
||||
|
||||
if (messageRecord.finishReason === 'error') {
|
||||
throw createWorkflowAgentExecutionError(
|
||||
outputSchema
|
||||
? 'Agent execution finished with an error while producing structured output. ' +
|
||||
"The agent's model or provider may not support JSON Schema structured output."
|
||||
: 'Agent execution finished with an error.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private workflowFailureMessage(message: string, outputSchema?: JSONSchema7): string {
|
||||
if (outputSchema) {
|
||||
const structuredOutputError = describeStructuredOutputError(message);
|
||||
if (structuredOutputError) return structuredOutputError;
|
||||
}
|
||||
return `Agent execution failed: ${message}`;
|
||||
}
|
||||
|
||||
async executeForWorkflow(
|
||||
agentId: string,
|
||||
message: string,
|
||||
@@ -528,7 +590,7 @@ export class AgentWorkflowExecutionService {
|
||||
sandboxScope?: WorkflowSandboxScope,
|
||||
streamObserver?: WorkflowAgentStreamObserver,
|
||||
): Promise<ExecuteAgentData> {
|
||||
return await this.executeForWorkflowInternal(
|
||||
return await this.executeForWorkflowInternal({
|
||||
agentId,
|
||||
message,
|
||||
executionId,
|
||||
@@ -540,41 +602,45 @@ export class AgentWorkflowExecutionService {
|
||||
workflowContext,
|
||||
sandboxScope,
|
||||
streamObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async executeForWorkflowInternal(
|
||||
agentId: string,
|
||||
message: string,
|
||||
executionId: string,
|
||||
threadId: string,
|
||||
projectId: string,
|
||||
telemetryUserId?: string,
|
||||
useDraftVersion?: boolean,
|
||||
outputSchema?: JSONSchema7,
|
||||
workflowContext?: ExecuteAgentWorkflowContext,
|
||||
sandboxScope?: WorkflowSandboxScope,
|
||||
streamObserver?: WorkflowAgentStreamObserver,
|
||||
params: StoredWorkflowExecutionContext,
|
||||
): Promise<ExecuteAgentData> {
|
||||
const { agentId, projectId, threadId, outputSchema, sandboxScope } = params;
|
||||
const { agentInstance, recordingParams, runType } = await this.prepareStoredWorkflowRun(params);
|
||||
const run = await this.streamCompiledWorkflowAgent(agentInstance, params, {
|
||||
telemetryAgentId: agentId,
|
||||
runType,
|
||||
recordingParams: { ...recordingParams, agentName: agentInstance.name },
|
||||
...(sandboxScope
|
||||
? { sandboxScope: { projectId, principalHash: sandboxScope.principalHash } }
|
||||
: {}),
|
||||
});
|
||||
return this.buildWorkflowResult({
|
||||
run,
|
||||
// The caller remaps the scoped thread key to its session id.
|
||||
session: { agentId, projectId, sessionId: threadId, threadId },
|
||||
outputSchema,
|
||||
});
|
||||
}
|
||||
|
||||
private async prepareStoredWorkflowRun(params: StoredWorkflowExecutionContext) {
|
||||
const {
|
||||
agentId,
|
||||
projectId,
|
||||
threadId,
|
||||
message,
|
||||
telemetryUserId,
|
||||
useDraftVersion,
|
||||
outputSchema,
|
||||
workflowContext,
|
||||
sandboxScope,
|
||||
} = params;
|
||||
// Keep the original intent if deletion happens during preparation.
|
||||
const sessionMode = await this.turnExecutionService.getSessionMode(threadId);
|
||||
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agentEntity) {
|
||||
throw new OperationalError('Agent not found or not accessible.');
|
||||
}
|
||||
|
||||
const credentialProvider = createAgentCredentialProvider(
|
||||
this.credentialsService,
|
||||
projectId,
|
||||
undefined,
|
||||
agentId,
|
||||
);
|
||||
|
||||
let agentData: Agent = agentEntity;
|
||||
|
||||
if (!useDraftVersion) {
|
||||
agentData = getPublishedAgentSnapshot(agentEntity);
|
||||
}
|
||||
const { agentData, credentialProvider } = await this.loadWorkflowAgent(params);
|
||||
const telemetryConfiguration = buildAgentConfigurationTelemetry(agentData);
|
||||
const runType: AgentRunTelemetryType = useDraftVersion ? 'test' : 'production';
|
||||
|
||||
@@ -606,16 +672,56 @@ export class AgentWorkflowExecutionService {
|
||||
await this.turnExecutionService.recordFailedStart(recordingParams, error);
|
||||
throw error;
|
||||
}
|
||||
return { agentInstance: compiled.agent, recordingParams, runType };
|
||||
}
|
||||
|
||||
const agentInstance = compiled.agent;
|
||||
const run = await this.streamWorkflowAgent({
|
||||
private async loadWorkflowAgent(params: StoredWorkflowExecutionContext) {
|
||||
const { agentId, projectId, useDraftVersion } = params;
|
||||
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agentEntity) {
|
||||
throw new OperationalError('Agent not found or not accessible.');
|
||||
}
|
||||
|
||||
const credentialProvider = createAgentCredentialProvider(
|
||||
this.credentialsService,
|
||||
projectId,
|
||||
undefined,
|
||||
agentId,
|
||||
);
|
||||
|
||||
let agentData: Agent = agentEntity;
|
||||
|
||||
if (!useDraftVersion) {
|
||||
agentData = getPublishedAgentSnapshot(agentEntity);
|
||||
}
|
||||
return { agentData, credentialProvider };
|
||||
}
|
||||
|
||||
private async streamCompiledWorkflowAgent(
|
||||
agentInstance: BuiltAgent,
|
||||
params: WorkflowExecutionContext,
|
||||
run: Pick<
|
||||
WorkflowAgentStreamParams,
|
||||
'telemetryAgentId' | 'runType' | 'recordingParams' | 'sandboxScope'
|
||||
>,
|
||||
): Promise<WorkflowAgentRunOutcome> {
|
||||
const {
|
||||
message,
|
||||
threadId,
|
||||
telemetryUserId,
|
||||
outputSchema,
|
||||
projectId,
|
||||
executionId,
|
||||
workflowContext,
|
||||
streamObserver,
|
||||
} = params;
|
||||
return await this.streamWorkflowAgent({
|
||||
agentInstance,
|
||||
message,
|
||||
threadId,
|
||||
telemetryAgentId: agentId,
|
||||
telemetryUserId,
|
||||
runType,
|
||||
outputSchema,
|
||||
...run,
|
||||
tracing: {
|
||||
projectId,
|
||||
executionId,
|
||||
@@ -623,24 +729,7 @@ export class AgentWorkflowExecutionService {
|
||||
nodeId: workflowContext?.callingNodeId,
|
||||
nodeName: workflowContext?.callingNodeName,
|
||||
},
|
||||
recordingParams: { ...recordingParams, agentName: agentInstance.name },
|
||||
streamObserver,
|
||||
...(sandboxScope
|
||||
? {
|
||||
sandboxScope: {
|
||||
projectId,
|
||||
principalHash: sandboxScope.principalHash,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return this.buildWorkflowResult({
|
||||
run,
|
||||
// sessionId here is still the scoped thread key — executeAgent remaps it
|
||||
// to the caller-facing id; this method never sees the unscoped one.
|
||||
session: { agentId, projectId, sessionId: threadId, threadId },
|
||||
outputSchema,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -665,6 +754,54 @@ export class AgentWorkflowExecutionService {
|
||||
workflowContext?: ExecuteAgentWorkflowContext,
|
||||
streamObserver?: WorkflowAgentStreamObserver,
|
||||
): Promise<ExecuteAgentData> {
|
||||
const { runtimeConfig, skills, credentialProvider } = await this.prepareInlineRuntime(
|
||||
inlineAgent,
|
||||
projectId,
|
||||
workflowContext,
|
||||
);
|
||||
// For telemetry/logging and memory-owner keying — never persisted, and
|
||||
// stable enough to aggregate runs of the same node across executions.
|
||||
const syntheticAgentId = `inline:${workflowContext?.workflowId ?? 'unknown'}:${
|
||||
workflowContext?.callingNodeName ?? 'unknown'
|
||||
}`;
|
||||
|
||||
const extraTools = this.buildWorkflowExtraTools(workflowContext);
|
||||
const compiled = await this.compileIsolatedFromSource(
|
||||
runtimeConfig,
|
||||
skills,
|
||||
syntheticAgentId,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
runType,
|
||||
outputSchema,
|
||||
extraTools?.length ? extraTools : undefined,
|
||||
);
|
||||
if (!compiled.ok || !compiled.agent) {
|
||||
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
|
||||
}
|
||||
const params: WorkflowExecutionContext = {
|
||||
message,
|
||||
executionId,
|
||||
threadId,
|
||||
projectId,
|
||||
telemetryUserId,
|
||||
outputSchema,
|
||||
workflowContext,
|
||||
streamObserver,
|
||||
};
|
||||
const run = await this.streamCompiledWorkflowAgent(compiled.agent, params, {
|
||||
telemetryAgentId: syntheticAgentId,
|
||||
runType,
|
||||
});
|
||||
this.trackInlineRun(params, syntheticAgentId, runType, runtimeConfig, run);
|
||||
return this.buildWorkflowResult({ run, session: null, outputSchema });
|
||||
}
|
||||
|
||||
private async prepareInlineRuntime(
|
||||
inlineAgent: InlineAgentPayload,
|
||||
projectId: string,
|
||||
workflowContext: ExecuteAgentWorkflowContext | undefined,
|
||||
) {
|
||||
const { config, skills } = await this.validateInlineAgentConfig(inlineAgent);
|
||||
|
||||
// Session memory: when the caller supplied a session id, inline agents run
|
||||
@@ -701,48 +838,18 @@ export class AgentWorkflowExecutionService {
|
||||
runtimeConfig.tools,
|
||||
new Set(accessibleCredentials.map((credential) => credential.type)),
|
||||
);
|
||||
return { runtimeConfig, skills, credentialProvider };
|
||||
}
|
||||
|
||||
// For telemetry/logging and memory-owner keying — never persisted, and
|
||||
// stable enough to aggregate runs of the same node across executions.
|
||||
const syntheticAgentId = `inline:${workflowContext?.workflowId ?? 'unknown'}:${
|
||||
workflowContext?.callingNodeName ?? 'unknown'
|
||||
}`;
|
||||
|
||||
const extraTools = this.buildWorkflowExtraTools(workflowContext);
|
||||
const compiled = await this.compileIsolatedFromSource(
|
||||
runtimeConfig,
|
||||
skills,
|
||||
syntheticAgentId,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
runType,
|
||||
outputSchema,
|
||||
extraTools?.length ? extraTools : undefined,
|
||||
);
|
||||
if (!compiled.ok || !compiled.agent) {
|
||||
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
|
||||
}
|
||||
|
||||
const run = await this.streamWorkflowAgent({
|
||||
agentInstance: compiled.agent,
|
||||
message,
|
||||
threadId,
|
||||
telemetryAgentId: syntheticAgentId,
|
||||
telemetryUserId,
|
||||
runType,
|
||||
outputSchema,
|
||||
tracing: {
|
||||
projectId,
|
||||
executionId,
|
||||
workflowId: workflowContext?.workflowId,
|
||||
nodeId: workflowContext?.callingNodeId,
|
||||
nodeName: workflowContext?.callingNodeName,
|
||||
},
|
||||
streamObserver,
|
||||
});
|
||||
|
||||
// No `recordMessage` here: inline runs have no agent entity to attach a
|
||||
// session to. Emit the turn-finished telemetry directly instead.
|
||||
private trackInlineRun(
|
||||
params: WorkflowExecutionContext,
|
||||
syntheticAgentId: string,
|
||||
runType: AgentRunTelemetryType,
|
||||
runtimeConfig: AgentJsonConfig,
|
||||
run: WorkflowAgentRunOutcome,
|
||||
): void {
|
||||
const { telemetryUserId, threadId } = params;
|
||||
// Inline runs have no stored session, so report completion here.
|
||||
try {
|
||||
this.telemetry.trackAgentTurnFinished({
|
||||
agent_id: syntheticAgentId,
|
||||
@@ -755,10 +862,7 @@ export class AgentWorkflowExecutionService {
|
||||
? 'failed'
|
||||
: 'succeeded',
|
||||
configuration: buildAgentConfigurationTelemetryFromConfig(runtimeConfig),
|
||||
latency_ms: run.messageRecord.duration,
|
||||
cost: run.messageRecord.totalCost ?? 0,
|
||||
token_count: run.messageRecord.usage?.totalTokens ?? 0,
|
||||
tool_call_count: run.messageRecord.timeline.filter((t) => t.type === 'tool-call').length,
|
||||
...buildAgentTurnMetrics(run.messageRecord),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to track inline agent execution telemetry', {
|
||||
@@ -766,8 +870,6 @@ export class AgentWorkflowExecutionService {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return this.buildWorkflowResult({ run, session: null, outputSchema });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ToolContext } from '@n8n/agents';
|
||||
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
@@ -157,39 +158,13 @@ export class AgentWorkflowToolResumeService {
|
||||
if (checkpoint.status !== 'active' || checkpoint.checkpoint.status !== 'suspended') return;
|
||||
const persistence = checkpoint.checkpoint.persistence;
|
||||
if (!persistence) return;
|
||||
let messageContext = readIntegrationMessageContext(persistence);
|
||||
const allowLegacyThreadId = messageContext === undefined;
|
||||
if (messageContext === undefined) {
|
||||
try {
|
||||
messageContext = await this.messageContextService.getLatest(persistence.threadId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Could not read the thread message context for an agent resume', {
|
||||
runId: agentRun.runId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
messageContext = null;
|
||||
}
|
||||
}
|
||||
const [platform, storedCredentialId] = messageContext?.integrationConnectionId.split(':') ?? [];
|
||||
let integrationType = agentRun.integrationType;
|
||||
let credentialId: string | undefined;
|
||||
if (allowLegacyThreadId) {
|
||||
if (messageContext?.platform === integrationType && platform === integrationType) {
|
||||
credentialId = storedCredentialId;
|
||||
} else {
|
||||
messageContext = null;
|
||||
}
|
||||
} else {
|
||||
if (!messageContext || messageContext.platform !== platform || !storedCredentialId) {
|
||||
this.logger.warn('Agent resume has no integration reply context', {
|
||||
agentId: agentRun.agentId,
|
||||
runId: agentRun.runId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
integrationType = messageContext.platform;
|
||||
credentialId = storedCredentialId;
|
||||
}
|
||||
const route = await this.getIntegrationResumeRoute(
|
||||
agentRun,
|
||||
agentRun.integrationType,
|
||||
persistence,
|
||||
);
|
||||
if (!route) return;
|
||||
const { integrationType, credentialId, messageContext, allowLegacyThreadId } = route;
|
||||
const bridge = this.chatIntegrationService.getBridge(
|
||||
agentRun.agentId,
|
||||
integrationType,
|
||||
@@ -266,4 +241,53 @@ export class AgentWorkflowToolResumeService {
|
||||
executionId: result.executionId ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
private async getIntegrationResumeRoute(
|
||||
agentRun: RelatedAgentRun,
|
||||
integrationType: string,
|
||||
persistence: NonNullable<ToolContext['persistence']>,
|
||||
) {
|
||||
let messageContext = readIntegrationMessageContext(persistence);
|
||||
const allowLegacyThreadId = messageContext === undefined;
|
||||
if (messageContext === undefined) {
|
||||
messageContext = await this.loadLegacyMessageContext(persistence.threadId, agentRun.runId);
|
||||
}
|
||||
const [platform, credentialId] = messageContext?.integrationConnectionId.split(':') ?? [];
|
||||
if (allowLegacyThreadId) {
|
||||
if (messageContext?.platform === integrationType && platform === integrationType) {
|
||||
return { integrationType, credentialId, messageContext, allowLegacyThreadId };
|
||||
}
|
||||
return {
|
||||
integrationType,
|
||||
credentialId: undefined,
|
||||
messageContext: null,
|
||||
allowLegacyThreadId,
|
||||
};
|
||||
}
|
||||
if (!messageContext || messageContext.platform !== platform || !credentialId) {
|
||||
this.logger.warn('Agent resume has no integration reply context', {
|
||||
agentId: agentRun.agentId,
|
||||
runId: agentRun.runId,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
integrationType: messageContext.platform,
|
||||
credentialId,
|
||||
messageContext,
|
||||
allowLegacyThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadLegacyMessageContext(threadId: string, runId: string) {
|
||||
try {
|
||||
return await this.messageContextService.getLatest(threadId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Could not read the thread message context for an agent resume', {
|
||||
runId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BuiltTool, CredentialProvider } from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import type { CodeBuilderSearchResult } from '@n8n/ai-utilities/node-catalog';
|
||||
import type {
|
||||
CodeBuilderSearchResult,
|
||||
NodeRequest as CatalogNodeRequest,
|
||||
} from '@n8n/ai-utilities/node-catalog';
|
||||
import { AGENT_BUILDER_HIDDEN_AVAILABLE_TOOL_NODE_TYPES } from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import { isToolType, isTriggerNodeType } from 'n8n-workflow';
|
||||
@@ -16,13 +19,7 @@ import { MCP_REGISTRY_PACKAGE_NAME } from '../mcp-registry/node-description-tran
|
||||
|
||||
type NodeRequest =
|
||||
| string
|
||||
| {
|
||||
nodeId: string;
|
||||
version?: number;
|
||||
resource?: string;
|
||||
operation?: string;
|
||||
mode?: string;
|
||||
};
|
||||
| (Omit<Exclude<CatalogNodeRequest, string>, 'version'> & { version?: number });
|
||||
|
||||
/**
|
||||
* Nodes the agent runtime can execute directly. Triggers are workflow entry
|
||||
@@ -203,15 +200,7 @@ export class AgentsToolsService {
|
||||
* The catalog's `getNodeTypes` signature expects `version` as a string (matching the
|
||||
* builder tool's wire format); adapt at the boundary.
|
||||
*/
|
||||
function normalizeNodeRequestForCatalog(req: NodeRequest):
|
||||
| string
|
||||
| {
|
||||
nodeId: string;
|
||||
version?: string;
|
||||
resource?: string;
|
||||
operation?: string;
|
||||
mode?: string;
|
||||
} {
|
||||
function normalizeNodeRequestForCatalog(req: NodeRequest): CatalogNodeRequest {
|
||||
if (typeof req === 'string') return req;
|
||||
const { version, ...rest } = req;
|
||||
return version === undefined ? rest : { ...rest, version: String(version) };
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AgentCapabilityTool,
|
||||
type AgentIntegrationConfig,
|
||||
type AgentJsonConfig,
|
||||
type AgentModelCredentialConfig,
|
||||
type AgentSkill,
|
||||
type ListAgentsQueryDto,
|
||||
} from '@n8n/api-types';
|
||||
@@ -22,9 +23,9 @@ import { v4 as uuid } from 'uuid';
|
||||
// eslint-disable-next-line import-x/no-cycle
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
||||
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
|
||||
import { AgentExecutionService } from './agent-execution.service';
|
||||
import { AgentKnowledgeService } from './agent-knowledge.service';
|
||||
@@ -38,6 +39,7 @@ import { AgentTaskRepository } from './repositories/agent-task.repository';
|
||||
import {
|
||||
AgentRepository,
|
||||
type AgentSummary,
|
||||
type AgentListResult,
|
||||
type AgentSummaryFilters,
|
||||
} from './repositories/agent.repository';
|
||||
import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service';
|
||||
@@ -47,7 +49,7 @@ type CreateAgentOptions = {
|
||||
availableInMCP?: boolean;
|
||||
id?: string;
|
||||
adoptOnCollision?: boolean;
|
||||
defaultModel?: { model: string; credential: string };
|
||||
defaultModel?: AgentModelCredentialConfig;
|
||||
/** Create with this config instead of the empty draft, so eval thread seeding
|
||||
* can recreate an already-built agent in one insert. */
|
||||
schema?: AgentJsonConfig;
|
||||
@@ -124,31 +126,11 @@ export class AgentsService {
|
||||
user,
|
||||
}: CreateAgentOptions = {},
|
||||
): Promise<{ agent: Agent; adopted: boolean }> {
|
||||
const defaultConfig: AgentJsonConfig = {
|
||||
name,
|
||||
model: '',
|
||||
instructions: '',
|
||||
...(defaultModel ?? {}),
|
||||
tools: [],
|
||||
skills: [],
|
||||
// Seeded at birth so every agent has a distinct tile, and so the builder
|
||||
// sees an existing icon name when it reads the config — without one it
|
||||
// invents its own, which the icon tile cannot render.
|
||||
personalisation: {
|
||||
icon: DEFAULT_AGENT_PERSONALISATION.icon,
|
||||
gradient: getRandomAgentPersonalisationGradient(),
|
||||
},
|
||||
};
|
||||
|
||||
// A user-driven duplicate seeds a full config. Mirror the `updateConfig`
|
||||
// write path: sanitize the config, then blank any credential the
|
||||
// duplicating user cannot use in this project. Eval seeding (no `user`)
|
||||
// inserts the config as-is, as before.
|
||||
const { schemaConfig, integrations } = schema
|
||||
? user
|
||||
? await this.prepareDuplicateConfig(schema, projectId, user)
|
||||
: decomposeJsonConfig(schema)
|
||||
: decomposeJsonConfig(defaultConfig);
|
||||
const { schemaConfig, integrations } = await this.prepareInitialConfig(projectId, name, {
|
||||
schema,
|
||||
user,
|
||||
defaultModel,
|
||||
});
|
||||
|
||||
const agent = this.agentRepository.create({
|
||||
...(id ? { id } : {}),
|
||||
@@ -166,16 +148,10 @@ export class AgentsService {
|
||||
try {
|
||||
saved = await this.agentRepository.save(agent);
|
||||
} catch (error) {
|
||||
if (!id || !isUniqueConstraintError(error)) throw error;
|
||||
// Never disclose whether the id exists in another project.
|
||||
const conflict = new ConflictError('An agent with this id already exists');
|
||||
if (!adoptOnCollision) throw conflict;
|
||||
// Returned as it stands: the winner may already have configured it, and
|
||||
// this call's `name`/`schema` describe a draft that never existed.
|
||||
const existing = await this.agentRepository.findByIdAndProjectId(id, projectId);
|
||||
if (!existing) throw conflict;
|
||||
this.logger.debug('Adopted concurrently created SDK agent', { agentId: id, projectId });
|
||||
return { agent: existing, adopted: true };
|
||||
return {
|
||||
agent: await this.adoptExistingAgent(id, projectId, adoptOnCollision, error),
|
||||
adopted: true,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug('Created SDK agent', { agentId: saved.id, projectId });
|
||||
@@ -233,7 +209,7 @@ export class AgentsService {
|
||||
async findByProjectIdPaginated(
|
||||
projectId: string,
|
||||
options: ListAgentsQueryDto,
|
||||
): Promise<{ count: number; data: Agent[] }> {
|
||||
): Promise<AgentListResult> {
|
||||
return await this.agentRepository.findByProjectIdsPaginated([projectId], options);
|
||||
}
|
||||
|
||||
@@ -248,8 +224,12 @@ export class AgentsService {
|
||||
* the full `AgentJsonConfig`.
|
||||
*/
|
||||
async getCapabilitySummary(agentId: string, projectId: string): Promise<AgentCapabilitySummary> {
|
||||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
const entity = await getAgentOrThrow(
|
||||
this.agentRepository,
|
||||
agentId,
|
||||
projectId,
|
||||
'Agent not found',
|
||||
);
|
||||
|
||||
const schema = entity.schema;
|
||||
|
||||
@@ -260,28 +240,7 @@ export class AgentsService {
|
||||
type: integration.type,
|
||||
}));
|
||||
|
||||
const tools = (schema?.tools ?? []).flatMap<AgentCapabilityTool>((tool) => {
|
||||
switch (tool.type) {
|
||||
case 'custom':
|
||||
return [{ type: 'custom', name: entity.tools[tool.id]?.descriptor?.name ?? tool.id }];
|
||||
case 'workflow':
|
||||
return [{ type: 'workflow', name: tool.name ?? tool.workflow }];
|
||||
case 'node':
|
||||
return [
|
||||
{
|
||||
type: 'node',
|
||||
name: tool.name,
|
||||
nodeType: tool.node?.nodeType,
|
||||
nodeTypeVersion: tool.node?.nodeTypeVersion,
|
||||
},
|
||||
];
|
||||
default:
|
||||
// Unknown tool type from an unvalidated persisted config (import,
|
||||
// history restore, version skew): drop it rather than emit an
|
||||
// `undefined` chip the card would choke on.
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const tools = this.getCapabilityTools(entity);
|
||||
|
||||
const mcpServers = (schema?.mcpServers ?? []).map((server) => ({ name: server.name }));
|
||||
|
||||
@@ -290,17 +249,7 @@ export class AgentsService {
|
||||
name: entity.skills[skill.id]?.name ?? skill.id,
|
||||
}));
|
||||
|
||||
const taskRefs = schema?.tasks ?? [];
|
||||
let taskNamesById: Record<string, string> = {};
|
||||
if (taskRefs.length > 0) {
|
||||
const taskBodies = await this.agentTaskRepository.findByAgentId(agentId);
|
||||
taskNamesById = Object.fromEntries(taskBodies.map((task) => [task.id, task.name]));
|
||||
}
|
||||
const tasks = taskRefs.map((task) => ({
|
||||
id: task.id,
|
||||
name: taskNamesById[task.id] ?? task.id,
|
||||
enabled: task.enabled,
|
||||
}));
|
||||
const tasks = await this.getCapabilityTasks(entity);
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
@@ -355,10 +304,7 @@ export class AgentsService {
|
||||
return await this.agentRepository.findByIdInProjects(agentId, projectIds);
|
||||
}
|
||||
|
||||
async findByUserPaginated(
|
||||
userId: string,
|
||||
options: ListAgentsQueryDto,
|
||||
): Promise<{ count: number; data: Agent[] }> {
|
||||
async findByUserPaginated(userId: string, options: ListAgentsQueryDto): Promise<AgentListResult> {
|
||||
const projectRelations = await this.projectRelationRepository.findAllByUser(userId);
|
||||
const projectIds = projectRelations.map((pr) => pr.projectId);
|
||||
return await this.agentRepository.findByProjectIdsPaginated(projectIds, options);
|
||||
@@ -390,25 +336,11 @@ export class AgentsService {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.agentKnowledgeService.deleteAllFilesForAgent(projectId, agentId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to delete knowledge files on agent delete', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
await this.deleteKnowledgeFiles(projectId, agentId);
|
||||
|
||||
await this.agentKnowledgeService.destroyKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
try {
|
||||
await this.agentChatAttachmentService.deleteByAgent(agentId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to delete chat attachments on agent delete', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
await this.deleteChatAttachments(agentId);
|
||||
|
||||
const chatIntegrationService = Container.get(ChatIntegrationService);
|
||||
for (const integration of agent.integrations ?? []) {
|
||||
@@ -425,6 +357,120 @@ export class AgentsService {
|
||||
|
||||
this.eventService.emit('agent-deleted', { agentId, projectId });
|
||||
|
||||
await this.stopDeletedAgentTasks(agentId);
|
||||
|
||||
await this.clearDeletedAgentTestChats(agentId);
|
||||
|
||||
this.logger.debug('Deleted SDK agent', { agentId, projectId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async prepareInitialConfig(
|
||||
projectId: string,
|
||||
name: string,
|
||||
{ schema, user, defaultModel }: CreateAgentOptions,
|
||||
): Promise<ReturnType<typeof decomposeJsonConfig>> {
|
||||
const defaultConfig: AgentJsonConfig = {
|
||||
name,
|
||||
model: '',
|
||||
instructions: '',
|
||||
...(defaultModel ?? {}),
|
||||
tools: [],
|
||||
skills: [],
|
||||
// Seeded at birth so every agent has a distinct tile, and so the builder
|
||||
// sees an existing icon name when it reads the config — without one it
|
||||
// invents its own, which the icon tile cannot render.
|
||||
personalisation: {
|
||||
icon: DEFAULT_AGENT_PERSONALISATION.icon,
|
||||
gradient: getRandomAgentPersonalisationGradient(),
|
||||
},
|
||||
};
|
||||
if (!schema) return decomposeJsonConfig(defaultConfig);
|
||||
if (!user) return decomposeJsonConfig(schema);
|
||||
return await this.prepareDuplicateConfig(schema, projectId, user);
|
||||
}
|
||||
|
||||
private async adoptExistingAgent(
|
||||
id: string | undefined,
|
||||
projectId: string,
|
||||
adoptOnCollision: boolean,
|
||||
error: unknown,
|
||||
): Promise<Agent> {
|
||||
if (!id || !isUniqueConstraintError(error)) throw error;
|
||||
// Never disclose whether the id exists in another project.
|
||||
const conflict = new ConflictError('An agent with this id already exists');
|
||||
if (!adoptOnCollision) throw conflict;
|
||||
// Returned as it stands: the winner may already have configured it, and
|
||||
// this call's `name`/`schema` describe a draft that never existed.
|
||||
const existing = await this.agentRepository.findByIdAndProjectId(id, projectId);
|
||||
if (!existing) throw conflict;
|
||||
this.logger.debug('Adopted concurrently created SDK agent', { agentId: id, projectId });
|
||||
return existing;
|
||||
}
|
||||
|
||||
private getCapabilityTools(entity: Agent): AgentCapabilityTool[] {
|
||||
return (entity.schema?.tools ?? []).flatMap<AgentCapabilityTool>((tool) => {
|
||||
switch (tool.type) {
|
||||
case 'custom':
|
||||
return [{ type: 'custom', name: entity.tools[tool.id]?.descriptor?.name ?? tool.id }];
|
||||
case 'workflow':
|
||||
return [{ type: 'workflow', name: tool.name ?? tool.workflow }];
|
||||
case 'node':
|
||||
return [
|
||||
{
|
||||
type: 'node',
|
||||
name: tool.name,
|
||||
nodeType: tool.node?.nodeType,
|
||||
nodeTypeVersion: tool.node?.nodeTypeVersion,
|
||||
},
|
||||
];
|
||||
default:
|
||||
// Unknown tool type from an unvalidated persisted config (import,
|
||||
// history restore, version skew): drop it rather than emit an
|
||||
// `undefined` chip the card would choke on.
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getCapabilityTasks(entity: Agent): Promise<AgentCapabilitySummary['tasks']> {
|
||||
const taskRefs = entity.schema?.tasks ?? [];
|
||||
let taskNamesById: Record<string, string> = {};
|
||||
if (taskRefs.length > 0) {
|
||||
const taskBodies = await this.agentTaskRepository.findByAgentId(entity.id);
|
||||
taskNamesById = Object.fromEntries(taskBodies.map((task) => [task.id, task.name]));
|
||||
}
|
||||
return taskRefs.map((task) => ({
|
||||
id: task.id,
|
||||
name: taskNamesById[task.id] ?? task.id,
|
||||
enabled: task.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
private async deleteKnowledgeFiles(projectId: string, agentId: string): Promise<void> {
|
||||
try {
|
||||
await this.agentKnowledgeService.deleteAllFilesForAgent(projectId, agentId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to delete knowledge files on agent delete', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteChatAttachments(agentId: string): Promise<void> {
|
||||
try {
|
||||
await this.agentChatAttachmentService.deleteByAgent(agentId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to delete chat attachments on agent delete', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async stopDeletedAgentTasks(agentId: string): Promise<void> {
|
||||
try {
|
||||
const { AgentTaskService } = await import('./agent-task.service.js');
|
||||
await Container.get(AgentTaskService).requestReconcile(agentId);
|
||||
@@ -434,7 +480,9 @@ export class AgentsService {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async clearDeletedAgentTestChats(agentId: string): Promise<void> {
|
||||
try {
|
||||
await this.testChatService.clearAllTestChatMessages(agentId);
|
||||
} catch (error) {
|
||||
@@ -443,9 +491,5 @@ export class AgentsService {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug('Deleted SDK agent', { agentId, projectId });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,14 +194,12 @@ export class AgentBackgroundJobService {
|
||||
async settle(jobId: string, settlement: AgentBackgroundJobSettlement): Promise<boolean> {
|
||||
try {
|
||||
const settled = await this.jobRepository.settleIfRunning(jobId, settlement);
|
||||
if (settled) {
|
||||
const job = await this.findJob(jobId);
|
||||
if (job) {
|
||||
this.notifyJobUpdate(job);
|
||||
await this.requestWakeSafely(job.parentThreadId);
|
||||
}
|
||||
}
|
||||
return settled;
|
||||
if (!settled) return false;
|
||||
const job = await this.findJob(jobId);
|
||||
if (!job) return true;
|
||||
this.notifyJobUpdate(job);
|
||||
await this.requestWakeSafely(job.parentThreadId);
|
||||
return true;
|
||||
} finally {
|
||||
// Drop the handle even when the write throws — a leaked entry would
|
||||
// shield the still-running row from orphan reconciliation forever.
|
||||
@@ -416,33 +414,7 @@ export class AgentBackgroundJobService {
|
||||
private async cancelWorkflowJob(
|
||||
job: AgentBackgroundJob,
|
||||
): Promise<'cancelled' | 'already-settled'> {
|
||||
if (job.childExecutionId !== null && job.workflowId !== null) {
|
||||
// Lazy: ExecutionService is a heavy dependency this service otherwise
|
||||
// never needs — workers load this class for the settle path alone.
|
||||
const { ExecutionService } = await import('@/executions/execution.service.js');
|
||||
const { MissingExecutionStopError } = await import(
|
||||
'@/errors/missing-execution-stop.error.js'
|
||||
);
|
||||
try {
|
||||
await Container.get(ExecutionService).stop(job.childExecutionId, [job.workflowId]);
|
||||
} catch (error) {
|
||||
if (error instanceof MissingExecutionStopError || error instanceof WorkflowOperationError) {
|
||||
this.logger.debug('Workflow job execution was already beyond stopping', {
|
||||
jobId: job.id,
|
||||
executionId: job.childExecutionId,
|
||||
});
|
||||
return 'already-settled';
|
||||
}
|
||||
|
||||
this.logger.error('Failed to stop a workflow job execution — it may still be running', {
|
||||
jobId: job.id,
|
||||
executionId: job.childExecutionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!(await this.stopWorkflowJobExecution(job))) return 'already-settled';
|
||||
|
||||
// The stopped execution's settle hook may have written `cancelled` first;
|
||||
// either way the job is cancelled.
|
||||
@@ -497,34 +469,9 @@ export class AgentBackgroundJobService {
|
||||
|
||||
let settledAny = false;
|
||||
for (const job of candidates) {
|
||||
const executionId = job.childExecutionId;
|
||||
const executionStatus = statuses.get(executionId);
|
||||
|
||||
try {
|
||||
if (executionStatus === undefined) {
|
||||
settledAny =
|
||||
(await this.settle(job.id, {
|
||||
status: 'failed',
|
||||
error: EXECUTION_OUTCOME_UNKNOWN_ERROR,
|
||||
})) || settledAny;
|
||||
continue;
|
||||
}
|
||||
if (!isTerminalExecutionStatus(executionStatus)) continue;
|
||||
|
||||
const status = settlementStatusForExecution(executionStatus);
|
||||
settledAny =
|
||||
(await this.settle(job.id, {
|
||||
status,
|
||||
result: status === 'completed' ? await this.loadExecutionResult(executionId) : null,
|
||||
error: executionStatus === 'success' ? null : `Execution ${executionStatus}`,
|
||||
})) || settledAny;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to reconcile workflow background job', {
|
||||
jobId: job.id,
|
||||
executionId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
settledAny =
|
||||
(await this.settleFinishedWorkflowJob(job, statuses.get(job.childExecutionId))) ||
|
||||
settledAny;
|
||||
}
|
||||
|
||||
return settledAny;
|
||||
@@ -614,4 +561,61 @@ export class AgentBackgroundJobService {
|
||||
}
|
||||
return settledAny;
|
||||
}
|
||||
|
||||
private async stopWorkflowJobExecution(job: AgentBackgroundJob): Promise<boolean> {
|
||||
if (job.childExecutionId === null || job.workflowId === null) return true;
|
||||
// Lazy: ExecutionService is a heavy dependency this service otherwise
|
||||
// never needs — workers load this class for the settle path alone.
|
||||
const { ExecutionService } = await import('@/executions/execution.service.js');
|
||||
const { MissingExecutionStopError } = await import('@/errors/missing-execution-stop.error.js');
|
||||
try {
|
||||
await Container.get(ExecutionService).stop(job.childExecutionId, [job.workflowId]);
|
||||
} catch (error) {
|
||||
if (error instanceof MissingExecutionStopError || error instanceof WorkflowOperationError) {
|
||||
this.logger.debug('Workflow job execution was already beyond stopping', {
|
||||
jobId: job.id,
|
||||
executionId: job.childExecutionId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.error('Failed to stop a workflow job execution — it may still be running', {
|
||||
jobId: job.id,
|
||||
executionId: job.childExecutionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async settleFinishedWorkflowJob(
|
||||
job: AgentBackgroundJob & { childExecutionId: string },
|
||||
executionStatus: ExecutionStatus | undefined,
|
||||
): Promise<boolean> {
|
||||
const executionId = job.childExecutionId;
|
||||
try {
|
||||
if (executionStatus === undefined) {
|
||||
return await this.settle(job.id, {
|
||||
status: 'failed',
|
||||
error: EXECUTION_OUTCOME_UNKNOWN_ERROR,
|
||||
});
|
||||
}
|
||||
if (!isTerminalExecutionStatus(executionStatus)) return false;
|
||||
const status = settlementStatusForExecution(executionStatus);
|
||||
return await this.settle(job.id, {
|
||||
status,
|
||||
result: status === 'completed' ? await this.loadExecutionResult(executionId) : null,
|
||||
error: executionStatus === 'success' ? null : `Execution ${executionStatus}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to reconcile workflow background job', {
|
||||
jobId: job.id,
|
||||
executionId,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AGENT_BACKGROUND_UPDATES_OPEN_TAG,
|
||||
formatWakeMessage,
|
||||
} from './background-job-messages';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentBackgroundJob } from '../entities/agent-background-job.entity';
|
||||
import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration';
|
||||
import { AgentBackgroundJobRepository } from '../repositories/agent-background-job.repository';
|
||||
@@ -173,47 +174,12 @@ export class AgentWakeService {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await this.agentRepository.findById(first.parentAgentId);
|
||||
if (!agent) {
|
||||
this.recordFailure(threadId, generation, 'Background job parent agent no longer exists');
|
||||
return;
|
||||
}
|
||||
|
||||
let identity: ExecuteForWakeConfig['identity'];
|
||||
try {
|
||||
identity = await this.resolveIdentity(
|
||||
first.parentResourceId,
|
||||
first.parentPrincipalHash,
|
||||
agent.projectId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.recordFailure(
|
||||
threadId,
|
||||
generation,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const target = await this.resolveWakeTarget(first, threadId, generation);
|
||||
if (!target) return;
|
||||
const { agent, identity } = target;
|
||||
|
||||
try {
|
||||
this.activeWakes.add(threadId);
|
||||
try {
|
||||
await this.orchestrator.executeForWake({
|
||||
agentId: agent.id,
|
||||
projectId: agent.projectId,
|
||||
message: formatWakeMessage(jobs),
|
||||
backgroundJobSignal: {
|
||||
tasks: jobs.flatMap(({ id, title, kind, status }) =>
|
||||
status === 'running' ? [] : [{ id, title, kind, status }],
|
||||
),
|
||||
},
|
||||
memory: { threadId, resourceId: first.parentResourceId },
|
||||
identity,
|
||||
abortSignal: signal,
|
||||
});
|
||||
} finally {
|
||||
this.activeWakes.delete(threadId);
|
||||
}
|
||||
await this.runWake(agent, identity, jobs, threadId, first.parentResourceId, signal);
|
||||
|
||||
if (signal.aborted) return;
|
||||
await this.backgroundJobService.markMailConsumed(
|
||||
@@ -282,4 +248,57 @@ export class AgentWakeService {
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWakeTarget(first: AgentBackgroundJob, threadId: string, generation: string) {
|
||||
const agent = await this.agentRepository.findById(first.parentAgentId);
|
||||
if (!agent) {
|
||||
this.recordFailure(threadId, generation, 'Background job parent agent no longer exists');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let identity: ExecuteForWakeConfig['identity'];
|
||||
try {
|
||||
identity = await this.resolveIdentity(
|
||||
first.parentResourceId,
|
||||
first.parentPrincipalHash,
|
||||
agent.projectId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.recordFailure(
|
||||
threadId,
|
||||
generation,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return { agent, identity };
|
||||
}
|
||||
|
||||
private async runWake(
|
||||
agent: Agent,
|
||||
identity: ExecuteForWakeConfig['identity'],
|
||||
jobs: AgentBackgroundJob[],
|
||||
threadId: string,
|
||||
resourceId: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
this.activeWakes.add(threadId);
|
||||
try {
|
||||
await this.orchestrator.executeForWake({
|
||||
agentId: agent.id,
|
||||
projectId: agent.projectId,
|
||||
message: formatWakeMessage(jobs),
|
||||
backgroundJobSignal: {
|
||||
tasks: jobs.flatMap(({ id, title, kind, status }) =>
|
||||
status === 'running' ? [] : [{ id, title, kind, status }],
|
||||
),
|
||||
},
|
||||
memory: { threadId, resourceId },
|
||||
identity,
|
||||
abortSignal: signal,
|
||||
});
|
||||
} finally {
|
||||
this.activeWakes.delete(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BuiltTool, ToolContext } from '@n8n/agents';
|
||||
import type { BuiltTool, CreateDelegateSubAgentToolOptions, ToolContext } from '@n8n/agents';
|
||||
import { INLINE_SUB_AGENT_ID } from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import { SUB_AGENT_TASK_DIFFICULTIES, type SubAgentSource } from '@n8n/api-types';
|
||||
@@ -7,8 +7,10 @@ import { z } from 'zod';
|
||||
import { decodeAgentSandboxHostMetadata } from '../agent-sandbox-principal';
|
||||
import { isTaskRunMemoryResourceId } from '../utils/agent-memory-scope';
|
||||
import type { AgentBackgroundJobService } from './agent-background-job.service';
|
||||
import type { SubAgentBackgroundRunner } from './sub-agent-background-runner';
|
||||
import type { SubAgentRunContext } from '../sub-agents/sub-agent-runner';
|
||||
import type {
|
||||
BackgroundSubAgentRunContext,
|
||||
SubAgentBackgroundRunner,
|
||||
} from './sub-agent-background-runner';
|
||||
|
||||
/** Cap on the result text echoed to the model; the full text stays on the row. */
|
||||
const RESULT_ECHO_MAX_CHARS = 8000;
|
||||
@@ -17,20 +19,12 @@ export interface BackgroundJobToolsOptions {
|
||||
jobService: AgentBackgroundJobService;
|
||||
backgroundRunner: SubAgentBackgroundRunner;
|
||||
sourcesById: Record<string, SubAgentSource>;
|
||||
availableSubAgents: Array<{ id: string; name: string; useWhen?: string }>;
|
||||
availableSubAgents: NonNullable<CreateDelegateSubAgentToolOptions['availableSubAgents']>;
|
||||
projectId: string;
|
||||
parentAgentId: string;
|
||||
// The workspace handle is principal-scoped, not thread-scoped. The sandbox
|
||||
// outlives the parent turn, so capture it when the tool is built.
|
||||
runContext: Pick<
|
||||
SubAgentRunContext,
|
||||
| 'credentialProvider'
|
||||
| 'runType'
|
||||
| 'workflowToolExecutionMode'
|
||||
| 'user'
|
||||
| 'instrumentation'
|
||||
| 'parentWorkspaceHandle'
|
||||
>;
|
||||
runContext: BackgroundSubAgentRunContext;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,16 @@ import {
|
||||
type SubAgentRunResult,
|
||||
} from '../sub-agents/sub-agent-runner';
|
||||
|
||||
export type BackgroundSubAgentRunContext = Pick<
|
||||
SubAgentRunContext,
|
||||
| 'credentialProvider'
|
||||
| 'runType'
|
||||
| 'workflowToolExecutionMode'
|
||||
| 'user'
|
||||
| 'instrumentation'
|
||||
| 'parentWorkspaceHandle'
|
||||
>;
|
||||
|
||||
export interface BackgroundSpawnRequest {
|
||||
subAgentId: string;
|
||||
source: SubAgentSource;
|
||||
@@ -55,15 +65,7 @@ export class SubAgentBackgroundRunner {
|
||||
context: {
|
||||
projectId: string;
|
||||
parentAgentId: string;
|
||||
} & Pick<
|
||||
SubAgentRunContext,
|
||||
| 'credentialProvider'
|
||||
| 'runType'
|
||||
| 'workflowToolExecutionMode'
|
||||
| 'user'
|
||||
| 'instrumentation'
|
||||
| 'parentWorkspaceHandle'
|
||||
>,
|
||||
} & BackgroundSubAgentRunContext,
|
||||
): Promise<BackgroundJobReceipt> {
|
||||
// Throws on an unusable task name — before the job row exists, so a bad
|
||||
// name cannot leave a phantom `running` row holding a thread slot.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ import { modelStreamStallOptions } from '../model-stream-stall-options';
|
||||
import { buildAgentPreviewPath } from './agent-builder-preview-path';
|
||||
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
|
||||
import { buildBuilderPrompt } from './agents-builder-prompts';
|
||||
import { AgentsBuilderToolsService } from './agents-builder-tools.service';
|
||||
import { AgentsBuilderToolsService, type BuilderTools } from './agents-builder-tools.service';
|
||||
import { BuilderCheckpointUnavailableError } from './errors';
|
||||
import {
|
||||
BUILDER_PLANNER_TODOS_DESCRIPTION,
|
||||
@@ -242,14 +242,7 @@ export class AgentsBuilderService {
|
||||
// always runs on it directly.
|
||||
const modelConfig = session.modelConfig;
|
||||
|
||||
const modelRecommendationsSection = await getModelRecommendationsSection();
|
||||
const instructions = buildBuilderPrompt({
|
||||
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
|
||||
modelRecommendationsSection,
|
||||
});
|
||||
const finalInstructions = session.instructionsAddendum
|
||||
? `${instructions}\n\n${session.instructionsAddendum}`
|
||||
: instructions;
|
||||
const finalInstructions = await this.createBuilderInstructions(projectId, agentId, session);
|
||||
const runtimeSkills = getBuilderRuntimeSkills();
|
||||
|
||||
const tools = this.agentsBuilderToolsService.getTools(
|
||||
@@ -265,36 +258,8 @@ export class AgentsBuilderService {
|
||||
},
|
||||
);
|
||||
|
||||
const { Agent, Memory, Tool, createPlannerTodosTool } = await import('@n8n/agents');
|
||||
|
||||
const onMemoryUsage = async (report: MemoryTaskUsageReport) => {
|
||||
try {
|
||||
const items = tokenUsageToBuilderUsageItems(report.model, report.usage);
|
||||
if (items.length === 0) return;
|
||||
await this.instanceAiCreditService.claimRunUsage(
|
||||
user,
|
||||
session.hostThreadId,
|
||||
`${session.runId}:agent-builder:${agentId}:memory:${report.task}:${report.reportId}`,
|
||||
items,
|
||||
'completed',
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to claim agent-builder observational-memory usage', {
|
||||
agentId,
|
||||
hostThreadId: session.hostThreadId,
|
||||
runId: session.runId,
|
||||
task: report.task,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const builderMemory = new Memory()
|
||||
.storage(this.n8nMemory.getImplementation(agentId))
|
||||
.observationalMemory({
|
||||
observe: createObservationLogObserveFn(modelConfig, { onUsage: onMemoryUsage }),
|
||||
reflect: createObservationLogReflectFn(modelConfig, { onUsage: onMemoryUsage }),
|
||||
});
|
||||
const { Agent } = await import('@n8n/agents');
|
||||
const builderMemory = await this.createBuilderMemory(agentId, user, session);
|
||||
|
||||
const builder = new Agent('agent-builder')
|
||||
.model(modelConfig)
|
||||
@@ -311,46 +276,7 @@ export class AgentsBuilderService {
|
||||
if (session.telemetry) builder.telemetry(session.telemetry);
|
||||
if (session.memoryTaskObserver) builder.memoryTaskObserver(session.memoryTaskObserver);
|
||||
|
||||
const plannerTodosTool = createPlannerTodosTool({
|
||||
description: BUILDER_PLANNER_TODOS_DESCRIPTION,
|
||||
systemInstruction: BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
|
||||
});
|
||||
const reportRequiredArtifactTool = session.onRequiredArtifact
|
||||
? new Tool(REPORT_REQUIRED_ARTIFACT_TOOL_NAME)
|
||||
.description(
|
||||
'Report a workflow or data table that Instance AI must create outside the target Agent. ' +
|
||||
'Use relationship "agent-entrypoint" for a channel bridge that invokes the Agent; it will not be attached as an Agent tool.',
|
||||
)
|
||||
.input(reportRequiredArtifactInputSchema)
|
||||
.handler(async (input: ReportRequiredArtifactInput) => {
|
||||
session.onRequiredArtifact?.(input.artifact);
|
||||
return { ok: true };
|
||||
})
|
||||
.build()
|
||||
: undefined;
|
||||
const builderTools = [
|
||||
...tools.json,
|
||||
...tools.shared,
|
||||
plannerTodosTool,
|
||||
...(reportRequiredArtifactTool ? [reportRequiredArtifactTool] : []),
|
||||
];
|
||||
const claimedToolNames = new Set(builderTools.map((tool) => tool.name));
|
||||
|
||||
for (const tool of builderTools) {
|
||||
builder.tool(tool);
|
||||
}
|
||||
|
||||
for (const [toolName, tool] of session.mcpTools ?? []) {
|
||||
if (claimedToolNames.has(toolName)) {
|
||||
this.logger.warn('Skipped MCP tool that conflicts with an agent builder tool', {
|
||||
toolName,
|
||||
agentId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
claimedToolNames.add(toolName);
|
||||
builder.tool(tool);
|
||||
}
|
||||
await this.registerBuilderTools(builder, tools, agentId, session);
|
||||
|
||||
builder.reasoning(resolveAIAReasoning(modelConfig));
|
||||
|
||||
@@ -382,4 +308,115 @@ export class AgentsBuilderService {
|
||||
): Promise<SerializableAgentState | null> {
|
||||
return await this.n8nCheckpointStorage.findSuspendedForThread(agentId, threadId);
|
||||
}
|
||||
|
||||
private async claimMemoryUsage(
|
||||
report: MemoryTaskUsageReport,
|
||||
agentId: string,
|
||||
user: User,
|
||||
session: InstanceAiBuilderSessionOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const items = tokenUsageToBuilderUsageItems(report.model, report.usage);
|
||||
if (items.length === 0) return;
|
||||
await this.instanceAiCreditService.claimRunUsage(
|
||||
user,
|
||||
session.hostThreadId,
|
||||
`${session.runId}:agent-builder:${agentId}:memory:${report.task}:${report.reportId}`,
|
||||
items,
|
||||
'completed',
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to claim agent-builder observational-memory usage', {
|
||||
agentId,
|
||||
hostThreadId: session.hostThreadId,
|
||||
runId: session.runId,
|
||||
task: report.task,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createBuilderInstructions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
session: InstanceAiBuilderSessionOptions,
|
||||
): Promise<string> {
|
||||
const modelRecommendationsSection = await getModelRecommendationsSection();
|
||||
const instructions = buildBuilderPrompt({
|
||||
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
|
||||
modelRecommendationsSection,
|
||||
});
|
||||
return session.instructionsAddendum
|
||||
? `${instructions}\n\n${session.instructionsAddendum}`
|
||||
: instructions;
|
||||
}
|
||||
|
||||
private async createBuilderMemory(
|
||||
agentId: string,
|
||||
user: User,
|
||||
session: InstanceAiBuilderSessionOptions,
|
||||
) {
|
||||
const { Memory } = await import('@n8n/agents');
|
||||
|
||||
const onMemoryUsage = async (report: MemoryTaskUsageReport) =>
|
||||
await this.claimMemoryUsage(report, agentId, user, session);
|
||||
|
||||
return new Memory().storage(this.n8nMemory.getImplementation(agentId)).observationalMemory({
|
||||
observe: createObservationLogObserveFn(session.modelConfig, { onUsage: onMemoryUsage }),
|
||||
reflect: createObservationLogReflectFn(session.modelConfig, { onUsage: onMemoryUsage }),
|
||||
});
|
||||
}
|
||||
|
||||
private async registerBuilderTools(
|
||||
builder: RuntimeAgent,
|
||||
tools: BuilderTools,
|
||||
agentId: string,
|
||||
session: InstanceAiBuilderSessionOptions,
|
||||
): Promise<void> {
|
||||
const { createPlannerTodosTool } = await import('@n8n/agents');
|
||||
const plannerTodosTool = createPlannerTodosTool({
|
||||
description: BUILDER_PLANNER_TODOS_DESCRIPTION,
|
||||
systemInstruction: BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
|
||||
});
|
||||
const reportRequiredArtifactTool = await this.createRequiredArtifactTool(session);
|
||||
const builderTools = [
|
||||
...tools.json,
|
||||
...tools.shared,
|
||||
plannerTodosTool,
|
||||
...(reportRequiredArtifactTool ? [reportRequiredArtifactTool] : []),
|
||||
];
|
||||
const claimedToolNames = new Set(builderTools.map((tool) => tool.name));
|
||||
|
||||
for (const tool of builderTools) {
|
||||
builder.tool(tool);
|
||||
}
|
||||
|
||||
for (const [toolName, tool] of session.mcpTools ?? []) {
|
||||
if (claimedToolNames.has(toolName)) {
|
||||
this.logger.warn('Skipped MCP tool that conflicts with an agent builder tool', {
|
||||
toolName,
|
||||
agentId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
claimedToolNames.add(toolName);
|
||||
builder.tool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
private async createRequiredArtifactTool(session: InstanceAiBuilderSessionOptions) {
|
||||
if (!session.onRequiredArtifact) return undefined;
|
||||
const { Tool } = await import('@n8n/agents');
|
||||
return new Tool(REPORT_REQUIRED_ARTIFACT_TOOL_NAME)
|
||||
.description(
|
||||
'Report a workflow or data table that Instance AI must create outside the target Agent. ' +
|
||||
'Use relationship "agent-entrypoint" for a channel bridge that invokes the Agent; it will not be attached as an Agent tool.',
|
||||
)
|
||||
.input(reportRequiredArtifactInputSchema)
|
||||
.handler(async (input: ReportRequiredArtifactInput) => {
|
||||
session.onRequiredArtifact?.(input.artifact);
|
||||
return { ok: true };
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,23 @@ import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
|
||||
import { OutboundHttp } from '@n8n/backend-network';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import { createAiProxyFetch } from '@/utils/ai-proxy-fetch';
|
||||
|
||||
import type { ModelChoice } from './model-lookup.types';
|
||||
import { mapCredentialForProvider } from '../json-config/credential-field-mapping';
|
||||
import { LLM_PROVIDER_DEFAULTS } from '../llm-provider-defaults';
|
||||
import { decryptAgentCredential } from '../utils/decrypt-agent-credential';
|
||||
|
||||
export type ModelCatalogPolicy = 'curated' | 'endpoint-only' | 'managed';
|
||||
|
||||
export type LiveModelLookupResult =
|
||||
| {
|
||||
status: 'success';
|
||||
models: Array<{ name: string; value: string }>;
|
||||
models: ModelChoice[];
|
||||
policy: ModelCatalogPolicy;
|
||||
}
|
||||
| { status: 'unavailable'; error: unknown; policy: ModelCatalogPolicy };
|
||||
@@ -58,7 +59,7 @@ export class BuilderModelLiveLookupService {
|
||||
credentialType: string,
|
||||
provider: string,
|
||||
options?: ModelLookupOptions,
|
||||
): Promise<Array<{ name: string; value: string }>> {
|
||||
): Promise<ModelChoice[]> {
|
||||
const result = await this.lookup(
|
||||
user,
|
||||
projectId,
|
||||
@@ -98,7 +99,10 @@ export class BuilderModelLiveLookupService {
|
||||
return this.getEvalModelCatalog(credentialType, provider, 'curated');
|
||||
}
|
||||
|
||||
const credentialData = await this.decryptWithExpressions(usable, projectId, user);
|
||||
const credentialData = await decryptAgentCredential(this.credentialsHelper, usable, {
|
||||
projectId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return await this.discoverModels(provider, credentialData);
|
||||
}
|
||||
@@ -121,34 +125,6 @@ export class BuilderModelLiveLookupService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the way node execution does, so credential defaults are applied and
|
||||
* expressions resolved. A base URL computed from sibling fields (region,
|
||||
* workspace) is otherwise absent or still literal expression text.
|
||||
*
|
||||
* There is no workflow or execution here, so `additionalData` comes from
|
||||
* `getBase()` and the mode is `internal` — which skips dynamic-credential
|
||||
* resolution, leaving a per-user credential on its static stored data. Same
|
||||
* as `AgentsCredentialProvider`.
|
||||
*/
|
||||
private async decryptWithExpressions(
|
||||
credential: { id: string; name: string; type: string },
|
||||
projectId: string,
|
||||
user: User,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
// Imported lazily: pulls in the execution stack, and model lookup is not on
|
||||
// every request.
|
||||
const { getBase } = await import('@/workflow-execute-additional-data.js');
|
||||
const additionalData = await getBase({ userId: user.id, projectId });
|
||||
|
||||
return await this.credentialsHelper.getDecrypted(
|
||||
additionalData,
|
||||
{ id: credential.id, name: credential.name },
|
||||
credential.type,
|
||||
'internal',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the chat models n8n Connect (AI Gateway) allows for a provider.
|
||||
* Gateway resolution or discovery failures are returned as managed-policy
|
||||
@@ -243,12 +219,12 @@ export class BuilderModelLiveLookupService {
|
||||
typeof credentialData.headerValue === 'string'
|
||||
) {
|
||||
const normalizedName = credentialData.headerName.toLowerCase();
|
||||
const headerName =
|
||||
normalizedName === 'authorization'
|
||||
? 'Authorization'
|
||||
: normalizedName === 'openai-organization'
|
||||
? 'OpenAI-Organization'
|
||||
: credentialData.headerName;
|
||||
let headerName = credentialData.headerName;
|
||||
if (normalizedName === 'authorization') {
|
||||
headerName = 'Authorization';
|
||||
} else if (normalizedName === 'openai-organization') {
|
||||
headerName = 'OpenAI-Organization';
|
||||
}
|
||||
headers[headerName] = credentialData.headerValue;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -7,9 +7,9 @@ import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
buildAskCredentialTool,
|
||||
buildAskEmbeddingCredentialTool,
|
||||
type AskCredentialToolDeps,
|
||||
type AskEmbeddingCredentialToolDeps,
|
||||
} from '../ask-credential.tool';
|
||||
import type { CredentialSetupDeps } from '../setup-tool.types';
|
||||
|
||||
interface TestCtx {
|
||||
resumeData?: unknown;
|
||||
@@ -37,8 +37,8 @@ function expectListed(credentialService: InstanceAiCredentialService, credential
|
||||
|
||||
let track: Mock;
|
||||
|
||||
function askCredentialTool(deps: Omit<AskCredentialToolDeps, 'track' | 'projectId'>) {
|
||||
const merged: AskCredentialToolDeps = { projectId: 'project-1', ...deps, track };
|
||||
function askCredentialTool(deps: Omit<CredentialSetupDeps, 'track' | 'projectId'>) {
|
||||
const merged: CredentialSetupDeps = { projectId: 'project-1', ...deps, track };
|
||||
return buildAskCredentialTool(merged);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
|
||||
import type { BuiltTool, CredentialListItem, InterruptibleToolContext } from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
@@ -16,22 +16,9 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
|
||||
import type { BuilderTrackFn } from '../builder-config-telemetry';
|
||||
import type { CredentialSetupDeps } from './setup-tool.types';
|
||||
|
||||
export interface AskCredentialToolDeps {
|
||||
credentialService: InstanceAiCredentialService;
|
||||
/** Project the agent lives in — scopes the FE credential picker. */
|
||||
projectId: string;
|
||||
isCredentialTypeKnown?: (credentialType: string) => boolean;
|
||||
/**
|
||||
* Credential ids of the agent's configured chat channel integrations. When
|
||||
* one of them matches the requested credential type, it is reused for the
|
||||
* tool instead of asking the user to pick a credential.
|
||||
*/
|
||||
listIntegrationCredentialIds?: () => Promise<string[]>;
|
||||
track: BuilderTrackFn;
|
||||
}
|
||||
|
||||
export interface AskEmbeddingCredentialToolDeps extends AskCredentialToolDeps {
|
||||
export interface AskEmbeddingCredentialToolDeps extends CredentialSetupDeps {
|
||||
isAssistantProxyEnabled: () => boolean;
|
||||
}
|
||||
|
||||
@@ -40,7 +27,7 @@ type AskCredentialToolResult =
|
||||
| {
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
credentials: Record<string, { id: string; name: string }>;
|
||||
credentials: Record<string, Pick<CredentialListItem, 'id' | 'name'>>;
|
||||
};
|
||||
|
||||
function withNodeCredentialMap(
|
||||
@@ -63,7 +50,7 @@ async function listExistingCredentials(
|
||||
credentialService: InstanceAiCredentialService,
|
||||
projectId: string,
|
||||
credentialType: string,
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
): Promise<Array<Pick<CredentialListItem, 'id' | 'name'>>> {
|
||||
const all = await credentialService.list({ type: credentialType, projectId });
|
||||
return all.map((c) => ({ id: c.id, name: c.name }));
|
||||
}
|
||||
@@ -109,7 +96,7 @@ async function resolveResume(
|
||||
async function resolveCredentialSelection(
|
||||
input: AskCredentialInput,
|
||||
ctx: InterruptibleToolContext<CredentialSuspendPayload, CredentialResumeData>,
|
||||
deps: AskCredentialToolDeps,
|
||||
deps: CredentialSetupDeps,
|
||||
): Promise<AskCredentialToolResult> {
|
||||
if (ctx.resumeData !== undefined && ctx.resumeData !== null) {
|
||||
return await resolveResume(
|
||||
@@ -172,7 +159,7 @@ async function resolveCredentialSelection(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool {
|
||||
export function buildAskCredentialTool(deps: CredentialSetupDeps): BuiltTool {
|
||||
return new Tool(ASK_CREDENTIAL_TOOL_NAME)
|
||||
.description(
|
||||
'Show a credential picker card in the chat UI and suspend until the user selects ' +
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTrackFn } from '../builder-config-telemetry';
|
||||
import type { ChannelSetupDeps } from './setup-tool.types';
|
||||
|
||||
const configureChannelInputSchema = z.object({
|
||||
integrationType: z.string().describe('Chat platform type from list_integration_types'),
|
||||
@@ -19,15 +19,7 @@ const configureChannelInputSchema = z.object({
|
||||
|
||||
type ConfigureChannelInput = z.infer<typeof configureChannelInputSchema>;
|
||||
|
||||
export interface ConfigureChannelToolDeps {
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
/** Wraps `AgentIntegrationPersistenceService.listChatIntegrations()`. */
|
||||
listChatIntegrationTypes: () => string[];
|
||||
track: BuilderTrackFn;
|
||||
}
|
||||
|
||||
export function buildConfigureChannelTool(deps: ConfigureChannelToolDeps): BuiltTool {
|
||||
export function buildConfigureChannelTool(deps: ChannelSetupDeps): BuiltTool {
|
||||
return new Tool(CONFIGURE_CHANNEL_TOOL_NAME)
|
||||
.description(
|
||||
'Configure one available chat channel for the target agent. First call ' +
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
|
||||
import type { BuiltTool, CredentialListItem, InterruptibleToolContext } from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import {
|
||||
channelSuspendPayloadSchema,
|
||||
@@ -14,14 +14,14 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTrackFn } from '../builder-config-telemetry';
|
||||
import { BUILDER_TOOLS } from '../builder-tool-names';
|
||||
import type { ChannelSetupDeps, CredentialSetupDeps } from './setup-tool.types';
|
||||
|
||||
/** Filter an already-fetched credential list down to one type, in the shape setup cards need. */
|
||||
function credentialsOfType(
|
||||
all: Array<{ id: string; name: string; type: string }>,
|
||||
all: CredentialListItem[],
|
||||
credentialType: string,
|
||||
): Array<{ id: string; name: string }> {
|
||||
): Array<Pick<CredentialListItem, 'id' | 'name'>> {
|
||||
return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name }));
|
||||
}
|
||||
|
||||
@@ -38,16 +38,7 @@ async function credentialNameById(
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinishSetupToolDeps {
|
||||
credentialService: InstanceAiCredentialService;
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
track: BuilderTrackFn;
|
||||
isCredentialTypeKnown?: (credentialType: string) => boolean;
|
||||
/** Credential ids of the agent's configured chat channel integrations — reused for a matching credential slot. */
|
||||
listIntegrationCredentialIds?: () => Promise<string[]>;
|
||||
/** Wraps `AgentIntegrationPersistenceService.listChatIntegrations()`. */
|
||||
listChatIntegrationTypes: () => string[];
|
||||
export interface FinishSetupToolDeps extends CredentialSetupDeps, ChannelSetupDeps {
|
||||
/**
|
||||
* Credential types whose every required node-tool slot is already served by an
|
||||
* n8n Connect managed credential — a card for these is redundant. A type still
|
||||
@@ -363,7 +354,7 @@ async function suspendForPhase(params: {
|
||||
const credentialRequests: Array<{
|
||||
credentialType: string;
|
||||
reason: string;
|
||||
existingCredentials: Array<{ id: string; name: string }>;
|
||||
existingCredentials: Array<Pick<CredentialListItem, 'id' | 'name'>>;
|
||||
}> = [];
|
||||
for (const slot of phase.slots) {
|
||||
if (seenTypes.has(slot.credentialType)) continue;
|
||||
|
||||
@@ -12,16 +12,13 @@ import {
|
||||
} from '../../llm-provider-defaults';
|
||||
import { findVerifiedModelId, normalizeProviderModelId } from '../../utils/provider-model-id';
|
||||
import { BUILDER_TOOLS } from '../builder-tool-names';
|
||||
import type { ModelChoice } from '../model-lookup.types';
|
||||
|
||||
/** User-facing name written for an n8n credits (AI Gateway managed) model credential. */
|
||||
const N8N_CONNECT_CREDENTIAL_NAME = 'Gateway credits';
|
||||
|
||||
export interface ModelLookup {
|
||||
list(
|
||||
credentialId: string,
|
||||
credentialType: string,
|
||||
provider: string,
|
||||
): Promise<Array<{ name: string; value: string }>>;
|
||||
list(credentialId: string, credentialType: string, provider: string): Promise<ModelChoice[]>;
|
||||
}
|
||||
|
||||
/** Provisions free OpenAI credits on demand for a zero-credential builder session. */
|
||||
@@ -112,11 +109,6 @@ function toLlmResolution(
|
||||
};
|
||||
}
|
||||
|
||||
interface CallableModel {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many models an `unknown_model` result carries. A provider catalog runs to
|
||||
* hundreds of ids (OpenRouter alone), and the whole list would land in the
|
||||
@@ -126,7 +118,7 @@ interface CallableModel {
|
||||
*/
|
||||
const MAX_AVAILABLE_MODELS = 25;
|
||||
|
||||
function unknownModel(provider: string, requestedModel: string, availableModels: CallableModel[]) {
|
||||
function unknownModel(provider: string, requestedModel: string, availableModels: ModelChoice[]) {
|
||||
const shown = availableModels.slice(0, MAX_AVAILABLE_MODELS);
|
||||
return {
|
||||
ok: false as const,
|
||||
@@ -162,7 +154,7 @@ async function listCallableModels(
|
||||
credential: CredentialListItem,
|
||||
provider: string,
|
||||
modelLookup: ModelLookup,
|
||||
): Promise<{ ok: true; models: CallableModel[] } | { ok: false; error: unknown }> {
|
||||
): Promise<{ ok: true; models: ModelChoice[] } | { ok: false; error: unknown }> {
|
||||
try {
|
||||
const models = await modelLookup.list(credential.id, credential.type, provider);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
|
||||
|
||||
import type { BuilderTrackFn } from '../builder-config-telemetry';
|
||||
|
||||
interface SetupToolDeps {
|
||||
/** Scopes setup to the agent's project. */
|
||||
projectId: string;
|
||||
track: BuilderTrackFn;
|
||||
}
|
||||
|
||||
export interface CredentialSetupDeps extends SetupToolDeps {
|
||||
credentialService: InstanceAiCredentialService;
|
||||
isCredentialTypeKnown?: (credentialType: string) => boolean;
|
||||
/** Returns configured channel credential IDs for credential reuse. */
|
||||
listIntegrationCredentialIds?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface ChannelSetupDeps extends SetupToolDeps {
|
||||
agentId: string;
|
||||
/** Lists the channel types that setup can offer. */
|
||||
listChatIntegrationTypes: () => string[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ModelChoice {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { AgentBackgroundJobDto } from '@n8n/api-types';
|
||||
import { DateTimeColumn, WithTimestampsAndStringId } from '@n8n/db';
|
||||
import { Column, Entity, Index } from '@n8n/typeorm';
|
||||
|
||||
export type AgentBackgroundJobKind = 'subagent' | 'workflow';
|
||||
export type AgentBackgroundJobStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type AgentBackgroundJobKind = AgentBackgroundJobDto['kind'];
|
||||
export type AgentBackgroundJobStatus = AgentBackgroundJobDto['status'];
|
||||
|
||||
/**
|
||||
* Durable registry of background jobs dispatched by top-level agents: detached
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentMessageAuthor } from '@n8n/api-types';
|
||||
import type { AgentExecutionStatus, AgentMessageAuthor } from '@n8n/api-types';
|
||||
import {
|
||||
DateTimeColumn,
|
||||
JsonColumn,
|
||||
@@ -9,9 +9,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm';
|
||||
|
||||
import { AgentExecutionThread } from './agent-execution-thread.entity';
|
||||
import type { TimelineEvent } from '../execution-recorder';
|
||||
import type { StoredAttachmentRef } from '../types/agent-chat-attachment';
|
||||
import type { AgentExecutionFailureSummary } from '../utils/execution-failure-summary';
|
||||
|
||||
export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
|
||||
export type AgentExecutionHitlStatus = 'suspended' | 'resumed';
|
||||
|
||||
/**
|
||||
@@ -62,12 +62,7 @@ export class AgentExecution extends WithTimestampsAndStringId {
|
||||
|
||||
/** Metadata of files attached to the user turn ({id, fileName, mimeType, sizeBytes}[]); bytes live in BinaryDataService. */
|
||||
@JsonColumn({ nullable: true })
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}> | null;
|
||||
attachments: StoredAttachmentRef[] | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
model: string | null;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ToolDescriptor } from '@n8n/agents';
|
||||
import { type AgentJsonConfig, type AgentSkill } from '@n8n/api-types';
|
||||
import { JsonColumn, WithTimestamps } from '@n8n/db';
|
||||
import {
|
||||
@@ -35,13 +34,7 @@ export class AgentHistory extends WithTimestamps {
|
||||
schema: AgentJsonConfig | null;
|
||||
|
||||
@JsonColumn({ nullable: true, default: null })
|
||||
tools: Record<
|
||||
string,
|
||||
{
|
||||
code: string;
|
||||
descriptor: ToolDescriptor;
|
||||
}
|
||||
> | null;
|
||||
tools: Agent['tools'] | null;
|
||||
|
||||
@JsonColumn({ nullable: true, default: null })
|
||||
skills: Record<string, AgentSkill> | null;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { JSONObject } from '@n8n/agents';
|
||||
import type { EpisodicMemoryStatus, JSONObject } from '@n8n/agents';
|
||||
import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from '@n8n/db';
|
||||
import { Column, Entity, Index } from '@n8n/typeorm';
|
||||
|
||||
export type MemoryEntryStatus = 'active' | 'superseded' | 'dropped';
|
||||
|
||||
@Entity({ name: 'agents_memory_entries' })
|
||||
@Index(['agentId', 'resourceId', 'status', 'createdAt', 'id'])
|
||||
@Index(['agentId', 'resourceId', 'contentHash'], { unique: true })
|
||||
@@ -23,7 +21,7 @@ export class AgentMemoryEntryEntity extends WithTimestampsAndStringId {
|
||||
contentHash: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
status: MemoryEntryStatus;
|
||||
status: EpisodicMemoryStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 36, nullable: true })
|
||||
supersededBy: string | null;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ObservationLogTaskKind } from '@n8n/agents';
|
||||
import { DateTimeColumn, WithTimestamps } from '@n8n/db';
|
||||
import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
|
||||
|
||||
export type ObservationTaskKind = 'observer' | 'reflector';
|
||||
|
||||
@Entity({ name: 'agents_observation_locks' })
|
||||
@Index(['observationScopeId'])
|
||||
export class AgentObservationLockEntity extends WithTimestamps {
|
||||
@@ -13,7 +12,7 @@ export class AgentObservationLockEntity extends WithTimestamps {
|
||||
observationScopeId: string;
|
||||
|
||||
@PrimaryColumn({ type: 'varchar', length: 20 })
|
||||
taskKind: ObservationTaskKind;
|
||||
taskKind: ObservationLogTaskKind;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
holderId: string;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ObservationLogMarker, ObservationLogStatus } from '@n8n/agents';
|
||||
import { WithTimestampsAndStringId } from '@n8n/db';
|
||||
import { Column, Entity, Index } from '@n8n/typeorm';
|
||||
|
||||
export type ObservationMarker = 'critical' | 'important' | 'info' | 'completion';
|
||||
export type ObservationStatus = 'active' | 'superseded' | 'dropped';
|
||||
|
||||
@Entity({ name: 'agents_observations' })
|
||||
@Index(['agentId', 'observationScopeId', 'status', 'createdAt', 'id'])
|
||||
@Index(['agentId', 'observationScopeId', 'createdAt', 'id'])
|
||||
@@ -18,7 +16,7 @@ export class AgentObservationEntity extends WithTimestampsAndStringId {
|
||||
observationScopeId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
marker: ObservationMarker;
|
||||
marker: ObservationLogMarker;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
text: string;
|
||||
@@ -30,7 +28,7 @@ export class AgentObservationEntity extends WithTimestampsAndStringId {
|
||||
tokenCount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
status: ObservationStatus;
|
||||
status: ObservationLogStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 36, nullable: true })
|
||||
supersededBy: string | null;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isSensitiveKey } from '@n8n/utils/redaction/sensitive-key';
|
||||
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
|
||||
import { extractFromAICalls, isFromAIOnlyExpression } from 'n8n-workflow';
|
||||
|
||||
import type { ToolRegistry } from './tool-registry';
|
||||
import type { ToolRegistry, ToolRegistryEntry } from './tool-registry';
|
||||
|
||||
/** Cap on child trace characters persisted per delegation. Tighter than the
|
||||
* live forwarding budget because this is written into every parent execution row. */
|
||||
@@ -203,7 +203,7 @@ function sanitizeExecutionLogRecord(value: unknown): Record<string, unknown> | u
|
||||
export interface ToolCallDetails {
|
||||
toolName: string;
|
||||
displayName?: string;
|
||||
kind: 'tool' | 'workflow' | 'node';
|
||||
kind: ToolRegistryEntry['kind'];
|
||||
input: unknown;
|
||||
node?: {
|
||||
type: string;
|
||||
@@ -267,7 +267,7 @@ export type TimelineEvent =
|
||||
| { type: 'reasoning'; content: string; timestamp: number; endTime?: number }
|
||||
| {
|
||||
type: 'tool-call';
|
||||
kind: 'tool' | 'workflow' | 'node';
|
||||
kind: ToolRegistryEntry['kind'];
|
||||
name: string;
|
||||
toolCallId: string;
|
||||
input: unknown;
|
||||
|
||||
+2
-4
@@ -6,12 +6,10 @@ import { Time } from '@n8n/constants';
|
||||
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentChannelRef } from '../../utils/agent-channel';
|
||||
import type { Agent } from '../../entities/agent.entity';
|
||||
import type { AgentChannelStatus } from '../../entities/agent-channel-status.entity';
|
||||
import type {
|
||||
AgentChannelRef,
|
||||
AgentChannelStatusRepository,
|
||||
} from '../../repositories/agent-channel-status.repository';
|
||||
import type { AgentChannelStatusRepository } from '../../repositories/agent-channel-status.repository';
|
||||
import type { AgentRepository } from '../../repositories/agent.repository';
|
||||
import { AgentChannelReconciler } from '../agent-channel-reconciler.service';
|
||||
import { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
|
||||
|
||||
+3
-4
@@ -6,6 +6,7 @@ import type { StateAdapter, Lock, QueueEntry } from 'chat';
|
||||
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import { AgentChangePublisher } from '../../agent-change-publisher.service';
|
||||
import { AgentChatSubscriptionStateService } from '../agent-chat-subscription-state.service';
|
||||
import type { AgentChatSubscriptionRepository } from '../../repositories/agent-chat-subscription.repository';
|
||||
import type { AgentIntegrationConfig } from '@n8n/api-types';
|
||||
@@ -55,10 +56,8 @@ function makeService(multiMainEnabled = true) {
|
||||
} as Partial<GlobalConfig>);
|
||||
const logger = mockLogger();
|
||||
const service = new AgentChatSubscriptionStateService(
|
||||
logger,
|
||||
repository,
|
||||
publisher,
|
||||
globalConfig,
|
||||
new AgentChangePublisher(publisher, globalConfig, logger),
|
||||
);
|
||||
|
||||
return { service, repository, publisher, logger };
|
||||
@@ -380,7 +379,7 @@ describe('AgentChatSubscriptionStateService', () => {
|
||||
);
|
||||
expect(delegate.subscribe).toHaveBeenCalledWith('thread-1');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[AgentChatSubscriptionStateService] Failed to publish subscription change',
|
||||
'Failed to publish agent-chat-subscription-changed',
|
||||
expect.objectContaining({ threadId: 'thread-1', action: 'subscribe', error: 'redis down' }),
|
||||
);
|
||||
});
|
||||
|
||||
+3
-2
@@ -12,6 +12,7 @@ import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
import { AgentChangePublisher } from '../../agent-change-publisher.service';
|
||||
import { AgentExecutionOrchestratorService } from '../../agent-execution-orchestrator.service';
|
||||
import type { Agent } from '../../entities/agent.entity';
|
||||
import type { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
|
||||
@@ -153,7 +154,7 @@ function buildServiceWith(
|
||||
urlService,
|
||||
registry,
|
||||
instanceSettings,
|
||||
publisher,
|
||||
new AgentChangePublisher(publisher, globalConfig, logger),
|
||||
globalConfig,
|
||||
chatSubscriptionStateService,
|
||||
statusReporter,
|
||||
@@ -277,7 +278,7 @@ describe('ChatIntegrationService', () => {
|
||||
mock(),
|
||||
mock(),
|
||||
mock<InstanceSettings>({ isLeader: true }),
|
||||
mock(),
|
||||
mock<AgentChangePublisher>(),
|
||||
mock<GlobalConfig>({ multiMainSetup: { enabled: false } } as Partial<GlobalConfig>),
|
||||
mock<AgentChatSubscriptionStateService>(),
|
||||
mock<AgentChannelStatusReporter>(),
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
|
||||
);
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(params.chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(params.chat);
|
||||
const actionExecutor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
registry,
|
||||
|
||||
+29
-29
@@ -135,7 +135,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
chat.thread.mockReturnValue(sentThread as never);
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -150,7 +150,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
awaitResponse: false,
|
||||
});
|
||||
|
||||
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledWith('agent-1', {
|
||||
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledWith('agent-1', {
|
||||
type: 'slack',
|
||||
credentialId: 'cred-a',
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
});
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -233,7 +233,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.openDM.mockResolvedValue(thread as never);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(undefined);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
@@ -284,7 +284,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
chat.openDM.mockResolvedValue(dmThread as never);
|
||||
chat.thread.mockReturnValue(sentThread as never);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -320,7 +320,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
chat.channel.mockReturnValue(channel as never);
|
||||
chat.thread.mockReturnValue(sentThread as never);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -358,7 +358,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.thread.mockReturnValue(thread as never);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const registry = buildRegistry();
|
||||
Container.set(ChatIntegrationRegistry, registry);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
@@ -424,7 +424,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.thread.mockReturnValue(thread as never);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const shortenCallback = vi.fn(async (_actionId: string, _value: string, _label?: string) => ({
|
||||
id: 'short1234',
|
||||
value: '',
|
||||
@@ -499,7 +499,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ editMessage });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const registry = buildRegistry();
|
||||
registry.register(new ShortCallbackTelegramIntegration());
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
@@ -544,7 +544,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ editMessage });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const registry = buildRegistry();
|
||||
registry.register(new ShortCallbackTelegramIntegration());
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
@@ -584,7 +584,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({});
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const registry = buildRegistry();
|
||||
registry.register(new ShortCallbackTelegramIntegration());
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
@@ -627,7 +627,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ editMessage });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const shortenCallback = vi.fn(async (_actionId: string, _value: string) => ({
|
||||
id: 'short1234',
|
||||
value: '',
|
||||
@@ -705,7 +705,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -760,7 +760,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -810,7 +810,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ addReaction });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -880,7 +880,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ addReaction });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -943,7 +943,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -970,7 +970,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
|
||||
it('returns a structured error when the selected connection is unavailable', async () => {
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(undefined);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -1026,7 +1026,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -1130,7 +1130,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -1201,7 +1201,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -1284,7 +1284,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.thread.mockReturnValue(thread as never);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const registry = buildRegistry();
|
||||
Container.set(ChatIntegrationRegistry, registry);
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
@@ -1406,7 +1406,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
silent: true,
|
||||
note: expect.stringContaining('No reply will be sent'),
|
||||
});
|
||||
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
|
||||
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when the reply expectation is required', async () => {
|
||||
@@ -1430,7 +1430,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
ok: false,
|
||||
error: { code: 'REPLY_REQUIRED', message: expect.stringContaining('reply is expected') },
|
||||
});
|
||||
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
|
||||
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when the reply expectation is unset or the message context is missing', async () => {
|
||||
@@ -1456,7 +1456,7 @@ describe('ChatIntegrationActionExecutor', () => {
|
||||
error: { code: 'REPLY_REQUIRED', message: expect.any(String) },
|
||||
});
|
||||
}
|
||||
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
|
||||
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1475,7 +1475,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const guard = new ChannelRateLimitGuard();
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
@@ -1521,7 +1521,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const guard = new ChannelRateLimitGuard();
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
@@ -1563,7 +1563,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
|
||||
});
|
||||
|
||||
expect(slackAdapter.addReaction).toHaveBeenCalledTimes(1);
|
||||
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledTimes(1);
|
||||
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledTimes(1);
|
||||
expect(result2).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
@@ -1580,7 +1580,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const guard = new ChannelRateLimitGuard();
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
|
||||
+12
-19
@@ -63,7 +63,6 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
@@ -78,10 +77,6 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
input: { query: 'Michael Drury' },
|
||||
});
|
||||
|
||||
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledWith('agent-1', {
|
||||
type: 'slack',
|
||||
credentialId: 'cred-a',
|
||||
});
|
||||
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledWith('agent-1', slack);
|
||||
expect(chat.getAdapter).toHaveBeenCalledWith('slack');
|
||||
expect(usersList).toHaveBeenCalledWith({ limit: 10, token: 'xoxb-token' });
|
||||
@@ -134,7 +129,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -190,7 +185,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -204,7 +199,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
input: { userId: 'user-1' },
|
||||
});
|
||||
|
||||
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledWith('agent-1', {
|
||||
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledWith('agent-1', {
|
||||
type: 'linear',
|
||||
credentialId: 'cred-b',
|
||||
});
|
||||
@@ -250,7 +245,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -317,7 +312,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -414,7 +409,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -562,7 +557,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -638,7 +633,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -697,7 +692,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -742,7 +737,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue({ client: linearClient });
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -778,7 +773,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -816,7 +811,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(chat);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
chatIntegrationService,
|
||||
buildRegistry(),
|
||||
@@ -856,7 +851,6 @@ describe('ChatIntegrationContextQueryExecutor — rate-limit handling', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const guard = new ChannelRateLimitGuard();
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
@@ -893,7 +887,6 @@ describe('ChatIntegrationContextQueryExecutor — rate-limit handling', () => {
|
||||
const chat = mock<ChatInstance>();
|
||||
chat.getAdapter.mockReturnValue(slackAdapter);
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
|
||||
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
|
||||
const guard = new ChannelRateLimitGuard();
|
||||
const executor = new ChatIntegrationContextQueryExecutor(
|
||||
|
||||
-1
@@ -26,7 +26,6 @@ describe('IntegrationMessageContextService', () => {
|
||||
}
|
||||
const threadRepository = mock<AgentThreadRepository>();
|
||||
const resourceRepository = mock<AgentResourceRepository>();
|
||||
resourceRepository.existsBy.mockResolvedValue(true);
|
||||
threadRepository.findOneBy.mockImplementation(async ({ id }: { id: string }) =>
|
||||
threads.has(id) ? (threads.get(id) as never) : (null as never),
|
||||
);
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('internal integration dispatch', () => {
|
||||
expect(tool.description).toContain('NEVER call respond with only message.text');
|
||||
});
|
||||
|
||||
it('action executor skips getChatInstance and routes respond to the integration', async () => {
|
||||
it('action executor routes respond to the integration without a chat instance', async () => {
|
||||
const executor = new ChatIntegrationActionExecutor(
|
||||
chatIntegrationService,
|
||||
registry,
|
||||
@@ -224,7 +224,7 @@ describe('internal integration dispatch', () => {
|
||||
currentMessageContext: makeContext(),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
|
||||
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('action executor returns UNSUPPORTED_ACTION for send_dm on n8n_chat', async () => {
|
||||
@@ -240,10 +240,10 @@ describe('internal integration dispatch', () => {
|
||||
awaitResponse: false,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_ACTION' } });
|
||||
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
|
||||
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('context executor skips getChatInstance for internal integrations', async () => {
|
||||
it('context executor queries internal integrations without a chat instance', async () => {
|
||||
userRepository.findOneBy.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
firstName: 'Ada',
|
||||
@@ -261,6 +261,6 @@ describe('internal integration dispatch', () => {
|
||||
input: { userId: 'user-1' },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
|
||||
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { AgentMessageRepository } from '../../repositories/agent-message.re
|
||||
import type { AgentObservationCursorRepository } from '../../repositories/agent-observation-cursor.repository';
|
||||
import type { AgentObservationLockRepository } from '../../repositories/agent-observation-lock.repository';
|
||||
import type { AgentObservationRepository } from '../../repositories/agent-observation.repository';
|
||||
import type { AgentResourceRepository } from '../../repositories/agent-resource.repository';
|
||||
import { AgentResourceRepository } from '../../repositories/agent-resource.repository';
|
||||
import type { AgentThreadRepository } from '../../repositories/agent-thread.repository';
|
||||
import { N8nMemory } from '../n8n-memory';
|
||||
|
||||
@@ -85,6 +85,9 @@ describe('N8nMemory', () => {
|
||||
execute: vi.fn().mockResolvedValue({ raw: {}, generatedMaps: [], identifiers: [] }),
|
||||
};
|
||||
resourceRepository.createQueryBuilder.mockReturnValue(resourceInsertQueryBuilder as never);
|
||||
resourceRepository.ensureExists.mockImplementation(
|
||||
AgentResourceRepository.prototype.ensureExists.bind(resourceRepository),
|
||||
);
|
||||
transactionDelete = vi.fn().mockResolvedValue({ affected: 1, raw: {} });
|
||||
transactionObservationCreate = vi.fn((input) => ({ ...input }) as AgentObservationEntity);
|
||||
transactionObservationFind = vi.fn().mockResolvedValue([]);
|
||||
|
||||
+2
-3
@@ -15,9 +15,9 @@ import type { UrlService } from '@/services/url.service';
|
||||
|
||||
import type { AgentIntegrationManagementService } from '../../agent-integration-management.service';
|
||||
import type { AgentRepository } from '../../repositories/agent.repository';
|
||||
import type { AgentIntegrationRemovalContext } from '../agent-chat-integration';
|
||||
import {
|
||||
SlackManagedSetupService,
|
||||
type DeleteManagedSlackAppOptions,
|
||||
type FinalizeSlackManagerCredentialOptions,
|
||||
type GetManagedSetupStateOptions,
|
||||
type GetManagedSlackAppSettingsOptions,
|
||||
@@ -125,7 +125,7 @@ describe('Slack setup services', () => {
|
||||
options: UpdateManagedSlackAppSettingsOptions,
|
||||
) => ReturnType<SlackManagedSetupService['updateAppSettings']>;
|
||||
deleteManagedAppForCredential: (
|
||||
options: DeleteManagedSlackAppOptions,
|
||||
options: AgentIntegrationRemovalContext,
|
||||
) => ReturnType<SlackManagedSetupService['deleteAppForCredential']>;
|
||||
};
|
||||
|
||||
@@ -190,7 +190,6 @@ describe('Slack setup services', () => {
|
||||
methods,
|
||||
userRepository,
|
||||
cacheService,
|
||||
cipher,
|
||||
projectService,
|
||||
);
|
||||
const managedService = new SlackManagedSetupService(
|
||||
|
||||
+14
-25
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
|
||||
import type { UserRepository } from '@n8n/db';
|
||||
import type { Cipher } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CacheService } from '@/services/cache/cache.service';
|
||||
@@ -15,21 +14,13 @@ describe('SlackManualSetupService', () => {
|
||||
const methods = mock<SlackMethodsService>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const cacheService = mock<CacheService>();
|
||||
const cipher = mock<Cipher>();
|
||||
const projectService = mock<ProjectService>();
|
||||
projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
|
||||
return {
|
||||
service: new SlackManualSetupService(
|
||||
methods,
|
||||
userRepository,
|
||||
cacheService,
|
||||
cipher,
|
||||
projectService,
|
||||
),
|
||||
service: new SlackManualSetupService(methods, userRepository, cacheService, projectService),
|
||||
methods,
|
||||
userRepository,
|
||||
cacheService,
|
||||
cipher,
|
||||
projectService,
|
||||
};
|
||||
}
|
||||
@@ -87,7 +78,7 @@ describe('SlackManualSetupService', () => {
|
||||
});
|
||||
|
||||
it('completes OAuth by atomically consuming state and creating the bot credential', async () => {
|
||||
const { service, methods, userRepository, cacheService, cipher } = makeService();
|
||||
const { service, methods, userRepository, cacheService } = makeService();
|
||||
const user = { id: 'user-1' };
|
||||
const agent = {
|
||||
id: 'agent-1',
|
||||
@@ -105,7 +96,7 @@ describe('SlackManualSetupService', () => {
|
||||
redirectUrl: 'https://n8n.example/callback',
|
||||
};
|
||||
cacheService.take.mockResolvedValue('encrypted-session');
|
||||
cipher.decryptV2.mockResolvedValue(JSON.stringify(session));
|
||||
methods.decodeSession.mockResolvedValue(session);
|
||||
userRepository.findOne.mockResolvedValue(user as never);
|
||||
methods.getAgent.mockResolvedValue(agent as never);
|
||||
methods.callSlackApi.mockResolvedValue({
|
||||
@@ -130,20 +121,18 @@ describe('SlackManualSetupService', () => {
|
||||
});
|
||||
|
||||
it('rejects callback state for a different project or agent', async () => {
|
||||
const { service, methods, cacheService, cipher } = makeService();
|
||||
const { service, methods, cacheService } = makeService();
|
||||
cacheService.take.mockResolvedValue('encrypted-session');
|
||||
cipher.decryptV2.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
userId: 'user-1',
|
||||
appId: 'app-1',
|
||||
clientId: 'client-1',
|
||||
clientSecret: 'secret-1',
|
||||
signingSecret: 'signing-1',
|
||||
redirectUrl: 'https://n8n.example/callback',
|
||||
}),
|
||||
);
|
||||
methods.decodeSession.mockResolvedValue({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
userId: 'user-1',
|
||||
appId: 'app-1',
|
||||
clientId: 'client-1',
|
||||
clientSecret: 'secret-1',
|
||||
signingSecret: 'signing-1',
|
||||
redirectUrl: 'https://n8n.example/callback',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeInstall({
|
||||
|
||||
@@ -7,21 +7,19 @@ import { Service } from '@n8n/di';
|
||||
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
|
||||
import { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { agentChannelKey, agentChannelRef, type AgentChannelRef } from '../utils/agent-channel';
|
||||
import { AgentChannelStatusReporter } from './agent-channel-status-reporter';
|
||||
import { ChatIntegrationRegistry } from './agent-chat-integration';
|
||||
import { ChatIntegrationService } from './chat-integration.service';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
|
||||
import {
|
||||
AgentChannelStatusRepository,
|
||||
type AgentChannelRef,
|
||||
} from '../repositories/agent-channel-status.repository';
|
||||
import { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository';
|
||||
import { AgentRepository } from '../repositories/agent.repository';
|
||||
|
||||
/** Why a pass is running. Only the periodic one waits out a channel's backoff. */
|
||||
export type ChannelReconcileReason = 'startup' | 'leader-takeover' | 'interval';
|
||||
|
||||
/** Channels a pass should see running, keyed by {@link channelKey}. */
|
||||
/** Channels a pass should see running, keyed by {@link agentChannelKey}. */
|
||||
type WantedChannels = Map<string, { agent: Agent; integration: AgentIntegrationConfig }>;
|
||||
|
||||
/**
|
||||
@@ -31,10 +29,6 @@ type WantedChannels = Map<string, { agent: Agent; integration: AgentIntegrationC
|
||||
*/
|
||||
const SHUTDOWN_SETTLE_MS = 5 * Time.seconds.toMilliseconds;
|
||||
|
||||
function channelKey(ref: AgentChannelRef): string {
|
||||
return `${ref.agentId}:${ref.integrationType}:${ref.credentialId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the channels this main runs in line with the channels the published
|
||||
* configuration asks for, and keeps its account of them current.
|
||||
@@ -206,23 +200,7 @@ export class AgentChannelReconciler {
|
||||
const agents = await this.agentRepository.findPublished();
|
||||
const ownStatuses = await this.channelStatusRepository.findOwnAll();
|
||||
|
||||
const wantedHere: WantedChannels = new Map();
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const integration of agent.integrations ?? []) {
|
||||
// A draft entry has no credential to connect with. The builder writes
|
||||
// it so the panel can show a needs-setup chip, and publishing rejects
|
||||
// it, so it can only be here mid-setup.
|
||||
if (isDraftIntegration(integration)) continue;
|
||||
|
||||
const key = channelKey({
|
||||
agentId: agent.id,
|
||||
integrationType: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
});
|
||||
if (this.runsHere(integration)) wantedHere.set(key, { agent, integration });
|
||||
}
|
||||
}
|
||||
const wantedHere = this.collectWantedChannels(agents);
|
||||
|
||||
await this.settleWanted(wantedHere, ownStatuses, reason);
|
||||
await this.releaseGhosts(wantedHere);
|
||||
@@ -252,68 +230,13 @@ export class AgentChannelReconciler {
|
||||
ownStatuses: AgentChannelStatus[],
|
||||
reason: ChannelReconcileReason,
|
||||
): Promise<void> {
|
||||
const ownByChannel = new Map(ownStatuses.map((status) => [channelKey(status), status]));
|
||||
const ownByChannel = new Map(ownStatuses.map((status) => [agentChannelKey(status), status]));
|
||||
const now = new Date();
|
||||
|
||||
for (const [key, { agent, integration }] of wantedHere) {
|
||||
if (this.isShuttingDown) return;
|
||||
|
||||
const ref = this.refOf(agent, integration);
|
||||
const own = ownByChannel.get(key);
|
||||
|
||||
// `wantedHere` was decided when the pass began, and a stepdown since then
|
||||
// makes a leader-only channel someone else's. Checked before the branches
|
||||
// below so a demoted main neither starts it — putting a polling loop on a
|
||||
// follower, the one thing the role gate exists to prevent — nor goes on
|
||||
// affirming a row for it. The row is withdrawn here because
|
||||
// `forgetOwnOrphans` reads the same stale snapshot and would leave it
|
||||
// standing, reported as this instance's, until the next pass.
|
||||
if (!this.runsHere(integration)) {
|
||||
await this.statusReporter.withdraw(ref);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.chatIntegrationService.hasLiveChannel(ref)) {
|
||||
await this.affirmRunning(ref, own);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Startup and takeover always try: the backoff was set by an earlier life
|
||||
// of this process or by whatever it inherited, and a restart or a
|
||||
// promotion is exactly when the cause may have gone away.
|
||||
if (reason === 'interval' && !this.statusReporter.isRetryReady(own, now)) {
|
||||
// Waiting is still this main standing behind what it said, so the lease
|
||||
// is kept alive. From the third consecutive failure on the backoff
|
||||
// outgrows a lease (four intervals against three), and letting the row
|
||||
// expire mid-wait would have the sweep delete it: the channel would
|
||||
// report as `starting` with no reason given, and the next pass — seeing
|
||||
// no row — would retry at once and count from one again, so the backoff
|
||||
// could never grow past that point.
|
||||
await this.statusReporter.refreshLease(ref);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.chatIntegrationService.startChannel(agent, integration);
|
||||
this.logger.info('[AgentChannelReconciler] Started channel', {
|
||||
agentId: agent.id,
|
||||
type: integration.type,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
// `connect` has already recorded why, which is what the user sees.
|
||||
// Logged at warn rather than error because a retry is scheduled and the
|
||||
// state is reported — this is not the last word on the channel.
|
||||
this.logger.warn('[AgentChannelReconciler] Could not start channel', {
|
||||
agentId: agent.id,
|
||||
type: integration.type,
|
||||
attempts: (own?.attempts ?? 0) + 1,
|
||||
// Scrubbed for the same reason `recordFailure` scrubs it: a platform
|
||||
// error can quote the credential it failed with, and a Telegram API
|
||||
// URL carries the bot token in its path.
|
||||
error: scrubSecretsInText(error instanceof Error ? error.message : String(error)),
|
||||
});
|
||||
}
|
||||
await this.settleChannel(agent, integration, ownByChannel.get(key), reason, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +275,7 @@ export class AgentChannelReconciler {
|
||||
private async releaseGhosts(wantedHere: WantedChannels): Promise<void> {
|
||||
for (const ref of this.chatIntegrationService.listLiveChannels()) {
|
||||
if (this.isShuttingDown) return;
|
||||
if (wantedHere.has(channelKey(ref))) continue;
|
||||
if (wantedHere.has(agentChannelKey(ref))) continue;
|
||||
|
||||
try {
|
||||
await this.chatIntegrationService.releaseChannelLocally(ref.agentId, {
|
||||
@@ -386,7 +309,7 @@ export class AgentChannelReconciler {
|
||||
): Promise<void> {
|
||||
for (const status of ownStatuses) {
|
||||
if (this.isShuttingDown) return;
|
||||
if (wantedHere.has(channelKey(status))) continue;
|
||||
if (wantedHere.has(agentChannelKey(status))) continue;
|
||||
|
||||
await this.statusReporter.withdraw({
|
||||
agentId: status.agentId,
|
||||
@@ -422,11 +345,70 @@ export class AgentChannelReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
private refOf(agent: Agent, integration: AgentIntegrationConfig): AgentChannelRef {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
integrationType: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
};
|
||||
private collectWantedChannels(agents: Agent[]): WantedChannels {
|
||||
const wanted: WantedChannels = new Map();
|
||||
for (const agent of agents) {
|
||||
const integrations = (agent.integrations ?? []).filter(
|
||||
(integration) => !isDraftIntegration(integration) && this.runsHere(integration),
|
||||
);
|
||||
for (const integration of integrations) {
|
||||
wanted.set(agentChannelKey(agentChannelRef(agent.id, integration)), { agent, integration });
|
||||
}
|
||||
}
|
||||
return wanted;
|
||||
}
|
||||
|
||||
private async settleChannel(
|
||||
agent: Agent,
|
||||
integration: AgentIntegrationConfig,
|
||||
own: AgentChannelStatus | undefined,
|
||||
reason: ChannelReconcileReason,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
const ref = agentChannelRef(agent.id, integration);
|
||||
// A leader stepdown can invalidate the set selected at the start of the pass.
|
||||
if (!this.runsHere(integration)) {
|
||||
await this.statusReporter.withdraw(ref);
|
||||
return;
|
||||
}
|
||||
if (this.chatIntegrationService.hasLiveChannel(ref)) {
|
||||
await this.affirmRunning(ref, own);
|
||||
return;
|
||||
}
|
||||
if (reason === 'interval' && !this.statusReporter.isRetryReady(own, now)) {
|
||||
// Keep the failure and its backoff alive until the next attempt.
|
||||
await this.statusReporter.refreshLease(ref);
|
||||
return;
|
||||
}
|
||||
await this.startWantedChannel(agent, integration, own, reason);
|
||||
}
|
||||
|
||||
private async startWantedChannel(
|
||||
agent: Agent,
|
||||
integration: AgentIntegrationConfig,
|
||||
own: AgentChannelStatus | undefined,
|
||||
reason: ChannelReconcileReason,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.chatIntegrationService.startChannel(agent, integration);
|
||||
this.logger.info('[AgentChannelReconciler] Started channel', {
|
||||
agentId: agent.id,
|
||||
type: integration.type,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
// `connect` has already recorded why, which is what the user sees.
|
||||
// Logged at warn rather than error because a retry is scheduled and the
|
||||
// state is reported — this is not the last word on the channel.
|
||||
this.logger.warn('[AgentChannelReconciler] Could not start channel', {
|
||||
agentId: agent.id,
|
||||
type: integration.type,
|
||||
attempts: (own?.attempts ?? 0) + 1,
|
||||
// Scrubbed for the same reason `recordFailure` scrubs it: a platform
|
||||
// error can quote the credential it failed with, and a Telegram API
|
||||
// URL carries the bot token in its path.
|
||||
error: scrubSecretsInText(error instanceof Error ? error.message : String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import { Time } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
|
||||
|
||||
import type { AgentChannelRef } from '../utils/agent-channel';
|
||||
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
|
||||
import {
|
||||
AgentChannelStatusRepository,
|
||||
type AgentChannelRef,
|
||||
} from '../repositories/agent-channel-status.repository';
|
||||
import { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository';
|
||||
|
||||
/**
|
||||
* How long a row counts for, as a multiple of the reconcile interval. Three
|
||||
|
||||
@@ -16,10 +16,8 @@ import { UserError, type Logger } from 'n8n-workflow';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import {
|
||||
AgentChatAttachmentService,
|
||||
type StoredAttachmentRef,
|
||||
} from '../agent-chat-attachment.service';
|
||||
import { AgentChatAttachmentService } from '../agent-chat-attachment.service';
|
||||
import type { StoredAttachmentRef } from '../types/agent-chat-attachment';
|
||||
import { AgentConversationStateService } from '../agent-conversation-state.service';
|
||||
import type {
|
||||
AgentExecutionOrchestratorService,
|
||||
@@ -86,6 +84,51 @@ interface SessionGenerationState {
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
interface InboundMessage {
|
||||
/** Prepared, trimmed text. It can be empty when the message has attachments. */
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
interface TurnSession {
|
||||
/** The active platform session, with the `/new` or idle generation applied. */
|
||||
activeThreadId: InternalThread;
|
||||
/** The session that the turn runs in. A task-session binding can redirect it. */
|
||||
memory: { threadId: InternalThread; resourceId: string };
|
||||
sessionMode: AgentSessionMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects `/new` for adapters that deliver it as plain text rather than a
|
||||
* slash command (i.e. everything but Telegram — see the `onSlashCommand`
|
||||
* registration). Only treated as the reset command alone: a `/new`
|
||||
* sent together with an attachment falls through to a normal turn instead
|
||||
* of silently dropping the attachment along with the reset. Returns
|
||||
* whether the caller must reset the session instead of running a turn.
|
||||
*/
|
||||
function isResetCommand(inbound: InboundMessage): boolean {
|
||||
return inbound.text.toLowerCase() === RESET_SESSION_COMMAND && inbound.attachments.length === 0;
|
||||
}
|
||||
|
||||
function buildModelMessage(
|
||||
labelledText: string,
|
||||
bridgeExecutionContext: BridgeExecutionContext,
|
||||
): string {
|
||||
const modelContext: string[] = [];
|
||||
if (bridgeExecutionContext.historyContext) {
|
||||
modelContext.push(bridgeExecutionContext.historyContext);
|
||||
}
|
||||
if (bridgeExecutionContext.platformMessage) {
|
||||
modelContext.push(formatPlatformMessageContext(bridgeExecutionContext.platformMessage));
|
||||
}
|
||||
modelContext.push(
|
||||
bridgeExecutionContext.platformMessage
|
||||
? `The actual user message follows.\n${labelledText}`
|
||||
: labelledText,
|
||||
);
|
||||
return modelContext.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply sent when a message arrives while the run is parked. Leads with the
|
||||
* suspension card's own title (e.g. `Waiting on "Approval workflow"`) so the
|
||||
@@ -344,7 +387,7 @@ export class AgentChatBridge {
|
||||
if (shouldSubscribe) {
|
||||
await anchoredThread.subscribe();
|
||||
}
|
||||
await this.executeAndStream(anchoredThread, message, { isNewMention: true });
|
||||
await this.handleInboundMessage(anchoredThread, message, { isNewMention: true });
|
||||
} catch (error) {
|
||||
await this.postErrorToThread(thread, error);
|
||||
}
|
||||
@@ -354,7 +397,7 @@ export class AgentChatBridge {
|
||||
try {
|
||||
if (!this.canUserAccess(message.author)) return;
|
||||
const anchoredThread = this.anchorInboundThread(thread, message);
|
||||
await this.executeAndStream(anchoredThread, message, { isNewMention: false });
|
||||
await this.handleInboundMessage(anchoredThread, message, { isNewMention: false });
|
||||
} catch (error) {
|
||||
await this.postErrorToThread(thread, error);
|
||||
}
|
||||
@@ -488,28 +531,10 @@ export class AgentChatBridge {
|
||||
return toInternalThreadId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `/new` for adapters that deliver it as plain text rather than a
|
||||
* slash command (i.e. everything but Telegram — see the `onSlashCommand`
|
||||
* registration above). Only treated as the reset command alone: a `/new`
|
||||
* sent together with an attachment falls through to a normal turn instead
|
||||
* of silently dropping the attachment along with the reset. Returns
|
||||
* whether it was handled — the caller must not run a turn when it was.
|
||||
*/
|
||||
private async handleResetCommand(
|
||||
thread: Thread,
|
||||
text: string,
|
||||
inboundAttachments: Attachment[],
|
||||
): Promise<boolean> {
|
||||
if (text.toLowerCase() !== RESET_SESSION_COMMAND || inboundAttachments.length > 0) return false;
|
||||
await this.resetSession(thread);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates to a brand-new session for `thread` and confirms it there.
|
||||
* Unbinding a task-run session (see {@link resolveSession} in
|
||||
* `executeAndStream`) and rotating the generation happen inside the same
|
||||
* `resolveTurnSession`) and rotating the generation happen inside the same
|
||||
* critical section, in that order, as one unit:
|
||||
* - Same critical section: splitting them would let a concurrent message
|
||||
* land in between and read the just-rotated generation while the old
|
||||
@@ -626,23 +651,64 @@ export class AgentChatBridge {
|
||||
// Core execution pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async executeAndStream(
|
||||
private async handleInboundMessage(
|
||||
thread: Thread,
|
||||
message: Message,
|
||||
options: { isNewMention: boolean },
|
||||
): Promise<void> {
|
||||
const { isNewMention } = options;
|
||||
const inbound = await this.readInboundMessage(message);
|
||||
if (!inbound) return;
|
||||
if (isResetCommand(inbound)) {
|
||||
await this.resetSession(thread);
|
||||
return;
|
||||
}
|
||||
await this.runTurn(thread, message, inbound, options);
|
||||
}
|
||||
|
||||
private async readInboundMessage(message: Message): Promise<InboundMessage | null> {
|
||||
const platformAgentContext = this.getPlatformAgentContext();
|
||||
const text = this.prepareInboundText(
|
||||
await this.getInboundText(message),
|
||||
platformAgentContext,
|
||||
).trim();
|
||||
// `?? []` guards rehydrated/serialized messages that predate the field.
|
||||
const inboundAttachments = message.attachments ?? [];
|
||||
if (!text && inboundAttachments.length === 0) return;
|
||||
if (await this.handleResetCommand(thread, text, inboundAttachments)) return;
|
||||
const attachments = message.attachments ?? [];
|
||||
if (!text && attachments.length === 0) return null;
|
||||
return { text, attachments };
|
||||
}
|
||||
|
||||
const threadId = await this.resolveActiveThreadId(thread);
|
||||
private async runTurn(
|
||||
thread: Thread,
|
||||
message: Message,
|
||||
inbound: InboundMessage,
|
||||
options: { isNewMention: boolean },
|
||||
): Promise<void> {
|
||||
const platformAgentContext = this.getPlatformAgentContext();
|
||||
const session = await this.resolveTurnSession(thread, message);
|
||||
// The run parks against the session it executes in, which for a bound reply
|
||||
// is the task's thread rather than the platform one — so this has to come
|
||||
// after the binding is resolved, and before anything is stored for a turn
|
||||
// that is not going to run.
|
||||
if (await this.postStillWaitingReply(thread, session.memory.threadId.id)) return;
|
||||
const { attachments, attachmentNotes } = await this.storeInboundAttachments(
|
||||
inbound.attachments,
|
||||
session.memory.threadId.id,
|
||||
session.memory.resourceId,
|
||||
);
|
||||
await this.deliverTurn({
|
||||
thread,
|
||||
message,
|
||||
text: inbound.text,
|
||||
session,
|
||||
attachments,
|
||||
attachmentNotes,
|
||||
isNewMention: options.isNewMention,
|
||||
platformAgentContext,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveTurnSession(thread: Thread, message: Message): Promise<TurnSession> {
|
||||
const activeThreadId = await this.resolveActiveThreadId(thread);
|
||||
const resourceId = integrationMemoryResourceId(this.integration.type, message.author.userId);
|
||||
// If this thread was established by an outbound task send, continue that
|
||||
// task's session instead of starting a fresh one. Attachments are stored
|
||||
@@ -653,21 +719,42 @@ export class AgentChatBridge {
|
||||
// so it has to be looked up the same way, not by whatever generation is
|
||||
// currently active.
|
||||
const sessionOrigin = await this.messageContextBridge.resolveSession(this.baseThreadId(thread));
|
||||
const memoryThreadId = sessionOrigin ? toInternalThreadId(sessionOrigin.threadId) : threadId;
|
||||
const memoryResourceId = sessionOrigin?.resourceId ?? resourceId;
|
||||
const sessionMode = sessionOrigin
|
||||
? 'existing'
|
||||
: ((await this.agentService.getSessionMode?.(memoryThreadId.id)) ?? 'new');
|
||||
// The run parks against the session it executes in, which for a bound reply
|
||||
// is the task's thread rather than the platform one — so this has to come
|
||||
// after the binding is resolved, and before anything is stored for a turn
|
||||
// that is not going to run.
|
||||
if (await this.postStillWaitingReply(thread, memoryThreadId.id)) return;
|
||||
const { attachments, attachmentNotes } = await this.storeInboundAttachments(
|
||||
inboundAttachments,
|
||||
memoryThreadId.id,
|
||||
memoryResourceId,
|
||||
);
|
||||
if (sessionOrigin) {
|
||||
return {
|
||||
activeThreadId,
|
||||
memory: {
|
||||
threadId: toInternalThreadId(sessionOrigin.threadId),
|
||||
resourceId: sessionOrigin.resourceId,
|
||||
},
|
||||
sessionMode: 'existing',
|
||||
};
|
||||
}
|
||||
return {
|
||||
activeThreadId,
|
||||
memory: { threadId: activeThreadId, resourceId },
|
||||
sessionMode: (await this.agentService.getSessionMode?.(activeThreadId.id)) ?? 'new',
|
||||
};
|
||||
}
|
||||
|
||||
private async deliverTurn({
|
||||
thread,
|
||||
message,
|
||||
text,
|
||||
session,
|
||||
attachments,
|
||||
attachmentNotes,
|
||||
isNewMention,
|
||||
platformAgentContext,
|
||||
}: {
|
||||
thread: Thread;
|
||||
message: Message;
|
||||
text: string;
|
||||
session: TurnSession;
|
||||
attachments: StoredAttachmentRef[];
|
||||
attachmentNotes: string[];
|
||||
isNewMention: boolean;
|
||||
platformAgentContext: PlatformAgentContext;
|
||||
}): Promise<void> {
|
||||
const statusRetry = new AbortController();
|
||||
const replyExpectation =
|
||||
this.integrationImpl?.getReplyExpectation?.({
|
||||
@@ -711,33 +798,21 @@ export class AgentChatBridge {
|
||||
const author = toMessageAuthor(message.author);
|
||||
const textWithNotes = [text, ...attachmentNotes].filter(Boolean).join('\n');
|
||||
const labelledText = `[${author.name} (${author.id})]: ${textWithNotes}`;
|
||||
const modelContext: string[] = [];
|
||||
if (bridgeExecutionContext.historyContext) {
|
||||
modelContext.push(bridgeExecutionContext.historyContext);
|
||||
}
|
||||
if (bridgeExecutionContext.platformMessage) {
|
||||
modelContext.push(formatPlatformMessageContext(bridgeExecutionContext.platformMessage));
|
||||
}
|
||||
modelContext.push(
|
||||
bridgeExecutionContext.platformMessage
|
||||
? `The actual user message follows.\n${labelledText}`
|
||||
: labelledText,
|
||||
);
|
||||
const modelMessage = modelContext.join('\n\n');
|
||||
const modelMessage = buildModelMessage(labelledText, bridgeExecutionContext);
|
||||
const stream = this.agentService.executeForChatPublished({
|
||||
messageContext,
|
||||
contextConversation: { threadId: threadId.id, resourceId: message.author.userId },
|
||||
contextConversation: {
|
||||
threadId: session.activeThreadId.id,
|
||||
resourceId: message.author.userId,
|
||||
},
|
||||
agentId: this.agentId,
|
||||
projectId: this.n8nProjectId,
|
||||
message: textWithNotes,
|
||||
modelMessage,
|
||||
author,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
memory: {
|
||||
threadId: memoryThreadId,
|
||||
resourceId: memoryResourceId,
|
||||
},
|
||||
sessionMode,
|
||||
memory: session.memory,
|
||||
sessionMode: session.sessionMode,
|
||||
integrationType: this.integration.type,
|
||||
sandboxPrincipalHash: hashAgentSandboxPrincipal({
|
||||
type: 'integration-thread',
|
||||
|
||||
@@ -17,12 +17,12 @@ import {
|
||||
import type {
|
||||
IntegrationAction,
|
||||
IntegrationActionDefinition,
|
||||
IntegrationActionParams,
|
||||
IntegrationActionResult,
|
||||
IntegrationContextQuery,
|
||||
IntegrationContextQueryDefinition,
|
||||
IntegrationMessageContext,
|
||||
IntegrationContextQueryParams,
|
||||
IntegrationPlatformMessageContext,
|
||||
IntegrationToolConnectionDescriptor,
|
||||
ReplyExpectation,
|
||||
} from './integration-tools';
|
||||
|
||||
@@ -514,22 +514,17 @@ export abstract class AgentChatIntegration {
|
||||
}
|
||||
|
||||
/** Per-platform context-query execution params. */
|
||||
export interface PlatformContextQueryParams {
|
||||
export interface PlatformContextQueryParams
|
||||
extends Omit<IntegrationContextQueryParams, 'persistence'> {
|
||||
/** `undefined` only for integrations with `requiresChatInstance === false`. */
|
||||
chat: ChatInstance | undefined;
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
query: IntegrationContextQuery;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-platform action-execution params. */
|
||||
export interface PlatformActionParams {
|
||||
export interface PlatformActionParams
|
||||
extends Omit<IntegrationActionParams, 'awaitResponse' | 'runId' | 'toolCallId'> {
|
||||
/** `undefined` only for integrations with `requiresChatInstance === false`. */
|
||||
chat: ChatInstance | undefined;
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
action: IntegrationAction;
|
||||
input: Record<string, unknown>;
|
||||
currentMessageContext?: IntegrationMessageContext;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-10
@@ -3,26 +3,20 @@ import type { Lock, QueueEntry, StateAdapter } from 'chat';
|
||||
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
|
||||
import {
|
||||
type AgentChatSubscriptionRepository,
|
||||
type AgentChatSubscriptionScope,
|
||||
} from '../repositories/agent-chat-subscription.repository';
|
||||
import type { AgentChatSubscriptionRepository } from '../repositories/agent-chat-subscription.repository';
|
||||
import { agentChannelKey, type AgentChannelRef } from '../utils/agent-channel';
|
||||
|
||||
export type SubscriptionAction = PubSubCommandMap['agent-chat-subscription-changed']['action'];
|
||||
|
||||
const NEGATIVE_SUBSCRIPTION_CACHE_TTL_MS = 30_000;
|
||||
|
||||
export function scopeKey(scope: AgentChatSubscriptionScope): string {
|
||||
return `${scope.agentId}:${scope.integrationType}:${scope.credentialId}`;
|
||||
}
|
||||
|
||||
export class AgentChatSubscriptionStateAdapter implements StateAdapter {
|
||||
private connected = false;
|
||||
|
||||
private readonly negativeSubscriptionCache = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly scope: AgentChatSubscriptionScope,
|
||||
private readonly scope: AgentChannelRef,
|
||||
private readonly integration: AgentIntegrationConfig,
|
||||
private readonly delegate: StateAdapter,
|
||||
private readonly repository: AgentChatSubscriptionRepository,
|
||||
@@ -35,7 +29,7 @@ export class AgentChatSubscriptionStateAdapter implements StateAdapter {
|
||||
) {}
|
||||
|
||||
get key(): string {
|
||||
return scopeKey(this.scope);
|
||||
return agentChannelKey(this.scope);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
|
||||
+14
-54
@@ -1,22 +1,14 @@
|
||||
import type { AgentIntegrationConfig } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { StateAdapter } from 'chat';
|
||||
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
|
||||
import {
|
||||
AgentChatSubscriptionStateAdapter,
|
||||
scopeKey,
|
||||
type SubscriptionAction,
|
||||
} from './agent-chat-subscription-state.adapter';
|
||||
import {
|
||||
AgentChatSubscriptionRepository,
|
||||
type AgentChatSubscriptionScope,
|
||||
} from '../repositories/agent-chat-subscription.repository';
|
||||
import { AgentChatSubscriptionStateAdapter } from './agent-chat-subscription-state.adapter';
|
||||
import { AgentChangePublisher } from '../agent-change-publisher.service';
|
||||
import { AgentChatSubscriptionRepository } from '../repositories/agent-chat-subscription.repository';
|
||||
import { agentChannelKey, agentChannelRef } from '../utils/agent-channel';
|
||||
|
||||
interface CreateStateAdapterOptions {
|
||||
agentId: string;
|
||||
@@ -24,34 +16,27 @@ interface CreateStateAdapterOptions {
|
||||
delegate: StateAdapter;
|
||||
}
|
||||
|
||||
function toScope(agentId: string, integration: AgentIntegrationConfig): AgentChatSubscriptionScope {
|
||||
return {
|
||||
agentId,
|
||||
integrationType: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
};
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AgentChatSubscriptionStateService {
|
||||
private readonly activeStates = new Map<string, AgentChatSubscriptionStateAdapter>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly repository: AgentChatSubscriptionRepository,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly changePublisher: AgentChangePublisher,
|
||||
) {}
|
||||
|
||||
createStateAdapter(options: CreateStateAdapterOptions): StateAdapter {
|
||||
const scope = toScope(options.agentId, options.integration);
|
||||
const scope = agentChannelRef(options.agentId, options.integration);
|
||||
const state = new AgentChatSubscriptionStateAdapter(
|
||||
scope,
|
||||
options.integration,
|
||||
options.delegate,
|
||||
this.repository,
|
||||
async (integration, threadId, action) =>
|
||||
await this.publishSubscriptionChanged(options.agentId, integration, threadId, action),
|
||||
await this.changePublisher.publish({
|
||||
command: 'agent-chat-subscription-changed',
|
||||
payload: { agentId: options.agentId, integration, threadId, action },
|
||||
}),
|
||||
(disconnectedState) => this.unregister(disconnectedState),
|
||||
);
|
||||
this.activeStates.set(state.key, state);
|
||||
@@ -62,14 +47,16 @@ export class AgentChatSubscriptionStateService {
|
||||
agentId: string,
|
||||
integration: AgentIntegrationConfig,
|
||||
): Promise<void> {
|
||||
await this.repository.deleteForConnection(toScope(agentId, integration));
|
||||
await this.repository.deleteForConnection(agentChannelRef(agentId, integration));
|
||||
}
|
||||
|
||||
@OnPubSubEvent('agent-chat-subscription-changed', { instanceType: 'main' })
|
||||
async handleSubscriptionChanged(
|
||||
payload: PubSubCommandMap['agent-chat-subscription-changed'],
|
||||
): Promise<void> {
|
||||
const state = this.activeStates.get(scopeKey(toScope(payload.agentId, payload.integration)));
|
||||
const state = this.activeStates.get(
|
||||
agentChannelKey(agentChannelRef(payload.agentId, payload.integration)),
|
||||
);
|
||||
await state?.applyRemoteChange(payload.threadId, payload.action);
|
||||
}
|
||||
|
||||
@@ -78,31 +65,4 @@ export class AgentChatSubscriptionStateService {
|
||||
this.activeStates.delete(state.key);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishSubscriptionChanged(
|
||||
agentId: string,
|
||||
integration: AgentIntegrationConfig,
|
||||
threadId: string,
|
||||
action: SubscriptionAction,
|
||||
): Promise<void> {
|
||||
if (!this.globalConfig.multiMainSetup.enabled) return;
|
||||
try {
|
||||
await this.publisher.publishCommand({
|
||||
command: 'agent-chat-subscription-changed',
|
||||
payload: { agentId, integration, threadId, action },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'[AgentChatSubscriptionStateService] Failed to publish subscription change',
|
||||
{
|
||||
agentId,
|
||||
integrationType: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
threadId,
|
||||
action,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { InstanceSettings } from 'n8n-core';
|
||||
import { OperationalError, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
@@ -31,7 +30,13 @@ import {
|
||||
import { channelIntegrationRecorder } from './recording/channel-integration-recorder';
|
||||
import { recordAdapterCalls } from './recording/recording-adapter';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentChannelRef } from '../repositories/agent-channel-status.repository';
|
||||
import { AgentChangePublisher } from '../agent-change-publisher.service';
|
||||
import {
|
||||
agentChannelKey,
|
||||
agentChannelRef,
|
||||
type AgentChannelRef,
|
||||
type IntegrationRef,
|
||||
} from '../utils/agent-channel';
|
||||
import { AgentRepository } from '../repositories/agent.repository';
|
||||
import { AgentChannelStatusReporter } from './agent-channel-status-reporter';
|
||||
|
||||
@@ -141,7 +146,7 @@ export class ChatIntegrationService {
|
||||
private readonly urlService: UrlService,
|
||||
private readonly integrationRegistry: ChatIntegrationRegistry,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly changePublisher: AgentChangePublisher,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly chatSubscriptionStateService: AgentChatSubscriptionStateService,
|
||||
private readonly statusReporter: AgentChannelStatusReporter,
|
||||
@@ -161,22 +166,10 @@ export class ChatIntegrationService {
|
||||
integration: AgentIntegrationConfig,
|
||||
action: 'connect' | 'disconnect',
|
||||
): Promise<void> {
|
||||
if (!this.globalConfig.multiMainSetup.enabled) return;
|
||||
try {
|
||||
const payload = { agentId, integration, action };
|
||||
await this.publisher.publishCommand({
|
||||
command: 'agent-chat-integration-changed',
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] Failed to publish ${action} for ${integration.type} on agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private connectionKey(agentId: string, type: string, credentialId: string): string {
|
||||
return `${agentId}:${type}:${credentialId}`;
|
||||
await this.changePublisher.publish({
|
||||
command: 'agent-chat-integration-changed',
|
||||
payload: { agentId, integration, action },
|
||||
});
|
||||
}
|
||||
|
||||
private integrationFromConnectionKey(key: string): AgentChatIntegration | undefined {
|
||||
@@ -302,7 +295,7 @@ export class ChatIntegrationService {
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = this.channelRef(agentId, integration);
|
||||
const ref = agentChannelRef(agentId, integration);
|
||||
try {
|
||||
await this.establishConnection(agentId, integration, projectId, options);
|
||||
} catch (error) {
|
||||
@@ -324,7 +317,7 @@ export class ChatIntegrationService {
|
||||
projectId: string,
|
||||
options: ConnectOptions = {},
|
||||
): Promise<void> {
|
||||
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
|
||||
const key = agentChannelKey(agentChannelRef(agentId, integration));
|
||||
const ingressEnabled = options.ingressEnabled ?? true;
|
||||
|
||||
if (ingressEnabled) {
|
||||
@@ -337,21 +330,7 @@ export class ChatIntegrationService {
|
||||
|
||||
const integrationImpl = this.integrationRegistry.require(integration.type);
|
||||
|
||||
// Decrypt the integration credential to get platform tokens
|
||||
const decryptedData = await this.decryptCredentialForProject(
|
||||
integration.credentialId,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const ctx: AgentChatIntegrationContext = {
|
||||
agentId,
|
||||
projectId,
|
||||
integration,
|
||||
credentialId: integration.credentialId,
|
||||
credential: decryptedData,
|
||||
ingressEnabled,
|
||||
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
|
||||
};
|
||||
const ctx = await this.createConnectionContext(agentId, projectId, integration, ingressEnabled);
|
||||
|
||||
// Pre-connect hook — webhook-based platforms use this to detect
|
||||
// credential conflicts (e.g. a Telegram bot token already in use) and
|
||||
@@ -360,114 +339,20 @@ export class ChatIntegrationService {
|
||||
await integrationImpl.onBeforeConnect(ctx);
|
||||
}
|
||||
|
||||
let state: StateAdapter | undefined;
|
||||
let chat!: ChatSdk;
|
||||
let bridge: AgentChatBridge | undefined;
|
||||
let initializeStarted = false;
|
||||
|
||||
// Initialize the Chat instance (connects adapters, state adapter, etc.) and
|
||||
// run post-initialize hooks (e.g. Telegram setWebhook) once it is live.
|
||||
// If setup throws after registering subscription state but before
|
||||
// initialization starts, disconnect the state directly. Once initialize()
|
||||
// starts, chat.shutdown() owns cleanup for adapters, timers, and state.
|
||||
try {
|
||||
// Delegate adapter construction to the platform implementation.
|
||||
const adapter = recordAdapterCalls(
|
||||
integration.type,
|
||||
await integrationImpl.createAdapter(ctx),
|
||||
);
|
||||
channelIntegrationRecorder.startFetchRecording();
|
||||
|
||||
// Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform
|
||||
const { Chat } = await loadChatSdk();
|
||||
const { createMemoryState } = await loadMemoryState();
|
||||
|
||||
const memoryState = createMemoryState();
|
||||
state = ingressEnabled
|
||||
? this.chatSubscriptionStateService.createStateAdapter({
|
||||
agentId,
|
||||
integration,
|
||||
delegate: memoryState,
|
||||
})
|
||||
: memoryState;
|
||||
|
||||
chat = new Chat({
|
||||
userName: `n8n-agent-${agentId}`,
|
||||
// Use the platform type as the adapter key (e.g. 'slack') so that
|
||||
// bot.webhooks.slack maps correctly to the handler.
|
||||
adapters: { [integration.type]: adapter } as Record<string, never>,
|
||||
state,
|
||||
});
|
||||
|
||||
if (ingressEnabled) {
|
||||
const componentMapper = new ComponentMapper();
|
||||
const agentExecutionOrchestratorService = await getAgentExecutionOrchestratorService();
|
||||
|
||||
bridge = AgentChatBridge.create(
|
||||
chat,
|
||||
agentId,
|
||||
agentExecutionOrchestratorService,
|
||||
componentMapper,
|
||||
this.logger,
|
||||
projectId,
|
||||
integration,
|
||||
);
|
||||
}
|
||||
|
||||
initializeStarted = true;
|
||||
await chat.initialize();
|
||||
|
||||
if (ingressEnabled && integrationImpl.onAfterConnect && !options.skipExternalHooks) {
|
||||
await integrationImpl.onAfterConnect(ctx);
|
||||
}
|
||||
} catch (error) {
|
||||
if (initializeStarted) {
|
||||
await chat.shutdown().catch((shutdownError: unknown) => {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] Shutdown after failed connect threw: ${shutdownError instanceof Error ? shutdownError.message : String(shutdownError)}`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await state?.disconnect().catch((disconnectError: unknown) => {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] State cleanup after failed setup threw: ${disconnectError instanceof Error ? disconnectError.message : String(disconnectError)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
// Mirror of the `onConnected` call below. A platform that stashed
|
||||
// per-connection state during `createAdapter` — Discord keeps the
|
||||
// decrypted bot token there — must get the chance to release it, or a
|
||||
// failed connect strands it for the life of the process.
|
||||
await this.runDisconnectedHook(integrationImpl, ctx, `${key} after failed connect`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The `chat` variable is returned by `new Chat(...)` from the ESM-only
|
||||
// package. Its runtime shape matches our local `ChatInstance` interface.
|
||||
// We validate the required methods exist before storing.
|
||||
const chatInstance = chat as ChatInstance;
|
||||
const { chat, bridge } = await this.initializeConnection(ctx, integrationImpl, options, key);
|
||||
|
||||
const targetConnections = ingressEnabled ? this.connections : this.outboundConnections;
|
||||
targetConnections.set(key, {
|
||||
chat: chatInstance,
|
||||
chat,
|
||||
bridge,
|
||||
context: ctx,
|
||||
ref: this.channelRef(agentId, integration),
|
||||
ref: agentChannelRef(agentId, integration),
|
||||
});
|
||||
|
||||
// Runs on every main, never gated on `skipExternalHooks`: this builds
|
||||
// local runtime state each main owns for itself (e.g. Discord's
|
||||
// leader-gated Gateway socket), not cluster-wide external state.
|
||||
if (integrationImpl.onConnected) {
|
||||
try {
|
||||
await integrationImpl.onConnected(ctx);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] onConnected failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.runConnectedHook(integrationImpl, ctx, key);
|
||||
|
||||
this.logger.info(
|
||||
`[ChatIntegrationService] ${ingressEnabled ? 'Connected' : 'Outbound connected'}: ${key}`,
|
||||
@@ -488,7 +373,7 @@ export class ChatIntegrationService {
|
||||
*/
|
||||
async disconnect(
|
||||
agentId: string,
|
||||
integration?: { credentialId: string; type: string },
|
||||
integration?: IntegrationRef,
|
||||
options: DisconnectOptions = {},
|
||||
): Promise<void> {
|
||||
if (!integration) {
|
||||
@@ -529,10 +414,7 @@ export class ChatIntegrationService {
|
||||
* through {@link disconnect} instead would relay a teardown to the leader and
|
||||
* stop a channel that is meant to keep running.
|
||||
*/
|
||||
async releaseChannelLocally(
|
||||
agentId: string,
|
||||
integration: { credentialId: string; type: string },
|
||||
): Promise<void> {
|
||||
async releaseChannelLocally(agentId: string, integration: IntegrationRef): Promise<void> {
|
||||
await this.disconnectLocal(agentId, integration, { skipExternalHooks: true });
|
||||
}
|
||||
|
||||
@@ -660,7 +542,7 @@ export class ChatIntegrationService {
|
||||
}
|
||||
|
||||
for (const integration of additions) {
|
||||
const key = this.connectionKey(agent.id, integration.type, integration.credentialId);
|
||||
const key = agentChannelKey(agentChannelRef(agent.id, integration));
|
||||
if (this.connections.has(key)) continue;
|
||||
|
||||
try {
|
||||
@@ -680,14 +562,9 @@ export class ChatIntegrationService {
|
||||
/**
|
||||
* Return the first live Chat instance for an agent, or undefined if not connected.
|
||||
*/
|
||||
getChatInstance(
|
||||
agentId: string,
|
||||
integration?: { type: string; credentialId: string },
|
||||
): ChatInstance | undefined {
|
||||
getChatInstance(agentId: string, integration?: IntegrationRef): ChatInstance | undefined {
|
||||
if (integration) {
|
||||
return this.connections.get(
|
||||
this.connectionKey(agentId, integration.type, integration.credentialId),
|
||||
)?.chat;
|
||||
return this.connections.get(agentChannelKey(agentChannelRef(agentId, integration)))?.chat;
|
||||
}
|
||||
return this.findConnection(agentId)?.chat;
|
||||
}
|
||||
@@ -705,7 +582,7 @@ export class ChatIntegrationService {
|
||||
// connections on one platform would otherwise reply into the wrong workspace.
|
||||
// No fallback in that case, for the same reason.
|
||||
if (credentialId !== undefined) {
|
||||
return this.connections.get(this.connectionKey(agentId, integrationType, credentialId))
|
||||
return this.connections.get(agentChannelKey({ agentId, integrationType, credentialId }))
|
||||
?.bridge;
|
||||
}
|
||||
return this.findConnection(agentId, integrationType, (c) => c.bridge !== undefined)?.bridge;
|
||||
@@ -734,7 +611,7 @@ export class ChatIntegrationService {
|
||||
* a follower must neither restart a channel the leader just started nor tear
|
||||
* down one it cannot see — but a caller needing certainty has to ask the leader.
|
||||
*/
|
||||
isChannelLive(agentId: string, integration: { type: string; credentialId: string }): boolean {
|
||||
isChannelLive(agentId: string, integration: IntegrationRef): boolean {
|
||||
if (this.shouldRouteToLeader(integration.type, true)) return true;
|
||||
return this.getChatInstance(agentId, integration) !== undefined;
|
||||
}
|
||||
@@ -745,24 +622,14 @@ export class ChatIntegrationService {
|
||||
*/
|
||||
async getChatInstanceForTools(
|
||||
agentId: string,
|
||||
integration: { type: string; credentialId: string },
|
||||
integration: IntegrationRef,
|
||||
): Promise<ChatInstance | undefined> {
|
||||
const live = this.getChatInstance(agentId, integration);
|
||||
if (live) return live;
|
||||
|
||||
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
|
||||
const handleInitializationError = (error: unknown) => {
|
||||
this.logger.warn(
|
||||
'[ChatIntegrationService] Could not initialize outbound integration for Preview',
|
||||
{
|
||||
agentId,
|
||||
type: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
error,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
const key = agentChannelKey(agentChannelRef(agentId, integration));
|
||||
const handleInitializationError = (error: unknown) =>
|
||||
this.reportOutboundError(agentId, integration, error);
|
||||
const agent = await this.agentRepository
|
||||
.findOne({ where: { id: agentId } })
|
||||
.catch(handleInitializationError);
|
||||
@@ -781,32 +648,16 @@ export class ChatIntegrationService {
|
||||
const outbound = this.outboundConnections.get(key)?.chat;
|
||||
if (outbound) return outbound;
|
||||
|
||||
const pending = this.outboundConnectionInitializations.get(key);
|
||||
if (pending) return await pending;
|
||||
|
||||
const initialization = this.connect(agentId, persistedIntegration, agent.projectId, {
|
||||
ingressEnabled: false,
|
||||
})
|
||||
.then(() => this.outboundConnections.get(key)?.chat)
|
||||
.catch(handleInitializationError);
|
||||
this.outboundConnectionInitializations.set(key, initialization);
|
||||
|
||||
try {
|
||||
return await initialization;
|
||||
} finally {
|
||||
if (this.outboundConnectionInitializations.get(key) === initialization) {
|
||||
this.outboundConnectionInitializations.delete(key);
|
||||
}
|
||||
}
|
||||
return await this.initializeOutboundConnection(key, agent, persistedIntegration);
|
||||
}
|
||||
|
||||
getShortenCallback(
|
||||
agentId: string,
|
||||
integration: { type: string; credentialId: string },
|
||||
integration: IntegrationRef,
|
||||
metadata?: CallbackMetadata,
|
||||
): ShortenCallback | undefined {
|
||||
return this.connections
|
||||
.get(this.connectionKey(agentId, integration.type, integration.credentialId))
|
||||
.get(agentChannelKey(agentChannelRef(agentId, integration)))
|
||||
?.bridge?.getShortenCallback(metadata);
|
||||
}
|
||||
|
||||
@@ -847,9 +698,7 @@ export class ChatIntegrationService {
|
||||
}
|
||||
|
||||
hasLiveChannel(ref: AgentChannelRef): boolean {
|
||||
return this.connections.has(
|
||||
this.connectionKey(ref.agentId, ref.integrationType, ref.credentialId),
|
||||
);
|
||||
return this.connections.has(agentChannelKey(ref));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -912,7 +761,7 @@ export class ChatIntegrationService {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.connectionKey(agentId, type, credentialId);
|
||||
const key = agentChannelKey({ agentId, integrationType: type, credentialId });
|
||||
if (this.connections.has(key)) return;
|
||||
|
||||
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
|
||||
@@ -953,35 +802,14 @@ export class ChatIntegrationService {
|
||||
payload: PubSubCommandMap['agent-chat-leader-channel-request'],
|
||||
): Promise<void> {
|
||||
const { agentId, integration, action } = payload;
|
||||
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
|
||||
const key = agentChannelKey(agentChannelRef(agentId, integration));
|
||||
|
||||
try {
|
||||
await this.runLeaderOperation(key, action, async () => {
|
||||
if (action === 'disconnect') {
|
||||
await this.disconnectLocal(agentId, integration);
|
||||
return;
|
||||
}
|
||||
|
||||
// No "already connected, nothing to do" shortcut: the connection key
|
||||
// excludes settings, so a settings-only save arrives on a live key and
|
||||
// has to rebuild — same as a local connect does. Duplicate requests are
|
||||
// deduped by `runLeaderOperation`, not by inspecting the runtime.
|
||||
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
|
||||
if (!agent) {
|
||||
throw new UnexpectedError(`Agent ${agentId} not found on the leader instance`);
|
||||
}
|
||||
|
||||
await this.connectLocal(agentId, integration, agent.projectId);
|
||||
|
||||
// Leadership can change during startup. A poller must not outlive our
|
||||
// term, and the requester has to hear that its channel is not running.
|
||||
if (!this.instanceSettings.isLeader) {
|
||||
await this.disconnectOne(key, { skipExternalHooks: true });
|
||||
throw new OperationalError(
|
||||
'This instance stopped being the leader while the channel was starting up',
|
||||
);
|
||||
}
|
||||
});
|
||||
await this.runLeaderOperation(
|
||||
key,
|
||||
action,
|
||||
async () => await this.applyLeaderChannelRequest(payload, key),
|
||||
);
|
||||
await this.leaderChannelRelay.respond(payload);
|
||||
} catch (error) {
|
||||
const failure = ensureError(error);
|
||||
@@ -996,14 +824,6 @@ export class ChatIntegrationService {
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private channelRef(agentId: string, integration: AgentIntegrationConfig): AgentChannelRef {
|
||||
return {
|
||||
agentId,
|
||||
integrationType: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this main has to hand the operation to the leader instead of running
|
||||
* it locally. Whether ingress makes a connection leader-bound is the
|
||||
@@ -1085,10 +905,10 @@ export class ChatIntegrationService {
|
||||
|
||||
private async disconnectLocal(
|
||||
agentId: string,
|
||||
integration: { credentialId: string; type: string },
|
||||
integration: IntegrationRef,
|
||||
options: DisconnectOptions = {},
|
||||
): Promise<void> {
|
||||
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
|
||||
const key = agentChannelKey(agentChannelRef(agentId, integration));
|
||||
await this.disconnectOne(key, options);
|
||||
await this.disconnectOutboundOne(key);
|
||||
}
|
||||
@@ -1132,18 +952,7 @@ export class ChatIntegrationService {
|
||||
// `onAfterConnect`, which runs after `chat.initialize()`. Errors are
|
||||
// logged but never re-thrown: local teardown must always complete so a
|
||||
// transient remote failure can't leak in-process resources.
|
||||
if (!options.skipExternalHooks) {
|
||||
const integration = this.integrationFromConnectionKey(key);
|
||||
if (integration?.onBeforeDisconnect) {
|
||||
try {
|
||||
await integration.onBeforeDisconnect(conn.context);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] onBeforeDisconnect failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!options.skipExternalHooks) await this.runBeforeDisconnectHook(key, conn.context);
|
||||
|
||||
try {
|
||||
await conn.chat.shutdown();
|
||||
@@ -1218,4 +1027,241 @@ export class ChatIntegrationService {
|
||||
? { skipExternalHooks, settings: integration.settings }
|
||||
: { skipExternalHooks };
|
||||
}
|
||||
|
||||
private async createConnectionContext(
|
||||
agentId: string,
|
||||
projectId: string,
|
||||
integration: AgentIntegrationConfig,
|
||||
ingressEnabled: boolean,
|
||||
): Promise<AgentChatIntegrationContext> {
|
||||
// Decrypt the integration credential to get platform tokens
|
||||
const decryptedData = await this.decryptCredentialForProject(
|
||||
integration.credentialId,
|
||||
projectId,
|
||||
);
|
||||
|
||||
return {
|
||||
agentId,
|
||||
projectId,
|
||||
integration,
|
||||
credentialId: integration.credentialId,
|
||||
credential: decryptedData,
|
||||
ingressEnabled,
|
||||
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
|
||||
};
|
||||
}
|
||||
|
||||
private async initializeConnection(
|
||||
ctx: AgentChatIntegrationContext,
|
||||
integrationImpl: AgentChatIntegration,
|
||||
options: ConnectOptions,
|
||||
key: string,
|
||||
): Promise<{ chat: ChatInstance; bridge?: AgentChatBridge }> {
|
||||
const { agentId, integration, ingressEnabled } = ctx;
|
||||
let state: StateAdapter | undefined;
|
||||
let chat!: ChatSdk;
|
||||
let bridge: AgentChatBridge | undefined;
|
||||
let initializeStarted = false;
|
||||
|
||||
// Initialize the Chat instance (connects adapters, state adapter, etc.) and
|
||||
// run post-initialize hooks (e.g. Telegram setWebhook) once it is live.
|
||||
// If setup throws after registering subscription state but before
|
||||
// initialization starts, disconnect the state directly. Once initialize()
|
||||
// starts, chat.shutdown() owns cleanup for adapters, timers, and state.
|
||||
try {
|
||||
// Delegate adapter construction to the platform implementation.
|
||||
const adapter = recordAdapterCalls(
|
||||
integration.type,
|
||||
await integrationImpl.createAdapter(ctx),
|
||||
);
|
||||
channelIntegrationRecorder.startFetchRecording();
|
||||
|
||||
// Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform
|
||||
const { Chat } = await loadChatSdk();
|
||||
const { createMemoryState } = await loadMemoryState();
|
||||
|
||||
const memoryState = createMemoryState();
|
||||
state = ingressEnabled
|
||||
? this.chatSubscriptionStateService.createStateAdapter({
|
||||
agentId,
|
||||
integration,
|
||||
delegate: memoryState,
|
||||
})
|
||||
: memoryState;
|
||||
|
||||
chat = new Chat({
|
||||
userName: `n8n-agent-${agentId}`,
|
||||
// Use the platform type as the adapter key (e.g. 'slack') so that
|
||||
// bot.webhooks.slack maps correctly to the handler.
|
||||
adapters: { [integration.type]: adapter } as Record<string, never>,
|
||||
state,
|
||||
});
|
||||
|
||||
if (ingressEnabled) bridge = await this.createChatBridge(chat, ctx);
|
||||
|
||||
initializeStarted = true;
|
||||
await chat.initialize();
|
||||
|
||||
if (ingressEnabled && integrationImpl.onAfterConnect && !options.skipExternalHooks) {
|
||||
await integrationImpl.onAfterConnect(ctx);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.cleanupFailedConnection(chat, state, initializeStarted);
|
||||
// Mirror of the `onConnected` call below. A platform that stashed
|
||||
// per-connection state during `createAdapter` — Discord keeps the
|
||||
// decrypted bot token there — must get the chance to release it, or a
|
||||
// failed connect strands it for the life of the process.
|
||||
await this.runDisconnectedHook(integrationImpl, ctx, `${key} after failed connect`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The `chat` variable is returned by `new Chat(...)` from the ESM-only
|
||||
// package. Its runtime shape matches our local `ChatInstance` interface.
|
||||
// We validate the required methods exist before storing.
|
||||
return { chat: chat as ChatInstance, bridge };
|
||||
}
|
||||
|
||||
private async createChatBridge(
|
||||
chat: ChatSdk,
|
||||
ctx: AgentChatIntegrationContext,
|
||||
): Promise<AgentChatBridge> {
|
||||
const { agentId, projectId, integration } = ctx;
|
||||
const componentMapper = new ComponentMapper();
|
||||
const agentExecutionOrchestratorService = await getAgentExecutionOrchestratorService();
|
||||
|
||||
return AgentChatBridge.create(
|
||||
chat,
|
||||
agentId,
|
||||
agentExecutionOrchestratorService,
|
||||
componentMapper,
|
||||
this.logger,
|
||||
projectId,
|
||||
integration,
|
||||
);
|
||||
}
|
||||
|
||||
private async cleanupFailedConnection(
|
||||
chat: ChatSdk,
|
||||
state: StateAdapter | undefined,
|
||||
initializeStarted: boolean,
|
||||
): Promise<void> {
|
||||
if (initializeStarted) {
|
||||
await chat.shutdown().catch((shutdownError: unknown) => {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] Shutdown after failed connect threw: ${shutdownError instanceof Error ? shutdownError.message : String(shutdownError)}`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await state?.disconnect().catch((disconnectError: unknown) => {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] State cleanup after failed setup threw: ${disconnectError instanceof Error ? disconnectError.message : String(disconnectError)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async runConnectedHook(
|
||||
integrationImpl: AgentChatIntegration,
|
||||
ctx: AgentChatIntegrationContext,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
if (!integrationImpl.onConnected) return;
|
||||
try {
|
||||
await integrationImpl.onConnected(ctx);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] onConnected failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private reportOutboundError(
|
||||
agentId: string,
|
||||
integration: IntegrationRef,
|
||||
error: unknown,
|
||||
): undefined {
|
||||
this.logger.warn(
|
||||
'[ChatIntegrationService] Could not initialize outbound integration for Preview',
|
||||
{
|
||||
agentId,
|
||||
type: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
error,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async initializeOutboundConnection(
|
||||
key: string,
|
||||
agent: Agent,
|
||||
integration: AgentIntegrationConfig,
|
||||
): Promise<ChatInstance | undefined> {
|
||||
const agentId = agent.id;
|
||||
const handleInitializationError = (error: unknown) =>
|
||||
this.reportOutboundError(agentId, integration, error);
|
||||
const pending = this.outboundConnectionInitializations.get(key);
|
||||
if (pending) return await pending;
|
||||
|
||||
const initialization = this.connect(agentId, integration, agent.projectId, {
|
||||
ingressEnabled: false,
|
||||
})
|
||||
.then(() => this.outboundConnections.get(key)?.chat)
|
||||
.catch(handleInitializationError);
|
||||
this.outboundConnectionInitializations.set(key, initialization);
|
||||
|
||||
try {
|
||||
return await initialization;
|
||||
} finally {
|
||||
if (this.outboundConnectionInitializations.get(key) === initialization) {
|
||||
this.outboundConnectionInitializations.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyLeaderChannelRequest(
|
||||
{ agentId, integration, action }: PubSubCommandMap['agent-chat-leader-channel-request'],
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
if (action === 'disconnect') {
|
||||
await this.disconnectLocal(agentId, integration);
|
||||
return;
|
||||
}
|
||||
|
||||
// No "already connected, nothing to do" shortcut: the connection key
|
||||
// excludes settings, so a settings-only save arrives on a live key and
|
||||
// has to rebuild — same as a local connect does. Duplicate requests are
|
||||
// deduped by `runLeaderOperation`, not by inspecting the runtime.
|
||||
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
|
||||
if (!agent) {
|
||||
throw new UnexpectedError(`Agent ${agentId} not found on the leader instance`);
|
||||
}
|
||||
|
||||
await this.connectLocal(agentId, integration, agent.projectId);
|
||||
|
||||
// Leadership can change during startup. A poller must not outlive our
|
||||
// term, and the requester has to hear that its channel is not running.
|
||||
if (!this.instanceSettings.isLeader) {
|
||||
await this.disconnectOne(key, { skipExternalHooks: true });
|
||||
throw new OperationalError(
|
||||
'This instance stopped being the leader while the channel was starting up',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runBeforeDisconnectHook(
|
||||
key: string,
|
||||
context: AgentChatIntegrationContext,
|
||||
): Promise<void> {
|
||||
const integration = this.integrationFromConnectionKey(key);
|
||||
if (!integration?.onBeforeDisconnect) return;
|
||||
try {
|
||||
await integration.onBeforeDisconnect(context);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] onBeforeDisconnect failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
rateLimitExceeded,
|
||||
} from './integration-helpers';
|
||||
import type {
|
||||
IntegrationAction,
|
||||
IntegrationActionExecutor,
|
||||
IntegrationActionParams,
|
||||
IntegrationActionResult,
|
||||
IntegrationMessageContext,
|
||||
IntegrationToolConnectionDescriptor,
|
||||
@@ -73,15 +73,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
private readonly channelRateLimitGuard: ChannelRateLimitGuard,
|
||||
) {}
|
||||
|
||||
async execute(params: {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
action: IntegrationAction;
|
||||
input: Record<string, unknown>;
|
||||
awaitResponse: boolean;
|
||||
runId?: string;
|
||||
toolCallId?: string;
|
||||
currentMessageContext?: IntegrationMessageContext;
|
||||
}): Promise<IntegrationActionResult> {
|
||||
async execute(params: IntegrationActionParams): Promise<IntegrationActionResult> {
|
||||
if (!params.descriptor.agentId) return connectionUnavailable();
|
||||
|
||||
if (params.action === 'do_not_respond') {
|
||||
@@ -123,11 +115,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
const { credentialId } = params.descriptor.integration;
|
||||
if (!credentialId) return connectionUnavailable();
|
||||
|
||||
let chat = this.chatIntegrationService.getChatInstance(params.descriptor.agentId, {
|
||||
type: params.descriptor.integration.type,
|
||||
credentialId,
|
||||
});
|
||||
chat ??= await this.chatIntegrationService.getChatInstanceForTools(
|
||||
const chat = await this.chatIntegrationService.getChatInstanceForTools(
|
||||
params.descriptor.agentId,
|
||||
params.descriptor.integration,
|
||||
);
|
||||
@@ -178,7 +166,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
* End the turn without posting anything. Allowed only when the latest
|
||||
* inbound message marked the reply as optional.
|
||||
*/
|
||||
private doNotRespond(params: ExecuteParams): IntegrationActionResult {
|
||||
private doNotRespond(params: IntegrationActionParams): IntegrationActionResult {
|
||||
if (params.currentMessageContext?.replyExpectation !== 'optional') {
|
||||
return integrationError(
|
||||
INTEGRATION_ERROR_CODES.REPLY_REQUIRED,
|
||||
@@ -195,7 +183,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
|
||||
private async addReactionToMessage(
|
||||
chat: ChatInstance,
|
||||
params: ExecuteParams,
|
||||
params: IntegrationActionParams,
|
||||
): Promise<IntegrationActionResult> {
|
||||
const adapter = chat.getAdapter(params.descriptor.integration.type);
|
||||
if (!supportsAddReaction(adapter)) {
|
||||
@@ -246,7 +234,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
|
||||
private async respondInCurrentThread(
|
||||
chat: ChatInstance,
|
||||
params: ExecuteParams,
|
||||
params: IntegrationActionParams,
|
||||
): Promise<IntegrationActionResult> {
|
||||
const input = respondInputSchema.parse(params.input);
|
||||
const threadId = params.currentMessageContext?.target.threadId;
|
||||
@@ -284,7 +272,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
|
||||
private async sendDirectMessage(
|
||||
chat: ChatInstance,
|
||||
params: ExecuteParams,
|
||||
params: IntegrationActionParams,
|
||||
): Promise<IntegrationActionResult> {
|
||||
const input = sendDmInputSchema.parse(params.input);
|
||||
const thread = await chat.openDM(input.userId);
|
||||
@@ -308,7 +296,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
|
||||
private async editMessageInCurrentThread(
|
||||
chat: ChatInstance,
|
||||
params: ExecuteParams,
|
||||
params: IntegrationActionParams,
|
||||
): Promise<IntegrationActionResult> {
|
||||
const input = editMessageInputSchema.parse(params.input);
|
||||
const currentMessageContext = params.currentMessageContext;
|
||||
@@ -346,7 +334,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
|
||||
|
||||
private async sendChannelMessage(
|
||||
chat: ChatInstance,
|
||||
params: ExecuteParams,
|
||||
params: IntegrationActionParams,
|
||||
): Promise<IntegrationActionResult> {
|
||||
const input = sendChannelMessageInputSchema.parse(params.input);
|
||||
const channelId = normalizePlatformId(params.descriptor.integration.type, input.channelId);
|
||||
@@ -453,16 +441,6 @@ function supportsAddReaction(adapter: unknown): adapter is Pick<Adapter, 'addRea
|
||||
return isRecord(adapter) && typeof adapter.addReaction === 'function';
|
||||
}
|
||||
|
||||
interface ExecuteParams {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
action: IntegrationAction;
|
||||
input: Record<string, unknown>;
|
||||
awaitResponse: boolean;
|
||||
runId?: string;
|
||||
toolCallId?: string;
|
||||
currentMessageContext?: IntegrationMessageContext;
|
||||
}
|
||||
|
||||
function buildMessageContextFromSentMessage(params: {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
sent: SentMessage;
|
||||
|
||||
@@ -5,9 +5,8 @@ import { ChatIntegrationService } from './chat-integration.service';
|
||||
import { INTEGRATION_ERROR_CODES } from './integration-error-codes';
|
||||
import { connectionUnavailable, integrationError, rateLimitExceeded } from './integration-helpers';
|
||||
import type {
|
||||
IntegrationContextQuery,
|
||||
IntegrationContextQueryExecutor,
|
||||
IntegrationToolConnectionDescriptor,
|
||||
IntegrationContextQueryParams,
|
||||
} from './integration-tools';
|
||||
import { ChannelRateLimitGuard } from './channel-rate-limit.guard';
|
||||
import { caughtIntegrationError, channelRateLimitMessage } from './channel-rate-limit';
|
||||
@@ -25,12 +24,7 @@ export class ChatIntegrationContextQueryExecutor implements IntegrationContextQu
|
||||
private readonly channelRateLimitGuard: ChannelRateLimitGuard,
|
||||
) {}
|
||||
|
||||
async execute(params: {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
query: IntegrationContextQuery;
|
||||
input: Record<string, unknown>;
|
||||
persistence?: { threadId: string; resourceId: string };
|
||||
}): Promise<unknown> {
|
||||
async execute(params: IntegrationContextQueryParams): Promise<unknown> {
|
||||
if (!params.descriptor.agentId) return connectionUnavailable();
|
||||
|
||||
if (this.channelRateLimitGuard.isBlocked(params.descriptor.integrationConnectionId)) {
|
||||
@@ -65,11 +59,7 @@ export class ChatIntegrationContextQueryExecutor implements IntegrationContextQu
|
||||
const { credentialId } = params.descriptor.integration;
|
||||
if (!credentialId) return connectionUnavailable();
|
||||
|
||||
let chat = this.chatIntegrationService.getChatInstance(params.descriptor.agentId, {
|
||||
type: params.descriptor.integration.type,
|
||||
credentialId,
|
||||
});
|
||||
chat ??= await this.chatIntegrationService.getChatInstanceForTools(
|
||||
const chat = await this.chatIntegrationService.getChatInstanceForTools(
|
||||
params.descriptor.agentId,
|
||||
params.descriptor.integration,
|
||||
);
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
import { INTEGRATION_ERROR_CODES, type IntegrationErrorCode } from './integration-error-codes';
|
||||
import type { IntegrationActionResult } from './integration-tool-types';
|
||||
|
||||
export interface IntegrationErrorResponse {
|
||||
ok: false;
|
||||
error: {
|
||||
code: IntegrationErrorCode;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
export type IntegrationErrorResponse = Extract<IntegrationActionResult, { ok: false }>;
|
||||
|
||||
export function integrationError(
|
||||
code: IntegrationErrorCode,
|
||||
|
||||
+3
-12
@@ -130,7 +130,7 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
|
||||
await this.threadRepository.save(existing);
|
||||
return;
|
||||
}
|
||||
await this.ensureResource(origin.resourceId);
|
||||
await this.resourceRepository.ensureExists(origin.resourceId);
|
||||
await this.threadRepository.save(
|
||||
this.threadRepository.create({
|
||||
id: derivedThreadId,
|
||||
@@ -154,7 +154,7 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
|
||||
await this.threadRepository.save(existing);
|
||||
return;
|
||||
}
|
||||
await this.ensureResource(originThreadId);
|
||||
await this.resourceRepository.ensureExists(originThreadId);
|
||||
await this.threadRepository.save(
|
||||
this.threadRepository.create({
|
||||
id: originThreadId,
|
||||
@@ -182,7 +182,7 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureResource(resourceId ?? threadId);
|
||||
await this.resourceRepository.ensureExists(resourceId ?? threadId);
|
||||
await this.threadRepository.save(
|
||||
this.threadRepository.create({
|
||||
id: threadId,
|
||||
@@ -193,15 +193,6 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureResource(resourceId: string): Promise<void> {
|
||||
const exists = await this.resourceRepository.existsBy({ id: resourceId });
|
||||
if (!exists) {
|
||||
await this.resourceRepository.save(
|
||||
this.resourceRepository.create({ id: resourceId, metadata: null }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseMetadata(value: string | null | undefined): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
|
||||
@@ -170,25 +170,29 @@ export interface SessionBinding {
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export interface IntegrationContextQueryParams {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
query: IntegrationContextQuery;
|
||||
input: Record<string, unknown>;
|
||||
persistence?: { threadId: string; resourceId: string };
|
||||
}
|
||||
|
||||
export interface IntegrationContextQueryExecutor {
|
||||
execute(params: {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
query: IntegrationContextQuery;
|
||||
input: Record<string, unknown>;
|
||||
persistence?: { threadId: string; resourceId: string };
|
||||
}): Promise<unknown>;
|
||||
execute(params: IntegrationContextQueryParams): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IntegrationActionParams {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
action: IntegrationAction;
|
||||
input: Record<string, unknown>;
|
||||
awaitResponse: boolean;
|
||||
runId?: string;
|
||||
toolCallId?: string;
|
||||
currentMessageContext?: IntegrationMessageContext;
|
||||
}
|
||||
|
||||
export interface IntegrationActionExecutor {
|
||||
execute(params: {
|
||||
descriptor: IntegrationToolConnectionDescriptor;
|
||||
action: IntegrationAction;
|
||||
input: Record<string, unknown>;
|
||||
awaitResponse: boolean;
|
||||
runId?: string;
|
||||
toolCallId?: string;
|
||||
currentMessageContext?: IntegrationMessageContext;
|
||||
}): Promise<IntegrationActionResult>;
|
||||
execute(params: IntegrationActionParams): Promise<IntegrationActionResult>;
|
||||
}
|
||||
|
||||
export type IntegrationActionResult =
|
||||
|
||||
@@ -59,7 +59,6 @@ import type { AgentMessageEntity } from '../entities/agent-message.entity';
|
||||
import { AgentObservationCursorEntity } from '../entities/agent-observation-cursor.entity';
|
||||
import { AgentObservationLockEntity } from '../entities/agent-observation-lock.entity';
|
||||
import { AgentObservationEntity } from '../entities/agent-observation.entity';
|
||||
import { AgentResourceEntity } from '../entities/agent-resource.entity';
|
||||
import { AgentThreadEntity } from '../entities/agent-thread.entity';
|
||||
import { AgentMemoryEntryCandidateRepository } from '../repositories/agent-memory-entry-candidate.repository';
|
||||
import { AgentMemoryEntryLockRepository } from '../repositories/agent-memory-entry-lock.repository';
|
||||
@@ -165,7 +164,7 @@ export class N8nMemoryImpl
|
||||
}
|
||||
|
||||
async saveThread(thread: Omit<Thread, 'createdAt' | 'updatedAt'>): Promise<Thread> {
|
||||
await this.ensureResource(thread.resourceId);
|
||||
await this.resourceRepository.ensureExists(thread.resourceId);
|
||||
|
||||
const existing = await this.threadRepository.findOneBy({ id: thread.id });
|
||||
|
||||
@@ -194,17 +193,6 @@ export class N8nMemoryImpl
|
||||
return this.toThread(saved);
|
||||
}
|
||||
|
||||
private async ensureResource(resourceId: string): Promise<void> {
|
||||
// Two callers can create a new thread scope at the same time. Ignore the loser.
|
||||
await this.resourceRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(AgentResourceEntity)
|
||||
.values({ id: resourceId, metadata: null })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteThread(threadId: string, ctx: OperationContext = {}): Promise<void> {
|
||||
await this.threadRepository.runInTransaction(ctx, async (trx) => {
|
||||
await this.dropEpisodicEntriesWithoutSources(trx, threadId);
|
||||
@@ -602,7 +590,7 @@ export class N8nMemoryImpl
|
||||
candidate: NewEpisodicMemoryCaptureCandidate,
|
||||
): Promise<EpisodicMemoryCaptureCandidate> {
|
||||
const resourceId = episodicMemoryWriteScopeId(candidate);
|
||||
await this.ensureResource(resourceId);
|
||||
await this.resourceRepository.ensureExists(resourceId);
|
||||
const entity = await this.memoryEntryCandidateRepository.enqueueCandidate({
|
||||
agentId: this.agentId,
|
||||
...candidate,
|
||||
@@ -643,7 +631,7 @@ export class N8nMemoryImpl
|
||||
opts: { ttlMs: number; holderId: string },
|
||||
): Promise<EpisodicMemoryTaskLockHandle | null> {
|
||||
const resourceId = episodicMemoryWriteScopeId(scope);
|
||||
await this.ensureResource(resourceId);
|
||||
await this.resourceRepository.ensureExists(resourceId);
|
||||
|
||||
const now = new Date();
|
||||
const heldUntil = new Date(now.getTime() + opts.ttlMs);
|
||||
@@ -700,7 +688,7 @@ export class N8nMemoryImpl
|
||||
const resourceId = sourceThreadId
|
||||
? episodicMemoryWriteScopeId({ resourceId: entry.resourceId, threadId: sourceThreadId })
|
||||
: entry.resourceId;
|
||||
await this.ensureResource(resourceId);
|
||||
await this.resourceRepository.ensureExists(resourceId);
|
||||
|
||||
return await this.memoryEntryRepository.manager.transaction(async (trx) => {
|
||||
const entryRepo = trx.getRepository(AgentMemoryEntryEntity);
|
||||
|
||||
+239
-211
@@ -12,7 +12,6 @@ import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
@@ -27,6 +26,7 @@ import { CacheService } from '@/services/cache/cache.service';
|
||||
import { SlackMethodsService } from './slack-methods.service';
|
||||
import {
|
||||
childRecord,
|
||||
parseSlackAppSetupResponse,
|
||||
managedSlackAppCacheKey,
|
||||
SLACK_APP_SETUP_TTL_MS,
|
||||
SLACK_BOT_SCOPES,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from './slack-setup.types';
|
||||
import type { Agent } from '../../../entities/agent.entity';
|
||||
import { AgentRepository } from '../../../repositories/agent.repository';
|
||||
import type { AgentIntegrationRemovalContext } from '../../agent-chat-integration';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const SLACK_MANAGER_CREDENTIAL_TYPE = 'slackManagerOAuth2Api';
|
||||
@@ -79,14 +80,6 @@ export interface UpdateManagedSlackAppSettingsOptions extends GetManagedSlackApp
|
||||
alwaysOnline: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteManagedSlackAppOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
credentialId: string;
|
||||
user: User;
|
||||
deleteExternalResource?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedSlackAppDeletionWarning {
|
||||
integrationType: 'slack';
|
||||
code: 'app_not_deleted';
|
||||
@@ -144,23 +137,10 @@ function stringsFromScope(value: unknown): Set<string> {
|
||||
return new Set(value.split(/[\s,]+/).filter(Boolean));
|
||||
}
|
||||
|
||||
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
|
||||
const keys: Array<keyof SlackAppSetupSession> = [
|
||||
'projectId',
|
||||
'agentId',
|
||||
'userId',
|
||||
'appId',
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'signingSecret',
|
||||
'redirectUrl',
|
||||
];
|
||||
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
|
||||
}
|
||||
|
||||
function hasManagedSessionShape(value: unknown): value is ManagedSlackAppSession {
|
||||
function hasManagedSessionShape(
|
||||
value: SlackAppSetupSession | undefined,
|
||||
): value is ManagedSlackAppSession {
|
||||
return (
|
||||
hasSessionShape(value) &&
|
||||
isRecord(value) &&
|
||||
typeof value.managerCredentialId === 'string' &&
|
||||
typeof value.teamId === 'string' &&
|
||||
@@ -211,39 +191,12 @@ export class SlackManagedSetupService {
|
||||
|
||||
for (const usableCredential of usableCredentials) {
|
||||
if (usableCredential.type !== SLACK_MANAGER_CREDENTIAL_TYPE) continue;
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
const summary = await this.getManagerCredentialSummary(
|
||||
usableCredential.id,
|
||||
integrations,
|
||||
options.user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential) continue;
|
||||
|
||||
const rawData = await this.credentialsService.decrypt(credential, true);
|
||||
if (!this.usesManagedSlackAuth(rawData)) continue;
|
||||
const oauthTokenData = childRecord(rawData, 'oauthTokenData');
|
||||
const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined;
|
||||
const accessToken = stringProperty(authedUser, 'access_token');
|
||||
const grantedScopes = stringsFromScope(
|
||||
stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'),
|
||||
);
|
||||
const reconnectRequired =
|
||||
!!accessToken && REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope));
|
||||
const workspaces =
|
||||
accessToken && oauthTokenData
|
||||
? await this.getWorkspacesFromContext(
|
||||
{ credential, rawData, oauthTokenData, accessToken },
|
||||
integrations,
|
||||
options.user,
|
||||
false,
|
||||
)
|
||||
: [];
|
||||
managerCredentials.push({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
connected: !!accessToken,
|
||||
reconnectRequired,
|
||||
workspaces,
|
||||
});
|
||||
if (summary) managerCredentials.push(summary);
|
||||
}
|
||||
|
||||
return { managedSetupAvailable: true, managerCredentials };
|
||||
@@ -315,20 +268,7 @@ export class SlackManagedSetupService {
|
||||
return { status: 'connected', appId: existing.appId, credentialId: existing.credentialId };
|
||||
}
|
||||
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
options.managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const workspaces = await this.getWorkspacesFromContext(
|
||||
manager,
|
||||
agent.integrations ?? [],
|
||||
options.user,
|
||||
);
|
||||
const workspace = workspaces.find(({ id }) => id === options.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundError('Slack workspace is not available to this credential');
|
||||
}
|
||||
const { manager, workspace } = await this.getInstallWorkspace(options, agent);
|
||||
|
||||
const { session, created } = await this.getOrCreateManagedAppSession(
|
||||
options,
|
||||
@@ -342,22 +282,8 @@ export class SlackManagedSetupService {
|
||||
bot_scopes: SLACK_BOT_SCOPES.join(','),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const botAccessToken = stringProperty(
|
||||
childRecord(response, 'api_access_tokens'),
|
||||
'bot_access_token',
|
||||
);
|
||||
if (!botAccessToken?.startsWith('xoxb-')) {
|
||||
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
|
||||
}
|
||||
const credentialId = await this.methods.connectBotCredential(
|
||||
agent,
|
||||
options.user,
|
||||
botAccessToken,
|
||||
session,
|
||||
);
|
||||
return { status: 'connected', appId: session.appId, credentialId };
|
||||
}
|
||||
if (response.ok)
|
||||
return await this.connectManagedInstallation(options, agent, session, response);
|
||||
|
||||
const error = stringProperty(response, 'error') ?? 'unknown_error';
|
||||
if (MANAGED_INSTALL_APPROVAL_ERRORS.has(error)) {
|
||||
@@ -365,16 +291,7 @@ export class SlackManagedSetupService {
|
||||
}
|
||||
const responseOauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
if (responseOauthAuthorizeUrl || MANAGED_INSTALL_FALLBACK_ERRORS.has(error)) {
|
||||
const oauthAuthorizeUrl = responseOauthAuthorizeUrl ?? session.oauthAuthorizeUrl;
|
||||
const teamId = stringProperty(response, 'team_id') ?? session.teamId;
|
||||
const updatedSession: ManagedSlackAppSession = { ...session, oauthAuthorizeUrl, teamId };
|
||||
const state = randomBytes(32).toString('hex');
|
||||
await this.methods.storeSession(state, updatedSession);
|
||||
return {
|
||||
status: 'manual_install_required',
|
||||
appId: session.appId,
|
||||
installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, session.redirectUrl),
|
||||
};
|
||||
return await this.prepareManualInstall(session, response, responseOauthAuthorizeUrl);
|
||||
}
|
||||
|
||||
if (created) {
|
||||
@@ -449,7 +366,7 @@ export class SlackManagedSetupService {
|
||||
}
|
||||
|
||||
async deleteAppForCredential(
|
||||
options: DeleteManagedSlackAppOptions,
|
||||
options: AgentIntegrationRemovalContext,
|
||||
): Promise<ManagedSlackAppDeletionWarning | undefined> {
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
options.credentialId,
|
||||
@@ -468,30 +385,7 @@ export class SlackManagedSetupService {
|
||||
|
||||
let warning: ManagedSlackAppDeletionWarning | undefined;
|
||||
if (managedAppId && managerCredentialId && shouldDeleteExternalResource) {
|
||||
try {
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.manifest.delete', {
|
||||
app_id: managedAppId,
|
||||
});
|
||||
if (!response.ok && stringProperty(response, 'error') !== 'app_not_found') {
|
||||
throw this.methods.slackError('delete the Slack app', response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof NotFoundError)) throw error;
|
||||
warning = {
|
||||
integrationType: 'slack',
|
||||
code: 'app_not_deleted',
|
||||
action: {
|
||||
type: 'open_url',
|
||||
url: `https://api.slack.com/apps/${encodeURIComponent(managedAppId)}`,
|
||||
},
|
||||
details: { appId: managedAppId },
|
||||
};
|
||||
}
|
||||
warning = await this.deleteManagedExternalApp(options, managedAppId, managerCredentialId);
|
||||
}
|
||||
|
||||
if (managerCredentialId && typeof data.teamId === 'string') {
|
||||
@@ -641,34 +535,8 @@ export class SlackManagedSetupService {
|
||||
user: User,
|
||||
allowRefresh = true,
|
||||
): Promise<SlackManagedWorkspaceSummary[]> {
|
||||
const team = childRecord(manager.oauthTokenData, 'team');
|
||||
const enterprise = childRecord(manager.oauthTokenData, 'enterprise');
|
||||
const isEnterpriseInstall = manager.oauthTokenData.is_enterprise_install === true;
|
||||
const workspaceRecords: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (isEnterpriseInstall) {
|
||||
let cursor = '';
|
||||
do {
|
||||
const response = allowRefresh
|
||||
? await this.callManagerSlackApi(manager, 'auth.teams.list', {
|
||||
limit: '100',
|
||||
...(cursor ? { cursor } : {}),
|
||||
})
|
||||
: await this.methods.callSlackApi('auth.teams.list', {
|
||||
token: manager.accessToken,
|
||||
limit: '100',
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
if (!response.ok) break;
|
||||
if (Array.isArray(response.teams)) {
|
||||
workspaceRecords.push(...response.teams.filter(isRecord));
|
||||
}
|
||||
cursor =
|
||||
stringProperty(childRecord(response, 'response_metadata'), 'next_cursor')?.trim() ?? '';
|
||||
} while (cursor);
|
||||
} else if (team) {
|
||||
workspaceRecords.push(team);
|
||||
}
|
||||
const workspaceRecords = await this.listWorkspaceRecords(manager, allowRefresh);
|
||||
|
||||
const enterpriseId = stringProperty(enterprise, 'id');
|
||||
const result: SlackManagedWorkspaceSummary[] = [];
|
||||
@@ -732,8 +600,8 @@ export class SlackManagedSetupService {
|
||||
const key = managedSlackAppCacheKey({ ...options, userId: options.user.id });
|
||||
const cached = await this.cacheService.get<unknown>(key);
|
||||
if (typeof cached === 'string') {
|
||||
const session = await this.decryptManagedAppSession(cached);
|
||||
if (session) {
|
||||
const session = await this.methods.decodeSession(cached);
|
||||
if (hasManagedSessionShape(session)) {
|
||||
return {
|
||||
session: { ...session, teamName: session.teamName ?? workspaceName },
|
||||
created: false,
|
||||
@@ -743,6 +611,206 @@ export class SlackManagedSetupService {
|
||||
await this.cacheService.delete(key);
|
||||
}
|
||||
|
||||
return await this.createManagedAppSession(options, agent, manager, workspaceName, key);
|
||||
}
|
||||
|
||||
private async callManagerSlackApi<T extends { [key: string]: unknown }>(
|
||||
manager: ManagerCredentialContext,
|
||||
method: string,
|
||||
params: Record<string, string> | SlackApiParamsFactory,
|
||||
): Promise<({ ok: true } & T) | { ok: false; error: string }> {
|
||||
const paramsForToken = (accessToken: string): SlackApiParams =>
|
||||
typeof params === 'function' ? params(accessToken) : { ...params, token: accessToken };
|
||||
const response = await this.methods.callSlackApi(method, paramsForToken(manager.accessToken));
|
||||
const error = stringProperty(response, 'error');
|
||||
if (!['invalid_auth', 'token_expired'].includes(error ?? '')) return response;
|
||||
|
||||
const accessToken = await this.refreshManagerAccessToken(manager);
|
||||
if (!accessToken) return response;
|
||||
return await this.methods.callSlackApi(method, paramsForToken(accessToken));
|
||||
}
|
||||
|
||||
private async setManagedAppIcon(
|
||||
manager: ManagerCredentialContext,
|
||||
managedAppId: string,
|
||||
): Promise<void> {
|
||||
const image = await readFile(join(__dirname, 'assets', 'n8n-bot-icon.png'));
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.icon.set', (accessToken) => {
|
||||
const formData = new FormData();
|
||||
formData.set('token', accessToken);
|
||||
formData.set('app_id', managedAppId);
|
||||
formData.set(
|
||||
'file',
|
||||
new Blob([new Uint8Array(image)], { type: 'image/png' }),
|
||||
'n8n-bot-icon.png',
|
||||
);
|
||||
return formData;
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw this.methods.slackError('set the Slack app icon', response);
|
||||
}
|
||||
}
|
||||
|
||||
private async getManagerCredentialSummary(
|
||||
credentialId: string,
|
||||
integrations: AgentIntegrationConfig[],
|
||||
user: User,
|
||||
): Promise<SlackManagerCredentialSummary | undefined> {
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
credentialId,
|
||||
user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential) return undefined;
|
||||
|
||||
const rawData = await this.credentialsService.decrypt(credential, true);
|
||||
if (!this.usesManagedSlackAuth(rawData)) return undefined;
|
||||
const oauthTokenData = childRecord(rawData, 'oauthTokenData');
|
||||
const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined;
|
||||
const accessToken = stringProperty(authedUser, 'access_token');
|
||||
const grantedScopes = stringsFromScope(
|
||||
stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'),
|
||||
);
|
||||
const reconnectRequired =
|
||||
!!accessToken && REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope));
|
||||
const workspaces =
|
||||
accessToken && oauthTokenData
|
||||
? await this.getWorkspacesFromContext(
|
||||
{ credential, rawData, oauthTokenData, accessToken },
|
||||
integrations,
|
||||
user,
|
||||
false,
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
connected: !!accessToken,
|
||||
reconnectRequired,
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
private async listWorkspaceRecords(manager: ManagerCredentialContext, allowRefresh: boolean) {
|
||||
const team = childRecord(manager.oauthTokenData, 'team');
|
||||
if (manager.oauthTokenData.is_enterprise_install !== true) return team ? [team] : [];
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
let cursor = '';
|
||||
do {
|
||||
const params = { limit: '100', ...(cursor ? { cursor } : {}) };
|
||||
let response;
|
||||
if (allowRefresh) {
|
||||
response = await this.callManagerSlackApi(manager, 'auth.teams.list', params);
|
||||
} else {
|
||||
response = await this.methods.callSlackApi('auth.teams.list', {
|
||||
token: manager.accessToken,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
if (!response.ok) break;
|
||||
if (Array.isArray(response.teams)) records.push(...response.teams.filter(isRecord));
|
||||
cursor =
|
||||
stringProperty(childRecord(response, 'response_metadata'), 'next_cursor')?.trim() ?? '';
|
||||
} while (cursor);
|
||||
return records;
|
||||
}
|
||||
|
||||
private async getInstallWorkspace(options: InstallManagedSlackAppOptions, agent: Agent) {
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
options.managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const workspaces = await this.getWorkspacesFromContext(
|
||||
manager,
|
||||
agent.integrations ?? [],
|
||||
options.user,
|
||||
);
|
||||
const workspace = workspaces.find(({ id }) => id === options.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundError('Slack workspace is not available to this credential');
|
||||
}
|
||||
return { manager, workspace };
|
||||
}
|
||||
|
||||
private async connectManagedInstallation(
|
||||
options: InstallManagedSlackAppOptions,
|
||||
agent: Agent,
|
||||
session: ManagedSlackAppSession,
|
||||
response: Record<string, unknown>,
|
||||
): Promise<InstallSlackManagedAppResponse> {
|
||||
const botAccessToken = stringProperty(
|
||||
childRecord(response, 'api_access_tokens'),
|
||||
'bot_access_token',
|
||||
);
|
||||
if (!botAccessToken?.startsWith('xoxb-')) {
|
||||
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
|
||||
}
|
||||
const credentialId = await this.methods.connectBotCredential(
|
||||
agent,
|
||||
options.user,
|
||||
botAccessToken,
|
||||
session,
|
||||
);
|
||||
return { status: 'connected', appId: session.appId, credentialId };
|
||||
}
|
||||
|
||||
private async prepareManualInstall(
|
||||
session: ManagedSlackAppSession,
|
||||
response: Record<string, unknown>,
|
||||
responseOauthAuthorizeUrl: string | undefined,
|
||||
): Promise<InstallSlackManagedAppResponse> {
|
||||
const oauthAuthorizeUrl = responseOauthAuthorizeUrl ?? session.oauthAuthorizeUrl;
|
||||
const teamId = stringProperty(response, 'team_id') ?? session.teamId;
|
||||
const updatedSession: ManagedSlackAppSession = { ...session, oauthAuthorizeUrl, teamId };
|
||||
const state = randomBytes(32).toString('hex');
|
||||
await this.methods.storeSession(state, updatedSession);
|
||||
return {
|
||||
status: 'manual_install_required',
|
||||
appId: session.appId,
|
||||
installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, session.redirectUrl),
|
||||
};
|
||||
}
|
||||
|
||||
private async deleteManagedExternalApp(
|
||||
options: AgentIntegrationRemovalContext,
|
||||
managedAppId: string,
|
||||
managerCredentialId: string,
|
||||
): Promise<ManagedSlackAppDeletionWarning | undefined> {
|
||||
try {
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.manifest.delete', {
|
||||
app_id: managedAppId,
|
||||
});
|
||||
if (!response.ok && stringProperty(response, 'error') !== 'app_not_found') {
|
||||
throw this.methods.slackError('delete the Slack app', response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof NotFoundError)) throw error;
|
||||
return {
|
||||
integrationType: 'slack',
|
||||
code: 'app_not_deleted',
|
||||
action: {
|
||||
type: 'open_url',
|
||||
url: `https://api.slack.com/apps/${encodeURIComponent(managedAppId)}`,
|
||||
},
|
||||
details: { appId: managedAppId },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async createManagedAppSession(
|
||||
options: InstallManagedSlackAppOptions,
|
||||
agent: Agent,
|
||||
manager: ManagerCredentialContext,
|
||||
workspaceName: string,
|
||||
key: string,
|
||||
): Promise<{ session: ManagedSlackAppSession; created: boolean }> {
|
||||
const redirectUrl = this.methods.callbackUrl(options.projectId, options.agentId);
|
||||
const manifest = this.methods.buildManifest(agent.name, options.projectId, options.agentId, {
|
||||
redirectUrl,
|
||||
@@ -754,31 +822,20 @@ export class SlackManagedSetupService {
|
||||
});
|
||||
if (!response.ok) throw this.methods.slackError('create the Slack app', response);
|
||||
|
||||
const credentials = childRecord(response, 'credentials');
|
||||
const appId = stringProperty(response, 'app_id');
|
||||
const clientId = stringProperty(credentials, 'client_id');
|
||||
const clientSecret = stringProperty(credentials, 'client_secret');
|
||||
const signingSecret = stringProperty(credentials, 'signing_secret');
|
||||
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
try {
|
||||
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
|
||||
throw new BadRequestError('Slack returned an incomplete app setup response');
|
||||
}
|
||||
await this.setManagedAppIcon(manager, appId);
|
||||
const setup = parseSlackAppSetupResponse(response);
|
||||
await this.setManagedAppIcon(manager, setup.appId);
|
||||
|
||||
const session = {
|
||||
projectId: options.projectId,
|
||||
agentId: options.agentId,
|
||||
userId: options.user.id,
|
||||
appId,
|
||||
clientId,
|
||||
clientSecret,
|
||||
signingSecret,
|
||||
...setup,
|
||||
redirectUrl,
|
||||
managerCredentialId: options.managerCredentialId,
|
||||
teamId: options.workspaceId,
|
||||
teamName: workspaceName,
|
||||
oauthAuthorizeUrl,
|
||||
} satisfies ManagedSlackAppSession;
|
||||
await this.cacheService.set(
|
||||
key,
|
||||
@@ -797,30 +854,9 @@ export class SlackManagedSetupService {
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptManagedAppSession(
|
||||
value: string,
|
||||
): Promise<ManagedSlackAppSession | undefined> {
|
||||
try {
|
||||
const decrypted = await this.cipher.decryptV2(value);
|
||||
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
|
||||
if (hasManagedSessionShape(session)) return session;
|
||||
} catch {
|
||||
// Ignore stale or undecryptable managed setup state.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async callManagerSlackApi<T extends { [key: string]: unknown }>(
|
||||
private async refreshManagerAccessToken(
|
||||
manager: ManagerCredentialContext,
|
||||
method: string,
|
||||
params: Record<string, string> | SlackApiParamsFactory,
|
||||
): Promise<({ ok: true } & T) | { ok: false; error: string }> {
|
||||
const paramsForToken = (accessToken: string): SlackApiParams =>
|
||||
typeof params === 'function' ? params(accessToken) : { ...params, token: accessToken };
|
||||
let response = await this.methods.callSlackApi(method, paramsForToken(manager.accessToken));
|
||||
const error = stringProperty(response, 'error');
|
||||
if (!['invalid_auth', 'token_expired'].includes(error ?? '')) return response;
|
||||
|
||||
): Promise<string | undefined> {
|
||||
const authedUser = childRecord(manager.oauthTokenData, 'authed_user');
|
||||
const refreshToken = stringProperty(authedUser, 'refresh_token');
|
||||
const overwrite = this.credentialsOverwrites.getOverwrites(SLACK_MANAGER_CREDENTIAL_TYPE);
|
||||
@@ -829,7 +865,7 @@ export class SlackManagedSetupService {
|
||||
typeof overwrite?.clientId !== 'string' ||
|
||||
typeof overwrite.clientSecret !== 'string'
|
||||
) {
|
||||
return response;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const refreshResponse = await this.methods.callSlackApi<RefreshTokenResponse>(
|
||||
@@ -843,9 +879,24 @@ export class SlackManagedSetupService {
|
||||
);
|
||||
const refreshedAccessToken = stringProperty(refreshResponse, 'access_token');
|
||||
if (!refreshResponse.ok || !refreshedAccessToken) {
|
||||
return response;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await this.saveRefreshedManagerToken(
|
||||
manager,
|
||||
authedUser,
|
||||
refreshResponse,
|
||||
refreshedAccessToken,
|
||||
);
|
||||
return refreshedAccessToken;
|
||||
}
|
||||
|
||||
private async saveRefreshedManagerToken(
|
||||
manager: ManagerCredentialContext,
|
||||
authedUser: Record<string, unknown> | undefined,
|
||||
refreshResponse: RefreshTokenResponse,
|
||||
refreshedAccessToken: string,
|
||||
): Promise<void> {
|
||||
const refreshedAuthedUser: Record<string, unknown> = {
|
||||
...authedUser,
|
||||
access_token: refreshedAccessToken,
|
||||
@@ -878,28 +929,5 @@ export class SlackManagedSetupService {
|
||||
await this.credentialsService.update(manager.credential.id, encrypted, updatedData);
|
||||
manager.oauthTokenData = oauthTokenData;
|
||||
manager.accessToken = refreshedAccessToken;
|
||||
response = await this.methods.callSlackApi(method, paramsForToken(refreshedAccessToken));
|
||||
return response;
|
||||
}
|
||||
|
||||
private async setManagedAppIcon(
|
||||
manager: ManagerCredentialContext,
|
||||
managedAppId: string,
|
||||
): Promise<void> {
|
||||
const image = await readFile(join(__dirname, 'assets', 'n8n-bot-icon.png'));
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.icon.set', (accessToken) => {
|
||||
const formData = new FormData();
|
||||
formData.set('token', accessToken);
|
||||
formData.set('app_id', managedAppId);
|
||||
formData.set(
|
||||
'file',
|
||||
new Blob([new Uint8Array(image)], { type: 'image/png' }),
|
||||
'n8n-bot-icon.png',
|
||||
);
|
||||
return formData;
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw this.methods.slackError('set the Slack app icon', response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-63
@@ -2,9 +2,6 @@ import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from
|
||||
import type { User } from '@n8n/db';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
@@ -14,7 +11,12 @@ import { CacheService } from '@/services/cache/cache.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import { SlackMethodsService } from './slack-methods.service';
|
||||
import { childRecord, type SlackAppSetupSession, slackSetupCacheKey } from './slack-setup.types';
|
||||
import {
|
||||
childRecord,
|
||||
parseSlackAppSetupResponse,
|
||||
type SlackAppSetupSession,
|
||||
slackSetupCacheKey,
|
||||
} from './slack-setup.types';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
export interface CreateSlackAppOptions {
|
||||
@@ -36,27 +38,12 @@ export interface CompleteSlackAppInstallOptions {
|
||||
state: string;
|
||||
}
|
||||
|
||||
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
|
||||
const keys: Array<keyof SlackAppSetupSession> = [
|
||||
'projectId',
|
||||
'agentId',
|
||||
'userId',
|
||||
'appId',
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'signingSecret',
|
||||
'redirectUrl',
|
||||
];
|
||||
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class SlackManualSetupService {
|
||||
constructor(
|
||||
private readonly methods: SlackMethodsService,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly projectService: ProjectService,
|
||||
) {}
|
||||
|
||||
@@ -79,16 +66,52 @@ export class SlackManualSetupService {
|
||||
throw this.methods.slackError('create the Slack app', response);
|
||||
}
|
||||
|
||||
const credentials = childRecord(response, 'credentials');
|
||||
const appId = stringProperty(response, 'app_id');
|
||||
const clientId = stringProperty(credentials, 'client_id');
|
||||
const clientSecret = stringProperty(credentials, 'client_secret');
|
||||
const signingSecret = stringProperty(credentials, 'signing_secret');
|
||||
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
|
||||
throw new BadRequestError('Slack returned an incomplete app setup response');
|
||||
return await this.createInstallSession(options, redirectUrl, response);
|
||||
}
|
||||
|
||||
async getManifest(options: GetSlackAppManifestOptions): Promise<SlackAgentAppManifestResponse> {
|
||||
const agent = await this.methods.getAgent(options.agentId, options.projectId);
|
||||
return {
|
||||
manifest: this.methods.buildManifest(agent.name, options.projectId, options.agentId),
|
||||
};
|
||||
}
|
||||
|
||||
async completeInstall(options: CompleteSlackAppInstallOptions): Promise<void> {
|
||||
const session = await this.consumeSession(options.state);
|
||||
if (session.projectId !== options.projectId || session.agentId !== options.agentId) {
|
||||
throw new BadRequestError('Slack app setup state does not match this agent');
|
||||
}
|
||||
|
||||
const user = await this.getInstallUser(session);
|
||||
|
||||
const agent = await this.methods.getAgent(session.agentId, session.projectId);
|
||||
const { accessToken, teamName } = await this.exchangeInstallCode(session, options.code);
|
||||
await this.methods.connectBotCredential(agent, user, accessToken, {
|
||||
...session,
|
||||
...(teamName ? { teamName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
|
||||
const cached = await this.cacheService.take<unknown>(slackSetupCacheKey(state));
|
||||
if (typeof cached !== 'string') {
|
||||
throw new BadRequestError('Slack app setup state has expired or is invalid');
|
||||
}
|
||||
|
||||
const session = await this.methods.decodeSession(cached);
|
||||
if (session) return session;
|
||||
|
||||
throw new BadRequestError('Slack app setup state has expired or is invalid');
|
||||
}
|
||||
|
||||
private async createInstallSession(
|
||||
options: CreateSlackAppOptions,
|
||||
redirectUrl: string,
|
||||
response: Record<string, unknown>,
|
||||
): Promise<CreateSlackAgentAppResponse> {
|
||||
const { appId, clientId, clientSecret, signingSecret, oauthAuthorizeUrl } =
|
||||
parseSlackAppSetupResponse(response);
|
||||
|
||||
const state = randomBytes(32).toString('hex');
|
||||
const setupSession = {
|
||||
projectId: options.projectId,
|
||||
@@ -108,19 +131,7 @@ export class SlackManualSetupService {
|
||||
};
|
||||
}
|
||||
|
||||
async getManifest(options: GetSlackAppManifestOptions): Promise<SlackAgentAppManifestResponse> {
|
||||
const agent = await this.methods.getAgent(options.agentId, options.projectId);
|
||||
return {
|
||||
manifest: this.methods.buildManifest(agent.name, options.projectId, options.agentId),
|
||||
};
|
||||
}
|
||||
|
||||
async completeInstall(options: CompleteSlackAppInstallOptions): Promise<void> {
|
||||
const session = await this.consumeSession(options.state);
|
||||
if (session.projectId !== options.projectId || session.agentId !== options.agentId) {
|
||||
throw new BadRequestError('Slack app setup state does not match this agent');
|
||||
}
|
||||
|
||||
private async getInstallUser(session: SlackAppSetupSession): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: session.userId },
|
||||
relations: ['role'],
|
||||
@@ -133,12 +144,14 @@ export class SlackManualSetupService {
|
||||
if (!project) {
|
||||
throw new ForbiddenError('You do not have permission to complete Slack app setup');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
const agent = await this.methods.getAgent(session.agentId, session.projectId);
|
||||
private async exchangeInstallCode(session: SlackAppSetupSession, code: string) {
|
||||
const tokenResponse = await this.methods.callSlackApi(
|
||||
'oauth.v2.access',
|
||||
{
|
||||
code: options.code,
|
||||
code,
|
||||
redirect_uri: session.redirectUrl,
|
||||
},
|
||||
{
|
||||
@@ -158,26 +171,6 @@ export class SlackManualSetupService {
|
||||
|
||||
const team = childRecord(tokenResponse, 'team');
|
||||
const teamName = stringProperty(team, 'name');
|
||||
await this.methods.connectBotCredential(agent, user, accessToken, {
|
||||
...session,
|
||||
...(teamName ? { teamName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
|
||||
const cached = await this.cacheService.take<unknown>(slackSetupCacheKey(state));
|
||||
if (typeof cached !== 'string') {
|
||||
throw new BadRequestError('Slack app setup state has expired or is invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await this.cipher.decryptV2(cached);
|
||||
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
|
||||
if (hasSessionShape(session)) return session;
|
||||
} catch {
|
||||
// Invalid encrypted state falls through to the shared callback error.
|
||||
}
|
||||
|
||||
throw new BadRequestError('Slack app setup state has expired or is invalid');
|
||||
return { accessToken, teamName };
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -9,14 +9,16 @@ import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import { getAgentOrThrow } from '../../../utils/get-agent-or-throw';
|
||||
import {
|
||||
hasSessionShape,
|
||||
managedSlackAppCacheKey,
|
||||
SLACK_APP_SETUP_TTL_MS,
|
||||
SLACK_BOT_SCOPES,
|
||||
@@ -103,9 +105,7 @@ export class SlackMethodsService {
|
||||
}
|
||||
|
||||
async getAgent(agentId: string, projectId: string): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
return agent;
|
||||
return await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
||||
}
|
||||
|
||||
buildManifest(
|
||||
@@ -188,6 +188,17 @@ export class SlackMethodsService {
|
||||
);
|
||||
}
|
||||
|
||||
async decodeSession(value: string): Promise<SlackAppSetupSession | undefined> {
|
||||
try {
|
||||
const decrypted = await this.cipher.decryptV2(value);
|
||||
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
|
||||
if (hasSessionShape(session)) return session;
|
||||
} catch {
|
||||
// Each setup flow decides how to handle invalid cached state.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async connectBotCredential(
|
||||
agent: Agent,
|
||||
user: User,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:';
|
||||
const SLACK_MANAGED_APP_CACHE_PREFIX = 'agents:slack-managed-app:';
|
||||
|
||||
@@ -42,6 +46,20 @@ export interface SlackAppSetupSession {
|
||||
teamName?: string;
|
||||
}
|
||||
|
||||
export function hasSessionShape(value: unknown): value is SlackAppSetupSession {
|
||||
const keys: Array<keyof SlackAppSetupSession> = [
|
||||
'projectId',
|
||||
'agentId',
|
||||
'userId',
|
||||
'appId',
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'signingSecret',
|
||||
'redirectUrl',
|
||||
];
|
||||
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
|
||||
}
|
||||
|
||||
export function slackSetupCacheKey(state: string): string {
|
||||
return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`;
|
||||
}
|
||||
@@ -64,3 +82,16 @@ export function childRecord(
|
||||
const child = record[key];
|
||||
return isRecord(child) ? child : undefined;
|
||||
}
|
||||
|
||||
export function parseSlackAppSetupResponse(response: Record<string, unknown>) {
|
||||
const credentials = childRecord(response, 'credentials');
|
||||
const appId = stringProperty(response, 'app_id');
|
||||
const clientId = stringProperty(credentials, 'client_id');
|
||||
const clientSecret = stringProperty(credentials, 'client_secret');
|
||||
const signingSecret = stringProperty(credentials, 'signing_secret');
|
||||
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
|
||||
throw new BadRequestError('Slack returned an incomplete app setup response');
|
||||
}
|
||||
return { appId, clientId, clientSecret, signingSecret, oauthAuthorizeUrl };
|
||||
}
|
||||
|
||||
@@ -23,15 +23,16 @@ import {
|
||||
hasNativeWebSearchProvider,
|
||||
isNativeWebSearchRequested,
|
||||
} from '@n8n/ai-utilities/agent-config';
|
||||
import type {
|
||||
AgentSkill,
|
||||
AgentJsonConfig,
|
||||
AgentJsonMcpServerConfig,
|
||||
AgentJsonMemoryConfig,
|
||||
AgentJsonToolConfig,
|
||||
AgentJsonSkillConfig,
|
||||
import {
|
||||
MANAGED_CREDENTIAL_TOKEN,
|
||||
type AgentModelCredentialConfig,
|
||||
type AgentSkill,
|
||||
type AgentJsonConfig,
|
||||
type AgentJsonMcpServerConfig,
|
||||
type AgentJsonMemoryConfig,
|
||||
type AgentJsonToolConfig,
|
||||
type AgentJsonSkillConfig,
|
||||
} from '@n8n/api-types';
|
||||
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
|
||||
import { createHash } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -94,11 +95,6 @@ export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Pro
|
||||
*/
|
||||
export type McpClientBuilder = (server: AgentJsonMcpServerConfig) => Promise<McpClient>;
|
||||
|
||||
type MemoryWorkerModelConfig = {
|
||||
model: string;
|
||||
credential: string;
|
||||
};
|
||||
|
||||
export interface BuildFromJsonOptions {
|
||||
/** Executes custom tool handlers inside isolates. */
|
||||
toolExecutor: ToolExecutor;
|
||||
@@ -629,7 +625,7 @@ async function resolveModelConfig(
|
||||
}
|
||||
|
||||
async function resolveMemoryWorkerModelConfig(
|
||||
config: MemoryWorkerModelConfig,
|
||||
config: AgentModelCredentialConfig,
|
||||
credentialProvider: CredentialProvider,
|
||||
): Promise<ModelConfig> {
|
||||
// Mirrors `resolveModelConfig`: an empty credential means "not configured",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { CredentialProvider, McpClient, McpServerConfig } from '@n8n/agents';
|
||||
import type {
|
||||
CredentialProvider,
|
||||
McpClient,
|
||||
McpServerConfig,
|
||||
McpConnectionFailedEvent,
|
||||
} from '@n8n/agents';
|
||||
import type { AgentJsonMcpServerConfig } from '@n8n/api-types';
|
||||
import type { CustomFetch } from '@n8n/backend-network';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
@@ -127,7 +132,7 @@ export interface BuildMcpClientDeps {
|
||||
* remaining servers' tools. Used for logging/telemetry — the user-facing
|
||||
* warning is emitted from the agent runtime as a `warning` stream chunk.
|
||||
*/
|
||||
onConnectionFailed?: (event: { server: string; error: string }) => void;
|
||||
onConnectionFailed?: (event: McpConnectionFailedEvent) => void;
|
||||
onToolCallSettled?: McpServerConfig['onToolCallSettled'];
|
||||
}
|
||||
|
||||
@@ -281,8 +286,7 @@ export async function buildMcpClientForServer(
|
||||
}),
|
||||
...(onConnectionFailed
|
||||
? {
|
||||
onConnectionFailed: (event: { server: string; error: string }) =>
|
||||
onConnectionFailed(event),
|
||||
onConnectionFailed: (event: McpConnectionFailedEvent) => onConnectionFailed(event),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user