diff --git a/packages/compiler/src/ast-helpers.ts b/packages/compiler/src/ast-helpers.ts index 0b784608..0204a22e 100644 --- a/packages/compiler/src/ast-helpers.ts +++ b/packages/compiler/src/ast-helpers.ts @@ -313,7 +313,8 @@ export function extractDictExpression( } } - return Object.keys(result).length > 0 ? result : undefined; + // An empty dictionary is a value, including inside other dictionaries or lists. + return result; } /** diff --git a/packages/compiler/test/ast-helpers.test.ts b/packages/compiler/test/ast-helpers.test.ts index d2c81d3d..26b1237a 100644 --- a/packages/compiler/test/ast-helpers.test.ts +++ b/packages/compiler/test/ast-helpers.test.ts @@ -10,10 +10,17 @@ import { DiagnosticSeverity } from '@agentscript/types'; import { type Expression, AtIdentifier, + DictLiteral, + ListLiteral, + StringLiteral, Identifier, MemberExpression, } from '@agentscript/language'; -import { resolveAtReference } from '../src/ast-helpers.js'; +import { + resolveAtReference, + extractDictExpression, + extractExpressionValue, +} from '../src/ast-helpers.js'; import { CompilerContext } from '../src/compiler-context.js'; let ctx: CompilerContext; @@ -72,3 +79,74 @@ describe('resolveAtReference', () => { ).toBe(true); }); }); + +describe('issue #101: dictionary extraction contract', () => { + it('extracts an empty dictionary as an object', () => { + expect(extractDictExpression(new DictLiteral([]))).toStrictEqual({}); + }); + + it('keeps missing dictionary input undefined', () => { + expect(extractDictExpression(undefined)).toBeUndefined(); + }); + + it('keeps non-dictionary input undefined', () => { + expect(extractDictExpression(new StringLiteral('value'))).toBeUndefined(); + }); + + it('keeps unsupported expressions undefined', () => { + expect( + extractExpressionValue(new Identifier('unresolved')) + ).toBeUndefined(); + }); + + it('preserves empty dictionary values while omitting unsupported values', () => { + const expression = new DictLiteral([ + { + key: new StringLiteral('empty'), + value: new DictLiteral([]), + __diagnostics: [], + }, + { + key: new StringLiteral('unsupported'), + value: new Identifier('unresolved'), + __diagnostics: [], + }, + { + key: new StringLiteral('kept'), + value: new StringLiteral('value'), + __diagnostics: [], + }, + ]); + + expect(extractExpressionValue(expression)).toStrictEqual({ + empty: {}, + kept: 'value', + }); + }); + + it('preserves empty list elements while omitting unsupported expressions', () => { + const expression = new ListLiteral([ + new DictLiteral([]), + new Identifier('unresolved'), + new StringLiteral('value'), + new DictLiteral([]), + ]); + + expect(extractExpressionValue(expression)).toStrictEqual([{}, 'value', {}]); + }); + + it('preserves empty plain-array elements while omitting unsupported expressions', () => { + const expressions = [ + new DictLiteral([]), + new Identifier('unresolved'), + new StringLiteral('value'), + new DictLiteral([]), + ]; + + expect(extractExpressionValue(expressions)).toStrictEqual([ + {}, + 'value', + {}, + ]); + }); +}); diff --git a/packages/compiler/test/live.test.ts b/packages/compiler/test/live.test.ts index 56cb81ee..45748a7f 100644 --- a/packages/compiler/test/live.test.ts +++ b/packages/compiler/test/live.test.ts @@ -152,3 +152,30 @@ live: }); }); }); + +describe('issue #101: empty objects in live parameters', () => { + it.each([ + { name: 'an empty dictionary value', literal: '{}', expected: {} }, + { + name: 'empty objects at their original list positions', + literal: '[{}, {"n": 1}, {}]', + expected: [{}, { n: 1 }, {}], + }, + ])('preserves $name', ({ literal, expected }) => { + const { output, diagnostics } = compile( + parseSource( + agent(` +live: + enabled: True + params: + profile: ${literal} +`) + ) + ); + + expect(diagnostics.filter(d => d.severity === 1)).toEqual([]); + expect(output.agent_version.live?.params).toStrictEqual({ + profile: expected, + }); + }); +}); diff --git a/packages/compiler/test/model-config-inheritance.test.ts b/packages/compiler/test/model-config-inheritance.test.ts index 03885a3e..9311123c 100644 --- a/packages/compiler/test/model-config-inheritance.test.ts +++ b/packages/compiler/test/model-config-inheritance.test.ts @@ -525,3 +525,61 @@ subagent topic4: }); }); }); + +describe('issue #101: empty model parameter values', () => { + it('preserves nested empty values inherited from global parameters', () => { + const { output, diagnostics } = compileSource(` +config: + developer_name: "TestAgent" +model_config: + model: "model://gpt-4" + params: + profile: {"empty": {}, "full": {"n": 1}} +start_agent main: + description: "Entry point" +subagent support: + description: "Support" + reasoning: + instructions: -> + | Help with requests +`); + + expect(diagnostics.filter(d => d.severity === 1)).toEqual([]); + expect( + findNode(output, 'support', 'subagent').model_configuration + ).toStrictEqual({ + model_ref: 'gpt-4', + configuration: { profile: { empty: {}, full: { n: 1 } } }, + }); + }); + + it('allows an empty topic parameter to override a populated global value', () => { + const { output, diagnostics } = compileSource(` +config: + developer_name: "TestAgent" +model_config: + model: "model://gpt-4" + params: + profile: {"n": 1} + temperature: 0.7 +start_agent main: + description: "Entry point" +subagent support: + description: "Support" + model_config: + params: + profile: {} + reasoning: + instructions: -> + | Help with requests +`); + + expect(diagnostics.filter(d => d.severity === 1)).toEqual([]); + expect( + findNode(output, 'support', 'subagent').model_configuration + ).toStrictEqual({ + model_ref: 'gpt-4', + configuration: { profile: {}, temperature: 0.7 }, + }); + }); +}); diff --git a/packages/compiler/test/state-variables.test.ts b/packages/compiler/test/state-variables.test.ts index 9aeb2153..3fd00cc5 100644 --- a/packages/compiler/test/state-variables.test.ts +++ b/packages/compiler/test/state-variables.test.ts @@ -1253,3 +1253,51 @@ variables: expect(findStateVar(result, 'obj_no_default')!.default).toBeUndefined(); }); }); + +describe('issue #101: empty objects in variable defaults', () => { + it.each([ + { + name: 'an empty child beside a populated child', + type: 'object', + literal: '{"empty": {}, "full": {"n": 1}}', + expected: { empty: {}, full: { n: 1 } }, + }, + { + name: 'a recursive chain ending in an empty object', + type: 'object', + literal: '{"a": {"b": {}}}', + expected: { a: { b: {} } }, + }, + { + name: 'empty objects at their original list positions', + type: 'list[object]', + literal: '[{}, {"n": 1}, {}]', + expected: [{}, { n: 1 }, {}], + }, + { + name: 'a list containing only empty objects', + type: 'list[object]', + literal: '[{}, {}]', + expected: [{}, {}], + }, + { + name: 'an empty object inside a nested list', + type: 'object', + literal: '{"a": [{}]}', + expected: { a: [{}] }, + }, + ])('preserves $name', ({ type, literal, expected }) => { + const result = compileSource( + agentSource(` +variables: + payload: mutable ${type} = ${literal}`) + ); + + expect( + result.diagnostics.filter(d => d.severity === DiagnosticSeverity.Error) + ).toEqual([]); + const variable = findStateVar(result, 'payload'); + expect(variable).toBeDefined(); + expect(variable!.default).toStrictEqual(expected); + }); +});