Skip to content

Commit 727d9d7

Browse files
committed
fix: address PR review findings
1 parent 329e0b5 commit 727d9d7

2 files changed

Lines changed: 156 additions & 18 deletions

File tree

packages/kosong/src/providers/pythinker-schema.ts

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,24 +157,56 @@ function foldRootAnyOf(root: Record<string, unknown>): void {
157157
delete root['anyOf'];
158158

159159
const rootProperties = root['properties'];
160-
const merged: Record<string, unknown> = isRecord(rootProperties)
161-
? rootProperties
162-
: {};
160+
const alternativesByName = new Map<string, unknown[]>();
161+
if (isRecord(rootProperties)) {
162+
for (const [name, property] of Object.entries(rootProperties)) {
163+
alternativesByName.set(name, [cloneJsonValue(property)]);
164+
}
165+
}
163166
for (const branch of branches) {
164167
const branchProperties = branch['properties'];
165168
if (!isRecord(branchProperties)) continue;
166169
for (const [name, property] of Object.entries(branchProperties)) {
167-
if (!hasOwn(merged, name)) {
168-
merged[name] = cloneJsonValue(property);
169-
}
170+
addRootPropertyAlternative(alternativesByName, name, property);
170171
}
171172
}
172-
if (Object.keys(merged).length > 0) {
173+
174+
if (alternativesByName.size > 0) {
175+
const merged: Record<string, unknown> = {};
176+
for (const [name, alternatives] of alternativesByName) {
177+
merged[name] = alternatives.length === 1 ? alternatives[0] : { anyOf: alternatives };
178+
}
173179
root['properties'] = merged;
174180
}
175181
root['type'] = 'object';
176182
}
177183

184+
/**
185+
* Record one branch's schema for a merged root property.
186+
*
187+
* Root `anyOf` branches are alternatives, so two branches declaring the same
188+
* property with different schemas (e.g. `value` as a string in one branch, an
189+
* integer in another) must both stay representable — keeping only the first
190+
* one seen would silently narrow what the tool actually accepts. Identical
191+
* schemas collapse to one; differing schemas fold into an `anyOf` on the
192+
* merged property.
193+
*/
194+
function addRootPropertyAlternative(
195+
alternativesByName: Map<string, unknown[]>,
196+
name: string,
197+
property: unknown,
198+
): void {
199+
const cloned = cloneJsonValue(property);
200+
const alternatives = alternativesByName.get(name);
201+
if (!alternatives) {
202+
alternativesByName.set(name, [cloned]);
203+
return;
204+
}
205+
if (!alternatives.some((existing) => deepEqualJson(existing, cloned))) {
206+
alternatives.push(cloned);
207+
}
208+
}
209+
178210
function hasUnresolvedDefinitionRef(node: unknown, bucketKey: string): boolean {
179211
if (Array.isArray(node)) {
180212
return node.some((child) => hasUnresolvedDefinitionRef(child, bucketKey));
@@ -337,8 +369,13 @@ const ANYOF_PARENT_KEEP_KEYS = new Set([
337369
* so schemas that are perfectly valid elsewhere are rejected on this wire.
338370
*
339371
* Distributing is lossless: `P ∧ (B₁ ∨ B₂)` and `(P ∧ B₁) ∨ (P ∧ B₂)` accept
340-
* exactly the same instances. A branch that already declares a keyword keeps
341-
* its own, which is the narrower of the two.
372+
* exactly the same instances — *if* a branch that already declares the same
373+
* keyword is merged conjunctively with the parent's value rather than simply
374+
* overriding it. `required` is the one keyword this function merges that way
375+
* (parent and branch field lists are unioned, since both are actually
376+
* required). Every other overlapping keyword still keeps the branch's own
377+
* value: a full conjunctive merge for arbitrary keywords (`properties`,
378+
* `items`, …) is out of scope for this compatibility normalizer.
342379
*/
343380
function distributeAnyOfParentKeywords(node: Record<string, unknown>): void {
344381
const branches = node['anyOf'];
@@ -355,6 +392,8 @@ function distributeAnyOfParentKeywords(node: Record<string, unknown>): void {
355392
for (const key of inherited) {
356393
if (!hasOwn(branch, key)) {
357394
branch[key] = cloneJsonValue(node[key]);
395+
} else if (key === 'required') {
396+
branch[key] = mergeRequired(node[key], branch[key]);
358397
}
359398
}
360399
}
@@ -363,6 +402,26 @@ function distributeAnyOfParentKeywords(node: Record<string, unknown>): void {
363402
}
364403
}
365404

405+
/**
406+
* Union two `required` field lists.
407+
*
408+
* A parent's `required` and a branch's own `required` are both mandatory —
409+
* dropping the parent's list when the branch already has one would silently
410+
* accept objects missing a field the parent demanded.
411+
*/
412+
function mergeRequired(parentValue: unknown, branchValue: unknown): unknown {
413+
if (!Array.isArray(parentValue) || !Array.isArray(branchValue)) {
414+
return branchValue;
415+
}
416+
const merged = [...branchValue];
417+
for (const name of parentValue) {
418+
if (!merged.includes(name)) {
419+
merged.push(name);
420+
}
421+
}
422+
return merged;
423+
}
424+
366425
function visitChildSchemas(node: Record<string, unknown>, visit: (schema: unknown) => void): void {
367426
for (const { key, kind } of CHILD_SCHEMA_SLOTS) {
368427
const value = node[key];
@@ -574,6 +633,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
574633
return typeof value === 'object' && value !== null && !Array.isArray(value);
575634
}
576635

636+
function deepEqualJson(a: unknown, b: unknown): boolean {
637+
if (a === b) {
638+
return true;
639+
}
640+
if (Array.isArray(a) && Array.isArray(b)) {
641+
return a.length === b.length && a.every((item, index) => deepEqualJson(item, b[index]));
642+
}
643+
if (isRecord(a) && isRecord(b)) {
644+
const aKeys = Object.keys(a);
645+
const bKeys = Object.keys(b);
646+
return aKeys.length === bKeys.length && aKeys.every((key) => hasOwn(b, key) && deepEqualJson(a[key], b[key]));
647+
}
648+
return false;
649+
}
650+
577651
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
578652
return Object.prototype.hasOwnProperty.call(obj, key);
579653
}

packages/kosong/test/providers/pythinker-schema.test.ts

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,40 @@ describe('normalizePythinkerToolSchema', () => {
612612
});
613613
});
614614

615+
it('folds a root anyOf property declared differently across branches into an alternative', () => {
616+
const result = normalizePythinkerToolSchema({
617+
anyOf: [
618+
{ type: 'object', properties: { value: { type: 'string' } } },
619+
{ type: 'object', properties: { value: { type: 'integer' } } },
620+
],
621+
});
622+
623+
expect(result).toEqual({
624+
type: 'object',
625+
properties: {
626+
value: { anyOf: [{ type: 'string' }, { type: 'integer' }] },
627+
},
628+
});
629+
});
630+
631+
it('collapses a root anyOf property that is identical across every branch', () => {
632+
const result = normalizePythinkerToolSchema({
633+
anyOf: [
634+
{ type: 'object', properties: { mode: { type: 'string' }, task_id: { type: 'string' } } },
635+
{ type: 'object', properties: { mode: { type: 'string' }, shell_id: { type: 'string' } } },
636+
],
637+
});
638+
639+
expect(result).toEqual({
640+
type: 'object',
641+
properties: {
642+
mode: { type: 'string' },
643+
task_id: { type: 'string' },
644+
shell_id: { type: 'string' },
645+
},
646+
});
647+
});
648+
615649
it('distributes into nested anyOf nodes and keeps narrower branch keywords', () => {
616650
const result = normalizePythinkerToolSchema({
617651
type: 'object',
@@ -637,6 +671,31 @@ describe('normalizePythinkerToolSchema', () => {
637671
});
638672
});
639673

674+
it('unions a parent required list with a branch that already declares its own', () => {
675+
const result = normalizePythinkerToolSchema({
676+
type: 'object',
677+
properties: {
678+
variant: {
679+
type: 'object',
680+
required: ['common'],
681+
anyOf: [{ required: ['variant'] }, { required: ['common'] }],
682+
},
683+
},
684+
});
685+
686+
expect(result).toEqual({
687+
type: 'object',
688+
properties: {
689+
variant: {
690+
anyOf: [
691+
{ type: 'object', required: ['variant', 'common'] },
692+
{ type: 'object', required: ['common'] },
693+
],
694+
},
695+
},
696+
});
697+
});
698+
640699
it('leaves anyOf nodes alone when the parent only carries metadata', () => {
641700
const schema = {
642701
type: 'object',
@@ -651,23 +710,28 @@ describe('normalizePythinkerToolSchema', () => {
651710
expect(normalizePythinkerToolSchema(schema)).toEqual(schema);
652711
});
653712

654-
it('keeps cyclic $ref and definition buckets on the parent of an anyOf', () => {
713+
it('keeps $ref and $defs on the anyOf parent instead of distributing them into branches', () => {
714+
// $ref must be genuinely cyclic (self-referential) to survive derefJsonSchema
715+
// and still be present by the time distributeAnyOfParentKeywords runs.
655716
const result = normalizePythinkerToolSchema({
656717
type: 'object',
657718
properties: {
658719
node: {
659-
anyOf: [{ $ref: '#/$defs/Node' }, { type: 'null' }],
660-
},
661-
},
662-
$defs: {
663-
Node: {
664-
type: 'object',
665-
properties: { next: { $ref: '#/$defs/Node' } },
720+
$ref: '#/properties/node',
721+
$defs: { Extra: { type: 'string' } },
722+
anyOf: [{ type: 'null' }],
666723
},
667724
},
668725
});
669726

670-
expect(result['$defs']).toBeDefined();
727+
const node = (result['properties'] as Record<string, unknown>)['node'] as Record<string, unknown>;
728+
expect(node['$ref']).toBe('#/properties/node');
729+
expect(node['$defs']).toEqual({ Extra: { type: 'string' } });
730+
const branches = node['anyOf'] as Record<string, unknown>[];
731+
for (const branch of branches) {
732+
expect(branch).not.toHaveProperty('$ref');
733+
expect(branch).not.toHaveProperty('$defs');
734+
}
671735
});
672736

673737
it('dereferences and normalizes local definition buckets', () => {

0 commit comments

Comments
 (0)