Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
71516ca
feat(framework): add tool-approval action-id grammar
ChmaraX Jun 30, 2026
507fd99
feat(framework): add default/resolved approval cards + card scanner
ChmaraX Jun 30, 2026
d8e7ba3
feat(framework): add internal tool-approval kit barrel
ChmaraX Jun 30, 2026
ea9c46b
feat(framework): add tool-approval types and ctx.toolApproval.request
ChmaraX Jun 30, 2026
6b44fb7
feat(framework): pause turn on PendingApproval without posting sentinel
ChmaraX Jun 30, 2026
150a4a0
feat(framework): route approval clicks to onToolApproval with default…
ChmaraX Jun 30, 2026
0fbf0ec
feat(framework/ai-sdk): reconstruct approval history and type tool ap…
ChmaraX Jun 30, 2026
ffd9c17
feat(framework/ai-sdk): pause on approval and auto-resume statelessly
ChmaraX Jun 30, 2026
45e47ab
feat(agents): persist tool-approval decisions to the transcript for s…
ChmaraX Jun 30, 2026
9c2a6cf
feat(framework): persist tool-approval payload in richContent instead…
ChmaraX Jun 30, 2026
6d84e16
fix(framework/ai-sdk): resume only the in-flight approval cycle
ChmaraX Jun 30, 2026
4ea16ca
fix(framework/ai-sdk): type AiSdkResult by consumed fields
ChmaraX Jun 30, 2026
5e17845
fix(framework/ai-sdk): emit execution-denied tool-result for denied a…
ChmaraX Jun 30, 2026
0bc2aee
revert(framework/ai-sdk): drop execution-denied mapping for denied ap…
ChmaraX Jun 30, 2026
6b62bd0
fix(framework/ai-sdk): surface one approval card per turn
ChmaraX Jun 30, 2026
26e2302
feat(agents): wire self-hosted tool approval end-to-end with ledger h…
ChmaraX Jul 1, 2026
f26e5a9
fix(agents): auto-deny superseded tool approvals on new bridge messages
ChmaraX Jul 2, 2026
6add072
fix(framework/ai-sdk): post MessageContent returns from onToolApproval
ChmaraX Jul 2, 2026
6bc6026
fix(framework/ai-sdk): pair resolved approval tool results at request…
ChmaraX Jul 2, 2026
6e14319
refactor(framework/ai-sdk): modularize history/reply mappers and clar…
ChmaraX Jul 2, 2026
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
2 changes: 2 additions & 0 deletions apps/api/src/app/agents/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { PlanLimitGateService } from './conversation-runtime/ingress/plan-limit-
import { AgentReplyController } from './conversation-runtime/reply/agent-reply.controller';
import { BridgeRuntime } from './conversation-runtime/runtime/bridge.runtime';
import { BridgeExecutorService } from './conversation-runtime/runtime/bridge-executor.service';
import { BridgeExpireSupersededApprovalsService } from './conversation-runtime/runtime/bridge-expire-superseded-approvals.service';
import { RuntimeResolver } from './conversation-runtime/runtime/runtime-resolver.service';
import { AgentEmailActionTokenService } from './email/agent-email-action-token.service';
import { AgentEmailActionsController } from './email/agent-email-actions.controller';
Expand Down Expand Up @@ -122,6 +123,7 @@ import { USE_CASES } from './usecases';
AgentActionTokenService,
AgentInboundHandler,
BridgeExecutorService,
BridgeExpireSupersededApprovalsService,
BridgeRuntime,
ManagedRuntime,
RuntimeResolver,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ConversationActivityEntity,
ConversationActivityRepository,
ConversationActivitySenderTypeEnum,
ConversationActivityToolData,
ConversationActivityTypeEnum,
ConversationChannel,
ConversationEntity,
Expand Down Expand Up @@ -97,6 +98,11 @@ export interface PersistAgentActivityParams extends ConversationActivityContext
richContent?: Record<string, unknown>;
}

/** Agent message params plus the tool call the approval card gates. */
export interface PersistToolApprovalRequestParams extends PersistAgentActivityParams {
toolData: ConversationActivityToolData;
}

