diff --git a/Directory.Packages.props b/Directory.Packages.props
index 33e04ffc8..cafd4b063 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -124,6 +124,7 @@
+
diff --git a/specs/008-weaver-ai-copilot/contracts/runtime-contract.md b/specs/008-weaver-ai-copilot/contracts/runtime-contract.md
index a05bf9ba7..8d2b17eb9 100644
--- a/specs/008-weaver-ai-copilot/contracts/runtime-contract.md
+++ b/specs/008-weaver-ai-copilot/contracts/runtime-contract.md
@@ -7,7 +7,7 @@ public interface IAIProvider
{
string Name { get; }
ValueTask CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default);
- IAsyncEnumerable ExecuteTurnAsync(AITurnRequest request, CancellationToken cancellationToken = default);
+ IAsyncEnumerable ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, CancellationToken cancellationToken = default);
}
public interface IAIOrchestrator
@@ -27,6 +27,11 @@ public interface IAIToolRegistry
ValueTask FindAsync(string name, AIToolQuery query, CancellationToken cancellationToken = default);
}
+public interface IAIProviderToolInvoker
+{
+ ValueTask InvokeAsync(AIProviderToolInvocation invocation, CancellationToken cancellationToken = default);
+}
+
public interface IAIContextProvider
{
string Kind { get; }
@@ -50,9 +55,16 @@ public interface IAIAuditSink
## Provider Boundary
- `Elsa.AI.Abstractions` owns `AIProviderEvent`, `AIStreamEvent`, session, tool, context, proposal, and audit models.
-- `Elsa.AI.Copilot` maps Copilot SDK and CLI events into Elsa-owned models.
+- `Elsa.AI.Host` resolves context and the authorized tool set, but does not run a model/tool continuation loop.
+- `Elsa.AI.Copilot` uses `GitHub.Copilot.SDK` sessions directly, registers Elsa tools as governed Copilot SDK callbacks, and maps Copilot SDK events into Elsa-owned models.
- No Copilot SDK type may appear in `Elsa.AI.Abstractions`, `Elsa.AI.Host`, workflow models, REST contracts, or Studio contracts.
+## Agent Loop Ownership
+
+- Copilot SDK owns agent planning, tool invocation sequencing, continuation turns, custom agent selection, MCP wiring, permission callbacks, hooks, and provider session state.
+- Elsa Host owns tenant-safe context attachment resolution, RBAC-filtered tool metadata, server-side tool execution, proposal-only mutation enforcement, audit, redaction, and Studio stream contracts.
+- Provider adapters may expose provider-specific features through provider configuration metadata, but those details remain inside the adapter module and are mapped to Elsa-owned contracts before reaching Studio.
+
## Built-In MVP Tools
| Tool | Mutability | Purpose |
diff --git a/specs/008-weaver-ai-copilot/plan.md b/specs/008-weaver-ai-copilot/plan.md
index ed782a60e..502d51fea 100644
--- a/specs/008-weaver-ai-copilot/plan.md
+++ b/specs/008-weaver-ai-copilot/plan.md
@@ -5,19 +5,19 @@
## Summary
-Introduce Weaver as Elsa's AI copilot platform: a server-hosted, provider-isolated AI orchestration layer with Studio chat, governed tool execution, context providers, streaming events, durable audit records, and durable proposal-only workflow mutations. The first delivery establishes `Elsa.AI.Abstractions`, `Elsa.AI.Host`, `Elsa.AI.Copilot`, durable proposal/audit persistence, and a paired `Elsa.Studio.AI` module, with read-only workflow/runtime tools and safe workflow proposal flows.
+Introduce Weaver as Elsa's AI copilot platform: a server-hosted, GitHub Copilot SDK-native agent experience with Studio chat, governed Elsa tool execution, context providers, streaming events, durable audit records, and durable proposal-only workflow mutations. The first delivery establishes `Elsa.AI.Abstractions`, `Elsa.AI.Host`, `Elsa.AI.Copilot`, durable proposal/audit persistence, and a paired `Elsa.Studio.AI` module, with read-only workflow/runtime tools and safe workflow proposal flows. Copilot SDK owns session orchestration, model/tool continuation, custom agents, MCP wiring, hooks, and permission callbacks; Elsa owns Studio-facing contracts, context resolution, tenancy/RBAC checks, proposal safety, audit, and redaction.
## Technical Context
**Language/Version**: C# latest, nullable reference types enabled, implicit usings enabled; paired Studio Blazor/Razor module work in the Studio repository.
-**Primary Dependencies**: Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, existing identity/authorization and tenancy services, workflow definition/instance abstractions, diagnostics/log abstractions, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Logging`, OpenTelemetry, `System.Text.Json`, SignalR or SSE streaming, GitHub Copilot SDK isolated behind `Elsa.AI.Copilot`, and headless Copilot CLI JSON-RPC integration.
+**Primary Dependencies**: Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, existing identity/authorization and tenancy services, workflow definition/instance abstractions, diagnostics/log abstractions, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Logging`, OpenTelemetry, `System.Text.Json`, SignalR or SSE streaming, `GitHub.Copilot.SDK` isolated behind `Elsa.AI.Copilot`, and the SDK-managed Copilot runtime connection.
**Storage**: Configurable conversation/session retention with in-memory support for development and tests; durable proposal and audit stores required for MVP using Elsa persistence provider abstractions and an EF Core provider package for production.
**Testing**: xUnit unit tests for abstractions, tool metadata, authorization gates, context redaction, proposal lifecycle, persistence state transitions, explicit tool enablement, reconnect handling, and adapter mapping; integration tests for chat streaming, tool invocation, capabilities, proposal apply, tenant isolation, durable audit records, durable proposals, and scoped trend analysis; contract tests for Studio-facing API and stream event shapes.
**Target Platform**: ASP.NET Core Elsa Server on supported .NET target frameworks, plus Elsa Studio SPA integration through server APIs only.
**Project Type**: Modular .NET server libraries with REST/streaming APIs and a paired Studio UI module.
**Performance Goals**: First streamed chat event within 3 seconds p95 after server accepts a turn under normal load; tool metadata/capabilities under 250 ms p95; proposal validation under 5 seconds p95 for typical workflow definitions; bounded server-side context payloads.
-**Constraints**: Studio is AI-agnostic; AI runtime is server-hosted; provider SDK types cannot leak into core abstractions, workflow models, or Studio contracts; AI writes are proposal-only; same authorized user may request/approve/apply proposals in MVP; all tools enforce tenant/RBAC/ownership server-side; proposal/admin/MCP tools require explicit administrator enablement; runtime trend analysis is scoped to attached references plus selected time range and diagnostics scope; secrets and sensitive config are redacted before model context, stream output, and audit records; implementation execution should use dedicated git worktrees for Core and paired Studio work rather than using a local primary checkout directly.
-**Scale/Scope**: Server AI abstractions, Copilot adapter, chat/session orchestration with configurable disconnect grace, stream event translation, tool registry, context providers, durable audit sink, durable proposal store, MVP workflow/runtime tools, proposal apply endpoint, Studio chat/proposal UX contracts, and extension APIs for third-party tools/agents/MCP registrations.
+**Constraints**: Studio is AI-agnostic; AI runtime is server-hosted; provider SDK types cannot leak into core abstractions, workflow models, or Studio contracts; AI writes are proposal-only; same authorized user may request/approve/apply proposals in MVP; all Elsa tools enforce tenant/RBAC/ownership server-side before Copilot receives results; proposal/admin/MCP tools require explicit administrator enablement; runtime trend analysis is scoped to attached references plus selected time range and diagnostics scope; secrets and sensitive config are redacted before model context, stream output, and audit records; implementation execution should use dedicated git worktrees for Core and paired Studio work rather than using a local primary checkout directly.
+**Scale/Scope**: Server AI abstractions, Copilot SDK adapter, provider-owned chat/session orchestration with configurable disconnect grace at Elsa boundaries, stream event translation, tool registry, context providers, durable audit sink, durable proposal store, MVP workflow/runtime tools, proposal apply endpoint, Studio chat/proposal UX contracts, and extension APIs for third-party tools/agents/MCP registrations.
## Constitution Check
@@ -120,7 +120,7 @@ src/modules/
**UI Prototype Reference**: Review `elsa-extensions` branch `origin/feat/ai` at commit `93f0e09d71e57f5daff1e2d593f0a51faaa80417` and its parent chain before implementing Studio UI. Useful patterns include the Razor/MudBlazor Agents menu placement under `/ai/*`, management tables, route structure, Refit client interfaces, validators, and agent configuration tabs for general metadata, input/output variables, services, plugins, and execution settings. Do not carry forward raw API key reveal, provider-specific service configuration as the primary experience, or an agent-management-first flow; Weaver's first screen remains the chat/proposal experience.
-**Structure Decision**: Keep provider-neutral contracts in `Elsa.AI.Abstractions`, server orchestration, APIs, built-in tools, proposals, and audit in `Elsa.AI.Host`, and Copilot SDK/CLI integration in `Elsa.AI.Copilot`. The Studio module consumes only REST and streaming contracts; if the Studio repository is not present, its implementation tasks become a sibling-repository follow-up.
+**Structure Decision**: Keep provider-neutral contracts in `Elsa.AI.Abstractions`, server APIs, built-in tools, proposals, context, and audit in `Elsa.AI.Host`, and Copilot SDK integration in `Elsa.AI.Copilot`. The Copilot adapter must use `GitHub.Copilot.SDK` session APIs directly instead of reducing Copilot to a generic turn-completion API. `Elsa.AI.Host` prepares context and governed tool handles, then streams provider-owned agent events; it must not reimplement Copilot's tool continuation loop. The Studio module consumes only REST and streaming contracts; if the Studio repository is not present, its implementation tasks become a sibling-repository follow-up.
## Phase 0 Output
@@ -129,8 +129,10 @@ See [research.md](./research.md).
Resolved decisions:
- Use a server-hosted AI runtime with Studio sending only references.
-- Isolate GitHub Copilot SDK and headless CLI JSON-RPC behind `Elsa.AI.Copilot`.
+- Isolate `GitHub.Copilot.SDK` behind `Elsa.AI.Copilot`.
+- Let the Copilot SDK own session creation/resume, custom agents, MCP, hooks, permission callbacks, model selection, and tool continuation.
- Translate provider stream events into Elsa-owned stream contracts.
+- Pass Elsa tools to Copilot as governed SDK tool callbacks so Copilot plans and continues the agent loop while Elsa executes and audits the actual server-side capabilities.
- Use proposal-only writes for workflow creation and updates.
- Use configurable conversation retention, but require durable proposal and audit stores for MVP.
- Allow the same authorized user to request, approve, reject, and apply proposals in MVP, with explicit actions and durable audit records.
diff --git a/specs/008-weaver-ai-copilot/quickstart.md b/specs/008-weaver-ai-copilot/quickstart.md
index 7e427d91a..154ae80b6 100644
--- a/specs/008-weaver-ai-copilot/quickstart.md
+++ b/specs/008-weaver-ai-copilot/quickstart.md
@@ -11,6 +11,8 @@ Validate the MVP path for Weaver without relying on direct Studio-to-provider ca
5. Create a workflow proposal.
6. Validate, approve, apply, and durably audit the proposal.
+The Copilot adapter should use `GitHub.Copilot.SDK` as the agent runtime. Elsa Server supplies context, tool callbacks, redaction, audit, and proposal enforcement; it should not emulate Copilot's tool continuation loop.
+
## Worktree Setup
Implementation should run from dedicated git worktrees, not the primary local checkout. Create separate worktrees for Core and the paired Studio module so feature work, generated artifacts, and test output stay isolated:
@@ -33,7 +35,7 @@ services
ai.UseHost();
ai.UseCopilot(copilot =>
{
- copilot.CliPath = "copilot";
+ copilot.RuntimePath = "copilot";
copilot.Model = "configured-model";
});
});
@@ -47,14 +49,15 @@ services
3. Request `GET /ai/tools` as an authorized user and verify MVP tools are returned.
4. Start `POST /ai/chat` with a `WorkflowDefinition` attachment reference and ask Weaver to explain it.
5. Verify stream events include assistant deltas and any tool lifecycle events.
-6. Ask Weaver to generate a simple workflow.
-7. Verify a `proposal.created` event appears and `GET /ai/proposals/{id}` returns payload, rationale, warnings, diagnostics, and graph preview.
-8. Attempt to apply without approval and verify the server rejects the transition.
-9. Approve and apply the proposal as an authorized user.
-10. Verify the workflow is persisted, validation passed, and durable audit records exist for prompt, tool calls, approval, and apply.
-11. Restart the server with durable persistence configured and verify proposals and audit records are still available.
-12. Disconnect during a chat turn, reconnect within the configured grace window, and verify durable outputs produced while disconnected are recoverable.
-13. Ask for runtime trends using attached references plus a selected time range and diagnostics scope, then verify results do not include data outside that scope.
+6. Verify Elsa logs/audit show server-side tool execution and that Copilot SDK session events, not Host-managed continuation turns, drove the agent loop.
+7. Ask Weaver to generate a simple workflow.
+8. Verify a `proposal.created` event appears and `GET /ai/proposals/{id}` returns payload, rationale, warnings, diagnostics, and graph preview.
+9. Attempt to apply without approval and verify the server rejects the transition.
+10. Approve and apply the proposal as an authorized user.
+11. Verify the workflow is persisted, validation passed, and durable audit records exist for prompt, tool calls, approval, and apply.
+12. Restart the server with durable persistence configured and verify proposals and audit records are still available.
+13. Disconnect during a chat turn, reconnect within the configured grace window, and verify durable outputs produced while disconnected are recoverable.
+14. Ask for runtime trends using attached references plus a selected time range and diagnostics scope, then verify results do not include data outside that scope.
## Targeted Test Commands
diff --git a/specs/008-weaver-ai-copilot/research.md b/specs/008-weaver-ai-copilot/research.md
index f5b7ab695..8c124271b 100644
--- a/specs/008-weaver-ai-copilot/research.md
+++ b/specs/008-weaver-ai-copilot/research.md
@@ -29,9 +29,23 @@
- Expose Copilot SDK types in server contracts: rejected because it would make Elsa public APIs depend on preview runtime details.
- Build provider-specific behavior into `Elsa.AI.Host`: rejected because provider isolation is a core architectural requirement.
+## Decision: Let Copilot SDK own the agent loop
+
+**Rationale**: Weaver's strategic provider is GitHub Copilot SDK, not a generic chat-completion abstraction. The SDK already provides session creation/resume, streaming events, model selection, custom tools, permission handlers, hooks, custom agents, MCP server configuration, and persisted session state. Reimplementing the tool-call/continuation loop in `Elsa.AI.Host` would duplicate the SDK, reduce Copilot capabilities to a lowest-common-denominator protocol, and make custom agents/MCP/hooks harder to expose correctly.
+
+`Elsa.AI.Host` therefore prepares tenant-safe context, resolves the authorized tool set, records audit events, persists Elsa conversations/proposals, and maps provider events. `Elsa.AI.Copilot` creates/resumes Copilot SDK sessions, registers Elsa tools as SDK callbacks, configures Copilot agents/MCP/hooks, and streams SDK session events back as Elsa-owned events.
+
+**Sources**: [GitHub Copilot SDK agent loop](https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/agent-loop), [GitHub Copilot SDK custom tools](https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/tools), [GitHub Copilot SDK custom agents](https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/custom-agents), [GitHub Copilot SDK MCP](https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/mcp), [GitHub Copilot SDK hooks](https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/hooks)
+
+**Alternatives considered**:
+
+- Keep `Elsa.AI.Host` as the agent loop and treat Copilot as a stream of `tool.call` events: rejected because it loses SDK-native behavior and forces Elsa to own continuation semantics.
+- Pass Copilot SDK types through Studio and Elsa contracts: rejected because it couples public Elsa APIs to a provider SDK and weakens provider isolation.
+- Build a broad provider abstraction first: rejected because no second provider requirement is proven and it would likely constrain the Copilot-native MVP.
+
## Decision: Translate provider events into Elsa stream events
-**Rationale**: Copilot SDK emits assistant deltas, tool execution lifecycle events, permission/user-input events, session lifecycle events, and sub-agent events. Elsa should map those into stable `AIStreamEvent` contracts for Studio.
+**Rationale**: Copilot SDK emits assistant deltas, reasoning events, tool execution lifecycle events, permission/user-input events, session lifecycle events, and sub-agent events. Elsa should map those into stable `AIStreamEvent` contracts for Studio without leaking provider SDK types.
**Sources**: [Streaming events in the Copilot SDK](https://docs.github.com/en/enterprise-cloud%40latest/copilot/how-tos/copilot-sdk/use-copilot-sdk/streaming-events)
diff --git a/specs/008-weaver-ai-copilot/tasks.md b/specs/008-weaver-ai-copilot/tasks.md
index 50cf5f24e..cba141f18 100644
--- a/specs/008-weaver-ai-copilot/tasks.md
+++ b/specs/008-weaver-ai-copilot/tasks.md
@@ -97,13 +97,13 @@
- [X] T053 [P] [US1] Implement workflow instance context provider in `src/modules/Elsa.AI.Host/Context/WorkflowInstanceContextProvider.cs`.
- [X] T054 [US1] Implement context resolver with authorization and redaction in `src/modules/Elsa.AI.Host/Context/AIContextResolver.cs`.
- [X] T055 [US1] Implement AI tool registry with enablement filtering in `src/modules/Elsa.AI.Host/Services/AIToolRegistry.cs`.
-- [X] T056 [US1] Implement orchestrator turn flow in `src/modules/Elsa.AI.Host/Services/AIOrchestrator.cs`.
+- [X] T056 [US1] Implement host chat preparation, persistence, audit, and provider streaming flow in `src/modules/Elsa.AI.Host/Services/AIOrchestrator.cs`.
- [X] T057 [US1] Implement reconnect grace tracking in `src/modules/Elsa.AI.Host/Streaming/AIStreamSessionManager.cs`.
- [X] T058 [US1] Implement stream event mapper in `src/modules/Elsa.AI.Host/Streaming/AIStreamEventMapper.cs`.
- [X] T059 [US1] Implement chat endpoint in `src/modules/Elsa.AI.Host/Endpoints/AI/Chat/Endpoint.cs`.
- [X] T060 [US1] Implement tools endpoint in `src/modules/Elsa.AI.Host/Endpoints/AI/Tools/Endpoint.cs`.
- [X] T061 [US1] Implement capabilities endpoint in `src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs`.
-- [X] T062 [US1] Implement Copilot provider event adapter in `src/modules/Elsa.AI.Copilot/Adapters/CopilotProvider.cs`.
+- [X] T062R [US1] Replace placeholder Copilot provider with `GitHub.Copilot.SDK` session create/resume, SDK-owned agent loop, event mapping, and governed Elsa tool callbacks in `src/modules/Elsa.AI.Copilot/Adapters/CopilotProvider.cs`.
- [X] T063 [US1] Implement Copilot feature and shell feature registration in `src/modules/Elsa.AI.Copilot/ShellFeatures/CopilotAIFeature.cs`.
- [X] T064 [US1] Draft paired Studio Razor chat panel implementation, after reviewing the `elsa-extensions` `origin/feat/ai` UI prototype at commit `93f0e09d71e57f5daff1e2d593f0a51faaa80417`, in `../elsa-studio/src/modules/Elsa.Studio.AI/UI/Components/WeaverChatPanel.razor`.
- [X] T065 [US1] Run chat MVP tests in `test/integration/Elsa.AI.IntegrationTests/Elsa.AI.IntegrationTests.csproj`.
@@ -245,7 +245,8 @@
**Purpose**: Close cross-cutting governance gaps, validate boundaries, update documentation, and run targeted checks.
-- [ ] T131 [P] Update implementation quickstart notes in `specs/008-weaver-ai-copilot/quickstart.md`.
+- [X] T131 [P] Update implementation quickstart notes in `specs/008-weaver-ai-copilot/quickstart.md`.
+- [X] T131A [P] Add Copilot-native runtime boundary tests proving Host does not execute provider continuation loops and `Elsa.AI.Copilot` depends on `GitHub.Copilot.SDK`.
- [ ] T132 [P] Add API documentation in `src/modules/Elsa.AI.Host/README.md`.
- [ ] T133 [P] Add Copilot adapter documentation in `src/modules/Elsa.AI.Copilot/README.md`.
- [ ] T134 [P] Add persistence provider documentation in `src/modules/Elsa.AI.Persistence.EFCore/README.md`.
diff --git a/src/modules/Elsa.AI.Abstractions/Contracts/IAIProvider.cs b/src/modules/Elsa.AI.Abstractions/Contracts/IAIProvider.cs
index 591aa2f92..eb7f8a800 100644
--- a/src/modules/Elsa.AI.Abstractions/Contracts/IAIProvider.cs
+++ b/src/modules/Elsa.AI.Abstractions/Contracts/IAIProvider.cs
@@ -6,10 +6,15 @@ public interface IAIProvider
{
string Name { get; }
ValueTask CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default);
- IAsyncEnumerable ExecuteTurnAsync(AITurnRequest request, CancellationToken cancellationToken = default);
+ IAsyncEnumerable ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, CancellationToken cancellationToken = default);
}
public interface IAIOrchestrator
{
IAsyncEnumerable ExecuteChatAsync(AIChatRequest request, CancellationToken cancellationToken = default);
}
+
+public interface IAIProviderToolInvoker
+{
+ ValueTask InvokeAsync(AIProviderToolInvocation invocation, CancellationToken cancellationToken = default);
+}
diff --git a/src/modules/Elsa.AI.Abstractions/Models/AIConversation.cs b/src/modules/Elsa.AI.Abstractions/Models/AIConversation.cs
index c90f5ccaf..c951d0f9a 100644
--- a/src/modules/Elsa.AI.Abstractions/Models/AIConversation.cs
+++ b/src/modules/Elsa.AI.Abstractions/Models/AIConversation.cs
@@ -71,7 +71,6 @@ public record AITurnRequest
public IReadOnlyCollection Messages { get; init; } = [];
public IReadOnlyCollection Context { get; init; } = [];
public IReadOnlyCollection Tools { get; init; } = [];
- public IReadOnlyCollection ToolResults { get; init; } = [];
public string? Agent { get; init; }
public AIProviderConfiguration? ProviderConfiguration { get; init; }
}
@@ -85,11 +84,11 @@ public record AIProviderConfiguration
public string? Endpoint { get; init; }
}
-public record AIToolTurnResult
+public record AIProviderToolInvocation
{
- public string ToolCallId { get; init; } = default!;
+ public string Id { get; init; } = Guid.NewGuid().ToString("N");
public string ToolName { get; init; } = default!;
- public AIToolResult Result { get; init; } = new();
+ public JsonObject Arguments { get; init; } = [];
}
public record AIProviderEvent
diff --git a/src/modules/Elsa.AI.Copilot/Adapters/CopilotProvider.cs b/src/modules/Elsa.AI.Copilot/Adapters/CopilotProvider.cs
index 9f7a08eee..976a41cc4 100644
--- a/src/modules/Elsa.AI.Copilot/Adapters/CopilotProvider.cs
+++ b/src/modules/Elsa.AI.Copilot/Adapters/CopilotProvider.cs
@@ -1,11 +1,20 @@
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading.Channels;
using Elsa.AI.Abstractions.Contracts;
using Elsa.AI.Abstractions.Models;
using Elsa.AI.Copilot.Options;
+using GitHub.Copilot;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.AI.Copilot.Adapters;
-public class CopilotProvider(IOptions options) : IAIProvider
+public class CopilotProvider(
+ IOptions options,
+ CopilotSessionEventMapper eventMapper,
+ ILogger logger) : IAIProvider
{
public string Name => options.Value.ProviderName ?? "copilot";
@@ -20,19 +29,162 @@ public class CopilotProvider(IOptions options) : IAIProvider
});
}
- public async IAsyncEnumerable ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable ExecuteTurnAsync(
+ AITurnRequest request,
+ IAIProviderToolInvoker toolInvoker,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- await Task.Yield();
+ var copilotOptions = options.Value;
+ await using var client = CreateClient(copilotOptions);
+ await client.StartAsync(cancellationToken);
- yield return new AIProviderEvent
+ await using var session = await CreateOrResumeSessionAsync(client, request, toolInvoker, cancellationToken);
+ var events = Channel.CreateUnbounded(new UnboundedChannelOptions
{
- Type = "assistant.delta",
- Sequence = 1,
- Timestamp = DateTimeOffset.UtcNow,
- Data = new JsonObject
+ SingleReader = true,
+ SingleWriter = false
+ });
+
+ using var subscription = session.On(sessionEvent =>
+ {
+ foreach (var providerEvent in eventMapper.Map(sessionEvent))
+ events.Writer.TryWrite(providerEvent);
+
+ if (sessionEvent is SessionIdleEvent or SessionErrorEvent)
+ events.Writer.TryComplete();
+ });
+
+ try
+ {
+ await session.SendAsync(new MessageOptions
{
- ["content"] = "Copilot adapter is registered. Runtime CLI integration is deferred to the provider implementation slice."
- }
+ Prompt = BuildPrompt(request),
+ DisplayPrompt = request.Message
+ }, cancellationToken);
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ events.Writer.TryComplete(e);
+ }
+
+ await foreach (var providerEvent in events.Reader.ReadAllAsync(cancellationToken))
+ yield return providerEvent;
+ }
+
+ private CopilotClient CreateClient(CopilotOptions copilotOptions)
+ {
+ var clientOptions = new CopilotClientOptions
+ {
+ Connection = CreateRuntimeConnection(copilotOptions),
+ WorkingDirectory = copilotOptions.WorkingDirectory,
+ BaseDirectory = copilotOptions.BaseDirectory,
+ GitHubToken = copilotOptions.GitHubToken,
+ UseLoggedInUser = copilotOptions.UseLoggedInUser,
+ Logger = logger
};
+
+ return new CopilotClient(clientOptions);
+ }
+
+ private static RuntimeConnection? CreateRuntimeConnection(CopilotOptions copilotOptions)
+ {
+ if (!string.IsNullOrWhiteSpace(copilotOptions.RuntimeUrl))
+ return RuntimeConnection.ForUri(copilotOptions.RuntimeUrl, copilotOptions.ConnectionToken);
+
+ if (!string.IsNullOrWhiteSpace(copilotOptions.RuntimePath) || copilotOptions.RuntimeArguments.Count > 0)
+ return RuntimeConnection.ForStdio(copilotOptions.RuntimePath, copilotOptions.RuntimeArguments.ToList());
+
+ return null;
+ }
+
+ private async Task CreateOrResumeSessionAsync(CopilotClient client, AITurnRequest request, IAIProviderToolInvoker toolInvoker, CancellationToken cancellationToken)
+ {
+ var providerSessionId = NormalizeSessionId(request.ProviderSessionId) ?? request.ConversationId;
+ var resumeConfig = ConfigureSession(new ResumeSessionConfig
+ {
+ ContinuePendingWork = true,
+ SuppressResumeEvent = true
+ }, request, toolInvoker);
+
+ try
+ {
+ return await client.ResumeSessionAsync(providerSessionId, resumeConfig, cancellationToken);
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ logger.LogDebug(e, "Copilot session {ProviderSessionId} could not be resumed; creating a new session.", providerSessionId);
+ }
+
+ var createConfig = ConfigureSession(new SessionConfig
+ {
+ SessionId = providerSessionId
+ }, request, toolInvoker);
+
+ return await client.CreateSessionAsync(createConfig, cancellationToken);
+ }
+
+ private T ConfigureSession(T config, AITurnRequest request, IAIProviderToolInvoker toolInvoker) where T : SessionConfigBase
+ {
+ var copilotOptions = options.Value;
+ var providerConfiguration = request.ProviderConfiguration;
+ var model = providerConfiguration?.Model ?? copilotOptions.Model;
+
+ config.ClientName = "Elsa Weaver";
+ config.Model = model;
+ config.ReasoningEffort = copilotOptions.ReasoningEffort;
+ config.Streaming = copilotOptions.EnableStreaming;
+ config.IncludeSubAgentStreamingEvents = copilotOptions.IncludeSubAgentStreamingEvents;
+ config.Tools = CreateTools(request.Tools, toolInvoker);
+ config.AvailableTools = request.Tools.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ config.OnPermissionRequest = PermissionHandler.ApproveAll;
+
+ if (!string.IsNullOrWhiteSpace(providerConfiguration?.Endpoint))
+ config.Provider = new ProviderConfig
+ {
+ Type = providerConfiguration.Provider,
+ BaseUrl = providerConfiguration.Endpoint,
+ ModelId = model
+ };
+
+ return config;
+ }
+
+ private static ICollection CreateTools(IReadOnlyCollection tools, IAIProviderToolInvoker toolInvoker) =>
+ tools
+ .Where(x => !string.IsNullOrWhiteSpace(x.Name))
+ .Select(x => (AIFunctionDeclaration)new ElsaCopilotToolFunction(x, toolInvoker))
+ .ToList();
+
+ private static string BuildPrompt(AITurnRequest request)
+ {
+ if (request.Context.Count == 0)
+ return request.Message;
+
+ var prompt = new StringBuilder();
+ prompt.AppendLine(request.Message);
+ prompt.AppendLine();
+ prompt.AppendLine("Elsa context references resolved by the server:");
+
+ foreach (var context in request.Context)
+ {
+ prompt.AppendLine();
+ prompt.AppendLine($"- Kind: {context.Kind}");
+ prompt.AppendLine($" ReferenceId: {context.ReferenceId}");
+ if (!string.IsNullOrWhiteSpace(context.Summary))
+ prompt.AppendLine($" Summary: {context.Summary}");
+ if (context.Data.Count > 0)
+ prompt.AppendLine($" Data: {context.Data}");
+ }
+
+ return prompt.ToString();
+ }
+
+ private static string? NormalizeSessionId(string? providerSessionId)
+ {
+ if (string.IsNullOrWhiteSpace(providerSessionId))
+ return null;
+
+ var separatorIndex = providerSessionId.IndexOf(':', StringComparison.Ordinal);
+ return separatorIndex < 0 ? providerSessionId : providerSessionId[(separatorIndex + 1)..];
}
}
diff --git a/src/modules/Elsa.AI.Copilot/Adapters/CopilotSessionEventMapper.cs b/src/modules/Elsa.AI.Copilot/Adapters/CopilotSessionEventMapper.cs
new file mode 100644
index 000000000..51aaf1264
--- /dev/null
+++ b/src/modules/Elsa.AI.Copilot/Adapters/CopilotSessionEventMapper.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using Elsa.AI.Abstractions.Models;
+using GitHub.Copilot;
+
+namespace Elsa.AI.Copilot.Adapters;
+
+public class CopilotSessionEventMapper
+{
+ public IEnumerable Map(SessionEvent sessionEvent)
+ {
+ var timestamp = sessionEvent.Timestamp == default ? DateTimeOffset.UtcNow : sessionEvent.Timestamp;
+ var sequence = 0L;
+
+ switch (sessionEvent)
+ {
+ case AssistantMessageDeltaEvent assistantDelta when !string.IsNullOrEmpty(assistantDelta.Data?.DeltaContent):
+ yield return Create("assistant.delta", sequence++, timestamp, new JsonObject
+ {
+ ["content"] = assistantDelta.Data.DeltaContent,
+ ["messageId"] = assistantDelta.Data.MessageId,
+ ["parentToolCallId"] = assistantDelta.Data.ParentToolCallId
+ });
+ break;
+ case AssistantMessageEvent assistantMessage when !string.IsNullOrEmpty(assistantMessage.Data?.Content):
+ yield return Create("assistant.message", sequence++, timestamp, new JsonObject
+ {
+ ["content"] = assistantMessage.Data.Content,
+ ["messageId"] = assistantMessage.Data.MessageId,
+ ["model"] = assistantMessage.Data.Model,
+ ["turnId"] = assistantMessage.Data.TurnId
+ });
+ break;
+ case AssistantReasoningDeltaEvent reasoningDelta when !string.IsNullOrEmpty(reasoningDelta.Data?.DeltaContent):
+ yield return Create("assistant.reasoning.delta", sequence++, timestamp, new JsonObject
+ {
+ ["content"] = reasoningDelta.Data.DeltaContent,
+ ["reasoningId"] = reasoningDelta.Data.ReasoningId
+ });
+ break;
+ case AssistantReasoningEvent reasoning when !string.IsNullOrEmpty(reasoning.Data?.Content):
+ yield return Create("assistant.reasoning", sequence++, timestamp, new JsonObject
+ {
+ ["content"] = reasoning.Data.Content,
+ ["reasoningId"] = reasoning.Data.ReasoningId
+ });
+ break;
+ case ToolExecutionStartEvent toolStart:
+ yield return Create("tool.started", sequence++, timestamp, new JsonObject
+ {
+ ["toolCallId"] = toolStart.Data?.ToolCallId,
+ ["toolName"] = ReadToolName(toolStart.Data?.ToolName, toolStart.Data?.McpToolName),
+ ["mcpServerName"] = toolStart.Data?.McpServerName,
+ ["arguments"] = toolStart.Data?.Arguments is { } arguments ? JsonNode.Parse(arguments.GetRawText()) : null
+ });
+ break;
+ case ToolExecutionCompleteEvent toolComplete:
+ yield return Create("tool.result", sequence++, timestamp, new JsonObject
+ {
+ ["toolCallId"] = toolComplete.Data?.ToolCallId,
+ ["toolName"] = ReadToolName(toolComplete.Data?.ToolDescription?.Name, null),
+ ["status"] = toolComplete.Data?.Success == false ? AIToolInvocationStatus.Failed.ToString() : AIToolInvocationStatus.Completed.ToString(),
+ ["summary"] = toolComplete.Data?.Result?.Content ?? "",
+ ["error"] = toolComplete.Data?.Error?.Message
+ });
+ break;
+ case SessionErrorEvent error:
+ yield return Create("conversation.error", sequence++, timestamp, new JsonObject
+ {
+ ["content"] = error.Data?.Message ?? "Copilot session error.",
+ ["errorCode"] = error.Data?.ErrorCode,
+ ["errorType"] = error.Data?.ErrorType,
+ ["statusCode"] = error.Data?.StatusCode
+ });
+ break;
+ }
+ }
+
+ private static AIProviderEvent Create(string type, long sequence, DateTimeOffset timestamp, JsonObject data) =>
+ new()
+ {
+ Type = type,
+ Sequence = sequence,
+ Timestamp = timestamp,
+ Data = data
+ };
+
+ private static string? ReadToolName(string? toolName, string? mcpToolName) =>
+ !string.IsNullOrWhiteSpace(toolName) ? toolName : mcpToolName;
+}
diff --git a/src/modules/Elsa.AI.Copilot/Adapters/ElsaCopilotToolFunction.cs b/src/modules/Elsa.AI.Copilot/Adapters/ElsaCopilotToolFunction.cs
new file mode 100644
index 000000000..fcf4f6646
--- /dev/null
+++ b/src/modules/Elsa.AI.Copilot/Adapters/ElsaCopilotToolFunction.cs
@@ -0,0 +1,89 @@
+using System.Reflection;
+using System.Text.Json;
+using Elsa.AI.Abstractions.Contracts;
+using Elsa.AI.Abstractions.Models;
+using GitHub.Copilot;
+using Microsoft.Extensions.AI;
+
+namespace Elsa.AI.Copilot.Adapters;
+
+public class ElsaCopilotToolFunction(AIToolDefinition definition, IAIProviderToolInvoker toolInvoker) : AIFunction
+{
+ private static readonly MethodInfo InvokeMethod = typeof(ElsaCopilotToolFunction).GetMethod(nameof(InvokeToolAsync), BindingFlags.NonPublic | BindingFlags.Instance)!;
+ private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
+ private readonly JsonElement _jsonSchema = JsonSerializer.SerializeToElement(definition.Schema, SerializerOptions);
+ private readonly IReadOnlyDictionary _additionalProperties = CreateAdditionalProperties(definition);
+
+ public override string Name => definition.Name;
+ public override string Description => definition.Description;
+ public override JsonElement JsonSchema => _jsonSchema;
+ public override JsonSerializerOptions JsonSerializerOptions => SerializerOptions;
+ public override MethodInfo UnderlyingMethod => InvokeMethod;
+ public override IReadOnlyDictionary AdditionalProperties => _additionalProperties;
+
+ protected override async ValueTask