@feniix/bridgekit 0.7.0 → 0.8.1
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 +86 -1
- package/README.md +10 -2
- package/dist/src/core/define-tool.d.ts +1 -1
- package/dist/src/core/execute-tool.js +75 -8
- package/llms.txt +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,92 @@ 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.
|
|
7
|
+
## [0.8.1] - Unreleased
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Union-of-objects validation no longer surfaces phantom missing-required-
|
|
12
|
+
property entries for every branch. When TypeBox emits an `anyOf`/`oneOf`
|
|
13
|
+
error at a path, sibling `required`/`additionalProperties` entries at the
|
|
14
|
+
same path are now suppressed so consumers see only the union summary.
|
|
15
|
+
`const`/`enum` discriminator errors at deeper paths are kept (they're
|
|
16
|
+
signal about which branch was intended).
|
|
17
|
+
Resolves [#35](https://github.com/feniix/bridgekit/issues/35).
|
|
18
|
+
|
|
19
|
+
## [0.8.0] - 2026-05-26
|
|
20
|
+
|
|
21
|
+
### Breaking
|
|
22
|
+
|
|
23
|
+
- **`PortableValidationError` renamed `path` → `field`.** No deprecation alias.
|
|
24
|
+
Update assertions accordingly. `field` is derived from TypeBox's structured
|
|
25
|
+
error data, not the localized message string. Resolves
|
|
26
|
+
[#33](https://github.com/feniix/bridgekit/issues/33).
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// Before (0.7 and earlier)
|
|
30
|
+
assert.equal(result.structuredContent.validationErrors[0].path, "/file_path");
|
|
31
|
+
|
|
32
|
+
// After (0.8+)
|
|
33
|
+
assert.equal(result.structuredContent.validationErrors[0].field, "file_path");
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Entries *inside* `validationErrors[]` share the `{ field, message }` shape
|
|
37
|
+
with handler-emitted domain-failure data so iteration code can read `.field`
|
|
38
|
+
uniformly. The `validationErrors[]` array itself only exists on the
|
|
39
|
+
`kind: "validation"` branch of `PortableToolErrorDetails` — always narrow
|
|
40
|
+
with `isValidationFailure` (or the discriminator) before iterating.
|
|
41
|
+
|
|
42
|
+
- **`validationErrors[]` cardinality change.** Previously, a TypeBox error
|
|
43
|
+
with the message `must have required properties X, Y` produced a single
|
|
44
|
+
`PortableValidationError` whose message listed all missing props. Now,
|
|
45
|
+
`executePortableTool` emits **one `PortableValidationError` per missing
|
|
46
|
+
property**, with `field` set to the missing prop name and `message`
|
|
47
|
+
normalized to `must have required property <field>`. Consumers asserting
|
|
48
|
+
`validationErrors.length === 1` for a multi-prop missing case will break.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// Schema: Type.Object({ file_path: Type.String(), count: Type.Number() })
|
|
52
|
+
// Args: {}
|
|
53
|
+
|
|
54
|
+
// Before (0.7)
|
|
55
|
+
// validationErrors.length === 1
|
|
56
|
+
// validationErrors[0] === { path: "/", message: "must have required properties file_path, count" }
|
|
57
|
+
|
|
58
|
+
// After (0.8+)
|
|
59
|
+
// validationErrors.length === 2
|
|
60
|
+
// validationErrors === [
|
|
61
|
+
// { field: "file_path", message: "must have required property file_path" },
|
|
62
|
+
// { field: "count", message: "must have required property count" },
|
|
63
|
+
// ]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **Duplicate `(field, message)` pairs are deduplicated.** Union/discriminator
|
|
67
|
+
mismatches previously emitted multiple identical `(field, message)` entries
|
|
68
|
+
(one per failed union branch); they are now collapsed. Dedup only removes
|
|
69
|
+
entries — it never introduces new ones — so `validationErrors.length` can
|
|
70
|
+
only decrease relative to raw TypeBox output. Consumers locking in exact
|
|
71
|
+
counts on union failures may need to adjust.
|
|
72
|
+
|
|
73
|
+
### Notes on field derivation
|
|
74
|
+
|
|
75
|
+
- `field` is derived from TypeBox's structured error:
|
|
76
|
+
- `keyword === "required"` reads `params.requiredProperties` and emits one
|
|
77
|
+
entry per missing prop name.
|
|
78
|
+
- `keyword === "additionalProperties"` reads `params.additionalProperties`
|
|
79
|
+
and emits one entry per offending key name. (Previously fell back to the
|
|
80
|
+
`(root)` sentinel — the offending key is now surfaced as `field`.)
|
|
81
|
+
- Other errors take the last meaningful segment of `instancePath`
|
|
82
|
+
(e.g. `/text` → `"text"`, `/items/0/name` → `"name"`).
|
|
83
|
+
- The structured-access path is locale- and message-format-independent.
|
|
84
|
+
- For array-element validation, `field` is the leaf segment, which can be a
|
|
85
|
+
numeric index (e.g. `field: "0"`) and loses the path context. If positional
|
|
86
|
+
information matters, keep schemas permissive at the BridgeKit boundary and
|
|
87
|
+
validate positionally in your handler.
|
|
88
|
+
- For root-level schema failures with empty `instancePath` (e.g. `null` passed
|
|
89
|
+
to a `Type.Object` schema), `field` is the sentinel `"(root)"` rather than
|
|
90
|
+
the empty string.
|
|
91
|
+
|
|
92
|
+
## [0.7.0] - 2026-05-26
|
|
8
93
|
|
|
9
94
|
### Changed
|
|
10
95
|
|
package/README.md
CHANGED
|
@@ -114,11 +114,19 @@ try {
|
|
|
114
114
|
// After (0.7+)
|
|
115
115
|
const result = await piTool.execute(...);
|
|
116
116
|
if (result.isError) {
|
|
117
|
-
if (isValidationFailure(result)) {
|
|
118
|
-
|
|
117
|
+
if (isValidationFailure(result)) {
|
|
118
|
+
// validationErrors is Array<{ field: string; message: string }>.
|
|
119
|
+
for (const { field, message } of result.structuredContent.validationErrors) {
|
|
120
|
+
// ...
|
|
121
|
+
}
|
|
122
|
+
} else if (isDomainFailure(result)) { /* handler-level error */ }
|
|
119
123
|
}
|
|
120
124
|
```
|
|
121
125
|
|
|
126
|
+
`PortableValidationError` exposes `{ field, message }`. `field` is derived from TypeBox's structured error data — required-property errors read `params.requiredProperties` (so a prop named `"a,b"` survives intact and the value is locale-independent); other errors take the last meaningful segment of the offending JSON pointer (`text`, not `/text`). One error is emitted per missing required property, and duplicate `(field, message)` pairs (e.g. from union mismatches) are deduplicated. Validation and domain errors share this `{ field, message }` shape, so a consumer reading `.field` does not need to branch on which kind of failure produced the entry. (`path` was the pre-0.8.0 name; it has been removed.)
|
|
127
|
+
|
|
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
|
+
|
|
122
130
|
The two modes expose the failure discriminator on different fields. In the
|
|
123
131
|
new default `"return"` mode, prefer the result guards over inspecting a
|
|
124
132
|
`kind` field directly — handler-emitted failures do not carry a synthesized
|
|
@@ -33,7 +33,7 @@ export type PortableToolErrorDetails = {
|
|
|
33
33
|
kind: "domain";
|
|
34
34
|
} & Record<string, unknown>);
|
|
35
35
|
export interface PortableValidationError {
|
|
36
|
-
|
|
36
|
+
field: string;
|
|
37
37
|
message: string;
|
|
38
38
|
}
|
|
39
39
|
export type PortableToolBuiltInHost = "pi" | "mcp" | "test";
|
|
@@ -1,22 +1,89 @@
|
|
|
1
1
|
import { Check, Errors } from "typebox/value";
|
|
2
|
+
const ROOT_FIELD = "(root)";
|
|
3
|
+
function suppressSiblingErrorsUnderUnion(errors) {
|
|
4
|
+
// For unions of objects, TypeBox emits per-branch `required` /
|
|
5
|
+
// `additionalProperties` errors at the union's path. The user only needs
|
|
6
|
+
// to satisfy ONE branch, so those entries are phantoms; the `anyOf` /
|
|
7
|
+
// `oneOf` summary at the same path is the real signal.
|
|
8
|
+
//
|
|
9
|
+
// `const` / `enum` errors at the same path survive — they carry real
|
|
10
|
+
// discriminator info about which branch was intended.
|
|
11
|
+
//
|
|
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.
|
|
16
|
+
const unionPaths = new Set();
|
|
17
|
+
for (const error of errors) {
|
|
18
|
+
if (error.keyword === "anyOf" || error.keyword === "oneOf") {
|
|
19
|
+
unionPaths.add(error.instancePath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (unionPaths.size === 0)
|
|
23
|
+
return errors;
|
|
24
|
+
return errors.filter((error) => {
|
|
25
|
+
if (!unionPaths.has(error.instancePath))
|
|
26
|
+
return true;
|
|
27
|
+
return error.keyword !== "required" && error.keyword !== "additionalProperties";
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function expandTypeBoxError(error) {
|
|
31
|
+
// Read offending property names from TypeBox's structured `params` rather
|
|
32
|
+
// than parsing the message string. Two payoffs: locale independence (the
|
|
33
|
+
// helper works the same after `Locale.Set("de_DE")`), and faithful
|
|
34
|
+
// preservation of property names that contain delimiters TypeBox's prose
|
|
35
|
+
// would split on (commas, etc.).
|
|
36
|
+
if (error.keyword === "required") {
|
|
37
|
+
const props = error.params.requiredProperties.filter((p) => p.length > 0);
|
|
38
|
+
if (props.length > 0) {
|
|
39
|
+
return props.map((field) => ({
|
|
40
|
+
field,
|
|
41
|
+
message: `must have required property ${field}`,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (error.keyword === "additionalProperties") {
|
|
46
|
+
const props = error.params.additionalProperties.filter((p) => p.length > 0);
|
|
47
|
+
if (props.length > 0) {
|
|
48
|
+
return props.map((field) => ({
|
|
49
|
+
field,
|
|
50
|
+
message: `must not have additional property ${field}`,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const segments = error.instancePath.split("/").filter(Boolean);
|
|
55
|
+
return [{ field: segments.at(-1) ?? ROOT_FIELD, message: error.message }];
|
|
56
|
+
}
|
|
2
57
|
export function validatePortableToolArgs(tool, args) {
|
|
3
58
|
if (Check(tool.parameters, args)) {
|
|
4
59
|
return { ok: true };
|
|
5
60
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
61
|
+
// TypeBox can emit multiple errors per offending field (e.g. union/anyOf
|
|
62
|
+
// mismatches fire one error per failed branch). First, when an anyOf/oneOf
|
|
63
|
+
// error fires at a path, drop sibling `required`/`additionalProperties`
|
|
64
|
+
// errors at the same path — they're phantoms (the consumer only needs to
|
|
65
|
+
// satisfy ONE branch, not all of them). Then dedupe the survivors by
|
|
66
|
+
// (field, message) using JSON.stringify so the key can't collide regardless
|
|
67
|
+
// of what characters field or message contain.
|
|
68
|
+
const rawErrors = suppressSiblingErrorsUnderUnion([...Errors(tool.parameters, args)]);
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const errors = rawErrors
|
|
71
|
+
.flatMap((error) => expandTypeBoxError(error))
|
|
72
|
+
.filter(({ field, message }) => {
|
|
73
|
+
const key = JSON.stringify([field, message]);
|
|
74
|
+
if (seen.has(key))
|
|
75
|
+
return false;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
return { ok: false, errors };
|
|
13
80
|
}
|
|
14
81
|
export async function executePortableTool(tool, args, ctx) {
|
|
15
82
|
const validation = validatePortableToolArgs(tool, args);
|
|
16
83
|
if (!validation.ok) {
|
|
17
84
|
return {
|
|
18
85
|
text: `Invalid arguments for ${tool.name}: ${validation.errors
|
|
19
|
-
.map((error) => `${error.
|
|
86
|
+
.map((error) => `${error.field}: ${error.message}`)
|
|
20
87
|
.join("; ")}`,
|
|
21
88
|
structuredContent: {
|
|
22
89
|
kind: "validation",
|
package/llms.txt
CHANGED
|
@@ -73,6 +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
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`.
|
|
77
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.
|
|
78
79
|
|
|
@@ -82,8 +83,10 @@ export function createTools() {
|
|
|
82
83
|
const result = await executePortableTool(tool, args, { host: "test" });
|
|
83
84
|
if (result.isError) {
|
|
84
85
|
if (isValidationFailure(result)) {
|
|
85
|
-
//
|
|
86
|
-
|
|
86
|
+
// validationErrors is typed: Array<{ field: string; message: string }>.
|
|
87
|
+
for (const { field, message } of result.structuredContent.validationErrors) {
|
|
88
|
+
log.warn({ tool: result.structuredContent.tool, field, message }, "bad args");
|
|
89
|
+
}
|
|
87
90
|
} else if (isDomainFailure(result)) {
|
|
88
91
|
// Handler-emitted failure; result.structuredContent is whatever the
|
|
89
92
|
// handler chose. No synthesized `kind` discriminator.
|