Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ interface ServerWorkflowTaskUpdateData extends ServerWorkflowTaskBase {

interface ServerWorkflowTaskTriggerAction extends ServerWorkflowTaskBase {
taskType: ServerTaskTypeEnum.TriggerAction;
// Manual is valid for a form-bearing action (PRD-511): pause for the user with no AI prefill.
executionType:
| ServerStepExecutionTypeEnum.Manual
| ServerStepExecutionTypeEnum.FullyAutomated
| ServerStepExecutionTypeEnum.AutomatedWithConfirmation;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/workflow-executor/src/executors/agent-with-log.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type ActivityLog from './activity-log';
import type {
ActionForm,
AgentPort,
ExecuteActionQuery,
GetActionFormInfoQuery,
GetActionFormQuery,
GetRecordQuery,
GetRelatedDataQuery,
GetSingleRelatedDataQuery,
Expand Down Expand Up @@ -117,6 +119,12 @@ export default class AgentWithLog {
return this.agentPort.getActionFormInfo(query, this.user);
}

// Unaudited passthrough: reading the form structure (and applying values to reveal dependent
// fields via change hooks) is read-only — the actual execution is what gets logged (PRD-509/511).
getActionForm(query: GetActionFormQuery): Promise<ActionForm> {
return this.agentPort.getActionForm(query, this.user);
}

// Unaudited passthrough: resolves a polymorphic relation's target type (metadata probe). The
// actual related-record load is audited separately, so this records NO activity-log entry.
resolvePolymorphicType(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ActionForm, ActionFormField } from '../ports/agent-port';
import type { StepExecutionResult } from '../types/execution-context';
import type { ActionRef, TriggerRecordActionStepExecutionData } from '../types/step-execution-data';
import type {
ActionRef,
AiFilledFormValue,
TriggerRecordActionStepExecutionData,
} from '../types/step-execution-data';
import type { ActionSchema, CollectionSchema, RecordRef } from '../types/validated/collection';
import type { TriggerActionStepDefinition } from '../types/validated/step-definition';

Expand All @@ -23,6 +28,16 @@ Important rules:
- Final answer is definitive, you won't receive any other input from the user.
- Do not refer to yourself as "I" in the response, use a passive formulation instead.`;

const FILL_FORM_SYSTEM_PROMPT = `You are filling out an action form using the user's request and the data available in the workflow context.

Important rules:
- The request is an explicit instruction. When it states a value for a field (e.g. "set the price to 35"), use that exact value — following an explicit instruction is NOT guessing.
- Otherwise, fill a field only when the workflow context gives you the value with confidence.
- A field's "current" value is just its existing default; override it when the request asks you to.
- If neither the request nor the workflow context gives you a field's value, LEAVE IT OUT — never guess or assume.
- For Enum fields, use exactly one of the allowed values, otherwise leave the field out.
- Do not invent identifiers, dates, amounts, or file contents that are absent from both the request and the context.`;

interface ActionTarget extends ActionRef {
selectedRecordRef: RecordRef;
}
Expand Down Expand Up @@ -56,9 +71,16 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
async exec => {
const { selectedRecordRef, pendingData, userConfirmation } = exec;

// The frontend executes the action itself and posts the result back.
// A confirmed step without actionResult is a broken frontend contract.
if (!pendingData || !userConfirmation || !('actionResult' in userConfirmation)) {
// The frontend executes the action natively and posts the result back. A confirmed step
// must carry an actionResult — UNLESS the submission only created an approval request
// (pending-approval), in which case no result exists yet (PRD-511/520).
const isPendingApproval = userConfirmation?.submissionOutcome === 'pending-approval';

if (
!pendingData ||
!userConfirmation ||
(!('actionResult' in userConfirmation) && !isPendingApproval)
) {
throw new StepStateError(
`Frontend confirmed action but did not provide actionResult ` +
`(run "${this.context.runId}", step ${this.context.stepIndex})`,
Expand All @@ -71,7 +93,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
name: pendingData.name,
};

return this.saveFrontendResult(target, userConfirmation.actionResult, exec);
return this.saveFrontendResult(target, exec);
},
);
}
Expand Down Expand Up @@ -104,31 +126,192 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
name: action.name,
};

// Branch B -- fully automated: executor runs the action itself, so it cannot
// handle forms (no UI to fill them). Reject form-bearing actions here. When the
// frontend is in the loop (Branch C), it handles the form natively so no check.
if (step.executionType === StepExecutionMode.FullyAutomated) {
const { hasForm } = await this.context.agent.getActionFormInfo({
collection: selectedRecordRef.collectionName,
action: target.name,
id: selectedRecordRef.recordId,
});
if (hasForm) throw new UnsupportedActionFormError(target.displayName);
const form = await this.context.agent.getActionForm({
collection: selectedRecordRef.collectionName,
action: target.name,
id: selectedRecordRef.recordId,
});
const hasForm = form.fields.length > 0;

// Formless action — unchanged behavior: Full AI runs it directly, otherwise pause for the user.
if (!hasForm) {
return step.executionType === StepExecutionMode.FullyAutomated
? this.executeOnExecutor(target)
: this.pauseForConfirmation(target);
}

// Manual (PRD-511): pause with the native form, NO AI pre-fill.
if (step.executionType === StepExecutionMode.Manual) {
return this.pauseForConfirmation(target, { fields: form.fields, aiFilledValues: [] });
}

return this.executeOnExecutor(target);
// Full AI on a form is implemented in PRD-512 (fill + submit). Until then, unsupported.
if (step.executionType === StepExecutionMode.FullyAutomated) {
throw new UnsupportedActionFormError(target.displayName);
}

// Branch C -- Awaiting confirmation (frontend executes the action, including forms)
// AI-assisted (PRD-511): AI pre-fills what it can from the workflow context, then pause for
// the user to review/edit/submit natively.
const { aiFilledValues, form: filledForm } = await this.fillFormWithAi(
selectedRecordRef,
target.name,
form,
);

return this.pauseForConfirmation(target, { fields: filledForm.fields, aiFilledValues });
}

// Pause the step awaiting user confirmation. For form-bearing actions, `form` carries the native
// form fields + the ordered AI prefill the front replays sequentially (PRD-511).
private async pauseForConfirmation(
target: ActionTarget,
form?: { fields: ActionFormField[]; aiFilledValues: AiFilledFormValue[] },
): Promise<StepExecutionResult> {
await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'trigger-action',
stepIndex: this.context.stepIndex,
pendingData: { displayName: target.displayName, name: target.name },
pendingData: { displayName: target.displayName, name: target.name, ...(form && { form }) },
selectedRecordRef: target.selectedRecordRef,
});

return this.buildOutcomeResult({ status: 'awaiting-input' });
}

// Shared AI form-fill loop (PRD-511, reused by Full AI in PRD-512). Iteratively asks the AI to
// fill the fields it has context for (leave-empty-if-unsure), re-applying after each pass so
// change hooks reveal dependent fields. Bounded by max iterations + no-progress detection so an
// oscillating dynamic form can't loop forever. Returns the values in fill order + the final form.
private async fillFormWithAi(
recordRef: RecordRef,
action: string,
initialForm: ActionForm,
): Promise<{ aiFilledValues: AiFilledFormValue[]; form: ActionForm }> {
const MAX_ITERATIONS = 3;
const accumulator: Record<string, unknown> = {};
const ordered: AiFilledFormValue[] = [];
let form = initialForm;

for (let i = 0; i < MAX_ITERATIONS; i += 1) {
// eslint-disable-next-line no-await-in-loop
const aiValues = await this.askAiToFillForm(form);
let progressed = false;

for (const [field, value] of Object.entries(aiValues)) {
const isEmpty = value === undefined || value === null || value === '';
const exists = form.fields.some(f => f.name === field);
const isNew = accumulator[field] !== value;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low executors/trigger-record-action-step-executor.ts:202

The isNew check on line 202 uses !== (reference comparison), so when the AI returns a structurally identical object or array value across iterations (same content, different reference), it is treated as "new." This pushes duplicate entries into ordered, inflating the aiFilledValues audit trail and triggering unnecessary sequential replays on the frontend. Consider using a value-based comparison such as JSON.stringify.

Suggested change
const isNew = accumulator[field] !== value;
const isNew = JSON.stringify(accumulator[field]) !== JSON.stringify(value);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts around line 202:

The `isNew` check on line 202 uses `!==` (reference comparison), so when the AI returns a structurally identical object or array value across iterations (same content, different reference), it is treated as "new." This pushes duplicate entries into `ordered`, inflating the `aiFilledValues` audit trail and triggering unnecessary sequential replays on the frontend. Consider using a value-based comparison such as `JSON.stringify`.

Evidence trail:
packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts lines 190-213 (loop with `accumulator[field] !== value` on line 202); lines 244-305 (`askAiToFillForm` returns `Record<string, unknown>`, schema uses `z.unknown()` at line 267); packages/workflow-executor/src/ports/agent-port.ts lines 61-68 (ActionFormField type is `string`, value is `unknown`); packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 300-311 (field types include 'Enum' and others via `field.getType()`); packages/workflow-executor/src/types/step-execution-data.ts lines 84-89 (AiFilledFormValue interface with `value: unknown`).


// Keep only non-empty values for fields that still exist and weren't already set.
if (!isEmpty && exists && isNew) {
accumulator[field] = value;
ordered.push({ field, value });
progressed = true;
}
}

// No-progress guard: the AI added nothing new this pass → it has no more context to offer.
if (!progressed) break;

// Re-apply so change hooks reveal/adjust dependent fields for the next pass.
// eslint-disable-next-line no-await-in-loop
form = await this.context.agent.getActionForm({
collection: recordRef.collectionName,
action,
id: recordRef.recordId,
values: accumulator,
});

if (form.canExecute) break;
}

// Drop any value whose field no longer exists after the hooks (state drift) — fail-safe.
const finalFieldNames = new Set(form.fields.map(f => f.name));
const aiFilledValues = ordered.filter(v => finalFieldNames.has(v.field));

// Debug trace for support: the net field values actually retained (after drop-stale) + whether
// the form is now complete enough to submit. Off by default (Debug level). Client-side log only.
this.context.logger('Debug', 'AI form-fill: final values', {
...this.logCtx,
aiFilledValues,
canExecute: form.canExecute,
});

return { aiFilledValues, form };
}

// One AI fill pass: present the current form fields and ask the AI for values it's confident
// about. The strict leave-empty rule (never guess) lives in the prompt + tool description.
private async askAiToFillForm(form: ActionForm): Promise<Record<string, unknown>> {
const { stepDefinition: step } = this.context;
const fieldLines = form.fields
.map(field => {
const parts = [`- ${field.name} (${field.type}${field.isRequired ? ', required' : ''})`];
if (field.enumValues?.length) parts.push(`allowed: ${field.enumValues.join(', ')}`);

if (field.value !== undefined && field.value !== null) {
parts.push(`current: ${JSON.stringify(field.value)}`);
}

return parts.join(' — ');
})
.join('\n');

const tool = new DynamicStructuredTool({
name: 'fill_action_form',
description:
'Provide values for the action form fields you have enough context to fill. ' +
'Return a `values` object keyed by field name. Leave a field OUT entirely if you are ' +
'not sure — never guess or assume. For Enum fields use exactly one of the allowed values.',
schema: z.object({
values: z
.record(z.string(), z.unknown())
.optional()
.describe('Field name → value, only for fields you are confident about.'),
}),
func: undefined,
});

const contextMessage = this.buildContextMessage();
const previousStepsMessages = await this.buildPreviousStepsMessages();
const messages = [
contextMessage,
...previousStepsMessages,
new SystemMessage(FILL_FORM_SYSTEM_PROMPT),
new SystemMessage(`Action form fields:\n${fieldLines}`),
new HumanMessage(`**Request**: ${step.prompt ?? 'Fill the action form.'}`),
];

// Debug trace for support: the inputs the AI fill works from. Off by default (Debug level); a
// client turns it on with LOG_LEVEL=Debug to diagnose an under-/mis-filled form. Logged before
// the call so it's available even if the AI invocation fails. Client-side log only.
// Only the non-redundant parts: the request (instruction), the fields as structured rows, and
// the workflow context (record + previous steps) — the static fill rules aren't logged.
this.context.logger('Debug', 'AI form-fill: context', {
...this.logCtx,
request: step.prompt ?? null,
fields: form.fields.map(field => ({
name: field.name,
type: field.type,
required: field.isRequired,
current: field.value,
...(field.enumValues?.length ? { allowed: field.enumValues } : {}),
})),
workflowContext: [contextMessage, ...previousStepsMessages].map(message => message.content),
});

const { values } = await this.invokeWithTool<{ values?: Record<string, unknown> }>(
messages,
tool,
);

this.context.logger('Debug', 'AI form-fill: values returned by the AI', {
...this.logCtx,
values: values ?? {},
});

return values ?? {};
}

/** Branch B — executor runs the action via the audited agent, then persists the result. */
private async executeOnExecutor(target: ActionTarget): Promise<StepExecutionResult> {
const { selectedRecordRef, displayName, name } = target;
Expand Down Expand Up @@ -162,20 +345,33 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
return this.buildOutcomeResult({ status: 'success' });
}

/** Branch A — the frontend executed the action; executor only persists the result it sent. */
/**
* Branch A — the frontend executed the action natively; the executor persists what it reported.
* Records the submission outcome (executed vs pending-approval), the submitted values, and the
* AI prefill (from the stored pending payload) for the audit trail / downstream-AI context.
*/
private async saveFrontendResult(
target: ActionTarget,
actionResult: unknown,
existingExecution: TriggerRecordActionStepExecutionData,
): Promise<StepExecutionResult> {
const { selectedRecordRef, displayName, name } = target;
const confirmation = existingExecution.userConfirmation;
const submissionOutcome = confirmation?.submissionOutcome ?? 'executed';
const aiFilledValues = existingExecution.pendingData?.form?.aiFilledValues;

await this.context.runStore.saveStepExecution(this.context.runId, {
...existingExecution,
type: 'trigger-action',
stepIndex: this.context.stepIndex,
executionParams: { displayName, name },
executionResult: { success: true, actionResult },
executionResult: {
success: true,
// No action result exists yet when the submission only created an approval request.
...(submissionOutcome === 'executed' && { actionResult: confirmation?.actionResult }),
submissionOutcome,
...(confirmation?.submittedValues && { submittedValues: confirmation.submittedValues }),
...(aiFilledValues?.length && { aiFilledValues }),
},
selectedRecordRef,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const triggerActionPatchSchema = z
// presence check lives in the step-executor so a descriptive StepStateError can
// name the runId/stepIndex — not achievable from inside a zod schema.
actionResult: z.unknown().optional(),
// PRD-511/520: the front executes the action natively, so it self-reports the final form
// values it submitted (lets the executor diff against the AI prefill for the audit trail).
submittedValues: z.record(z.string(), z.unknown()).optional(),
// Whether the native submit actually executed the action, or only created an approval request
// (non-blocking): downstream AI steps must be told an awaiting-approval action did NOT run.
submissionOutcome: z.enum(['executed', 'pending-approval']).optional(),
})
.strict();

Expand Down
Loading
Loading