@feniix/bridgekit 0.8.1 → 0.8.3

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,69 @@ 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.1] - Unreleased
7
+ ## [0.8.3] - Unreleased
8
+
9
+ ### Fixed
10
+
11
+ - `PortableValidationError.field` now preserves the full property name when
12
+ it contains `/`. TypeBox does not implement JSON Pointer RFC 6901's `~1`
13
+ escape, so a property named `a/b` previously produced `field: "b"` for
14
+ wrong-type / `const` / `enum` errors (the string-split fallback dropped
15
+ the prefix). The fix walks the actual schema with a greedy longest-prefix
16
+ match against `properties` keys and falls back to the prior behavior for
17
+ paths the schema doesn't model. The `required` and `additionalProperties`
18
+ paths were already correct via structured `params` and are unchanged.
19
+ Resolves [#36](https://github.com/feniix/bridgekit/issues/36).
20
+
21
+ ## [0.8.2] - 2026-05-26
22
+
23
+ ### Fixed
24
+
25
+ - Discriminated unions now surface the active branch's
26
+ `required`/`additionalProperties` hints. When an `anyOf` fires at a path
27
+ and exactly one branch's discriminator (`Literal`/`const`/`enum`/`Union of
28
+ Literals`) is satisfied by the input, BridgeKit keeps that branch's
29
+ sibling errors and suppresses the others. For non-discriminated unions
30
+ and ambiguous cases, the conservative "suppress all" behavior from 0.8.1
31
+ is preserved. Resolves
32
+ [#38](https://github.com/feniix/bridgekit/issues/38).
33
+
34
+ Behavior notes for consumers upgrading from 0.8.1:
35
+
36
+ - `validationErrors.length` for a discriminated-union failure now reflects
37
+ the active branch's missing/extra props (typically 1 per missing field),
38
+ rather than the single `{ field: "(root)", message: ".*anyOf.*" }` entry
39
+ that 0.8.1 produced. Consumers asserting `validationErrors.length === 1`
40
+ or checking for `field === "(root)"` on these payloads will need to
41
+ update; assertions on `field` / `message` for the actual missing prop
42
+ are the new contract.
43
+ - The `anyOf`/`oneOf` summary at the union path is **suppressed** when an
44
+ active branch is resolved. Consumers using `field: "(root)"` as a
45
+ "union failed" sentinel will not see it for discriminated cases — read
46
+ the active-branch's specific entry instead. The summary is still
47
+ emitted in the fallback case (no active branch).
48
+ - Discriminators expressed as `Type.Union([Type.Literal("a"),
49
+ Type.Literal("b")])` (the anyOf-of-const idiom) are now recognized as
50
+ discriminators. 0.8.1 silently treated them as non-discriminators and
51
+ fell back to suppress-all.
52
+ - Discriminated unions inside `Type.Array(...)` are now resolved per
53
+ array element (the path walker descends into `items`). 0.8.1 bailed out
54
+ on array index segments.
55
+ - `const` / `enum` error messages now surface the allowed value(s)
56
+ (`must equal "create"` / `must equal one of "create", "update"`)
57
+ instead of the opaque `must be equal to constant`. Helps agents pick a
58
+ valid discriminator value on retry.
59
+ - Discriminator key lookup uses `Object.hasOwn`, so a schema discriminator
60
+ whose name happens to be `toString` / `constructor` / etc. won't be
61
+ matched via the prototype chain.
62
+
63
+ Note on nested unions: `field` is the leaf segment of the JSON pointer
64
+ (`"name"` for `/event/name`), not the full path. For schemas with the
65
+ same prop name at multiple depths this can be ambiguous; consumers
66
+ needing full path context should track which tool input branch was
67
+ active out-of-band.
68
+
69
+ ## [0.8.1] - 2026-05-26
8
70
 
9
71
  ### Fixed
10
72
 
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,6 +1,193 @@
1
- import { Check, Errors } from "typebox/value";
1
+ import { Check, Errors, Pointer } from "typebox/value";
2
2
  const ROOT_FIELD = "(root)";
3
- function suppressSiblingErrorsUnderUnion(errors) {
3
+ function fieldFromPath(instancePath) {
4
+ return instancePath.split("/").filter(Boolean).at(-1) ?? ROOT_FIELD;
5
+ }
6
+ /**
7
+ * Resolve the leaf field name of an instancePath against the actual schema.
8
+ *
9
+ * TypeBox does not escape `/` inside property names when building
10
+ * `instancePath` (it does not follow JSON Pointer RFC 6901's `~1` encoding),
11
+ * so a property literally named `a/b` produces `instancePath: "/a/b"`. The
12
+ * string-split fallback (`fieldFromPath`) would then surface `"b"` and lose
13
+ * the prefix. This helper walks the schema greedily — at each object node it
14
+ * matches the longest prefix of remaining path segments that names a real
15
+ * property key, descending into the matched sub-schema — and returns the
16
+ * actual leaf key. Array nodes consume a single numeric segment via `items`.
17
+ *
18
+ * Returns `undefined` if the walk gets stuck (the schema does not model the
19
+ * data path — e.g. `additionalProperties` content). The caller then falls
20
+ * back to the string-split `fieldFromPath`, preserving prior behavior.
21
+ *
22
+ * The `required` and `additionalProperties` callers already read structured
23
+ * `params` and are unaffected; this helper covers the `const` / `enum` /
24
+ * default keyword branches.
25
+ */
26
+ function fieldFromSchemaWalk(schema, instancePath) {
27
+ if (instancePath === "")
28
+ return undefined;
29
+ const segments = instancePath.split("/").filter(Boolean);
30
+ if (segments.length === 0)
31
+ return undefined;
32
+ let current = schema;
33
+ let lastKey;
34
+ let i = 0;
35
+ while (i < segments.length) {
36
+ if (!current || typeof current !== "object")
37
+ return undefined;
38
+ const node = current;
39
+ // Array descent: numeric segment against an `items` schema. Tuple-form
40
+ // `items` (array) indexes positionally; schema-form descends uniformly.
41
+ if (node.items !== undefined && /^\d+$/.test(segments[i])) {
42
+ const idx = Number(segments[i]);
43
+ if (Array.isArray(node.items)) {
44
+ if (idx >= node.items.length)
45
+ return undefined;
46
+ current = node.items[idx];
47
+ }
48
+ else {
49
+ current = node.items;
50
+ }
51
+ lastKey = segments[i];
52
+ i++;
53
+ continue;
54
+ }
55
+ // Object descent: greedy longest-prefix match against `properties` keys.
56
+ // This is what lets `a/b` resolve to a single key when the schema
57
+ // declares it, even though the instancePath segments it as ["a", "b"].
58
+ if (node.properties && typeof node.properties === "object") {
59
+ const props = node.properties;
60
+ let matched = -1;
61
+ for (let j = segments.length; j > i; j--) {
62
+ const candidate = segments.slice(i, j).join("/");
63
+ if (Object.hasOwn(props, candidate)) {
64
+ matched = j;
65
+ lastKey = candidate;
66
+ current = props[candidate];
67
+ break;
68
+ }
69
+ }
70
+ if (matched === -1)
71
+ return undefined;
72
+ i = matched;
73
+ continue;
74
+ }
75
+ return undefined;
76
+ }
77
+ return lastKey;
78
+ }
79
+ function fieldFromError(schema, error) {
80
+ return fieldFromSchemaWalk(schema, error.instancePath) ?? fieldFromPath(error.instancePath);
81
+ }
82
+ function readDiscriminatorValues(propSchema) {
83
+ // Extract the allowed values from a discriminator-eligible prop schema.
84
+ // Recognizes three shapes:
85
+ // - `Type.Literal("x")` → `{ const: "x" }`
86
+ // - `Type.String({ enum: ["x", "y"] })` → `{ enum: ["x", "y"] }`
87
+ // - `Type.Union([Type.Literal("x"), Type.Literal("y")])` →
88
+ // `{ anyOf: [{ const: "x" }, { const: "y" }] }`
89
+ // The anyOf-of-const case is idiomatic TypeBox and was missed before:
90
+ // without it, a branch declaring its tag as a Union-of-Literals was
91
+ // treated as having no discriminator, silently bailing out of branch
92
+ // matching.
93
+ if (!propSchema || typeof propSchema !== "object")
94
+ return [];
95
+ if ("const" in propSchema)
96
+ return [propSchema.const];
97
+ if (Array.isArray(propSchema.enum))
98
+ return propSchema.enum;
99
+ if (Array.isArray(propSchema.anyOf) && propSchema.anyOf.length > 0) {
100
+ const consts = [];
101
+ for (const entry of propSchema.anyOf) {
102
+ if (entry && typeof entry === "object" && "const" in entry) {
103
+ consts.push(entry.const);
104
+ }
105
+ else {
106
+ return [];
107
+ }
108
+ }
109
+ return consts;
110
+ }
111
+ return [];
112
+ }
113
+ function resolveSchemaAtPath(schema, instancePath) {
114
+ // Walk a JSON-pointer-style instancePath (e.g. "/event", "/events/0")
115
+ // down to the sub-schema at that data path. Follows both `properties`
116
+ // for object schemas and `items` for array schemas (numeric segments
117
+ // descend into the items schema). Other JSON Schema composites bail
118
+ // out; the caller then falls back to the conservative suppress-all rule.
119
+ if (instancePath === "")
120
+ return schema;
121
+ const segments = instancePath.split("/").filter(Boolean);
122
+ let current = schema;
123
+ for (const segment of segments) {
124
+ if (!current || typeof current !== "object")
125
+ return undefined;
126
+ const obj = current;
127
+ if (obj.properties && typeof obj.properties === "object" && segment in obj.properties) {
128
+ current = obj.properties[segment];
129
+ if (current === undefined)
130
+ return undefined;
131
+ continue;
132
+ }
133
+ if (obj.items !== undefined && /^\d+$/.test(segment)) {
134
+ if (Array.isArray(obj.items)) {
135
+ const idx = Number(segment);
136
+ if (idx >= obj.items.length)
137
+ return undefined;
138
+ current = obj.items[idx];
139
+ }
140
+ else {
141
+ current = obj.items;
142
+ }
143
+ continue;
144
+ }
145
+ return undefined;
146
+ }
147
+ return current;
148
+ }
149
+ function readUnionBranches(node) {
150
+ // Accept both `anyOf` and `oneOf`. Each branch must be a plain object
151
+ // schema for this helper to apply — primitive unions (e.g. `Union([
152
+ // Literal("a"), Literal("b")])` rendered as anyOf-of-consts) have no
153
+ // discriminator-prop concept and the caller falls back.
154
+ if (!node || typeof node !== "object")
155
+ return undefined;
156
+ const shape = node;
157
+ const branches = shape.anyOf ?? shape.oneOf;
158
+ if (!Array.isArray(branches) || branches.length === 0)
159
+ return undefined;
160
+ if (!branches.every((b) => b && typeof b === "object" && b.type === "object" && b.properties)) {
161
+ return undefined;
162
+ }
163
+ return branches;
164
+ }
165
+ function branchDiscriminatorMatches(branch, value) {
166
+ // A branch matches its discriminator when every prop with a discriminator-
167
+ // eligible schema (Literal / enum / anyOf-of-Literal) is present on the
168
+ // input with a value the schema allows. `Object.hasOwn` avoids matching
169
+ // prototype-chain props like `toString`.
170
+ if (!value || typeof value !== "object")
171
+ return false;
172
+ const input = value;
173
+ let hasDiscriminator = false;
174
+ for (const [key, propSchema] of Object.entries(branch.properties ?? {})) {
175
+ const allowed = readDiscriminatorValues(propSchema);
176
+ if (allowed.length === 0)
177
+ continue;
178
+ hasDiscriminator = true;
179
+ if (!Object.hasOwn(input, key) || !allowed.includes(input[key]))
180
+ return false;
181
+ }
182
+ return hasDiscriminator;
183
+ }
184
+ function isSubsetOf(props, set) {
185
+ for (const p of props)
186
+ if (!set.has(p))
187
+ return false;
188
+ return true;
189
+ }
190
+ function suppressSiblingErrorsUnderUnion(schema, value, errors) {
4
191
  // For unions of objects, TypeBox emits per-branch `required` /
5
192
  // `additionalProperties` errors at the union's path. The user only needs
6
193
  // to satisfy ONE branch, so those entries are phantoms; the `anyOf` /
@@ -9,10 +196,20 @@ function suppressSiblingErrorsUnderUnion(errors) {
9
196
  // `const` / `enum` errors at the same path survive — they carry real
10
197
  // discriminator info about which branch was intended.
11
198
  //
12
- // Known trade-off (#38): discriminated unions where the user picks a
13
- // branch correctly but forgets that branch's required props also lose
14
- // the "you picked tag=X and forgot Y" hint. Keyword-aware branch
15
- // matching is the follow-up.
199
+ // Discriminated-union refinement (#38): when an `anyOf`/`oneOf` fires at
200
+ // path P and exactly one branch's discriminator props (`const` / `enum`)
201
+ // are satisfied by the input, that branch is "active". Required and
202
+ // additionalProperties errors attributable to the active branch are
203
+ // preserved (so the user sees "you picked op=create, missing name");
204
+ // contributions from losing branches at P and its descendants are
205
+ // suppressed. Schema-walking on the public JSON-Schema shape avoids
206
+ // depending on TypeBox internals.
207
+ //
208
+ // Fallback: zero or multiple branches match — there is no actionable
209
+ // owner for the phantom errors, so the conservative 0.8.1 rule applies
210
+ // (drop all sibling `required` / `additionalProperties` at P, keep
211
+ // const / enum). This covers non-discriminated unions and ambiguous
212
+ // inputs.
16
213
  const unionPaths = new Set();
17
214
  for (const error of errors) {
18
215
  if (error.keyword === "anyOf" || error.keyword === "oneOf") {
@@ -21,13 +218,118 @@ function suppressSiblingErrorsUnderUnion(errors) {
21
218
  }
22
219
  if (unionPaths.size === 0)
23
220
  return errors;
221
+ const resolutions = new Map();
222
+ for (const path of unionPaths) {
223
+ const unionSchema = resolveSchemaAtPath(schema, path);
224
+ const branches = readUnionBranches(unionSchema);
225
+ if (!branches) {
226
+ resolutions.set(path, { kind: "no-active" });
227
+ continue;
228
+ }
229
+ const branchValue = Pointer.Get(value, path);
230
+ const matchedIndices = [];
231
+ for (let i = 0; i < branches.length; i++) {
232
+ if (branchDiscriminatorMatches(branches[i], branchValue))
233
+ matchedIndices.push(i);
234
+ }
235
+ if (matchedIndices.length !== 1) {
236
+ resolutions.set(path, { kind: "no-active" });
237
+ continue;
238
+ }
239
+ const activeIndex = matchedIndices[0];
240
+ const active = branches[activeIndex];
241
+ const branchRequired = new Set(active.required ?? []);
242
+ const branchProps = new Set(Object.keys(active.properties ?? {}));
243
+ // Map of property-name -> set of disallowed values held by losing
244
+ // branches' discriminators. Used to drop "must equal X" errors at the
245
+ // union's descendant paths when X belongs to a branch the user didn't
246
+ // pick. Covers `const`, `enum`, and `Union([Literal, Literal])` (the
247
+ // anyOf-of-const shape) via `readDiscriminatorValues`.
248
+ const losingDiscriminators = new Map();
249
+ for (let i = 0; i < branches.length; i++) {
250
+ if (i === activeIndex)
251
+ continue;
252
+ for (const [key, propSchema] of Object.entries(branches[i].properties ?? {})) {
253
+ const values = readDiscriminatorValues(propSchema);
254
+ if (values.length === 0)
255
+ continue;
256
+ let existing = losingDiscriminators.get(key);
257
+ if (!existing) {
258
+ existing = new Set();
259
+ losingDiscriminators.set(key, existing);
260
+ }
261
+ for (const v of values)
262
+ existing.add(v);
263
+ }
264
+ }
265
+ resolutions.set(path, { kind: "active", branchRequired, branchProps, losingDiscriminators });
266
+ }
24
267
  return errors.filter((error) => {
25
- if (!unionPaths.has(error.instancePath))
268
+ // Errors at a union path: handle by keyword + active-branch attribution.
269
+ const resolution = resolutions.get(error.instancePath);
270
+ if (resolution) {
271
+ if (resolution.kind === "no-active") {
272
+ // 0.8.1 fallback: drop required/additionalProperties at P; keep
273
+ // const / enum / anyOf (they carry discriminator info the user
274
+ // needs to fix the input).
275
+ return error.keyword !== "required" && error.keyword !== "additionalProperties";
276
+ }
277
+ // Active branch resolved: suppress the anyOf/oneOf summary at P too.
278
+ // The active branch's specific errors (preserved below) are strictly
279
+ // more actionable than "must match a schema in anyOf".
280
+ if (error.keyword === "anyOf" || error.keyword === "oneOf")
281
+ return false;
282
+ if (error.keyword === "required") {
283
+ // Attribute by structured params: the active branch demanded a
284
+ // superset of these props iff the missing props are all in its
285
+ // `required` array. Other branches' `required` entries get dropped.
286
+ const props = error.params.requiredProperties ?? [];
287
+ return props.length > 0 && isSubsetOf(props, resolution.branchRequired);
288
+ }
289
+ if (error.keyword === "additionalProperties") {
290
+ // The active-branch's additionalProperties error lists ONLY keys
291
+ // genuinely missing from its `properties` map. A losing branch may
292
+ // include the active branch's legitimate props in its list (because
293
+ // it has its own, narrower property set), so we keep only errors
294
+ // where EVERY offending key is also additional for the active
295
+ // branch. Losing-branch entries that drag in active-branch props
296
+ // get dropped — they would expand into phantom "name is additional"
297
+ // entries otherwise.
298
+ const props = error.params.additionalProperties ?? [];
299
+ return props.length > 0 && props.every((p) => !resolution.branchProps.has(p));
300
+ }
26
301
  return true;
27
- return error.keyword !== "required" && error.keyword !== "additionalProperties";
302
+ }
303
+ // Errors at descendant paths of a union path: when an active branch is
304
+ // resolved at the parent, drop `const`/`enum` errors whose allowedValue
305
+ // belongs to a losing branch's discriminator at this prop. This keeps
306
+ // the active-branch error story coherent ("you picked create, forgot
307
+ // name") instead of mixing in "could also have been delete" noise.
308
+ if (error.keyword !== "const" && error.keyword !== "enum")
309
+ return true;
310
+ for (const [unionPath, resolution] of resolutions) {
311
+ if (resolution.kind !== "active")
312
+ continue;
313
+ const prefix = unionPath === "" ? "/" : `${unionPath}/`;
314
+ if (!error.instancePath.startsWith(prefix))
315
+ continue;
316
+ const remainder = error.instancePath.slice(prefix.length);
317
+ if (remainder.includes("/"))
318
+ continue; // only direct discriminator props
319
+ const losingValues = resolution.losingDiscriminators.get(remainder);
320
+ if (!losingValues)
321
+ continue;
322
+ if (error.keyword === "const" && losingValues.has(error.params.allowedValue))
323
+ return false;
324
+ if (error.keyword === "enum" && Array.isArray(error.params.allowedValues)) {
325
+ if (error.params.allowedValues.every((v) => losingValues.has(v)))
326
+ return false;
327
+ }
328
+ }
329
+ return true;
28
330
  });
29
331
  }
30
- function expandTypeBoxError(error) {
332
+ function expandTypeBoxError(schema, error) {
31
333
  // Read offending property names from TypeBox's structured `params` rather
32
334
  // than parsing the message string. Two payoffs: locale independence (the
33
335
  // helper works the same after `Locale.Set("de_DE")`), and faithful
@@ -51,8 +353,28 @@ function expandTypeBoxError(error) {
51
353
  }));
52
354
  }
53
355
  }
54
- const segments = error.instancePath.split("/").filter(Boolean);
55
- return [{ field: segments.at(-1) ?? ROOT_FIELD, message: error.message }];
356
+ // const / enum errors carry the allowed values in `params`. Surfacing them
357
+ // in the message lets an agent recovering from an invalid-discriminator
358
+ // failure see exactly what tag(s) are accepted, rather than the opaque
359
+ // `must be equal to constant`.
360
+ if (error.keyword === "const") {
361
+ return [
362
+ {
363
+ field: fieldFromError(schema, error),
364
+ message: `must equal ${JSON.stringify(error.params.allowedValue)}`,
365
+ },
366
+ ];
367
+ }
368
+ if (error.keyword === "enum") {
369
+ const allowed = error.params.allowedValues ?? [];
370
+ return [
371
+ {
372
+ field: fieldFromError(schema, error),
373
+ message: allowed.length > 0 ? `must equal one of ${allowed.map((v) => JSON.stringify(v)).join(", ")}` : error.message,
374
+ },
375
+ ];
376
+ }
377
+ return [{ field: fieldFromError(schema, error), message: error.message }];
56
378
  }
57
379
  export function validatePortableToolArgs(tool, args) {
58
380
  if (Check(tool.parameters, args)) {
@@ -65,10 +387,12 @@ export function validatePortableToolArgs(tool, args) {
65
387
  // satisfy ONE branch, not all of them). Then dedupe the survivors by
66
388
  // (field, message) using JSON.stringify so the key can't collide regardless
67
389
  // of what characters field or message contain.
68
- const rawErrors = suppressSiblingErrorsUnderUnion([...Errors(tool.parameters, args)]);
390
+ const rawErrors = suppressSiblingErrorsUnderUnion(tool.parameters, args, [
391
+ ...Errors(tool.parameters, args),
392
+ ]);
69
393
  const seen = new Set();
70
394
  const errors = rawErrors
71
- .flatMap((error) => expandTypeBoxError(error))
395
+ .flatMap((error) => expandTypeBoxError(tool.parameters, error))
72
396
  .filter(({ field, message }) => {
73
397
  const key = JSON.stringify([field, message]);
74
398
  if (seen.has(key))
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.1",
3
+ "version": "0.8.3",
4
4
  "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.",
5
5
  "keywords": [
6
6
  "pi",