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 @@ -42,6 +42,10 @@ Choose the fields that are most useful for determining which record best matches
const SELECT_RECORD_SYSTEM_PROMPT = `You are an AI agent selecting the most relevant related record from a list of candidates.
Choose the record that best matches the user request based on the provided field values.`;

// Only appended when -1 is a valid answer. The base prompt tells the AI to always pick, so without
// this it forces a weak match on an impossible request instead of declining.
const SELECT_RECORD_NONE_ALLOWED_PROMPT = `If the request states a specific requirement that NO candidate satisfies, return -1 instead of forcing an arbitrary or loosely-related match. Do NOT return -1 merely because a match is imperfect — only when no candidate genuinely fits the request.`;

// Bound only what is sent to the AI in selectBestRecordIndex — the full candidate list is
// always returned to the front via availableRecordIds. These cap the prompt size, not the data.
const MAX_RELEVANT_FIELDS = 6;
Expand Down Expand Up @@ -103,13 +107,17 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
execution: LoadRelatedRecordStepExecutionData,
fieldName: string,
): Promise<StepExecutionResult> {
if (!execution.pendingData) {
if (!execution.pendingData || !execution.selectedRecordRef) {
throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`);
}

const suggestViaAi = this.context.stepDefinition.executionType !== StepExecutionMode.Manual;
const schema = await this.getCollectionSchema(execution.selectedRecordRef.collectionName);
const target = await this.buildTarget(schema, fieldName, execution.selectedRecordRef);
const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target);
const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(
target,
suggestViaAi,
);

await this.context.runStore.saveStepExecution(this.context.runId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium executors/load-related-record-step-executor.ts:122

refreshCandidatesForField spreads the old pendingData (line 126) without clearing suggestNoRecord. When a prior relation set suggestNoRecord: true and the user switches to a relation that returns a suggestedRecord, the saved pendingData still carries suggestNoRecord: true, so the UI keeps the "No record to load" state even though a valid record was just suggested. Consider recomputing suggestNoRecord in refreshCandidatesForField the same way saveAndAwaitInput does.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/load-related-record-step-executor.ts around line 122:

`refreshCandidatesForField` spreads the old `pendingData` (line 126) without clearing `suggestNoRecord`. When a prior relation set `suggestNoRecord: true` and the user switches to a relation that returns a `suggestedRecord`, the saved `pendingData` still carries `suggestNoRecord: true`, so the UI keeps the "No record to load" state even though a valid record was just suggested. Consider recomputing `suggestNoRecord` in `refreshCandidatesForField` the same way `saveAndAwaitInput` does.

...execution,
Expand All @@ -127,23 +135,22 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo

private async handleFirstCall(): Promise<StepExecutionResult> {
const { stepDefinition: step } = this.context;
const target = await this.resolveTarget();
const useAi = step.executionType !== StepExecutionMode.Manual;

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

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

return this.saveAndAwaitInput(target, sourceSchema);
return this.saveAndAwaitInput(target, sourceSchema, useAi);
}

// 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> {
private async resolveTarget(useAi: boolean): Promise<RelationTarget> {
const { preRecordedArgs } = this.context.stepDefinition;

const sourceRecords =
Expand All @@ -168,8 +175,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
);
}

// Manual never invokes the AI chooser → eligible[0], i.e. the workflow-start record's first
// relation (getAvailableRecordRefs lists the base record first). The user switches the relation
// at runtime; a non-base source needs deterministic "Related to" pinning (selectedRecordStepId).
const chosen =
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
eligible.length === 1 ? eligible[0] : await this.selectRelationToFollow(eligible);
eligible.length > 1 && useAi ? await this.selectRelationToFollow(eligible) : eligible[0];

return this.targetFromCandidate(chosen);
}
Expand Down Expand Up @@ -265,15 +275,36 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
};
}

// Branch C: AI suggests the best candidate, then awaits user confirmation. Save errors
// propagate directly — the relation-load hasn't run yet, so the step can be safely retried.
// Save errors propagate directly — the relation-load hasn't run yet, so the step can be retried.
private async saveAndAwaitInput(
target: RelationTarget,
sourceSchema: CollectionSchema,
suggestViaAi: boolean,
): Promise<StepExecutionResult> {
const { selectedRecordRef, name, displayName } = target;
const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(
target,
suggestViaAi,
);

return this.persistAwaitInput(target, sourceSchema, {
availableRecordIds,
suggestedRecord,
// An AI pass that yields no record is a deliberate "nothing relevant" → pre-check "No X to load".
// A Manual pass with no suggestion just means the user picks, so no pre-check.
suggestNoRecord: suggestViaAi && !suggestedRecord,
});
}

const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target);
private async persistAwaitInput(
target: RelationTarget,
sourceSchema: CollectionSchema,
pending: {
availableRecordIds: LoadRelatedRecordCandidate[];
suggestedRecord?: LoadRelatedRecordCandidate;
suggestNoRecord: boolean;
},
): Promise<StepExecutionResult> {
const { selectedRecordRef, name, displayName } = target;

const availableFields: RelationRef[] = sourceSchema.fields
.filter(isFollowableRelation)
Expand All @@ -285,16 +316,20 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
pendingData: {
availableFields,
suggestedField: { name, displayName },
availableRecordIds,
suggestedRecord,
availableRecordIds: pending.availableRecordIds,
suggestedRecord: pending.suggestedRecord,
...(pending.suggestNoRecord && { suggestNoRecord: true }),
},
selectedRecordRef,
});

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

private async collectCandidateIds(target: RelationTarget): Promise<{
private async collectCandidateIds(
target: RelationTarget,
suggestViaAi: boolean,
): Promise<{
availableRecordIds: LoadRelatedRecordCandidate[];
suggestedRecord?: LoadRelatedRecordCandidate;
}> {
Expand All @@ -309,6 +344,9 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
const { relatedData, bestIndex, relatedSchema } = await this.selectBestFromRelatedData(
target,
50,
// allowNone: the AI may judge no candidate relevant (→ "No X to load"); only meaningful when
// ranking (Manual passes rank=false and never calls the AI).
{ rank: suggestViaAi, allowNone: true },
);

if (relatedData.length === 0) {
Expand All @@ -325,7 +363,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo

return {
availableRecordIds: relatedData.map(toCandidate),
suggestedRecord: toCandidate(relatedData[bestIndex]),
suggestedRecord: bestIndex >= 0 ? toCandidate(relatedData[bestIndex]) : undefined,
};
}

Expand All @@ -338,37 +376,30 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
return v === undefined || v === null ? null : String(v);
}

/** Branch B: fully automated. xToOne loads the linked record; HasMany ranks candidates via AI; BelongsToMany takes the first. */
private async resolveAndLoadAutomatic(target: RelationTarget): Promise<StepExecutionResult> {
const record = await this.fetchRecordForRelation(target);

return this.persistAndReturn(record, target, undefined);
}

private async fetchRecordForRelation(target: RelationTarget): Promise<RecordRef> {
if (target.relationType === 'BelongsTo' || target.relationType === 'HasOne') {
return this.fetchXToOneRecordRef(target);
}

if (target.relationType === 'HasMany') {
return this.selectBestRelatedRecord(target);
}

return this.fetchFirstCandidate(target);
}
private async resolveAndLoadAutomatic(): Promise<StepExecutionResult> {
// No source record throws (like Manual/AI-assisted) → the front offers "continue without" (PRD-550).
const target = await this.resolveTarget(true);
const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target, true);

private async fetchXToOneRecordRef(target: RelationTarget): Promise<RecordRef> {
const candidate = await this.fetchXToOneCandidate(target);
// Full AI auto-loads a confident pick and advances. With nothing relevant to load it degrades to
// an AI-assisted confirmation (a human decides, "No X to load" pre-checked) instead of skipping —
// mirrors the Trigger Action automated→confirmation fallback.
if (!suggestedRecord) {
const sourceSchema = await this.getCollectionSchema(target.selectedRecordRef.collectionName);

if (!candidate) {
throw new RelatedRecordNotFoundError(target.selectedRecordRef.collectionName, target.name);
return this.persistAwaitInput(target, sourceSchema, {
availableRecordIds,
suggestNoRecord: true,
});
}

return {
const record: RecordRef = {
collectionName: target.relatedCollectionName,
recordId: candidate.recordId,
recordId: suggestedRecord.recordId,
stepIndex: this.context.stepIndex,
};

return this.persistAndReturn(record, target, undefined);
}

private async fetchXToOneCandidate(
Expand Down Expand Up @@ -401,7 +432,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
): Promise<StepExecutionResult> {
const { selectedRecordRef, pendingData, userConfirmation } = execution;

if (!pendingData) {
if (!pendingData || !selectedRecordRef) {
throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`);
}

Expand Down Expand Up @@ -448,6 +479,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
private async selectBestFromRelatedData(
target: Pick<RelationTarget, 'selectedRecordRef' | 'name' | 'relatedCollectionName'>,
limit: number,
opts: { rank: boolean; allowNone: boolean } = { rank: true, allowNone: false },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Preferential

{ rank; allowNone } permits the meaningless { rank:false, allowNone:true } — when rank:false the method returns (line 492-494) before allowNone is ever read. A union { rank: false } | { rank: true; allowNone: boolean } makes that illegal state unrepresentable. Minor (private, single-file); flag only for clarity should it grow more callers.

): Promise<{
relatedData: RecordData[];
bestIndex: number;
Expand All @@ -457,13 +489,18 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
const relatedSchema = await this.getCollectionSchema(target.relatedCollectionName);
const relatedData = await this.fetchRelatedData(target, relatedSchema, limit);

// Empty (bestIndex unused — callers guard on length) or single → no ranking needed.
// length<=1 short-circuits before opts.allowNone, so Full AI with a single candidate always
// loads it — the AI is never asked to reject a sole option.
if (relatedData.length <= 1) {
return { relatedData, bestIndex: 0, suggestedFields: [], relatedSchema };
}

// The final record stays AI-suggested + user-confirmed — only the source + relation are
// pinned deterministically. Index-based record pinning was removed (not revise-safe).
if (!opts.rank) {
return { relatedData, bestIndex: -1, suggestedFields: [], relatedSchema };
}

// The final record stays AI-suggested + user-confirmed (or AI-decided in Full AI) — only the
// source + relation are pinned deterministically. Index-based record pinning was removed.
const suggestedFields = await this.selectRelevantFields(
relatedSchema,
this.context.stepDefinition.prompt,
Expand All @@ -472,43 +509,12 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
relatedData,
suggestedFields,
this.context.stepDefinition.prompt,
opts.allowNone,
);

return { relatedData, bestIndex, suggestedFields, relatedSchema };
}

/** HasMany + fully automated execution: fetch top 50, then AI calls to select the best record. */
private async selectBestRelatedRecord(target: RelationTarget): Promise<RecordRef> {
const { relatedData, bestIndex } = await this.selectBestFromRelatedData(target, 50);

if (relatedData.length === 0) {
throw new RelatedRecordNotFoundError(target.selectedRecordRef.collectionName, target.name);
}

return this.toRecordRef(relatedData[bestIndex]);
}

private async fetchFirstCandidate(target: RelationTarget): Promise<RecordRef> {
const candidates = await this.fetchCandidates(target, 1);

return candidates[0];
}

private async fetchCandidates(
target: Pick<RelationTarget, 'selectedRecordRef' | 'name' | 'relatedCollectionName'>,
limit: number,
): Promise<RecordRef[]> {
const { selectedRecordRef, name } = target;
const relatedSchema = await this.getCollectionSchema(target.relatedCollectionName);
const relatedData = await this.fetchRelatedData(target, relatedSchema, limit);

if (relatedData.length === 0) {
throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name);
}

return relatedData.map(r => this.toRecordRef(r));
}

