Skip to content

feat(spec): declare the basic catalog functions and add clamp, round, min, max and abs, add live-calculator example ( fixes #302 ) - #2149

Open
ClemensSchartmueller wants to merge 11 commits into
a2ui-project:mainfrom
ClemensSchartmueller:302-clientsidefunctions
Open

feat(spec): declare the basic catalog functions and add clamp, round, min, max and abs, add live-calculator example ( fixes #302 )#2149
ClemensSchartmueller wants to merge 11 commits into
a2ui-project:mainfrom
ClemensSchartmueller:302-clientsidefunctions

Conversation

@ClemensSchartmueller

@ClemensSchartmueller ClemensSchartmueller commented Aug 1, 2026

Copy link
Copy Markdown

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, comparison and string functions are now
declared in catalogs/basic/catalog.json; they were implemented all along but
undeclared. Five new numeric functions are added: clamp, round, min, max
and abs. docs/a2ui_protocol.md gains a section on reactive client-side
computation, and 37_live-calculator.json demonstrates the full loop with two
Sliders driving Text values through add, multiply, divide, clamp and
formatCurrency. All of this lands in v0_9, v0_9_1 and v1_0, with the
v0_9/v0_9_1 catalogs and test cases staying byte-identical and v1_0 keeping
its FunctionCommon wrapper and sibling returnType. Every specification file
in 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. round rounds halfway
cases away from zero (-2.5-3, not Math.round's -2) and shifts the
decimal exponent instead of multiplying by a power of ten, so round(1.005, 2)
is 1.01 rather than the 1.00 that binary representation error would give.
clamp returns min when max is below min, as the catalog declares.

a2ui_core. Mirrors those implementations in Python, using Decimal with
ROUND_HALF_UP to match the away-from-zero tie behaviour that the built-in
round'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

divide by zero now follows IEEE 754. The implementation special-cased a
zero divisor and returned positive Infinity unconditionally, which contradicted
the catalog description already on main. The sign of the dividend now carries,
so -1 / 0 is -Infinity and 0 / 0 is NaN. 1 / 0 is unchanged. This is
the 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_apis is removed. Its classes are generated
into function_apis and re-exported from a2ui.core.basic_catalog under the
same names with identical argument types, so importing from the package root is
unaffected; only a deep import of the submodule path breaks. equals and
not_equals keep untyped a/b arguments, matching the z.any() contract on
the TypeScript side, so null comparisons and exact integer comparisons behave
as before.

Both are recorded in the respective CHANGELOG.md files.

Test coverage

New unit tests cover the five functions in both web_core and a2ui_core,
including the tie-rounding and max < min edge cases, plus the corrected
divide-by-zero results. function_catalog_validation.json exercises the newly
declared function schemas across all three spec versions, and the
37_live-calculator example 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:

  • I have updated the relevant CHANGELOG.md file.
  • I updated/added relevant documentation.
  • My code changes (if any) have tests.
  • If my branch is on a fork, I have verified that scripts/e2e_test.sh passes.

If you need help, consider asking for advice on the discussion board.

…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ClemensSchartmueller

ClemensSchartmueller commented Aug 1, 2026

Copy link
Copy Markdown
Author

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>
@ClemensSchartmueller ClemensSchartmueller changed the title WiP: feat(spec): declare the basic catalog functions and add clamp, round, min, max and abs ( fixes #302 ) feat(spec): declare the basic catalog functions and add clamp, round, min, max and abs, add live-calculator example ( fixes #302 ) Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to declare client-side event handling where one component can affect another component

1 participant