diff --git a/.changeset/fix-landlord-maxretries.md b/.changeset/fix-landlord-maxretries.md new file mode 100644 index 0000000..536a431 --- /dev/null +++ b/.changeset/fix-landlord-maxretries.md @@ -0,0 +1,5 @@ +--- +"landlord": patch +--- + +Validate `maxRetries` on a contract as a positive integer. It was previously an unconstrained `z.number()`, so negative or zero values silently caused a tenant to escalate without ever running, and fractional values produced an unexpected extra attempt in the `attempt < maxRetries` loop. The schema now enforces `int().min(1)`. diff --git a/packages/landlord/src/contract.ts b/packages/landlord/src/contract.ts index 295cff5..4c6f0e5 100644 --- a/packages/landlord/src/contract.ts +++ b/packages/landlord/src/contract.ts @@ -16,7 +16,7 @@ export const ContractSchema = z.object({ toolsAllowed: z.array(z.string()).optional(), toolsDenied: z.array(z.string()).optional(), dependsOn: z.array(z.string()).default([]), - maxRetries: z.number().default(3), + maxRetries: z.number().int().min(1).default(3), }); export type Checkpoint = z.infer; diff --git a/packages/landlord/test/contract.test.ts b/packages/landlord/test/contract.test.ts index a4c80f4..969b2d0 100644 --- a/packages/landlord/test/contract.test.ts +++ b/packages/landlord/test/contract.test.ts @@ -63,4 +63,19 @@ describe('ContractSchema', () => { }); expect(result.success).toBe(false); }); + + it('rejects non-positive, non-integer, and fractional maxRetries', () => { + const base = { + role: 'r', + objective: 'x', + subPrompt: 'x', + checkpoints: [], + outputSchema: {}, + }; + for (const maxRetries of [0, -1, 2.5]) { + const result = ContractSchema.safeParse({ ...base, maxRetries }); + expect(result.success).toBe(false); + } + expect(ContractSchema.safeParse({ ...base, maxRetries: 1 }).success).toBe(true); + }); });