Skip to content

feat: Add typed structured outputs for Node and .NET - #2590

Draft
SteveSandersonMS wants to merge 7 commits into
mainfrom
sdk/typed-structured-output
Draft

feat: Add typed structured outputs for Node and .NET#2590
SteveSandersonMS wants to merge 7 commits into
mainfrom
sdk/typed-structured-output

Conversation

@SteveSandersonMS

@SteveSandersonMS SteveSandersonMS commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Provider-native structured output for Node/TypeScript and C#, paired with
https://github.com/github/copilot-agent-runtime/pull/19652 and related to #1185.

Draft: synchronized with unreleased runtime b28bd95da9f15fb24a676f828fb9f754b014715d.
Updated with SDK main at dcfbb938, including its published CLI pin
1.0.84-4, startup serialization, OAuth metadata, and SourceLink security fix.
This PR must not land
until the runtime feature ships and the SDK pin can be updated.

Node accepts JSON Schema or Zod on MessageOptions.responseSchema. Passing a
Zod schema as the second argument to sendAndWait instead returns an inferred,
parsed, validated value. C# accepts JsonElement on
MessageOptions.ResponseSchema, and generic SendAndWaitAsync<TResult> overloads
infer a schema with the same Microsoft.Extensions.AI.AIJsonUtilities used by
custom tools, then deserialize the selected result.

The existing API shapes already supported the runtime's latest fixes. This
follow-up refreshes generated batch-contract documentation, documents late
steering and reset/restart behavior, corrects send-result ID documentation, and
adds matching Node/C# late-steering E2Es using one shared real-provider snapshot.
It also rejects invalid Node second arguments instead of silently treating a
misplaced raw schema as an unformatted send.

Representative usage

These examples assume a live local session backed by a model/provider route
that supports native JSON Schema. C# examples use GitHub.Copilot and
System.Text.Json; the preview APIs have the repository's experimental annotation.
Examples are alternatives, not a single sequence to execute against one session.

1. Prompt to a typed result

Node: infer the return type from a Zod value. A TypeScript type argument alone
cannot supply a runtime schema.

import { z } from "zod";

const answerSchema = z.object({ answer: z.number().int() });
const result = await session.sendAndWait("What is 19 + 23?", answerSchema);
console.log(result.answer); // number, parsed and validated

C#: infer from the result type using the normal reflection-enabled defaults.

var result = await session.SendAndWaitAsync<Answer>("What is 19 + 23?");
Console.WriteLine(result.AnswerValue);

public sealed class Answer
{
    [System.Text.Json.Serialization.JsonPropertyName("answer")]
    public required int AnswerValue { get; set; }
}

C# defaults to AIJsonUtilities.DefaultOptions, just as custom tools do.
Deserialization checks JSON/type compatibility and required members, not every
JSON Schema or application constraint. Node additionally calls the supplied
schema's parse method.

2. Full message options, time limits, and C# source-generated serialization

Node: keep attachments and other message options while still returning a typed value.

const summarySchema = z.object({
    summary: z.string(),
    actionItems: z.array(z.string()),
});
const result = await session.sendAndWait(
    {
        prompt: "Summarize this report and list its action items.",
        attachments: [{ type: "file", path: "/work/report.txt" }],
    },
    summarySchema,
    120_000,
);
console.log(result.actionItems);

C#: use one serialization contract for both inference and deserialization.
This also works when reflection-based serialization is disabled.

using System.Text.Json.Serialization;

using var cancellation = new CancellationTokenSource();
var result = await session.SendAndWaitAsync<Answer>(
    new MessageOptions
    {
        Prompt = "What answer is stated in this report?",
        Attachments = [new AttachmentFile { Path = "/work/report.txt" }],
    },
    serializerOptions: OutputJsonContext.Default.Options,
    timeout: TimeSpan.FromMinutes(2),
    cancellationToken: cancellation.Token);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Answer))]
internal partial class OutputJsonContext : JsonSerializerContext;

The options object is not modified. Do not also supply ResponseSchema/
responseSchema when using the typed overload. Timeout and C# cancellation stop
waiting; they do not abort agent work.

3. Explicit schema, assistant-event result

Use the options property when you want the event envelope and JSON text rather
than automatic typed parsing.

Node

const message = await session.sendAndWait({
    prompt: "What is 19 + 23?",
    responseSchema: answerSchema.toJSONSchema(),
});
console.log(message?.data.content);
console.log(message?.data.originatingMessageId);

The property can also hold answerSchema directly; that still returns an event,
not an inferred Answer object.

C#

using var schema = JsonDocument.Parse("""
    {"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"],"additionalProperties":false}
    """);
var message = await session.SendAndWaitAsync(new MessageOptions
{
    Prompt = "What is 19 + 23?",
    ResponseSchema = schema.RootElement.Clone(),
});
Console.WriteLine(message?.Data.Content);
Console.WriteLine(message?.Data.OriginatingMessageId);

These convenience options request name: "response" and strict: true.
Raw schema-bearing waits correlate messages, but do not validate or deserialize
the JSON text.

4. Different schemas on overlapping queued sends

Each submitted run owns its schema. Neither result may be replaced by the other
run's answer, although both waits can be delayed until the session is idle.

Node

const first = session.sendAndWait("What is 19 + 23?", answerSchema);
const second = session.sendAndWait(
    "Give a one-sentence explanation of addition.",
    z.object({ explanation: z.string() }),
);
const [answer, explanation] = await Promise.all([first, second]);

C#

var first = session.SendAndWaitAsync<Answer>("What is 19 + 23?");
var second = session.SendAndWaitAsync<Explanation>(
    "Give a one-sentence explanation of addition.");
await Task.WhenAll(first, second);
Console.WriteLine((await first).AnswerValue);
Console.WriteLine((await second).Text);

public sealed class Explanation
{
    public required string Text { get; set; }
}

An independent later send without a schema restores normal output. Immediate
steering is different: it inherits the active run's schema and origin, including
when promoted into a follow-up after the final model request. Send steering
without a schema:

await session.send({ prompt: "Use the revised figures.", mode: "immediate" });
await session.SendAsync(new MessageOptions
{
    Prompt = "Use the revised figures.",
    Mode = "immediate",
});

Explicit schemas on immediate delivery are rejected even while idle.

5. Admission-only sends and event-driven applications

send / SendAsync return the submitted user message's ID, not an assistant
response ID or a completed result. Applications that already own the event loop
can subscribe before sending and retain root assistant messages for correlation.
These are collection fragments, not alternative wait helpers:

Node

import type { AssistantMessageEvent } from "@github/copilot-sdk";

const replies: AssistantMessageEvent[] = [];
const unsubscribe = session.on("assistant.message", (event) => {
    if (!event.agentId) replies.push(event);
});
const origin = await session.send({
    prompt: "What is 19 + 23?",
    responseSchema: answerSchema,
});
// Keep listening until your event loop observes completion; do not parse here.

C#

var replies = new System.Collections.Concurrent.ConcurrentQueue<AssistantMessageEvent>();
using var subscription = session.On<AssistantMessageEvent>(message =>
{
    if (string.IsNullOrEmpty(message.AgentId)) replies.Enqueue(message);
});
var origin = await session.SendAsync(new MessageOptions
{
    Prompt = "What is 19 + 23?",
    ResponseSchema = schema.RootElement.Clone(),
});
// Keep this subscription's scope alive until your event loop observes completion.

After the run has started and the session reaches non-autopilot idle, select the
last root assistant message with this origin and no tool requests. Buffering
before admission completes matters: messages can precede the returned ID.
Handle session errors and aborted idle rather than returning partial output.
Dispose/unsubscribe after completion. For streaming UIs, the ordinary
assistant.message_delta / AssistantMessageDeltaEvent events remain available;
do not try to parse each delta as a JSON document.

6. Full response-format metadata and batch RPCs

Use generated RPC methods for the full contract: schema name, description,
strictness, and batches. These return admission information, not typed results.

Node

const responseFormat = {
    type: "json_schema" as const,
    jsonSchema: {
        name: "arithmetic",
        description: "The computed answer",
        strict: true,
        schema: answerSchema.toJSONSchema(),
    },
};
const single = await session.rpc.send({
    prompt: "What is 19 + 23?",
    responseFormat,
});
const batch = await session.rpc.sendMessages({
    messages: [
        { prompt: "The two operands are 19 and 23." },
        { prompt: "Return their sum." },
    ],
    responseFormat,
});
console.log(single.messageId, batch.messageIds.at(-1));

C#

using GitHub.Copilot.Rpc;

