Merge remote-tracking branch 'origin/release/3.8.0' into release/3.8.0
This commit is contained in:
commit
8c4b6fa115
|
|
@ -124,6 +124,7 @@
|
|||
<PackageVersion Include="FluentMigrator.Runner.SQLite" Version="8.0.1"/>
|
||||
<PackageVersion Include="Fluid.Core" Version="2.31.0"/>
|
||||
<PackageVersion Include="Fody" Version="6.9.3" PrivateAssets="All"/>
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0"/>
|
||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" PrivateAssets="All"/>
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.10"/>
|
||||
<PackageVersion Include="IronCompress" Version="1.7.0"/>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ public interface IAIProvider
|
|||
{
|
||||
string Name { get; }
|
||||
ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAIOrchestrator
|
||||
|
|
@ -27,6 +27,11 @@ public interface IAIToolRegistry
|
|||
ValueTask<IAITool?> FindAsync(string name, AIToolQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAIProviderToolInvoker
|
||||
{
|
||||
ValueTask<AIToolResult> 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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -6,10 +6,15 @@ public interface IAIProvider
|
|||
{
|
||||
string Name { get; }
|
||||
ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAIOrchestrator
|
||||
{
|
||||
IAsyncEnumerable<AIStreamEvent> ExecuteChatAsync(AIChatRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAIProviderToolInvoker
|
||||
{
|
||||
ValueTask<AIToolResult> InvokeAsync(AIProviderToolInvocation invocation, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ public record AITurnRequest
|
|||
public IReadOnlyCollection<AIMessage> Messages { get; init; } = [];
|
||||
public IReadOnlyCollection<AIResolvedContext> Context { get; init; } = [];
|
||||
public IReadOnlyCollection<AIToolDefinition> Tools { get; init; } = [];
|
||||
public IReadOnlyCollection<AIToolTurnResult> 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
|
||||
|
|
|
|||
|
|
@ -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<CopilotOptions> options) : IAIProvider
|
||||
public class CopilotProvider(
|
||||
IOptions<CopilotOptions> options,
|
||||
CopilotSessionEventMapper eventMapper,
|
||||
ILogger<CopilotProvider> logger) : IAIProvider
|
||||
{
|
||||
public string Name => options.Value.ProviderName ?? "copilot";
|
||||
|
||||
|
|
@ -20,19 +29,162 @@ public class CopilotProvider(IOptions<CopilotOptions> options) : IAIProvider
|
|||
});
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> 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<AIProviderEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
Type = "assistant.delta",
|
||||
Sequence = 1,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = new JsonObject
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
using var subscription = session.On<SessionEvent>(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<CopilotSession> 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>(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<AIFunctionDeclaration> CreateTools(IReadOnlyCollection<AIToolDefinition> 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)..];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AIProviderEvent> 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;
|
||||
}
|
||||
|
|
@ -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<string, object?> _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<string, object?> AdditionalProperties => _additionalProperties;
|
||||
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await InvokeToolAsync(arguments, cancellationToken);
|
||||
return new ToolResultAIContent(new ToolResultObject
|
||||
{
|
||||
TextResultForLlm = result.Summary,
|
||||
ResultType = result.Status == AIToolInvocationStatus.Failed ? "error" : "text",
|
||||
Error = result.Error,
|
||||
ToolTelemetry = new Dictionary<string, object>
|
||||
{
|
||||
["status"] = result.Status.ToString(),
|
||||
["toolName"] = definition.Name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask<AIToolResult> InvokeToolAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
var invocation = new AIProviderToolInvocation
|
||||
{
|
||||
Id = ReadToolCallId(arguments) ?? Guid.NewGuid().ToString("N"),
|
||||
ToolName = definition.Name,
|
||||
Arguments = ToJsonObject(arguments)
|
||||
};
|
||||
|
||||
return await toolInvoker.InvokeAsync(invocation, cancellationToken);
|
||||
}
|
||||
|
||||
private static string? ReadToolCallId(AIFunctionArguments arguments)
|
||||
{
|
||||
if (arguments.Context == null)
|
||||
return null;
|
||||
|
||||
foreach (var value in arguments.Context.Values)
|
||||
{
|
||||
if (value is ToolInvocation invocation && !string.IsNullOrWhiteSpace(invocation.ToolCallId))
|
||||
return invocation.ToolCallId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonObject ToJsonObject(AIFunctionArguments arguments)
|
||||
{
|
||||
var json = new JsonObject();
|
||||
foreach (var (key, value) in arguments)
|
||||
json[key] = value == null ? null : JsonSerializer.SerializeToNode(value, SerializerOptions);
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> CreateAdditionalProperties(AIToolDefinition definition)
|
||||
{
|
||||
var properties = new Dictionary<string, object?>
|
||||
{
|
||||
["elsa_mutability"] = definition.Mutability.ToString(),
|
||||
["elsa_danger_level"] = definition.DangerLevel.ToString(),
|
||||
["skip_permission"] = definition.Mutability == AIToolMutability.ReadOnly && definition.DangerLevel == AIToolDangerLevel.Low
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(definition.Provider))
|
||||
properties["elsa_provider"] = definition.Provider;
|
||||
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="GitHub.Copilot.SDK" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" VersionOverride="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public static class ServiceCollectionExtensions
|
|||
services.Configure(configureOptions);
|
||||
|
||||
services.AddOptions<CopilotOptions>();
|
||||
services.TryAddSingleton<CopilotSessionEventMapper>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAIProvider, CopilotProvider>());
|
||||
|
||||
return services;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,23 @@ namespace Elsa.AI.Copilot.Options;
|
|||
|
||||
public class CopilotOptions
|
||||
{
|
||||
public string CliPath { get; set; } = "copilot";
|
||||
public string? RuntimePath { get; set; }
|
||||
public string? RuntimeUrl { get; set; }
|
||||
public string? ConnectionToken { get; set; }
|
||||
public ICollection<string> RuntimeArguments { get; set; } = [];
|
||||
public string? WorkingDirectory { get; set; }
|
||||
public string? BaseDirectory { get; set; }
|
||||
public string? GitHubToken { get; set; }
|
||||
public bool? UseLoggedInUser { get; set; }
|
||||
public bool EnableStreaming { get; set; } = true;
|
||||
public bool IncludeSubAgentStreamingEvents { get; set; } = true;
|
||||
public string? Model { get; set; }
|
||||
public string? ReasoningEffort { get; set; }
|
||||
public string? ProviderName { get; set; } = "copilot";
|
||||
|
||||
public string CliPath
|
||||
{
|
||||
get => RuntimePath ?? "copilot";
|
||||
set => RuntimePath = value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,14 @@ public class CopilotAIFeature : IShellFeature
|
|||
private static readonly CopilotOptions DefaultOptions = new();
|
||||
|
||||
public string CliPath { get; set; } = DefaultOptions.CliPath;
|
||||
public string? RuntimePath { get; set; } = DefaultOptions.RuntimePath;
|
||||
public string? RuntimeUrl { get; set; } = DefaultOptions.RuntimeUrl;
|
||||
public string? WorkingDirectory { get; set; } = DefaultOptions.WorkingDirectory;
|
||||
public string? BaseDirectory { get; set; } = DefaultOptions.BaseDirectory;
|
||||
public string? GitHubToken { get; set; } = DefaultOptions.GitHubToken;
|
||||
public bool? UseLoggedInUser { get; set; } = DefaultOptions.UseLoggedInUser;
|
||||
public string? Model { get; set; } = DefaultOptions.Model;
|
||||
public string? ReasoningEffort { get; set; } = DefaultOptions.ReasoningEffort;
|
||||
public string? ProviderName { get; set; } = DefaultOptions.ProviderName;
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
|
|
@ -28,8 +35,14 @@ public class CopilotAIFeature : IShellFeature
|
|||
|
||||
private void ConfigureOptions(CopilotOptions options)
|
||||
{
|
||||
options.CliPath = CliPath;
|
||||
options.RuntimePath = RuntimePath ?? CliPath;
|
||||
options.RuntimeUrl = RuntimeUrl;
|
||||
options.WorkingDirectory = WorkingDirectory;
|
||||
options.BaseDirectory = BaseDirectory;
|
||||
options.GitHubToken = GitHubToken;
|
||||
options.UseLoggedInUser = UseLoggedInUser;
|
||||
options.Model = Model;
|
||||
options.ReasoningEffort = ReasoningEffort;
|
||||
options.ProviderName = ProviderName;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ public class AIOrchestrator(
|
|||
ILogger<AIOrchestrator> logger,
|
||||
IOptions<AIHostOptions> options) : IAIOrchestrator
|
||||
{
|
||||
private const int MaxProviderTurns = 8;
|
||||
|
||||
public async IAsyncEnumerable<AIStreamEvent> ExecuteChatAsync(AIChatRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversationId = request.ConversationId ?? Guid.NewGuid().ToString("N");
|
||||
|
|
@ -91,7 +89,6 @@ public class AIOrchestrator(
|
|||
}
|
||||
|
||||
var isDuplicateReconnectMessage = request.IsReconnect && HasReconnectUserMessage(conversation, request.Message);
|
||||
var providerHistory = messages.ToList();
|
||||
if (request.IsReconnect && messages.Count > 0)
|
||||
sequence = GetNextSequence(messages);
|
||||
|
||||
|
|
@ -104,9 +101,6 @@ public class AIOrchestrator(
|
|||
if (!isDuplicateReconnectMessage)
|
||||
messages.Add(userMessage);
|
||||
|
||||
var knownToolCallIds = RestoreToolResults(messages).Select(x => x.ToolCallId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var pendingToolResults = isDuplicateReconnectMessage ? RestorePendingToolResults(messages) : new List<AIToolTurnResult>();
|
||||
|
||||
await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Active, messages, conversation, providerSessionId, cancellationToken);
|
||||
await RecordChatAuditAsync("chat.started", request, conversationId, provider?.Name, cancellationToken);
|
||||
|
||||
|
|
@ -159,114 +153,64 @@ public class AIOrchestrator(
|
|||
else
|
||||
{
|
||||
var assistantContent = new StringBuilder();
|
||||
|
||||
for (var turn = 0; turn < MaxProviderTurns; turn++)
|
||||
var providerHistory = isDuplicateReconnectMessage
|
||||
? messages.ToList()
|
||||
: messages.Where(x => x.Id != userMessage.Id).ToList();
|
||||
var turnRequest = new AITurnRequest
|
||||
{
|
||||
var currentTurnToolResults = new List<AIToolTurnResult>();
|
||||
var currentTurnMessages = new List<AIMessage>();
|
||||
var currentTurnToolMessages = new List<AIMessage>();
|
||||
assistantContent.Clear();
|
||||
ConversationId = conversationId,
|
||||
ProviderSessionId = providerSessionId,
|
||||
Message = isDuplicateReconnectMessage ? "" : request.Message,
|
||||
Messages = providerHistory,
|
||||
Context = context,
|
||||
Tools = tools.Where(x => x.IsEnabled).ToList(),
|
||||
Agent = request.Agent,
|
||||
ProviderConfiguration = providerSelection.Configuration
|
||||
};
|
||||
var toolInvoker = new HostToolInvoker(this, request, conversationId);
|
||||
Exception? providerTurnError = null;
|
||||
|
||||
var turnRequest = new AITurnRequest
|
||||
await foreach (var providerRead in ReadProviderEventsAsync(provider.ExecuteTurnAsync(turnRequest, toolInvoker, cancellationToken), cancellationToken))
|
||||
{
|
||||
if (providerRead.Error != null)
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
ProviderSessionId = providerSessionId,
|
||||
Message = turn == 0 && !isDuplicateReconnectMessage ? request.Message : "",
|
||||
Messages = providerHistory.ToList(),
|
||||
Context = context,
|
||||
Tools = tools.Where(x => x.IsEnabled).ToList(),
|
||||
ToolResults = GetUnrepresentedToolResults(pendingToolResults, providerHistory),
|
||||
Agent = request.Agent,
|
||||
ProviderConfiguration = providerSelection.Configuration
|
||||
};
|
||||
Exception? providerTurnError = null;
|
||||
await foreach (var providerRead in ReadProviderEventsAsync(provider.ExecuteTurnAsync(turnRequest, cancellationToken), cancellationToken))
|
||||
{
|
||||
if (providerRead.Error != null)
|
||||
{
|
||||
providerTurnError = providerRead.Error;
|
||||
break;
|
||||
}
|
||||
|
||||
var providerEvent = providerRead.Event!;
|
||||
var streamEvent = streamEventMapper.Map(conversationId, providerEvent) with { Sequence = sequence++ };
|
||||
yield return streamEvent;
|
||||
|
||||
if (TryReadAssistantContent(providerEvent, out var content))
|
||||
assistantContent.Append(content);
|
||||
|
||||
if (!TryReadToolCall(providerEvent, out var toolCall))
|
||||
continue;
|
||||
|
||||
if (knownToolCallIds.Contains(toolCall.Id) ||
|
||||
currentTurnToolResults.Any(x => string.Equals(x.ToolCallId, toolCall.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
var toolExecution = await ExecuteToolCallAsync(toolCall, request, conversationId, sequence++, cancellationToken);
|
||||
yield return toolExecution.StreamEvent;
|
||||
|
||||
currentTurnToolResults.Add(toolExecution.TurnResult);
|
||||
var toolMessage = CreateMessage(conversationId, AIMessageRole.Tool, toolExecution.TurnResult.Result.Summary, toolExecution.StreamEvent.Sequence, new JsonObject
|
||||
{
|
||||
["toolCallId"] = toolExecution.TurnResult.ToolCallId,
|
||||
["toolName"] = toolExecution.TurnResult.ToolName,
|
||||
["status"] = toolExecution.TurnResult.Result.Status.ToString()
|
||||
});
|
||||
currentTurnToolMessages.Add(toolMessage);
|
||||
}
|
||||
|
||||
if (providerTurnError != null)
|
||||
{
|
||||
const string content = "Weaver could not complete the AI provider turn for this request.";
|
||||
logger.LogWarning(providerTurnError, "Failed to execute AI provider turn for conversation {ConversationId}.", conversationId);
|
||||
yield return CreateEvent("conversation.error", conversationId, sequence++, new JsonObject
|
||||
{
|
||||
["content"] = content
|
||||
});
|
||||
messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
|
||||
await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Failed, messages, conversation, providerSessionId, cancellationToken);
|
||||
await RecordChatAuditAsync("chat.failed", request, conversationId, provider.Name, cancellationToken);
|
||||
yield return CreateEvent("conversation.completed", conversationId, sequence);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (assistantContent.Length > 0 || currentTurnToolMessages.Count > 0)
|
||||
{
|
||||
var assistantSequence = currentTurnToolMessages.Count > 0
|
||||
? currentTurnToolMessages.Min(x => x.StreamSequence) - 1
|
||||
: sequence - 1;
|
||||
var assistantMessage = CreateMessage(conversationId, AIMessageRole.Assistant, assistantContent.ToString(), assistantSequence, CreateAssistantToolCallMetadata(currentTurnToolResults));
|
||||
messages.Add(assistantMessage);
|
||||
currentTurnMessages.Add(assistantMessage);
|
||||
}
|
||||
|
||||
messages.AddRange(currentTurnToolMessages);
|
||||
currentTurnMessages.AddRange(currentTurnToolMessages);
|
||||
|
||||
if (currentTurnToolResults.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var toolResult in currentTurnToolResults)
|
||||
knownToolCallIds.Add(toolResult.ToolCallId);
|
||||
|
||||
pendingToolResults = currentTurnToolResults;
|
||||
if (providerHistory.All(x => x.Id != userMessage.Id))
|
||||
providerHistory.Add(userMessage);
|
||||
|
||||
providerHistory.AddRange(currentTurnMessages);
|
||||
await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Active, messages, conversation, providerSessionId, cancellationToken);
|
||||
|
||||
if (turn == MaxProviderTurns - 1)
|
||||
{
|
||||
const string content = "Tool execution stopped because the provider requested too many continuation turns.";
|
||||
yield return CreateEvent("assistant.delta", conversationId, sequence++, new JsonObject
|
||||
{
|
||||
["content"] = content
|
||||
});
|
||||
messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
|
||||
providerTurnError = providerRead.Error;
|
||||
break;
|
||||
}
|
||||
|
||||
var providerEvent = providerRead.Event!;
|
||||
var streamEvent = streamEventMapper.Map(conversationId, providerEvent) with { Sequence = sequence++ };
|
||||
yield return streamEvent;
|
||||
|
||||
if (TryReadAssistantContent(providerEvent, out var content))
|
||||
assistantContent.Append(content);
|
||||
|
||||
if (TryReadToolResult(providerEvent, out var toolResultMessage))
|
||||
messages.Add(CreateMessage(conversationId, AIMessageRole.Tool, toolResultMessage.Summary, streamEvent.Sequence, new JsonObject
|
||||
{
|
||||
["toolCallId"] = toolResultMessage.ToolCallId,
|
||||
["toolName"] = toolResultMessage.ToolName,
|
||||
["status"] = toolResultMessage.Status
|
||||
}));
|
||||
}
|
||||
|
||||
if (providerTurnError != null)
|
||||
{
|
||||
const string content = "Weaver could not complete the AI provider turn for this request.";
|
||||
logger.LogWarning(providerTurnError, "Failed to execute AI provider turn for conversation {ConversationId}.", conversationId);
|
||||
yield return CreateEvent("conversation.error", conversationId, sequence++, new JsonObject
|
||||
{
|
||||
["content"] = content
|
||||
});
|
||||
messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
|
||||
await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Failed, messages, conversation, providerSessionId, cancellationToken);
|
||||
await RecordChatAuditAsync("chat.failed", request, conversationId, provider.Name, cancellationToken);
|
||||
yield return CreateEvent("conversation.completed", conversationId, sequence);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (assistantContent.Length > 0)
|
||||
messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, assistantContent.ToString(), sequence - 1));
|
||||
}
|
||||
|
||||
await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Completed, messages, conversation, providerSessionId, cancellationToken);
|
||||
|
|
@ -399,9 +343,10 @@ public class AIOrchestrator(
|
|||
}
|
||||
}
|
||||
|
||||
private async ValueTask<ToolExecutionResult> ExecuteToolCallAsync(ToolCall toolCall, AIChatRequest request, string conversationId, long sequence, CancellationToken cancellationToken)
|
||||
private async ValueTask<AIToolResult> InvokeProviderToolAsync(AIProviderToolInvocation invocation, AIChatRequest request, string conversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tool = await toolRegistry.FindAsync(toolCall.Name, new AIToolQuery
|
||||
var toolCall = new ToolCall(invocation.Id, invocation.ToolName, invocation.Arguments);
|
||||
var tool = await toolRegistry.FindAsync(invocation.ToolName, new AIToolQuery
|
||||
{
|
||||
Agent = request.Agent,
|
||||
ActorId = request.UserId,
|
||||
|
|
@ -412,7 +357,7 @@ public class AIOrchestrator(
|
|||
{
|
||||
var result = new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = $"Tool '{toolCall.Name}' was not found." };
|
||||
await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.failed"], cancellationToken);
|
||||
return CreateToolExecutionResult(conversationId, sequence, toolCall, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
using var toolScope = tool;
|
||||
|
|
@ -429,13 +374,13 @@ public class AIOrchestrator(
|
|||
}, cancellationToken);
|
||||
await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.completed"], cancellationToken);
|
||||
|
||||
return CreateToolExecutionResult(conversationId, sequence, toolCall, LimitToolResult(result));
|
||||
return LimitToolResult(result);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(e, "AI tool {ToolName} failed for conversation {ConversationId}.", toolCall.Name, conversationId);
|
||||
await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.failed"], cancellationToken);
|
||||
return CreateToolExecutionResult(conversationId, sequence, toolCall, new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Tool execution failed." });
|
||||
return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Tool execution failed." };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,25 +412,6 @@ public class AIOrchestrator(
|
|||
}
|
||||
};
|
||||
|
||||
private static AIStreamEvent CreateToolResultEvent(string conversationId, long sequence, ToolCall toolCall, AIToolResult result) =>
|
||||
CreateEvent("tool.result", conversationId, sequence, new JsonObject
|
||||
{
|
||||
["toolCallId"] = toolCall.Id,
|
||||
["toolName"] = toolCall.Name,
|
||||
["status"] = result.Status.ToString(),
|
||||
["summary"] = result.Summary,
|
||||
["error"] = result.Error,
|
||||
["data"] = result.Data.DeepClone()
|
||||
});
|
||||
|
||||
private static ToolExecutionResult CreateToolExecutionResult(string conversationId, long sequence, ToolCall toolCall, AIToolResult result) =>
|
||||
new(CreateToolResultEvent(conversationId, sequence, toolCall, result), new AIToolTurnResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
ToolName = toolCall.Name,
|
||||
Result = result
|
||||
});
|
||||
|
||||
private IReadOnlyCollection<AIResolvedContext> LimitResolvedContext(IReadOnlyCollection<AIResolvedContext> contexts)
|
||||
{
|
||||
var maxBytes = options.Value.MaxResolvedContextBytes;
|
||||
|
|
@ -580,22 +506,6 @@ public class AIOrchestrator(
|
|||
return value[..low];
|
||||
}
|
||||
|
||||
private static bool TryReadToolCall(AIProviderEvent providerEvent, out ToolCall toolCall)
|
||||
{
|
||||
toolCall = default;
|
||||
if (!string.Equals(providerEvent.Type, "tool.call", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var name = providerEvent.Data["toolName"]?.GetValue<string>() ?? providerEvent.Data["name"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return false;
|
||||
|
||||
var id = providerEvent.Data["id"]?.GetValue<string>() ?? Guid.NewGuid().ToString("N");
|
||||
var arguments = providerEvent.Data["arguments"]?.DeepClone() as JsonObject ?? [];
|
||||
toolCall = new ToolCall(id, name, arguments);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadAssistantContent(AIProviderEvent providerEvent, out string content)
|
||||
{
|
||||
content = "";
|
||||
|
|
@ -606,6 +516,27 @@ public class AIOrchestrator(
|
|||
return !string.IsNullOrEmpty(content);
|
||||
}
|
||||
|
||||
private static bool TryReadToolResult(AIProviderEvent providerEvent, out ToolResultMessage toolResult)
|
||||
{
|
||||
toolResult = default;
|
||||
if (!string.Equals(providerEvent.Type, "tool.result", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(providerEvent.Type, "tool.completed", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var toolCallId = providerEvent.Data["toolCallId"]?.GetValue<string>() ?? providerEvent.Data["id"]?.GetValue<string>();
|
||||
var toolName = providerEvent.Data["toolName"]?.GetValue<string>() ?? providerEvent.Data["name"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName))
|
||||
return false;
|
||||
|
||||
var summary = providerEvent.Data["summary"]?.GetValue<string>() ??
|
||||
providerEvent.Data["content"]?.GetValue<string>() ??
|
||||
providerEvent.Data["result"]?.GetValue<string>() ??
|
||||
"";
|
||||
var status = providerEvent.Data["status"]?.GetValue<string>() ?? AIToolInvocationStatus.Completed.ToString();
|
||||
toolResult = new ToolResultMessage(toolCallId, toolName, status, summary);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AIMessage CreateMessage(string conversationId, AIMessageRole role, string content, long streamSequence, JsonObject? metadata = null) =>
|
||||
new()
|
||||
{
|
||||
|
|
@ -618,21 +549,6 @@ public class AIOrchestrator(
|
|||
Metadata = metadata ?? []
|
||||
};
|
||||
|
||||
private static JsonObject? CreateAssistantToolCallMetadata(IReadOnlyCollection<AIToolTurnResult> toolResults)
|
||||
{
|
||||
if (toolResults.Count == 0)
|
||||
return null;
|
||||
|
||||
var toolCallIds = new JsonArray();
|
||||
foreach (var toolResult in toolResults)
|
||||
toolCallIds.Add(toolResult.ToolCallId);
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["toolCallIds"] = toolCallIds
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasReconnectUserMessage(AIConversation? conversation, string message)
|
||||
{
|
||||
return conversation is { Status: AIConversationStatus.Active } &&
|
||||
|
|
@ -648,66 +564,6 @@ public class AIOrchestrator(
|
|||
private static long GetNextSequence(IReadOnlyCollection<AIMessage> messages) =>
|
||||
messages.Count == 0 ? 0 : messages.Max(x => x.StreamSequence) + 1;
|
||||
|
||||
private static List<AIToolTurnResult> RestoreToolResults(IEnumerable<AIMessage> messages)
|
||||
{
|
||||
return messages
|
||||
.Where(x => x.Role == AIMessageRole.Tool)
|
||||
.Select(CreateToolTurnResult)
|
||||
.OfType<AIToolTurnResult>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<AIToolTurnResult> RestorePendingToolResults(IReadOnlyCollection<AIMessage> messages)
|
||||
{
|
||||
return messages
|
||||
.Reverse()
|
||||
.TakeWhile(x => x.Role == AIMessageRole.Tool)
|
||||
.Reverse()
|
||||
.Select(CreateToolTurnResult)
|
||||
.OfType<AIToolTurnResult>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<AIToolTurnResult> GetUnrepresentedToolResults(IReadOnlyCollection<AIToolTurnResult> toolResults, IReadOnlyCollection<AIMessage> messages)
|
||||
{
|
||||
if (toolResults.Count == 0)
|
||||
return [];
|
||||
|
||||
var representedToolCallIds = messages
|
||||
.Where(x => x.Role == AIMessageRole.Tool)
|
||||
.Select(x => x.Metadata["toolCallId"]?.GetValue<string>())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return toolResults
|
||||
.Where(x => !representedToolCallIds.Contains(x.ToolCallId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static AIToolTurnResult? CreateToolTurnResult(AIMessage message)
|
||||
{
|
||||
var toolCallId = message.Metadata["toolCallId"]?.GetValue<string>();
|
||||
var toolName = message.Metadata["toolName"]?.GetValue<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName))
|
||||
return null;
|
||||
|
||||
var status = Enum.TryParse<AIToolInvocationStatus>(message.Metadata["status"]?.GetValue<string>(), out var parsedStatus)
|
||||
? parsedStatus
|
||||
: AIToolInvocationStatus.Completed;
|
||||
|
||||
return new AIToolTurnResult
|
||||
{
|
||||
ToolCallId = toolCallId,
|
||||
ToolName = toolName,
|
||||
Result = new AIToolResult
|
||||
{
|
||||
Status = status,
|
||||
Summary = message.Content
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeMessage(string message) =>
|
||||
message.ReplaceLineEndings("\n").Trim();
|
||||
|
||||
|
|
@ -755,7 +611,13 @@ public class AIOrchestrator(
|
|||
}
|
||||
|
||||
private readonly record struct ToolCall(string Id, string Name, JsonObject Arguments);
|
||||
private readonly record struct ToolExecutionResult(AIStreamEvent StreamEvent, AIToolTurnResult TurnResult);
|
||||
private readonly record struct ToolResultMessage(string ToolCallId, string ToolName, string Status, string Summary);
|
||||
private readonly record struct ProviderReadResult(AIProviderEvent? Event, Exception? Error);
|
||||
private readonly record struct ProviderSelection(IAIProvider? Provider, AIProviderConfiguration? Configuration);
|
||||
|
||||
private class HostToolInvoker(AIOrchestrator orchestrator, AIChatRequest request, string conversationId) : IAIProviderToolInvoker
|
||||
{
|
||||
public ValueTask<AIToolResult> InvokeAsync(AIProviderToolInvocation invocation, CancellationToken cancellationToken = default) =>
|
||||
orchestrator.InvokeProviderToolAsync(invocation, request, conversationId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ public class AICapabilitiesEndpointTests
|
|||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new AISessionHandle { Id = request.ConversationId });
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ public class AIChatEndpointTests
|
|||
services.AddAIHostServices();
|
||||
services.RemoveAll<IAIAuditSink>();
|
||||
services.AddSingleton<IAIAuditSink>(auditSink);
|
||||
services.AddSingleton<IAIProvider, ToolCallAIProvider>();
|
||||
services.AddSingleton<IAIProvider, UnknownToolAIProvider>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var orchestrator = provider.GetRequiredService<IAIOrchestrator>();
|
||||
|
||||
|
|
@ -534,10 +534,10 @@ public class AIChatEndpointTests
|
|||
Assert.Equal("Tool execution failed.", toolResult.Data["error"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Chat orchestration sends tool results back to provider continuations")]
|
||||
public async Task ChatOrchestrationSendsToolResultsBackToProviderContinuations()
|
||||
[Fact(DisplayName = "Chat orchestration lets providers own tool continuation")]
|
||||
public async Task ChatOrchestrationLetsProvidersOwnToolContinuation()
|
||||
{
|
||||
var provider = new ToolContinuationAIProvider();
|
||||
var provider = new ToolCallAIProvider();
|
||||
var services = new ServiceCollection();
|
||||
services.AddAIHostServices();
|
||||
services.AddSingleton<IAIProvider>(provider);
|
||||
|
|
@ -554,19 +554,9 @@ public class AIChatEndpointTests
|
|||
}))
|
||||
events.Add(streamEvent);
|
||||
|
||||
var continuation = Assert.Single(provider.Requests, x => x.Messages.Any(message => message.Role == AIMessageRole.Tool));
|
||||
var continuationMessages = continuation.Messages.Where(x => x.Role is AIMessageRole.Assistant or AIMessageRole.Tool).ToList();
|
||||
var request = Assert.Single(provider.Requests);
|
||||
|
||||
Assert.Empty(continuation.ToolResults);
|
||||
Assert.Collection(
|
||||
continuationMessages,
|
||||
assistant => Assert.Equal(AIMessageRole.Assistant, assistant.Role),
|
||||
tool =>
|
||||
{
|
||||
Assert.Equal(AIMessageRole.Tool, tool.Role);
|
||||
Assert.Equal("tool-call-1", tool.Metadata["toolCallId"]!.GetValue<string>());
|
||||
Assert.Equal("Echoed", tool.Content);
|
||||
});
|
||||
Assert.DoesNotContain(request.Messages, x => x.Role == AIMessageRole.Tool);
|
||||
Assert.Contains(events, x => x.Type == "assistant.delta" && x.Data["content"]!.GetValue<string>() == "Used Echoed");
|
||||
}
|
||||
|
||||
|
|
@ -969,8 +959,8 @@ public class AIChatEndpointTests
|
|||
Assert.Equal(2, conversation.Messages.Count);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Chat orchestration resumes persisted tool results on reconnect")]
|
||||
public async Task ChatOrchestrationResumesPersistedToolResultsOnReconnect()
|
||||
[Fact(DisplayName = "Chat orchestration sends persisted history on reconnect")]
|
||||
public async Task ChatOrchestrationSendsPersistedHistoryOnReconnect()
|
||||
{
|
||||
var provider = new CapturingTurnProvider();
|
||||
var services = new ServiceCollection();
|
||||
|
|
@ -1048,7 +1038,6 @@ public class AIChatEndpointTests
|
|||
var completedConversation = await store.FindAsync("conversation-1");
|
||||
|
||||
Assert.Equal("", reconnectRequest.Message);
|
||||
Assert.Empty(reconnectRequest.ToolResults);
|
||||
Assert.Equal("tool-call-1", restoredToolMessage.Metadata["toolCallId"]!.GetValue<string>());
|
||||
Assert.Equal("echo", restoredToolMessage.Metadata["toolName"]!.GetValue<string>());
|
||||
Assert.Equal("Echoed", restoredToolMessage.Content);
|
||||
|
|
@ -1358,13 +1347,12 @@ public class AIChatEndpointTests
|
|||
Assert.Equal(64, data["maxBytes"]!.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Chat orchestration persists max tool turn warning")]
|
||||
public async Task ChatOrchestrationPersistsMaxToolTurnWarning()
|
||||
[Fact(DisplayName = "Chat orchestration persists provider-emitted tool results")]
|
||||
public async Task ChatOrchestrationPersistsProviderEmittedToolResults()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAIHostServices();
|
||||
services.AddSingleton<IAIProvider, EndlessToolCallAIProvider>();
|
||||
services.AddSingleton<IAITool, EchoTool>();
|
||||
services.AddSingleton<IAIProvider, ProviderToolEventAIProvider>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var orchestrator = provider.GetRequiredService<IAIOrchestrator>();
|
||||
var store = provider.GetRequiredService<IAIConversationStore>();
|
||||
|
|
@ -1374,23 +1362,20 @@ public class AIChatEndpointTests
|
|||
ConversationId = "conversation-1",
|
||||
UserId = "user-1",
|
||||
TenantId = "tenant-1",
|
||||
Message = "Use tools forever"
|
||||
Message = "Use a tool"
|
||||
}))
|
||||
{
|
||||
// Intentionally drain the stream to completion.
|
||||
}
|
||||
|
||||
var conversation = await store.FindAsync("conversation-1");
|
||||
var toolMessage = Assert.Single(conversation!.Messages, x => x.Role == AIMessageRole.Tool);
|
||||
|
||||
Assert.Contains(
|
||||
conversation!.Messages,
|
||||
x => x.Role == AIMessageRole.Assistant && x.Content == "Tool execution stopped because the provider requested too many continuation turns.");
|
||||
Assert.Equal("tool-call-1", toolMessage.Metadata["toolCallId"]!.GetValue<string>());
|
||||
Assert.Equal("echo", toolMessage.Metadata["toolName"]!.GetValue<string>());
|
||||
Assert.Equal("Echoed", toolMessage.Content);
|
||||
}
|
||||
|
||||
private static string? GetToolResultSummary(AITurnRequest request) =>
|
||||
request.ToolResults.FirstOrDefault()?.Result.Summary ??
|
||||
request.Messages.LastOrDefault(x => x.Role == AIMessageRole.Tool)?.Content;
|
||||
|
||||
private class SequencedAIProvider : IAIProvider
|
||||
{
|
||||
public string Name => "sequenced";
|
||||
|
|
@ -1398,7 +1383,7 @@ public class AIChatEndpointTests
|
|||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new AISessionHandle { Id = request.ConversationId });
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
|
|
@ -1425,7 +1410,7 @@ public class AIChatEndpointTests
|
|||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new AISessionHandle { Id = request.ConversationId });
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
|
|
@ -1458,7 +1443,7 @@ public class AIChatEndpointTests
|
|||
});
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
Requests.Add(request);
|
||||
|
|
@ -1483,7 +1468,7 @@ public class AIChatEndpointTests
|
|||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("Session creation failed.");
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
|
|
@ -1501,7 +1486,7 @@ public class AIChatEndpointTests
|
|||
ProviderSessionId = $"provider-session-{request.ConversationId}"
|
||||
});
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
|
|
@ -1522,7 +1507,7 @@ public class AIChatEndpointTests
|
|||
return ValueTask.FromResult(new AISessionHandle());
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
|
|
@ -1609,107 +1594,118 @@ public class AIChatEndpointTests
|
|||
throw new InvalidOperationException("Context unavailable.");
|
||||
}
|
||||
|
||||
private class ToolContinuationAIProvider : IAIProvider
|
||||
private class ToolCallAIProvider : IAIProvider
|
||||
{
|
||||
public string Name => "tool-continuation";
|
||||
public string Name => "tool-caller";
|
||||
public List<AITurnRequest> Requests { get; } = [];
|
||||
|
||||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new AISessionHandle { Id = request.ConversationId });
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
Requests.Add(request);
|
||||
var toolResultSummary = GetToolResultSummary(request);
|
||||
|
||||
if (toolResultSummary == null)
|
||||
var result = await toolInvoker.InvokeAsync(new AIProviderToolInvocation
|
||||
{
|
||||
yield return new AIProviderEvent
|
||||
Id = "tool-call-1",
|
||||
ToolName = "echo",
|
||||
Arguments = new JsonObject
|
||||
{
|
||||
Type = "tool.call",
|
||||
Sequence = 1,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = new JsonObject
|
||||
{
|
||||
["id"] = "tool-call-1",
|
||||
["toolName"] = "echo",
|
||||
["arguments"] = new JsonObject
|
||||
{
|
||||
["text"] = "hello"
|
||||
}
|
||||
}
|
||||
};
|
||||
["text"] = "hello"
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
yield break;
|
||||
}
|
||||
yield return new AIProviderEvent
|
||||
{
|
||||
Type = "tool.result",
|
||||
Sequence = 1,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = new JsonObject
|
||||
{
|
||||
["toolCallId"] = "tool-call-1",
|
||||
["toolName"] = "echo",
|
||||
["status"] = result.Status.ToString(),
|
||||
["summary"] = result.Summary,
|
||||
["error"] = result.Error,
|
||||
["data"] = result.Data.DeepClone()
|
||||
}
|
||||
};
|
||||
|
||||
yield return new AIProviderEvent
|
||||
{
|
||||
Type = "assistant.delta",
|
||||
Sequence = 1,
|
||||
Sequence = 2,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = new JsonObject
|
||||
{
|
||||
["content"] = $"Used {toolResultSummary}"
|
||||
["content"] = $"Used {result.Summary}"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class ToolCallAIProvider : IAIProvider
|
||||
private class UnknownToolAIProvider : IAIProvider
|
||||
{
|
||||
public string Name => "tool-caller";
|
||||
public string Name => "unknown-tool-caller";
|
||||
|
||||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new AISessionHandle { Id = request.ConversationId });
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
var result = await toolInvoker.InvokeAsync(new AIProviderToolInvocation
|
||||
{
|
||||
Id = "tool-call-1",
|
||||
ToolName = "echo",
|
||||
Arguments = new JsonObject
|
||||
{
|
||||
["text"] = "hello"
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
yield return new AIProviderEvent
|
||||
{
|
||||
Type = "tool.call",
|
||||
Type = "tool.result",
|
||||
Sequence = 1,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = new JsonObject
|
||||
{
|
||||
["id"] = "tool-call-1",
|
||||
["toolCallId"] = "tool-call-1",
|
||||
["toolName"] = "echo",
|
||||
["arguments"] = new JsonObject
|
||||
{
|
||||
["text"] = "hello"
|
||||
}
|
||||
["status"] = result.Status.ToString(),
|
||||
["summary"] = result.Summary,
|
||||
["error"] = result.Error,
|
||||
["data"] = result.Data.DeepClone()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class EndlessToolCallAIProvider : IAIProvider
|
||||
private class ProviderToolEventAIProvider : IAIProvider
|
||||
{
|
||||
private int _index;
|
||||
|
||||
public string Name => "endless-tool-caller";
|
||||
public string Name => "provider-tool-event";
|
||||
|
||||
public ValueTask<AISessionHandle> CreateSessionAsync(CreateAISessionRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new AISessionHandle { Id = request.ConversationId });
|
||||
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
public async IAsyncEnumerable<AIProviderEvent> ExecuteTurnAsync(AITurnRequest request, IAIProviderToolInvoker toolInvoker, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
var id = Interlocked.Increment(ref _index);
|
||||
|
||||
yield return new AIProviderEvent
|
||||
{
|
||||
Type = "tool.call",
|
||||
Type = "tool.result",
|
||||
Sequence = 1,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = new JsonObject
|
||||
{
|
||||
["id"] = $"tool-call-{id}",
|
||||
["toolCallId"] = "tool-call-1",
|
||||
["toolName"] = "echo",
|
||||
["arguments"] = new JsonObject
|
||||
["status"] = AIToolInvocationStatus.Completed.ToString(),
|
||||
["summary"] = "Echoed",
|
||||
["data"] = new JsonObject
|
||||
{
|
||||
["text"] = "hello"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
using Elsa.AI.Abstractions.Contracts;
|
||||
using Elsa.AI.Abstractions.Models;
|
||||
using Elsa.AI.Copilot.Adapters;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Elsa.AI.Copilot.UnitTests;
|
||||
|
||||
public class ElsaCopilotToolFunctionTests
|
||||
{
|
||||
[Fact(DisplayName = "Tool function preserves Elsa metadata and schema")]
|
||||
public void ToolFunctionPreservesElsaMetadataAndSchema()
|
||||
{
|
||||
var definition = CreateDefinition();
|
||||
var function = new ElsaCopilotToolFunction(definition, new CapturingToolInvoker());
|
||||
|
||||
Assert.Equal("workflow.inspect", function.Name);
|
||||
Assert.Equal("Inspect workflow", function.Description);
|
||||
Assert.Equal("workflowId", function.JsonSchema.GetProperty("required")[0].GetString());
|
||||
var additionalProperties = function.AdditionalProperties!;
|
||||
|
||||
Assert.Equal("Proposal", additionalProperties["elsa_mutability"]);
|
||||
Assert.Equal("Medium", additionalProperties["elsa_danger_level"]);
|
||||
Assert.False((bool)additionalProperties["skip_permission"]!);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Tool function invokes Elsa provider tool invoker")]
|
||||
public async Task ToolFunctionInvokesElsaProviderToolInvoker()
|
||||
{
|
||||
var invoker = new CapturingToolInvoker();
|
||||
var function = new ElsaCopilotToolFunction(CreateDefinition(), invoker);
|
||||
var arguments = new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["workflowId"] = "workflow-1"
|
||||
})
|
||||
{
|
||||
Context = new Dictionary<object, object?>
|
||||
{
|
||||
["copilotInvocation"] = new ToolInvocation
|
||||
{
|
||||
ToolCallId = "sdk-tool-call-1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(arguments);
|
||||
var toolResult = Assert.IsType<ToolResultAIContent>(result);
|
||||
|
||||
Assert.Equal("Workflow inspected", toolResult.Result.TextResultForLlm);
|
||||
var invocation = Assert.Single(invoker.Invocations);
|
||||
Assert.Equal("sdk-tool-call-1", invocation.Id);
|
||||
Assert.Equal("workflow.inspect", invocation.ToolName);
|
||||
Assert.Equal("workflow-1", invocation.Arguments["workflowId"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
private static AIToolDefinition CreateDefinition() =>
|
||||
new()
|
||||
{
|
||||
Name = "workflow.inspect",
|
||||
Description = "Inspect workflow",
|
||||
Mutability = AIToolMutability.Proposal,
|
||||
DangerLevel = AIToolDangerLevel.Medium,
|
||||
Schema = new JsonObject
|
||||
{
|
||||
["type"] = "object",
|
||||
["required"] = new JsonArray("workflowId"),
|
||||
["properties"] = new JsonObject
|
||||
{
|
||||
["workflowId"] = new JsonObject
|
||||
{
|
||||
["type"] = "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private class CapturingToolInvoker : IAIProviderToolInvoker
|
||||
{
|
||||
public List<AIProviderToolInvocation> Invocations { get; } = [];
|
||||
|
||||
public ValueTask<AIToolResult> InvokeAsync(AIProviderToolInvocation invocation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Invocations.Add(invocation);
|
||||
return ValueTask.FromResult(new AIToolResult
|
||||
{
|
||||
Summary = "Workflow inspected"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue