Skip to content

Feature request: input-guard layer for LLM-authored component props #660

@Shinyaigeek

Description

@Shinyaigeek

Is your feature request related to a problem? Please describe.

LLM-authored props are flaky input — the model often emits values that don't
match the declared zod schema (wrong shape, an object where a string is
expected, or partial values mid-stream). Today these flow straight to the leaf
renderers and are blindly stringified, so malformed input becomes a
user-visible [object Object].

Concrete case: the DSL is positional — ListItem(title, subtitle, …), with arg
order derived from the zod object key order. LLMs frequently fall back to a
React/JSON habit and emit an object as the first arg:

ListItem({ title: "Inbox", subtitle: "12 unread" })

The whole object lands in the title slot, and the renderer does:

// packages/react-ui/src/genui-lib/ListBlock/index.tsx:28
const title = String(item?.props?.title ?? ""); // -> "[object Object]"

Root cause: zod schemas exist (ListItem.title: z.string(),
FollowUpItem.text: z.string(), …) but are not enforced as value-level
validators
. In packages/lang-core the schema is used only for positional-arg
→ named-prop mapping, prompt generation, and required-field error enrichment —
there is no safeParse/coercion of leaf prop values, so a wrong-typed value
passes through untouched and is String()-ed at the leaf.

It is not a one-off; the same pattern recurs:

  • genui-lib/ListBlock/index.tsx:28,29,31 (title, subtitle, actionLabel)
  • genui-lib/FollowUpBlock/index.tsx:22 (text)
  • genui-lib/SectionBlock/index.tsx:69,80,81 (trigger, value)
  • genui-lib/TextContent/index.tsx:32 (text)

In my usecase, integrators currently work around this by defensively overriding
ListBlock/FollowUpBlock in their own libraries.

Describe the solution you'd like

A single, named input-guard layer for LLM-authored props, with two
complementary mechanisms (defense-in-depth):

  1. Validate-time guard — run the existing zod schema against leaf prop
    values and surface mismatches through the existing onError correction loop
    (today used for missing-required props / tool-not-found). Can reject and
    route to self-correction — but cannot reject mid-stream, where partial props
    are normal.
  2. Render-time guard — a documented coercion contract (not a hidden helper)
    that turns an arbitrary leaf value into display text: object →
    .title/.text/.label/.value fallback, arrays handled,
    null/undefined"", and never [object Object]. This is the last line
    of defense for streaming and for anything that slips past validation, where
    salvage is the only option. Replace the raw String(...) calls in the
    leaf/block components above with this contract.

Key design point: salvage is never silent — when the render-time guard
salvages (or validation fails), it reports through the same onError channel,
so the mismatch stays visible to telemetry and the LLM-correction loop instead
of being papered over.

Neither layer subsumes the other, so a render-time guard is still needed even
after validation lands.

Describe alternatives you've considered

  • Just fix the two leaf components with a String() replacement. This is the
    current integrator workaround and stays ad-hoc — it relabels the patch without
    defining a contract, doesn't report mismatches, and every integrator keeps
    re-applying it. Rejected in favor of a first-class, overridable guard.
  • Validate-time only (hard parse error). Cannot handle streaming, where
    partial/incomplete props are expected — would reject valid in-progress UIs.
  • Render-time only (silent salvage). Hides malformed model output and removes
    the chance for the self-correction loop to fix it. Hence "salvage + report".

Additional context

Open questions for the design discussion:

  • Where should the coercion contract live — genui-lib/helpers.ts, or a
    first-class export so integrators can import/override it (the structural fix
    for "every integrator re-patches")?
  • Should validate-time mismatches be a hard parse error or a soft error
    (render salvaged value + report)? Streaming probably forces soft.
  • What is the canonical object→text fallback order
    (.title.text.label.value?), and should it be configurable?

I think these point should be considered in tackling this issue concretly

Non-goals: changing the positional-arg DSL itself; schema changes beyond
enforcing existing zod types.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions