Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/java-codegen-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'java/sdk/src/generated/**'
- '.github/workflows/java-codegen-check.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'nodejs/package.json'
- 'java/scripts/codegen/**'
Expand Down Expand Up @@ -70,18 +71,18 @@ jobs:
echo "✅ Generated files are up-to-date"
fi

# --- On push to main: fail if generated files are stale (existing behavior) ---
- name: Fail on stale generated files (push to main)
if: steps.check-changes.outputs.changed == 'true' && github.event_name != 'pull_request'
# Drafts may intentionally target an unreleased schema; report drift without rewriting them.
- name: Fail on stale generated files without automatic updates
if: steps.check-changes.outputs.changed == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.draft == true)
run: |
echo "::error::Generated files are out of date. Run 'cd java/scripts/codegen && npm run generate' and commit the changes."
git diff
exit 1

# --- On PR: commit regenerated files back and verify build ---
# --- On ready PRs: commit regenerated files back and verify build ---
- name: Commit and push regenerated files to PR branch
id: push-regen
if: steps.check-changes.outputs.changed == 'true' && github.event_name == 'pull_request'
if: steps.check-changes.outputs.changed == 'true' && github.event_name == 'pull_request' && github.event.pull_request.draft == false
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
Expand Down
97 changes: 97 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,103 @@ Setup, build, and test instructions are maintained with each SDK:
- [Rust](rust/README.md#development)
- [Java](java/README.md#development-setup)

### Testing an unreleased runtime API

The runtime's Rust contracts under `src/native/sdk-contract` produce both
`generated/api.schema.json` (RPC methods) and
`generated/session-events.schema.json` (event payloads). In a local checkout of
`github/copilot-agent-runtime`, build the runtime and emit these schemas:

```bash
pnpm run build
pnpm bazel build //src/native/schema-codegen:schema-codegen
bazel-bin/src/native/schema-codegen/schema-codegen emit \
--api "$PWD/generated/api.schema.json" \
--session-events "$PWD/generated/session-events.schema.json"
```

The SDK generators normally download schemas from the pinned CLI release. To
use the local schemas instead, pass the event-schema path followed by the
RPC-schema path. From this repository's `scripts/codegen` directory:

```bash
npm ci
for language in typescript csharp python go rust; do
node --import tsx "$language.ts" \
"$RUNTIME_ROOT/generated/session-events.schema.json" \
"$RUNTIME_ROOT/generated/api.schema.json"
done
```

Set `RUNTIME_ROOT` to the absolute path of the runtime checkout. Java's generator
at `java/scripts/codegen/java.ts` reads these files from
`java/scripts/codegen/target/schemas` instead of accepting positional arguments;
stage the local schemas there before running it. Do not hand-edit generated
wrappers. Regenerating against a newer runtime
also includes any other contract changes since the SDK's pinned release.

If the SDK's pinned release is newer than the runtime feature branch, preserve
the released APIs rather than overwriting them with older local schemas.
Three-way merge each feature schema with its runtime-base schema and the pinned
package's schema, then pass the merged files to the generators. For example,
with `RUNTIME_BASE` set to the feature branch's base commit and
`PINNED_SCHEMAS_DIR` pointing to the released package's `schemas` directory:

```bash
MERGED_SCHEMAS_DIR=$(mktemp -d)
for name in api session-events; do
git -C "$RUNTIME_ROOT" show "$RUNTIME_BASE:generated/$name.schema.json" \
> "$MERGED_SCHEMAS_DIR/base-$name.schema.json"
git merge-file -p "$RUNTIME_ROOT/generated/$name.schema.json" \
"$MERGED_SCHEMAS_DIR/base-$name.schema.json" \
"$PINNED_SCHEMAS_DIR/$name.schema.json" \
> "$MERGED_SCHEMAS_DIR/$name.schema.json" || break
done
```

Resolve any schema conflicts before generating. The existing
`getApiSchemaPath()` and `getSessionEventsSchemaPath()` helpers in
`scripts/codegen/utils.ts` locate schemas for the current pin. This approach
preserves newer released contracts while adding the exact runtime feature delta;
generated SDK wrappers should never be merged by dropping unrelated APIs.

Set `COPILOT_CLI_PATH` to the built runtime's `dist-cli/index.js` to run SDK E2Es
against that checkout rather than the packaged runtime. For example:

```bash
export COPILOT_CLI_PATH="$RUNTIME_ROOT/dist-cli/index.js"
# Supply GITHUB_TOKEN with Copilot access when recording new provider responses.
cd nodejs
npm test -- test/e2e/structured_output.e2e.test.ts
cd ../dotnet
dotnet test test/GitHub.Copilot.SDK.Test.csproj \
--filter FullyQualifiedName~StructuredOutputE2ETests
```

The shared harness records real inference responses under `test/snapshots`.
Record new captures with `GITHUB_TOKEN` set and `GITHUB_ACTIONS` unset;
never author model responses by hand. Rerun with `GITHUB_ACTIONS=true` and real
provider credentials removed to require replay instead of forwarding cache
misses upstream. A draft targeting an unreleased runtime should document the
required runtime revision; update the pinned release only after it ships.
Pinned-schema CI can report drift in such a draft. Java codegen reports this
without automatically rewriting draft branches; automatic updates resume once
the pull request is ready for review.

For recording behind `HTTPS_PROXY`, Node versions that support environment
proxies (including Node 24.20) need `NODE_USE_ENV_PROXY=1` in the test runner's
environment. If the host proxy substitutes a protected credential, set
`GITHUB_TOKEN="$GH_TOKEN"` using its issued placeholder; do not print or persist
the credential. Keep localhost and loopback in `NO_PROXY`.

Equivalent cross-language E2Es should share snapshot names and prompts.
For example, Node's `typed_wait_returns_stop_hook_correction` and C#'s
`Typed_Wait_Returns_Stop_Hook_Correction` both use
`test/snapshots/structured_output/typed_wait_returns_stop_hook_correction.yaml`.
It was recorded once against real `gpt-4.1` inference, then replayed by both SDKs
against the local runtime. Both typed helpers select the corrected answer at
idle; there is no final-message flag.

## Submitting a Pull Request

1. Fork and clone the repository
Expand Down
128 changes: 128 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ Send a message to the session.
- `Attachments` - File attachments
- `Mode` - Delivery mode ("enqueue" or "immediate")
- `Source` - Optional message origin: `MessageSource.User`, `MessageSource.System`, or `MessageSource.Agent(id)`. Omitted by default, preserving the runtime's default user behavior.
- `ResponseSchema` - Experimental provider-native JSON Schema (`JsonElement`) for this turn.

Returns the message ID.

Expand All @@ -277,6 +278,133 @@ await session.SendAndWaitAsync(new MessageOptions
Agent sources serialize as `agent-<id>`. Pass the agent ID without adding a
prefix. The SDK preserves its case and whitespace and rejects null IDs.

##### Structured outputs (experimental)

Use `SendAndWaitAsync<TResult>` to infer a JSON Schema from a .NET type and
deserialize the final response. Schema inference uses
`Microsoft.Extensions.AI.AIJsonUtilities`, the same technology as custom tools.
In a reflection-enabled application, `await session.SendAndWaitAsync<Inventory>(prompt)`
needs no serialization configuration. The example below supplies source-generated
metadata so it also works when reflection serialization is disabled.

```csharp
var result = await session.SendAndWaitAsync<Inventory>(
"How many red widgets are in stock?",
serializerOptions: InventoryJsonContext.Default.Options);
Console.WriteLine($"{result.Count} {result.Color} widgets");

public sealed class Inventory
{
public required int Count { get; set; }
public required string Color { get; set; }
}

[System.Text.Json.Serialization.JsonSourceGenerationOptions(
PropertyNamingPolicy = System.Text.Json.Serialization.JsonKnownNamingPolicy.CamelCase)]
[System.Text.Json.Serialization.JsonSerializable(typeof(Inventory))]
internal partial class InventoryJsonContext : System.Text.Json.Serialization.JsonSerializerContext;
```

The same serialization options govern schema inference and deserialization,
including naming policies, `[JsonPropertyName]`, converters, required members,
and nullable annotations. Options default to `AIJsonUtilities.DefaultOptions`,
as for custom tools. Supply a source-generated resolver (as above) for Native
AOT or when reflection serialization is disabled. The typed helper requests
strict output, marks all schema properties required, and disallows additional
properties; nullable properties can still contain JSON null.

The helper waits for non-autopilot session idle after the requested user message
is consumed, selecting only root assistant messages with that originating message
ID. This can wait for other queued work to drain, but other messages and subagent
responses cannot replace the result. Session errors or an aborted idle after the
requested run starts conservatively fail the wait, even if later queued work
caused them. It throws `InvalidOperationException` when there is no final response,
and `JsonException` for invalid JSON, an incompatible
value, or a null result. Deserialization is not full JSON Schema validation:
validate application-specific constraints yourself. Timeout defaults to 60
seconds; timeout and cancellation stop waiting without aborting runtime work.
The original `MessageOptions` is not modified, and an explicit `ResponseSchema`
cannot be combined with this typed overload.

For an explicit schema, set `MessageOptions.ResponseSchema`. Schemas are opaque
`JsonElement` values, just like custom-tool schemas. The SDK forwards this schema
unchanged with the name `response` and `strict: true`. The untyped
`SendAndWaitAsync` still returns an assistant message event; it does not validate
or deserialize the response. Schema-bearing waits use the same message
correlation as typed waits; unformatted waits retain their existing behavior.

With `SendAsync`, collect root `AssistantMessageEvent` events whose
`Data.OriginatingMessageId` matches the returned message ID, then select the last
one without tool requests when the session becomes idle. Subscribe before sending
because events can precede the send acknowledgement, and handle `SessionErrorEvent` normally.
There is no final-message flag: stop hooks can reject an initial answer and
request a correction. Those corrections retain the original schema and
originating message ID, so `SendAndWaitAsync` selects the corrected response at
idle. Independent queued sends retain their own schemas and IDs.

```csharp
using var schema = System.Text.Json.JsonDocument.Parse("""
{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"],"additionalProperties":false}
""");
var message = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Count the widgets.",
ResponseSchema = schema.RootElement.Clone(),
});
```

Use the generated `session.Rpc` APIs for advanced response-format options:

```csharp
using GitHub.Copilot.Rpc;
using System.Text.Json;

using var schema = JsonDocument.Parse("""
{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"],"additionalProperties":false}
""");
var format = new ResponseFormat
{
Type = "json_schema",
JsonSchema = new JsonSchemaResponseFormat
{
Name = "inventory",
Schema = schema.RootElement.Clone(),
Strict = true,
Description = "The inventory count",
},
};
await session.Rpc.SendAsync("Count the widgets.", responseFormat: format);
// A batch shares one output contract:
await session.Rpc.SendMessagesAsync(
[new() { Prompt = "There are 42 widgets." }, new() { Prompt = "Report the count." }],
responseFormat: format);
```

Raw schemas and outputs are passed through without validation or rewriting.
Provider support and schema restrictions apply. The format persists through
tool continuations in that run, not independent subsequent runs. An ordinary
`Mode = "immediate"` steering message inherits the active format and originating
message ID, even if it arrives after the final model request and is promoted
into a follow-up run. Specifying a new format on an immediate message is rejected,
even while idle.
Each batch starts one run: the final returned message ID is its origin, preceding
messages are context, and an empty batch has no origin. An immediate batch
steers the active run instead and retains its origin.
The schema is not a persisted session default: autonomous resume-pending work
after a restart does not restore it. A terminal tool that clears context ends
the old run; its fresh seed does not inherit the schema or origin. Such a run
can finish without a structured result, in which case the typed wait throws.
After a successful terminal tool, the runtime disables tools while the model
produces the structured result. Stop-hook corrections remain supported.
Remote sessions and known HydraFusion routes reject response formats before
admission. Schemas larger than 32 MiB when JSON-encoded are also rejected before
admission, using the runtime's existing request-size ceiling. This does not
guarantee the schema plus conversation and tools fits the provider's budget.
Use a provider route that enforces JSON Schema: an API-compatible gateway can
ignore unsupported format fields, and the Claude Chat-completions compatibility
route is not equivalent to Anthropic's native Messages endpoint. This preview
requires the unreleased runtime changes; see [local-runtime development](../CONTRIBUTING.md#testing-an-unreleased-runtime-api).

##### `On(Action<SessionEvent> handler): IDisposable`

Subscribe to session events. Returns a disposable to unsubscribe.
Expand Down
Loading
Loading