From b94c7c5ade957652f35356708e0286700abff17d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 09:02:26 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(workflow-executor):=20apply=20build-ti?= =?UTF-8?q?me=20filters=20on=201=E2=80=93n=20Load=20Related=20Record=20(PR?= =?UTF-8?q?D-553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread preRecordedArgs.filters (a trusted build-time conditionTree) through GetRelatedDataQuery into the agent-client's SelectOptions.filters on the 1–n list fetch (fetchRelatedData). xToOne goes through getSingleRelatedData (no list to filter), so it's untouched. The port stays agnostic (filters?: unknown); the adapter casts to the agent-client type at the single call site. The agent validates the filter at query time. fixes PRD-553 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 6 +- .../load-related-record-step-executor.ts | 3 + .../workflow-executor/src/ports/agent-port.ts | 3 + .../src/types/validated/step-definition.ts | 6 ++ .../load-related-record-step-executor.test.ts | 59 +++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 23203d1888..0e128a3c49 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -11,7 +11,7 @@ import type { import type SchemaCache from '../schema-cache'; import type { StepUser } from '../types/execution-context'; import type { RecordData } from '../types/validated/collection'; -import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; +import type { ActionEndpointsByCollection, SelectOptions } from '@forestadmin/agent-client'; import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-client'; import jsonwebtoken from 'jsonwebtoken'; @@ -109,7 +109,7 @@ export default class AgentClientAgentPort implements AgentPort { } async getRelatedData( - { collection, id, relation, relatedSchema, limit, fields }: GetRelatedDataQuery, + { collection, id, relation, relatedSchema, limit, fields, filters }: GetRelatedDataQuery, user: StepUser, ): Promise { return this.callAgent('getRelatedData', async () => { @@ -120,6 +120,8 @@ export default class AgentClientAgentPort implements AgentPort { .list>({ ...(limit !== null && { pagination: { size: limit, number: 1 } }), ...(fields?.length && { fields }), + // Trusted build-time conditionTree (PRD-553); the agent validates it at query time. + ...(filters !== undefined && { filters: filters as SelectOptions['filters'] }), }); return rows.map(row => { diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 48ffda96ac..f39010349f 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -562,6 +562,9 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { ); }); + it('forwards a build-time filter (PRD-553) to getRelatedData on the 1–n list fetch', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'r' }, + id: 'c3', + }, + ], + }); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const filters = { field: 'city', operator: 'equal', value: 'Lyon' }; + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'address', filters }, + }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(agentPort.getRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'customers', relation: 'address', filters }), + expect.anything(), + ); + }); + it('skips field-selection AI call when related collection has no non-relation fields', async () => { const hasManySchema = makeCollectionSchema({ fields: [ From 32978a86d9a0a7385e80c355abfa7b4b93e08311 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 09:05:41 +0200 Subject: [PATCH 2/4] test(workflow-executor): cover filter on AutomatedWithConfirmation path (PRD-553 review) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../load-related-record-step-executor.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index d9eb64f5c1..a521c91a74 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -635,6 +635,65 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); + it('also forwards the filter on the AutomatedWithConfirmation path (the user-facing list)', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'r' }, + id: 'c3', + }, + ], + }); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const filters = { field: 'city', operator: 'equal', value: 'Lyon' }; + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + preRecordedArgs: { relationName: 'address', filters }, + }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(agentPort.getRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'customers', relation: 'address', filters }), + expect.anything(), + ); + }); + it('skips field-selection AI call when related collection has no non-relation fields', async () => { const hasManySchema = makeCollectionSchema({ fields: [ From de7ac62da162eeb65bb7804eb63facc59f33a04e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 22 Jun 2026 11:50:17 +0200 Subject: [PATCH 3/4] fix(workflow-executor): address review comments Remove inline comments flagged in PR review. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflow-executor/src/adapters/agent-client-agent-port.ts | 1 - .../src/executors/load-related-record-step-executor.ts | 2 -- packages/workflow-executor/src/ports/agent-port.ts | 2 -- 3 files changed, 5 deletions(-) diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 0e128a3c49..96a6f2d166 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -120,7 +120,6 @@ export default class AgentClientAgentPort implements AgentPort { .list>({ ...(limit !== null && { pagination: { size: limit, number: 1 } }), ...(fields?.length && { fields }), - // Trusted build-time conditionTree (PRD-553); the agent validates it at query time. ...(filters !== undefined && { filters: filters as SelectOptions['filters'] }), }); diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index f39010349f..b67dda0510 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -562,8 +562,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor Date: Mon, 22 Jun 2026 12:02:49 +0200 Subject: [PATCH 4/4] docs(workflow-executor): simplify the filters field comment Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/types/validated/step-definition.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index c4833557c3..9fc5148d60 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -106,11 +106,7 @@ export const LoadRelatedRecordStepDefinitionSchema = z.object({ selectedRecordStepId: z.string().optional(), /** "From collection" — the relation to follow (technical name) */ relationName: z.string().optional(), - /** - * Build-time filter narrowing the candidate records on a 1–n relation (PRD-553). Forest's - * plain conditionTree, forwarded verbatim to the agent (which validates it at query time) — - * kept loose here since it's trusted build config, not executor-validated input. - */ + /** 1–n relation filter (conditionTree), forwarded verbatim; loosely typed as it's trusted config the agent validates. */ filters: z.unknown().optional(), }) .optional(),