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
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 @@ -86,6 +86,7 @@ interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase {
executionType:
| ServerStepExecutionTypeEnum.FullyAutomated
| ServerStepExecutionTypeEnum.AutomatedWithConfirmation;
preRecordedArgs?: { selectedRecordStepId?: string; relationName?: string };
}

export interface ServerWorkflowTaskMcpServer extends ServerWorkflowTaskBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function mapTask(task: ServerWorkflowTask): StepDefinition {
return LoadRelatedRecordStepDefinitionSchema.parse({
...base,
type: StepType.LoadRelatedRecord,
preRecordedArgs: task.preRecordedArgs,
});
default:
throw new InvalidStepDefinitionError(
Expand Down
12 changes: 12 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,18 @@ export class InvalidPreRecordedArgsError extends WorkflowExecutorError {
}
}

// The "Related to" source step ran but loaded no record, so this step has nothing to load from.
// Distinct from a bad config — the user can continue without.
export class SourceRecordMissingError extends WorkflowExecutorError {
constructor(sourceTitle?: string) {
const from = sourceTitle ? `"${sourceTitle}"` : 'its source step';
super(
`Source step ${from} loaded no record`,
`This step loads from ${from}, but that step didn't load any record, so nothing can be loaded here.`,
);
}
}

// Boundary error — surfaces from Runner.start() and is caught at the CLI/HTTP layer, not by step executors.
export class AgentProbeError extends Error {
// Manual `cause` assignment: Error accepts it natively since Node 16.9 but our TS target is ES2020.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ import {
NoRelationshipFieldsError,
RelatedRecordNotFoundError,
RelationNotFoundError,
SourceRecordMissingError,
StepStateError,
} from '../errors';
import RecordStepExecutor from './record-step-executor';
import { StepExecutionMode } from '../types/validated/step-definition';
import {
StepExecutionMode,
StepType,
WORKFLOW_START_STEP_ID,
} from '../types/validated/step-definition';

const SELECT_RELATION_SYSTEM_PROMPT = `You are an AI agent loading a related record based on a user request.
You are given relations to follow, each shown as "<source record> → <relation> (→ <target collection>)".
Expand Down Expand Up @@ -145,12 +150,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
// 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;
preRecordedArgs?.selectedRecordStepId !== undefined
? [await this.resolveSourceRecordRef(preRecordedArgs.selectedRecordStepId)]
: await this.getAvailableRecordRefs();

const candidates = await this.buildRelationCandidates(sourceRecords);

Expand Down Expand Up @@ -187,14 +191,36 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
};
}

private requireRecordAtStepIndex(records: RecordRef[], stepIndex: number): RecordRef {
const match = records.find(r => r.stepIndex === stepIndex);
private async resolveSourceRecordRef(stepId: string): Promise<RecordRef> {
if (stepId === WORKFLOW_START_STEP_ID) {
return this.context.baseRecordRef;
}

const matches = this.context.previousSteps.filter(
step =>
step.stepDefinition.type === StepType.LoadRelatedRecord &&
step.stepOutcome.stepId === stepId,
);
const sourceStep = matches[matches.length - 1];

if (sourceStep) {
const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId);
const execution = this.resolveStepExecution(sourceStep, stepExecutions);

if (!match) {
throw new InvalidPreRecordedArgsError(`No record found at step index ${stepIndex}`);
if (
execution?.type === 'load-related-record' &&
execution.executionResult !== undefined &&
'record' in execution.executionResult
) {
return execution.executionResult.record;
}

// The source step exists but loaded nothing → clear "no source record" message,
// distinct from a config pointing at a non-existent step.
throw new SourceRecordMissingError(sourceStep.stepDefinition.title);
}

return match;
throw new InvalidPreRecordedArgsError(`No source record found for step "${stepId}"`);
}

private async buildRelationCandidates(records: RecordRef[]): Promise<RelationCandidate[]> {
Expand Down Expand Up @@ -473,29 +499,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
return { relatedData, bestIndex: 0, suggestedFields: [], relatedSchema };
}

const { preRecordedArgs } = this.context.stepDefinition;

if (preRecordedArgs?.selectedRecordIndex !== undefined) {
if (
!Number.isInteger(preRecordedArgs.selectedRecordIndex) ||
preRecordedArgs.selectedRecordIndex < 0 ||
preRecordedArgs.selectedRecordIndex >= relatedData.length
) {
throw new InvalidPreRecordedArgsError(
`Record index ${preRecordedArgs.selectedRecordIndex} is out of range (0-${
relatedData.length - 1
})`,
);
}

return {
relatedData,
bestIndex: preRecordedArgs.selectedRecordIndex,
suggestedFields: [],
relatedSchema,
};
}

// The final record stays AI-suggested + user-confirmed — only the source + relation are
Comment thread
Scra3 marked this conversation as resolved.
// pinned deterministically. Index-based record pinning was removed (not revise-safe).
const suggestedFields = await this.selectRelevantFields(
relatedSchema,
this.context.stepDefinition.prompt,
Expand Down
14 changes: 11 additions & 3 deletions packages/workflow-executor/src/types/validated/step-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,23 @@ export const LoadRelatedRecordStepDefinitionSchema = z.object({
.catch(AutomatedWithConfirmation),
preRecordedArgs: z
.object({
selectedRecordStepIndex: z.number().int().optional(),
/** Technical name of the relation to follow */
/**
* "Related to" — the source record, 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 (clones keep the id) and known at build time (unlike the runtime
* stepIndex), so the editor can write it deterministically.
*/
selectedRecordStepId: z.string().optional(),
/** "From collection" — the relation to follow (technical name) */
relationName: z.string().optional(),
selectedRecordIndex: z.number().int().optional(),
})
.optional(),
});
export type LoadRelatedRecordStepDefinition = z.infer<typeof LoadRelatedRecordStepDefinitionSchema>;

/** Sentinel "Related to" reference for the record the workflow was triggered on (the base record). */
export const WORKFLOW_START_STEP_ID = 'workflow-start';

export const McpStepDefinitionSchema = z.object({
...sharedFields,
type: z.literal(StepType.Mcp),
Expand Down
Loading
Loading