private async fetchRelatedData(
target: Pick<RelationTarget, 'selectedRecordRef' | 'name'>,
relatedSchema: CollectionSchema,
Expand Down Expand Up @@ -650,11 +656,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
.map(dn => nonRelationFields.find(f => f.displayName === dn)?.fieldName ?? dn);
}

/** AI call 2 for HasMany: selects the best record by index from the candidate list. */
private async selectBestRecordIndex(
candidates: RecordData[],
fieldNames: string[],
prompt: string | undefined,
allowNone = false,
): Promise<number> {
const filteredCandidates = candidates.map((c, i) => {
const entries = Object.entries(c.values).filter(
Expand All @@ -681,33 +687,44 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo

const shown = lines.length;

if (shown < candidates.length) {
const truncated = shown < candidates.length;

if (truncated) {
this.context.logger('Warn', 'load-related-record: candidate list truncated for AI prompt', {
...this.logCtx,
shown,
total: candidates.length,
});
}

// "None relevant" (-1) is only trustworthy when the AI saw the whole list. If it was truncated,
// force a pick from what was shown — otherwise a match in the unseen tail would be silently skipped.
const noneAllowed = allowNone && !truncated;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Should fix

The noneAllowed = allowNone && !truncated rule — the safety core of the feature (-1 "none relevant" only trusted when the AI saw the whole list) — has no test. The truncation tests all run the await path (allowNone:false); the none-relevant test uses a 2-candidate, non-truncated list. So the Full-AI + truncated combination, where noneAllowed collapses to false, minIndex→0, and a -1 response is rejected by the guard at line 764, is unverified — a regression dropping !truncated (silently skipping a record that matches in the unseen tail) would pass the whole suite. Add: Full-AI HasMany, candidates wide enough to truncate, AI returns -1 → assert InvalidAIResponseError, not a skip.

const maxIndex = shown - 1;
const minIndex = noneAllowed ? -1 : 0;
const tool = new DynamicStructuredTool({
name: 'select-record-by-content',
description: 'Select the most relevant related record by its index.',
schema: z.object({
recordIndex: z
.number()
.int()
.min(0)
.min(minIndex)
.max(maxIndex)
.describe(`0-based index of the most relevant record (0 to ${maxIndex})`),
reasoning: z.string().describe('Why this record was chosen'),
.describe(
noneAllowed
? `0-based index of the most relevant record (0 to ${maxIndex}), or -1 if none of the candidates is relevant`
: `0-based index of the most relevant record (0 to ${maxIndex})`,
),
reasoning: z.string().describe('Why this record was chosen (or why none is relevant)'),
}),
func: undefined,
});

const messages = [
this.buildContextMessage(),
new SystemMessage(SELECT_RECORD_SYSTEM_PROMPT),
...(noneAllowed ? [new SystemMessage(SELECT_RECORD_NONE_ALLOWED_PROMPT)] : []),
new SystemMessage(`Candidates:\n${lines.join('\n')}`),
new HumanMessage(`**Request**: ${prompt ?? 'Select the most relevant record.'}`),
];
Expand All @@ -717,22 +734,15 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
tool,
);

// NOTE: The Zod schema's .min(0).max(maxIndex) shapes the tool prompt only — it is NOT
// validated against the AI response. This guard is the sole runtime enforcement.
if (!Number.isInteger(recordIndex) || recordIndex < 0 || recordIndex > maxIndex) {
// NOTE: The Zod schema's .min().max() shapes the tool prompt only — it is NOT validated against
// the AI response. This guard is the sole runtime enforcement. -1 (none relevant) is accepted
// only when noneAllowed (allowNone and the list was not truncated).
if (!Number.isInteger(recordIndex) || recordIndex < minIndex || recordIndex > maxIndex) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
throw new InvalidAIResponseError(
`AI selected record index ${recordIndex} which is out of range (0-${maxIndex}) or not an integer`,
`AI selected record index ${recordIndex} which is out of range (${minIndex}-${maxIndex}) or not an integer`,
);
}

return recordIndex;
}

private toRecordRef(data: RecordData): RecordRef {
return {
collectionName: data.collectionName,
recordId: data.recordId,
stepIndex: this.context.stepIndex,
};
}
}
5 changes: 4 additions & 1 deletion packages/workflow-executor/src/http/step-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ export default function serializeStepForWire(step: StepExecutionData): unknown {
return { ...step, selectedRecordRef: serializeRecordRef(step.selectedRecordRef) };

case 'load-related-record': {
// Omit selectedRecordRef when absent — serializing undefined throws.
const result: Record<string, unknown> = {
...step,
selectedRecordRef: serializeRecordRef(step.selectedRecordRef),
...(step.selectedRecordRef && {
selectedRecordRef: serializeRecordRef(step.selectedRecordRef),
}),
};

if (step.pendingData) {
Expand Down
Loading
Loading