Skip to content
Open
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
60 changes: 60 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,66 @@ var session = await client.CreateSessionAsync(new SessionConfig
});
```

### In-memory skills (experimental)

Use `SessionConfig.SkillProvider` to supply text-only skills from application storage
without writing skill files or replacing the native `skill` tool. The runtime requests
metadata through `ListAsync` and reads the complete Markdown lazily through `ReadAsync`.

```csharp
#pragma warning disable GHCP001 // Experimental native skill-provider API.
using GitHub.Copilot;

await using var client = new CopilotClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
SkillProvider = new ApplicationSkills(),
EnableSkills = true,
OnPermissionRequest = PermissionHandler.ApproveAll,
});

sealed class ApplicationSkills : SkillProvider
{
public override Task<IReadOnlyList<SkillProviderDescriptor>> ListAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<IReadOnlyList<SkillProviderDescriptor>>(
[
new() { Name = "release-review", Description = "Review a release checklist." }
]);
}

public override Task<string> ReadAsync(
string name, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (name != "release-review")
throw new KeyNotFoundException(name);

return Task.FromResult("""
---
name: release-review
description: Review a release checklist.
---
Check the version, changelog, and test results before recommending a release.
""");
}
}
```

- The provider uses experimental native callbacks; it does not expose supporting files or assets.
- Cloud sessions do not support skill providers, even with an explicit session ID.
- Markdown frontmatter must agree with the catalog descriptor. Names must be unique
case-insensitively and match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`.
- Catalogs are limited to 1,024 descriptors and 1 MiB of aggregate metadata. Each
complete Markdown response is limited to 1 MiB of UTF-8 text.
- Implementations must support concurrent calls and honor cancellation.
- Re-supply `SkillProvider` on resume. Its binding is not serialized or persisted.
- `EnableSkills = false` leaves the provider bound but dormant. Set it to `true`
explicitly when using `CopilotClientMode.Empty`.
- Existing `SkillDirectories` can be used alongside provider skills.

### Multiple Sessions

