mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(contracts): preserve safe defaults and client literal guidance
This commit is contained in:
+10
-5
@@ -1118,7 +1118,9 @@ advertised. Every advertised example is a distinct valid canonical input.
|
||||
Root object unions are presented as
|
||||
one object without root `anyOf`, `oneOf`, or `allOf` for model API compatibility.
|
||||
The projection merges properties, retains shared required fields, and
|
||||
describes the accepted input groups. Which fields belong together is guidance
|
||||
describes the accepted input groups. A property default is advertised only when
|
||||
every input group accepts that property with the same default, so default-filling
|
||||
clients cannot inject a scenario-only property into a passive comparison. Which fields belong together is guidance
|
||||
in that description rather than a `dependentRequired` or `dependencies`
|
||||
keyword, because Gemini function declarations reject both and one unknown
|
||||
keyword fails the client's whole tool catalog. Canonical runtime validation
|
||||
@@ -1135,9 +1137,11 @@ masks without a repeating regular expression or temporary byte arrays. The
|
||||
server rejects malformed or unconstrained patterns before provider dispatch;
|
||||
the advertised strings alone do not express those rules.
|
||||
|
||||
Boolean literal constraints in input schemas are relaxed only in this
|
||||
advertisement. For example, a boolean `const` is presented as `type: boolean`;
|
||||
an affected `not` is omitted, conditional predicates and their branches are
|
||||
Boolean and empty-string literal constraints in input schemas are relaxed only
|
||||
in this advertisement. Boolean `const` becomes `type: boolean`; empty-string
|
||||
`const` or an enum containing an empty string is widened without dropping the
|
||||
accepted empty value. Defaults and descriptions remain intact.
|
||||
An affected `not` is omitted, conditional predicates and their branches are
|
||||
omitted together, pure boolean-literal `oneOf` branches collapse to a boolean,
|
||||
and other affected `oneOf` branches are presented as `anyOf` to retain branch
|
||||
guidance without making overlapping widened branches reject inputs.
|
||||
@@ -1289,7 +1293,8 @@ and the identity dimension unknown.
|
||||
the complete `normalized_result` from `discover_webmcp_tools`, or null.
|
||||
- Scenario: `before_scenario` and `after_scenario` contain complete
|
||||
`normalized_result` objects from `capture_browser_scenario`. Optional
|
||||
`normalization` defaults to `{ "rules": [] }`.
|
||||
`normalization` defaults to `{ "rules": [] }` in the canonical scenario parser;
|
||||
this scenario-only default is omitted from the merged advertised input.
|
||||
|
||||
The advertised schema includes complete nested capture fields and rejects
|
||||
incomplete pairs and structurally malformed captures. Mixed comparison families,
|
||||
|
||||
@@ -173,17 +173,18 @@ let schemasChecked = 0;
|
||||
let examplesChecked = 0;
|
||||
let artifactDigest;
|
||||
const schemaValidator = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const booleanLiteralPath = (schema, path = "$", seen = new Set()) => {
|
||||
const unsupportedLiteralPath = (schema, path = "$", seen = new Set()) => {
|
||||
if (!schema || typeof schema !== "object" || seen.has(schema))
|
||||
return undefined;
|
||||
seen.add(schema);
|
||||
for (const [key, child] of Object.entries(schema)) {
|
||||
const childPath = `${path}.${key}`;
|
||||
if (key === "const" && typeof child === "boolean") return childPath;
|
||||
if (key === "const" && (typeof child === "boolean" || child === ""))
|
||||
return childPath;
|
||||
if (
|
||||
key === "enum" &&
|
||||
Array.isArray(child) &&
|
||||
child.some((item) => typeof item === "boolean")
|
||||
child.some((item) => typeof item === "boolean" || item === "")
|
||||
)
|
||||
return childPath;
|
||||
const schemaChildrenAtKey =
|
||||
@@ -201,7 +202,7 @@ const booleanLiteralPath = (schema, path = "$", seen = new Set()) => {
|
||||
? [[childPath, child]]
|
||||
: [];
|
||||
for (const [nestedPath, nested] of schemaChildrenAtKey) {
|
||||
const found = booleanLiteralPath(nested, nestedPath, seen);
|
||||
const found = unsupportedLiteralPath(nested, nestedPath, seen);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
@@ -270,11 +271,13 @@ const fixture = await createGeminiModelFixture({
|
||||
schemaValidator.validateSchema(tool.parametersJsonSchema),
|
||||
`${tool.name}: invalid advertised JSON Schema: ${JSON.stringify(schemaValidator.errors)}`,
|
||||
);
|
||||
const booleanLiteral = booleanLiteralPath(tool.parametersJsonSchema);
|
||||
const unsupportedLiteral = unsupportedLiteralPath(
|
||||
tool.parametersJsonSchema,
|
||||
);
|
||||
assert.equal(
|
||||
booleanLiteral,
|
||||
unsupportedLiteral,
|
||||
undefined,
|
||||
`${tool.name}: Gemini SDK declaration retains a boolean const/enum at ${booleanLiteral}`,
|
||||
`${tool.name}: Gemini SDK declaration retains a boolean or empty-string const/enum at ${unsupportedLiteral}`,
|
||||
);
|
||||
const name = tool.name.slice(prefix.length);
|
||||
const contract = toolContract(name);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { evmInterfaceSummaryViewResultSchema } from "../../domain/analysisView/a
|
||||
/** Delivery selection leaves the existing provider input and original Evidence unchanged. */
|
||||
const evmInterfaceInspectionInputSchema = inspectEvmInterfaceInputSchema.extend(
|
||||
{
|
||||
detail: analysisDetailSchema.unwrap().optional(),
|
||||
detail: analysisDetailSchema,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -246,6 +246,11 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
changed = true;
|
||||
}
|
||||
if (node.const === "") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "string";
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
Array.isArray(node.enum) &&
|
||||
node.enum.length > 0 &&
|
||||
@@ -255,6 +260,18 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
changed = true;
|
||||
}
|
||||
if (Array.isArray(node.enum) && node.enum.includes("")) {
|
||||
// Removing only the empty member would reject a canonical-valid input.
|
||||
// Gemini transports turn const into enum and refuse empty enum members.
|
||||
const values = node.enum;
|
||||
delete node.enum;
|
||||
if (
|
||||
node.type === undefined &&
|
||||
values.every((item) => typeof item === "string")
|
||||
)
|
||||
node.type = "string";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const negated = Object.hasOwn(value, "not")
|
||||
? normalizeAdvertisedSchema(value.not)
|
||||
@@ -380,6 +397,38 @@ const flattenRootObjectUnion = (
|
||||
variants.set(name, known);
|
||||
}
|
||||
const required = sharedRequiredProperties(branches);
|
||||
// A default lifted out of an input group can make a client send a field
|
||||
// rejected by another strict group. Keep it only when every group owns the
|
||||
// property and supplies the same default.
|
||||
for (const [name, schemas] of variants) {
|
||||
const defaults = branches.map((branch) => {
|
||||
const property = isObject(branch.properties)
|
||||
? branch.properties[name]
|
||||
: undefined;
|
||||
return isObject(property) ? property.default : undefined;
|
||||
});
|
||||
if (
|
||||
defaults.every(
|
||||
(value) => value !== undefined && isDeepStrictEqual(value, defaults[0]),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const withoutDefaults: unknown[] = [];
|
||||
for (const schema of schemas) {
|
||||
let withoutDefault = schema;
|
||||
if (isObject(schema) && Object.hasOwn(schema, "default")) {
|
||||
const { default: _default, ...rest } = schema;
|
||||
withoutDefault = rest;
|
||||
}
|
||||
if (
|
||||
!withoutDefaults.some((known) =>
|
||||
isDeepStrictEqual(known, withoutDefault),
|
||||
)
|
||||
)
|
||||
withoutDefaults.push(withoutDefault);
|
||||
}
|
||||
variants.set(name, withoutDefaults);
|
||||
}
|
||||
// A coarse property minimum rejects underspecified objects. Partner fields
|
||||
// and exact group membership remain enforced by the canonical runtime parser.
|
||||
const fewestRequired = Math.min(
|
||||
|
||||
@@ -18,7 +18,7 @@ export const managedTargetInputSchema = z.strictObject({
|
||||
|
||||
/** Delivery selection belongs to artifact and member inspection, not native queries. */
|
||||
export const managedArtifactInputSchema = managedTargetInputSchema.extend({
|
||||
detail: analysisDetailSchema.unwrap().optional(),
|
||||
detail: analysisDetailSchema,
|
||||
});
|
||||
|
||||
/** Member inspection may select declaring types before members are decoded. */
|
||||
|
||||
@@ -419,7 +419,97 @@ describe("root input presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("advertised Boolean literals", () => {
|
||||
describe("advertised client-compatible literals", () => {
|
||||
it.each([{ const: "" }, { enum: ["", "named"] }])(
|
||||
"widens unsupported empty-string literals without changing canonical validation: %j",
|
||||
(literal) => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
type: "string",
|
||||
...literal,
|
||||
default: "",
|
||||
description: "The literal value.",
|
||||
},
|
||||
},
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
expect(
|
||||
new Ajv2020({ strict: false }).compile(canonical)({ value: "" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
new Ajv2020({ strict: false }).compile(advertised)({ value: "" }),
|
||||
).toBe(true);
|
||||
expect(advertised).toHaveProperty(["properties", "value"], {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "The literal value.",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("widens overlapping empty-string oneOf branches while preserving nonempty alternatives", () => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
oneOf: [
|
||||
{ type: "string", const: "" },
|
||||
{ type: "string", minLength: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const original = new Ajv2020({ strict: false }).compile(canonical);
|
||||
const presented = new Ajv2020({ strict: false }).compile(advertised);
|
||||
for (const value of ["", "named"]) {
|
||||
expect(original({ value })).toBe(true);
|
||||
expect(presented({ value })).toBe(true);
|
||||
}
|
||||
expect(advertised).toHaveProperty(["properties", "value", "anyOf"]);
|
||||
expect(advertised).not.toHaveProperty(["properties", "value", "oneOf"]);
|
||||
});
|
||||
|
||||
it("keeps only defaults shared by every root input group", () => {
|
||||
const schema = z.union([
|
||||
z.strictObject({
|
||||
left: z.string(),
|
||||
shared: z.boolean().default(false),
|
||||
only_left: z.string().default("left"),
|
||||
selection: z.string().default("left"),
|
||||
}),
|
||||
z.strictObject({
|
||||
right: z.string(),
|
||||
shared: z.boolean().default(false),
|
||||
selection: z.string().default("right"),
|
||||
}),
|
||||
]);
|
||||
const advertised = presentInputJsonSchema(
|
||||
z.toJSONSchema(schema),
|
||||
() => undefined,
|
||||
);
|
||||
expect(advertised).toHaveProperty(
|
||||
["properties", "shared", "default"],
|
||||
false,
|
||||
);
|
||||
expect(advertised).not.toHaveProperty([
|
||||
"properties",
|
||||
"only_left",
|
||||
"default",
|
||||
]);
|
||||
expect(advertised).toHaveProperty(["properties", "selection"], {
|
||||
type: "string",
|
||||
});
|
||||
const input = { right: "selected" };
|
||||
const validate = new Ajv2020({ strict: false, useDefaults: true }).compile(
|
||||
advertised,
|
||||
);
|
||||
expect(validate(input)).toBe(true);
|
||||
expect(schema.safeParse(input).success).toBe(true);
|
||||
expect(input).not.toHaveProperty("only_left");
|
||||
});
|
||||
it("keeps canonical boolean-literal inputs accepted after advertisement", () => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { wasmArtifactSummaryViewResultSchema } from "../../domain/analysisView/a
|
||||
/** Delivery selection does not change the provider input or original Evidence. */
|
||||
export const wasmArtifactInspectionInputSchema =
|
||||
inspectWasmArtifactInputSchema.extend({
|
||||
detail: analysisDetailSchema.unwrap().optional(),
|
||||
detail: analysisDetailSchema,
|
||||
});
|
||||
/** Optional caller-supplied WABT capability with target-free CLI/MCP parity. */
|
||||
export const INSPECT_WASM_ARTIFACT_CONTRACT = {
|
||||
|
||||
@@ -92,6 +92,14 @@ describe("browser comparison input boundary", () => {
|
||||
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
|
||||
const validate = ajv.compile(advertised.inputSchema);
|
||||
const passive = await capturePassivePair(provider, browser);
|
||||
const defaultFilledPassive = structuredClone(passive);
|
||||
const fillDefaults = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
useDefaults: true,
|
||||
}).compile(advertised.inputSchema);
|
||||
expect(fillDefaults(defaultFilledPassive)).toBe(true);
|
||||
expect(defaultFilledPassive).not.toHaveProperty("normalization");
|
||||
const scenario = contract.examples[0]?.input;
|
||||
if (scenario === undefined) throw new Error("Missing scenario example");
|
||||
const withoutNormalization = Object.fromEntries(
|
||||
@@ -143,11 +151,13 @@ describe("browser comparison input boundary", () => {
|
||||
},
|
||||
];
|
||||
for (const { input, advertisedValid, runtimeValid } of [
|
||||
...[passive, scenario, withoutNormalization].map((input) => ({
|
||||
input,
|
||||
advertisedValid: true,
|
||||
runtimeValid: true,
|
||||
})),
|
||||
...[passive, defaultFilledPassive, scenario, withoutNormalization].map(
|
||||
(input) => ({
|
||||
input,
|
||||
advertisedValid: true,
|
||||
runtimeValid: true,
|
||||
}),
|
||||
),
|
||||
...invalid.map((input) => ({
|
||||
input,
|
||||
advertisedValid: false,
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
|
||||
import {
|
||||
arrayItemSchemaError,
|
||||
booleanLiteralSchemaError,
|
||||
unsupportedInputLiteralSchemaError,
|
||||
} from "../../fixtures/mcpClientSchemaConstraints.js";
|
||||
|
||||
interface AdvertisedTool {
|
||||
@@ -284,7 +284,7 @@ it("keeps SDK-converted schemas valid and accepting canonical examples", async (
|
||||
`${profile} profile: ${tool.name}`,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
booleanLiteralSchemaError(tool.inputSchema, tool.name),
|
||||
unsupportedInputLiteralSchemaError(tool.inputSchema, tool.name),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
ajv.validateSchema(tool.inputSchema),
|
||||
|
||||
@@ -19,6 +19,8 @@ import { inspectSolidityMetadata } from "../../../src/evm/SolidityMetadata.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { registerEvmTools } from "../../../src/server/registerEvmTools.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import {
|
||||
@@ -45,6 +47,40 @@ const decodedMetadataFixture = () => {
|
||||
return value;
|
||||
};
|
||||
|
||||
it("preserves shared EVM detail guidance and defaults through full SDK discovery", async () => {
|
||||
const inspect = vi.fn(async () => {
|
||||
throw new Error("Discovery must not invoke the provider");
|
||||
});
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "evm-detail", version: "1" },
|
||||
{},
|
||||
undefined,
|
||||
createToolResultDelivery(undefined),
|
||||
);
|
||||
registerEvmTools(server, new EvmInterfaceService({ inspect }), silentLogger);
|
||||
const client = new Client({ name: "evm-detail", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const { inputSchema } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_evm_interface",
|
||||
);
|
||||
expect(inputSchema).toHaveProperty(["properties", "detail"], {
|
||||
type: "string",
|
||||
enum: ["complete", "summary"],
|
||||
default: "complete",
|
||||
description: expect.stringContaining(
|
||||
"summary retains that complete Evidence",
|
||||
),
|
||||
});
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => {
|
||||
const value = decodedMetadataFixture();
|
||||
const inspect = vi.fn(() =>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { processScenarioSchema } from "../../../src/domain/process/processScenar
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../../fixtures/mcpToolCatalog.js";
|
||||
import {
|
||||
arrayItemSchemaError,
|
||||
booleanLiteralSchemaError,
|
||||
unsupportedInputLiteralSchemaError,
|
||||
} from "../../fixtures/mcpClientSchemaConstraints.js";
|
||||
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
|
||||
|
||||
@@ -45,7 +45,7 @@ function schemaErrors(tools: readonly ToolSchemas[]): string[] {
|
||||
);
|
||||
if (clientError !== undefined) return [clientError];
|
||||
if (kind === "inputSchema") {
|
||||
const literalError = booleanLiteralSchemaError(
|
||||
const literalError = unsupportedInputLiteralSchemaError(
|
||||
schema,
|
||||
`${tool.name}.${kind}`,
|
||||
);
|
||||
@@ -882,14 +882,30 @@ it("checks client array constraints in schema positions while preserving literal
|
||||
examples: [{ enum: [false] }],
|
||||
};
|
||||
expect(
|
||||
booleanLiteralSchemaError(
|
||||
unsupportedInputLiteralSchemaError(
|
||||
{ ...booleanMetadata, properties: {} },
|
||||
"literal",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(booleanLiteralSchemaError(booleanMetadata, "literal")).toBe(
|
||||
expect(unsupportedInputLiteralSchemaError(booleanMetadata, "literal")).toBe(
|
||||
"literal.properties.default.const is boolean",
|
||||
);
|
||||
for (const [literal, reason] of [
|
||||
[{ const: "" }, "const is an empty string"],
|
||||
[{ enum: ["named", ""] }, "enum contains an empty string"],
|
||||
] as const) {
|
||||
expect(
|
||||
unsupportedInputLiteralSchemaError(
|
||||
{
|
||||
type: "object",
|
||||
properties: { value: literal },
|
||||
default: { const: "" },
|
||||
examples: [{ enum: [""] }],
|
||||
},
|
||||
"literal",
|
||||
),
|
||||
).toBe(`literal.properties.value.${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
|
||||
+9
-3
@@ -40,18 +40,21 @@ export const arrayItemSchemaError = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Reject Gemini-incompatible boolean literals in advertised input positions. */
|
||||
export const booleanLiteralSchemaError = (
|
||||
/** Reject Gemini-incompatible boolean and empty-string input literals. */
|
||||
export const unsupportedInputLiteralSchemaError = (
|
||||
schema: unknown,
|
||||
path: string,
|
||||
): string | undefined => {
|
||||
if (!isRecord(schema)) return undefined;
|
||||
if (typeof schema.const === "boolean") return `${path}.const is boolean`;
|
||||
if (schema.const === "") return `${path}.const is an empty string`;
|
||||
if (
|
||||
Array.isArray(schema.enum) &&
|
||||
schema.enum.some((value) => typeof value === "boolean")
|
||||
)
|
||||
return `${path}.enum contains a boolean`;
|
||||
if (Array.isArray(schema.enum) && schema.enum.includes(""))
|
||||
return `${path}.enum contains an empty string`;
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
const children =
|
||||
schemaMaps.has(key) && isRecord(value)
|
||||
@@ -66,7 +69,10 @@ export const booleanLiteralSchemaError = (
|
||||
? [[key, value] as const]
|
||||
: [];
|
||||
for (const [suffix, child] of children) {
|
||||
const error = booleanLiteralSchemaError(child, `${path}.${suffix}`);
|
||||
const error = unsupportedInputLiteralSchemaError(
|
||||
child,
|
||||
`${path}.${suffix}`,
|
||||
);
|
||||
if (error !== undefined) return error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user