fix(mcp): restore >0 validation and reject ambiguous numbers in measurement()
Adversarial-review follow-ups to the lingo measurement() field:
- Add a `positive` option (strict > 0) and use it for the non-zero dimension
params. Swapping z.number().positive() → measurement(..,{min:0}) had started
admitting 0 (the core node schemas have no positivity backstop), so a zero-size
wall/opening/roof could be created. Inclusive-0 fields (overhang, sill height,
knee-wall height, opening offset, roof pitch) keep min:0.
- Escalate AMBIGUOUS_NUMBER to error so "1,234" fails instead of silently reading
as 1234 — a 1000x hazard for European decimals. Matches lingo's own /ai fields.
- roofLevelElevation reverted to z.number() (it's a level ordinal, not meters);
radians fields now advertise radian-appropriate examples.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f66dcd438b
commit
3273aac551
@@ -23,14 +23,20 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const
|
|||||||
export const createStoryShellInput = {
|
export const createStoryShellInput = {
|
||||||
levelId: NodeIdSchema,
|
levelId: NodeIdSchema,
|
||||||
footprint: z.array(Vec2Schema).min(3),
|
footprint: z.array(Vec2Schema).min(3),
|
||||||
wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8),
|
wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default(
|
||||||
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default(
|
2.8,
|
||||||
0.16,
|
|
||||||
),
|
),
|
||||||
|
wallThickness: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).default(0.16),
|
||||||
createSlab: z.boolean().default(true),
|
createSlab: z.boolean().default(true),
|
||||||
createCeiling: z.boolean().default(true),
|
createCeiling: z.boolean().default(true),
|
||||||
slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1),
|
slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1),
|
||||||
ceilingHeight: measurement('length', 'm', { min: 0, description: 'Ceiling height.' }).optional(),
|
ceilingHeight: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Ceiling height.',
|
||||||
|
}).optional(),
|
||||||
namePrefix: z.string().optional(),
|
namePrefix: z.string().optional(),
|
||||||
wallMaterialPreset: z.string().optional(),
|
wallMaterialPreset: z.string().optional(),
|
||||||
slabMaterialPreset: z.string().optional(),
|
slabMaterialPreset: z.string().optional(),
|
||||||
@@ -50,24 +56,24 @@ export const createRoofInput = {
|
|||||||
roofLevelId: NodeIdSchema.optional(),
|
roofLevelId: NodeIdSchema.optional(),
|
||||||
useDedicatedRoofLevel: z.boolean().default(true),
|
useDedicatedRoofLevel: z.boolean().default(true),
|
||||||
roofLevelLabel: z.string().default('Roof'),
|
roofLevelLabel: z.string().default('Roof'),
|
||||||
roofLevelElevation: measurement('length', 'm', {
|
// A level ordinal (story index), not a length — kept numeric.
|
||||||
description: 'Roof level elevation.',
|
roofLevelElevation: z.number().optional(),
|
||||||
}).optional(),
|
|
||||||
roofLevelHeight: measurement('length', 'm', {
|
roofLevelHeight: measurement('length', 'm', {
|
||||||
min: 0,
|
positive: true,
|
||||||
description: 'Roof level height.',
|
description: 'Roof level height.',
|
||||||
}).optional(),
|
}).optional(),
|
||||||
center: Vec3Schema.optional(),
|
center: Vec3Schema.optional(),
|
||||||
width: measurement('length', 'm', { min: 0, description: 'Roof width.' }),
|
width: measurement('length', 'm', { positive: true, description: 'Roof width.' }),
|
||||||
depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }),
|
depth: measurement('length', 'm', { positive: true, description: 'Roof depth.' }),
|
||||||
roofType: z.enum(ROOF_TYPES).default('hip'),
|
roofType: z.enum(ROOF_TYPES).default('hip'),
|
||||||
pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35),
|
pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35),
|
||||||
wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default(
|
wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default(
|
||||||
0.35,
|
0.35,
|
||||||
),
|
),
|
||||||
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default(
|
wallThickness: measurement('length', 'm', {
|
||||||
0.16,
|
positive: true,
|
||||||
),
|
description: 'Wall thickness.',
|
||||||
|
}).default(0.16),
|
||||||
overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45),
|
overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45),
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -86,13 +92,15 @@ export const createStairBetweenLevelsInput = {
|
|||||||
toLevelId: NodeIdSchema,
|
toLevelId: NodeIdSchema,
|
||||||
position: Vec3Schema,
|
position: Vec3Schema,
|
||||||
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0),
|
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0),
|
||||||
width: measurement('length', 'm', { min: 0, description: 'Stair width.' }).default(1),
|
width: measurement('length', 'm', { positive: true, description: 'Stair width.' }).default(1),
|
||||||
runLength: measurement('length', 'm', { min: 0, description: 'Horizontal run length.' }).default(
|
runLength: measurement('length', 'm', {
|
||||||
3,
|
positive: true,
|
||||||
),
|
description: 'Horizontal run length.',
|
||||||
totalRise: measurement('length', 'm', { min: 0, description: 'Total vertical rise.' }).default(
|
}).default(3),
|
||||||
2.8,
|
totalRise: measurement('length', 'm', {
|
||||||
),
|
positive: true,
|
||||||
|
description: 'Total vertical rise.',
|
||||||
|
}).default(2.8),
|
||||||
stepCount: z.number().int().positive().default(14),
|
stepCount: z.number().int().positive().default(14),
|
||||||
railingMode: z.enum(RAILING_MODES).default('both'),
|
railingMode: z.enum(RAILING_MODES).default('both'),
|
||||||
destinationSlabId: NodeIdSchema.optional(),
|
destinationSlabId: NodeIdSchema.optional(),
|
||||||
@@ -100,11 +108,11 @@ export const createStairBetweenLevelsInput = {
|
|||||||
createDestinationSlabOpening: z.boolean().default(true),
|
createDestinationSlabOpening: z.boolean().default(true),
|
||||||
createSourceCeilingOpening: z.boolean().default(true),
|
createSourceCeilingOpening: z.boolean().default(true),
|
||||||
openingWidth: measurement('length', 'm', {
|
openingWidth: measurement('length', 'm', {
|
||||||
min: 0,
|
positive: true,
|
||||||
description: 'Floor opening width.',
|
description: 'Floor opening width.',
|
||||||
}).optional(),
|
}).optional(),
|
||||||
openingLength: measurement('length', 'm', {
|
openingLength: measurement('length', 'm', {
|
||||||
min: 0,
|
positive: true,
|
||||||
description: 'Floor opening length.',
|
description: 'Floor opening length.',
|
||||||
}).optional(),
|
}).optional(),
|
||||||
openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0),
|
openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0),
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ export const createWallInput = {
|
|||||||
levelId: NodeIdSchema,
|
levelId: NodeIdSchema,
|
||||||
start: Vec2Schema,
|
start: Vec2Schema,
|
||||||
end: Vec2Schema,
|
end: Vec2Schema,
|
||||||
thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(),
|
thickness: measurement('length', 'm', {
|
||||||
height: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(),
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).optional(),
|
||||||
|
height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWallOutput = {
|
export const createWallOutput = {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export const cutOpeningInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
type: z.enum(['door', 'window']),
|
type: z.enum(['door', 'window']),
|
||||||
position: z.number().min(0).max(1),
|
position: z.number().min(0).max(1),
|
||||||
width: measurement('length', 'm', { min: 0, description: 'Opening width.' }),
|
width: measurement('length', 'm', { positive: true, description: 'Opening width.' }),
|
||||||
height: measurement('length', 'm', { min: 0, description: 'Opening height.' }),
|
height: measurement('length', 'm', { positive: true, description: 'Opening height.' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cutOpeningOutput = {
|
export const cutOpeningOutput = {
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ describe('measurement()', () => {
|
|||||||
if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number')
|
if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('positive rejects zero and negatives (restores .positive() behavior)', () => {
|
||||||
|
const m = measurement('length', 'm', { positive: true })
|
||||||
|
expect(m.safeParse(0).success).toBe(false)
|
||||||
|
expect(m.safeParse('0m').success).toBe(false)
|
||||||
|
expect(m.safeParse(-1).success).toBe(false)
|
||||||
|
expect(m.parse(0.1)).toBe(0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects ambiguous separators instead of a silent 1000x reading', () => {
|
||||||
|
const r = measurement('length', 'm').safeParse('1,234')
|
||||||
|
expect(r.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test('emits a number|string JSON schema advertising natural language', () => {
|
test('emits a number|string JSON schema advertising natural language', () => {
|
||||||
const schema = z.toJSONSchema(
|
const schema = z.toJSONSchema(
|
||||||
measurement('length', 'm', { min: 0, description: 'Wall thickness.' }),
|
measurement('length', 'm', { min: 0, description: 'Wall thickness.' }),
|
||||||
|
|||||||
@@ -13,15 +13,21 @@ import { z } from 'zod'
|
|||||||
* The emitted JSON Schema is `number | string`, so the model is free to answer
|
* The emitted JSON Schema is `number | string`, so the model is free to answer
|
||||||
* in whatever unit it is thinking in; AI SDK v6 applies the transform and
|
* in whatever unit it is thinking in; AI SDK v6 applies the transform and
|
||||||
* forwards the canonical number to the tool executor.
|
* forwards the canonical number to the tool executor.
|
||||||
|
*
|
||||||
|
* Tool-boundary safety: genuinely ambiguous separators (`"1,234"`, which could
|
||||||
|
* mean 1234 or 1.234) are rejected rather than silently absorbed, so a European
|
||||||
|
* decimal never becomes a 1000× value.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MeasurementOptions {
|
export interface MeasurementOptions {
|
||||||
/** Lower bound, in `unit`. */
|
|
||||||
min?: number
|
|
||||||
/** Upper bound, in `unit`. */
|
|
||||||
max?: number
|
|
||||||
/** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */
|
/** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */
|
||||||
description?: string
|
description?: string
|
||||||
|
/** Upper bound, in `unit`. */
|
||||||
|
max?: number
|
||||||
|
/** Inclusive lower bound, in `unit`. */
|
||||||
|
min?: number
|
||||||
|
/** Require the value to be strictly greater than 0 (for non-zero dimensions). */
|
||||||
|
positive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function unitNoun(unit: string): string {
|
function unitNoun(unit: string): string {
|
||||||
@@ -38,16 +44,29 @@ function unitNoun(unit: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function naturalLanguageNote(kind: Kind, unit: string): string {
|
function naturalLanguageNote(kind: Kind, unit: string): string {
|
||||||
const examples =
|
let examples: string
|
||||||
kind === 'angle'
|
if (kind === 'angle') {
|
||||||
? unit === 'rad'
|
// Lead with an example in the field's own unit so a bare number is read
|
||||||
? '45, "45°", "1.57rad", "0.25 turn"'
|
// the way the model intends (a bare "45" in a radians field is 45 rad).
|
||||||
: '45, "45°", "1.57rad", "0.25 turn"'
|
examples =
|
||||||
: '0.9, "6 ft", "180cm", "2 ft 3 in"'
|
unit === 'rad' ? '1.5708, "90°", "1.57rad", "0.25 turn"' : '45, "45°", "1.57rad", "0.25 turn"'
|
||||||
|
} else {
|
||||||
|
examples = '0.9, "6 ft", "180cm", "2 ft 3 in"'
|
||||||
|
}
|
||||||
return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.`
|
return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.`
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundsNote(unit: string, min?: number, max?: number): string {
|
function boundsNote(
|
||||||
|
unit: string,
|
||||||
|
min: number | undefined,
|
||||||
|
max: number | undefined,
|
||||||
|
positive: boolean,
|
||||||
|
): string {
|
||||||
|
if (positive) {
|
||||||
|
return max === undefined
|
||||||
|
? ` Must be greater than 0 ${unit}.`
|
||||||
|
: ` Greater than 0, up to ${max} ${unit}.`
|
||||||
|
}
|
||||||
if (min !== undefined && max !== undefined) return ` Range ${min}–${max} ${unit}.`
|
if (min !== undefined && max !== undefined) return ` Range ${min}–${max} ${unit}.`
|
||||||
if (min !== undefined) return ` Minimum ${min} ${unit}.`
|
if (min !== undefined) return ` Minimum ${min} ${unit}.`
|
||||||
if (max !== undefined) return ` Maximum ${max} ${unit}.`
|
if (max !== undefined) return ` Maximum ${max} ${unit}.`
|
||||||
@@ -55,10 +74,10 @@ function boundsNote(unit: string, min?: number, max?: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) {
|
export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) {
|
||||||
const { min, max } = opts
|
const { min, max, positive = false } = opts
|
||||||
const description = [
|
const description = [
|
||||||
opts.description,
|
opts.description,
|
||||||
naturalLanguageNote(kind, unit) + boundsNote(unit, min, max),
|
naturalLanguageNote(kind, unit) + boundsNote(unit, min, max, positive),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -70,7 +89,14 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions =
|
|||||||
if (typeof val === 'number') {
|
if (typeof val === 'number') {
|
||||||
value = val
|
value = val
|
||||||
} else {
|
} else {
|
||||||
const result = parseQuantity(val, { kind, unit, strictness: 'forgiving' })
|
const result = parseQuantity(val, {
|
||||||
|
kind,
|
||||||
|
unit,
|
||||||
|
strictness: 'forgiving',
|
||||||
|
// A genuinely ambiguous separator ("1,234") must not silently become
|
||||||
|
// a 1000× value at a tool boundary — fail so the model self-corrects.
|
||||||
|
escalate: { AMBIGUOUS_NUMBER: 'error' },
|
||||||
|
})
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
@@ -84,6 +110,10 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions =
|
|||||||
ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` })
|
ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` })
|
||||||
return z.NEVER
|
return z.NEVER
|
||||||
}
|
}
|
||||||
|
if (positive && value <= 0) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Must be greater than 0 ${unit} (got ${value}).` })
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
if (min !== undefined && value < min) {
|
if (min !== undefined && value < min) {
|
||||||
ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` })
|
ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` })
|
||||||
return z.NEVER
|
return z.NEVER
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export const photoToSceneInput = {
|
|||||||
name: z.string().default('Scene from photo'),
|
name: z.string().default('Scene from photo'),
|
||||||
save: z.boolean().default(true),
|
save: z.boolean().default(true),
|
||||||
defaultWallThickness: measurement('length', 'm', {
|
defaultWallThickness: measurement('length', 'm', {
|
||||||
min: 0,
|
positive: true,
|
||||||
description: 'Default wall thickness.',
|
description: 'Default wall thickness.',
|
||||||
}).default(0.2),
|
}).default(0.2),
|
||||||
defaultWallHeight: measurement('length', 'm', {
|
defaultWallHeight: measurement('length', 'm', {
|
||||||
min: 0,
|
positive: true,
|
||||||
description: 'Default wall height.',
|
description: 'Default wall height.',
|
||||||
}).default(2.6),
|
}).default(2.6),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,14 @@ export const createRoomInput = {
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
polygon: z.array(Vec2Schema).min(3),
|
polygon: z.array(Vec2Schema).min(3),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(),
|
wallHeight: measurement('length', 'm', {
|
||||||
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(),
|
positive: true,
|
||||||
|
description: 'Wall height.',
|
||||||
|
}).optional(),
|
||||||
|
wallThickness: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRoomOutput = {
|
export const createRoomOutput = {
|
||||||
@@ -68,8 +74,8 @@ export const addDoorInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
t: z.number().min(0).max(1).optional(),
|
t: z.number().min(0).max(1).optional(),
|
||||||
position: z.number().min(0).max(1).optional(),
|
position: z.number().min(0).max(1).optional(),
|
||||||
width: measurement('length', 'm', { min: 0, description: 'Door width.' }).optional(),
|
width: measurement('length', 'm', { positive: true, description: 'Door width.' }).optional(),
|
||||||
height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(),
|
height: measurement('length', 'm', { positive: true, description: 'Door height.' }).optional(),
|
||||||
hingesSide: z.enum(['left', 'right']).optional(),
|
hingesSide: z.enum(['left', 'right']).optional(),
|
||||||
swingDirection: z.enum(['inward', 'outward']).optional(),
|
swingDirection: z.enum(['inward', 'outward']).optional(),
|
||||||
}
|
}
|
||||||
@@ -88,8 +94,8 @@ export const addWindowInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
t: z.number().min(0).max(1).optional(),
|
t: z.number().min(0).max(1).optional(),
|
||||||
position: z.number().min(0).max(1).optional(),
|
position: z.number().min(0).max(1).optional(),
|
||||||
width: measurement('length', 'm', { min: 0, description: 'Window width.' }).optional(),
|
width: measurement('length', 'm', { positive: true, description: 'Window width.' }).optional(),
|
||||||
height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(),
|
height: measurement('length', 'm', { positive: true, description: 'Window height.' }).optional(),
|
||||||
sillHeight: measurement('length', 'm', {
|
sillHeight: measurement('length', 'm', {
|
||||||
min: 0,
|
min: 0,
|
||||||
description: 'Sill height above floor.',
|
description: 'Sill height above floor.',
|
||||||
|
|||||||
Reference in New Issue
Block a user