```csharp
Expand Down
49 changes: 45 additions & 4 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,7 @@ private CopilotSession InitializeSession(
session.RegisterElicitationHandler(config.OnElicitationRequest);
session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest);
session.RegisterAutoModeSwitchHandler(config.OnAutoModeSwitchRequest);
session.RegisterSkillProvider(config.SkillProvider);
if (config.OnUserInputRequest != null)
{
session.RegisterUserInputHandler(config.OnUserInputRequest);
Expand Down Expand Up @@ -1170,6 +1171,12 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
{
ArgumentNullException.ThrowIfNull(config);
ValidateGitHubTokenConfig(config);
if (config.SkillProvider is not null && config.Cloud is not null)
{
throw new ArgumentException(
"SkillProvider is not supported for cloud sessions, including those with an explicit session ID.",
nameof(config));
}

var connection = await EnsureConnectedAsync(cancellationToken);
var totalTimestamp = Stopwatch.GetTimestamp();
Expand Down Expand Up @@ -1302,7 +1309,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
GitHubMcpToolConfig: config.GitHubMcpToolConfig,
ManagedSettings: config.ManagedSettings,
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null,
AdditionalDirectories: config.AdditionalDirectories);
AdditionalDirectories: config.AdditionalDirectories,
HasSkillProvider: config.SkillProvider is not null ? true : null);

var rpcTimestamp = Stopwatch.GetTimestamp();

Expand Down Expand Up @@ -1545,7 +1553,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
GitHubMcpToolConfig: config.GitHubMcpToolConfig,
ManagedSettings: config.ManagedSettings,
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null,
AdditionalDirectories: config.AdditionalDirectories);
AdditionalDirectories: config.AdditionalDirectories,
HasSkillProvider: config.SkillProvider is not null ? true : null);

var rpcTimestamp = Stopwatch.GetTimestamp();
var response = await InvokeRpcAsync<ResumeSessionResponse>(
Expand Down Expand Up @@ -1758,6 +1767,7 @@ public async Task DeleteSessionAsync(string sessionId, CancellationToken cancell
if (_sessions.TryRemove(sessionId, out var session))
{
session.ReleaseGitHubTokenProviderRegistration();
session.RegisterSkillProvider(null);
}
}

Expand Down Expand Up @@ -2689,6 +2699,9 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
rpc.SetLocalRpcMethod("autoModeSwitch.request", handler.OnAutoModeSwitchRequest);
rpc.SetLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
rpc.SetLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform);
// Internal native callbacks are intentionally absent from the generated client-session API.
rpc.SetLocalRpcMethod("skillProvider.list", handler.OnSkillProviderList, singleObjectParam: true);
rpc.SetLocalRpcMethod("skillProvider.read", handler.OnSkillProviderRead, singleObjectParam: true);
ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId =>
{
var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
Expand Down Expand Up @@ -2931,6 +2944,20 @@ public async ValueTask<SystemMessageTransformRpcResponse> OnSystemMessageTransfo
return await session.HandleSystemMessageTransformAsync(sections);
}

public async ValueTask<SkillProviderListResponse> OnSkillProviderList(SkillProviderListRequest request, CancellationToken cancellationToken)
{
var session = client.GetSession(request.SessionId) ?? throw new ArgumentException($"Unknown session {request.SessionId}");
var skills = await session.HandleSkillProviderListAsync(cancellationToken).ConfigureAwait(false);
return new SkillProviderListResponse(skills);
}

public async ValueTask<SkillProviderReadResponse> OnSkillProviderRead(SkillProviderReadRequest request, CancellationToken cancellationToken)
{
var session = client.GetSession(request.SessionId) ?? throw new ArgumentException($"Unknown session {request.SessionId}");
var markdown = await session.HandleSkillProviderReadAsync(request.Name, cancellationToken).ConfigureAwait(false);
return new SkillProviderReadResponse(markdown);
}

}

private class Connection(
Expand Down Expand Up @@ -3087,7 +3114,8 @@ internal record CreateSessionRequest(
[property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null,
bool? EnableGitHubTelemetryForwarding = null,
[property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null,
IList<string>? AdditionalDirectories = null);
IList<string>? AdditionalDirectories = null,
bool? HasSkillProvider = null);
#pragma warning restore GHCP001

internal record ToolDefinition(
Expand Down Expand Up @@ -3207,7 +3235,8 @@ internal record ResumeSessionRequest(
[property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null,
bool? EnableGitHubTelemetryForwarding = null,
[property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null,
IList<string>? AdditionalDirectories = null);
IList<string>? AdditionalDirectories = null,
bool? HasSkillProvider = null);
#pragma warning restore GHCP001

internal record ResumeSessionResponse(
Expand Down Expand Up @@ -3300,6 +3329,14 @@ internal record AutoModeSwitchRequestResponse(
internal record HooksInvokeResponse(
object? Output);

internal record SkillProviderListRequest(string SessionId);

internal record SkillProviderListResponse(IReadOnlyList<SkillProviderDescriptor> Skills);

internal record SkillProviderReadRequest(string SessionId, string Name);

internal record SkillProviderReadResponse(string Markdown);

[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
AllowOutOfOrderMetadataProperties = true,
Expand Down Expand Up @@ -3338,6 +3375,10 @@ internal record HooksInvokeResponse(
[JsonSerializable(typeof(SessionUiCapabilities))]
[JsonSerializable(typeof(SessionMetadata))]
[JsonSerializable(typeof(SetForegroundSessionRequest))]
[JsonSerializable(typeof(SkillProviderListRequest))]
[JsonSerializable(typeof(SkillProviderListResponse))]
[JsonSerializable(typeof(SkillProviderReadRequest))]
[JsonSerializable(typeof(SkillProviderReadResponse))]
[JsonSerializable(typeof(SystemMessageConfig))]
[JsonSerializable(typeof(SystemMessageTransformRpcResponse))]
[JsonSerializable(typeof(CommandWireDefinition))]
Expand Down
20 changes: 20 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public sealed partial class CopilotSession : IAsyncDisposable
private volatile Func<ElicitationContext, Task<ElicitationResult>>? _elicitationHandler;
private volatile Func<ExitPlanModeRequest, ExitPlanModeInvocation, Task<ExitPlanModeResult>>? _exitPlanModeHandler;
private volatile Func<AutoModeSwitchRequest, AutoModeSwitchInvocation, Task<AutoModeSwitchResponse>>? _autoModeSwitchHandler;
private volatile SkillProvider? _skillProvider;
private ImmutableArray<EventSubscription> _eventHandlers = ImmutableArray<EventSubscription>.Empty;

private sealed record EventSubscription(Type EventType, Action<SessionEvent> Handler);
Expand Down Expand Up @@ -206,6 +207,7 @@ internal CopilotSession(
internal void RemoveFromClient()
{
((ICollection<KeyValuePair<string, CopilotSession>>)_parentClient._sessions).Remove(new(SessionId, this));
_skillProvider = null;
}

/// <summary>
Expand Down Expand Up @@ -1229,6 +1231,24 @@ internal void RegisterAutoModeSwitchHandler(Func<AutoModeSwitchRequest, AutoMode
_autoModeSwitchHandler = handler;
}

internal void RegisterSkillProvider(SkillProvider? provider)
{
_skillProvider = provider;
}

internal Task<IReadOnlyList<SkillProviderDescriptor>> HandleSkillProviderListAsync(CancellationToken cancellationToken)
{
var provider = _skillProvider ?? throw new InvalidOperationException($"No skill provider registered for session {SessionId}");
return provider.ListAsync(cancellationToken);
}

internal Task<string> HandleSkillProviderReadAsync(string name, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(name);
var provider = _skillProvider ?? throw new InvalidOperationException($"No skill provider registered for session {SessionId}");
return provider.ReadAsync(name, cancellationToken);
}

/// <summary>
/// Registers per-provider <c>BearerTokenProvider</c> callbacks for BYOK
/// providers configured with managed-identity / on-demand bearer-token auth.
Expand Down
88 changes: 88 additions & 0 deletions dotnet/src/SkillProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using System.Diagnostics.CodeAnalysis;

namespace GitHub.Copilot;

/// <summary>
/// Supplies in-memory, text-only skills to the native runtime's built-in <c>skill</c> tool.
/// </summary>
/// <remarks>
/// <para>
/// This experimental API uses internal native runtime callbacks. The runtime requests catalog
/// metadata first and reads a skill's Markdown lazily when it is invoked. No skill files are
/// written, and supporting files, scripts, and other filesystem assets are not provided.
/// </para>
/// <para>
/// Set <see cref="SessionConfigBase.SkillProvider"/> before creating or resuming a session.
/// The binding is not persisted and must be supplied again on resume. Implementations should
/// support concurrent callbacks and honor the supplied cancellation token.
/// </para>
/// </remarks>
[Experimental(Diagnostics.Experimental)]
public abstract class SkillProvider
{
/// <summary>
/// Lists skill metadata without loading the skills' Markdown bodies.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the runtime callback.</param>
/// <returns>
/// The skill catalog, or an empty list when no skills are available. Names must be unique
/// using a case-insensitive comparison and satisfy <see cref="SkillProviderDescriptor.Name"/>.
/// The native runtime limits catalogs to 1,024 descriptors and 1 MiB of aggregate metadata.
/// </returns>
public abstract Task<IReadOnlyList<SkillProviderDescriptor>> ListAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Reads the complete <c>SKILL.md</c>-format Markdown for a catalog entry.
/// </summary>
/// <param name="name">The name of the skill to read.</param>
/// <param name="cancellationToken">Cancellation token for the runtime callback.</param>
/// <returns>
/// The complete Markdown, including YAML frontmatter matching the metadata returned by
/// <see cref="ListAsync"/>. The native runtime rejects inconsistent metadata.
/// The complete response text must not exceed 1 MiB when encoded as UTF-8.
/// </returns>
/// <remarks>Throw if the named skill is unavailable; do not return a file path.</remarks>
public abstract Task<string> ReadAsync(string name, CancellationToken cancellationToken = default);
}

/// <summary>
/// Catalog metadata for an experimental, text-only native skill supplied by <see cref="SkillProvider"/>.
/// </summary>
/// <remarks>
/// The native runtime validates the catalog and its agreement with each skill's Markdown
/// frontmatter. Provider skills are pathless: the runtime assigns source <c>sdk</c> and an empty path.
/// </remarks>
[Experimental(Diagnostics.Experimental)]
public sealed class SkillProviderDescriptor
{
/// <summary>
/// Gets the pathless skill name. Must match <c>^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$</c>
/// and be unique in the catalog using a case-insensitive comparison.
/// </summary>
public required string Name { get; init; }

/// <summary>Gets the description advertised before the skill's Markdown is read.</summary>
public required string Description { get; init; }

/// <summary>
/// Gets whether the skill is user-invocable. When omitted, the native runtime default applies.
/// Must agree with the Markdown frontmatter's <c>user-invocable</c> value.
/// </summary>
public bool? UserInvocable { get; init; }

/// <summary>
/// Gets whether model invocation is disabled. When omitted, the native runtime default applies.
/// Must agree with the Markdown frontmatter's <c>disable-model-invocation</c> value.
/// </summary>
public bool? DisableModelInvocation { get; init; }

/// <summary>
/// Gets the optional argument hint. Must agree with the Markdown frontmatter's
/// <c>argument-hint</c> value.
/// </summary>
public string? ArgumentHint { get; init; }
}
26 changes: 21 additions & 5 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2883,8 +2883,9 @@ public sealed class CustomAgentConfig
/// <summary>
/// List of skill names to preload into this agent's context.
/// When set, the full content of each listed skill is eagerly injected into
/// the agent's context at startup. Skills are resolved by name from the
/// session's configured skill directories (<see cref="SessionConfigBase.SkillDirectories"/>).
/// the agent's context at startup. Skills are resolved by name from the session's native
/// catalog, including <see cref="SessionConfigBase.SkillDirectories"/> and
/// <see cref="SessionConfigBase.SkillProvider"/>.
/// When omitted, no skills are injected (opt-in model).
/// </summary>
[JsonPropertyName("skills")]
Expand Down Expand Up @@ -3371,6 +3372,7 @@ protected SessionConfigBase(SessionConfigBase? other)
CanvasHandler = other.CanvasHandler;
#pragma warning restore GHCP001
SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null;
SkillProvider = other.SkillProvider;
PluginDirectories = other.PluginDirectories is not null ? [.. other.PluginDirectories] : null;
InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null;
SessionLimits = other.SessionLimits;
Expand Down Expand Up @@ -3497,9 +3499,9 @@ protected SessionConfigBase(SessionConfigBase? other)

/// <summary>
/// When <see langword="true"/>, enables skill loading, including built-in
/// skills and discovered skill directories. When <see langword="false"/>, no
/// skills are loaded regardless of <see cref="SkillDirectories"/> or
/// <see cref="EnableConfigDiscovery"/>.
/// skills, discovered skill directories, and <see cref="SkillProvider"/>.
/// When <see langword="false"/>, no skills are loaded regardless of
/// <see cref="SkillDirectories"/>, <see cref="SkillProvider"/>, or <see cref="EnableConfigDiscovery"/>.
/// </summary>
public bool? EnableSkills { get; set; }

Expand Down Expand Up @@ -3747,6 +3749,20 @@ protected SessionConfigBase(SessionConfigBase? other)
/// <summary>Directories to load skills from.</summary>
public IList<string>? SkillDirectories { get; set; }

/// <summary>
/// Gets or sets an experimental, in-memory, text-only provider for the native <c>skill</c> tool.
/// </summary>
/// <remarks>
/// The provider is registered before the create/resume request and is not serialized or persisted.
/// Supply it again on resume. Setting <see cref="EnableSkills"/> to <see langword="false"/>
/// keeps the provider bound but prevents the runtime from loading skills. File-based skills may
/// coexist with provider skills. Cloud sessions do not support providers, regardless of whether
/// the caller supplies a session ID.
/// </remarks>
[Experimental(Diagnostics.Experimental)]
[JsonIgnore]
public SkillProvider? SkillProvider { get; set; }

/// <summary>
/// Local filesystem paths to Open Plugins-format directories
/// (https://open-plugins.com/) to load for this session.
Expand Down
Loading