feat(ai): flip tool-argument validation to native#357
Conversation
Back pi's packages/ai/src/utils/validation.ts with the Rust port in
crates/pidgin-ai (utils/validation.rs). validateToolArguments and
validateToolCall now run natively: the AJV-compatible primitive coercion,
the recursive allOf/anyOf/oneOf + object + array coercion driver, the
pass/fail check over the JSON-Schema subset, and the
`Validation failed for tool "<name>": …` throw-envelope all live in Rust.
- Add crates/pidgin-napi/src/ai_validation.rs exposing both functions as
free #[napi] fns. Tool/ToolCall cross as JSON strings (the TypeBox schema's
JSON-Schema projection, the [TypeBox.Kind] symbol dropped by JSON.stringify);
each returns a discriminated outcome envelope ({ok,value}/{ok,error}).
- Register `pub mod ai_validation;` in lib.rs (pub for the free-fn reachability
rule).
- Add the native shim conformance/shims/packages/ai/src/utils/validation.ts:
re-export the pi original, override both fns to marshal via JSON.stringify,
return the coerced value, and re-throw new Error(envelope) on failure.
- Flip the manifest row to status:"native", tests:["test/validation.test.ts"].
test/validation.test.ts passes 3/3 native. Full ai suite: zero new
regressions (the 3 failing tests are pre-existing model-catalog phantoms).
Codegen missing:0, native 52 -> 53.
0402d24 to
0e46a63
Compare
Conformance smoke: agent + ai + tui
Headline is rust-backed: passing cases in files whose module-under-test is a native (Rust addon) module. Raw all-pass is shown secondary — it is inflated by unflipped TypeScript that passes without touching any Rust.
agent: 73/180 (40.6%) · ai: 84/1296 (6.5%) · tui: 375/678 (55.3%) rust-backed = passing / total tests run, per the manifest's per-native-row AttributionA pi test file is rust-backed iff the module it primarily exercises — its module-under-test — is Per-file decisions (from each native manifest row's
CLI conformance (black-box, against the pidgin binary)
The four repointed coding-agent CLI test files spawn the compiled pidgin binary via |
What
Flips pi's
packages/ai/src/utils/validation.ts(tool-argument validation +JSON-Schema coercion) to the native Rust engine.
validateToolArgumentsandvalidateToolCallnow run incrates/pidgin-ai(utils/validation.rs);packages/ai/test/validation.test.tspasses fully native.Before / After
Before:
validation.tsran pi's own TypeScript — TypeBoxValue.Convert+the hand-rolled
coerceWithJsonSchema+ compiledValue.Check. Manifest rowstatus: "original". Native module count: 52.After: a native shim overlays the module and delegates to the addon. Every
decision runs in Rust: the AJV-compatible primitive-coercion rules
(
coercePrimitiveByType), the recursiveallOf/anyOf/oneOf+ object + arraycoercion driver, the pass/fail check over the JSON-Schema subset, and the
Validation failed for tool "<name>": …throw-envelope. Manifest rowstatus: "native",tests: ["test/validation.test.ts"]. Native module count: 53.How
crates/pidgin-napi/src/ai_validation.rs(new): exposes both functions asfree
#[napi]fns.Tool/ToolCallcross the boundary as JSON strings —the tool's
parametersis a TypeBoxTSchema, andJSON.stringifydrops itshidden
[TypeBox.Kind]symbol, leaving exactly the JSON-Schema projection theport models as
serde_json::Value. Each fn returns a discriminated outcomeenvelope:
{ok:true,value}on success,{ok:false,error}carrying pi's thrownmessage verbatim.
crates/pidgin-napi/src/lib.rs: registerspub mod ai_validation;(pubfor the free-fn crate-reachability rule).
conformance/shims/packages/ai/src/utils/validation.ts(new): re-exportsthe preserved pi original, overrides both fns to
JSON.stringifytheir inputs,delegate, return the coerced
value, and re-thrownew Error(envelope)onfailure so pi's
.toThrow("Validation failed")/Tool "<name>" not foundcontract holds.
.toEqualdeep-equality means the freshly parsed return is fine.conformance/manifest.json: the onevalidation.tsrow flipped to native.The symbol-drop is behaviour-preserving
pi's coercion path has two branches gated on
[TypeBox.Kind]: TypeBox schemasrun only
Value.Convert, plain serialized schemas additionally run thehand-rolled coercion. Rust
Values carry no Kind, so the port always runs thehand-rolled coercion — and for the shapes these utils see it reproduces the same
observable result
Value.Convertwould have produced (e.g.{count:"42"}->{count:42}for aType.Number()field). The tests assert only plain coercedvalues (
.toEqual) and, on failure, only the substring"Validation failed"(pi's own envelope prefix) — never TypeBox's per-error message text — so the
reimplementation satisfies the full test contract.
Verification
test/validation.test.ts: 3/3 pass, native (all cases).packages/aisuite: zero new regressions — 553 passed / 3 failed /740 skipped; the 3 failures are pre-existing model-catalog phantoms
(Anthropic adaptive-thinking + Kimi pricing, from generated model data),
unrelated to this change.
missing: 0, native 52 -> 53.cargo fmt --all --check,cargo clippy --all-targets(no warnings), andwhole-repo straitjacket v0.2.3 (
crates conformance scripts .github, vendorexcluded): 0 errors.
🤖 Generated with Claude Code
Generated by Claude Code