Skip to content

Commit c884d37

Browse files
alban bertoliniclaude
andcommitted
feat(workflow-executor): add TriggerActionStepExecutor with confirmation flow
Implements TriggerActionStepExecutor following the UpdateRecordStepExecutor pattern (branches A/B/C, confirmation flow, automaticExecution). - Add TriggerActionStepExecutionData type with executionParams (actionDisplayName + actionName), executionResult ({ success } | { skipped }), and pendingAction - Add NoActionsError for collections with no actions - Implement selectAction via AI tool with displayName enum and technical name hints - resolveAndExecute stores the technical actionName in executionParams for traceability; action result discarded per privacy constraint - Fix buildStepSummary in BaseStepExecutor to include trigger-action pendingAction in prior-step AI context (parity with update-record pendingUpdate) - Export TriggerActionStepExecutor, TriggerActionStepExecutionData, NoActionsError Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d99fe27 commit c884d37

6 files changed

Lines changed: 1003 additions & 0 deletions

File tree

packages/workflow-executor/src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,9 @@ export class NoWritableFieldsError extends WorkflowExecutorError {
5151
super(`No writable fields on record from collection "${collectionName}"`);
5252
}
5353
}
54+
55+
export class NoActionsError extends WorkflowExecutorError {
56+
constructor(collectionName: string) {
57+
super(`No actions available on collection "${collectionName}"`);
58+
}
59+
}

packages/workflow-executor/src/executors/base-step-executor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
103103
lines.push(` Input: ${JSON.stringify(execution.executionParams)}`);
104104
} else if (execution.type === 'update-record' && execution.pendingUpdate) {
105105
lines.push(` Pending: ${JSON.stringify(execution.pendingUpdate)}`);
106+
} else if (execution.type === 'trigger-action' && execution.pendingAction) {
107+
lines.push(` Pending: ${JSON.stringify(execution.pendingAction)}`);
106108
}
107109

