Skip to content

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

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

feat: Add typed structured outputs for Node and .NET#2590
SteveSandersonMS wants to merge 8 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 145f0fc7d0c741d10bfc36c255956004202d771d.
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 support the runtime's latest fixes. This update
regenerates all SDK contracts directly from the current runtime and documents
output-only terminal-tool finalization, early unsupported-route rejection, and
the 32 MiB schema ceiling. Matching Node/C# E2Es exercise admission rejection and
a typed result corrected by a stop hook after a terminal tool. The latter uses
one shared real-provider snapshot. Earlier late-steering, concurrent-send, and
schema-clearing coverage remains intact.

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 successful terminal tool requires an output-only model follow-up for
    structured output: tools are disabled for finalization, including when a
    configured tool choice would otherwise require a tool. Stop-hook corrections
    remain supported. 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 known HydraFusion routes reject
    formats before admission. Runtime also rejects schemas exceeding 32 MiB when
    JSON-encoded, using its existing request-size ceiling without allocating a
    second serialized schema string. This does not guarantee that the whole
    provider request fits. No schema truncation or depth rewriting is performed.

Generated contracts and release dependency

Regenerated Node, C#, Python, Go, Rust, and Java contracts directly from runtime
145f0fc7d0, which now includes runtime main at 5a9360792e.
The previous three-way schema merge is no longer needed: the current runtime
contains the newer MCP metadata as well as the structured-output feature.
No previous schema definitions or object fields were removed relative to this
PR's previous merged schema inputs, and no generated wrappers were hand-edited.
The Node root export of PermissionDecisionSource now comes from the generated
event module because it is shared by both events and RPCs; its public import
remains unchanged.

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 locally built runtime 145f0fc7d0, using COPILOT_CLI_PATH rather
than the older packaged CLI:

  • All 9 Node and 8 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, output-only terminal finalization,
    and single/empty-batch rejection of oversized schemas and HydraFusion.
  • The new
    structured_output/typed_wait_returns_stop_hook_correction_after_terminal_tool.yaml
    was recorded once using real gpt-4.1 responses through the existing proxy,
    then replayed by both languages. Both typed helpers return corrected 99 rather
    than initial 63, invoke the terminal tool exactly once, and retain the original
    origin. Both assert the actual finalization request has tool_choice: "none".
    Admission failures produce no user-message/session-error history, leave the
    queue empty, and make no inference requests.
    All 11 pre-existing structured-output captures remain unchanged.
    No model-response YAML was hand-written; the existing C# proxy DTO gained only
    an optional tool_choice field so the wire request can be asserted.
  • Node's 20 structured-output unit tests and 5 existing send-and-wait
    regressions
    , plus 31 event type/codegen regressions, pass, as do the build,
    typecheck, and scoped lint.
  • C#'s 36 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

This comment has been minimized.

Regenerate all language contracts from runtime 145f0fc7d0. Preserve the Node permission-source export after it moves into shared event definitions. Exercise output-only terminal finalization with stop-hook correction and synchronous size/HydraFusion rejection through both Node and C# SDKs.

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

Copy link
Copy Markdown
Contributor

SDK Consistency Review — PR #2590

Scope of changes (from the authoritative diff): This PR adds a new typed structured-output feature (schema-inferred sendAndWait/SendAndWaitAsync<TResult>, plus MessageOptions.responseSchema/ResponseSchema) to Node.js/TypeScript (nodejs/src/session.ts, nodejs/src/schema.ts, nodejs/src/types.ts) and .NET (dotnet/src/Session.StructuredOutput.cs, dotnet/src/Session.cs, dotnet/src/Types.cs), backed by regenerated wire contracts (ResponseFormat/JsonSchemaResponseFormat/responseFormat field on send/sendMessages) across all six generated packages (Node, .NET, Python, Go, Rust, Java).

Findings:

  1. No hand-written convenience API added for Python, Go, Rust, or Java. Only the generated RPC types (ResponseFormat, JsonSchemaResponseFormat, responseFormat field) landed in those languages — send/send_and_wait/SendAndWait in Python, Go, and Rust, and the Java session client, were not touched to expose an equivalent typed/structured-output helper or a responseSchema option on message-send options.
  2. This appears to be intentional and explicitly scoped, not an oversight: the PR title ("Add typed structured outputs for Node and .NET"), description, and validation notes state "Handwritten convenience APIs and feature E2Es remain limited to Node and C#," and the PR is still in draft, blocked pending an unreleased runtime feature. The Node/.NET API shapes are also well-aligned with each other (parallel responseSchema/ResponseSchema message option, parallel typed-overload semantics, same rejection rules for immediate mode + explicit schema, same "schema not persisted across independent sends" semantics).
  3. No consistency issues found within the two implemented languages — naming, error conditions (schema + immediate mode conflict, schema + typed-overload conflict), and event-correlation semantics (originatingMessageId) match across Node and .NET, accounting for normal language idioms (camelCase vs PascalCase, Zod vs Microsoft.Extensions.AI schema inference).

Suggestion: Since this is linked to #1185 and the generated contracts are already in place for Python, Go, Rust, and Java, consider opening (or confirming there's) a follow-up tracking issue for adding the equivalent send_and_wait/SendAndWait/etc. structured-output convenience helpers to those four SDKs once this lands, to avoid long-term feature-parity drift. No inline changes are requested for this PR — the scoping is clearly documented and appropriate for a draft.

Generated by SDK Consistency Review Agent for #2590 · copilot · sonnet50 · 55.1 AIC · ⌖ 12.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