Skip to content

feat(reflect,mental-models): surface structured output in the control plane - #3113

Merged
nicoloboschi merged 16 commits into
mainfrom
feat/structured-output-reflect-mental-model
Aug 3, 2026
Merged

feat(reflect,mental-models): surface structured output in the control plane#3113
nicoloboschi merged 16 commits into
mainfrom
feat/structured-output-reflect-mental-model

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

What & why

Reflect's structured output (response_schemastructured_output) was fully implemented and tested in the engine but never surfaced in the control plane UI. This PR exposes it in the reflect (think) view, and extends the same structured-output extraction to mental models via a per-model response_schema stored in the trigger config.

The engine docstrings still claimed it was "not yet supported" — that was stale and is fixed here.

Changes

Engine / API

  • refresh_mental_model now reads trigger.response_schema, forwards it to the internal reflect call, and persists the parsed structured_output onto the stored reflect_response payload (alongside the markdown content).
  • Added response_schema to MentalModelTrigger (lives in the existing trigger JSON blob — no migration).
  • Fixed the stale "not yet supported" docstrings in memory_engine.py.

Control plane (the missing UI)

  • Reflect: api/reflect/route.ts + lib/api.ts forward response_schema; the think view gets a JSON-schema input (with invalid-JSON feedback) and renders structured_output in a card.
  • Mental models: both the create and update dialogs get a schema editor (options tab); the detail modal renders the stored structured_output (content tab) and the configured schema (configuration tab).

Generated / i18n / tests

  • Regenerated OpenAPI spec + client SDKs.
  • Added i18n keys across all 10 locales.
  • New test_mental_model_structured_output.py: verifies the trigger schema is forwarded to reflect and the parsed output is persisted (and that no schema ⇒ no structured output). Existing reflect structured-output tests unchanged.

Testing

  • tests/test_mental_model_structured_output.py — 2 passing (deterministic, reflect_async mocked)
  • Existing tests/test_reflect_agent.py::TestReflectStructuredOutput still green
  • ./scripts/hooks/lint.sh passes; control-plane tsc clean on changed files

Notes

  • Backend structured output for reflect was already correct — this PR is mostly the UI plus the mental-model wiring.
  • The SDK hand-written convenience wrappers already omit most trigger knobs (mode, fact_types, recall_*), so response_schema is intentionally not added there (no parity regression — neither wrapper exposed these).

Reflect's response_schema -> structured_output was already implemented and
tested in the engine but never exposed in the UI. Surface it in the reflect
(think) view, and extend the same structured-output extraction to mental
models via a per-model response_schema stored in the trigger config.

- engine: refresh_mental_model reads trigger.response_schema, forwards it to
  the internal reflect call, and persists the parsed structured_output onto
  the stored reflect_response payload; fix stale 'not yet supported' docstrings
- api: add response_schema to MentalModelTrigger
- control-plane: reflect route + api.ts forward response_schema; think-view
  gets a JSON-schema input and renders structured_output; create/update mental
  model dialogs get a schema editor; detail modal renders structured_output
- tests: mental model structured-output plumbing (schema forwarded + persisted)
- regenerate OpenAPI spec + client SDKs; add i18n keys for all locales
…config tab

Adds a read-only JSON card for the mental model's trigger.response_schema in
the detail modal's Configuration tab (mirrors the tag_groups card), plus the
regenerated go openapi.yaml.
…intl

The response_schema placeholder was a t() message whose value is literal JSON.
next-intl parses messages as ICU, so the '{' in the example was read as an
argument placeholder, the parse failed, and the field rendered the raw message
key instead of the example. Inline the JSON example directly on the placeholder
prop (i18n:check skips JSON-shaped placeholders) and drop the now-unused
*Placeholder message keys. Caught by running the control plane.
…ema builder

Validation (both reflect and mental models): a schema that is valid JSON but
not a usable object-with-properties silently produced empty structured_output
or blew up inside the LLM extraction call later. Now:
- engine: validate_response_schema() enforces the usable-shape contract
  (object schema, non-empty properties, well-formed required); wired as Pydantic
  field_validators on ReflectRequest.response_schema and
  MentalModelTrigger.response_schema (invalid -> HTTP 422).
- control-plane: the reflect and mental-model forms validate the schema shape on
  submit (not just JSON.parse) and surface the specific error.

No-code schema builder: a 'Build schema' button on both the reflect view and the
mental-model dialogs opens a dialog with Visual and Code modes. Visual mode edits
a flat field list (name, type, array item-type, description, required); Code mode
edits raw JSON. The two stay in sync and Apply is gated on a usable schema. Shared
frontend lib (response-schema.ts) mirrors the backend contract.

tests: test_response_schema_validation.py (16 cases: validator + model integration).
…status