var format = new ResponseFormat
{
    Type = "json_schema",
    JsonSchema = new JsonSchemaResponseFormat
    {
        Name = "arithmetic",
        Description = "The computed answer",
        Strict = true,
        Schema = schema.RootElement.Clone(),
    },
};
var single = await session.Rpc.SendAsync("What is 19 + 23?", responseFormat: format);
var batch = await session.Rpc.SendMessagesAsync(
    [
        new() { Prompt = "The two operands are 19 and 23." },
        new() { Prompt = "Return their sum." },
    ],
    responseFormat: format);
Console.WriteLine($"{single.MessageId}, {batch.MessageIds.Last()}");

A batch admitted as new work starts one run. Earlier messages provide
context; the final returned ID is the assistant messages' originatingMessageId.
An empty batch runs over existing history and has no origin. Immediate batches
steer the active run and do not establish a new origin. Put the format beside
the batch, not on individual messages.

Completion and provider semantics

  • turnId identifies a model/tool iteration, not an entire queued run.
    originatingMessageId remains stable through tool calls, ordinary steering,
    late-steering follow-ups, and internal stop-hook corrections.
  • Schema-bearing waits subscribe before sending, buffer pre-acknowledgement
    events, and select the last correlated root assistant message without tool
    requests at non-autopilot session idle. There is no final-message flag and
    no delayed runtime event publication. Intermediate text need not be valid JSON.
  • Other queued runs and subagents cannot replace the selected result. Later
    queued work can delay idle. Session errors and aborted idle after the requested
    run starts conservatively fail the wait, even if later work caused them.
    Unformatted waits retain their existing behavior.
  • A terminal tool can require a model follow-up for structured output. If that
    tool clears context, the old run ends; a fresh seed does not inherit its schema
    or origin. A typed wait may therefore fail for lack of a structured result.
    Schemas are not persisted session defaults; autonomous resume-pending work
    after restart does not restore an interrupted send's contract.
  • Runtime passes schemas through without validating their contents or parsing
    the model output. Provider restrictions apply. OpenAI Chat uses
    response_format, OpenAI Responses uses text.format, and Anthropic Messages
    uses output_config.format. API-compatible gateways can ignore unsupported
    fields. The Claude Chat-completions compatibility route is not the native
    Anthropic Messages route. Remote sessions and HydraFusion reject formats.

Generated contracts and release dependency

Regenerated Node, C#, Python, Go, Rust, and Java contracts using the current
generators. The newer released schemas contain MCP source metadata absent from
the runtime feature branch, so generation uses a three-way merge of:

  • Runtime feature schemas at b28bd95da9.
  • Their runtime-base schemas at 7fc0540350.
  • Published CLI 1.0.84-4 schemas, resolved by the existing SDK schema loader.

The merged inputs preserve every released contract and apply exactly the runtime
PR's nine RPC-schema changes and one event-schema change. No generated wrapper
was hand-edited and no newer MCP API was dropped. The Python merge conflict was
resolved by regeneration, not by choosing one side. CONTRIBUTING.md documents
this workflow.

The latest feature delta includes batch-origin documentation; no extra public API
was needed for late steering. Handwritten convenience APIs and feature E2Es remain
limited to Node and C#. The PR also retains the C# generator fix and regressions
for singleton anyOf / oneOf definitions, alongside main's required-null fix.

CONTRIBUTING.md explains local schema generation and COPILOT_CLI_PATH.
Java's codegen workflow reports drift rather than overwriting generated output
on a draft; ready-for-review PRs retain automatic regeneration. Pinned-schema and
packaged-runtime CI are not expected to be green until the runtime is released
and this draft updates its CLI pin.

Validation

Against local runtime b28bd95da9:

  • All 7 Node and 6 C# structured-output E2Es pass in credential-free replay.
    Coverage includes raw schemas, typed inference, tools, immediate steering,
    schema clearing, batch RPCs, concurrent schemas, stop-hook corrections, and
    late steering after the final model request.
  • The new structured_output/typed_wait_returns_late_steering_response.yaml
    was recorded once using real gpt-4.1 responses through the existing proxy.
    Both languages share it, just as both share the stop-hook correction capture.
    Their typed waits return corrected 99 rather than initial 42, with the original
    origin retained. All 11 structured-output capture files remain unchanged by replay.
    No shared test infrastructure or model-response YAML was hand-written.
  • Node's 20 structured-output unit tests and 5 existing send-and-wait
    regressions
    pass, as do the build, typecheck, and scoped lint.
  • C#'s 34 combined structured-output unit/E2E cases pass with reflection
    serialization disabled. Two additional targeted cases exercise default
    inference with reflection enabled, including the concurrent-send E2E.
    Builds pass for net8.0, net10.0, and netstandard2.0.
  • Earlier generator coverage included the C# singleton regressions, Node and Java
    generator tests, Python/Go generated-package tests, and Rust compilation.
    This follow-up regenerates all languages while preserving main's newer contracts.

