@feniix/bridgekit 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,67 @@ All notable changes to `@feniix/bridgekit` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [0.8.0] - Unreleased
7
+ ## [0.8.2] - Unreleased
8
+
9
+ ### Fixed
10
+
11
+ - Discriminated unions now surface the active branch's
12
+ `required`/`additionalProperties` hints. When an `anyOf` fires at a path
13
+ and exactly one branch's discriminator (`Literal`/`const`/`enum`/`Union of
14
+ Literals`) is satisfied by the input, BridgeKit keeps that branch's
15
+ sibling errors and suppresses the others. For non-discriminated unions
16
+ and ambiguous cases, the conservative "suppress all" behavior from 0.8.1
17
+ is preserved. Resolves
18
+ [#38](https://github.com/feniix/bridgekit/issues/38).
19
+
20
+ Behavior notes for consumers upgrading from 0.8.1:
21
+
22
+ - `validationErrors.length` for a discriminated-union failure now reflects
23
+ the active branch's missing/extra props (typically 1 per missing field),
24
+ rather than the single `{ field: "(root)", message: ".*anyOf.*" }` entry
25
+ that 0.8.1 produced. Consumers asserting `validationErrors.length === 1`
26
+ or checking for `field === "(root)"` on these payloads will need to
27
+ update; assertions on `field` / `message` for the actual missing prop
28
+ are the new contract.
29
+ - The `anyOf`/`oneOf` summary at the union path is **suppressed** when an
30
+ active branch is resolved. Consumers using `field: "(root)"` as a
31
+ "union failed" sentinel will not see it for discriminated cases — read
32
+ the active-branch's specific entry instead. The summary is still
33
+ emitted in the fallback case (no active branch).
34
+ - Discriminators expressed as `Type.Union([Type.Literal("a"),
35
+ Type.Literal("b")])` (the anyOf-of-const idiom) are now recognized as
36
+ discriminators. 0.8.1 silently treated them as non-discriminators and
37
+ fell back to suppress-all.
38
+ - Discriminated unions inside `Type.Array(...)` are now resolved per
39
+ array element (the path walker descends into `items`). 0.8.1 bailed out
40
+ on array index segments.
41
+ - `const` / `enum` error messages now surface the allowed value(s)
42
+ (`must equal "create"` / `must equal one of "create", "update"`)
43
+ instead of the opaque `must be equal to constant`. Helps agents pick a
44
+ valid discriminator value on retry.
45
+ - Discriminator key lookup uses `Object.hasOwn`, so a schema discriminator
46
+ whose name happens to be `toString` / `constructor` / etc. won't be
47
+ matched via the prototype chain.
48
+
49
+ Note on nested unions: `field` is the leaf segment of the JSON pointer
50
+ (`"name"` for `/event/name`), not the full path. For schemas with the
51
+ same prop name at multiple depths this can be ambiguous; consumers
52
+ needing full path context should track which tool input branch was
53
+ active out-of-band.
54
+
55
+ ## [0.8.1] - 2026-05-26
56
+
57
+ ### Fixed
58
+
59
+ - Union-of-objects validation no longer surfaces phantom missing-required-
60
+ property entries for every branch. When TypeBox emits an `anyOf`/`oneOf`
61
+ error at a path, sibling `required`/`additionalProperties` entries at the
62
+ same path are now suppressed so consumers see only the union summary.
63
+ `const`/`enum` discriminator errors at deeper paths are kept (they're
64
+ signal about which branch was intended).
65
+ Resolves [#35](https://github.com/feniix/bridgekit/issues/35).
66
+
67
+ ## [0.8.0] - 2026-05-26
8
68
 
9
69
  ### Breaking
10
70
 
package/README.md CHANGED
@@ -127,6 +127,12 @@ if (result.isError) {
127
127
 
128
128
  For array-element validation, `field` is the leaf segment, which can be a numeric index (e.g. `field: "0"`) and loses path context. For root-level schema failures with empty `instancePath` (e.g. `null` passed to a `Type.Object` schema), `field` is the sentinel `"(root)"`.
129
129
 
130
+ For **discriminated unions** (`Type.Union([Type.Object({tag: Literal("a"), …}), Type.Object({tag: Literal("b"), …})])`), when exactly one branch's discriminator matches the input, BridgeKit surfaces only that branch's missing-required hints (as of 0.8.2). Recognized discriminator shapes: `Literal` (`const`), `enum`, and `Union of Literals` (`anyOf` of consts). Resolution works inside `Type.Array(...)` per-element. When no branch matches (invalid discriminator), the `anyOf` summary and `const`/`enum` errors survive so consumers can identify the failed discriminator and pick a valid value.
131
+
132
+ `const`/`enum` error messages carry the allowed value(s) directly (`must equal "create"` / `must equal one of "create", "update"`) so an agent can pick a valid discriminator on retry.
133
+
134
+ For nested discriminated unions, `field` is the leaf segment of the JSON pointer (`"name"` for `/event/name`), not the full path. Consumers needing full path context for ambiguous prop names should track the active union branch out-of-band.
135
+
130
136
  The two modes expose the failure discriminator on different fields. In the
131
137
  new default `"return"` mode, prefer the result guards over inspecting a
132
138
  `kind` field directly — handler-emitted failures do not carry a synthesized
@@ -1,5 +1,258 @@
1
- import { Check, Errors } from "typebox/value";
1
+ import { Check, Errors, Pointer } from "typebox/value";
2
2
  const ROOT_FIELD = "(root)";
3
+ function fieldFromPath(instancePath) {
4
+ return instancePath.split("/").filter(Boolean).at(-1) ?? ROOT_FIELD;
5
+ }
6
+ function readDiscriminatorValues(propSchema) {
7
+ // Extract the allowed values from a discriminator-eligible prop schema.
8
+ // Recognizes three shapes:
9
+ // - `Type.Literal("x")` → `{ const: "x" }`
10
+ // - `Type.String({ enum: ["x", "y"] })` → `{ enum: ["x", "y"] }`
11
+ // - `Type.Union([Type.Literal("x"), Type.Literal("y")])` →
12
+ // `{ anyOf: [{ const: "x" }, { const: "y" }] }`
13
+ // The anyOf-of-const case is idiomatic TypeBox and was missed before:
14
+ // without it, a branch declaring its tag as a Union-of-Literals was
15
+ // treated as having no discriminator, silently bailing out of branch
16
+ // matching.
17
+ if (!propSchema || typeof propSchema !== "object")
18
+ return [];
19
+ if ("const" in propSchema)
20
+ return [propSchema.const];
21
+ if (Array.isArray(propSchema.enum))
22
+ return propSchema.enum;
23
+ if (Array.isArray(propSchema.anyOf) && propSchema.anyOf.length > 0) {
24
+ const consts = [];
25
+ for (const entry of propSchema.anyOf) {
26
+ if (entry && typeof entry === "object" && "const" in entry) {
27
+ consts.push(entry.const);
28
+ }
29
+ else {
30
+ return [];
31
+ }
32
+ }
33
+ return consts;
34
+ }
35
+ return [];
36
+ }
37
+ function resolveSchemaAtPath(schema, instancePath) {
38
+ // Walk a JSON-pointer-style instancePath (e.g. "/event", "/events/0")
39
+ // down to the sub-schema at that data path. Follows both `properties`
40
+ // for object schemas and `items` for array schemas (numeric segments
41
+ // descend into the items schema). Other JSON Schema composites bail
42
+ // out; the caller then falls back to the conservative suppress-all rule.
43
+ if (instancePath === "")
44
+ return schema;
45
+ const segments = instancePath.split("/").filter(Boolean);
46
+ let current = schema;
47
+ for (const segment of segments) {
48
+ if (!current || typeof current !== "object")
49
+ return undefined;
50
+ const obj = current;
51
+ if (obj.properties && typeof obj.properties === "object" && segment in obj.properties) {
52
+ current = obj.properties[segment];
53
+ if (current === undefined)
54
+ return undefined;
55
+ continue;
56
+ }
57
+ if (obj.items !== undefined && /^\d+$/.test(segment)) {
58
+ if (Array.isArray(obj.items)) {
59
+ const idx = Number(segment);
60
+ if (idx >= obj.items.length)
61
+ return undefined;
62
+ current = obj.items[idx];
63
+ }
64
+ else {
65
+ current = obj.items;
66
+ }
67
+ continue;
68
+ }
69
+ return undefined;
70
+ }
71
+ return current;
72
+ }
73
+ function readUnionBranches(node) {
74
+ // Accept both `anyOf` and `oneOf`. Each branch must be a plain object
75
+ // schema for this helper to apply — primitive unions (e.g. `Union([
76
+ // Literal("a"), Literal("b")])` rendered as anyOf-of-consts) have no
77
+ // discriminator-prop concept and the caller falls back.
78
+ if (!node || typeof node !== "object")
79
+ return undefined;
80
+ const shape = node;
81
+ const branches = shape.anyOf ?? shape.oneOf;
82
+ if (!Array.isArray(branches) || branches.length === 0)
83
+ return undefined;
84
+ if (!branches.every((b) => b && typeof b === "object" && b.type === "object" && b.properties)) {
85
+ return undefined;
86
+ }
87
+ return branches;
88
+ }
89
+ function branchDiscriminatorMatches(branch, value) {
90
+ // A branch matches its discriminator when every prop with a discriminator-
91
+ // eligible schema (Literal / enum / anyOf-of-Literal) is present on the
92
+ // input with a value the schema allows. `Object.hasOwn` avoids matching
93
+ // prototype-chain props like `toString`.
94
+ if (!value || typeof value !== "object")
95
+ return false;
96
+ const input = value;
97
+ let hasDiscriminator = false;
98
+ for (const [key, propSchema] of Object.entries(branch.properties ?? {})) {
99
+ const allowed = readDiscriminatorValues(propSchema);
100
+ if (allowed.length === 0)
101
+ continue;
102
+ hasDiscriminator = true;
103
+ if (!Object.hasOwn(input, key) || !allowed.includes(input[key]))
104
+ return false;
105
+ }
106
+ return hasDiscriminator;
107
+ }
108
+ function isSubsetOf(props, set) {
109
+ for (const p of props)
110
+ if (!set.has(p))
111
+ return false;
112
+ return true;
113
+ }
114
+ function suppressSiblingErrorsUnderUnion(schema, value, errors) {
115
+ // For unions of objects, TypeBox emits per-branch `required` /
116
+ // `additionalProperties` errors at the union's path. The user only needs
117
+ // to satisfy ONE branch, so those entries are phantoms; the `anyOf` /
118
+ // `oneOf` summary at the same path is the real signal.
119
+ //
120
+ // `const` / `enum` errors at the same path survive — they carry real
121
+ // discriminator info about which branch was intended.
122
+ //
123
+ // Discriminated-union refinement (#38): when an `anyOf`/`oneOf` fires at
124
+ // path P and exactly one branch's discriminator props (`const` / `enum`)
125
+ // are satisfied by the input, that branch is "active". Required and
126
+ // additionalProperties errors attributable to the active branch are
127
+ // preserved (so the user sees "you picked op=create, missing name");
128
+ // contributions from losing branches at P and its descendants are
129
+ // suppressed. Schema-walking on the public JSON-Schema shape avoids
130
+ // depending on TypeBox internals.
131
+ //
132
+ // Fallback: zero or multiple branches match — there is no actionable
133
+ // owner for the phantom errors, so the conservative 0.8.1 rule applies
134
+ // (drop all sibling `required` / `additionalProperties` at P, keep
135
+ // const / enum). This covers non-discriminated unions and ambiguous
136
+ // inputs.
137
+ const unionPaths = new Set();
138
+ for (const error of errors) {
139
+ if (error.keyword === "anyOf" || error.keyword === "oneOf") {
140
+ unionPaths.add(error.instancePath);
141
+ }
142
+ }
143
+ if (unionPaths.size === 0)
144
+ return errors;
145
+ const resolutions = new Map();
146
+ for (const path of unionPaths) {
147
+ const unionSchema = resolveSchemaAtPath(schema, path);
148
+ const branches = readUnionBranches(unionSchema);
149
+ if (!branches) {
150
+ resolutions.set(path, { kind: "no-active" });
151
+ continue;
152
+ }
153
+ const branchValue = Pointer.Get(value, path);
154
+ const matchedIndices = [];
155
+ for (let i = 0; i < branches.length; i++) {
156
+ if (branchDiscriminatorMatches(branches[i], branchValue))
157
+ matchedIndices.push(i);
158
+ }
159
+ if (matchedIndices.length !== 1) {
160
+ resolutions.set(path, { kind: "no-active" });
161
+ continue;
162
+ }
163
+ const activeIndex = matchedIndices[0];
164
+ const active = branches[activeIndex];
165
+ const branchRequired = new Set(active.required ?? []);
166
+ const branchProps = new Set(Object.keys(active.properties ?? {}));
167
+ // Map of property-name -> set of disallowed values held by losing
168
+ // branches' discriminators. Used to drop "must equal X" errors at the
169
+ // union's descendant paths when X belongs to a branch the user didn't
170
+ // pick. Covers `const`, `enum`, and `Union([Literal, Literal])` (the
171
+ // anyOf-of-const shape) via `readDiscriminatorValues`.
172
+ const losingDiscriminators = new Map();
173
+ for (let i = 0; i < branches.length; i++) {
174
+ if (i === activeIndex)
175
+ continue;
176
+ for (const [key, propSchema] of Object.entries(branches[i].properties ?? {})) {
177
+ const values = readDiscriminatorValues(propSchema);
178
+ if (values.length === 0)
179
+ continue;
180
+ let existing = losingDiscriminators.get(key);
181
+ if (!existing) {
182
+ existing = new Set();
183
+ losingDiscriminators.set(key, existing);
184
+ }
185
+ for (const v of values)
186
+ existing.add(v);
187
+ }
188
+ }
189
+ resolutions.set(path, { kind: "active", branchRequired, branchProps, losingDiscriminators });
190
+ }
191
+ return errors.filter((error) => {
192
+ // Errors at a union path: handle by keyword + active-branch attribution.
193
+ const resolution = resolutions.get(error.instancePath);
194
+ if (resolution) {
195
+ if (resolution.kind === "no-active") {
196
+ // 0.8.1 fallback: drop required/additionalProperties at P; keep
197
+ // const / enum / anyOf (they carry discriminator info the user
198
+ // needs to fix the input).
199
+ return error.keyword !== "required" && error.keyword !== "additionalProperties";
200
+ }
201
+ // Active branch resolved: suppress the anyOf/oneOf summary at P too.
202
+ // The active branch's specific errors (preserved below) are strictly
203
+ // more actionable than "must match a schema in anyOf".
204
+ if (error.keyword === "anyOf" || error.keyword === "oneOf")
205
+ return false;
206
+ if (error.keyword === "required") {
207
+ // Attribute by structured params: the active branch demanded a
208
+ // superset of these props iff the missing props are all in its
209
+ // `required` array. Other branches' `required` entries get dropped.
210
+ const props = error.params.requiredProperties ?? [];
211
+ return props.length > 0 && isSubsetOf(props, resolution.branchRequired);
212
+ }
213
+ if (error.keyword === "additionalProperties") {
214
+ // The active-branch's additionalProperties error lists ONLY keys
215
+ // genuinely missing from its `properties` map. A losing branch may
216
+ // include the active branch's legitimate props in its list (because
217
+ // it has its own, narrower property set), so we keep only errors
218
+ // where EVERY offending key is also additional for the active
219
+ // branch. Losing-branch entries that drag in active-branch props
220
+ // get dropped — they would expand into phantom "name is additional"
221
+ // entries otherwise.
222
+ const props = error.params.additionalProperties ?? [];
223
+ return props.length > 0 && props.every((p) => !resolution.branchProps.has(p));
224
+ }
225
+ return true;
226
+ }
227
+ // Errors at descendant paths of a union path: when an active branch is
228
+ // resolved at the parent, drop `const`/`enum` errors whose allowedValue
229
+ // belongs to a losing branch's discriminator at this prop. This keeps
230
+ // the active-branch error story coherent ("you picked create, forgot
231
+ // name") instead of mixing in "could also have been delete" noise.
232
+ if (error.keyword !== "const" && error.keyword !== "enum")
233
+ return true;
234
+ for (const [unionPath, resolution] of resolutions) {
235
+ if (resolution.kind !== "active")
236
+ continue;
237
+ const prefix = unionPath === "" ? "/" : `${unionPath}/`;
238
+ if (!error.instancePath.startsWith(prefix))
239
+ continue;
240
+ const remainder = error.instancePath.slice(prefix.length);
241
+ if (remainder.includes("/"))
242
+ continue; // only direct discriminator props
243
+ const losingValues = resolution.losingDiscriminators.get(remainder);
244
+ if (!losingValues)
245
+ continue;
246
+ if (error.keyword === "const" && losingValues.has(error.params.allowedValue))
247
+ return false;
248
+ if (error.keyword === "enum" && Array.isArray(error.params.allowedValues)) {
249
+ if (error.params.allowedValues.every((v) => losingValues.has(v)))
250
+ return false;
251
+ }
252
+ }
253
+ return true;
254
+ });
255
+ }
3
256
  function expandTypeBoxError(error) {
4
257
  // Read offending property names from TypeBox's structured `params` rather
5
258
  // than parsing the message string. Two payoffs: locale independence (the
@@ -24,19 +277,45 @@ function expandTypeBoxError(error) {
24
277
  }));
