Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
export type { FileRef } from '@novu/framework';

const SIGNAL_TYPES = ['metadata', 'trigger'] as const;
const METADATA_ACTIONS = ['set', 'delete', 'clear'] as const;
const MAX_INLINE_FILE_BASE64_CHARS = 7_000_000;
const MAX_FILES_PER_MESSAGE = 15;

Expand Down Expand Up @@ -92,7 +93,12 @@ export class IsValidSignal implements ValidatorConstraintInterface {
if (!signal?.type) return false;

if (signal.type === 'metadata') {
return isValidMetadataSignalKey(signal.key) && signal.value !== undefined;
const action = signal.action ?? 'set';
if (action === 'set') return isValidMetadataSignalKey(signal.key) && signal.value !== undefined;
if (action === 'delete') return isValidMetadataSignalKey(signal.key);
if (action === 'clear') return true;

return false;
}

if (signal.type === 'trigger') {
Expand All @@ -104,8 +110,9 @@ export class IsValidSignal implements ValidatorConstraintInterface {

defaultMessage(): string {
return (
'metadata signals require a key 1-128 chars of letters, digits and "-", "_", ":" separators ' +
'(no leading, trailing or consecutive separators) plus a defined value; ' +
'metadata signals require action (set|delete|clear): ' +
'set requires a key 1-128 chars of letters, digits and "-", "_", ":" separators plus a defined value; ' +
'delete requires a valid key; clear requires no additional fields; ' +
'trigger signals require workflowId.'
);
}
Expand Down Expand Up @@ -198,6 +205,12 @@ export class SignalDto {
@IsIn(SIGNAL_TYPES)
type: (typeof SIGNAL_TYPES)[number];

@ApiPropertyOptional({ enum: METADATA_ACTIONS })
@IsOptional()
@IsString()
@IsIn(METADATA_ACTIONS)
action?: (typeof METADATA_ACTIONS)[number];

@ApiPropertyOptional()
@IsOptional()
@IsString()
Expand Down
30 changes: 25 additions & 5 deletions apps/api/src/app/agents/services/agent-conversation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,14 @@ export interface PersistAgentActivityParams extends ConversationActivityContext
richContent?: Record<string, unknown>;
}

export type MetadataOp =
| { action: 'set'; key: string; value: unknown }
| { action: 'delete'; key: string }
| { action: 'clear' };

export interface UpdateMetadataParams extends ConversationActivityContext {
currentMetadata: Record<string, unknown>;
signals: Array<{ key: string; value: unknown }>;
ops: MetadataOp[];
}

export interface ResolveConversationParams extends ConversationActivityContext {
Expand Down Expand Up @@ -334,9 +339,24 @@ export class AgentConversationService {
}

async updateMetadata(params: UpdateMetadataParams): Promise<void> {
const merged: Record<string, unknown> = { ...(params.currentMetadata ?? {}) };
for (const signal of params.signals) {
merged[signal.key] = signal.value;
let merged: Record<string, unknown> = { ...(params.currentMetadata ?? {}) };
const descriptions: string[] = [];

for (const op of params.ops) {
switch (op.action) {
case 'set':
merged[op.key] = op.value;
descriptions.push(op.key);
break;
case 'delete':
delete merged[op.key];
descriptions.push(`-${op.key}`);
break;
case 'clear':
merged = {};
descriptions.push('(cleared)');
break;
}
}

const serialized = JSON.stringify(merged);
Expand All @@ -358,7 +378,7 @@ export class AgentConversationService {
integrationId: params.channel._integrationId,
platformThreadId: params.channel.platformThreadId,
agentId: params.agentIdentifier,
content: `Metadata updated: ${params.signals.map((s) => s.key).join(', ')}`,
content: `Metadata updated: ${descriptions.join(', ')}`,
signalData: { type: 'metadata', payload: merged },
environmentId: params.environmentId,
organizationId: params.organizationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AgentEventEnum } from '../../dtos/agent-event.enum';
import type { EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { isValidMetadataSignalKey } from '../../dtos/agent-reply-payload.dto';
import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service';
import type { MetadataOp } from '../../services/agent-conversation.service';
import { AgentConversationService } from '../../services/agent-conversation.service';
import { BridgeExecutorService } from '../../services/bridge-executor.service';
import { ChatSdkService } from '../../services/chat-sdk.service';
Expand Down Expand Up @@ -254,18 +255,20 @@ export class HandleAgentReply {
channel: ConversationChannel,
signals: HandleAgentReplyCommand['signals']
): Promise<void> {
const metadataSignals = (signals ?? []).filter(
(s): s is Extract<NonNullable<HandleAgentReplyCommand['signals']>[number], { type: 'metadata' }> =>
s.type === 'metadata'
);

if (metadataSignals.length) {
await this.validateMetadataSignalKeys(metadataSignals);
const rawMetadata = (signals ?? []).filter((s) => s.type === 'metadata') as Array<{
type: 'metadata';
action?: string;
key?: string;
value?: unknown;
}>;

if (rawMetadata.length) {
const ops = this.normalizeMetadataOps(rawMetadata);
await this.conversationService.updateMetadata({
conversationId: conversation._id,
channel,
currentMetadata: conversation.metadata ?? {},
signals: metadataSignals,
ops,
agentIdentifier: command.agentIdentifier,
environmentId: command.environmentId,
organizationId: command.organizationId,
Expand Down Expand Up @@ -344,15 +347,39 @@ export class HandleAgentReply {
}
}

private validateMetadataSignalKeys(signals: Array<{ key: string; value: unknown }>): void {
private normalizeMetadataOps(
signals: Array<{ type: 'metadata'; action?: string; key?: string; value?: unknown }>
): MetadataOp[] {
const ops: MetadataOp[] = [];

for (const signal of signals) {
if (!isValidMetadataSignalKey(signal.key)) {
throw new BadRequestException(`Invalid metadata signal key: "${signal.key}"`);
}
if (signal.value === undefined) {
throw new BadRequestException(`Metadata signal "${signal.key}" must have a defined value`);
const action = signal.action ?? 'set';

switch (action) {
case 'clear':
ops.push({ action: 'clear' });
break;
case 'delete':
if (!signal.key || !isValidMetadataSignalKey(signal.key)) {
throw new BadRequestException(`Invalid metadata signal key: "${signal.key}"`);
}
ops.push({ action: 'delete', key: signal.key });
break;
case 'set':
if (!signal.key || !isValidMetadataSignalKey(signal.key)) {
throw new BadRequestException(`Invalid metadata signal key: "${signal.key}"`);
}
if (signal.value === undefined) {
throw new BadRequestException(`Metadata signal "${signal.key}" must have a defined value`);
}
ops.push({ action: 'set', key: signal.key, value: signal.value });
break;
default:
throw new BadRequestException(`Unsupported metadata signal action: "${action}"`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return ops;
}

private async resolveConversation(
Expand Down
31 changes: 28 additions & 3 deletions packages/framework/src/resources/agent/agent.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,18 @@ export class AgentContextImpl {
readonly platform: string;
readonly platformContext: AgentPlatformContext;

readonly metadata: { set: (key: string, value: unknown) => void };
readonly metadata: {
get(key: string): unknown;
set(key: string, value: unknown): void;
delete(key: string): void;
clear(): void;
readonly current: Readonly<Record<string, unknown>>;
};

private _signals: Signal[] = [];
private _pendingReactions: AddReactionPayload[] = [];
private _resolveSignal: { summary?: string } | null = null;
private _metadataState: Record<string, unknown>;
private readonly _replyUrl: string;
private readonly _conversationId: string;
private readonly _integrationIdentifier: string;
Expand All @@ -285,9 +292,27 @@ export class AgentContextImpl {
this._secretKey = secretKey;
this._poster = { post: (body) => this._post(body) };

this._metadataState = { ...(request.conversation.metadata ?? {}) };

const self = this;
this.metadata = {
set: (key: string, value: unknown) => {
this._signals.push({ type: 'metadata', key, value });
get(key: string) {
return self._metadataState[key];
},
set(key: string, value: unknown) {
self._metadataState[key] = value;
self._signals.push({ type: 'metadata', action: 'set', key, value });
},
delete(key: string) {
delete self._metadataState[key];
self._signals.push({ type: 'metadata', action: 'delete', key });
},
clear() {
self._metadataState = {};
self._signals.push({ type: 'metadata', action: 'clear' });
},
get current() {
return { ...self._metadataState } as Readonly<Record<string, unknown>>;
},
};
}
Expand Down
Loading
Loading