From 60942a54af9ffc4711024e2f92f1b2461ffec30b Mon Sep 17 00:00:00 2001 From: Lee Hilton Date: Thu, 8 Oct 2026 10:29:17 -0700 Subject: [PATCH] fix(prompts): don't crash schema compression on a root-level description compressToolDescriptions() calls compressSchemaDescriptions(input_schema, null) for the schema ROOT, where there is no property name to judge. The first root-level `description` string therefore reached isObviousFromName(null), which threw: TypeError: Cannot read properties of null (reading 'toLowerCase') at isObviousFromName (src/prompts/system.js:160) at compressSchemaDescriptions (src/prompts/system.js:115) The orchestrator catches that throw and logs it as "System prompt optimization failed, continuing with original" (src/orchestrator/index.js), so the failure was silent and shipped: with the default TOOL_DESCRIPTIONS=minimal, minimal-mode tool-description compression did nothing for every tool whose input schema carried a root-level description. The sibling optimizeSystemPrompt() call sits in the same try block, so it was skipped for those requests too. Treat a non-string key as "not obvious" rather than dereferencing it. Regression coverage added to test/tool-schema-compression.test.js for a root-level schema description on object, bare-object and array schema roots. --- src/prompts/system.js | 7 +++- test/tool-schema-compression.test.js | 57 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/prompts/system.js b/src/prompts/system.js index bcb2707..9547d0f 100644 --- a/src/prompts/system.js +++ b/src/prompts/system.js @@ -147,10 +147,15 @@ function compressText(text, maxLength) { /** * Check if property name is self-explanatory - * @param {string} name - Property name + * @param {string|null} name - Property name, or null for a schema root * @returns {boolean} True if name is obvious */ function isObviousFromName(name) { + // The schema ROOT is compressed with key=null (see + // compressSchemaDescriptions(out.input_schema, null)): there is no property + // name to judge. Treat any non-string key as "not obvious" instead of + // crashing on name.toLowerCase() — that throw aborted the whole pass. + if (typeof name !== 'string') return false; const obvious = [ 'id', 'name', 'type', 'value', 'data', 'text', 'content', 'message', 'query', 'command', 'url', 'path', 'file', 'filename', diff --git a/test/tool-schema-compression.test.js b/test/tool-schema-compression.test.js index 5e3556d..7376019 100644 --- a/test/tool-schema-compression.test.js +++ b/test/tool-schema-compression.test.js @@ -79,3 +79,60 @@ describe('compressToolDescriptions preserves schema shape (issue #116)', () => { assert.strictEqual(t.strict, false); }); }); + +// Regression tests: compressSchemaDescriptions is invoked as +// compressSchemaDescriptions(schema, null) for the schema ROOT, where there is +// no property name to judge. Passing that null into isObviousFromName threw +// "TypeError: Cannot read properties of null (reading 'toLowerCase')", which +// the orchestrator catches and logs as +// "System prompt optimization failed, continuing with original" +// (src/orchestrator/index.js). Net effect: for every tool whose input schema +// carries a root-level `description`, minimal-mode compression silently did +// nothing — and the sibling optimizeSystemPrompt() call in the same try block +// was skipped too. +describe('compressToolDescriptions tolerates a root-level schema description', () => { + const rootDescriptionTool = () => ({ + type: 'function', + function: { + name: 'tool_root', + parameters: { + type: 'object', + description: 'A deliberately long root-level schema description that exceeds thirty chars', + properties: { + command: { type: 'string', description: 'The command to run' }, + custom: { type: 'string', description: 'A very long custom description that definitely needs trimming down' }, + }, + required: ['command'], + }, + }, + }); + + it('does not throw (was: TypeError on null.toLowerCase)', () => { + assert.doesNotThrow(() => compressToolDescriptions([rootDescriptionTool()], 'minimal')); + }); + + it('compresses the root description while preserving schema shape', () => { + const [t] = compressToolDescriptions([rootDescriptionTool()], 'minimal'); + assert.strictEqual(t.input_schema.type, 'object'); + assert.deepStrictEqual(t.input_schema.required, ['command']); + assert.strictEqual(typeof t.input_schema.description, 'string'); + assert.ok(t.input_schema.description.length <= 30, t.input_schema.description); + // Nested behaviour is unchanged. + assert.ok(!('description' in t.input_schema.properties.command)); + assert.ok(t.input_schema.properties.custom.description.length <= 30); + }); + + it('handles a root-level schema with no properties without throwing', () => { + assert.doesNotThrow(() => compressToolDescriptions([{ + name: 'bare', + input_schema: { type: 'object', description: 'x'.repeat(80) }, + }], 'minimal')); + }); + + it('handles a root-level schema array without throwing', () => { + assert.doesNotThrow(() => compressToolDescriptions([{ + name: 'listy', + input_schema: [{ type: 'object', description: 'x'.repeat(80) }], + }], 'minimal')); + }); +});