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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
function mapTask(task: ServerWorkflowTask): StepDefinition {
// executionType is passed through as-is; each schema's .default().catch() handles
// missing or unsupported values without requiring an explicit mapping here.
const base = { prompt: task.prompt, executionType: task.executionType };
const base = { prompt: task.prompt, executionType: task.executionType, title: task.title };

switch (task.taskType) {
case ServerTaskTypeEnum.McpServer:
Expand Down Expand Up @@ -65,6 +65,7 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti
type: StepType.Condition,
prompt: condition.prompt,
executionType: condition.executionType,
title: condition.title,
options,
});
}
Expand Down
19 changes: 11 additions & 8 deletions packages/workflow-executor/src/executors/base-step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,16 +241,19 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
}

protected buildContextMessage(): SystemMessage {
const { user } = this.context;
const { user, stepDefinition } = this.context;
const now = new Date();

return new SystemMessage(
[
`Step executed by: ${user.firstName} ${user.lastName} (${user.email}, id: ${user.id})`,
`Role: ${user.role} | Team: ${user.team}`,
`Current date and time: ${now.toISOString()} (UTC)`,
].join('\n'),
);
const lines = [
`Step executed by: ${user.firstName} ${user.lastName} (${user.email}, id: ${user.id})`,
`Role: ${user.role} | Team: ${user.team}`,
`Current date and time: ${now.toISOString()} (UTC)`,
];

// The step title carries the designer's intent — useful when the prompt is weak or empty.
if (stepDefinition.title) lines.push(`Step title: "${stepDefinition.title}"`);

return new SystemMessage(lines.join('\n'));
}