108110
if (execution.executionResult) {
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type { StepExecutionResult } from '../types/execution';
2+
import type { CollectionSchema, RecordRef } from '../types/record';
3+
import type { RecordTaskStepDefinition } from '../types/step-definition';
4+
import type { TriggerActionStepExecutionData } from '../types/step-execution-data';
5+
6+
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
7+
import { DynamicStructuredTool } from '@langchain/core/tools';
8+
import { z } from 'zod';
9+
10+
import { NoActionsError, WorkflowExecutorError } from '../errors';
11+
import BaseStepExecutor from './base-step-executor';
12+
13+
const TRIGGER_ACTION_SYSTEM_PROMPT = `You are an AI agent triggering an action on a record based on a user request.
14+
Select the action to trigger.
15+
16+
Important rules:
17+
- Be precise: only trigger the action directly relevant to the request.
18+
- Final answer is definitive, you won't receive any other input from the user.
19+
- Do not refer to yourself as "I" in the response, use a passive formulation instead.`;
20+
21+
interface TriggerTarget {
22+
selectedRecordRef: RecordRef;
23+
actionDisplayName: string;
24+
}
25+
26+
export default class TriggerActionStepExecutor extends BaseStepExecutor<RecordTaskStepDefinition> {
27+
async execute(): Promise<StepExecutionResult> {
28+
// Branch A -- Re-entry with user confirmation
29+
if (this.context.userConfirmed !== undefined) {
30+
return this.handleConfirmation();
31+
}
32+
33+
// Branches B & C -- First call
34+
return this.handleFirstCall();
35+
}
36+
37+
private async handleConfirmation(): Promise<StepExecutionResult> {
38+
const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId);
39+
const execution = stepExecutions.find(
40+
(e): e is TriggerActionStepExecutionData =>
41+
e.type === 'trigger-action' && e.stepIndex === this.context.stepIndex,
42+
);
43+
44+
if (!execution?.pendingAction) {
45+
throw new WorkflowExecutorError('No pending action found for this step');
46+
}
47+
48+
if (!this.context.userConfirmed) {
49+
await this.context.runStore.saveStepExecution(this.context.runId, {
50+
...execution,
51+
executionResult: { skipped: true },
52+
});
53+
54+
return this.buildOutcomeResult('success');
55+
}
56+
57+
const { selectedRecordRef, pendingAction } = execution;
58+
const target: TriggerTarget = {
59+
selectedRecordRef,
60+
actionDisplayName: pendingAction.actionDisplayName,
61+
};
62+
63+
return this.resolveAndExecute(target, execution);
64+
}
65+
66+
private async handleFirstCall(): Promise<StepExecutionResult> {
67+
const { stepDefinition: step } = this.context;
68+
const records = await this.getAvailableRecordRefs();
69+
70+
let target: TriggerTarget;
71+
72+
try {
73+
const selectedRecordRef = await this.selectRecordRef(records, step.prompt);
74+
const schema = await this.getCollectionSchema(selectedRecordRef.collectionName);
75+
const args = await this.selectAction(schema, step.prompt);
76+
target = { selectedRecordRef, actionDisplayName: args.actionDisplayName };
77+
} catch (error) {
78+
if (error instanceof WorkflowExecutorError) {
79+
return this.buildOutcomeResult('error', error.message);
80+
}
81+
82+
throw error;
83+
}
84+
85+
// Branch B -- automaticExecution
86+
if (step.automaticExecution) {
87+
return this.resolveAndExecute(target);
88+
}
89+
90+
// Branch C -- Awaiting confirmation
91+
await this.context.runStore.saveStepExecution(this.context.runId, {
92+
type: 'trigger-action',
93+
stepIndex: this.context.stepIndex,
94+
pendingAction: { actionDisplayName: target.actionDisplayName },
95+
selectedRecordRef: target.selectedRecordRef,
96+
});
97+
98+
return this.buildOutcomeResult('awaiting-input');
99+
}
100+
101+
/**
102+
* Resolves the action name, calls executeAction, and persists execution data.
103+
* When `existingExecution` is provided (confirmation flow), it is spread into the
104+
* saved execution to preserve pendingAction for traceability.
105+
*/
106+
private async resolveAndExecute(
107+
target: TriggerTarget,
108+
existingExecution?: TriggerActionStepExecutionData,
109+
): Promise<StepExecutionResult> {
110+
const { selectedRecordRef, actionDisplayName } = target;
111+
let actionName: string;
112+
113+
try {
114+
const schema = await this.getCollectionSchema(selectedRecordRef.collectionName);
115+
actionName = this.resolveActionName(schema, actionDisplayName);
116+
// Return value intentionally discarded: action results may contain client data
117+
// and must not leave the client's infrastructure (privacy constraint).
118+
await this.context.agentPort.executeAction(selectedRecordRef.collectionName, actionName, [
119+
selectedRecordRef.recordId,
120+
]);
121+
} catch (error) {
122+
if (error instanceof WorkflowExecutorError) {
123+
return this.buildOutcomeResult('error', error.message);
124+
}
125+
126+
throw error;
127+
}
128+
129+
await this.context.runStore.saveStepExecution(this.context.runId, {
130+
...existingExecution,
131+
type: 'trigger-action',
132+
stepIndex: this.context.stepIndex,
133+
executionParams: { actionDisplayName, actionName },
134+
executionResult: { success: true },
135+
selectedRecordRef,
136+
});
137+
138+
return this.buildOutcomeResult('success');
139+
}
140+
141+
private async selectAction(
142+
schema: CollectionSchema,
143+
prompt: string | undefined,
144+
): Promise<{ actionDisplayName: string; reasoning: string }> {
145+
const tool = this.buildSelectActionTool(schema);
146+
const messages = [
147+
...(await this.buildPreviousStepsMessages()),
148+
new SystemMessage(TRIGGER_ACTION_SYSTEM_PROMPT),
149+
new SystemMessage(
150+
`The selected record belongs to the "${schema.collectionDisplayName}" collection.`,
151+
),
152+
new HumanMessage(`**Request**: ${prompt ?? 'Trigger the relevant action.'}`),
153+
];
154+
155+
return this.invokeWithTool<{ actionDisplayName: string; reasoning: string }>(messages, tool);
156+
}
157+
158+
private buildSelectActionTool(schema: CollectionSchema): DynamicStructuredTool {
159+
if (schema.actions.length === 0) {
160+
throw new NoActionsError(schema.collectionName);
161+
}
162+
163+
const displayNames = schema.actions.map(a => a.displayName) as [string, ...string[]];
164+
const technicalNames = schema.actions
165+
.map(a => `${a.displayName} (technical name: ${a.name})`)
166+
.join(', ');
167+
168+
return new DynamicStructuredTool({
169+
name: 'select-action',
170+
description: 'Select the action to trigger on the record.',
171+
schema: z.object({
172+
actionDisplayName: z
173+
.enum(displayNames)
174+
.describe(`The display name of the action to trigger. Available: ${technicalNames}`),
175+
reasoning: z.string().describe('Why this action was chosen'),
176+
}),
177+
func: undefined,
178+
});
179+
}
180+
181+
private resolveActionName(schema: CollectionSchema, displayName: string): string {
182+
const action =
183+
schema.actions.find(a => a.displayName === displayName) ??
184+
schema.actions.find(a => a.name === displayName);
185+
186+
if (!action) {
187+
throw new WorkflowExecutorError(
188+
`Action "${displayName}" not found in collection "${schema.collectionName}"`,
189+
);
190+
}
191+
192+
return action.name;
193+
}
194+
}

