Skip to content

feat(agent): add local decision-model UI synthesizer (a2ui.inference_formats.decision) - #3339

Draft
gspencergoog wants to merge 30 commits into
a2ui-project:mainfrom
gspencergoog:feat/decision-models
Draft

gspencergoog wants to merge 30 commits into
a2ui-project:mainfrom
gspencergoog:feat/decision-models

Conversation

@gspencergoog

@gspencergoog gspencergoog commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds a2ui.inference_formats.decision, a catalog-agnostic local decision-model inference format and UI synthesizer for a2ui-agent-sdk. Agent servers can route component selection and data-binding decisions through an in-process ONNX cross-encoder or HTTP endpoint. Low-confidence queries fall back to Express DSL, a callback, or a summary card.

Reviewer Focus

  • Review synthesizer.py to check the three-stage routing pipeline (TEMPLATE_SELECT, FAST_1PASS_LEAF, CONTAINER_2PASS, and ABSTAIN_FALLBACK). Check the allow_actions safety gates and the fallback order (fallback_dsl, fallback_fn, and fallback_card).
  • Review format.py, parser.py, prompt_generator.py, and tool.py to check how DecisionFormatFactory integrates with A2uiRequestProcessor and A2uiGenerator and how DecisionRenderTool exposes synthesis to tool-calling agents.
  • Review backends.py to check create_decision_backend, off-thread ONNX inference in LocalCrossEncoderBackend, /v1/systemone wire support and ADC token caching in JevHttpBackend, and per-loop httpx.AsyncClient cleanup in _HttpClientOwner.
  • Review catalog_graph.py and calibration.py to check runtime schema inspection and the threshold sweep in calibrate_thresholds.
  • Test files under python/a2ui_agent/tests/decision/ contain self-contained domain catalog fixtures and can be skimmed after reviewing the core modules.

Changes

  • Added DecisionFormat, DecisionFormatFactory, DecisionParser, DecisionPromptGenerator, and DecisionBlock (format.py, parser.py, prompt_generator.py, decision_block.py). A2uiRequestProcessor(catalogs, format_factory=DecisionFormatFactory()) and A2uiGenerator compile and decompile <a2ui> decision blocks while sharing a single backend across format instances.
  • Added DecisionRenderTool in tool.py to generate a JSON Schema tool declaration with run_async and run_sync execution methods for MCP, ADK, and function-calling agents.
  • Added CatalogDecisionGraph in catalog_graph.py and inspect_payload_pointers in payload_inspector.py to classify component slots (SlotKind), checkable functions, and RFC 6901 JSON Pointers directly from CatalogApi schemas.
  • Added SurfaceTemplate and TemplateRegistry in template_registry.py for Stage 1 template matching. Added promote_synthesized to promote v0.8, v0.9, and v1.0 message envelopes.
  • Added DecisionBackend, LocalCrossEncoderBackend, JevHttpBackend, StepZeroLogprobBackend, DeterministicMockBackend, and create_decision_backend in backends.py, along with optional [decision-local] dependencies (onnxruntime, tokenizers, huggingface_hub, numpy) in pyproject.toml.
  • Added DecisionSynthesizer in synthesizer.py with from_env(), synthesize(), and synthesize_sync(). It supports direct template_id rendering, string and list payload coercion (coerce_decision_payload), and checked fallbacks (FallbackSource.DSL, FallbackSource.CALLBACK, and FallbackSource.CARD).
  • Added calibrate_thresholds and compute_bayes_optimal_threshold in calibration.py for threshold selection over labeled datasets.
  • Exported the public facade in a2ui.inference_formats.decision and a2ui.inference_formats, and documented the format and A2UI_DECISION_* environment variables in python/a2ui_agent/README.md, src/a2ui/inference_formats/README.md, and python/a2ui_agent/CHANGELOG.md.

