mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 01:21:36 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user