feat(spec): declare the basic catalog functions and add clamp, round, min, max and abs, add live-calculator example ( fixes #302 ) - #2149
Conversation
…ic catalog The basic catalogs declared 14 functions while both reference SDKs shipped 25. The eleven arithmetic, comparison and string operators were implemented but never published, so agents were not told they existed, payloads using them were rejected by `common_types.json`, and `Catalog.from_json` silently dropped them. Declares `add`, `subtract`, `multiply`, `divide`, `greater_than`, `less_than`, `equals`, `not_equals`, `contains`, `starts_with` and `ends_with` in the v0.9, v0.9.1 and v1.0 basic catalogs, adds the matching `$defs.anyFunction.oneOf` entries, extends the Functions table in each protocol guide, and adds 30 validation cases per version. Argument names are taken verbatim from `basic_functions_api.ts` so no renderer has to change. `equals` and `not_equals` declare untyped arguments, matching the `z.any()` contract of the reference implementations: a typed `DynamicValue` would reject a path that resolves to null and would coerce integers beyond 2^53 to float, making two distinct values compare equal. The v0.9 and v0.9.1 catalogs remain byte-identical. The v1.0 entries keep `returnType` as sibling metadata rather than on the wire, wrap the body in `allOf` with `FunctionCommon`, and omit `callableFrom` so they default to `rendererOnly`.
`operator_apis.py` was a hand-written mirror of the TypeScript API definitions, maintained separately because the operators were absent from the basic catalog. Now that they are declared, code generation produces the same classes, so the hand-written file is redundant. Regenerates `function_apis.py` from the v0.9 catalog, deletes `operator_apis.py` and repoints its two importers at `function_apis`. The generated argument models match the deleted ones for all eleven operators, and every class keeps its name, so `a2ui.core.basic_catalog` re-exports an unchanged public surface. Teaches the generator to convert snake_case catalog names to PascalCase class names, so `not_equals` yields `NotEqualsApi` rather than `Not_equalsApi`. The 14 pre-existing camelCase names contain no underscore and generate byte-identical output, leaving `function_apis.py` a purely additive diff.
Issue a2ui-project#302 asks for client-side reactive computation: an input component writes to a data-model path and consuming components recompute locally, with no agent roundtrip. The reactive machinery already exists, but the transformation the motivating example needs — constraining a slider's raw output to a usable range — has no function to express it. `numeric` only validates a range and returns a boolean; it does not constrain a value. Declares `clamp`, `round`, `min`, `max` and `abs` in the v0.9, v0.9.1 and v1.0 basic catalogs, adds the matching `$defs.anyFunction.oneOf` entries, extends the Functions table in each protocol guide, and adds 26 validation cases per version covering required arguments, argument types, additional-argument rejection and `returnType` handling. `clamp` takes `value`, `min` and `max`, all required. `round` takes a required `value` and an optional `decimals` defaulting to 0. `min` and `max` take a `values` array with `minItems: 2`, following the shape already established by `and` and `or`. `abs` takes a required `value`. All five return `number`. The behaviour of `clamp` when `max` is less than `min` is defined in the schema description rather than left to each renderer: the result is `min`. This is the normative reference for the implementations that follow. No implementation ships here. The catalogs declare these functions before the SDKs register them, matching the repository's schema-first authority chain; no test cross-references declared functions against registered implementations, so the transient gap is not exercised. `function_apis.py` is regenerated with the Python implementations in a later commit. The v0.9 and v0.9.1 catalogs remain byte-identical, as do their validation-test files. The v1.0 entries keep `returnType` as sibling metadata rather than on the wire, wrap the body in `allOf` with `FunctionCommon`, and omit `callableFrom` so they default to `rendererOnly`; the v1.0 test cases assert that `returnType` on the wire is rejected.
The v0.9 catalog declares `clamp`, `round`, `min`, `max` and `abs`, but no
renderer registers them, so a schema-valid payload using any of the five
currently throws at invocation. This adds the TypeScript implementations that
close that gap; the catalog declarations committed earlier are the normative
reference they are written against.
`basic_functions_api.ts` gains `ClampApi`, `RoundApi`, `MinApi`, `MaxApi` and
`AbsApi` under a new `// Numeric` section, and `basic_functions.ts` gains the
matching implementations registered in `createBasicCatalogFunctions()`.
Arguments follow the existing numeric style — `z.coerce.number()` behind a
`null` to `undefined` preprocess — which means null, missing and non-numeric
arguments are rejected by validation rather than reaching the implementation.
`DivideImplementation`'s explicit `NaN` guards are therefore not reproduced
here: they are unreachable under the same schema.
`clamp` returns `min` when `max` is below `min`, as the schema description of
`clamp.args.max` states. `min` and `max` take a `values` array with a minimum
of two items, matching `and` and `or`.
`round` rounds halfway cases away from zero, so -2.5 yields -3 rather than the
-2 that `Math.round` produces, and the scaling is done by rewriting the decimal
exponent instead of multiplying by a power of ten. Multiplying reintroduces the
binary representation error the rounding is meant to hide: `1.005 * 100` is
`100.49999999999999` and would round down to `1.00`, where `Number('1.005e2')`
is `100.5` and rounds to the expected `1.01`. A `decimals` large enough to
overflow the shift to `Infinity` returns the value unrounded rather than `NaN`.
Both choices are new semantics, not restatements of the catalog, and the Python
implementation that follows has to match them.
No changes are needed in the Lit, React or Angular renderers; all three consume
`BASIC_FUNCTIONS` or `createBasicCatalogFunctions()` directly and assert nothing
about the size of the function set.
The known `divide` divergence from the Python SDK on `-1 / 0` and `0 / 0` is
deliberately left alone. It is pre-existing and orthogonal to these five
functions, and folding a behaviour fix into a feature commit would hide it.
Verified with `yarn workspace @a2ui/web_core test`: 283 of 285 pass, the two
failures being the pre-existing machine-locale assertions in `formatNumber` and
`formatCurrency`. The Lit and React builds are unchanged; the Angular build
failure is pre-existing and unrelated.
Completes the pair started in web_core: the v0.9 catalog declares `clamp`, `round`, `min`, `max` and `abs`, but `create_basic_catalog_functions()` does not register them, so `Catalog.from_json` yields five functions an agent may emit and the Python renderer cannot execute. Regenerates `function_apis.py` from the v0.9 catalog, which now emits `ClampApi`, `RoundApi`, `MinApi`, `MaxApi` and `AbsApi`, adds the implementations to `function_impls.py`, registers them in `create_basic_catalog_functions()`, and re-exports the API classes from `a2ui.core.basic_catalog` alongside the other twenty-five. Two behaviours are inherited from web_core rather than from the catalog, and are the reason this is not a mechanical transcription: `round` rounds halfway cases away from zero. Python's built-in `round()` does banker's rounding — `round(2.5)` is `2` and `round(-2.5)` is `-2` — which would disagree with the TypeScript implementation on every tie, so this quantizes a `Decimal` with `ROUND_HALF_UP` instead. Building that `Decimal` from `str(value)` also keeps the decimal literal rather than its binary approximation, so `round(1.005, 2)` is `1.01` in both SDKs. A `decimals` large enough to exceed the decimal context precision returns the value unrounded, matching web_core's overflow behaviour rather than raising. `clamp` returns `min` when `max` is below `min`, as the schema description of `clamp.args.max` states. The five functions return `int` when the result is integral, following the existing arithmetic implementations. One asymmetry remains and is deliberate: the catalog gives `min` and `max` a `values` array with `minItems: 2`, which the TypeScript schemas enforce but the generated Pydantic models do not, because the generator does not emit `min_length`. This is pre-existing — `and` and `or` have the same gap — and fixing it belongs in the generator, not here. Regenerating also rewrites `schema/common_types.py` and `schema/client_capabilities.py`, dropping a hand-added validator from the first; both were reverted, so this commit touches only `function_apis.py`. Verified with `uv run pytest agent_sdks/python/a2ui_core/tests/`: 207 pass, up from 202. `uv run --all-packages mypy .` is clean across 103 files and pyink reports no changes. The full suite's 21 failures in `a2ui_agent` and `eval` are pre-existing and environment-specific.
Issue a2ui-project#302's scenario — an input component driving a computed value with no agent roundtrip — is now expressible, but nothing in the specification shows it. Every existing example either binds a path straight to a property or formats a value; none composes arithmetic over a path that an input component writes. Adds `37_live-calculator.json` to the basic catalog examples for v0.9, v0.9.1 and v1.0. Three `Slider` components write to `/order/subtotal`, `/order/tipPercent` and `/order/people`; `Text` components display amounts computed with `add`, `multiply`, `divide`, `round`, `clamp` and `formatCurrency`. Dragging any slider updates every amount locally. `clamp` guards the divisor rather than decorating the example: the people slider starts at 0, so the per-person line would divide by zero without it. It also appears inside a `formatString` template, which exercises the expression parser's function-call syntax against a newly published function. Function nesting stays at four levels, one below the limit `integrity_checker.py` enforces. Text styling avoids `variant` values above `body` because v1.0 narrowed the `Text.variant` enum to `caption` and `body`; the heading uses Markdown instead, as `36_modal.json` already does, which keeps the three copies structurally identical. The data model uses a subtotal of 40 and a tip of 20 percent so that every computed amount is exact in binary floating point, which lets the tests assert literal values without depending on how a renderer rounds. Adds the matching per-example integration tests to the Lit, React and Angular explorer harnesses. All three assert amounts without a currency symbol, since the symbol is locale-dependent and the amount is not. The v0.9 and v0.9.1 copies are byte-identical. The v1.0 copy differs only in `version`, `catalogId`, snake_case component identifiers, and the omission of `returnType`, which v1.0 moved off the wire. Verified with `specification/scripts/validate.py` (PASSED), the three spec suites (136/136/190), and the explorer harnesses: Lit 138 pass and React 132 pass, both including the three new cases. The Angular explorer could not be run here — its build fails with a pre-existing missing-entry-point error unrelated to this change — but its spec asserts exactly what the two passing ones do.
The protocol guide establishes that two-way binding is local and that every component bound to a path updates in real time, and separately that any `Dynamic*` property may hold a `FunctionCall`. It never joins the two, so the pattern issue a2ui-project#302 asks for — an input driving a computed value with no agent roundtrip — is derivable from the guide but never stated by it. Adds a "Reactive client-side computation" subsection to `a2ui_protocol.md` for v0.9, v0.9.1 and v1.0, placed immediately after "Two-way binding & input components" because it depends on the reactivity that section establishes. It walks the loop end to end, shows a nested `FunctionCall` over a bound path, and points at `37_live-calculator.json` as the worked instance. Two properties are stated explicitly because they are the ones a reader is likely to assume otherwise. Composition is not an expression language: the renderer only runs functions it already implements, and arguments are schema validated before invocation, so nothing executable crosses the wire. And a `FunctionCall` yields a value for one property rather than writing back into the data model, so each consumer carries its own. A second subsection covers extending the pattern with a custom catalog, using the colour manipulator from the issue, and links to the custom functions guide. That answers the case the basic catalog cannot: it has no colour-capable component, and adding one is a separate design conversation. The v1.0 copy says "renderer" where v0.9 says "client", matching the surrounding prose in each file, and its example omits `returnType` and uses a snake_case component identifier. `AGENTS.md` and `.agents/skills/` were reviewed as the schema-change rule requires and need no update: neither states a function count nor enumerates the basic catalog. The published site picks the section up automatically, since `docs/public/specification/*.md` includes these files by snippet. The three files gain 52, 52 and 49 lines with no deletions. The spec suites remain at 136, 136 and 190 passing.
The `divide` description this branch added to all three basic catalogs states that division by zero "yields positive or negative infinity, or NaN when the dividend is also zero". `DivideImplementation` returns positive `Infinity` for every zero divisor, so the branch shipped normative prose that its own reference renderer contradicts. The divergence is older than this work, but publishing `divide` is what makes it reachable: before the catalog declared the operator, a payload calling it was rejected on the wire, so no renderer could be asked to evaluate it. It is also the SDK pair the cross-SDK conformance tests are driven from — Python already returns `math.inf`, `-math.inf` and `math.nan` for the three cases. The fix is to delete the special case rather than extend it. IEEE 754 division, which is what the `/` operator already performs, produces exactly the table the catalog describes: `-1 / 0` is `-Infinity` and `0 / 0` is `NaN`. The explicit branch was the only thing preventing it. Adds assertions for the two previously wrong cases. One edge is deliberately left unasserted: a negative-zero divisor gives `-Infinity` here and `+inf` in Python, because Python cannot delegate to IEEE — float division by zero raises `ZeroDivisionError` there, so its implementation branches on the sign of the dividend alone. Asserting the difference would enshrine it; it is worth closing in `function_impls.py` separately. The `undefined`/`null`/`NaN` guards above are left in place though they are unreachable: the Zod schema coerces and rejects all three before the implementation runs, as the surrounding tests show. Removing them is a readability change, not a behaviour one, and does not belong in a fix. The Python suite still asserts only `10 / 0`, so parity on the other two cases is now real but untested there. Locking it in is a test-only change to `a2ui_core` and was left out of a `web_core` fix. Verified with `yarn workspace @a2ui/web_core test`: 283 of 285 pass, the two failures being the pre-existing machine-locale assertions in `formatNumber` and `formatCurrency`. Lint reports 0 errors.
…onfig `37_live-calculator.spec.ts`, added earlier in this branch, fails `./scripts/fix_format.sh --check`, which is the format gate CI runs. The root `format:check:all` script is `prettier --config .prettierrc --check .`. The explicit `--config` forces the root configuration onto every file and overrides the `prettier` block in `renderers/angular/package.json`, which the Angular workspace's own `format:check` resolves instead. The root config sets `"bracketSpacing": false` and the Angular one does not, so the file's single import statement was written to a convention the gate rejects. It is the only spec under `src/app/tests/v0_9/` that fails; `33` through `36` already conform. The divergence was missed because a CRLF working tree makes the same gate report 1708 files, which buries the one real hit. Confirmed on an LF checkout: before the change the gate names this file, after it no tracked file is flagged.
There was a problem hiding this comment.
Code Review
This pull request implements the clamp, round, min, max, and abs basic catalog functions across the Python SDK and TypeScript web core renderer, and transitions arithmetic, comparison, and string operator APIs to be sourced from generated catalog schemas. It also adds a new 'Live Calculator' example with corresponding tests. The review feedback suggests robustly handling both dictionary and list structures for Python function arguments, capping the decimals parameter to a safe range (e.g., -100 to 100) in both Python and TypeScript rounding implementations to prevent potential performance or DoS issues, and ensuring the TypeScript exponent shifting helper handles both lowercase 'e' and uppercase 'E' in scientific notation.
|
I appreciate intermediate feedback, which is why i created this "WiP" PR. |
The `round` catalog entry types `decimals` as a `DynamicNumber` with no integer constraint, and `RoundApi` coerces it with `z.coerce.number()`, so a fractional `decimals` is valid on the wire and arrives at the implementation unchanged. `shiftExponent` rewrites a number's decimal exponent by string concatenation, so a fractional exponent produces a malformed literal: `round(1.005, 2.5)` built `'1.005e2.5'`, which `Number()` parses as `NaN`. That tripped the `!Number.isFinite(scaled)` guard, and the guard returns the input unrounded, so the call silently yielded `1.005` rather than rounding at all. Python truncates via `int(_to_float(raw_decimals))` and returned `1.01` for the same payload, so the two SDKs the conformance tests are driven from disagreed on input neither of them rejects. Truncating towards zero adopts the Python behaviour rather than the accidental one. The pass-through was an artifact of building an unparseable literal, not a declared rule, and the catalog gives no basis for ignoring a `decimals` its own schema accepts. The overflow pass-through that shares the guard is unchanged: a `decimals` large enough to shift the value to `Infinity` still returns it untouched, matching `_round` when `Decimal.quantize` raises `InvalidOperation`, and `round(1.5, 400)` still asserts it in both suites. The Python assertions are test-only, since that implementation already truncated. They are included here rather than split out because they are the parity target this fix is defined against, and the two suites are maintained line for line. One divergence is deliberately left open. At extreme negative `decimals`, `round(1.005, -1e9)` gives `0` here and `1.005` in Python, which bails out of `quantize` before rounding. Both are graceful degradation at inputs no agent would emit, and the result here is arguably the correct one, so reconciling them means choosing a winner rather than fixing a defect. Verified with `yarn workspace @a2ui/web_core test`: 285 of 285 pass. Reverting only the `Math.trunc` call fails the new assertion with `actual: 1.005, expected: 1.01`, so it is not vacuous. `uv run pytest` on `test_functions.py` passes 34. Lint reports 0 errors.
Signed-off-by: Clemens Schartmüller <46600871+ClemensSchartmueller@users.noreply.github.com>
Description
Summary
Closes #302, which asks for a way to declare client-side interactions where one
component affects another without a server roundtrip — the motivating case being
a slider that updates a preview immediately rather than re-prompting the model.
This needs no new protocol capability. The reactive machinery already exists:
bindings are re-evaluated when the data they read changes, and catalog functions
are invoked by name against a client-trusted implementation. What was missing was
that most of those functions were never declared in the basic catalog, so an
agent had no way to discover them and a strict catalog validator would reject
them. This PR closes that gap, fills in the numeric helpers the pattern needs,
and documents it.
Nothing executable crosses the wire — only catalog-declared function names whose
arguments are validated by JSON Schema — so the expression-evaluator risk raised
in the issue does not apply.
Instead of allowing a generic execution-lib, this declares (as previously existing) explicit functions and re-implements them to avoid (extenral) RCE issues. Additionally, this has the benefit of preserving the existing spec beyond what is mentioned in section Breaking changes.
What changed
Specification.
arithmetic,comparisonandstringfunctions are nowdeclared in
catalogs/basic/catalog.json; they were implemented all along butundeclared. Five new numeric functions are added:
clamp,round,min,maxand
abs.docs/a2ui_protocol.mdgains a section on reactive client-sidecomputation, and
37_live-calculator.jsondemonstrates the full loop with twoSliders drivingTextvalues throughadd,multiply,divide,clampandformatCurrency. All of this lands inv0_9,v0_9_1andv1_0, with thev0_9/v0_9_1catalogs and test cases staying byte-identical andv1_0keepingits
FunctionCommonwrapper and siblingreturnType. Every specification filein this PR is a pure addition — no existing schema entry, description or test
case was modified.
web_core. Implements the five functions for
v0_9.roundrounds halfwaycases away from zero (
-2.5→-3, notMath.round's-2) and shifts thedecimal exponent instead of multiplying by a power of ten, so
round(1.005, 2)is
1.01rather than the1.00that binary representation error would give.clampreturnsminwhenmaxis belowmin, as the catalog declares.a2ui_core. Mirrors those implementations in Python, using
DecimalwithROUND_HALF_UPto match the away-from-zero tie behaviour that the built-inround's banker's rounding does not provide. The API classes for the arithmetic,comparison and string functions are now generated from the catalog schemas rather
than hand-written, which removes the duplication that let the two drift apart.
Breaking changes
divideby zero now follows IEEE 754. The implementation special-cased azero divisor and returned positive
Infinityunconditionally, which contradictedthe catalog description already on
main. The sign of the dividend now carries,so
-1 / 0is-Infinityand0 / 0isNaN.1 / 0is unchanged. This isthe implementation moving to the spec rather than the spec moving, but it does
change rendered output for anyone who depended on the old value.
a2ui.core.basic_catalog.operator_apisis removed. Its classes are generatedinto
function_apisand re-exported froma2ui.core.basic_catalogunder thesame names with identical argument types, so importing from the package root is
unaffected; only a deep import of the submodule path breaks.
equalsandnot_equalskeep untypeda/barguments, matching thez.any()contract onthe TypeScript side, so
nullcomparisons and exact integer comparisons behaveas before.
Both are recorded in the respective
CHANGELOG.mdfiles.Test coverage
New unit tests cover the five functions in both
web_coreanda2ui_core,including the tie-rounding and
max < minedge cases, plus the correcteddivide-by-zero results.
function_catalog_validation.jsonexercises the newlydeclared function schemas across all three spec versions, and the
37_live-calculatorexample has renderer specs in Angular, Lit and React.Demo Videos
Angular
A2UI-C.1.MP4
Lit
A2UI-C.2.MP4
React
A2UI-C.3.MP4
Pre-launch Checklist
One time:
For this PR:
If you need help, consider asking for advice on the discussion board.