Skip to content

Commit ca0dfb6

Browse files
feat(spec): offer agent.structuredOutput on the agent form and drop its stale not-enforced-yet ledger row (#21398)
Fixes #21374 Clause-②: no The form reconciliation ledger kept `agent.structuredOutput` unoffered on a reason that stopped being true: the key is enforced and graded `live`. This PR deletes that row and decides the offer on a measurement, as triage `5948883894` directed. The measurement says the Studio `composite` control carries the block, including its free-form JSON Schema record, so the key is offered. ## What changes | file | change | |---|---| | `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts` | The `agent` / `structuredOutput` `omit` row (base lines 408 to 414) is deleted. The gate's own rule (lines 307 to 309) gives this decision to the enforcement. No other line changes, and nothing new checks a row's `why` text. | | `packages/spec/src/ai/agent.form.ts` | One row in the AI Configuration section: `{ field: 'structuredOutput', type: 'composite', helpText }`. It is spelled like `memory` and `guardrails`, with no hand-written `fields`, so Studio derives the sub-rows from the served JSON Schema. | | four `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts` | Regenerated with `pnpm i18n:extract`. Two new leaves per locale: the row's label and help text. The `zh-CN`, `ja-JP` and `es-ES` leaves are authored, not left as copies of the English source. After the second extract, no locale's `source-hashes.generated.ts` holds an entry for them, so those three files are not in the diff. | | `packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts` | The per-locale translated-label control moves from 659 to 660. The test measured 660 (`expected 660 to be 659`) before the pin was edited. | | `.changeset/21374-agent-structured-output-form-offer.md` | `@objectstack/spec` minor (a new form offer), `@objectstack/platform-objects` patch (two catalog leaves). Neither is breaking. | ## The measurement (triage item 2) **Where it was read:** objectui at the `.objectui-sha` pin `31971ff1e28f`, from a local clone with `git show`. `main` moved the pin to `89cad75d5570` while this was in flight. `SchemaForm.tsx` and `widgets.tsx` are byte-identical between the two pins (`git diff --stat` is empty), and `merge-base --is-ancestor 31971ff1e28f 89cad75d5570` exits 0. So the same reading holds at the pin this branch now carries. **What the control is given:** the served node, measured with the emitter's own code. `z.toJSONSchema` was run with `markErasedAuthoringInput`, then `stripUnauthorableProperties`: the steps `toJsonSchemaSafe` in `metadata-protocol` takes. `agent` takes the output arm (24 top-level keys), and `structuredOutput` is served inline, with no `$ref`: | child | served node | |---|---| | `format`, `fallbackFormat` | `type: string`, `enum: [json_object, json_schema]` | | `schema` | `type: object`, `propertyNames: {type: string}`, `additionalProperties: {}`, and no `properties` | | `strict`, `retryOnValidationFailure` | `type: boolean` | | `maxRetries` | `type: integer`, `minimum: 0` | | `transformPipeline` | `type: array`, `items: {type: string, enum: [trim, parse_json, validate]}` | **How a `composite` row renders** (`packages/app-shell/src/views/metadata-admin/`, at the pin): 1. `SchemaForm.tsx` `resolveFieldFace` (line 837): `fieldSpec.type === 'composite'` gives `{ kind: 'composite' }`. 2. `FieldControl` (lines 2081 to 2099): the row has no `fields`, so `derivePropertyNames(schema)` (line 3376) lists every property of the served node. `CompositeField` (line 2518) renders one `FieldRow` per property, with the child node from `pickSubSchema(schema, 'composite', name)` (line 2487). A child edit writes `onChange({ ...obj, [field]: v })`, and an untouched child is never written. 3. Each child's face is decided by `resolveFieldWidget`, then `resolveFieldFace`: - **`schema`, the open record:** `inferWidget` (line 519) answers `object-fields` for `type: object`, and no name detector matches. `object-fields` is not a key of `WIDGETS` (`widgets.tsx` line 2934). `isObjectForm` is false (no `properties`), the node is not an object-row array, and the name is not in `KNOWN_PASSTHROUGH_WIDGETS` (line 230). So the face is `{ kind: 'raw-json', hint: 'object-fields' }`, which is `RawJsonEditor` (line 3218). The editor shows `JSON.stringify(value, null, 2)`. Each edit runs `JSON.parse` and passes the parsed value up. Text that does not parse shows `Invalid JSON` and passes nothing, and empty text passes `undefined`. - **`transformPipeline`, the enum array:** `inferWidget` (line 510) answers `multiselect`, a registered widget, so `MultiSelectWidget` renders it (`widgets.tsx` line 1396). Its options are `items.enum`. `toggle()` (lines 1428 to 1443) rebuilds the selection as `options.map(o => o.value).filter(v => set.has(v))`: declared enum order, duplicates collapsed, and an empty selection passed up as `undefined`. - `format` and `fallbackFormat` are selects over `enum`. `strict` and `retryOnValidationFailure` are switches, and `maxRetries` is a number input. **Round-trip verdicts** ("carries" means view it, edit it and save it back unchanged): - **`schema`: carries.** The stored record is shown whole as JSON. An untouched value is never re-emitted, so it saves back byte-equal. An edited value saves exactly the JSON that was typed. The parse still refuses an untyped subschema at its path, and that refusal is unchanged. - **`transformPipeline`: carries the step set.** An untouched value saves back unchanged. A toggle writes the steps in declared enum order (`trim`, `parse_json`, `validate`) with duplicates collapsed, so this control cannot author another order. Every pipeline written in this repo is already in enum order (`git grep transformPipeline`: the agent tests and the conversion fixtures). This repo cannot read whether the cloud runtime honours another order, so this PR makes no claim about it. - **Precedent, the same slot:** `action.ai.outputSchema` comes from the same `aiJsonSchemaSlot` factory. It is served with an identical node and is already offered through the action form's `ai` row, where it reaches the same raw-JSON face. Offering `structuredOutput` adds no new face. ## Verification All runs are on this branch. Gates and suites are at the merged head `5870ff91d7` (`origin/main` `69a12a0952` merged in, with a full `turbo run build --filter='./packages/**'` of 71 tasks first). **Ablation of the pin.** Two legs, both written with `scripts/ablation-replace.mjs` (anchor hit, then blob restored equal to `HEAD` and `git diff HEAD` empty), run at `77dedb2c86`. The suite was `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/metadata-form-zod-reconciliation.test.ts`, which imports the form registry from source, so no build sits between the edit and the test. - **Leg A**, the offer removed with the row still deleted: red, 1 failed and 75 passed, `agent.(root): accepted by the Zod but unauthorable in the form … expected [ 'structuredOutput' ] to deeply equal []`. - **Leg B**, the offer kept with the stale row re-inserted: red, 1 failed and 75 passed, `agent.(root).structuredOutput: the form offers it now — drop the ledger entry`. The first attempt at leg B was a no-op: the tool refused it because the replacement contained its own anchor, so no test ran. The retry used an anchor the replacement does not contain. - Restored, both files' blobs equal `HEAD`: `f37667ab25a0` (form) and `2350eaed10be` (test). **Suites:** | suite | result | |---|---| | `pnpm --filter @objectstack/platform-objects test` | `Test Files 59 passed (59)` · `Tests 949 passed (949)` | | `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2` (whole package) | `Test Files 647 passed (647)` · `Tests 18407 passed \| 1 todo (18408)` | | `pnpm --filter @objectstack/spec --filter @objectstack/platform-objects run typecheck` | both `Done` | | `pnpm --filter @objectstack/spec check:generated` | `✓ All 15 generated artifacts are up to date` | **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 87 commands against merge base `69a12a095`, with no stale-tree warning. Each was run, and its exit code was recorded before any pipe. `--ran` reconciliation: `87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN`. This includes `pnpm check:i18n` (`platform-objects in sync (11 bundle(s))`), `pnpm check:doc-authoring`, `pnpm check:nul-bytes`, the changeset gates (`check-adr-0087-registration`: no declared-breaking changeset; `check-changeset-no-major`: no `major`) and `pnpm check:i18n-coverage` (`621 baselined untranslated string(s), none new`, run in addition to the derived list). **Semver (A5):** `Clause-②: no`. `AgentSchema`'s accept set does not move, and `check:authorable-surface` and `check:api-surface` are green with no regeneration. The form definition ships in `@objectstack/spec`'s `dist`, and the catalogs ship in `@objectstack/platform-objects`'s `dist`. Both new leaves were found in the built `platform-objects` `dist`. So both packages publish a change, and this is a changeset, not `skip-changeset`. ## Acceptance notes Observations only. None meets a filing class here. - **The `schema` child's hint.** It shows objectui's announced fallback line, `widget object-fields — falling back to JSON until a custom renderer is registered.` The action form's `ai.outputSchema` shows the same line today. Polish on the objectui side. - **English sub-row labels.** The seven sub-row labels come from the served schema (`prettify(name)`, help text from each `describe()`), so they read in English in every locale. `memory`'s and `guardrails`' sub-rows read the same way: a schema-derived composite has no catalog key for its children. - **Stale neighbouring help texts in `agent.form.ts`.** `planning` names "strategy, max iterations, replan" (the schema declares only `maxIterations`). `memory` names "short-term" (removed, with a refusal and guidance on the key). Nobody owns them yet, so they are noted, not filed. - **Unenforced neighbours are offered.** `memory` and `lifecycle` are offered as composites while their describes say `[EXPERIMENTAL — not enforced]`, the reverse of this ledger's not-enforced-yet discipline. That discipline is a reconciliation-ledger convention, not a published contract, and this card does not touch them. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8598614 commit ca0dfb6

8 files changed

Lines changed: 33 additions & 8 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
The agent metadata form now offers `structuredOutput`, the output contract the cloud AI runtime enforces on every final answer. It is a `composite` row in the AI Configuration section, spelled like the `memory` and `guardrails` rows: Studio derives its seven sub-rows from the served JSON Schema.
7+
8+
Clause-②: no
9+
10+
- Before this, the block had no row on the agent form, so the only way to author it in Studio was the Source tab. The form's reconciliation test excused that with a ledger row saying the key was declared but not enforced. The key has been enforced since the structured-output enforcement landed (liveness `live`), and that row is gone.
11+
- What Studio renders, read in the console's metadata form renderer: `format` and `fallbackFormat` are selects over `json_object` / `json_schema`. `strict` and `retryOnValidationFailure` are switches, and `maxRetries` is a number. `transformPipeline` is a multi-select over `trim` / `parse_json` / `validate`. `schema`, the free-form JSON Schema record, is a JSON text editor: the stored value is shown as JSON and saved back as parsed. That is the same editor the action form already gives `ai.outputSchema`, which is the other slot this JSON Schema rule governs.
12+
- Two editing limits of those controls. A multi-select toggle stores the steps in the order the enum declares them (`trim`, `parse_json`, `validate`). And the schema editor keeps the last valid JSON while the text does not parse. A value nobody edits is saved back unchanged.
13+
- No schema, parse or export change. The accept set of `AgentSchema` is unchanged, and so is the refusal of an untyped JSON subschema at `structuredOutput.schema`. What moves is the form payload `getMetaTypes()` serves, and the two new leaves of the `platform-objects` metadata-form catalogs (the row's label and help text). Those are authored in `zh-CN`, `ja-JP` and `es-ES`, not left as copies of the English source.

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,6 +2404,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
24042404
label: "Lifecycle",
24052405
helpText: "State machine defining conversation flow"
24062406
},
2407+
structuredOutput: {
2408+
label: "Structured Output",
2409+
helpText: "Output contract for the agent's final answer: JSON format, the JSON Schema it is checked against, retries, fallback format and transform steps. Enforced by the cloud AI runtime."
2410+
},
24072411
skills: {
24082412
label: "Skills",
24092413
helpText: "Skill names (Agent→Skill→Tool architecture)"

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,6 +2404,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
24042404
label: "Ciclo de vida",
24052405
helpText: "Máquina de estado que define el flujo de conversación"
24062406
},
2407+
structuredOutput: {
2408+
label: "Salida estructurada",
2409+
helpText: "Contrato de salida para la respuesta final del agente: formato JSON, el JSON Schema con el que se valida, reintentos, formato de respaldo y pasos de transformación. Lo aplica el runtime de IA en la nube."
2410+
},
24072411
skills: {
24082412
label: "Habilidades",
24092413
helpText: "Nombres de skill (arquitectura Agent→Skill→Tool)"

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,6 +2404,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
24042404
label: "ライフサイクル",
24052405
helpText: "会話フローを定義するステートマシン"
24062406
},
2407+
structuredOutput: {
2408+
label: "構造化出力",
2409+
helpText: "エージェントの最終回答に対する出力契約: JSON 形式、回答の検証に使う JSON Schema、リトライ、フォールバック形式、変換ステップ。クラウド AI ランタイムが適用します。"
2410+
},
24072411
skills: {
24082412
label: "スキル",
24092413
helpText: "スキル名(Agent→Skill→Tool アーキテクチャ)"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1150,7 +1150,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11501150
// three locales.
11511151
// 659 since the field form offers `useGrouping` on `number` fields: one
11521152
// new row label, authored in all three locales.
1153-
expect(translated.length, `${locale} positive control`).toBe(659);
1153+
// 660 since the agent form offers `structuredOutput`: one new row label,
1154+
// authored in all three locales.
1155+
expect(translated.length, `${locale} positive control`).toBe(660);
11541156
}
11551157
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11561158
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,6 +2404,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
24042404
label: "生命周期",
24052405
helpText: "定义会话流程的状态机"
24062406
},
2407+
structuredOutput: {
2408+
label: "结构化输出",
2409+
helpText: "代理最终回答的输出契约:JSON 格式、用于校验回答的 JSON Schema、重试、回退格式与转换步骤。由云端 AI 运行时强制执行。"
2410+
},
24072411
skills: {
24082412
label: "技能",
24092413
helpText: "技能名称(Agent→Skill→Tool 架构)"

‎packages/spec/src/ai/agent.form.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const agentForm = defineForm({
4242
{ field: 'planning', type: 'composite', helpText: 'Autonomous reasoning configuration (strategy, max iterations, replan)' },
4343
{ field: 'memory', type: 'composite', helpText: 'Memory management (short-term, long-term, reflection)' },
4444
{ field: 'lifecycle', type: 'composite', helpText: 'State machine defining conversation flow' },
45+
{ field: 'structuredOutput', type: 'composite', helpText: "Output contract for the agent's final answer: JSON format, the JSON Schema it is checked against, retries, fallback format and transform steps. Enforced by the cloud AI runtime." },
4546
],
4647
},
4748
{

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -405,13 +405,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
405405
key: 'requires',
406406
why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0080: inferred at compile time; save/load enforcement of plugin presence is deferred). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
407407
},
408-
{
409-
kind: 'omit',
410-
type: 'agent',
411-
path: ROOT_PATH,
412-
key: 'structuredOutput',
413-
why: 'declared, not enforced yet — `[EXPERIMENTAL — not enforced]` in its own describe and `experimental` in the liveness ledger: parsed, no runtime consumer. No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
414-
},
415408
{
416409
kind: 'omit',
417410
type: 'action',

0 commit comments

Comments
 (0)