Skip to content
Merged
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
23 changes: 14 additions & 9 deletions packages/workflow-executor/src/executors/record-step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading