mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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]>
275 lines
8.2 KiB
TypeScript
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}`);
|