Files
qwen-code/scripts/generate-settings-schema.ts
T
qqqysandqqqys 5a9c9b3406 feat(goal): stop a Goal at a turn or an active-time budget (#11457)
* feat(goal): stop a Goal at a turn or an active-time budget

The token budget is a runaway-spend guard, sized for that job: 30,000,000
tokens by default, which a healthy long run reaches late. It is not what a
user reaches for to keep a Goal short -- people say "at most twenty turns"
or "at most half an hour". Neither had any effect, and the objective
template invites the first phrasing while the user doc has to explain that
writing it configures nothing.

Add two ceilings beside the token one, both off by default:
`model.goalMaxTurns` counts finished Goal turns and `model.goalMaxActiveMinutes`
counts the wall time the Goal spends active. The meters were already on the
record; only the ceilings and the stop were missing.

A Goal that reaches either takes the path a spent token budget already
takes: one wind-down turn to hand off, then `usage_limited` with a new
`limitKind` (`turn_budget` or `time_budget`) and a reason naming the budget.
A resume authorizes another window on top of what was used and moves only
the ceiling that ran out. Ceilings are read at the continuation boundary, so
the turn that crosses one still finishes; a turn crossing several reports one
reason, token first.

- `spentBudget` replaces the runtime's direct token check, so the
  continuation gate, the settle, and the no-progress bound cannot disagree
  about whether a Goal is out of allowance. The no-progress pause yields to
  every spent budget, not just the token one, so a cadence stop still gets
  its hand-off.
- The reducer's budget-resume branch keyed off the literal `token_budget`;
  it now keys off `isGoalBudgetLimitKind`, or a turn-stopped Goal would
  resume still rendering "ran its turn budget" as the reason it is active.
- The wind-down line stops naming the token budget, since the hosts carry a
  plain `windDown` flag and cannot say which ceiling was reached. It points
  at the budget line above it, which grew to report turns against their
  ceiling and, when one is armed, active minutes against theirs.
- Both new `limitKind` values reach the two whitelists outside core: the
  SDK's hand-copied union and the Web Shell's `getGoalState`, which rebuilds
  the record field by field and would otherwise drop them on the live path.

Design: docs/design/2026-09-09-goal-turn-and-time-budget.md and its
.zh-CN.md counterpart.

* fix(goal): repair the assertions and mocks the budget change broke

Six CLI assertions pinned the continuation prompt verbatim, and renaming
the budget line's prefix and rewording the wind-down line moved that text
out from under them. Two more CLI suites could not collect at all: they
build their `@qwen-code/qwen-code-core` mock by enumerating exports, and
`settingsSchema.ts` now reads two new caps at module scope.

Neither was reachable from the verification steps this change shipped
with, which ran only `runBudget.test.ts` and `config.test.ts` in the CLI
package. `cd packages/cli && npx vitest run` reaches all eight.

Also, while in these files:

- `get_goal`'s `lastGoal` summary already reported `tokenBudget`, so a
  Goal stopped by a cadence ceiling could be inspected and show only the
  allowance that did not run out. It now reports all three, and the tool
  description says so.
- Two validator doc comments said "-1 for no ceiling, or a positive
  integer"; the predicates accept `0` as the same opt-out, the way the
  token budget's sibling already documents it.
- `settingsSchema.test.ts` now pins both new entries: integer, no
  default, bounded by their caps, no restart.

* fix(config): reject signed zero for the Goal cadence settings, and say what the ceilings actually do

The `excludedValues` guard added last round compared with `Object.is`, which
tells `+0` from `-0`. `Number()` maps `-0`, `-0.0`, `-.0` and `-0e0` to `-0`,
`Number.isInteger(-0)` is true and `-0 < -1` is false, so `/config
model.goalMaxTurns=-0` walked past the guard, reported `Set ... = 0` because
`String(-0)` is `"0"`, and `JSON.stringify` persisted the excluded value.
Every later start in that scope then aborted on `must be > 0`, with `/config`
unreachable to repair it -- the same lockout the guard was added to prevent.
Compare numbers with `===` so signed zero matches, keeping identity for the
string half of the field's type.

The documentation of both ceilings overclaimed in three ways, each fixed
wherever the claim is published -- the two schema descriptions and their
generated mirror, the settings table, the user guide, both locales of the
Web Shell placeholder, three places in the goal-draft skill, and the
`propose_goal` objective example:

- `-1` was described as running Goals with no ceiling. It only removes a
  ceiling a Goal has already spent, on the resume or edit that follows; a Goal
  still under its ceiling keeps it.
- Neither ceiling is retrofitted onto a Goal already on the record, so the
  only way to bound one is to replace it. Nothing said so.
- The minutes row read as a wall-clock cap. There is no timer: the ceiling is
  read between turns, so a turn already running is never interrupted and the
  window can be exceeded by that turn plus the hand-off.

The design doc's "no behavioural change when off" bullet and its first
acceptance criterion were also too strong, in both languages. Restoring an
`active` Goal rebases `updatedAt` unconditionally, so the elapsed figure a
restored Goal reports drops offline time for everyone, settings or not -- and
drops with it any active time in an interrupted turn that no journal write had
committed. Both are now stated rather than implied, with the reason the record
cannot separate the two.

Two promises the tests did not hold:

- Three surfaces say user turns are still admitted at the ceiling, and the only
  case covering it admitted the turn one step below the limit. A case now
  reserves a user turn with the ceiling already spent and the hand-off in
  flight; adding a `spentBudget` gate to `beginTurn` reds it.
- The active-time ceiling is the only one whose spent state can change between
  queueing a continuation and delivering it. A case now queues under the
  ceiling with no host and binds past it, asserting the hand-off; making
  `bindHost` flush directly reds it.

* fix(goal): name what replacing a Goal costs, keep ceiling advice out of objective text

Round-three review, four findings.

Replacing a Goal is the only way to bound one already on the record, and the
docs offered it without the cost. `/goal set` goes through `createGoal`: a new
Goal at revision 1, turn, token and active-time meters at zero, and an
evidence cursor at the replacement, so everything the old Goal recorded drops
out of the citable catalog. Said so on every surface that offers the remedy --
the user guide, both settings rows and both schema descriptions, regenerated.

The ceiling settings were pointed at from two opposite places: the goal-draft
template slot said "outside the objective", while its exemplar, the user
guide's Budget row and strong example, the `propose_goal` objective example
and the Web Shell placeholder all wrote the pointer into objective text -- text
re-sent every turn to a model that cannot act on it, advising a setting that by
its own wording never applies to the Goal carrying it. One placement now:
never in objective text. The rules-of-thumb bullet names the settings, the
user guide's Budget row says to set them in settings, and the Web Shell pointer
moves from the placeholder into the hint rendered under the same field.
`SKILL.test.ts` pins both halves -- the settings are named in the skill body
and absent from the template and the exemplar -- so they cannot drift apart.
The strong example also gains the `On block:` clause the template asks for.

The signed-zero guard is one `includes` call: SameValueZero already matches
`-0` against a `[0]` exclusion, so the typeof-split ternary carried an arm no
setting can reach. The `-0` assertion from last round still pins it.

The settings table rows are trimmed to fit the widest existing row, so the
table is not re-padded, and one shortening that would have misdescribed which
values are rejected is reverted to the accurate list.

* fix(goal): stop the ceiling docs promising more than the runtime does

Round-four review, five findings, all documentation precision. Each one is
fixed by narrowing a claim rather than qualifying it further, so the surface
the next round can find fault with gets smaller.

- Bounding a Goal already on the record: "replacing it with /goal set or
  clearing it, which starts a new Goal" said clearing starts a Goal. Clearing
  leaves none. Only the replacement starts a new Goal at revision 1; clearing
  is followed by starting again. Both settings rows and both schema
  descriptions now say so, matching the user guide.
- "Time while the process is not running does not count" overclaimed. The
  only exclusion is the rebase a restart performs; a suspended process, such
  as a sleeping laptop, is charged. The claim now says exactly that.
- The quantified overshoot, "that turn plus the hand-off turn", holds only
  when a turn is running as the window runs out. A window that runs out while
  no turn runs is not noticed until the next turn ends. The bound is dropped
  in favour of saying the Goal can run well past the window.
- The Web Shell hint sent readers to settings for two keys that client's
  settings surface cannot reach. It now names `/config` in the chat, which
  runs in daemon sessions.
- The second weak-to-strong exemplar still wrote a bare turn count into its
  Budget. It carries the advisory marker like the first, and the skill test now
  checks every exemplar row instead of one.

---------

Co-authored-by: qqqys <[email protected]>
2026-09-11 01:01:02 +00:00

275 lines
8.2 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Generates a JSON Schema from the internal SETTINGS_SCHEMA definition.
*
* Usage: npx tsx scripts/generate-settings-schema.ts
*
* This reads the TypeScript settings schema and converts it to a standard
* JSON Schema file that VS Code uses for IntelliSense in settings.json files.
*
* Prerequisites: npm run build (core package must be built first)
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
SettingDefinition,
SettingItemDefinition,
SettingsSchema,
} from '../packages/cli/src/config/settingsSchema.js';
import { getSettingsSchema } from '../packages/cli/src/config/settingsSchema.js';
import { SETTINGS_VERSION } from '../packages/cli/src/config/settings.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface JsonSchemaProperty {
$schema?: string;
type?: string | string[];
description?: string;
properties?: Record<string, JsonSchemaProperty>;
items?: JsonSchemaProperty;
enum?: (string | number)[];
default?: unknown;
minimum?: number;
maximum?: number;
not?: JsonSchemaProperty;
additionalProperties?: boolean | JsonSchemaProperty;
required?: string[];
oneOf?: JsonSchemaProperty[];
anyOf?: JsonSchemaProperty[];
allOf?: JsonSchemaProperty[];
}
function convertItemDefinitionToJsonSchema(
itemDef: SettingItemDefinition,
): JsonSchemaProperty {
const schema: JsonSchemaProperty = {};
if (itemDef.description) {
schema.description = itemDef.description;
}
schema.type = itemDef.type;
if (itemDef.enum) {
schema.enum = itemDef.enum;
}
if (itemDef.type === 'object' && itemDef.properties) {
schema.properties = {};
const requiredFields: string[] = [];
for (const [key, childDef] of Object.entries(itemDef.properties)) {
const childSchema = convertItemDefinitionToJsonSchema(childDef);
schema.properties[key] = childSchema;
if (childDef.required) {
requiredFields.push(key);
}
}
if (requiredFields.length > 0) {
schema.required = requiredFields;
}
}
if (itemDef.type === 'object' && itemDef.additionalProperties !== undefined) {
if (typeof itemDef.additionalProperties === 'boolean') {
schema.additionalProperties = itemDef.additionalProperties;
} else {
schema.additionalProperties = convertItemDefinitionToJsonSchema(
itemDef.additionalProperties,
);
}
}
if (itemDef.items) {
schema.type = 'array';
schema.items = convertItemDefinitionToJsonSchema(itemDef.items);
}
return schema;
}
function convertSettingToJsonSchema(
setting: SettingDefinition,
): JsonSchemaProperty {
// Escape hatch: a SettingDefinition can supply a verbatim JSON Schema
// fragment for cases the `type` field cannot express (most commonly
// unions). The description is carried forward from the SettingDefinition
// so we don't have to restate it in the override.
if (setting.jsonSchemaOverride) {
const override = { ...setting.jsonSchemaOverride } as JsonSchemaProperty;
if (setting.description && override.description === undefined) {
override.description = setting.description;
}
return override;
}
const schema: JsonSchemaProperty = {};
if (setting.description) {
schema.description = setting.description;
}
switch (setting.type) {
case 'boolean':
schema.type = 'boolean';
break;
case 'string':
schema.type = 'string';
break;
case 'number':
schema.type = 'number';
break;
case 'integer':
schema.type = 'integer';
break;
case 'array':
schema.type = 'array';
if (setting.items) {
schema.items = convertItemDefinitionToJsonSchema(setting.items);
} else {
schema.items = { type: 'string' };
}
break;
case 'enum':
if (setting.options && setting.options.length > 0) {
schema.enum = setting.options.map((o) => o.value);
const optionsText =
'Options: ' + setting.options.map((o) => `${o.value}`).join(', ');
schema.description = schema.description
? `${schema.description} ${optionsText}`
: optionsText;
} else {
// Enum without predefined options - accept any string
schema.type = 'string';
}
break;
case 'object':
schema.type = 'object';
if (setting.properties) {
schema.properties = {};
for (const [key, childDef] of Object.entries(setting.properties)) {
schema.properties[key] = convertSettingToJsonSchema(
childDef as SettingDefinition,
);
}
} else {
schema.additionalProperties = true;
}
break;
}
// Add default value for simple and object types
if (setting.default !== undefined && setting.default !== null) {
const defaultVal = setting.default;
if (
typeof defaultVal === 'boolean' ||
typeof defaultVal === 'number' ||
typeof defaultVal === 'string'
) {
schema.default = defaultVal;
} else if (Array.isArray(defaultVal) && defaultVal.length > 0) {
schema.default = defaultVal;
} else if (
typeof defaultVal === 'object' &&
!Array.isArray(defaultVal) &&
Object.keys(defaultVal).length > 0
) {
// Non-empty plain object — publish so IDE editors can surface the
// default value (e.g. `{commit: true, pr: true}` for gitCoAuthor).
schema.default = defaultVal;
}
}
if (
(setting.type === 'number' || setting.type === 'integer') &&
setting.minimum !== undefined
) {
schema.minimum = setting.minimum;
}
if (
(setting.type === 'number' || setting.type === 'integer') &&
setting.maximum !== undefined
) {
schema.maximum = setting.maximum;
}
if (setting.excludedValues && setting.excludedValues.length > 0) {
schema.not = { enum: [...setting.excludedValues] };
}
// If the field accepts a legacy primitive shape (e.g. a boolean that was
// later expanded into an object), wrap with `anyOf` so existing values
// in users' settings.json don't trip the IDE schema validator while
// they wait for our migration to rewrite them on the next launch.
//
// Lift `description` and `default` to the outer (anyOf) level so IDE
// editors that surface schema-driven defaults / descriptions still see
// them — burying these behind `anyOf[N]` makes most validators ignore
// the `default`, which loses the "enabled by default" hint for any
// setting using `legacyTypes`.
if (setting.legacyTypes && setting.legacyTypes.length > 0) {
const description = schema.description;
const defaultVal = schema.default;
delete schema.description;
delete schema.default;
return {
...(description ? { description } : {}),
...(defaultVal !== undefined ? { default: defaultVal } : {}),
anyOf: [...setting.legacyTypes.map((t) => ({ type: t })), schema],
};
}
return schema;
}
function generateJsonSchema(
settingsSchema: SettingsSchema,
): JsonSchemaProperty {
const jsonSchema: JsonSchemaProperty = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
description: 'Qwen Code settings configuration',
properties: {},
additionalProperties: true,
};
for (const [key, setting] of Object.entries(settingsSchema)) {
jsonSchema.properties![key] = convertSettingToJsonSchema(
setting as SettingDefinition,
);
}
// Add $version property — sourced from settings.ts so a SETTINGS_VERSION
// bump propagates here instead of needing a parallel manual edit.
jsonSchema.properties!['$version'] = {
type: 'number',
description: 'Settings schema version for migration tracking.',
default: SETTINGS_VERSION,
};
return jsonSchema;
}
const schema = getSettingsSchema();
const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema);
const outputDir = path.resolve(
__dirname,
'../packages/vscode-ide-companion/schemas',
);
const outputPath = path.join(outputDir, 'settings.schema.json');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2) + '\n');
console.log(`Generated settings JSON Schema at: ${outputPath}`);