protected async buildPreviousStepsMessages(): Promise<SystemMessage[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import type {
LoadRelatedRecordStepExecutionData,
RelationRef,
} from '../types/step-execution-data';
import type { CollectionSchema, RecordData, RecordRef } from '../types/validated/collection';
import type {
CollectionSchema,
FieldSchema,
RecordData,
RecordRef,
} from '../types/validated/collection';
import type { LoadRelatedRecordStepDefinition } from '../types/validated/step-definition';

import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy';
Expand All @@ -22,10 +27,12 @@ import RecordStepExecutor from './record-step-executor';
import { StepExecutionMode } from '../types/validated/step-definition';

const SELECT_RELATION_SYSTEM_PROMPT = `You are an AI agent loading a related record based on a user request.
Select the relation to follow.
You are given relations to follow, each shown as "<source record> → <relation> (→ <target collection>)".
Choose the relation that LEADS TO the collection the user wants to load — decide from each
relation's target collection, NOT from which source record happens to resemble the request.

Important rules:
- Be precise: only select the relation directly relevant to the request.
- Pick the relation whose target collection matches the requested record.
- 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.`;

Expand Down Expand Up @@ -54,6 +61,14 @@ interface RelationTarget extends RelationRef {
relatedCollectionName: string;
}

// A relationship reachable from one available record — the unit the AI chooses among.
// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates filters on it).
interface RelationCandidate {
record: RecordRef;
schema: CollectionSchema;
field: FieldSchema & { relatedCollectionName: string };
}

export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<LoadRelatedRecordStepDefinition> {
protected async doExecute(): Promise<StepExecutionResult> {
// Branch A -- Re-entry after pending execution found in RunStore
Expand Down Expand Up @@ -105,26 +120,104 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo

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 schema = await this.getCollectionSchema(selectedRecordRef.collectionName);
const args = preRecordedArgs?.relationDisplayName
? { relationName: preRecordedArgs.relationDisplayName }
: await this.selectRelation(schema, step.prompt);
const target = this.buildTarget(schema, args.relationName, selectedRecordRef);
const target = await this.resolveTarget();

// Branch B -- fully automated execution
if (step.executionType === StepExecutionMode.FullyAutomated) {
return this.resolveAndLoadAutomatic(target);
}

// Branch C -- pre-fetch candidates, await user confirmation
return this.saveAndAwaitInput(target, schema);
const sourceSchema = await this.getCollectionSchema(target.selectedRecordRef.collectionName);

return this.saveAndAwaitInput(target, sourceSchema);
}

// Picks the (record, relation) pair to follow. Unlike a separate record-then-relation choice,
// this lets the AI decide by what each relation LEADS TO — so "load the dvd" follows
// store→dvds rather than latching onto a previously-loaded dvd whose collection just matches.
private async resolveTarget(): Promise<RelationTarget> {
const { preRecordedArgs } = this.context.stepDefinition;
const records = await this.getAvailableRecordRefs();

const sourceRecords =
preRecordedArgs?.selectedRecordStepIndex !== undefined
? [this.requireRecordAtStepIndex(records, preRecordedArgs.selectedRecordStepIndex)]
: records;

const candidates = await this.buildRelationCandidates(sourceRecords);

if (candidates.length === 0) {
throw new NoRelationshipFieldsError(sourceRecords[0]?.collectionName ?? 'unknown');
}

const pinned = preRecordedArgs?.relationDisplayName;
const eligible = pinned
? candidates.filter(c => this.matchesRelation(c.field, pinned))
: candidates;

if (eligible.length === 0) {
// Relations exist, but the pre-recorded one doesn't match any of them.
throw new InvalidPreRecordedArgsError(
`No relation matching "${pinned}" on the selected record`,
);
}

const chosen =
eligible.length === 1 ? eligible[0] : await this.selectRelationToFollow(eligible);

return this.targetFromCandidate(chosen);
}

private targetFromCandidate(candidate: RelationCandidate): RelationTarget {
const { record, field } = candidate;

return {
selectedRecordRef: record,
displayName: field.displayName,
name: field.fieldName,
relationType: field.relationType,
relatedCollectionName: field.relatedCollectionName,
};
}

private requireRecordAtStepIndex(records: RecordRef[], stepIndex: number): RecordRef {
const match = records.find(r => r.stepIndex === stepIndex);

if (!match) {
throw new InvalidPreRecordedArgsError(`No record found at step index ${stepIndex}`);
}

return match;
}

private async buildRelationCandidates(records: RecordRef[]): Promise<RelationCandidate[]> {
const candidates: RelationCandidate[] = [];

for (const record of records) {
// eslint-disable-next-line no-await-in-loop
const schema = await this.getCollectionSchema(record.collectionName);

for (const field of schema.fields) {
if (field.isRelationship && field.relatedCollectionName) {
candidates.push({
record,
schema,
field: { ...field, relatedCollectionName: field.relatedCollectionName },
});
}
}
}

return candidates;
}

private matchesRelation(field: FieldSchema, relationDisplayName: string): boolean {
// Normalize like findField, so a pre-recorded "my_relation" still matches "My Relation".
const normalize = (s: string) => s.toLowerCase().replace(/[\s_-]/g, '');
const target = normalize(relationDisplayName);

return normalize(field.displayName) === target || normalize(field.fieldName) === target;
}

private buildTarget(
Expand Down Expand Up @@ -449,47 +542,53 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
return this.buildOutcomeResult({ status: 'success' });
}

private async selectRelation(
schema: CollectionSchema,
prompt: string | undefined,
): Promise<{ relationName: string; reasoning: string }> {
const tool = this.buildSelectRelationTool(schema);
private relationOptionLabel(candidate: RelationCandidate): string {
const { record, schema, field } = candidate;

return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId} → ${field.displayName} (→ ${field.relatedCollectionName})`;
}

private async selectRelationToFollow(
candidates: RelationCandidate[],
): Promise<RelationCandidate> {
const labels = candidates.map(c => this.relationOptionLabel(c));
const labelTuple = labels as [string, ...string[]];

const tool = new DynamicStructuredTool({
name: 'select-relation-to-follow',
description: 'Select the relation to follow to load the requested related record.',
schema: z.object({
relation: z
.enum(labelTuple)
.describe('The relation to follow, chosen by the collection it leads to'),
reasoning: z.string().describe('Why this relation leads to the requested record'),
}),
func: undefined,
});

const messages = [
this.buildContextMessage(),
...(await this.buildPreviousStepsMessages()),
new SystemMessage(SELECT_RELATION_SYSTEM_PROMPT),
new SystemMessage(
`The selected record belongs to the "${schema.collectionDisplayName}" collection.`,
new HumanMessage(
`**Request**: ${this.context.stepDefinition.prompt ?? 'Load the relevant related record.'}`,
),
new HumanMessage(`**Request**: ${prompt ?? 'Load the relevant related record.'}`),
];

return this.invokeWithTool<{ relationName: string; reasoning: string }>(messages, tool);
}
const { relation } = await this.invokeWithTool<{ relation: string; reasoning: string }>(
messages,
tool,
);

private buildSelectRelationTool(schema: CollectionSchema): DynamicStructuredTool {
const relationFields = schema.fields.filter(f => f.isRelationship);
const index = labels.indexOf(relation);

if (relationFields.length === 0) {
throw new NoRelationshipFieldsError(schema.collectionName);
if (index === -1) {
throw new InvalidAIResponseError(
`AI selected relation "${relation}" which does not match any available option`,
);
}

const displayNames = relationFields.map(f => f.displayName) as [string, ...string[]];
const technicalNames = relationFields
.map(f => `${f.displayName} (technical name: ${f.fieldName})`)
.join(', ');

return new DynamicStructuredTool({
name: 'select-relation',
description: 'Select the relation to follow from the record.',
schema: z.object({
relationName: z
.enum(displayNames)
.describe(`The name of the relation to follow. Available: ${technicalNames}`),
reasoning: z.string().describe('Why this relation was chosen'),
}),
func: undefined,
});
return candidates[index];
}

/** AI call 1 for HasMany: selects the most relevant fields to compare candidates. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export enum StepExecutionMode {
const sharedFields = {
prompt: z.string().optional(),
aiConfigName: z.string().optional(),
title: z.string().optional(),
};

// Use z.enum(EnumObject), not z.nativeEnum — the latter is deprecated in zod 4.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe('toAvailableStepExecution', () => {
stepDefinition: {
type: StepType.ReadRecord,
prompt: 'prompt',
title: 'Task',
executionType: ServerStepExecutionTypeEnum.FullyAutomated,
},
previousSteps: [],
Expand Down Expand Up @@ -189,6 +190,7 @@ describe('toAvailableStepExecution', () => {
expect(result?.stepDefinition).toEqual({
type: StepType.Guidance,
prompt: 'follow the guide',
title: 'guidance',
executionType: ServerStepExecutionTypeEnum.Manual,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(task)).toEqual({
type: StepType.ReadRecord,
prompt: 'read it',
title: 'Test task',
executionType: ServerStepExecutionTypeEnum.FullyAutomated,
});
});
Expand All @@ -70,6 +71,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(task)).toEqual({
type: StepType.UpdateRecord,
prompt: 'update it',
title: 'Test task',
executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation,
});
});
Expand All @@ -80,6 +82,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(task)).toEqual({
type: StepType.TriggerAction,
prompt: 'trigger it',
title: 'Test task',
executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation,
});
});
Expand All @@ -93,6 +96,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(task)).toEqual({
type: StepType.LoadRelatedRecord,
prompt: 'load it',
title: 'Test task',
executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation,
});
});
Expand All @@ -108,6 +112,7 @@ describe('toStepDefinition', () => {
type: StepType.Mcp,
prompt: 'run mcp',
mcpServerId: 'mcp-abc',
title: 'Test task',
executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation,
});
});
Expand All @@ -128,6 +133,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(task)).toEqual({
type: StepType.Guidance,
prompt: 'guide them',
title: 'Test task',
executionType: StepExecutionMode.Manual,
});
});
Expand Down Expand Up @@ -196,6 +202,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(condition)).toEqual({
type: StepType.Condition,
prompt: 'Choose one',
title: 'Test condition',
options: ['Yes', 'No'],
executionType: StepExecutionMode.FullyAutomated,
});
Expand All @@ -210,6 +217,7 @@ describe('toStepDefinition', () => {
expect(toStepDefinition(condition)).toEqual({
type: StepType.Condition,
prompt: 'Choose one',
title: 'Test condition',
options: ['Approve', 'Reject'],
executionType: StepExecutionMode.FullyAutomated,
});
Expand Down
Loading
Loading