packages/workflow-executor/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type {
1919
ConditionStepExecutionData,
2020
ReadRecordStepExecutionData,
2121
UpdateRecordStepExecutionData,
22+
TriggerActionStepExecutionData,
2223
RecordTaskStepExecutionData,
2324
LoadRelatedRecordStepExecutionData,
2425
ExecutedStepExecutionData,
@@ -55,11 +56,13 @@ export {
5556
NoReadableFieldsError,
5657
NoResolvedFieldsError,
5758
NoWritableFieldsError,
59+
NoActionsError,
5860
} from './errors';
5961
export { default as BaseStepExecutor } from './executors/base-step-executor';
6062
export { default as ConditionStepExecutor } from './executors/condition-step-executor';
6163
export { default as ReadRecordStepExecutor } from './executors/read-record-step-executor';
6264
export { default as UpdateRecordStepExecutor } from './executors/update-record-step-executor';
65+
export { default as TriggerActionStepExecutor } from './executors/trigger-action-step-executor';
6366
export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port';
6467
export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port';
6568
export { default as ExecutorHttpServer } from './http/executor-http-server';

packages/workflow-executor/src/types/step-execution-data.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ export interface UpdateRecordStepExecutionData extends BaseStepExecutionData {
5555
selectedRecordRef: RecordRef;
5656
}
5757

58+
// -- Trigger Action --
59+
60+
export interface TriggerActionStepExecutionData extends BaseStepExecutionData {
61+
type: 'trigger-action';
62+
/** Display name and technical name of the executed action. */
63+
executionParams?: { actionDisplayName: string; actionName: string };
64+
executionResult?: { success: true } | { skipped: true };
65+
/** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */
66+
pendingAction?: { actionDisplayName: string };
67+
selectedRecordRef: RecordRef;
68+
}
69+
5870
// -- Generic AI Task (fallback for untyped steps) --
5971

6072
export interface RecordTaskStepExecutionData extends BaseStepExecutionData {
@@ -77,13 +89,15 @@ export type StepExecutionData =
7789
| ConditionStepExecutionData
7890
| ReadRecordStepExecutionData
7991
| UpdateRecordStepExecutionData
92+
| TriggerActionStepExecutionData
8093
| RecordTaskStepExecutionData
8194
| LoadRelatedRecordStepExecutionData;
8295

8396
export type ExecutedStepExecutionData =
8497
| ConditionStepExecutionData
8598
| ReadRecordStepExecutionData
8699
| UpdateRecordStepExecutionData
100+
| TriggerActionStepExecutionData
87101
| RecordTaskStepExecutionData;
88102

89103
// TODO: this condition should change when load-related-record gets its own executor

0 commit comments

Comments
 (0)