diff --git a/packages/workflow-executor/src/executors/record-step-executor.ts b/packages/workflow-executor/src/executors/record-step-executor.ts index 92e96f712..4430dfacc 100644 --- a/packages/workflow-executor/src/executors/record-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-step-executor.ts @@ -77,20 +77,25 @@ export default abstract class RecordStepExecutor< } protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { - // The tool definition sent to the LLM is built from z.literal(displayName) — the JSON - // Schema does constrain fieldName to exact values. However, invokeWithTool returns - // toolCall.args as-is without re-running Zod validation on the response, so the LLM can - // silently ignore the constraint and return a formatting variation (e.g. "first_name" - // instead of "firstname"). The normalized fallback catches these cosmetic mismatches. + // LLMs occasionally return formatting variants of field names (e.g. "first_name" for + // "firstname", "full-name" for "Full Name") even though the tool schema declares them + // as literals. Fall back to a normalized comparison so a cosmetic variation doesn't + // fail an otherwise correct step. const normalizeFieldName = (s: string) => s.toLowerCase().replace(/[\s_-]/g, ''); const normalized = normalizeFieldName(name); - return ( + const exact = schema.fields.find(f => f.displayName === name) ?? - schema.fields.find(f => f.fieldName === name) ?? - schema.fields.find(f => normalizeFieldName(f.displayName) === normalized) ?? - schema.fields.find(f => normalizeFieldName(f.fieldName) === normalized) + schema.fields.find(f => f.fieldName === name); + if (exact) return exact; + + const fuzzy = schema.fields.filter( + f => + normalizeFieldName(f.displayName) === normalized || + normalizeFieldName(f.fieldName) === normalized, ); + + return fuzzy.length === 1 ? fuzzy[0] : undefined; } private async toRecordIdentifier(record: RecordRef): Promise { diff --git a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts index 9c1866e09..c017607f8 100644 --- a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -499,6 +499,72 @@ describe('UpdateRecordStepExecutor', () => { }); }); + describe('resolveFieldName fuzzy matching', () => { + it.each([ + ['snake_case variant', 'full_name', 'Full Name', 'name'], + ['camelCase variant', 'fullName', 'Full Name', 'name'], + ['lowercase no separator', 'fullname', 'Full Name', 'name'], + ['hyphen variant', 'full-name', 'Full Name', 'name'], + ])( + 'resolves field when LLM returns %s (%s)', + async (_label, aiReturnedName, _displayName, expectedFieldName) => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + input: { fieldName: aiReturnedName, value: 'John Doe', reasoning: 'test' }, + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + expect.objectContaining({ values: { [expectedFieldName]: 'John Doe' } }), + expect.anything(), + ); + }, + ); + + it('returns undefined (field not found) when two fields normalize to the same string', async () => { + // { displayName: "Full Name", fieldName: "fullname" } and + // { displayName: "FullName", fieldName: "full_name" } both normalize to "fullname". + // Returning either one would be a silent wrong pick — undefined is safer. + const ambiguousSchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'fullname', + displayName: 'Full Name', + isRelationship: false, + type: 'String', + }, + { + fieldName: 'full_name', + displayName: 'FullName', + isRelationship: false, + type: 'String', + }, + ], + }); + const mockModel = makeMockModel({ + input: { fieldName: 'Full-Name', value: 'John', reasoning: 'test' }, + }); + const context = makeContext({ + model: mockModel.model, + workflowPort: makeMockWorkflowPort({ customers: ambiguousSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + }); + }); + describe('relationship fields excluded from update tool', () => { it('excludes relationship fields from the tool schema', async () => { const mockModel = makeMockModel({