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
1 change: 1 addition & 0 deletions packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface ServerWorkflowTaskUpdateData extends ServerWorkflowTaskBase {
executionType:
| ServerStepExecutionTypeEnum.FullyAutomated
| ServerStepExecutionTypeEnum.AutomatedWithConfirmation;
preRecordedArgs?: { selectedRecordStepId?: string; fieldName?: string; value?: unknown };
}

interface ServerWorkflowTaskTriggerAction extends ServerWorkflowTaskBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ function mapTask(task: ServerWorkflowTask): StepDefinition {
case ServerTaskTypeEnum.GetData:
return ReadRecordStepDefinitionSchema.parse({ ...base, type: StepType.ReadRecord });
case ServerTaskTypeEnum.UpdateData:
return UpdateRecordStepDefinitionSchema.parse({ ...base, type: StepType.UpdateRecord });
return UpdateRecordStepDefinitionSchema.parse({
...base,
type: StepType.UpdateRecord,
preRecordedArgs: task.preRecordedArgs,
});
case ServerTaskTypeEnum.TriggerAction:
return TriggerActionStepDefinitionSchema.parse({
...base,
Expand Down
136 changes: 103 additions & 33 deletions packages/workflow-executor/src/executors/update-record-step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,27 @@
)
.describe('JSON content as a valid JSON string');

function buildZodSchemaForPrimitive(type: string, enumValues?: string[]): z.ZodTypeAny {
// `forAiTool` = the schema is bound into an AI tool (langchain converts it to JSON Schema). The
// Boolean coercion uses `z.preprocess`, which langchain's cross-package transform-stripping misses
// when the workspace resolves two zod copies (root vs nested) → "Transforms cannot be represented
// in JSON Schema". For the AI tool we hand it a plain z.boolean(); coerceFieldValue (which builds
// with forAiTool=false) still normalizes any stray "true"/"false" string afterwards.
function buildZodSchemaForPrimitive(
type: string,
enumValues?: string[],
forAiTool = false,
): z.ZodTypeAny {
switch (type) {
case 'Boolean':
return z.preprocess(val => {
if (typeof val !== 'string') return val;
if (val === 'true') return true;
if (val === 'false') return false;

return val;
}, z.boolean());
return forAiTool
? z.boolean()
: z.preprocess(val => {
if (typeof val !== 'string') return val;
if (val === 'true') return true;
if (val === 'false') return false;

return val;
}, z.boolean());
case 'Date':
return z.iso.datetime().describe('ISO 8601 datetime, e.g. 2024-06-01T00:00:00Z');
case 'Dateonly':
Expand All @@ -74,7 +85,11 @@
}
}

function buildZodSchemaForField(field: FieldSchema, collectionName: string): z.ZodTypeAny {
function buildZodSchemaForField(
field: FieldSchema,
collectionName: string,
forAiTool = false,
): z.ZodTypeAny {
const { type, enumValues } = field;

// A writable (non-relationship) field with no column type is a malformed schema for this update:
Expand All @@ -87,14 +102,14 @@
// Nested array (e.g. [['String']]) → treat as opaque JSON.
if (Array.isArray(type[0])) return z.array(jsonStringSchema);

return z.array(buildZodSchemaForPrimitive(type[0] as string, enumValues));
return z.array(buildZodSchemaForPrimitive(type[0] as string, enumValues, forAiTool));
}

if (typeof type === 'object' && type !== null) {
return jsonStringSchema;
}

return buildZodSchemaForPrimitive(type as string, enumValues);
return buildZodSchemaForPrimitive(type as string, enumValues, forAiTool);
}

// Coerce a user-overridden value to the field's native type before updating the record.
Expand Down Expand Up @@ -154,11 +169,11 @@
// A user override of `null` (clearing the field) must win over the AI suggestion, so
// distinguish "no override" (undefined) from "override to null".
const rawValue =
userConfirmation?.value !== undefined ? userConfirmation.value : pendingData!.value;

Check warning on line 172 in packages/workflow-executor/src/executors/update-record-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion

const target: UpdateTarget = {
selectedRecordRef,
...pendingData!,

Check warning on line 176 in packages/workflow-executor/src/executors/update-record-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion
// The value comes from an `unknown` HTTP value (may be a boolean or array), so coerce
// it to the field's native type before updating. Idempotent on already-typed values.
value: await this.coerceOverride(selectedRecordRef, pendingData, rawValue),
Expand Down Expand Up @@ -186,32 +201,41 @@
private async handleFirstCall(): Promise<StepExecutionResult> {
const { stepDefinition: step } = this.context;
const { preRecordedArgs } = step;
const records = await this.getAvailableRecordRefs();

const selectedRecordRef = await this.resolveRecordRef(
records,
step.prompt,
preRecordedArgs?.selectedRecordStepIndex,
);
const selectedRecordRef = preRecordedArgs?.selectedRecordStepId
? await this.resolveSourceRecordRef(preRecordedArgs.selectedRecordStepId)
Comment thread
EnkiP marked this conversation as resolved.
: await this.resolveRecordRef(
await this.getAvailableRecordRefs(),
step.prompt,
preRecordedArgs?.selectedRecordStepIndex,
);
const schema = await this.getCollectionSchema(selectedRecordRef.collectionName);

if (preRecordedArgs?.fieldName !== undefined && preRecordedArgs?.value === undefined) {
throw new InvalidPreRecordedArgsError(
'fieldName and value must both be provided or both omitted',
);
}

// A value without a field is meaningless (nothing to write it to).
if (preRecordedArgs?.value !== undefined && preRecordedArgs?.fieldName === undefined) {
throw new InvalidPreRecordedArgsError(
'fieldName and value must both be provided or both omitted',
);
throw new InvalidPreRecordedArgsError('value was provided without a fieldName');
}

// Three build-time configurations, in order of determinism:
// - fieldName + value → fully deterministic, no AI.
// - fieldName only → field is pinned; the AI resolves just the value from the prompt.
// - neither → the AI picks both field and value.
const recordedField = preRecordedArgs?.fieldName;
const { fieldName, value } =
recordedField !== undefined
? { fieldName: recordedField, value: preRecordedArgs?.value }
: await this.selectFieldAndValue(schema, step.prompt);
let fieldName: string;
let value: unknown;

if (recordedField !== undefined && preRecordedArgs?.value !== undefined) {
fieldName = recordedField;
value = preRecordedArgs.value;
} else if (recordedField !== undefined) {
const field = this.findFieldByTechnicalName(schema, recordedField);
if (!field) throw new FieldNotFoundError(recordedField, schema.collectionName);
fieldName = recordedField;
value = await this.selectValueForField(schema, field, step.prompt);
} else {
({ fieldName, value } = await this.selectFieldAndValue(schema, step.prompt));
}

const field = this.findFieldByTechnicalName(schema, fieldName);

if (!field) {
Expand Down Expand Up @@ -302,12 +326,58 @@
input: { fieldName: string; value: unknown; reasoning: string };
}>(messages, tool);

const fieldName = this.resolveAiFieldName(schema, input.fieldName);

// The AI tool schema is JSON-Schema-safe (plain z.boolean() for Boolean), so it does not coerce
// a stray string — normalize to the field's native type, matching the pinned-field path.
return {
fieldName: this.resolveAiFieldName(schema, input.fieldName),
value: input.value,
fieldName,
value: coerceFieldValue(
this.findFieldByTechnicalName(schema, fieldName),
input.value,
schema.collectionName,
),
};
}

// Field is pinned at build time; only the value is AI-resolved from the prompt/workflow context.
private async selectValueForField(
schema: CollectionSchema,
field: FieldSchema,
prompt: string | undefined,
): Promise<unknown> {
if (field.type == null) {
throw new FieldTypeMissingError(field.fieldName, schema.collectionName);
}

const tool = new DynamicStructuredTool({
name: 'set-record-field-value',
description: `Provide the new value for the "${field.displayName}" field.`,
schema: z.object({
reasoning: z.string().describe('Why this value was chosen'),
value: buildZodSchemaForField(field, schema.collectionName, true).nullable(),
}),
func: undefined,
});

const messages = [
this.buildContextMessage(),
...(await this.buildPreviousStepsMessages()),
new SystemMessage(UPDATE_RECORD_SYSTEM_PROMPT),
new SystemMessage(
`The selected record belongs to the "${schema.collectionDisplayName}" collection. ` +
`Set the value of the "${field.displayName}" field.`,
),
new HumanMessage(`**Request**: ${prompt ?? `Set the "${field.displayName}" field.`}`),
];

const { value } = await this.invokeWithTool<{ value: unknown }>(messages, tool);

// The AI tool schema is JSON-Schema-safe (plain z.boolean() for Boolean), so it does not coerce
// a stray "true"/"42" string — coerceFieldValue normalizes the value to the field's native type.
return coerceFieldValue(field, value, schema.collectionName);
}

private buildUpdateFieldTool(schema: CollectionSchema): DynamicStructuredTool {
// Exclude type-less fields: they can't be coerced/written, so offering them to the AI would
// let a single drifted field fail the whole step. The override path still rejects an explicit
Expand All @@ -327,7 +397,7 @@
const fieldObjects = nonRelationFields.map(f =>
z.object({
fieldName: z.literal(f.displayName),
value: buildZodSchemaForField(f, schema.collectionName).nullable(),
value: buildZodSchemaForField(f, schema.collectionName, true).nullable(),
reasoning: z.string().describe('Why this field and value were chosen'),
}),
) as FieldObject[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export const UpdateRecordStepDefinitionSchema = z.object({
.catch(AutomatedWithConfirmation),
preRecordedArgs: z
.object({
/**
* "From record" — the source record to update, referenced by the stable BPMN step id of the
* previous Load Related Record step that loaded it, or WORKFLOW_START_STEP_ID for the trigger
* record. Stable across revisions, unlike the runtime stepIndex.
*/
selectedRecordStepId: z.string().optional(),
// Legacy runtime index; superseded by selectedRecordStepId. Kept for back-compat.
selectedRecordStepIndex: z.number().int().optional(),
/** Technical name of the field to update */
fieldName: z.string().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ describe('toStepDefinition', () => {
});
});

it('should forward preRecordedArgs on an update-data task', () => {
const task = makeTask({
taskType: ServerTaskTypeEnum.UpdateData,
prompt: 'update it',
preRecordedArgs: { selectedRecordStepId: 'load-1', fieldName: 'status', value: 'active' },
} as Partial<ServerWorkflowTask>);

expect(toStepDefinition(task)).toMatchObject({
type: StepType.UpdateRecord,
preRecordedArgs: { selectedRecordStepId: 'load-1', fieldName: 'status', value: 'active' },
});
});

it('should map task with trigger-action taskType to trigger-action', () => {
const task = makeTask({ taskType: ServerTaskTypeEnum.TriggerAction, prompt: 'trigger it' });

Expand Down
Loading
Loading