export type MetadataOp =
| { action: 'set'; key: string; value: unknown }
| { action: 'delete'; key: string }
Expand All @@ -117,6 +123,26 @@ export interface PersistTriggerSignalParams extends ConversationActivityContext
transactionId: string;
}

export interface PersistToolApprovalDecisionParams extends ConversationActivityContext {
approvalId: string;
approved: boolean;
toolName?: string;
actorType:
| ConversationActivitySenderTypeEnum.SUBSCRIBER
| ConversationActivitySenderTypeEnum.PLATFORM_USER
| ConversationActivitySenderTypeEnum.SYSTEM;
actorId: string;
}

export interface PersistToolResultParams extends ConversationActivityContext {
toolCallId: string;
toolName?: string;
/** The tool's output as returned by the model runtime (JSON-serializable). */
output: unknown;
/** Human-readable preview for the display timeline; defaults to a generic line. */
preview?: string;
}

@Injectable()
export class AgentConversationService {
constructor(
Expand Down Expand Up @@ -347,12 +373,16 @@ export class AgentConversationService {
return this.persistAgentActivity(params, ConversationActivityTypeEnum.MESSAGE, 'activity');
}

async persistToolApprovalRequest(params: PersistToolApprovalRequestParams): Promise<ConversationActivityEntity> {
return this.persistAgentActivity(params, ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST, 'activity');
}

async persistAgentEdit(params: PersistAgentActivityParams): Promise<ConversationActivityEntity> {
return this.persistAgentActivity(params, ConversationActivityTypeEnum.EDIT, 'preview');
}

private async persistAgentActivity(
params: PersistAgentActivityParams,
params: PersistAgentActivityParams & { toolData?: ConversationActivityToolData },
type: ConversationActivityTypeEnum,
touch: 'activity' | 'preview'
): Promise<ConversationActivityEntity> {
Expand All @@ -375,6 +405,7 @@ export class AgentConversationService {
senderName: params.agentName,
content: params.content,
richContent: params.richContent,
toolData: params.toolData,
type,
environmentId: params.environmentId,
organizationId: params.organizationId,
Expand Down Expand Up @@ -465,6 +496,48 @@ export class AgentConversationService {
]);
}

/**
* Persist a tool-approval decision as a signal activity so it becomes part of
* the durable transcript. Self-hosted (stateless) agents reconstruct the resume
* message list from history via `toModelMessages`, so the decision must live in
* the transcript — not only in the ephemeral approval card.
*/
async persistToolApprovalDecision(params: PersistToolApprovalDecisionParams): Promise<void> {
const toolName = params.toolName ?? 'tool call';

await this.activityRepository.createToolActivity({
identifier: `act_${shortId(12)}`,
conversationId: params.conversationId,
platform: params.channel.platform,
integrationId: params.channel._integrationId,
platformThreadId: params.channel.platformThreadId,
senderType: params.actorType,
senderId: params.actorId,
content: params.approved ? `Approved ${toolName}` : `Denied ${toolName}`,
type: ConversationActivityTypeEnum.TOOL_APPROVAL_DECISION,
toolData: { approvalId: params.approvalId, approved: params.approved, toolName: params.toolName },
environmentId: params.environmentId,
organizationId: params.organizationId,
});
}

async persistToolResult(params: PersistToolResultParams): Promise<void> {
await this.activityRepository.createToolActivity({
identifier: `act_${shortId(12)}`,
conversationId: params.conversationId,
platform: params.channel.platform,
integrationId: params.channel._integrationId,
platformThreadId: params.channel.platformThreadId,
senderType: ConversationActivitySenderTypeEnum.AGENT,
senderId: params.agentIdentifier,
content: params.preview ?? `Tool result: ${params.toolName ?? params.toolCallId}`,
type: ConversationActivityTypeEnum.TOOL_RESULT,
toolData: { toolCallId: params.toolCallId, toolName: params.toolName, output: params.output },
environmentId: params.environmentId,
organizationId: params.organizationId,
});
}

async persistTriggerSignal(params: PersistTriggerSignalParams): Promise<void> {
await this.activityRepository.createSignalActivity({
identifier: `act_${shortId(12)}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common';
import { PinoLogger } from '@novu/application-generic';
import { ConversationChannel } from '@novu/dal';
import { ConversationActivityToolData, ConversationChannel } from '@novu/dal';
import type { SentMessageInfo } from '@novu/framework';
import type { AdapterPostableMessage, CardElement, EmojiValue, PlanModel, Thread } from 'chat';
import { AgentConfigResolver, ResolvedAgentConfig } from '../../channels/agent-config-resolver.service';
Expand Down Expand Up @@ -46,6 +46,41 @@ export interface OutboundPersistContext {

export type OutboundMessage = ReplyContentDto;

/**
* Project an outbound reply onto its two persisted destinations: display content into
* `richContent` (an explicit allowlist of the renderable fields) and the tool-approval
* payload into the first-class `toolData` column. A `toolApproval` reply gates a tool, so
* its metadata belongs in `toolData`, not buried inside `richContent`.
*/
function splitReplyPersistence(msg: OutboundMessage): {
richContent?: Record<string, unknown>;
toolData?: ConversationActivityToolData;
} {
const richContent =
msg.card || msg.files?.length
? {
...(msg.markdown !== undefined && { markdown: msg.markdown }),
...(msg.card !== undefined && { card: msg.card }),
...(msg.files !== undefined && { files: msg.files }),
}
: undefined;

const approval = msg.toolApproval as
| { approvalId?: string; toolCallId?: string; name?: string; input?: Record<string, unknown> }
| undefined;

const toolData: ConversationActivityToolData | undefined = approval
? {
approvalId: approval.approvalId,
toolCallId: approval.toolCallId,
toolName: approval.name,
input: approval.input,
}
: undefined;

return { richContent, toolData };
}

export type OutboundDeliveryOptions = {
slackNative?: SlackNativeDelivery;
};
Expand Down Expand Up @@ -153,7 +188,7 @@ export class OutboundGateway {
agentIdentifier: persist.agentIdentifier,
agentName: persist.agentName,
content: this.extractTextFallback(msg),
richContent: msg.card || msg.files?.length ? (msg as Record<string, unknown>) : undefined,
richContent: splitReplyPersistence(msg).richContent,
environmentId: persist.environmentId,
organizationId: persist.organizationId,
});
Expand Down Expand Up @@ -564,18 +599,30 @@ export class OutboundGateway {
sent: SentMessageInfo,
msg: OutboundMessage
): Promise<void> {
await this.conversation.persistAgentMessage({
const { richContent, toolData } = splitReplyPersistence(msg);

const base = {
conversationId: persist.conversationId,
channel: persist.channel,
platformThreadId: sent.platformThreadId || undefined,
platformMessageId: sent.messageId,
agentIdentifier: persist.agentIdentifier,
agentName: persist.agentName,
content: this.extractTextFallback(msg),
richContent: msg.card || msg.files?.length ? (msg as Record<string, unknown>) : undefined,
richContent,
environmentId: persist.environmentId,
organizationId: persist.organizationId,
});
};

// A delivered card that gates a tool is a `TOOL_APPROVAL_REQUEST`, not a plain message —
// route it to its dedicated persister so the tool metadata lands in `toolData`.
if (toolData) {
await this.conversation.persistToolApprovalRequest({ ...base, toolData });

return;
}

await this.conversation.persistAgentMessage(base);
}

private async buildThreadPostArg(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,15 @@ describe('AgentInboundHandler', () => {
showWorkingSignal: sinon.stub().resolves(undefined),
showQueuedSignal: sinon.stub().resolves(undefined),
};
const expireSupersededApprovals = {
expireOnNewMessage: sinon.stub().resolves(undefined),
};
const bridgeRuntime = new BridgeRuntime(
bridgeExecutor as any,
outboundGateway as any,
conversationService as any,
environmentRepository as any,
expireSupersededApprovals as any,
logger as any
);
const managedRuntime = new ManagedRuntime(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SubscriberRepository,
} from '@novu/dal';
import type { AgentAction } from '@novu/framework';
import { parseApprovalActionId } from '@novu/framework/internal';
import { ENDPOINT_TYPES } from '@novu/shared';
import type { CardElement, EmojiValue, Message, Thread } from 'chat';
import { ConnectClaimTokenService } from '../../../connect/services/connect-claim-token.service';
Expand All @@ -21,6 +22,7 @@ import { LinkTelegramChatToSubscriberCommand } from '../../../telegram-linking/l
import { LinkTelegramChatToSubscriber } from '../../../telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase';
import { TelegramStartCodeService } from '../../../telegram-linking/telegram-start-code.service';
import { ResolvedAgentConfig } from '../../channels/agent-config-resolver.service';
import { parseToolApprovalActionId } from '../../managed-runtime/tool-approval/approval-card.builder';
import {
trackAgentInboundAction,
trackAgentInboundMessage,
Expand Down Expand Up @@ -992,6 +994,12 @@ export class AgentInboundHandler implements OnModuleInit {
return;
}

const actorType =
participantType === ConversationParticipantTypeEnum.SUBSCRIBER
? ConversationActivitySenderTypeEnum.SUBSCRIBER
: ConversationActivitySenderTypeEnum.PLATFORM_USER;
await this.recordApprovalVerdict(conversation, config, action, actorType, participantId);

// Everything else (incl. mcp-approval:* for managed) routes through the runtime,
// which owns its own action semantics.
const [subscriber, agent] = await Promise.all([
Expand Down Expand Up @@ -1021,4 +1029,65 @@ export class AgentInboundHandler implements OnModuleInit {

await runtime.dispatch(turn);
}

/**
* Normalise an approval-card click into a verdict. Self-hosted and managed
* cards use distinct action-id grammars (`tool-approval:*` vs
* `mcp-approval:*` / `direct-approval:*`), so they never collide; non-approval
* actions return `null` and are skipped.
*/
private parseApprovalVerdict(
actionId: string | undefined
): { approvalId: string; approved: boolean; toolName?: string } | null {
const selfHosted = parseApprovalActionId(actionId);
if (selfHosted) {
return { approvalId: selfHosted.approvalId, approved: selfHosted.approved };
}

const managed = parseToolApprovalActionId(actionId);
if (managed) {
const toolName = managed.trust?.scope === 'tool' ? managed.trust.toolName : undefined;

return { approvalId: managed.toolUseId, approved: managed.approved, toolName };
}

return null;
}

private async recordApprovalVerdict(
conversation: ConversationEntity,
config: ResolvedAgentConfig,
action: AgentAction,
actorType: ConversationActivitySenderTypeEnum.SUBSCRIBER | ConversationActivitySenderTypeEnum.PLATFORM_USER,
actorId: string
): Promise<void> {
const verdict = this.parseApprovalVerdict(action.id);
if (!verdict) {
return;
}

try {
await this.conversationService.persistToolApprovalDecision({
conversationId: conversation._id,
channel: this.conversationService.getPrimaryChannel(conversation),
agentIdentifier: config.agentIdentifier,
approvalId: verdict.approvalId,
approved: verdict.approved,
toolName: verdict.toolName,
actorType,
actorId,
environmentId: config.environmentId,
organizationId: config.organizationId,
});
} catch (err) {
// A failed transcript write must never drop the click — the runtime still
// receives onAction and can resolve the card.
this.logger.warn(err, `[agent:${config.agentIdentifier}] Failed to persist tool-approval decision`);
captureAgentWarning(err, {
component: 'inbound-turn-handler',
operation: 'persist-tool-approval-decision',
agentIdentifier: config.agentIdentifier,
});
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import type { Signal } from '@novu/framework';
import type { Signal, ToolResult } from '@novu/framework';
import { UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../../../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../../../auth/framework/external-api.decorator';
Expand Down Expand Up @@ -54,6 +54,7 @@ export class AgentReplyController {
edit: body.edit,
resolve: body.resolve,
signals: body.signals as Signal[],
toolResults: body.toolResults as ToolResult[],
addReactions: body.addReactions,
typing: body.typing,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Signal } from '@novu/framework';
import type { Signal, ToolResult } from '@novu/framework';
import type { PlanModel } from 'chat';
import { Type } from 'class-transformer';
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
Expand Down Expand Up @@ -38,6 +38,10 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand {
@IsArray()
signals?: Signal[];

@IsOptional()
@IsArray()
toolResults?: ToolResult[];

@IsOptional()
@IsArray()
@ValidateNested({ each: true })
Expand Down
Loading
Loading