C# tests target net8.0 and run on the installed .NET 10 runtime via
DOTNET_ROLL_FORWARD=Major. Main's SourceLink update resolves the previous
NU1902 build blocker; no warning exception or security-setting change is needed.
Full SDK suites, Native AOT publishing, and other-language E2Es were not run.

SteveSandersonMS and others added 2 commits September 9, 2026 15:15
Generate all language RPC wrappers from the local runtime schema, expose per-run output schemas, and correlate schema-bearing waits using originatingMessageId. Include real-provider recording/replay E2Es through the locally built runtime for raw schemas, tools, steering, batches, and overlapping typed sends.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Auto-committed by java-codegen-check workflow.
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Sep 9, 2026
Report pinned-schema drift without automatically rewriting draft Java output. Keep failure visibility, retain auto-regeneration for ready PRs, and restore the locally generated Java API after the initial workflow regenerated it against the old published runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Regenerate event types for all six SDK languages and document isFinalReply. Add real-provider Node and C# direct-send E2Es that parse the final correlated reply while stop hooks block idle, plus regressions preserving SendAndWait rejection on later errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Regenerate all SDK contracts from the local runtime, remove the provisional final-reply flag, and select correlated responses at idle. Share the real stop-hook correction capture between Node and C# and preserve existing captures while updating direct-send coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

SteveSandersonMS and others added 2 commits September 10, 2026 20:45
Refresh batch contracts, cover late steering with a shared provider capture, and reject malformed typed-wait arguments instead of sending unformatted requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ntracts

Regenerate wrappers from the three-way merged release and feature schemas, preserving main's MCP source metadata, client startup fixes, OAuth support, CLI pin, and SourceLink update.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

SDK Consistency Review

This PR adds provider-native structured output support (responseSchema / ResponseSchema, sendAndWait/SendAndWaitAsync<TResult>) to the Node.js/TypeScript and .NET SDKs only. The PR description explicitly scopes this to those two languages and is a draft pending an unreleased runtime feature (copilot-agent-runtime PR #19652), so this is an intentional, documented decision rather than an oversight.

What I checked

  • Node.js: MessageOptions.responseSchema (JSON Schema or Zod), session.sendAndWait(prompt, schema, timeout?) returning a parsed/validated typed result, plus responseFormat wiring in send(). ✅
  • .NET: MessageOptions.ResponseSchema (JsonElement?), new Session.StructuredOutput.cs with SendAndWaitAsync<TResult> overloads using Microsoft.Extensions.AI schema inference/deserialization. ✅
  • Go / Python / Java / Rust: only received the generated plumbing (ResponseFormat, JSONSchemaResponseFormat, originatingMessageId, updated SendMessagesRequest/SendRequest/event types). None of these got a hand-written public API surface (e.g., no ResponseFormat field on Go/Rust MessageOptions, no response_schema param on Python's send/send_and_wait, no typed sendAndWait equivalent in Java).

Assessment

No inline comments are needed. The asymmetry is called out by the authors themselves in the PR description ("Provider-native structured output for Node/TypeScript and C#") and is consistent with the repo's pattern of rolling out provider/runtime-dependent features language-by-language before back-filling parity. The generated-code changes for Go/Python/Java/Rust look like plain schema/type regeneration (keeping those SDKs in sync with the runtime contract) with no partial/broken feature exposed — a reasonable, low-risk state for a draft PR.

Suggestion for follow-up (not blocking): once this lands, consider tracking Go/Python/Java/Rust structured-output parity as a follow-up issue, since the underlying RPC/type support is now in place for those languages and only the ergonomic wrapper (responseSchema option + typed sendAndWait) is missing.

Generated by SDK Consistency Review Agent for #2590 · copilot · sonnet50 · 58.6 AIC · ⌖ 11.2 AIC · ⊞ 8.3K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant