Skip to content

feat(agents): structured output - #574

Draft
MarioCadenas wants to merge 8 commits into
mainfrom
feat/agent-structured-output
Draft

feat(agents): structured output#574
MarioCadenas wants to merge 8 commits into
mainfrom
feat/agent-structured-output

Conversation

@MarioCadenas

@MarioCadenas MarioCadenas commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Structured output for AppKit agents

Lets a code-config agent return a typed object validated against a Zod schema instead of freeform text. createAgent({ output: Schema }) (or a per-call runAgent(agent, input, { output }) override) makes the final answer schema-validated and surfaces it on every path.

What it does

  • Config + typingAgentDefinition<TOutput> + output?: z.ZodType; createAgent<S> is generic so the in-process result is typed via z.infer. Reuses the existing toToolJSONSchema; no new converter or dependency.
  • Non-streaming structuring — the agent answers normally (streaming its visible text on /chat), then structured output is produced by a dedicated non-streaming completion constrained by the schema via response_format. The answer is validated as-is first (cheap pre-check); otherwise the pass reformats it.
  • Validate + retryresolveStructuredOutput parses + Zod-validates, re-prompting with the flattened issues up to 2 times, then throws StructuredOutputError (carrying lastRaw, server-side only). Never returns partial/unvalidated data. If an endpoint rejects response_format, it's stripped and retried (prompt + Zod).
  • Result surfaceoutput_parsed on the /invocations + /responses envelope; a final appkit.structured_output SSE event on /chat (after the streamed text; retries invisible); RunAgentResult.output in-process. The structuring pass is a TOOL span under the turn's AGENT span.
  • Tests + docs + example — resolver / adapter / runAgent / translator unit tests; a "Structured output" section in the agents plugin doc; a classifier example agent in dev-playground.

⚠️ Dogfood finding (why the approach changed)

A live dogfood against a Databricks Claude endpoint returned INVALID_PARAMETER_VALUE: Structured output is not currently supported with streaming. Since DatabricksAdapter is streaming-only, the plan's original "send response_format inline on the streamed completion" (locked decision 3) cannot work on this endpoint. Fixed by making structured output a non-streaming completion (native structured output is supported that way), with the visible answer still streamed. This is the one substantive deviation from the approved plan, driven by real endpoint behavior.

Scope

Non-streaming structured pass + a single /chat final event (no progressive partial-JSON streaming). Schema is server-side (no client-sent JSON Schema). Code-config agents only — not markdown agent.md agents or sub-agents.

