diff --git a/packages/agent-client/src/approval-request-creator.ts b/packages/agent-client/src/approval-request-creator.ts index 2303fc420a..861b21c30c 100644 --- a/packages/agent-client/src/approval-request-creator.ts +++ b/packages/agent-client/src/approval-request-creator.ts @@ -7,6 +7,7 @@ export type ApprovalRequestPayload = { actionName: string; recordIds: (string | number)[]; inputs: ApprovalRequestInput[]; + message?: string; }; export type CreateApprovalRequest = ( @@ -43,6 +44,28 @@ export default function makeCreateApprovalRequest(options: { }, }); - return body?.data?.id ? { id: String(body.data.id) } : undefined; + const id = body?.data?.id ? String(body.data.id) : undefined; + + // Best-effort: the approval already exists, so a comment failure must not fail the request. + if (id && payload.message) { + try { + await ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + bearerToken: options.forestServerToken, + method: 'post', + path: `${APPROVAL_REQUEST_PATH}/${id}/comments`, + headers: { 'forest-rendering-id': String(options.renderingId) }, + body: { data: { attributes: { comment: payload.message } } }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + `Approval request ${id} created, but posting the reasoning comment failed`, + error, + ); + } + } + + return id ? { id } : undefined; }; } diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index 652661e805..44990589d9 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -83,6 +83,12 @@ export type BaseActionContext = { recordIds?: RecordId[]; }; +export type ActionExecuteOptions = { + signedApprovalRequest?: Record; + // Posted as a comment on the approval request when the action is approval-gated. + approvalRequestMessage?: string; +}; + export type ActionExecuteResult = | { success: string; html?: string } | { approvalRequested: true; approvalRequest?: { id: string } }; @@ -123,7 +129,8 @@ export default class Action { this.createApprovalRequest = createApprovalRequest; } - async execute(signedApprovalRequest?: Record): Promise { + async execute(options: ActionExecuteOptions = {}): Promise { + const { signedApprovalRequest, approvalRequestMessage } = options; const requestBody = { data: { attributes: { @@ -161,6 +168,7 @@ export default class Action { actionName: this.actionName, recordIds: this.ids ?? [], inputs, + ...(approvalRequestMessage && { message: approvalRequestMessage }), }); } catch (cause) { throw new ApprovalRequestCreationError(cause); diff --git a/packages/agent-client/test/approval-request-creator.test.ts b/packages/agent-client/test/approval-request-creator.test.ts index 4ce1df9f75..c868f370ae 100644 --- a/packages/agent-client/test/approval-request-creator.test.ts +++ b/packages/agent-client/test/approval-request-creator.test.ts @@ -46,6 +46,79 @@ describe('makeCreateApprovalRequest', () => { }); }); + it('posts the message as a comment on the created approval', async () => { + queryWithBearerToken.mockResolvedValueOnce({ data: { id: 'req_42' } }); + const create = makeCreateApprovalRequest({ + forestServerUrl: 'https://api.forestadmin.com', + forestServerToken: 'server-token', + renderingId: 42, + }); + + const result = await create({ + collectionName: 'users', + actionName: 'refund', + recordIds: ['1'], + inputs: [], + message: 'Refund requested by AI: duplicate payment detected', + }); + + expect(queryWithBearerToken).toHaveBeenCalledTimes(2); + expect(queryWithBearerToken).toHaveBeenLastCalledWith({ + forestServerUrl: 'https://api.forestadmin.com', + bearerToken: 'server-token', + method: 'post', + path: '/api/action-approvals/req_42/comments', + headers: { 'forest-rendering-id': '42' }, + body: { + data: { attributes: { comment: 'Refund requested by AI: duplicate payment detected' } }, + }, + }); + expect(result).toEqual({ id: 'req_42' }); + }); + + it('skips the comment when no approval id came back', async () => { + queryWithBearerToken.mockResolvedValueOnce({ data: {} }); + const create = makeCreateApprovalRequest({ + forestServerUrl: 'https://api.forestadmin.com', + forestServerToken: 'server-token', + renderingId: 42, + }); + + await create({ + collectionName: 'users', + actionName: 'refund', + recordIds: ['1'], + inputs: [], + message: 'some reasoning', + }); + + expect(queryWithBearerToken).toHaveBeenCalledTimes(1); + }); + + it('still returns the approval id (and warns) when posting the comment fails', async () => { + queryWithBearerToken + .mockResolvedValueOnce({ data: { id: 'req_42' } }) + .mockRejectedValueOnce(new Error('comments route down')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const create = makeCreateApprovalRequest({ + forestServerUrl: 'https://api.forestadmin.com', + forestServerToken: 'server-token', + renderingId: 42, + }); + + const result = await create({ + collectionName: 'users', + actionName: 'refund', + recordIds: ['1'], + inputs: [], + message: 'some reasoning', + }); + + expect(result).toEqual({ id: 'req_42' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('req_42'), expect.any(Error)); + warn.mockRestore(); + }); + it('returns the approval id read from the server response data.id', async () => { queryWithBearerToken.mockResolvedValue({ data: { id: 'req_42', type: 'action-approvals' } }); const create = makeCreateApprovalRequest({ diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 927152b359..423cfe770e 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -106,7 +106,7 @@ describe('Action', () => { httpRequester.query.mockResolvedValue({ success: 'Action executed' }); const signedApprovalRequest = { token: 'approval-token', requesterId: '123' }; - await action.execute(signedApprovalRequest); + await action.execute({ signedApprovalRequest }); expect(httpRequester.query).toHaveBeenCalledWith({ method: 'post', @@ -184,6 +184,34 @@ describe('Action', () => { expect(result).toEqual({ approvalRequested: true }); }); + it('forwards the approval message to the approval request creator', async () => { + fieldsFormStates.getFields.mockReturnValue([] as any); + const createApprovalRequest = jest.fn().mockResolvedValue(undefined); + const approvalAction = new Action( + 'users', + 'send-email', + httpRequester, + '/forest/actions/send-email', + fieldsFormStates, + ['1'], + undefined, + createApprovalRequest, + ); + httpRequester.query.mockRejectedValue( + new AgentHttpError(403, { + errors: [{ name: 'CustomActionRequiresApprovalError', detail: 'Needs approval' }], + }), + ); + + await approvalAction.execute({ + approvalRequestMessage: 'AI reasoning: user asked for a resend', + }); + + expect(createApprovalRequest).toHaveBeenCalledWith( + expect.objectContaining({ message: 'AI reasoning: user asked for a resend' }), + ); + }); + it('includes the approval request id when the creator returns one', async () => { fieldsFormStates.getFields.mockReturnValue([] as any); const createApprovalRequest = jest.fn().mockResolvedValue({ id: 'req_42' }); diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index 9c987ccc7d..7b28c2c910 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -2,6 +2,8 @@ import type { ForestServerClient } from '../http-client'; import type { Logger } from '../server'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + import { createActionArgumentShape } from '../utils/action-helpers'; import { buildClientWithActions } from '../utils/agent-caller'; import registerToolWithLogging from '../utils/tool-with-logging'; @@ -12,6 +14,7 @@ interface ExecuteActionArgument { actionName: string; recordIds: (string | number)[] | null; values?: Record; + reasoning?: string; } export default function declareExecuteActionTool( @@ -20,7 +23,16 @@ export default function declareExecuteActionTool( logger: Logger, collectionNames: string[] = [], ): string { - const argumentShape = createActionArgumentShape(collectionNames); + const argumentShape = { + ...createActionArgumentShape(collectionNames), + reasoning: z + .string() + .optional() + .describe( + 'A clear explanation of why you are executing this action. ' + + 'Shown to the approver when the action requires an approval — always provide it.', + ), + }; return registerToolWithLogging( mcpServer, @@ -62,7 +74,7 @@ If you call executeAction with missing required fields, it will return an error await action.setFields(options.values); } - const result = await action.execute(); + const result = await action.execute({ approvalRequestMessage: options.reasoning }); if ('approvalRequested' in result) { return { diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 190cbe61e2..ca606ad68d 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -332,6 +332,33 @@ describe('declareExecuteActionTool', () => { }); }); + it('should forward the reasoning to execute so it reaches the approval request', async () => { + const mockExecute = jest.fn().mockResolvedValue({ approvalRequested: true }); + const mockAction = jest.fn().mockResolvedValue({ + execute: mockExecute, + setFields: jest.fn().mockResolvedValue(undefined), + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClientWithActions.mockResolvedValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { + collectionName: 'users', + actionName: 'refund', + recordIds: [1], + reasoning: 'Duplicate payment detected on order #42', + }, + mockExtra, + ); + + expect(mockExecute).toHaveBeenCalledWith({ + approvalRequestMessage: 'Duplicate payment detected on order #42', + }); + }); + describe('activity logging', () => { beforeEach(() => { const mockExecute = jest.fn().mockResolvedValue({ success: 'Action executed' }); diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 0e79649788..d69c4e6a58 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -230,7 +230,7 @@ export default class AgentClientAgentPort implements AgentPort { } async executeAction( - { collection, action, id, values }: ExecuteActionQuery, + { collection, action, id, values, approvalMessage }: ExecuteActionQuery, { user, forestServerToken }: ActionCaller, ): Promise { return this.callAgent('executeAction', async () => { @@ -249,7 +249,7 @@ export default class AgentClientAgentPort implements AgentPort { } try { - const executeResult = await act.execute(); + const executeResult = await act.execute({ approvalRequestMessage: approvalMessage }); return typeof executeResult === 'object' && executeResult !== null && diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index a09af61908..fd354e40f0 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -43,6 +43,7 @@ Important rules: interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; isGlobal?: boolean; + approvalMessage?: string; } export default class TriggerRecordActionStepExecutor extends RecordStepExecutor { @@ -141,18 +142,24 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< : await this.resolveRecordRef(await this.getAvailableRecordRefs(), step.prompt); const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); const recordedAction = preRecordedArgs?.actionName; - const actionName = recordedAction ?? (await this.selectAction(schema, step.prompt)).actionName; + const selection = recordedAction + ? { actionName: recordedAction } + : await this.selectAction(schema, step.prompt); + const { actionName } = selection; const action = this.findActionByTechnicalName(schema, actionName); if (!action) { throw new ActionNotFoundError(actionName, schema.collectionName); } + const approvalMessage = ('reasoning' in selection && selection.reasoning) || step.prompt; + const target: ActionTarget = { selectedRecordRef, displayName: action.displayName, name: action.name, isGlobal: action.type === 'global', + ...(approvalMessage && { approvalMessage }), }; const form = await this.context.agent.getActionForm({ @@ -377,6 +384,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< // Global actions run on no record — omit the id so the approval isn't linked to one. ...(target.isGlobal ? {} : { id: selectedRecordRef.recordId }), ...(form && { values: form.values }), + ...(target.approvalMessage && { approvalMessage: target.approvalMessage }), }, { beforeCall: () => @@ -485,7 +493,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< private async selectAction( schema: CollectionSchema, prompt: string | undefined, - ): Promise<{ actionName: string }> { + ): Promise<{ actionName: string; reasoning?: string }> { const tool = this.buildSelectActionTool(schema); const messages = [ this.buildContextMessage(), @@ -497,12 +505,12 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< new HumanMessage(`**Request**: ${prompt ?? 'Trigger the relevant action.'}`), ]; - const { actionName } = await this.invokeWithTool<{ actionName: string; reasoning: string }>( - messages, - tool, - ); + const { actionName, reasoning } = await this.invokeWithTool<{ + actionName: string; + reasoning: string; + }>(messages, tool); - return { actionName: this.findAction(schema, actionName)?.name ?? actionName }; + return { actionName: this.findAction(schema, actionName)?.name ?? actionName, reasoning }; } private buildSelectActionTool(schema: CollectionSchema): DynamicStructuredTool { diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 8bb9d128ce..32228f2932 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -43,6 +43,8 @@ export type ExecuteActionQuery = { // Pre-filled form values. Set on the form before execution, going through the agent's // normal server-side validation — no bypass. Omitted for formless actions. values?: Record; + // AI reasoning, posted as a comment on the approval request when the action is approval-gated. + approvalMessage?: string; }; export type GetActionFormQuery = { diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index d6b0f78c5c..4533deb88a 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -784,6 +784,24 @@ describe('AgentClientAgentPort', () => { expect(result).toEqual({ approvalRequested: true, approvalRequest: { id: 'req_42' } }); }); + it('forwards the approvalMessage to execute so it reaches the approval request', async () => { + mockAction.execute.mockResolvedValue({ approvalRequested: true }); + + await port.executeAction( + { + collection: 'users', + action: 'sendEmail', + id: [1], + approvalMessage: 'AI reasoning: resend requested by the workflow', + }, + { user }, + ); + + expect(mockAction.execute).toHaveBeenCalledWith({ + approvalRequestMessage: 'AI reasoning: resend requested by the workflow', + }); + }); + it('wires the forestServer connection into agent-client when a server token is supplied', async () => { const portWithServer = new AgentClientAgentPort({ agentUrl: 'http://localhost:3310', diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index bcf25ca224..830bce2107 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -227,7 +227,12 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( - { collection: 'customers', action: 'send-welcome-email', id: [42] }, + { + collection: 'customers', + action: 'send-welcome-email', + id: [42], + approvalMessage: 'User requested welcome email', + }, { user: expect.objectContaining({ id: 1 }), forestServerToken: undefined }, ); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -248,6 +253,48 @@ describe('TriggerRecordActionStepExecutor', () => { ); }); + it('falls back to the step prompt as approvalMessage when the AI returns no reasoning', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); + const mockModel = makeMockModel({ actionName: 'Send Welcome Email' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + prompt: 'Send a welcome email to the customer', + }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + expect(agentPort.executeAction).toHaveBeenCalledWith( + expect.objectContaining({ approvalMessage: 'Send a welcome email to the customer' }), + expect.anything(), + ); + }); + + it('omits approvalMessage when the AI returns no reasoning and there is no prompt', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); + const mockModel = makeMockModel({ actionName: 'Send Welcome Email' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + prompt: undefined, + }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + const query = (agentPort.executeAction as jest.Mock).mock.calls[0][0]; + expect('approvalMessage' in query).toBe(false); + }); + it('does NOT attach a record when the action is global', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); @@ -274,7 +321,11 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); // The query carries no `id` for a global action. const query = (agentPort.executeAction as jest.Mock).mock.calls[0][0]; - expect(query).toEqual({ collection: 'customers', action: 'send-welcome-email' }); + expect(query).toEqual({ + collection: 'customers', + action: 'send-welcome-email', + approvalMessage: 'User requested welcome email', + }); expect('id' in query).toBe(false); }); @@ -1176,7 +1227,12 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( - { collection: 'customers', action: 'archive', id: [42] }, + { + collection: 'customers', + action: 'archive', + id: [42], + approvalMessage: 'User wants to archive', + }, { user: expect.objectContaining({ id: 1 }), forestServerToken: undefined }, ); }); @@ -1206,7 +1262,12 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.executeAction).toHaveBeenCalledWith( - { collection: 'customers', action: 'archive', id: [42] }, + { + collection: 'customers', + action: 'archive', + id: [42], + approvalMessage: 'fallback to technical name', + }, { user: expect.objectContaining({ id: 1 }), forestServerToken: undefined }, ); }); @@ -1592,6 +1653,30 @@ describe('TriggerRecordActionStepExecutor', () => { ); }); + it('falls back to the step prompt as approvalMessage when the action is pre-recorded', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ result: { ok: true } }); + const mockModel = makeMockModel(); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { actionName: 'send-welcome-email' }, + }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + expect(agentPort.executeAction).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMessage: 'Send a welcome email to the customer', + }), + expect.anything(), + ); + }); + it('still goes through awaiting-input when executionType is not FullyAutomated', async () => { const mockModel = makeMockModel(); const runStore = makeMockRunStore();