Impact & Risks

  • This addition is isolated to a2ui.inference_formats.decision and optional [decision-local] dependencies, leaving existing Direct JSON and Express parsers unchanged.
  • Uncalibrated logprob backends default to calibrated=False and require top_logit_margin >= uncalibrated_min_logit_margin before a local prediction is accepted.
  • Surfaces with mutable_paths or Action slots require confidence >= mutable_confidence_threshold (0.85 by default) before action-bearing components or slots can be synthesized.
  • Merged upstream/main (c4e0e0697); if this PR is squash-merged, record ancestry with git merge -s ours c4e0e0697 before the next branch sync.

Testing

  1. Run the decision synthesizer unit and end-to-end test suite across custom Finance, Healthcare, and DevOps catalogs:
    uv run --no-sync pytest python/a2ui_agent/tests/decision/
  2. Run the full a2ui_agent test suite (including framework-agnostic isolation tests):
    uv run --no-sync pytest python/a2ui_agent/tests/
  3. Run static type checking and formatting checks across a2ui_agent:
    uv run --no-sync mypy python/a2ui_agent
    uv run --no-sync pyink --check python/a2ui_agent/src/a2ui/inference_formats/decision python/a2ui_agent/tests/decision

…_formats.decision`)

Adds a catalog-agnostic local decision-model synthesis pipeline for A2UI v0.9 and v1.0 surfaces in `a2ui-agent-sdk`. Agent servers can route template selection, leaf binding, and container assembly through a local classifier or logprob endpoint. Low-confidence predictions fall back to a foundation model.
gemini-code-assist[bot]

This comment was marked as resolved.

Review feedback on PR a2ui-project#3339 identified an async-safety hazard when
calibrating thresholds on a shared synthesizer instance, a zero logit
margin on single-candidate step-0 logprob queries, unguarded float
conversions during probability normalization, and sub-optimal pointer
selection and camelCase formatting in check rules.

- Add an optional `config` override parameter to `synthesize()` and pass
  the permissive configuration per call in `_collect_evaluated_outputs()`
  without mutating `synthesizer.config`.
- Set `top_logit_margin` to `float("inf")` in
  `StepZeroLogprobBackend.predict` when only a single candidate is
  present.
- Catch `(ValueError, TypeError)` when converting probability values in
  `_normalize_probabilities`.
- Rank matched pointers by token overlap for optional `any` slots in
  `_select_optional_pointer` and select check function argument pointers
  via `_best_pointer_by_tokens` in `_default_arg_value`.
- Preserve camelCase function names when formatting check failure
  messages in `_build_checks`.
- Add unit tests covering each fix in `test_backends.py`,
  `test_calibration.py`, and `test_synthesizer.py`.
… backend

Enrich DecisionQuery context with catalog property descriptions and slot
metadata, enabling backends to score candidates without filesystem access.

Add an in-process LocalCrossEncoderBackend powered by ONNX Runtime under
the optional [decision-local] extra in a2ui-agent-sdk, allowing local CPU
decision modeling without external FastAPI sidecars or PyTorch.

Extend JevHttpBackend with native /v1/systemone format support, structured
choice query building, and non-blocking cached Application Default Credentials
(ADC) token resolution.

Add create_decision_backend() and DecisionSynthesizer.from_env() for
environment-driven backend initialization.
…rgonomics

Plug the decision format into the existing a2ui_agent pipeline and remove
the plumbing every adopter had to write by hand.

- DecisionFormat / DecisionFormatFactory implement InferenceFormat and
  InferenceFormatFactory, so A2uiRequestProcessor(catalogs,
  format_factory=DecisionFormatFactory()) works like the Express and
  Direct JSON factories. DecisionPromptGenerator asks for <a2ui> decision
  blocks and DecisionParser renders them through DecisionSynthesizer.
- DecisionRenderTool supplies a JSON Schema declaration and run_async /
  run_sync runners for MCP, ADK, and function-calling agents. A shared
  DecisionBlock keeps the prompt, parser, and tool on the same field names
  and decodes stringified payloads.
- DecisionSynthesizer keeps every existing signature and adds an optional
  backend (defaulting to create_decision_backend()), CatalogDecisionGraph
  input, synthesize_sync() for callers inside or outside a running loop,
  explicit template_id, JSON-string and array payloads, and abstention
  fallbacks (fallback_dsl, fallback_fn, fallback_card) reported through
  SynthesisOutput.fallback_source. The format and tool default
  fallback_card to True because an empty render is an error there.
- DecisionTask, DecisionWireFormat, and DecisionBackendType replace raw
  string literals. They override __str__/__format__ because on Python
  3.11+ an f-string of a plain (str, Enum) member yields Class.MEMBER,
  which broke the mock backend's scripted lookups.
- Backends support async with; HTTP backends re-create their httpx client
  per event loop and JevHttpBackend caches ADC tokens per instance instead
  of in a module global.
- TemplateRegistry.promote_synthesized reads v1.0 createSurface envelopes
  with embedded components and v0.8 surfaceUpdate envelopes.
- Decompose create_decision_backend (complexity 34 to 8) and
  _choose_child_component (30 to 4).
Two defects and several ergonomic refinements from the second API review
of a2ui.inference_formats.decision.

- DecisionBackendType.ONNX was a member that create_decision_backend
  rejected; it is now an alias of the local backend and appears in the
  error message's choices.
- DecisionFormatFactory built a new backend for every create_format call,
  so A2uiGenerator would have loaded one ONNX model per renderer request.
  The factory now resolves the backend once and shares it; the catalog
  graph is still built per format because catalogs are negotiated per
  renderer.
- Drop parse_json_strings from coerce_decision_payload, synthesize, and
  synthesize_sync: a string payload is never valid undecoded, so the flag
  only turned a usable input into an error.
- SynthesisOutput.fallback_source is a FallbackSource enum (DSL, CALLBACK,
  CARD) instead of a three-valued str; members still compare equal to the
  previous strings.
- DecisionWireFormat gains AUTO so JevHttpBackend.wire_format is no longer
  a Literal | Enum union.
- DecisionQuery.task is annotated DecisionTask | str and FallbackCallback
  may return wire-format dicts, which to_message_models already accepted.
- DECISION_RULES and the tool's payload schema state that a top-level array
  is stored under the items key; surface-id docstrings explain precedence.
- DecisionRenderTool.from_env is renamed create: it takes an explicit
  backend, so the old name no longer described it. Its only caller is the
  a2ui-pane worker, updated on its branch.
… roundtrip validation

- Manage owned httpx.AsyncClient instances per event loop in _HttpClientOwner and close temporary clients inside DecisionSynthesizer.synthesize_sync via aclose_current_loop()
- Add aclose() and async context manager methods to DecisionBackend and DecisionFormatFactory
- Offload LocalCrossEncoderBackend._ensure_loaded() inside asyncio.to_thread(_infer_sync) and serialize JevHttpBackend ADC refresh with an asyncio.Lock
- Enforce allow_actions gating across leaf, child, and fallback synthesis and abstain cleanly when a container has no eligible child candidates
- Validate rendered templates and fallback_fn messages against catalog schemas via validate_payload
- Emit non-empty synthetic intent in decision_block_from_messages, validate prompt examples in DecisionPromptGenerator.generate(), and assign distinct default surface IDs across multi-block arrays in DecisionParser.compile()
- Re-evaluate Stage 1 shadowing during threshold calibration, clamp recommended_mutable_threshold >= best.threshold, and normalize v0.8 component envelopes in TemplateRegistry.promote_synthesized
Land the data contracts and shared types that the archetype-driven layout
stage builds on, so later tracks can work on disjoint modules.

Contracts: catalogs/decision/v1 holds the catalog-agnostic archetype grammar
(14 archetypes, 6 families, 19 blocks) with JSON Schemas for it and for
per-catalog pattern files; catalogs/basic/v1/patterns.json says how the
basic catalog draws 17 abstract patterns and lists both the v1.0 and v0.9
basic catalog ids. The Python SDK vendors copies under decision/assets/,
kept identical by scripts/sync_decision_assets.py (--check) and a test.

Shared types: QuestionKind, DecisionQuestion, DecisionAnswer, DecisionBatch,
DecisionRecord, SemanticType, plan and binding types, PatternCapabilities,
LayoutBrief.parse, new DecisionTask members, SynthesisStage.ARCHETYPE, and
validated SynthesisConfig fields. DecisionBackend gains answer(batch); the
_AnswerViaPredict mixin derives it from predict() for all four backends.
bindings.py takes the wire helpers out of the synthesizer. PointerSummary
gains defaulted semantic fields plus key and parent. archetypes.py loads,
validates, and lints the grammar.

Two deviations from the draft, both found by validation: the form block's
range control required min and max fields that did not exist, so they were
added; and the collection knob note moved to the pattern note so knobs stay
a name-to-values map.
Resolves the round 3 API review findings so the layout grammar surface has
one owner per concept and no hidden dependency on the default grammar.

- Thresholds: the grammar's `thresholds` are the single source of truth.
  `SynthesisConfig` drops `action_gate_floor`, `action_gate_cap`,
  `expected_block_threshold`, `optional_block_threshold`, and `reask_below`
  in favor of `threshold_overrides`, and `LayoutPlanner.threshold(name)`
  resolves every threshold through one path. `ArchetypeGrammar.from_json`
  lints `actionGateFloor <= actionGateCap` and `expected <= optional`.
- Custom grammars: `DecisionBlock.layout` carries raw `role:key,key` items
  and the synthesizer validates them against its own grammar;
  `DecisionPromptGenerator(archetypes=...)` and `decision_rules(grammar)`
  render the prompt roles per grammar instead of at import time, and
  `DECISION_RULES` is resolved lazily.
- Backends: `PredictOnlyBackend` replaces the private `_AnswerViaPredict`
  mixin, `ensure_batch_backend` adapts predict-only objects, and
  `aclose_current_loop` is part of `DecisionBackend` so the synthesizer and
  `BatchingDecisionBackend` no longer duck-type it.
- Eligibility: `eligible_archetypes` is pure with a single `pointers`
  parameter; the bounded per-synthesizer cache replaces the module-global
  dict keyed on `id(grammar)`.
- Patterns: `MergedPatternLibrary.for_catalogs` replaces the classmethod on
  `PatternLibrary` that returned the other class, the `has()` alias is gone,
  `PatternRef` and `ResolvedPattern` replace tuple returns and string
  splitting, and `name:variant` is the only pattern-plus-variant spelling
  (`requires` entries now read `pattern:collection:grid`). The unimplemented
  `repeat-in-parent` and `first-item` fallback keywords are removed from the
  grammar and rejected by lint.
- Types: `PointerSummary.semantic_type`, `element_semantic_types`, and
  `SlotBinding.semantic_type` are `SemanticType`; the deferred Place and
  composite names join the enum; `PlanReason`, `Requirement`, `LintCode`,
  and `LintSeverity` are string enums. `_WireEnum` hashes by its wire value
  so members interoperate with string sets and dict keys.
- Signatures: `PatternExpander(protocol_version=)` replaces `is_v10`,
  `expand_page(page: PageBindings, ...)` replaces the loose slot parameters,
  `expand_block` takes only the nested `PlannedBlock.instances` shape,
  `build_batch` makes `eligible`, `brief`, and `payload` keyword-only,
  `BatchingDecisionBackend(max_pairs=None)` takes the budget from
  `SynthesisConfig.max_pairs` via the factory, and `BRIEF_ITEM_PATTERN` is
  shared by the parser and the tool schema.
- Facade: exports `PlannerState`, `PredictOnlyBackend`, `ensure_batch_backend`,
  `PatternRef`, `ResolvedPattern`, `PlanReason`, `Requirement`, `LintCode`,
  `LintSeverity`, `classify_array_field`, `humanize_key`, `decision_rules`,
  `resolve_fallback_pattern`, `shape_signature`, `BRIEF_ITEM_PATTERN`, and
  `MAX_BRIEF_SECTIONS`.
A chat-style ADK/A2A sample (`samples/agent/adk/format_playground`, port
10010) for trying the three inference formats on the same request. Stage 1
is one model call that turns "show my stock portfolio" into a decision block
(`intent`, `payload`, optional `layout`) using `decision_rules(grammar)`.
Stage 2 renders it through the chosen mode: `decision` compiles the block
with `DecisionSynthesizer` and makes no further model call; `express` and
`direct` make a second call with that format's own prompt and the same
payload, validated through the format's processor with one retry.

Slash commands `/mode`, `/compare` (all three modes from one payload),
`/trace` (the planner's questions, answers, and thresholds), and `/help`.
Every turn ends with a footer naming the mode, decision backend, model
calls, latency, tokens, and (for decision) the archetype and confidence.
Because the Lit sample shell renders surfaces only and clears them per
response, footers, help, trace, and errors are emitted as small A2UI status
surfaces compiled from Express, so the agent stays client-agnostic.

The executor accepts v0.9 `action` and `userAction` DataParts (fed to stage
1 as text so forms round-trip) and treats any other DataPart as a pasted
payload, which is how the shell sends a bare JSON object. The decision
backend comes from `A2UI_DECISION_BACKEND` via `create_decision_backend()`
and is named in `/help` and every footer. `LlmCaller` abstracts the ADK
runner so the 44 tests run with a scripted model and
`DeterministicMockBackend` only.

Registers the sample as a uv workspace member and pytest testpath, and adds
a `playground` config to the Lit shell (`?app=playground`).
# Conflicts:
#	catalogs/README.md
#	python/a2ui_agent/CHANGELOG.md
#	python/a2ui_agent/README.md
…riety sampling with remix

- Add thread tuning (defaulting to CPU core count), sequence length truncation, summary pointer limits, length-based pair sorting, and dynamic batch padding to LocalCrossEncoderBackend.
- Support A2UI_DECISION_THREADS and A2UI_DECISION_MAX_SEQ environment configuration in create_decision_backend.
- Implement VarietyMode.NEAR_TOP sampling across anatomy and instance choices with configurable variety_margin and seed control.
- Support authored copy (title and summary) in layout planning and slot binding.
- Add zero-decision-call remix and remix_sync methods to DecisionSynthesizer for instant layout re-sampling.
Introduce ADK tool mode, zero-model-latency tool rendering, /rate and
/ratings commands, and ratings JSONL storage to the format playground.
…d measurements

- Document tool-mode first across inference formats and playground READMEs.
- Add /remix [seed] command to re-sample alternative layouts at zero model cost.
- Wire decision strategy into eval runner and assemble format-agnostic prompts.
- Record empirical benchmark measurements for local ONNX, Jev, and Inspect AI.
…e local batching

Ensure every domain pointer emitted into updateDataModel is visibly bound in
updateComponents and cut local/step-zero question batching overhead:

- Add apply_no_orphaned_pointers in LayoutPlanner (after apply_urgency_terse)
  so top-level scalar pointers not bound by page/headline/prose/inputs/toggles
  are bound into facts even when gate:facts votes below threshold.
- Widen collection and steps element slot preferences, add avoidKeys for id and
  currency, run three-pass element binding (required -> preferred optional ->
  fallback optional with redundant duplicate suppression), unroll primitive
  arrays into indexed sub-pointers, and walk shape-eligible family/archetype
  fallbacks when the top choice lacks required pointers.
- Add build_stage1_batch and build_stage2_batch to LayoutPlanner and wire
  two-stage hierarchical batching into DecisionSynthesizer (enabled by default
  for LocalCrossEncoderBackend and StepZeroLogprobBackend).
- Extend StepZeroLogprobBackend with /v1/chat/completions and auth support, and
  add --backend step0, --two-stage, and --single-stage to decision_bench.py.

This branch has not been deployed

No deployments
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.

1 participant