Removes the inline response_schema JSON textarea from the reflect view and the
mental-model dialogs. Editing now happens exclusively in the schema builder; the
page shows only whether a schema is set (field count + names, with Edit/Remove)
or a Build schema button when none. Extracts the shared ResponseSchemaField
component used identically by reflect and both mental-model dialogs.
…reflect's answer

In delta mode reflect only sees facts created since the last refresh, so its
answer (and any structured_output it derived) reflects just the delta — while the
stored content is the delta-merged document. Persisting the reflect-derived value
made structured_output inconsistent with the markdown.

Now the mental-model refresh no longer passes response_schema to reflect; instead
it extracts structured_output from the FINAL stored content (correct for both full
and delta), and carries the previous value forward untouched when a delta refresh
preserves content (no new facts). Adds a delta test asserting extraction runs
against the merged document, not reflect's partial answer.
…o Visual

An empty schema serialises to properties:{}, which schemaToFields mapped to an
empty array — and the Code->Visual guard treated 'empty' the same as 'not
representable', blocking the switch. schemaToFields now returns [] (representable)
for a missing/empty properties map and null only for genuinely unrepresentable
schemas; the switch seeds a blank field when empty.
…builder

Adds a Structured Output section to the reflect docs: how response_schema returns
both text and a structured_output projection of the same answer, the schema rules,
mental-model structured output (extracted from the final/merged document), and the
no-code Build schema editor. Regenerates the docs-skill mirror.
…rays

The visual editor was flat — object/array fields had no way to define their inner
shape. Reworks the field model into a recursive tree (each field has a node; an
object node nests fields, an array node nests an item node) so you can build
nested objects and arrays-of-objects entirely in the visual editor. Code<->Visual
round-trips losslessly; schemas using features the editor can't represent (enum,
oneOf, $ref, tuple items, …) stay in code mode rather than being silently
flattened.
…resh loudly

Two problems surfaced by nested schemas on Gemini:

1. _generate_structured_output mapped object/array properties to bare dict/list,
   which serialize with additionalProperties — rejected by the Gemini API. So any
   schema with a nested object/array silently failed extraction. Now it builds a
   proper recursive Pydantic model (nested objects -> nested models, arrays ->
   typed lists), matching how retain's structured output already works on Gemini.

2. On extraction failure the mental-model refresh silently persisted content with
   no structured_output, clobbering the previously-stored value. Now, when a
   response_schema is configured and extraction yields nothing, the refresh raises
   MentalModelRefreshError — prior content and structured_output are preserved and
   the refresh can be retried.

Verified live on Gemini: a nested {location:object, people:array} schema now
extracts (structured_output present) instead of failing on additionalProperties.
Adds a fail-loud regression test.
text-destructive resolves to a dark red (#C0183A) in dark mode, which is
low-contrast on the dark dialog background. Use the codebase's standard
readable pattern (text-red-600 dark:text-red-400) for the builder's validation
error and the invalid-schema notice.
@nicoloboschi
nicoloboschi force-pushed the feat/structured-output-reflect-mental-model branch from d1093dd to b351d19 Compare August 3, 2026 13:06
Adding response_schema to MentalModelTrigger regenerated the Rust
MentalModelTriggerInput struct with a new field; the hand-written CLI struct
literals must initialize it (E0063). Sets response_schema: None in the three
construction sites (create/update mental model, knowledge-base pin).
…ext-empty claim; test schema lib

- api/mental-models: document the trigger.response_schema flag + a Structured
  Output section (extraction from final content, fail-loud, validation).
- api/reflect: correct the stale claim that text is empty with response_schema —
  reflect returns both text and structured_output.
- control-plane: vitest unit tests for the response-schema lib (validation +
  recursive fields<->schema round-trip).
- regenerate docs-skill mirror.
@nicoloboschi
nicoloboschi force-pushed the feat/structured-output-reflect-mental-model branch from 38d4dc1 to 3d6652e Compare August 3, 2026 13:21
…e_schema

The bank template schema embeds MentalModelTrigger; adding response_schema to
the trigger changed the generated schema. Regenerated so verify-generated-files
passes.
Resolves conflicts with #3119 (mental-model dry-run refresh + keep_trace), which
refactored refresh_mental_model into _execute_mental_model_refresh:
- MentalModelTrigger/CLI/api.ts/UI: keep both response_schema and keep_trace fields.
- memory_engine: re-apply structured-output extraction + fail-loud into the new
  refresh_mental_model persist path (dry-run/executor left untouched).
- locales: deep-merge both key sets (+ the new mentalModelDiagnostics namespace).
- regenerate openapi/clients/bank-template-schema/docs-skill for both new fields.
@nicoloboschi
nicoloboschi merged commit 4278f09 into main Aug 3, 2026
207 of 208 checks passed
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