Skip to content

fix(prompts): guard schema compression against a root-level description - #122

Open
lhilton wants to merge 1 commit into
Fast-Editor:mainfrom
lhilton:fix/schema-compression-null-key
Open

lhilton wants to merge 1 commit into
Fast-Editor:mainfrom
lhilton:fix/schema-compression-null-key

Conversation

@lhilton

@lhilton lhilton commented Oct 8, 2026

Copy link
Copy Markdown

Description

With the default TOOL_DESCRIPTIONS=minimal, tool-schema compression silently does nothing for any request containing a tool that carries a description on its schema root. compressToolDescriptions() calls compressSchemaDescriptions(input_schema, null) for the root, and the first root-level description hands that null to isObviousFromName(), which calls name.toLowerCase() unguarded — throwing TypeError: Cannot read properties of null (reading 'toLowerCase'). The orchestrator catches the throw and downgrades it to a warning, so the request still returns 200 with every tool's descriptions left uncompressed. Because the call sits inside a .map() over the tool array, one offending tool disables compression for the entire request. This change treats a non-string key as "not obvious", which is the correct answer for a schema root.

Reported from a self-hosted deployment; no affiliation with Lynkr or with any provider.

Closes #121

Problem

Observed on a self-hosted gateway (v9.14.17, OpenRouter provider) on every request that carried tools:

{"level":40,"err":{"type":"TypeError","message":"Cannot read properties of null (reading 'toLowerCase')",
 "stack":"...\n    at isObviousFromName (/app/src/prompts/system.js:160:32)
    at compressSchemaDescriptions (/app/src/prompts/system.js:115:14)
    at /app/src/prompts/system.js:97:26
    at Object.compressToolDescriptions (/app/src/prompts/system.js:88:16)
    at runAgentLoop (/app/src/orchestrator/index.js:1669:45)"},
 "msg":"System prompt optimization failed, continuing with original"}

Reproduction. POST /v1/chat/completions with a single tool whose parameters object carries a top-level description. Minimal mode is the default (src/config/index.js:248), so no configuration change is required.

Measured impact. On that gateway, 29 of 126 completed requests (23%) logged the warning, and the share of tool-bearing requests is higher still since the path only runs when tools are present. Each affected request forfeited the whole compression pass — and, because optimizeSystemPrompt() sits in the same try block, that too.

Not a duplicate. main is at 1243a46 and isObviousFromName() is still unguarded there; no branch in the repo carries a guard, and no other issue or PR covers this defect.

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Performance improvement
  • Refactoring

Changes Made

  • src/prompts/system.js — isObviousFromName() returns false for any non-string key and its @param now documents null, so a schema root (compressed with key === null) is treated as "not obvious" instead of dereferencing null.
  • test/tool-schema-compression.test.js — appends a suite covering a root-level schema description on object, bare-object and array schema roots. No new file, and the file is already listed in package.json's test:unit, so no package.json change is needed.

Behavior changes for review

  • A root-level schema description is now kept and truncated to 30 chars rather than dropped — there is no property name to judge it against, and this matches the intent of minimal mode.
  • On requests that previously threw, the sibling optimizeSystemPrompt() call in the same try block now runs as well. That follows directly from removing the throw; it is not a separate change.
  • When no tool carries a root-level description, behavior is unchanged — verified bit-for-bit on usage.prompt_tokens against an unpatched build.
  • No public signature or return shape changes; isObviousFromName() simply becomes total over its input domain.

Testing

  • Existing tests pass — node --test test/tool-schema-compression.test.js (9 pass / 0 fail after; 5 pass / 4 fail before). Also ran the target file plus 13 adjacent suites (14 files, 180 tests): 179 pass, 1 fail — see Known issue.
  • Added new tests — test/tool-schema-compression.test.js (extended; no new file).
  • Manual testing performed — two containers built from this repo's own image, differing only by a bind-mounted patched src/; the same 10-tool payload posted to both; compared usage.prompt_tokens returned by the upstream (OpenRouter, z-ai/glm-5.3-flash) and the compression debug line. No real API calls are made in the committed tests.

Verified on Node 22.23.3 (the version ci.yml pins) and Node 24.21.0 — identical results.

Measured results

Unit — node --test test/tool-schema-compression.test.js:

tests pass fail
unpatched 9 5 4
patched 9 9 0

All four failures were the newly added tests, failing with the production error (TypeError ... 'toLowerCase' at isObviousFromName, src/prompts/system.js:160).

Billed tokens — 10-tool payload, usage.prompt_tokens read off the upstream response; deterministic across 3 repetitions per row:

payload unpatched patched saved
all 10 tools have a root-level description 10056 4792 −5264 (−52.4%)
1 of 10 tools has a root-level description 9498 4594 −4904 (−51.6%)
control: no tool has a root-level description 4572 4572 0

The control row is the attribution check: with nothing to trip the bug, both builds are indistinguishable. Re-running the first two rows with SYSTEM_PROMPT_MODE=static (which takes optimizeSystemPrompt() out of play) reproduces them exactly, so the saving is attributable to tool-schema compression alone.

Worth knowing when reading the debug log: its char-based figure overstates the win — it reports 63.3% of characters saved against 52.4% of billed tokens, because what gets dropped is prose.

Lint — npx eslint@8.57.0 src/prompts/system.js --max-warnings 0 (exit 0).

Known issue (follow-up, not addressed here)

  1. CI does not run test/tool-schema-compression.test.js. .github/workflows/ci.yml runs its own hardcoded node --test list of 19 files, while package.json's test:unit lists 99 — 80 files never execute on a PR. The file holding this regression suite (and the existing Tool schema compression rebuilds input_schema from a short allowlist and drops strict, pattern, const and oneOf #116 tests) is one of them. Adding it to the workflow list is a one-line change, deliberately left out here to keep this PR scoped.
  2. test/context-compression.test.js already fails on unmodified main with ReferenceError: describe is not defined — it never imports from node:test — and is not in the CI list. Pre-existing; untouched here.
  3. Repo-wide lint is currently red on main: npx eslint src index.js --max-warnings 0 fails on src/clients/cursor-utils.js:34 ('execSync' is assigned a value but never used), introduced by feat: Add support for Cursor Pro #119. Unrelated to this change — the file touched here lints clean — and open PR Routing hygiene for agent harnesses, structured-output fidelity, OpenRouter hardening, shortfall semantic signals #120 appears to remove that unused import.
  4. Issue Tool schema compression rebuilds input_schema from a short allowlist and drops strict, pattern, const and oneOf #116 is a different defect and remains open: the feat:Tier routing on subscription passthrough, Jev routing judge, ups… #117 rewrite loses strict/pattern/const/oneOf from schemas. This PR does not address it.

Checklist

  • Code follows style guidelines
  • Documentation updated — n/a; no user-facing docs describe this internal path
  • Tests added/updated
  • No new warnings — npx eslint@8.57.0 src/prompts/system.js --max-warnings 0 exits 0 (the repo-wide src index.js invocation fails only on the pre-existing error above)

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.
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

✅ OpenCodeReview: Review complete: 0 finding(s) across 1 selected item(s).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tool schema compression throws on a root-level description, silently disabling compression for the whole request

1 participant