25
278
  }
26
279
  }
27
- const segments = error.instancePath.split("/").filter(Boolean);
28
- return [{ field: segments.at(-1) ?? ROOT_FIELD, message: error.message }];
280
+ // const / enum errors carry the allowed values in `params`. Surfacing them
281
+ // in the message lets an agent recovering from an invalid-discriminator
282
+ // failure see exactly what tag(s) are accepted, rather than the opaque
283
+ // `must be equal to constant`.
284
+ if (error.keyword === "const") {
285
+ return [
286
+ {
287
+ field: fieldFromPath(error.instancePath),
288
+ message: `must equal ${JSON.stringify(error.params.allowedValue)}`,
289
+ },
290
+ ];
291
+ }
292
+ if (error.keyword === "enum") {
293
+ const allowed = error.params.allowedValues ?? [];
294
+ return [
295
+ {
296
+ field: fieldFromPath(error.instancePath),
297
+ message: allowed.length > 0 ? `must equal one of ${allowed.map((v) => JSON.stringify(v)).join(", ")}` : error.message,
298
+ },
299
+ ];
300
+ }
301
+ return [{ field: fieldFromPath(error.instancePath), message: error.message }];
29
302
  }
30
303
  export function validatePortableToolArgs(tool, args) {
31
304
  if (Check(tool.parameters, args)) {
32
305
  return { ok: true };
33
306
  }
34
307
  // TypeBox can emit multiple errors per offending field (e.g. union/anyOf
35
- // mismatches fire one error per failed branch). Dedupe by (field, message)
36
- // using JSON.stringify so the key can't collide regardless of what
37
- // characters field or message contain.
308
+ // mismatches fire one error per failed branch). First, when an anyOf/oneOf
309
+ // error fires at a path, drop sibling `required`/`additionalProperties`
310
+ // errors at the same path — they're phantoms (the consumer only needs to
311
+ // satisfy ONE branch, not all of them). Then dedupe the survivors by
312
+ // (field, message) using JSON.stringify so the key can't collide regardless
313
+ // of what characters field or message contain.
314
+ const rawErrors = suppressSiblingErrorsUnderUnion(tool.parameters, args, [
315
+ ...Errors(tool.parameters, args),
316
+ ]);
38
317
  const seen = new Set();
39
- const errors = [...Errors(tool.parameters, args)]
318
+ const errors = rawErrors
40
319
  .flatMap((error) => expandTypeBoxError(error))
41
320
  .filter(({ field, message }) => {
42
321
  const key = JSON.stringify([field, message]);
package/llms.txt CHANGED
@@ -73,7 +73,7 @@ export function createTools() {
73
73
  - pi invalid arguments and portable `isError: true` results return `{ content, details, isError: true }` by default (as of 0.7). Unexpected handler exceptions are also caught in return mode and surfaced as `{ content, details: {}, isError: true }`. Branch on `result.isError` and narrow with `isValidationFailure` / `isDomainFailure` from `@feniix/bridgekit`.
74
74
  - MCP invalid arguments and portable `isError: true` results return `CallToolResult` with `isError: true`. Same guards narrow the result.
75
75
  - MCP preserves `structuredContent`; pi maps `structuredContent` into the pi `details` field. Both adapters fall back to `details` for legacy/debug payloads when `structuredContent` is absent.
76
- - Validation errors and domain errors share the `{ field, message }` shape *inside* `validationErrors[]`. The array itself only appears on `kind: "validation"` — narrow with `isValidationFailure` (or check the discriminator) before iterating. `field` is derived from TypeBox's structured error data: required-property errors read `params.requiredProperties` (one entry per missing prop), additional-property errors read `params.additionalProperties` (one entry per offending key), other errors take the last meaningful segment of the offending JSON pointer (`text`, not `/text`). Locale-independent and prop-names-with-commas-safe. Duplicate `(field, message)` pairs from union mismatches are deduplicated. As of 0.8.0; `path` no longer exists. Caveats: array-element fields can be numeric indices (`field: "0"`, path context lost); root-level schema failures use the sentinel `field: "(root)"` rather than empty string.
76
+ - Validation errors and domain errors share the `{ field, message }` shape *inside* `validationErrors[]`. The array itself only appears on `kind: "validation"` — narrow with `isValidationFailure` (or check the discriminator) before iterating. `field` is derived from TypeBox's structured error data: required-property errors read `params.requiredProperties` (one entry per missing prop), additional-property errors read `params.additionalProperties` (one entry per offending key), `const`/`enum` errors carry the allowed value(s) in the message (`must equal "create"` / `must equal one of "create", "update"`), other errors take the last meaningful segment of the offending JSON pointer (`text`, not `/text`). Locale-independent and prop-names-with-commas-safe. Duplicate `(field, message)` pairs from union mismatches are deduplicated. As of 0.8.0; `path` no longer exists. As of 0.8.2: **discriminated unions** (`Type.Union([Type.Object({tag:Literal("a"),…}),Type.Object({tag:Literal("b"),…})])`) surface the *active branch's* missing-required hints when exactly one branch's discriminator is satisfied — instead of the bare `anyOf` summary. Recognized discriminator shapes: `Literal` (`const`), `enum`, and `Union of Literals` (`anyOf` of consts). Works inside `Type.Array(...)` per-element. When no branch matches (invalid discriminator), falls back to the 0.8.1 behavior (drop sibling `required`, keep `const`/`enum`/`anyOf` summary). Caveats: array-element fields can be numeric indices (`field: "0"`, path context lost); root-level schema failures use the sentinel `field: "(root)"` rather than empty string; for nested discriminated unions `field` is the leaf segment, not the full path.
77
77
  - The result guards take a `PortableToolResult` — the value `executePortableTool` returns at the seam between BridgeKit and a host. They are not designed to be called on the pi adapter's wire object (`{ content, details, isError }`), which exposes `details` instead of `structuredContent`.
78
78
  - `registerPiTools(pi, tools, { errorHandling: "throw" })` opts into the pre-0.7 behavior where `isError` results throw `PortableToolExecutionError`. Selecting this value emits a `DeprecationWarning` (code `BRIDGEKIT_PI_THROW_DEPRECATED`) once per process; only the `"throw"` value is deprecated, not the option itself. Scheduled for removal in 1.0; do not adopt it for new code.
79
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.",
5
5
  "keywords": [
6
6
  "pi",