Notable choices to review

  • Wire event name is appkit.structured_output (internal AgentEvent is structured_output), matching the appkit.* siblings that use-agent-chat.ts switches on.
  • Generated API docs (docs/docs/api/appkit/*.md) are intentionally not regenerated here — docs:build passes, but committing the regen pulls in unrelated pre-existing drift (e.g. feat(stream): make SSE maxEventSize configurable end-to-end #568's streamConfig). Left to the release regen.

Verification

  • Unit tests: 71 structured-output-specific passing; broader agent/errors suites green.
  • pnpm -r typecheck, pnpm check:fix, pnpm build, pnpm docs:build: all clean.
  • Partially dogfooded: the streaming-rejection was found and fixed against a live Claude endpoint. A full end-to-end confirmation of the non-streaming happy path (valid object returned, output_parsed / structured_output surfaced) should be re-run against a live endpoint before marking ready-for-review.

Add an optional `output?: z.ZodType` field to AgentDefinition and make
createAgent generic so `createAgent({ output: Schema })` returns
`AgentDefinition<z.infer<Schema>>`. Thread the type param through
RunAgentResult (new `output?: TOutput`) and store the runtime schema on
RegisteredAgent. No behavior yet — this is the typing surface only.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Add an optional `outputSchema` (JSON Schema) to AgentInput. When set and
the completion is tool-free, DatabricksAdapter sends it as an
OpenAI-compatible `response_format: { type: json_schema, strict: true }`,
so the final text is the JSON to validate. Tool-having runs are unchanged
(Claude rejects response_format + tools) and get re-formatted by a separate
tool-free structuring pass — a second run() with no tools.

On a 400 that names the param, strip response_format and retry once (the
Zod validation upstream is the real guarantee). In structured mode the
text-based tool-call fallback is skipped so array-typed JSON isn't misread
as a tool call.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Add resolveStructuredOutput (core/agent): parse + zod-validate the agent's
answer, re-prompting a tool-free structuring pass with the flattened zod
issues up to 2 times before throwing StructuredOutputError with the last
raw output. Tool-free answers are validated inline; tool-having answers get
a first structuring pass. Strips a stray Markdown code fence before parsing.

Wire the in-process runAgent to it via a new 3rd { output } options arg
(overrides the agent's own schema), reusing the adapter for the tool-free
structuring pass. StructuredOutputError joins the error taxonomy; lastRaw is
server-side only and never leaks via clientMessage.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…event

Store the agent's output schema on RegisteredAgent and, when set, resolve
structured output after the run's final text: /invocations and /responses
gain a top-level output_parsed field (spread like mlflow_trace_id), and
/chat emits one final structured_output AgentEvent -> appkit.structured_output
SSE event after the streamed text. Retries stay invisible to the stream.

The structuring pass runs as a fresh tool-free adapter.run() constrained by
the schema, wrapped in a TOOL span nested under the turn's AGENT span. Adds
the new event to the shared AgentEvent/ResponseStreamEvent unions and the
event translator.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Thread the agent's JSON schema into the main adapter.run() (runAgent +
both plugin surfaces) so a tool-free structured agent gets response_format
on its own completion — the answer is JSON directly, no wasted structuring
round-trip. The adapter ignores outputSchema when tools are present, so
tool-having agents are unaffected and still use the separate structuring
pass. Completes the tool-branch strategy from the earlier adapter commit.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Split runAgent into two overloads: the no-override call types the result
from the agent's own schema; the per-call { output } override drives the
result type via z.infer of that schema (it also takes precedence at runtime).
Without this, an agent with no schema is AgentDefinition<string>, so a
{ output } override couldn't retype RunAgentResult.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Unit tests for the resolver (validate/retry/exhaust/fence-strip/no-leak), the
adapter (response_format on tool-free runs, none with tools, 400 strip-retry),
runAgent end-to-end (tool-free inline, tool-having structuring pass, per-call
override, throw), and the structured_output event translation. Add a
'Structured output' section to the agents plugin doc and a tool-free
'classifier' example agent to dev-playground.

Not verified against a live serving endpoint — needs manual dogfood.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size report

Compared against bundle-size-baseline.json (main).

@databricks/appkit

npm tarball (packed): 1.0 MB (+14 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 1.1 MB (+12 KB) 386 KB (+3.9 KB)
Type declarations 392 KB (+6.4 KB) 139 KB (+2.6 KB)
Source maps 2.2 MB (+29 KB) 724 KB (+9.3 KB)
Other 11 KB 3.7 KB
Total 3.6 MB (+48 KB) 1.2 MB (+16 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 95 KB (+107 B) 2.5 KB 98 KB (+107 B) external 313 KB (+239 B)
./beta 78 KB (+1.4 KB) 456 B 78 KB (+1.4 KB) external 235 KB (+4.8 KB)
./testing 17 KB (-2 B) 0 B 17 KB (-2 B) external 51 KB
./tsdown 520 B 0 B 520 B external 813 B
./type-generator 23 KB 0 B 23 KB external 65 KB

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 91 KB
. utils.js initial 4.0 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 61 KB
./beta stream-manager.js initial 5.8 KB
./beta databricks.js initial 3.6 KB
./beta wide-event-emitter.js initial 3.2 KB
./beta configuration.js initial 2.1 KB
./beta service-context.js initial 1.3 KB
./beta client.js initial 434 B
./beta client-options.js initial 219 B
./beta supervisor-api.js lazy 193 B
./beta databricks.js lazy 141 B
./beta index.js lazy 122 B
./testing index.js initial 17 KB
./tsdown index.js initial 520 B
./type-generator index.js initial 23 KB

@databricks/appkit-ui

npm tarball (packed): 350 KB — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 395 KB 132 KB
Type declarations 229 KB 84 KB
Source maps 766 KB 253 KB
CSS 16 KB 3.2 KB
Total 1.4 MB 473 KB
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
./js 5.3 KB 49 KB 55 KB 208 KB 14 KB
./js/beta 20 B 0 B 20 B 0 B 0 B
./react 432 KB 49 KB 481 KB 1.3 MB 177 KB
./react/beta 1.0 KB 0 B 1.0 KB 0 B 1.9 KB

Chunks:

Entry Chunk Load Size (gz)
./js index.js initial 5.2 KB
./js chunk initial 120 B
./js apache-arrow lazy 49 KB
./js/beta beta.js initial 20 B
./react index.js initial 430 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 1.0 KB

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🤖 AppKit PR bot

🔬 Run evals

Start an eval for this PR from the evals-monitor app: Go to Evals Monitor →

📦 Try this PR's app template

Scaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh auth login — and the Databricks CLI):

gh run download 33779428678 -R databricks/appkit -n appkit-template-0.71.0-pr.9feaff1-feat-agent-structured-output-574 -D appkit-pr-574 \
  && unzip -o "appkit-pr-574/appkit-template-0.71.0-pr.9feaff1-feat-agent-structured-output-574.zip" -d "appkit-pr-574" \
  && databricks apps init --template "appkit-pr-574"

The template pins @databricks/appkit and @databricks/appkit-ui to tarballs built from this branch, so the scaffolded app runs against this PR's code.

Databricks rejects response_format under stream:true ("Structured output is
not currently supported with streaming"), and DatabricksAdapter is
streaming-only, so the original inline/streaming approach failed on every
call. Structured output now runs as a dedicated NON-streaming completion:

- Add a non-streaming queryBody transport (connectors/serving query()) and a
  structuredCompletion() path in the adapter; run() uses it when outputSchema
  is set and tools are empty, emitting the JSON as one message event.
- The main agent run always streams normally (revert the toolless-inline
  wiring), so /chat still streams the visible answer; structured output is a
  separate pass afterwards.
- resolveStructuredOutput validates the answer as-is first (cheap pre-check),
  else reformats via the pass; drop the hadTools branch.
- Broaden the response_format-rejection detector to catch INVALID_PARAMETER_VALUE
  / "structured output" phrasing so strip-and-retry actually fires.

Found via live dogfood; updates tests, docs, and the example accordingly.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
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