feat(config): add rich description fields to JSON Schema output [AI-assisted] (#60067)

Merged via squash.

Prepared head SHA: a98b971924
Co-authored-by: solavrc <145330217+solavrc@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
Hiroshi Tanaka
2026-04-05 04:10:08 +09:00
committed by GitHub
parent 92aed3168a
commit 3f1b369f4a
5 changed files with 2531 additions and 24 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Prompt caching: keep prompt prefixes more reusable across transport fallback, deterministic MCP tool ordering, compaction, and embedded image history so follow-up turns hit cache more reliably. (#58036, #58037, #58038, #59054, #60603, #60691) Thanks @bcherny.
- Agents/cache: diagnostics: add prompt-cache break diagnostics, trace live cache scenarios through embedded runner paths, and show cache reuse explicitly in `openclaw status --verbose`. Thanks @vincentkoc.
- Agents/cache: stabilize cache-relevant system prompt fingerprints by normalizing equivalent structured prompt whitespace, line endings, hook-added system context, and runtime capability ordering so semantically unchanged prompts reuse KV/cache more reliably. Thanks @vincentkoc.
- Config/schema: enrich the exported `openclaw config schema` JSON Schema with field titles and descriptions so editors, agents, and other schema consumers receive the same config help metadata. (#60067) Thanks @solavrc.
### Fixes

View File

@@ -19,15 +19,19 @@ const mockWriteConfigFile = vi.fn<
const mockResolveSecretRefValue = vi.fn();
const mockReadBestEffortRuntimeConfigSchema = vi.fn();
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
writeConfigFile: (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) =>
mockWriteConfigFile(cfg, options),
replaceConfigFile: (params: {
nextConfig: OpenClawConfig;
writeOptions?: { unsetPaths?: string[][] };
}) => mockWriteConfigFile(params.nextConfig, params.writeOptions),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
writeConfigFile: (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) =>
mockWriteConfigFile(cfg, options),
replaceConfigFile: (params: {
nextConfig: OpenClawConfig;
writeOptions?: { unsetPaths?: string[][] };
}) => mockWriteConfigFile(params.nextConfig, params.writeOptions),
};
});
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args),
@@ -444,6 +448,13 @@ describe("config cli", () => {
describe("config schema", () => {
it("prints the generated JSON schema as plain text", async () => {
const { computeBaseConfigSchemaResponse } = await import("../config/schema-base.js");
mockReadBestEffortRuntimeConfigSchema.mockResolvedValueOnce(
computeBaseConfigSchemaResponse({
generatedAt: "2026-03-25T00:00:00.000Z",
}),
);
await runConfigCommand(["config", "schema"]);
expect(mockExit).not.toHaveBeenCalled();
@@ -454,23 +465,28 @@ describe("config cli", () => {
const payload = JSON.parse(String(raw)) as {
properties?: Record<string, unknown>;
};
const gateway = payload.properties?.gateway as
| { properties?: Record<string, unknown> }
| undefined;
const gatewayPort = gateway?.properties?.port as
| { title?: string; description?: string }
| undefined;
expect(payload.properties?.$schema).toEqual({ type: "string" });
expect(payload.properties?.channels).toEqual({
type: "object",
properties: {
telegram: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
expect(gatewayPort).toMatchObject({
title: "Gateway Port",
description: expect.stringContaining("TCP port used by the gateway listener"),
});
expect(payload.properties?.plugins).toEqual({
type: "object",
expect(payload.properties?.channels).toMatchObject({
title: "Channels",
properties: {},
additionalProperties: true,
});
expect(payload.properties?.plugins).toMatchObject({
title: "Plugins",
description: expect.stringContaining("Plugin system controls"),
properties: {
entries: {
type: "object",
title: "Plugin Entries",
},
},
});

View File

@@ -1,5 +1,6 @@
import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js";
import { VERSION } from "../version.js";
import { FIELD_HELP } from "./schema.help.js";
import type { ConfigUiHints } from "./schema.hints.js";
import {
applySensitiveUrlHints,
@@ -7,16 +8,28 @@ import {
collectMatchingSchemaPaths,
mapSensitivePaths,
} from "./schema.hints.js";
import { FIELD_LABELS } from "./schema.labels.js";
import { asSchemaObject, cloneSchema } from "./schema.shared.js";
import { applyDerivedTags } from "./schema.tags.js";
import { OpenClawSchema } from "./zod-schema.js";
type ConfigSchema = Record<string, unknown>;
type FieldDocumentation = {
titles: Record<string, string>;
descriptions: Record<string, string>;
};
type JsonSchemaObject = Record<string, unknown> & {
title?: string;
description?: string;
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
};
const LEGACY_HIDDEN_PUBLIC_PATHS = ["hooks.internal.handlers"] as const;
@@ -24,6 +37,114 @@ const LEGACY_HIDDEN_PUBLIC_PATHS = ["hooks.internal.handlers"] as const;
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
asSchemaObject<JsonSchemaObject>(value);
function buildFieldDocumentation(): FieldDocumentation {
const titles: Record<string, string> = {};
for (const [key, value] of Object.entries(FIELD_LABELS)) {
if (value) {
titles[key] = value;
}
}
const descriptions: Record<string, string> = {};
for (const [key, value] of Object.entries(FIELD_HELP)) {
if (value) {
descriptions[key] = value;
}
}
return { titles, descriptions };
}
/**
* Recursively walk a JSON Schema object and apply field docs using dot-path
* matching. Existing titles/descriptions (for example from Zod metadata) are
* preserved.
*/
function applyFieldDocumentation(
node: JsonSchemaObject,
documentation: FieldDocumentation,
prefixes: readonly string[] = [""],
): void {
const props = node.properties;
if (props) {
for (const [key, child] of Object.entries(props)) {
const childObj = asJsonSchemaObject(child);
if (!childObj) {
continue;
}
const childPrefixes = prefixes.map((prefix) => (prefix ? `${prefix}.${key}` : key));
applyNodeDocumentation(childObj, documentation, childPrefixes);
applyFieldDocumentation(childObj, documentation, childPrefixes);
}
}
// Handle additionalProperties (wildcard keys like "models.providers.*")
if (node.additionalProperties && typeof node.additionalProperties === "object") {
const addObj = asJsonSchemaObject(node.additionalProperties);
if (addObj) {
const wildcardPrefixes = prefixes.map((prefix) => (prefix ? `${prefix}.*` : "*"));
applyNodeDocumentation(addObj, documentation, wildcardPrefixes);
applyFieldDocumentation(addObj, documentation, wildcardPrefixes);
}
}
// Handle array items. Help/labels may use either "[]" notation
// (bindings[].type) or wildcard "*" notation (agents.list.*.skills).
if (node.items) {
const itemsObj = asJsonSchemaObject(node.items);
if (itemsObj) {
const itemPrefixes = Array.from(
new Set(
prefixes.flatMap((prefix) => {
const arrayPath = prefix ? `${prefix}[]` : "[]";
const wildcardAlias = prefix ? `${prefix}.*` : "*";
return wildcardAlias === arrayPath ? [arrayPath] : [wildcardAlias, arrayPath];
}),
),
);
applyNodeDocumentation(itemsObj, documentation, itemPrefixes);
applyFieldDocumentation(itemsObj, documentation, itemPrefixes);
}
}
// Recurse into composition branches (anyOf, oneOf, allOf) using the same
// path aliases so union/intersection variants inherit the same field docs.
for (const keyword of ["anyOf", "oneOf", "allOf"] as const) {
const branches = node[keyword];
if (Array.isArray(branches)) {
for (const branch of branches) {
const branchObj = asJsonSchemaObject(branch);
if (branchObj) {
applyFieldDocumentation(branchObj, documentation, prefixes);
}
}
}
}
}
function applyNodeDocumentation(
node: JsonSchemaObject,
documentation: FieldDocumentation,
pathCandidates: readonly string[],
): void {
if (!node.title) {
for (const path of pathCandidates) {
const title = documentation.titles[path];
if (title) {
node.title = title;
break;
}
}
}
if (!node.description) {
for (const path of pathCandidates) {
const description = documentation.descriptions[path];
if (description) {
node.description = description;
break;
}
}
}
}
export type BaseConfigSchemaResponse = {
schema: ConfigSchema;
uiHints: ConfigUiHints;
@@ -113,6 +234,10 @@ function computeBaseConfigSchemaStablePayload(): BaseConfigSchemaStablePayload {
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const schemaRoot = asJsonSchemaObject(schema);
if (schemaRoot) {
applyFieldDocumentation(schemaRoot, buildFieldDocumentation());
}
const baseHints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints());
const sensitiveUrlPaths = collectMatchingSchemaPaths(
OpenClawSchema,

File diff suppressed because it is too large Load Diff

View File

@@ -94,10 +94,18 @@ describe("config schema", () => {
it("exports schema + hints", () => {
const res = baseSchema;
const schema = res.schema as { properties?: Record<string, unknown> };
const gatewaySchema = schema.properties?.gateway as
| { properties?: Record<string, unknown> }
| undefined;
const gatewayPortSchema = gatewaySchema?.properties?.port as
| { title?: string; description?: string }
| undefined;
expect(schema.properties?.gateway).toBeTruthy();
expect(schema.properties?.agents).toBeTruthy();
expect(schema.properties?.acp).toBeTruthy();
expect(schema.properties?.$schema).toBeUndefined();
expect(gatewayPortSchema?.title).toBe("Gateway Port");
expect(gatewayPortSchema?.description).toContain("TCP port used by the gateway listener");
expect(res.uiHints.gateway?.label).toBe("Gateway");
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
expect(res.uiHints["channels.defaults.groupPolicy"]?.label).toBeTruthy();
@@ -329,7 +337,13 @@ describe("config schema", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime");
expect(lookup?.path).toBe("agents.list.0.runtime");
expect(lookup?.hintPath).toBe("agents.list[].runtime");
expect(lookup?.schema).toEqual({});
// The shallow lookup schema carries field docs, but should not expose
// nested composition keywords (allOf, oneOf, etc.).
expect(lookup?.schema).not.toHaveProperty("allOf");
expect(lookup?.schema).not.toHaveProperty("oneOf");
expect(lookup?.schema).not.toHaveProperty("anyOf");
expect(lookup?.schema).toHaveProperty("title", "Agent Runtime");
expect(lookup?.schema).toHaveProperty("description");
});
it("matches wildcard ui hints for concrete lookup paths", () => {
@@ -337,6 +351,10 @@ describe("config schema", () => {
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar");
expect(lookup?.hint?.help).toContain("workspace-relative path");
expect(lookup?.schema).toMatchObject({
title: "Identity Avatar",
description: expect.stringContaining("Agent avatar"),
});
});
it("normalizes bracketed lookup paths", () => {