-
Notifications
You must be signed in to change notification settings - Fork 12
feat(workflow-executor): add 3-way execution mode to the load related record step (PRD-148) #1728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
527aa7e
ea0b5f8
1579340
4f8b398
9ee1164
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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, { | ||
| ...execution, | ||
|
|
@@ -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 = | ||
|
|
@@ -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 = | ||
|
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); | ||
| } | ||
|
|
@@ -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) | ||
|
|
@@ -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; | ||
| }> { | ||
|
|
@@ -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) { | ||
|
|
@@ -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, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -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`); | ||
| } | ||
|
|
||
|
|
@@ -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 }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Opus 4.8 (1M context)] · Preferential
|
||
| ): Promise<{ | ||
| relatedData: RecordData[]; | ||
| bestIndex: number; | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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( | ||
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Opus 4.8 (1M context)] · Should fix The |
||
| 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.'}`), | ||
| ]; | ||
|
|
@@ -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) { | ||
|
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, | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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:122refreshCandidatesForFieldspreads the oldpendingData(line 126) without clearingsuggestNoRecord. When a prior relation setsuggestNoRecord: trueand the user switches to a relation that returns asuggestedRecord, the savedpendingDatastill carriessuggestNoRecord: true, so the UI keeps the "No record to load" state even though a valid record was just suggested. Consider recomputingsuggestNoRecordinrefreshCandidatesForFieldthe same waysaveAndAwaitInputdoes.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: