From c66f9aed452eff7a74b38ea809b29ae40b92d67a Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 8 Jun 2026 15:40:10 +0200 Subject: [PATCH] Add Weaver grounding tools Adds Spec Kit-backed Weaver grounding tools for activities, workflow definitions, workflow proposals, runtime instances, incidents, and Studio capability discovery. --- .specify/feature.json | 2 +- AGENTS.md | 5 +- specs/008-weaver-ai-copilot/quickstart.md | 27 +-- .../checklists/requirements.md | 34 +++ .../contracts/rest-api.md | 79 +++++++ .../contracts/tool-catalog.md | 124 +++++++++++ .../012-weaver-grounding-tools/data-model.md | 94 ++++++++ specs/012-weaver-grounding-tools/plan.md | 85 ++++++++ .../012-weaver-grounding-tools/quickstart.md | 48 +++++ specs/012-weaver-grounding-tools/research.md | 47 ++++ specs/012-weaver-grounding-tools/spec.md | 148 +++++++++++++ specs/012-weaver-grounding-tools/tasks.md | 201 ++++++++++++++++++ src/PackageManifest.props | 3 + .../Models/AIContextAttachment.cs | 9 + .../Models/AIGroundingModels.cs | 104 +++++++++ .../WorkflowDefinitionContextProvider.cs | 41 +++- .../WorkflowInstanceContextProvider.cs | 40 +++- src/modules/Elsa.AI.Host/Elsa.AI.Host.csproj | 2 + .../Endpoints/AI/Capabilities/Endpoint.cs | 92 +++++++- .../Extensions/ServiceCollectionExtensions.cs | 26 +++ .../Elsa.AI.Host/Features/AIFeature.cs | 15 ++ .../Elsa.AI.Host/Options/AIHostOptions.cs | 22 +- src/modules/Elsa.AI.Host/README.md | 42 ++++ .../Elsa.AI.Host/Services/AIGroundingJson.cs | 20 ++ .../Services/AIGroundingResultFormatter.cs | 108 ++++++++++ ...oolEnablementConfigurationHostedService.cs | 19 ++ .../Services/ActivityGroundingMapper.cs | 86 ++++++++ .../ActivityGroundingSearchService.cs | 64 ++++++ .../Services/RuntimeGroundingMapper.cs | 72 +++++++ .../WorkflowDraftValidationService.cs | 72 +++++++ .../Services/WorkflowGroundingMapper.cs | 127 +++++++++++ .../Services/WorkflowProposalDiffService.cs | 52 +++++ .../Tools/Activities/ActivitiesSearchTool.cs | 42 ++++ .../Activities/ActivityDescriptorTool.cs | 37 ++++ .../Elsa.AI.Host/Tools/GroundingToolBase.cs | 56 +++++ .../Tools/GroundingToolSchemas.cs | 52 +++++ .../Tools/Runtime/IncidentTool.cs | 38 ++++ .../Tools/Runtime/IncidentsSearchTool.cs | 38 ++++ .../Tools/Runtime/InstancesSearchTool.cs | 35 +++ .../Tools/Runtime/RuntimeToolBase.cs | 83 ++++++++ .../WorkflowInstanceActivityStateTool.cs | 49 +++++ .../WorkflowInstanceExecutionHistoryTool.cs | 35 +++ .../Tools/Runtime/WorkflowInstanceTool.cs | 30 +++ .../Workflows/WorkflowDefinitionGraphTool.cs | 30 +++ .../Tools/Workflows/WorkflowDefinitionTool.cs | 31 +++ .../Workflows/WorkflowProposeCreateTool.cs | 41 ++++ .../Workflows/WorkflowProposeUpdateTool.cs | 57 +++++ .../Tools/Workflows/WorkflowToolBase.cs | 46 ++++ .../Workflows/WorkflowUsageSearchTool.cs | 41 ++++ .../Workflows/WorkflowValidateDraftTool.cs | 30 +++ .../Tools/Workflows/WorkflowsSearchTool.cs | 32 +++ .../AIActivityGroundingToolTests.cs | 72 +++++++ .../AICapabilitiesEndpointTests.cs | 35 ++- .../AIChatEndpointTests.cs | 4 +- .../AIRuntimeGroundingToolTests.cs | 131 ++++++++++++ .../AIToolsEndpointTests.cs | 25 ++- .../AIWorkflowGroundingToolTests.cs | 132 ++++++++++++ .../AIWorkflowProposalToolTests.cs | 61 ++++++ .../Context/AIContextResolverTests.cs | 6 + .../Grounding/AIGroundingCapabilityTests.cs | 37 ++++ .../AIGroundingResultFormatterTests.cs | 50 +++++ .../Grounding/ActivityGroundingMapperTests.cs | 51 +++++ .../Grounding/RuntimeGroundingMapperTests.cs | 34 +++ .../Grounding/WorkflowDraftValidationTests.cs | 77 +++++++ .../Grounding/WorkflowGroundingMapperTests.cs | 38 ++++ .../WorkflowProposalDiffServiceTests.cs | 34 +++ 66 files changed, 3448 insertions(+), 52 deletions(-) create mode 100644 specs/012-weaver-grounding-tools/checklists/requirements.md create mode 100644 specs/012-weaver-grounding-tools/contracts/rest-api.md create mode 100644 specs/012-weaver-grounding-tools/contracts/tool-catalog.md create mode 100644 specs/012-weaver-grounding-tools/data-model.md create mode 100644 specs/012-weaver-grounding-tools/plan.md create mode 100644 specs/012-weaver-grounding-tools/quickstart.md create mode 100644 specs/012-weaver-grounding-tools/research.md create mode 100644 specs/012-weaver-grounding-tools/spec.md create mode 100644 specs/012-weaver-grounding-tools/tasks.md create mode 100644 src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs create mode 100644 src/modules/Elsa.AI.Host/README.md create mode 100644 src/modules/Elsa.AI.Host/Services/AIGroundingJson.cs create mode 100644 src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs create mode 100644 src/modules/Elsa.AI.Host/Services/AIToolEnablementConfigurationHostedService.cs create mode 100644 src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs create mode 100644 src/modules/Elsa.AI.Host/Services/ActivityGroundingSearchService.cs create mode 100644 src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs create mode 100644 src/modules/Elsa.AI.Host/Services/WorkflowDraftValidationService.cs create mode 100644 src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs create mode 100644 src/modules/Elsa.AI.Host/Services/WorkflowProposalDiffService.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Activities/ActivitiesSearchTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Activities/ActivityDescriptorTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/GroundingToolBase.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/GroundingToolSchemas.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/IncidentsSearchTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/InstancesSearchTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/RuntimeToolBase.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceActivityStateTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceExecutionHistoryTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionGraphTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeCreateTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeUpdateTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowToolBase.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowUsageSearchTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowValidateDraftTool.cs create mode 100644 src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowsSearchTool.cs create mode 100644 test/integration/Elsa.AI.IntegrationTests/AIActivityGroundingToolTests.cs create mode 100644 test/integration/Elsa.AI.IntegrationTests/AIRuntimeGroundingToolTests.cs create mode 100644 test/integration/Elsa.AI.IntegrationTests/AIWorkflowGroundingToolTests.cs create mode 100644 test/integration/Elsa.AI.IntegrationTests/AIWorkflowProposalToolTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingCapabilityTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingResultFormatterTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/ActivityGroundingMapperTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/RuntimeGroundingMapperTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowDraftValidationTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowGroundingMapperTests.cs create mode 100644 test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowProposalDiffServiceTests.cs diff --git a/.specify/feature.json b/.specify/feature.json index bc062ce66..a9933063f 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/008-weaver-ai-copilot" + "feature_directory": "specs/012-weaver-grounding-tools" } diff --git a/AGENTS.md b/AGENTS.md index 52e58fcd9..0a1a9fbc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable: For additional context about technologies to be used, project structure, -shell commands, and other important information, read `specs/008-weaver-ai-copilot/plan.md`. +shell commands, and other important information, read `specs/012-weaver-grounding-tools/plan.md`. ## Active Technologies @@ -99,8 +99,11 @@ shell commands, and other important information, read `specs/008-weaver-ai-copil - In-memory store for tests/development; Elsa-managed encrypted store with EF Core persistence for production; configuration-backed read-only store for deployment-managed values. No cloud vault or OS certificate store provider in v1. (007-secrets-module) - C# latest, nullable reference types enabled, implicit usings enabled; paired Studio Blazor/Razor module work in the Studio repository. + 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. (008-weaver-ai-copilot) - 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. (008-weaver-ai-copilot) +- C# latest, nullable reference types enabled, implicit usings enabled. + Elsa AI abstractions/host modules, `GitHub.Copilot.SDK` isolated behind `Elsa.AI.Copilot`, Activity Registry, workflow management/runtime abstractions, existing identity/tenancy services, FastEndpoints through Elsa endpoint patterns, `System.Text.Json`, `Microsoft.Extensions.Options`, and `Microsoft.Extensions.Logging`. (012-weaver-grounding-tools) +- Existing workflow definition/runtime stores and Activity Registry are read sources; durable proposal and audit stores remain the write/governance path; no new required database schema for the grounding MVP. (012-weaver-grounding-tools) ## Recent Changes +- 012-weaver-grounding-tools: Plans governed Weaver grounding tools for installed activities, workflow definitions, workflow proposals, workflow instances, incidents, and Studio capability discovery. - 008-weaver-ai-copilot: Captures Weaver as a server-hosted, provider-isolated AI copilot platform with Studio chat, governed tools, proposal-only workflow mutations, audit, and extensibility. - 006-diagnostics-console-logs: Plans raw stdout/stderr console capture with redaction-before-provider boundaries, bounded in-memory recent/live buffers, REST backfill/source endpoints, and a SignalR live hub. - 005-structured-log-persistence: Plans pluggable structured log storage with in-memory default and opt-in SQLite persistence using FluentMigrator. diff --git a/specs/008-weaver-ai-copilot/quickstart.md b/specs/008-weaver-ai-copilot/quickstart.md index 154ae80b6..3d75bc772 100644 --- a/specs/008-weaver-ai-copilot/quickstart.md +++ b/specs/008-weaver-ai-copilot/quickstart.md @@ -46,18 +46,21 @@ services 1. Start Elsa Server with Weaver enabled. 2. Request `GET /ai/capabilities` and verify streaming, proposal review, attachment kinds, and agents are listed. -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. 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. +3. Verify `GET /ai/capabilities` advertises grounding families for activities, workflows, proposals, and runtime, including disabled reasons when stores are not registered. +4. Request `GET /ai/tools` as an authorized user and verify Activity Registry, workflow definition, proposal, instance, and incident tools are returned. +5. Ask Weaver which installed activity can receive an HTTP request and verify the answer uses `activities.search` or `activities.getDescriptor`. +6. Start `POST /ai/chat` with a `WorkflowDefinition` attachment reference and ask Weaver to explain it. +7. Verify stream events include assistant deltas and any tool lifecycle events. +8. Verify Elsa logs/audit show server-side tool execution and that Copilot SDK session events, not Host-managed continuation turns, drove the agent loop. +9. Ask Weaver to generate a simple workflow. +10. Verify a proposal is created and `GET /ai/proposals/{id}` returns payload, rationale, warnings, diagnostics, and graph preview. +11. Attempt to apply without approval and verify the server rejects the transition. +12. Approve and apply the proposal as an authorized user. +13. Verify the workflow is persisted, validation passed, and durable audit records exist for prompt, tool calls, approval, and apply. +14. Restart the server with durable persistence configured and verify proposals and audit records are still available. +15. Disconnect during a chat turn, reconnect within the configured grace window, and verify durable outputs produced while disconnected are recoverable. +16. 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. +17. Ask Weaver why a failed workflow instance failed and verify it uses `instances.getExecutionHistory`, `instances.getActivityState`, `incidents.search`, or `incidents.get` without exposing sensitive state. ## Targeted Test Commands diff --git a/specs/012-weaver-grounding-tools/checklists/requirements.md b/specs/012-weaver-grounding-tools/checklists/requirements.md new file mode 100644 index 000000000..101082535 --- /dev/null +++ b/specs/012-weaver-grounding-tools/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Weaver Grounding Tools + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Specification is ready for implementation planning. diff --git a/specs/012-weaver-grounding-tools/contracts/rest-api.md b/specs/012-weaver-grounding-tools/contracts/rest-api.md new file mode 100644 index 000000000..053606abb --- /dev/null +++ b/specs/012-weaver-grounding-tools/contracts/rest-api.md @@ -0,0 +1,79 @@ +# REST API Contract: Weaver Grounding Tools + +This feature extends existing Weaver APIs without exposing provider SDK types. + +## GET `/ai/capabilities` + +Add grounding capability descriptors to the existing response. + +```json +{ + "streaming": true, + "conversationPersistence": true, + "proposalReview": true, + "supportedAttachmentKinds": [ + "WorkflowDefinition", + "WorkflowInstance", + "ActivitySelection", + "DiagnosticsScope", + "TimeRange" + ], + "grounding": [ + { + "name": "activities", + "displayName": "Activity catalog", + "enabled": true, + "toolNames": [ + "activities.search", + "activities.getDescriptor" + ], + "supportedAttachmentKinds": [ + "ActivitySelection" + ] + } + ] +} +``` + +## GET `/ai/tools` + +Returns the grounding tools available for the current actor, tenant, and optional agent scope. Existing `AIToolDefinition` shape remains the contract. + +## POST `/ai/chat` + +Existing chat request shape is retained. Grounding uses attachments and available tools. + +```json +{ + "conversationId": "conversation-123", + "message": "Create a workflow that starts on HTTP POST and sends an email", + "agent": "workflow-author", + "attachments": [ + { + "kind": "ActivitySelection", + "referenceId": "activities:http,email" + } + ] +} +``` + +## Stream Events + +Existing stream event shape is retained. Grounding tools should map to current tool lifecycle events: + +- `tool.started` +- `tool.result` +- `proposal.created` +- `conversation.error` +- `conversation.completed` + +Tool result data should include `toolName`, `toolCallId`, `status`, `summary`, and optional redacted result data. + +## Error Behavior + +- `400`: Invalid search filters, unsupported attachment kind, invalid draft payload. +- `403`: Missing permission, tenant mismatch, denied tool access. +- `404`: Activity, workflow, instance, incident, or proposal not found. +- `409`: Stale workflow baseline. +- `422`: Draft validation failed. +- `503`: Provider runtime unavailable; grounding capability endpoints may still work. diff --git a/specs/012-weaver-grounding-tools/contracts/tool-catalog.md b/specs/012-weaver-grounding-tools/contracts/tool-catalog.md new file mode 100644 index 000000000..9453460eb --- /dev/null +++ b/specs/012-weaver-grounding-tools/contracts/tool-catalog.md @@ -0,0 +1,124 @@ +# Tool Catalog Contract: Weaver Grounding Tools + +All tools use Elsa-owned `AIToolDefinition` metadata and execute server-side. Names are stable and namespaced. + +## Activity Tools + +### `activities.search` + +**Mutability**: `ReadOnly` +**Purpose**: Find installed activities by capability, type, category, input/output, trigger behavior, or text query. + +**Arguments** + +```json +{ + "query": "http request", + "category": "HTTP", + "canStartWorkflow": true, + "inputName": "Path", + "outputName": "Body", + "skip": 0, + "take": 20 +} +``` + +**Result**: `GroundingToolResult`. + +### `activities.getDescriptor` + +**Mutability**: `ReadOnly` +**Purpose**: Return detailed model-safe metadata for one installed activity. + +**Arguments** + +```json +{ + "typeName": "Elsa.Http.Endpoint", + "version": 1 +} +``` + +**Result**: `ActivityGroundingSummary`. + +## Workflow Definition Tools + +### `workflows.search` + +**Mutability**: `ReadOnly` +**Purpose**: Find authorized workflow definitions by name, status, activity usage, tag, or text query. + +### `workflows.getDefinition` + +**Mutability**: `ReadOnly` +**Purpose**: Return an authorized workflow definition summary and selected graph details. + +### `workflows.getDefinitionGraph` + +**Mutability**: `ReadOnly` +**Purpose**: Return graph-oriented activity and connection data for explanation, comparison, or proposal baselines. + +### `workflows.findUsages` + +**Mutability**: `ReadOnly` +**Purpose**: Find workflows that use an activity type, variable name, input, output, or expression syntax. + +## Proposal Tools + +### `workflows.validateDraft` + +**Mutability**: `Proposal` +**Purpose**: Validate a draft workflow payload without persisting it. + +### `workflows.proposeCreate` + +**Mutability**: `Proposal` +**Purpose**: Create a durable reviewable proposal for a new workflow. + +### `workflows.proposeUpdate` + +**Mutability**: `Proposal` +**Purpose**: Create a durable reviewable proposal for updating an existing workflow version. + +## Runtime Tools + +### `instances.search` + +**Mutability**: `ReadOnly` +**Purpose**: Find authorized workflow instances by workflow, status, date range, incident presence, or text query. + +### `instances.get` + +**Mutability**: `ReadOnly` +**Purpose**: Return a model-safe workflow instance summary. + +### `instances.getExecutionHistory` + +**Mutability**: `ReadOnly` +**Purpose**: Return a bounded activity timeline for an instance. + +### `instances.getActivityState` + +**Mutability**: `ReadOnly` +**Purpose**: Return bounded state for selected activities in an instance. + +### `incidents.search` + +**Mutability**: `ReadOnly` +**Purpose**: Find incidents by workflow, instance, activity, time range, or error text. + +### `incidents.get` + +**Mutability**: `ReadOnly` +**Purpose**: Return a single incident summary with evidence references. + +## Deferred Tools + +These are intentionally out of MVP and require explicit future approval semantics: + +- `instances.proposeRetry` +- `instances.proposeCancel` +- `instances.proposeRestart` +- `workflows.proposeDelete` +- `workflows.proposePublish` +- `workflows.proposeUnpublish` diff --git a/specs/012-weaver-grounding-tools/data-model.md b/specs/012-weaver-grounding-tools/data-model.md new file mode 100644 index 000000000..f40ae6dff --- /dev/null +++ b/specs/012-weaver-grounding-tools/data-model.md @@ -0,0 +1,94 @@ +# Data Model: Weaver Grounding Tools + +## ActivityGroundingSummary + +Model-safe description of an installed activity. + +**Fields**: `TypeName`, `Version`, `DisplayName`, `Description`, `Namespace`, `Categories`, `IsBrowsable`, `CanStartWorkflow`, `Inputs`, `Outputs`, `Constraints`, `Provider`. + +**Rules**: + +- Values are derived from Activity Registry descriptors. +- Descriptor details are reduced to model-safe metadata; runtime-only or sensitive implementation details are omitted. +- Multiple versions are represented explicitly. + +## ActivityPortSummary + +Model-safe input or output descriptor for an activity. + +**Fields**: `Name`, `DisplayName`, `Description`, `Type`, `IsRequired`, `IsArray`, `SupportedSyntaxes`, `DefaultValueSummary`, `Category`. + +**Rules**: + +- Default values are summarized or redacted. +- Type names are stable enough for authoring guidance but do not expose unsafe internals. + +## WorkflowGroundingSummary + +Model-safe view of a workflow definition or version. + +**Fields**: `DefinitionId`, `VersionId`, `Name`, `Version`, `Status`, `IsLatest`, `IsPublished`, `Activities`, `Connections`, `Variables`, `Inputs`, `Outputs`, `TriggerActivities`, `Warnings`. + +**Rules**: + +- Returned only after authorization checks. +- Large workflow graphs are summarized with optional detail lookup. +- Missing activity descriptors are called out as warnings. + +## WorkflowDraftProposalContext + +Information used to create or validate a workflow proposal. + +**Fields**: `Kind`, `ConversationId`, `BaselineDefinitionId`, `BaselineVersionId`, `DraftPayload`, `Rationale`, `Warnings`, `ValidationDiagnostics`, `GraphDiff`. + +**Rules**: + +- All writes remain proposals until approved and applied. +- Baseline version is required for updates. +- Drafts must validate against installed activity descriptors before apply. + +## RuntimeInstanceGroundingSummary + +Model-safe view of workflow instance runtime data. + +**Fields**: `InstanceId`, `WorkflowDefinitionId`, `WorkflowVersionId`, `Status`, `SubStatus`, `CreatedAt`, `UpdatedAt`, `FinishedAt`, `CurrentActivityIds`, `Timeline`, `Incidents`, `VariableSummaries`, `InputSummary`, `OutputSummary`. + +**Rules**: + +- Requires instance read permission. +- Variables and input/output payloads are redacted and size-limited. +- Detailed execution history can be paged or summarized. + +## IncidentGroundingSummary + +Model-safe incident/error evidence. + +**Fields**: `IncidentId`, `InstanceId`, `ActivityId`, `ActivityType`, `Message`, `ExceptionType`, `Timestamp`, `Evidence`, `Severity`. + +**Rules**: + +- Messages are redacted. +- Evidence references should be enough for Studio drill-in without dumping full logs into model context. + +## GroundingToolResult + +Common shape for bounded tool responses. + +**Fields**: `Summary`, `Items`, `TotalCount`, `HasMore`, `Cursor`, `Warnings`, `EvidenceReferences`. + +**Rules**: + +- Tool results must fit configured size limits. +- Large result sets return summaries plus cursors or filters. +- Results are suitable for both Copilot tool callbacks and Studio tool activity rendering. + +## GroundingCapabilityDescriptor + +Provider-neutral capability advertised to Studio. + +**Fields**: `Name`, `DisplayName`, `Description`, `ToolNames`, `SupportedAttachmentKinds`, `Enabled`, `DisabledReason`. + +**Rules**: + +- Studio uses this to enable or disable context pickers and chat actions. +- No provider SDK details are included. diff --git a/specs/012-weaver-grounding-tools/plan.md b/specs/012-weaver-grounding-tools/plan.md new file mode 100644 index 000000000..7f1115f42 --- /dev/null +++ b/specs/012-weaver-grounding-tools/plan.md @@ -0,0 +1,85 @@ +# Implementation Plan: Weaver Grounding Tools + +**Branch**: `codex/weaver-grounding-tools` | **Date**: 2026-06-08 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/012-weaver-grounding-tools/spec.md` + +## Summary + +Ground Weaver in Elsa data by adding governed read-only and proposal-only tool families for installed activities, workflow definitions, workflow drafts, workflow instances, incidents, and Studio capability discovery. Copilot SDK continues to own the agent loop; Elsa Host supplies authorized, redacted, bounded tool callbacks and proposal validation. + +## Technical Context + +**Language/Version**: C# latest, nullable reference types enabled, implicit usings enabled. +**Primary Dependencies**: Elsa AI abstractions/host modules, `GitHub.Copilot.SDK` behind `Elsa.AI.Copilot`, Activity Registry (`IActivityRegistry`/activity descriptors), workflow management/runtime abstractions, existing identity/tenancy services, FastEndpoints through Elsa endpoint patterns, `System.Text.Json`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Logging`. +**Storage**: Existing workflow definition/runtime stores and Activity Registry are read sources; durable proposal and audit stores from Weaver remain the write/governance path; no new required database schema for the grounding MVP. +**Testing**: xUnit unit tests in `test/unit/Elsa.AI.Host.UnitTests`; integration tests in `test/integration/Elsa.AI.IntegrationTests`; component tests only if workflow runtime fixtures are needed for seeded incidents. +**Target Platform**: ASP.NET Core Elsa Server multi-targeting `net8.0`, `net9.0`, and `net10.0`. +**Project Type**: Modular .NET server libraries with REST/streaming APIs and provider-neutral Studio contracts. +**Performance Goals**: Tool metadata/capability responses under 250 ms p95 for typical catalogs; first grounded chat tool result within 3 seconds p95 under normal server load; tool result payloads bounded by configured AI context limits. +**Constraints**: Studio remains provider-agnostic; Copilot SDK types stay inside `Elsa.AI.Copilot`; all data access runs server-side; all tools enforce tenant/RBAC/ownership; mutation remains proposal-only; secrets are redacted before model context, streams, and audit; direct destructive operational actions are out of scope. +**Scale/Scope**: Activity discovery, workflow definition search/detail/graph summaries, workflow draft validation/proposals, runtime instance/incident inspection, capability discovery, tests, contracts, and quickstart documentation. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Modular Architecture**: Pass. Work remains inside `Elsa.AI.Host` and `Elsa.AI.Abstractions` with provider-specific runtime behavior isolated in `Elsa.AI.Copilot`. +- **Composition & Extensibility**: Pass. Grounding is expressed as `IAITool` and `IAIContextProvider` implementations registered by feature composition. +- **Convention-Driven Design**: Pass. Endpoint and service additions follow existing AI module patterns. +- **Async & Pipeline Execution**: Pass. Store/runtime reads and tool execution stay async. +- **Testing Discipline**: Pass. New tool families require unit and integration coverage. +- **Trunk-Based Development**: Pass. This plan is focused on one feature area. +- **Simplicity, SRP, DRY & KISS**: Pass. Start with deterministic Elsa tools and proposal flows; no vector database, broad provider abstraction, or direct operational action tools in MVP. + +## Project Structure + +### Documentation (this feature) + +```text +specs/012-weaver-grounding-tools/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── rest-api.md +│ └── tool-catalog.md +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/modules/Elsa.AI.Abstractions/ +├── Models/ +│ └── AIGrounding*.cs +└── Contracts/ + +src/modules/Elsa.AI.Host/ +├── Context/ +├── Endpoints/AI/ +│ ├── Capabilities/ +│ ├── Tools/ +│ └── Proposals/ +├── Services/ +├── Tools/ +│ ├── Activities/ +│ ├── Workflows/ +│ └── Runtime/ +└── Options/ + +test/unit/Elsa.AI.Host.UnitTests/ +├── Grounding/ +└── Tools/ + +test/integration/Elsa.AI.IntegrationTests/ +``` + +**Structure Decision**: Add tool implementations under `Elsa.AI.Host/Tools` by domain. Shared DTOs that are part of provider-neutral tool results belong in `Elsa.AI.Abstractions/Models`; mapping services stay in `Elsa.AI.Host/Services`. Avoid a new module until the grounding surface grows beyond Weaver Host ownership. + +## Complexity Tracking + +No constitution violations. diff --git a/specs/012-weaver-grounding-tools/quickstart.md b/specs/012-weaver-grounding-tools/quickstart.md new file mode 100644 index 000000000..4756571d1 --- /dev/null +++ b/specs/012-weaver-grounding-tools/quickstart.md @@ -0,0 +1,48 @@ +# Quickstart: Weaver Grounding Tools + +## Goal + +Validate that Weaver can answer questions and create proposals grounded in installed Elsa data without direct database or provider SDK exposure. + +## Setup + +1. Start an Elsa Server with AI Host and Copilot enabled. +2. Ensure several activities are installed, including at least one trigger activity and one action activity. +3. Create or seed: + - one published workflow definition, + - one workflow using a custom or versioned activity, + - one failed workflow instance with an incident. +4. Configure durable proposal and audit storage if validating proposal lifecycle. + +## Manual Validation + +1. Request `GET /ai/capabilities`. +2. Verify grounding capabilities advertise activity, workflow, proposal, and runtime tool families. +3. Request `GET /ai/tools`. +4. Verify these tools are available for an authorized workflow author: + - `activities.search` + - `activities.getDescriptor` + - `workflows.search` + - `workflows.getDefinition` + - `workflows.validateDraft` + - `workflows.proposeCreate` + - `workflows.proposeUpdate` + - `instances.search` + - `instances.get` + - `incidents.search` +5. Ask Weaver: "What activities can start a workflow from an HTTP request?" +6. Verify the answer references installed activities only. +7. Ask Weaver: "Create a workflow that starts on HTTP POST and sends an email." +8. Verify Weaver searches activities, validates the draft, and creates a proposal instead of saving a workflow directly. +9. Ask Weaver to explain a seeded workflow definition. +10. Verify the answer includes real triggers, activities, inputs, outputs, and graph structure. +11. Ask Weaver why a seeded failed instance failed. +12. Verify the answer references the failed activity, incident message, timeline, and redacted state. + +## Targeted Test Commands + +```bash +dotnet test test/unit/Elsa.AI.Host.UnitTests/Elsa.AI.Host.UnitTests.csproj +dotnet test test/integration/Elsa.AI.IntegrationTests/Elsa.AI.IntegrationTests.csproj +dotnet build Elsa.sln -m:1 +``` diff --git a/specs/012-weaver-grounding-tools/research.md b/specs/012-weaver-grounding-tools/research.md new file mode 100644 index 000000000..ce88c6497 --- /dev/null +++ b/specs/012-weaver-grounding-tools/research.md @@ -0,0 +1,47 @@ +# Research: Weaver Grounding Tools + +## Decision: Start with deterministic Elsa tools, not embeddings + +**Rationale**: Activity descriptors, workflow definitions, workflow instances, and incidents are structured Elsa data. Deterministic search/detail tools provide accurate, permission-aware grounding and are easier to test than vector retrieval. Embeddings may be added later for documentation or large historical logs, but they are not required for the MVP. + +**Alternatives considered**: + +- Prompt stuffing full catalogs and workflow graphs: rejected because it is expensive, leaky, and brittle. +- Vector database first: rejected because the first use cases require exact installed activity and workflow metadata. +- Direct Copilot database access: rejected because Elsa must enforce tenant/RBAC/redaction boundaries. + +## Decision: Treat Activity Registry as the authoring foundation + +**Rationale**: Workflow creation and update quality depends on installed activities, versions, inputs, outputs, trigger behavior, and constraints. `activities.search` and `activities.getDescriptor` are the minimum primitives Weaver needs to draft valid workflows. + +**Alternatives considered**: + +- Hard-code common activity knowledge in prompts: rejected because users install custom activities and versions. +- Expose raw `ActivityDescriptor` objects: rejected because model-facing DTOs should be stable, bounded, and redacted. + +## Decision: Keep writes proposal-only + +**Rationale**: AI-generated workflow mutations are high impact. Weaver should create or update proposals, run validation, and let users approve/apply through Elsa APIs. This preserves auditability and avoids hidden writes from an agent loop. + +**Alternatives considered**: + +- Let Copilot call workflow persistence directly: rejected because it bypasses review and baseline checks. +- Add direct action tools with confirmation prompts in MVP: rejected because Studio steering/approval UX and audit semantics should mature first. + +## Decision: Split runtime inspection from operational actions + +**Rationale**: Instance and incident inspection is read-only and immediately useful. Retrying, canceling, restarting, or bulk operations can be destructive and should wait for explicit action/proposal semantics. + +**Alternatives considered**: + +- Include operational action tools in MVP: rejected because they expand risk and require stronger confirmation UX. +- Omit runtime tools entirely: rejected because users explicitly need to ask questions about workflow instances and failures. + +## Decision: Advertise grounding through provider-neutral capabilities + +**Rationale**: Elsa Studio should enable context pickers and chat affordances based on Elsa-owned capabilities, not Copilot SDK features. Capability discovery also makes partial deployments understandable. + +**Alternatives considered**: + +- Hard-code Studio controls: rejected because modules and deployments vary. +- Expose Copilot SDK feature flags directly: rejected because Studio must stay provider-agnostic. diff --git a/specs/012-weaver-grounding-tools/spec.md b/specs/012-weaver-grounding-tools/spec.md new file mode 100644 index 000000000..cfef58be5 --- /dev/null +++ b/specs/012-weaver-grounding-tools/spec.md @@ -0,0 +1,148 @@ +# Feature Specification: Weaver Grounding Tools + +**Feature Branch**: `codex/weaver-grounding-tools` +**Created**: 2026-06-08 +**Status**: Draft +**Input**: User description: "Plan the use cases and tools needed to ground Weaver/Copilot in Elsa data such as installed activity metadata, workflow definitions, workflow instances, incidents, and proposal-based workflow authoring." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Discover available activities (Priority: P1) + +A workflow author asks Weaver what activities are available or which activity should be used for a desired step, and Weaver answers using the activities installed in the current Elsa server. + +**Why this priority**: Workflow creation and update quality depends on knowing the real Activity Registry, not a generic model memory of possible activities. + +**Independent Test**: Ask Weaver to find an activity by capability, category, or input/output shape, then verify the answer only includes activities visible to the current user and tenant. + +**Acceptance Scenarios**: + +1. **Given** a server with installed activities, **When** a user asks what can receive an HTTP request, **Then** Weaver identifies matching installed activities with concise metadata and usage notes. +2. **Given** multiple versions of an activity exist, **When** Weaver requests details for the activity, **Then** the response clearly identifies version, inputs, outputs, categories, and constraints. +3. **Given** an activity is not available to the tenant or is not browsable, **When** Weaver searches activities, **Then** the activity is excluded or clearly marked unavailable according to policy. + +--- + +### User Story 2 - Understand workflow definitions (Priority: P2) + +A user asks Weaver to explain, search, compare, or inspect workflow definitions and receives answers grounded in authorized workflow definition data. + +**Why this priority**: Users need confidence that Weaver understands existing workflows before trusting generated changes. + +**Independent Test**: Attach or search for a workflow definition, ask Weaver to explain it, and verify the answer uses the actual workflow graph, activities, variables, inputs, outputs, triggers, and version metadata. + +**Acceptance Scenarios**: + +1. **Given** a user can view a workflow definition, **When** they ask Weaver what it does, **Then** Weaver summarizes triggers, activities, data flow, branches, and external dependencies. +2. **Given** a user searches for workflows using an activity, **When** matching workflows exist, **Then** Weaver returns only authorized matches with enough context to choose one. +3. **Given** a workflow has multiple versions, **When** Weaver compares versions, **Then** it explains meaningful graph and metadata differences without leaking unauthorized data. + +--- + +### User Story 3 - Create and update workflows safely (Priority: P3) + +A workflow author asks Weaver to create or update a workflow, and Weaver uses installed activity descriptors and validation tools to produce a reviewable proposal instead of directly saving changes. + +**Why this priority**: This is the core agentic authoring use case, but it must remain governed and reviewable. + +**Independent Test**: Ask Weaver to create or update a workflow that uses installed activities, verify it creates a proposal with validation diagnostics, and verify no workflow is persisted until approved and applied. + +**Acceptance Scenarios**: + +1. **Given** a user can create workflows, **When** they ask Weaver to create a workflow, **Then** Weaver consults available activities and creates a proposal with a valid draft, rationale, warnings, and validation diagnostics. +2. **Given** a user asks to modify an existing workflow, **When** Weaver proposes the change, **Then** the proposal references the baseline workflow version and includes a reviewable graph diff. +3. **Given** a proposed draft uses an unavailable activity or invalid input, **When** validation runs, **Then** the proposal is blocked with actionable diagnostics. + +--- + +### User Story 4 - Inspect runtime instances and incidents (Priority: P4) + +An operator asks Weaver why a workflow failed or what happened in a workflow instance, and Weaver uses authorized runtime data to summarize state, history, variables, incidents, and likely causes. + +**Why this priority**: Runtime inspection makes Weaver useful beyond authoring and gives operators fast, evidence-backed support. + +**Independent Test**: Ask Weaver to inspect a failed instance, then verify the response identifies the workflow, failed activity, incident/error evidence, relevant state, and next investigation steps. + +**Acceptance Scenarios**: + +1. **Given** a user can view a workflow instance, **When** they ask what happened, **Then** Weaver summarizes status, timeline, current/failed activity, incidents, and relevant variables. +2. **Given** a user asks for recurring failures in a time range, **When** matching incidents exist, **Then** Weaver summarizes trends within the selected scope only. +3. **Given** runtime data contains sensitive values, **When** Weaver reads or reports it, **Then** sensitive values are redacted before model context, stream output, and audit storage. + +--- + +### User Story 5 - Surface grounding capabilities to Studio (Priority: P5) + +Elsa Studio discovers which Weaver grounding features are available and renders chat controls, context attachment options, tool activity, proposal review, and unsupported-state messaging accordingly. + +**Why this priority**: Studio must remain provider-agnostic while still giving users a useful agentic interface. + +**Independent Test**: Request Weaver capabilities from Studio, verify supported attachment kinds and tool families are advertised, and verify UI controls are enabled only when backend capabilities exist. + +**Acceptance Scenarios**: + +1. **Given** Activity Registry grounding is available, **When** Studio loads Weaver capabilities, **Then** it can offer activity-aware authoring and activity search affordances. +2. **Given** runtime diagnostics tools are disabled, **When** Studio loads Weaver capabilities, **Then** instance/incident analysis controls are hidden or disabled with an explanatory state. +3. **Given** a chat turn invokes tools, **When** stream events arrive, **Then** Studio can render tool activity and proposal state without knowing provider SDK details. + +### Edge Cases + +- Activity metadata is missing, oversized, localized, duplicated, or has multiple versions. +- A workflow references a custom activity that is no longer installed. +- A user asks for tenant-wide analysis without selecting a permitted scope. +- Workflow definitions or instances are deleted between context selection and tool execution. +- A proposal baseline becomes stale before apply. +- Runtime variables, inputs, outputs, logs, incidents, or activity metadata contain secrets or sensitive configuration. +- A tool returns too many matches for model context and must paginate or summarize. +- The Copilot provider is unavailable while grounding tools and capability endpoints remain available. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide activity discovery tools that query installed activity metadata from Elsa's Activity Registry. +- **FR-002**: Activity discovery MUST support search by name, display name, category, namespace/type, version, input/output name, trigger capability, and free-text terms. +- **FR-003**: Activity detail results MUST include model-safe metadata for inputs, outputs, description, categories, version, browsability, trigger capability, and usage constraints. +- **FR-004**: System MUST provide workflow definition tools for search, retrieval, graph summary, version metadata, activity usage, and version comparison. +- **FR-005**: Workflow definition tools MUST enforce tenant, ownership, and workflow read permissions before returning data. +- **FR-006**: System MUST provide workflow proposal tools for creating drafts, updating existing workflows, validating drafts, and comparing drafts to baselines. +- **FR-007**: AI-originated workflow creation and update MUST remain proposal-only until a user explicitly approves and applies the proposal. +- **FR-008**: System MUST validate proposed workflow drafts against installed activity descriptors, workflow graph rules, required inputs, expression compatibility where practical, and baseline version. +- **FR-009**: System MUST provide runtime tools for searching instances, retrieving instance summaries, reading execution history, reading activity state, reading variables safely, and finding incidents. +- **FR-010**: Runtime tools MUST require an explicit workflow, instance, diagnostics scope, or time range unless the user has an administrative analysis permission. +- **FR-011**: System MUST redact sensitive values before data is sent to Copilot, streamed to Studio, or written to audit records. +- **FR-012**: Tool results MUST be bounded and summarizable so large activity catalogs, workflow graphs, logs, or incident sets do not exceed configured context limits. +- **FR-013**: System MUST expose provider-neutral capabilities that identify available grounding tool families and supported context attachment kinds. +- **FR-014**: Studio-facing contracts MUST not expose GitHub Copilot SDK types or database entities. +- **FR-015**: Every grounding tool invocation MUST be audited with actor, tenant, conversation, tool name, status, and redacted summary. +- **FR-016**: System MUST support deterministic tool outputs suitable for Copilot SDK tool callbacks and Studio tool activity rendering. +- **FR-017**: System MUST document which grounding tools are read-only, proposal-only, or future administrative actions. +- **FR-018**: Initial implementation MUST exclude direct destructive actions such as delete workflow, cancel instance, restart instance, or bulk retry. + +### Key Entities *(include if feature involves data)* + +- **Activity Grounding Summary**: Model-safe view of an installed activity descriptor, including type, version, name, description, categories, inputs, outputs, trigger behavior, and constraints. +- **Workflow Grounding Summary**: Model-safe view of a workflow definition or version, including identity, status, graph shape, activities used, variables, inputs, outputs, and links to full authorized details. +- **Workflow Draft Proposal**: Reviewable AI-generated workflow creation or update package with baseline, draft payload, diff, rationale, warnings, and validation diagnostics. +- **Runtime Instance Summary**: Model-safe view of a workflow instance, including status, workflow reference, timeline, current activity, incidents, selected variables, and redacted inputs/outputs. +- **Grounding Tool Result**: Bounded, redacted response from a Weaver tool with result items, summary, paging/cursor hints, and evidence references. +- **Grounding Capability Descriptor**: Provider-neutral capability advertised to Studio so UI features can be enabled or disabled. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Weaver can answer activity discovery questions using installed Activity Registry data in seeded tests with no hallucinated activity names. +- **SC-002**: Weaver can create a proposal for a simple workflow using only installed activities and receives blocking diagnostics when an unavailable activity is requested. +- **SC-003**: Weaver can explain a seeded workflow definition with correct trigger, activity, and data-flow references. +- **SC-004**: Weaver can inspect a seeded failed workflow instance and identify the failed activity, primary error, and relevant incident evidence. +- **SC-005**: All grounding tool responses are redacted and stay within configured result size limits in tests with oversized metadata or runtime data. +- **SC-006**: Studio capability discovery can determine whether activity, workflow, proposal, and runtime grounding are available without provider-specific assumptions. + +## Assumptions + +- The Copilot SDK integration from `specs/008-weaver-ai-copilot` is already present and owns the agent loop. +- Elsa Server remains the only component allowed to access workflow stores, runtime stores, Activity Registry, diagnostics, and audit persistence. +- Studio sends references and user intent only; it does not send raw workflow or runtime data to an AI provider. +- MVP covers read-only grounding tools and proposal-only workflow mutations; direct operational actions are deferred. +- Existing Elsa authorization, tenancy, activity registry, workflow management, runtime, and diagnostics abstractions remain the source of truth. diff --git a/specs/012-weaver-grounding-tools/tasks.md b/specs/012-weaver-grounding-tools/tasks.md new file mode 100644 index 000000000..b81f0597e --- /dev/null +++ b/specs/012-weaver-grounding-tools/tasks.md @@ -0,0 +1,201 @@ +# Tasks: Weaver Grounding Tools + +**Input**: Design documents from `specs/012-weaver-grounding-tools/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Tests**: New grounding code must include unit and integration coverage. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish shared grounding DTOs, options, and registration surfaces. + +- [X] T001 Add grounding DTO records in `src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs`. +- [X] T002 Add grounding result size and paging options in `src/modules/Elsa.AI.Host/Options/AIHostOptions.cs`. +- [X] T003 [P] Add grounding tool registration extension helpers in `src/modules/Elsa.AI.Host/Features/AIFeature.cs`. +- [X] T004 [P] Update AI capability response models in `src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared services that all grounding tool families need. + +- [X] T005 Add redaction and size-clamping helpers for grounding payloads in `src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs`. +- [X] T006 Add model-safe activity descriptor mapping service in `src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs`. +- [X] T007 Add model-safe workflow graph mapping service in `src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs`. +- [X] T008 Add model-safe runtime instance mapping service in `src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs`. +- [X] T009 Register grounding services and built-in tools in `src/modules/Elsa.AI.Host/Extensions/ServiceCollectionExtensions.cs`. +- [X] T010 [P] Add unit tests for result formatting and redaction in `test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingResultFormatterTests.cs`. +- [X] T011 [P] Add unit tests for capability descriptor composition in `test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingCapabilityTests.cs`. + +**Checkpoint**: Shared grounding services are available and tested. + +--- + +## Phase 3: User Story 1 - Discover available activities (Priority: P1) MVP + +**Goal**: Let Weaver search and inspect installed Activity Registry metadata. + +**Independent Test**: Search installed activities by capability and get one descriptor without using workflow or runtime tools. + +### Tests for User Story 1 + +- [X] T012 [P] [US1] Add unit tests for activity descriptor mapping in `test/unit/Elsa.AI.Host.UnitTests/Grounding/ActivityGroundingMapperTests.cs`. +- [X] T013 [P] [US1] Add integration tests for `activities.search` and `activities.getDescriptor` in `test/integration/Elsa.AI.IntegrationTests/AIActivityGroundingToolTests.cs`. + +### Implementation for User Story 1 + +- [X] T014 [US1] Implement `activities.search` tool in `src/modules/Elsa.AI.Host/Tools/Activities/ActivitiesSearchTool.cs`. +- [X] T015 [US1] Implement `activities.getDescriptor` tool in `src/modules/Elsa.AI.Host/Tools/Activities/ActivityDescriptorTool.cs`. +- [X] T016 [US1] Add activity search filtering by query, category, type, version, input, output, and trigger behavior in `src/modules/Elsa.AI.Host/Services/ActivityGroundingSearchService.cs`. +- [X] T017 [US1] Expose activity grounding capability metadata in `src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs`. +- [X] T018 [US1] Verify activity tools are listed by `GET /ai/tools` in `test/integration/Elsa.AI.IntegrationTests/AIToolsEndpointTests.cs`. + +**Checkpoint**: Weaver can ground authoring in installed activities. + +--- + +## Phase 4: User Story 2 - Understand workflow definitions (Priority: P2) + +**Goal**: Let Weaver search, retrieve, explain, and compare authorized workflow definitions. + +**Independent Test**: Attach or search a workflow definition and ask for a graph summary without creating a proposal. + +### Tests for User Story 2 + +- [X] T019 [P] [US2] Add unit tests for workflow graph mapping in `test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowGroundingMapperTests.cs`. +- [X] T020 [P] [US2] Add integration tests for workflow search/detail tools in `test/integration/Elsa.AI.IntegrationTests/AIWorkflowGroundingToolTests.cs`. + +### Implementation for User Story 2 + +- [X] T021 [US2] Implement `workflows.search` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowsSearchTool.cs`. +- [X] T022 [US2] Implement `workflows.getDefinition` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionTool.cs`. +- [X] T023 [US2] Implement `workflows.getDefinitionGraph` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionGraphTool.cs`. +- [X] T024 [US2] Implement `workflows.findUsages` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowUsageSearchTool.cs`. +- [X] T025 [US2] Extend `WorkflowDefinitionContextProvider` in `src/modules/Elsa.AI.Host/Context/WorkflowDefinitionContextProvider.cs` to use model-safe graph summaries. + +**Checkpoint**: Weaver can explain and search workflows using authorized definition data. + +--- + +## Phase 5: User Story 3 - Create and update workflows safely (Priority: P3) + +**Goal**: Let Weaver create or update workflow proposals using installed activity metadata and validation. + +**Independent Test**: Ask Weaver to create or update a workflow and verify proposal-only output with diagnostics. + +### Tests for User Story 3 + +- [X] T026 [P] [US3] Add unit tests for draft validation against activity descriptors in `test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowDraftValidationTests.cs`. +- [X] T027 [P] [US3] Add integration tests for proposal tools in `test/integration/Elsa.AI.IntegrationTests/AIWorkflowProposalToolTests.cs`. + +### Implementation for User Story 3 + +- [X] T028 [US3] Implement workflow draft validation service in `src/modules/Elsa.AI.Host/Services/WorkflowDraftValidationService.cs`. +- [X] T029 [US3] Implement `workflows.validateDraft` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowValidateDraftTool.cs`. +- [X] T030 [US3] Implement `workflows.proposeCreate` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeCreateTool.cs`. +- [X] T031 [US3] Implement `workflows.proposeUpdate` tool in `src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeUpdateTool.cs`. +- [X] T032 [US3] Add proposal graph diff generation in `src/modules/Elsa.AI.Host/Services/WorkflowProposalDiffService.cs`. +- [X] T033 [US3] Add stale baseline checks for update proposals in `src/modules/Elsa.AI.Host/Services/WorkflowDraftValidationService.cs`. + +**Checkpoint**: Weaver can produce governed workflow authoring proposals. + +--- + +## Phase 6: User Story 4 - Inspect runtime instances and incidents (Priority: P4) + +**Goal**: Let Weaver inspect workflow instances and incidents with redacted runtime evidence. + +**Independent Test**: Ask Weaver why a failed seeded instance failed and verify evidence-backed output. + +### Tests for User Story 4 + +- [X] T034 [P] [US4] Add unit tests for runtime grounding mapping in `test/unit/Elsa.AI.Host.UnitTests/Grounding/RuntimeGroundingMapperTests.cs`. +- [X] T035 [P] [US4] Add integration tests for instance and incident tools in `test/integration/Elsa.AI.IntegrationTests/AIRuntimeGroundingToolTests.cs`. + +### Implementation for User Story 4 + +- [X] T036 [US4] Implement `instances.search` tool in `src/modules/Elsa.AI.Host/Tools/Runtime/InstancesSearchTool.cs`. +- [X] T037 [US4] Implement `instances.get` tool in `src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceTool.cs`. +- [X] T038 [US4] Implement `instances.getExecutionHistory` tool in `src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceExecutionHistoryTool.cs`. +- [X] T039 [US4] Implement `instances.getActivityState` tool in `src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceActivityStateTool.cs`. +- [X] T040 [US4] Implement `incidents.search` tool in `src/modules/Elsa.AI.Host/Tools/Runtime/IncidentsSearchTool.cs`. +- [X] T041 [US4] Implement `incidents.get` tool in `src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs`. +- [X] T042 [US4] Extend `WorkflowInstanceContextProvider` in `src/modules/Elsa.AI.Host/Context/WorkflowInstanceContextProvider.cs` to include bounded incident and activity state summaries. + +**Checkpoint**: Weaver can answer runtime inspection questions from authorized instance and incident data. + +--- + +## Phase 7: User Story 5 - Surface grounding capabilities to Studio (Priority: P5) + +**Goal**: Let Studio discover and render available grounding features without provider-specific assumptions. + +**Independent Test**: Call capabilities and tools endpoints and verify grounding families, attachment kinds, and unavailable states. + +### Tests for User Story 5 + +- [X] T043 [P] [US5] Extend capability endpoint tests in `test/integration/Elsa.AI.IntegrationTests/AICapabilitiesEndpointTests.cs`. +- [X] T044 [P] [US5] Extend chat stream tests for grounded tool result events in `test/integration/Elsa.AI.IntegrationTests/AIChatEndpointTests.cs`. + +### Implementation for User Story 5 + +- [X] T045 [US5] Add grounding capability response fields in `src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs`. +- [X] T046 [US5] Add supported attachment kinds for activity, workflow, runtime, diagnostics, and time range in `src/modules/Elsa.AI.Abstractions/Models/AIContextAttachment.cs`. +- [X] T047 [US5] Add disabled grounding capability reasons in `src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs`. + +**Checkpoint**: Studio can discover Weaver grounding support and render UI affordances safely. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, safety review, and verification. + +- [X] T048 [P] Add host README documentation for grounding tools in `src/modules/Elsa.AI.Host/README.md`. +- [X] T049 [P] Update Weaver quickstart references in `specs/008-weaver-ai-copilot/quickstart.md`. +- [X] T050 Run `dotnet test test/unit/Elsa.AI.Host.UnitTests/Elsa.AI.Host.UnitTests.csproj`. +- [X] T051 Run `dotnet test test/integration/Elsa.AI.IntegrationTests/Elsa.AI.IntegrationTests.csproj`. +- [X] T052 Run `dotnet build Elsa.sln -m:1`. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies. +- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories. +- **US1 Activity Discovery**: First MVP story after Foundation. +- **US2 Workflow Understanding**: Can start after Foundation; benefits from US1 for missing activity warnings. +- **US3 Workflow Proposals**: Depends on US1 and US2. +- **US4 Runtime Inspection**: Can start after Foundation and proceed independently of US3. +- **US5 Studio Capabilities**: Can start after each tool family has capability metadata. +- **Polish**: Depends on implemented target stories. + +### Parallel Opportunities + +- T003 and T004 can run in parallel. +- T010 and T011 can run in parallel. +- Test tasks within each user story can run in parallel with each other. +- US2 and US4 can be implemented in parallel after Foundation. + +## Implementation Strategy + +### MVP First + +1. Complete Setup and Foundation. +2. Complete US1 activity discovery. +3. Validate that Weaver can search installed activities and answer activity questions. +4. Add US2 workflow understanding. +5. Add US3 proposal creation/update only after activity and workflow grounding are reliable. + +### Incremental Delivery + +1. Deliver activity grounding. +2. Deliver workflow explanation/search. +3. Deliver proposal validation and proposal creation/update. +4. Deliver runtime inspection. +5. Deliver Studio capability metadata for all enabled tool families. diff --git a/src/PackageManifest.props b/src/PackageManifest.props index 628730290..ae7c2e619 100644 --- a/src/PackageManifest.props +++ b/src/PackageManifest.props @@ -2,6 +2,9 @@ + + + diff --git a/src/modules/Elsa.AI.Abstractions/Models/AIContextAttachment.cs b/src/modules/Elsa.AI.Abstractions/Models/AIContextAttachment.cs index de8970499..e2d4eb463 100644 --- a/src/modules/Elsa.AI.Abstractions/Models/AIContextAttachment.cs +++ b/src/modules/Elsa.AI.Abstractions/Models/AIContextAttachment.cs @@ -34,3 +34,12 @@ public record AITimeRange public DateTimeOffset From { get; init; } public DateTimeOffset To { get; init; } } + +public static class AIContextAttachmentKinds +{ + public const string Activity = "Activity"; + public const string WorkflowDefinition = "WorkflowDefinition"; + public const string WorkflowInstance = "WorkflowInstance"; + public const string DiagnosticsScope = "DiagnosticsScope"; + public const string TimeRange = "TimeRange"; +} diff --git a/src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs b/src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs new file mode 100644 index 000000000..4ab5175b8 --- /dev/null +++ b/src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs @@ -0,0 +1,104 @@ +namespace Elsa.AI.Abstractions.Models; + +public record AIGroundingToolResult +{ + public string Summary { get; init; } = ""; + public IReadOnlyCollection Items { get; init; } = []; + public int Total { get; init; } + public int Returned { get; init; } + public bool Truncated { get; init; } + public string? Cursor { get; init; } + public IReadOnlyCollection Evidence { get; init; } = []; + public IReadOnlyCollection Warnings { get; init; } = []; +} + +public record AIGroundingCapabilityDescriptor +{ + public string Family { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public bool Available { get; init; } + public IReadOnlyCollection ToolNames { get; init; } = []; + public IReadOnlyCollection AttachmentKinds { get; init; } = []; + public IReadOnlyCollection DisabledReasons { get; init; } = []; +} + +public record ActivityGroundingSummary +{ + public string Type { get; init; } = ""; + public int Version { get; init; } + public string Namespace { get; init; } = ""; + public string Name { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string? Description { get; init; } + public string Category { get; init; } = ""; + public bool IsBrowsable { get; init; } + public bool IsTrigger { get; init; } + public bool IsContainer { get; init; } + public bool IsTerminal { get; init; } + public IReadOnlyCollection Inputs { get; init; } = []; + public IReadOnlyCollection Outputs { get; init; } = []; + public IReadOnlyCollection Ports { get; init; } = []; + public IReadOnlyCollection Constraints { get; init; } = []; +} + +public record ActivityPortSummary +{ + public string Name { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string? Description { get; init; } + public string Type { get; init; } = ""; + public string? Category { get; init; } + public bool IsBrowsable { get; init; } = true; + public bool IsSensitive { get; init; } + public bool IsRequired { get; init; } + public string? UIHint { get; init; } + public string? DefaultSyntax { get; init; } +} + +public record WorkflowGroundingSummary +{ + public string Id { get; init; } = ""; + public string DefinitionId { get; init; } = ""; + public string? Name { get; init; } + public string? Description { get; init; } + public int? Version { get; init; } + public bool IsLatest { get; init; } + public bool IsPublished { get; init; } + public bool IsReadonly { get; init; } + public string MaterializerName { get; init; } = ""; + public string? ProviderName { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public IReadOnlyCollection ActivityTypes { get; init; } = []; + public IReadOnlyCollection Variables { get; init; } = []; + public IReadOnlyCollection Inputs { get; init; } = []; + public IReadOnlyCollection Outputs { get; init; } = []; +} + +public record RuntimeInstanceGroundingSummary +{ + public string Id { get; init; } = ""; + public string? TenantId { get; init; } + public string DefinitionId { get; init; } = ""; + public string DefinitionVersionId { get; init; } = ""; + public int Version { get; init; } + public string Status { get; init; } = ""; + public string SubStatus { get; init; } = ""; + public string? CorrelationId { get; init; } + public string? Name { get; init; } + public int IncidentCount { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; init; } + public DateTimeOffset? FinishedAt { get; init; } +} + +public record IncidentGroundingSummary +{ + public string WorkflowInstanceId { get; init; } = ""; + public string ActivityId { get; init; } = ""; + public string ActivityNodeId { get; init; } = ""; + public string ActivityType { get; init; } = ""; + public string Message { get; init; } = ""; + public string? ExceptionType { get; init; } + public string? ExceptionMessage { get; init; } + public DateTimeOffset Timestamp { get; init; } +} diff --git a/src/modules/Elsa.AI.Host/Context/WorkflowDefinitionContextProvider.cs b/src/modules/Elsa.AI.Host/Context/WorkflowDefinitionContextProvider.cs index 52081028b..e54ee6832 100644 --- a/src/modules/Elsa.AI.Host/Context/WorkflowDefinitionContextProvider.cs +++ b/src/modules/Elsa.AI.Host/Context/WorkflowDefinitionContextProvider.cs @@ -1,29 +1,50 @@ using Elsa.AI.Abstractions.Contracts; using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; namespace Elsa.AI.Host.Context; -/// -/// Placeholder workflow definition context provider. Replace this implementation with a workflow-management-backed resolver before using workflow context for production AI reasoning. -/// -public class WorkflowDefinitionContextProvider : IAIContextProvider, IPlaceholderAIContextProvider +public class WorkflowDefinitionContextProvider(IServiceProvider serviceProvider, WorkflowGroundingMapper mapper) : IAIContextProvider, IPlaceholderAIContextProvider { - public string Kind => "WorkflowDefinition"; + public string Kind => AIContextAttachmentKinds.WorkflowDefinition; - public ValueTask ResolveAsync(AIContextResolutionRequest request, CancellationToken cancellationToken = default) + public async ValueTask ResolveAsync(AIContextResolutionRequest request, CancellationToken cancellationToken = default) { var attachment = request.Attachment; + var store = serviceProvider.GetService(typeof(IWorkflowDefinitionStore)) as IWorkflowDefinitionStore; + if (store == null) + return Unavailable(attachment); - return ValueTask.FromResult(new AIResolvedContext + var definition = await store.FindAsync(new WorkflowDefinitionFilter { Id = attachment.ReferenceId }, cancellationToken) + ?? await store.FindAsync(new WorkflowDefinitionFilter + { + DefinitionId = attachment.ReferenceId, + VersionOptions = Elsa.Common.Models.VersionOptions.Latest + }, cancellationToken); + if (definition == null || !string.Equals(NormalizeTenant(definition.TenantId), NormalizeTenant(request.TenantId), StringComparison.Ordinal)) + return NotFound(attachment); + + return new AIResolvedContext { Kind = Kind, ReferenceId = attachment.ReferenceId, - Summary = $"Workflow definition context provider is not implemented; only reference {attachment.ReferenceId} was received.", - Data = [], + Summary = $"Workflow definition {definition.DefinitionId} v{definition.Version}: {definition.Name}", + Data = AIGroundingJson.ToJsonObject(mapper.Map(definition)), Metadata = new JsonObject { ["activityId"] = attachment.ActivityId } - }); + }; } + + private AIResolvedContext Unavailable(AIContextAttachment attachment) => + new() { Kind = Kind, ReferenceId = attachment.ReferenceId, Summary = "Workflow definition store is not available.", Data = [] }; + + private AIResolvedContext NotFound(AIContextAttachment attachment) => + new() { Kind = Kind, ReferenceId = attachment.ReferenceId, Summary = "Workflow definition was not found or is not authorized.", Data = [] }; + + private static string NormalizeTenant(string? tenantId) => + string.IsNullOrWhiteSpace(tenantId) ? "" : tenantId; } diff --git a/src/modules/Elsa.AI.Host/Context/WorkflowInstanceContextProvider.cs b/src/modules/Elsa.AI.Host/Context/WorkflowInstanceContextProvider.cs index 78811563d..49d26bb1a 100644 --- a/src/modules/Elsa.AI.Host/Context/WorkflowInstanceContextProvider.cs +++ b/src/modules/Elsa.AI.Host/Context/WorkflowInstanceContextProvider.cs @@ -1,29 +1,49 @@ using Elsa.AI.Abstractions.Contracts; using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; namespace Elsa.AI.Host.Context; -/// -/// Placeholder workflow instance context provider. Replace this implementation with a runtime-backed resolver before using instance context for production AI reasoning. -/// -public class WorkflowInstanceContextProvider : IAIContextProvider, IPlaceholderAIContextProvider +public class WorkflowInstanceContextProvider(IServiceProvider serviceProvider, RuntimeGroundingMapper mapper) : IAIContextProvider, IPlaceholderAIContextProvider { - public string Kind => "WorkflowInstance"; + public string Kind => AIContextAttachmentKinds.WorkflowInstance; - public ValueTask ResolveAsync(AIContextResolutionRequest request, CancellationToken cancellationToken = default) + public async ValueTask ResolveAsync(AIContextResolutionRequest request, CancellationToken cancellationToken = default) { var attachment = request.Attachment; + var store = serviceProvider.GetService(typeof(IWorkflowInstanceStore)) as IWorkflowInstanceStore; + if (store == null) + return Unavailable(attachment); - return ValueTask.FromResult(new AIResolvedContext + var instance = await store.FindAsync(new WorkflowInstanceFilter { Id = attachment.ReferenceId }, cancellationToken); + if (instance == null || !string.Equals(NormalizeTenant(instance.TenantId), NormalizeTenant(request.TenantId), StringComparison.Ordinal)) + return NotFound(attachment); + + var data = AIGroundingJson.ToJsonObject(mapper.Map(instance)); + data["state"] = mapper.MapState(instance); + data["incidents"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.Incidents.Select(x => mapper.MapIncident(instance.Id, x))); + + return new AIResolvedContext { Kind = Kind, ReferenceId = attachment.ReferenceId, - Summary = $"Workflow instance context provider is not implemented; only reference {attachment.ReferenceId} was received.", - Data = [], + Summary = $"Workflow instance {instance.Id} is {instance.Status}/{instance.SubStatus} with {instance.IncidentCount} incidents.", + Data = data, Metadata = new JsonObject { ["activityId"] = attachment.ActivityId } - }); + }; } + + private AIResolvedContext Unavailable(AIContextAttachment attachment) => + new() { Kind = Kind, ReferenceId = attachment.ReferenceId, Summary = "Workflow instance store is not available.", Data = [] }; + + private AIResolvedContext NotFound(AIContextAttachment attachment) => + new() { Kind = Kind, ReferenceId = attachment.ReferenceId, Summary = "Workflow instance was not found or is not authorized.", Data = [] }; + + private static string NormalizeTenant(string? tenantId) => + string.IsNullOrWhiteSpace(tenantId) ? "" : tenantId; } diff --git a/src/modules/Elsa.AI.Host/Elsa.AI.Host.csproj b/src/modules/Elsa.AI.Host/Elsa.AI.Host.csproj index b0af6f924..d60e8b1e6 100644 --- a/src/modules/Elsa.AI.Host/Elsa.AI.Host.csproj +++ b/src/modules/Elsa.AI.Host/Elsa.AI.Host.csproj @@ -16,6 +16,8 @@ + + diff --git a/src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs b/src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs index 541fd5d7b..09343595e 100644 --- a/src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs +++ b/src/modules/Elsa.AI.Host/Endpoints/AI/Capabilities/Endpoint.cs @@ -1,8 +1,12 @@ using Elsa.Abstractions; using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; using Elsa.AI.Host.Options; using Elsa.AI.Host.Permissions; +using Elsa.Workflows; +using Elsa.Workflows.Management; using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Elsa.AI.Host.Endpoints.AI.Capabilities; @@ -12,7 +16,8 @@ public class Endpoint( IOptions options, IEnumerable providers, IEnumerable conversationStores, - IEnumerable proposalStores) : ElsaEndpointWithoutRequest + IEnumerable proposalStores, + IServiceScopeFactory serviceScopeFactory) : ElsaEndpointWithoutRequest { public override void Configure() { @@ -33,9 +38,70 @@ public class Endpoint( optionsValue.ConversationPersistenceEnabled && hasDurableConversationStore, optionsValue.ProposalReviewEnabled && proposalStores.Any(), optionsValue.SupportedAttachmentKinds.ToList(), - optionsValue.Agents.Select(x => new AIAgentCapability(x.Name, x.DisplayName, x.Description)).ToList())); + optionsValue.Agents.Select(x => new AIAgentCapability(x.Name, x.DisplayName, x.Description)).ToList(), + CreateGroundingCapabilities(optionsValue, proposalStores.Any()))); } + private IReadOnlyCollection CreateGroundingCapabilities(AIHostOptions optionsValue, bool hasProposalStore) + { + var grounding = optionsValue.Grounding; + using var scope = serviceScopeFactory.CreateScope(); + var services = scope.ServiceProvider; + return + [ + CreateCapability( + "activities", + "Activity discovery", + grounding.ActivityGroundingEnabled && services.GetService() != null, + [CapabilityToolNames.ActivitiesSearchToolName, CapabilityToolNames.ActivityDescriptorToolName], + [AIContextAttachmentKinds.Activity], + grounding.ActivityGroundingEnabled, + "Activity Registry is not registered."), + CreateCapability( + "workflows", + "Workflow definitions", + grounding.WorkflowGroundingEnabled && services.GetService() != null, + [CapabilityToolNames.WorkflowsSearchToolName, CapabilityToolNames.WorkflowDefinitionToolName, CapabilityToolNames.WorkflowDefinitionGraphToolName, CapabilityToolNames.WorkflowUsageSearchToolName], + [AIContextAttachmentKinds.WorkflowDefinition], + grounding.WorkflowGroundingEnabled, + "Workflow definition store is not registered."), + CreateCapability( + "proposals", + "Workflow proposals", + grounding.ProposalGroundingEnabled && hasProposalStore, + [CapabilityToolNames.WorkflowValidateDraftToolName, CapabilityToolNames.WorkflowProposeCreateToolName, CapabilityToolNames.WorkflowProposeUpdateToolName], + [AIContextAttachmentKinds.WorkflowDefinition, AIContextAttachmentKinds.Activity], + grounding.ProposalGroundingEnabled, + "AI proposal store is not registered."), + CreateCapability( + "runtime", + "Runtime inspection", + grounding.RuntimeGroundingEnabled && services.GetService() != null, + [CapabilityToolNames.InstancesSearchToolName, CapabilityToolNames.WorkflowInstanceToolName, CapabilityToolNames.WorkflowInstanceExecutionHistoryToolName, CapabilityToolNames.WorkflowInstanceActivityStateToolName, CapabilityToolNames.IncidentsSearchToolName, CapabilityToolNames.IncidentToolName], + [AIContextAttachmentKinds.WorkflowInstance, AIContextAttachmentKinds.DiagnosticsScope, AIContextAttachmentKinds.TimeRange], + grounding.RuntimeGroundingEnabled, + "Workflow instance store is not registered.") + ]; + } + + private static AIGroundingCapabilityDescriptor CreateCapability( + string family, + string displayName, + bool available, + IReadOnlyCollection toolNames, + IReadOnlyCollection attachmentKinds, + bool enabled, + string unavailableReason) => + new() + { + Family = family, + DisplayName = displayName, + Available = available, + ToolNames = toolNames, + AttachmentKinds = attachmentKinds, + DisabledReasons = available ? [] : [enabled ? unavailableReason : "Grounding family is disabled by configuration."] + }; + private static bool HasSelectableProvider(string? providerName, IReadOnlyCollection providerOptions, IReadOnlyCollection availableProviders) { if (!string.IsNullOrWhiteSpace(providerName)) @@ -57,6 +123,26 @@ public record Response( bool ConversationPersistence, bool ProposalReview, IReadOnlyCollection SupportedAttachmentKinds, - IReadOnlyCollection Agents); + IReadOnlyCollection Agents, + IReadOnlyCollection Grounding); public record AIAgentCapability(string Name, string DisplayName, string Description); + +file static class CapabilityToolNames +{ + public const string ActivitiesSearchToolName = "activities.search"; + public const string ActivityDescriptorToolName = "activities.getDescriptor"; + public const string WorkflowsSearchToolName = "workflows.search"; + public const string WorkflowDefinitionToolName = "workflows.getDefinition"; + public const string WorkflowDefinitionGraphToolName = "workflows.getDefinitionGraph"; + public const string WorkflowUsageSearchToolName = "workflows.findUsages"; + public const string WorkflowValidateDraftToolName = "workflows.validateDraft"; + public const string WorkflowProposeCreateToolName = "workflows.proposeCreate"; + public const string WorkflowProposeUpdateToolName = "workflows.proposeUpdate"; + public const string InstancesSearchToolName = "instances.search"; + public const string WorkflowInstanceToolName = "instances.get"; + public const string WorkflowInstanceExecutionHistoryToolName = "instances.getExecutionHistory"; + public const string WorkflowInstanceActivityStateToolName = "instances.getActivityState"; + public const string IncidentsSearchToolName = "incidents.search"; + public const string IncidentToolName = "incidents.get"; +} diff --git a/src/modules/Elsa.AI.Host/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.AI.Host/Extensions/ServiceCollectionExtensions.cs index 95027cfc8..1e662935e 100644 --- a/src/modules/Elsa.AI.Host/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.AI.Host/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,9 @@ using Elsa.AI.Host.Context; using Elsa.AI.Host.Options; using Elsa.AI.Host.Services; using Elsa.AI.Host.Streaming; +using Elsa.AI.Host.Tools.Activities; +using Elsa.AI.Host.Tools.Runtime; +using Elsa.AI.Host.Tools.Workflows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -26,7 +29,30 @@ public static class ServiceCollectionExtensions services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); services.TryAddScoped(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/modules/Elsa.AI.Host/Features/AIFeature.cs b/src/modules/Elsa.AI.Host/Features/AIFeature.cs index 785198022..936e3d044 100644 --- a/src/modules/Elsa.AI.Host/Features/AIFeature.cs +++ b/src/modules/Elsa.AI.Host/Features/AIFeature.cs @@ -1,13 +1,16 @@ using Elsa.AI.Host.Options; +using Elsa.AI.Host.Services; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Services; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.AI.Host.Features; public class AIFeature(IModule module) : FeatureBase(module) { public Action? ConfigureOptions { get; set; } + public Action? ConfigureToolEnablement { get; set; } public override void Configure() { @@ -17,6 +20,18 @@ public class AIFeature(IModule module) : FeatureBase(module) public override void Apply() { Services.AddAIHostServices(ConfigureOptions); + if (ConfigureToolEnablement != null) + Services.AddSingleton(ConfigureToolEnablement); Module.AddFastEndpointsFromModule(); } + + public AIFeature EnableWorkflowProposalTools() + { + ConfigureToolEnablement += enablement => + { + enablement.Enable("workflows.proposeCreate"); + enablement.Enable("workflows.proposeUpdate"); + }; + return this; + } } diff --git a/src/modules/Elsa.AI.Host/Options/AIHostOptions.cs b/src/modules/Elsa.AI.Host/Options/AIHostOptions.cs index 1c93ebdb7..83bcab045 100644 --- a/src/modules/Elsa.AI.Host/Options/AIHostOptions.cs +++ b/src/modules/Elsa.AI.Host/Options/AIHostOptions.cs @@ -1,3 +1,5 @@ +using Elsa.AI.Abstractions.Models; + namespace Elsa.AI.Host.Options; public class AIHostOptions @@ -9,12 +11,30 @@ public class AIHostOptions public TimeSpan ReconnectGrace { get; set; } = TimeSpan.FromMinutes(5); public int MaxToolResultBytes { get; set; } = 64 * 1024; public int MaxResolvedContextBytes { get; set; } = 128 * 1024; + public AIGroundingOptions Grounding { get; set; } = new(); public string? DefaultProviderName { get; set; } public ICollection Providers { get; set; } = []; - public ICollection SupportedAttachmentKinds { get; set; } = ["WorkflowDefinition", "WorkflowInstance"]; + public ICollection SupportedAttachmentKinds { get; set; } = + [ + AIContextAttachmentKinds.WorkflowDefinition, + AIContextAttachmentKinds.WorkflowInstance, + AIContextAttachmentKinds.Activity, + AIContextAttachmentKinds.DiagnosticsScope, + AIContextAttachmentKinds.TimeRange + ]; public ICollection Agents { get; set; } = [new() { Name = "workflow-author", DisplayName = "Workflow author", Description = "Creates safe workflow proposals" }]; } +public class AIGroundingOptions +{ + public int MaxItems { get; set; } = 25; + public int MaxResultBytes { get; set; } = 64 * 1024; + public bool ActivityGroundingEnabled { get; set; } = true; + public bool WorkflowGroundingEnabled { get; set; } = true; + public bool ProposalGroundingEnabled { get; set; } = true; + public bool RuntimeGroundingEnabled { get; set; } = true; +} + public class AIProviderOptions { public string Name { get; set; } = ""; diff --git a/src/modules/Elsa.AI.Host/README.md b/src/modules/Elsa.AI.Host/README.md new file mode 100644 index 000000000..b12f8d061 --- /dev/null +++ b/src/modules/Elsa.AI.Host/README.md @@ -0,0 +1,42 @@ +# Elsa AI Host + +Elsa AI Host owns Weaver's provider-neutral server surface: chat orchestration, context resolution, tool registration, proposal governance, audit, and Studio-facing capabilities. Provider SDK types stay outside this module. + +## Grounding Tools + +Built-in grounding tools are registered as `IAITool` implementations. Read-only tools are enabled by default; proposal tools must be enabled explicitly before a provider can invoke them. + +Activity tools: + +- `activities.search` +- `activities.getDescriptor` + +Workflow definition tools: + +- `workflows.search` +- `workflows.getDefinition` +- `workflows.getDefinitionGraph` +- `workflows.findUsages` + +Proposal-only tools: + +- `workflows.validateDraft` +- `workflows.proposeCreate` +- `workflows.proposeUpdate` + +Runtime inspection tools: + +- `instances.search` +- `instances.get` +- `instances.getExecutionHistory` +- `instances.getActivityState` +- `incidents.search` +- `incidents.get` + +The tools read from Elsa server-side sources such as `IActivityRegistry`, `IWorkflowDefinitionStore`, and `IWorkflowInstanceStore`. If a source is not registered, the tool returns an unavailable result and `/ai/capabilities` reports the disabled reason for Studio. + +All tool results are bounded by `AIHostOptions.Grounding`, redacted before returning to the model or Studio, and audited by the existing AI Host tool invocation path. + +## Proposal Safety + +`workflows.proposeCreate` and `workflows.proposeUpdate` write only to `IAIProposalStore`. They do not persist workflow definitions. Approval and apply remain separate governed actions. diff --git a/src/modules/Elsa.AI.Host/Services/AIGroundingJson.cs b/src/modules/Elsa.AI.Host/Services/AIGroundingJson.cs new file mode 100644 index 000000000..fdff9e710 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/AIGroundingJson.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Elsa.AI.Host.Services; + +internal static class AIGroundingJson +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static JsonObject ToJsonObject(T value) => + JsonSerializer.SerializeToNode(value, SerializerOptions) as JsonObject ?? []; + + public static JsonArray ToJsonArray(IEnumerable values) + { + var array = new JsonArray(); + foreach (var value in values) + array.Add(JsonSerializer.SerializeToNode(value, SerializerOptions)); + + return array; + } +} diff --git a/src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs b/src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs new file mode 100644 index 000000000..c2ddaa522 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.AI.Host.Services; + +public class AIGroundingResultFormatter(IOptions options) +{ + private static readonly string[] SensitiveNameFragments = + [ + "password", + "secret", + "token", + "apikey", + "api_key", + "authorization", + "connectionstring", + "connection_string", + "credential" + ]; + + public AIToolResult CreateResult(string summary, IEnumerable items, int total, IEnumerable? evidence = null, IEnumerable? warnings = null) + { + var maxItems = Math.Max(0, options.Value.Grounding.MaxItems); + var itemList = items + .Take(maxItems) + .Select(x => RedactObject((JsonObject)x.DeepClone())) + .ToList(); + var truncated = total > itemList.Count; + var groundingResult = new AIGroundingToolResult + { + Summary = summary, + Items = itemList, + Total = total, + Returned = itemList.Count, + Truncated = truncated, + Evidence = evidence?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? [], + Warnings = warnings?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? [] + }; + + var data = AIGroundingJson.ToJsonObject(groundingResult); + return new AIToolResult + { + Summary = truncated ? $"{summary} Returned {itemList.Count} of {total} results." : summary, + Data = LimitData(data) + }; + } + + public AIToolResult Unavailable(string sourceName) => + new() + { + Status = AIToolInvocationStatus.Failed, + Summary = $"{sourceName} is not available in this Elsa host.", + Error = $"{sourceName} is not available.", + Data = new JsonObject + { + ["available"] = false, + ["source"] = sourceName + } + }; + + public JsonObject RedactObject(JsonObject source) + { + var redacted = new JsonObject(); + foreach (var (key, value) in source) + redacted[key] = IsSensitiveKey(key) ? "***" : RedactNode(value); + + return redacted; + } + + public JsonObject LimitData(JsonObject data) + { + var maxBytes = Math.Max(0, options.Value.Grounding.MaxResultBytes); + if (maxBytes == 0 || JsonSerializer.SerializeToUtf8Bytes(data).Length <= maxBytes) + return data; + + return new JsonObject + { + ["truncated"] = true, + ["maxBytes"] = maxBytes + }; + } + + private JsonNode? RedactNode(JsonNode? node) => + node switch + { + JsonObject jsonObject => RedactObject(jsonObject), + JsonArray jsonArray => RedactArray(jsonArray), + null => null, + _ => node.DeepClone() + }; + + private JsonArray RedactArray(JsonArray source) + { + var redacted = new JsonArray(); + foreach (var value in source) + redacted.Add(RedactNode(value)); + + return redacted; + } + + private static bool IsSensitiveKey(string key) + { + var normalized = key.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal); + return SensitiveNameFragments.Any(x => normalized.Contains(x, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/modules/Elsa.AI.Host/Services/AIToolEnablementConfigurationHostedService.cs b/src/modules/Elsa.AI.Host/Services/AIToolEnablementConfigurationHostedService.cs new file mode 100644 index 000000000..b51fea270 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/AIToolEnablementConfigurationHostedService.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Hosting; + +namespace Elsa.AI.Host.Services; + +public class AIToolEnablementConfigurationHostedService( + AIToolEnablementService enablementService, + IEnumerable> configureActions) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + foreach (var configure in configureActions) + configure(enablementService); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => + Task.CompletedTask; +} diff --git a/src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs b/src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs new file mode 100644 index 000000000..91db26295 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs @@ -0,0 +1,86 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.Workflows.Models; + +namespace Elsa.AI.Host.Services; + +public class ActivityGroundingMapper +{ + public ActivityGroundingSummary Map(ActivityDescriptor descriptor) => + new() + { + Type = descriptor.TypeName, + Version = descriptor.Version, + Namespace = descriptor.Namespace, + Name = descriptor.Name, + DisplayName = descriptor.DisplayName ?? descriptor.Name, + Description = descriptor.Description, + Category = descriptor.Category, + IsBrowsable = descriptor.IsBrowsable, + IsTrigger = descriptor.IsStart, + IsContainer = descriptor.IsContainer, + IsTerminal = descriptor.IsTerminal, + Inputs = descriptor.Inputs.Select(MapInput).OrderBy(x => x.Name).ToList(), + Outputs = descriptor.Outputs.Select(MapOutput).OrderBy(x => x.Name).ToList(), + Ports = descriptor.Ports.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + Constraints = GetConstraints(descriptor).ToList() + }; + + private static ActivityPortSummary MapInput(InputDescriptor input) => + new() + { + Name = input.Name, + DisplayName = input.DisplayName ?? input.Name, + Description = input.Description, + Type = GetFriendlyTypeName(input.Type), + Category = input.Category, + IsBrowsable = input.IsBrowsable ?? true, + IsSensitive = input.IsSensitive, + IsRequired = input.DefaultValue == null && !IsNullable(input.Type), + UIHint = input.UIHint, + DefaultSyntax = input.DefaultSyntax + }; + + private static ActivityPortSummary MapOutput(OutputDescriptor output) => + new() + { + Name = output.Name, + DisplayName = output.DisplayName ?? output.Name, + Description = output.Description, + Type = GetFriendlyTypeName(output.Type), + IsBrowsable = output.IsBrowsable ?? true + }; + + private static IEnumerable GetConstraints(ActivityDescriptor descriptor) + { + if (!descriptor.IsBrowsable) + yield return "Not selectable from activity pickers."; + + if (descriptor.RunAsynchronously) + yield return "Runs asynchronously by default."; + + if (descriptor.IsStart) + yield return "Can start a workflow."; + + if (descriptor.IsTerminal) + yield return "Can terminate a workflow path."; + } + + private static bool IsNullable(Type type) => + !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + + private static string GetFriendlyTypeName(Type? type) + { + if (type == null) + return "unknown"; + + var nullableType = Nullable.GetUnderlyingType(type); + if (nullableType != null) + return $"{GetFriendlyTypeName(nullableType)}?"; + + if (!type.IsGenericType) + return type.Name; + + var name = type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]; + return $"{name}<{string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName))}>"; + } +} diff --git a/src/modules/Elsa.AI.Host/Services/ActivityGroundingSearchService.cs b/src/modules/Elsa.AI.Host/Services/ActivityGroundingSearchService.cs new file mode 100644 index 000000000..ed72cfe2b --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/ActivityGroundingSearchService.cs @@ -0,0 +1,64 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; + +namespace Elsa.AI.Host.Services; + +public class ActivityGroundingSearchService(ActivityGroundingMapper mapper) +{ + public IReadOnlyCollection Search( + IActivityRegistry registry, + string? query, + string? category, + string? type, + int? version, + string? input, + string? output, + bool? trigger) + { + var descriptors = registry.ListAll().AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(query)) + descriptors = descriptors.Where(x => MatchesQuery(x, query)); + + if (!string.IsNullOrWhiteSpace(category)) + descriptors = descriptors.Where(x => string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(type)) + descriptors = descriptors.Where(x => string.Equals(x.TypeName, type, StringComparison.OrdinalIgnoreCase) || + string.Equals(x.Name, type, StringComparison.OrdinalIgnoreCase)); + + if (version != null) + descriptors = descriptors.Where(x => x.Version == version); + + if (!string.IsNullOrWhiteSpace(input)) + descriptors = descriptors.Where(x => x.Inputs.Any(inputDescriptor => MatchesText(inputDescriptor.Name, input) || MatchesText(inputDescriptor.DisplayName, input))); + + if (!string.IsNullOrWhiteSpace(output)) + descriptors = descriptors.Where(x => x.Outputs.Any(outputDescriptor => MatchesText(outputDescriptor.Name, output) || MatchesText(outputDescriptor.DisplayName, output))); + + if (trigger != null) + descriptors = descriptors.Where(x => x.IsStart == trigger); + + return descriptors + .OrderBy(x => x.Category) + .ThenBy(x => x.DisplayName ?? x.Name) + .ThenByDescending(x => x.Version) + .ToList(); + } + + public JsonObject Map(ActivityDescriptor descriptor) => + AIGroundingJson.ToJsonObject(mapper.Map(descriptor)); + + private static bool MatchesQuery(ActivityDescriptor descriptor, string query) => + MatchesText(descriptor.Name, query) || + MatchesText(descriptor.DisplayName, query) || + MatchesText(descriptor.Description, query) || + MatchesText(descriptor.Category, query) || + MatchesText(descriptor.Namespace, query) || + MatchesText(descriptor.TypeName, query) || + descriptor.Inputs.Any(x => MatchesText(x.Name, query) || MatchesText(x.DisplayName, query) || MatchesText(x.Description, query)) || + descriptor.Outputs.Any(x => MatchesText(x.Name, query) || MatchesText(x.DisplayName, query) || MatchesText(x.Description, query)); + + private static bool MatchesText(string? value, string query) => + !string.IsNullOrWhiteSpace(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs b/src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs new file mode 100644 index 000000000..2b8a2a43b --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs @@ -0,0 +1,72 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Models; + +namespace Elsa.AI.Host.Services; + +public class RuntimeGroundingMapper(AIGroundingResultFormatter formatter) +{ + public RuntimeInstanceGroundingSummary Map(WorkflowInstanceSummary summary) => + new() + { + Id = summary.Id, + TenantId = summary.TenantId, + DefinitionId = summary.DefinitionId, + DefinitionVersionId = summary.DefinitionVersionId, + Version = summary.Version, + Status = summary.Status.ToString(), + SubStatus = summary.SubStatus.ToString(), + CorrelationId = summary.CorrelationId, + Name = summary.Name, + IncidentCount = summary.IncidentCount, + CreatedAt = summary.CreatedAt, + UpdatedAt = summary.UpdatedAt, + FinishedAt = summary.FinishedAt + }; + + public RuntimeInstanceGroundingSummary Map(WorkflowInstance instance) => + new() + { + Id = instance.Id, + TenantId = instance.TenantId, + DefinitionId = instance.DefinitionId, + DefinitionVersionId = instance.DefinitionVersionId, + Version = instance.Version, + Status = instance.Status.ToString(), + SubStatus = instance.SubStatus.ToString(), + CorrelationId = instance.CorrelationId, + Name = instance.Name, + IncidentCount = instance.IncidentCount, + CreatedAt = instance.CreatedAt, + UpdatedAt = instance.UpdatedAt, + FinishedAt = instance.FinishedAt + }; + + public IncidentGroundingSummary MapIncident(string workflowInstanceId, ActivityIncident incident) => + new() + { + WorkflowInstanceId = workflowInstanceId, + ActivityId = incident.ActivityId, + ActivityNodeId = incident.ActivityNodeId, + ActivityType = incident.ActivityType, + Message = incident.Message, + ExceptionType = incident.Exception?.Type?.FullName, + ExceptionMessage = incident.Exception?.Message, + Timestamp = incident.Timestamp + }; + + public JsonObject MapState(WorkflowInstance instance) => + formatter.RedactObject(new JsonObject + { + ["id"] = instance.Id, + ["status"] = instance.Status.ToString(), + ["subStatus"] = instance.SubStatus.ToString(), + ["input"] = AIGroundingJson.ToJsonObject(instance.WorkflowState.Input), + ["output"] = AIGroundingJson.ToJsonObject(instance.WorkflowState.Output), + ["properties"] = AIGroundingJson.ToJsonObject(instance.WorkflowState.Properties), + ["scheduledActivityCount"] = instance.WorkflowState.ScheduledActivities.Count, + ["bookmarkCount"] = instance.WorkflowState.Bookmarks.Count, + ["activityExecutionContextCount"] = instance.WorkflowState.ActivityExecutionContexts.Count + }); +} diff --git a/src/modules/Elsa.AI.Host/Services/WorkflowDraftValidationService.cs b/src/modules/Elsa.AI.Host/Services/WorkflowDraftValidationService.cs new file mode 100644 index 000000000..6287aab66 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/WorkflowDraftValidationService.cs @@ -0,0 +1,72 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.Workflows; + +namespace Elsa.AI.Host.Services; + +public class WorkflowDraftValidationService(IServiceProvider serviceProvider) +{ + public IReadOnlyCollection Validate(JsonObject draft, string? baselineVersionId = null, string? expectedBaselineVersionId = null) + { + var diagnostics = new List(); + if (draft.Count == 0) + diagnostics.Add(Error("draft.empty", "Draft payload is empty.", "$")); + + if (!string.IsNullOrWhiteSpace(expectedBaselineVersionId) && + !string.Equals(baselineVersionId, expectedBaselineVersionId, StringComparison.Ordinal)) + diagnostics.Add(Error("baseline.stale", "The proposal baseline version is stale.", "$.baselineVersionId")); + + var registry = serviceProvider.GetService(typeof(IActivityRegistry)) as IActivityRegistry; + if (registry == null) + { + diagnostics.Add(Warning("activityRegistry.unavailable", "Activity Registry is not available, so activity validation was skipped.", "$")); + return diagnostics; + } + + foreach (var activityType in FindActivityTypes(draft).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (registry.Find(activityType) == null) + diagnostics.Add(Error("activity.unavailable", $"Activity '{activityType}' is not installed.", "$")); + } + + return diagnostics; + } + + private static IEnumerable FindActivityTypes(JsonNode? node) + { + if (node is JsonObject jsonObject) + { + if (TryReadString(jsonObject, "type", out var type) || TryReadString(jsonObject, "typeName", out type) || TryReadString(jsonObject, "activityType", out type)) + yield return type; + + foreach (var child in jsonObject.Select(x => x.Value)) + { + foreach (var activityType in FindActivityTypes(child)) + yield return activityType; + } + } + else if (node is JsonArray jsonArray) + { + foreach (var child in jsonArray) + { + foreach (var activityType in FindActivityTypes(child)) + yield return activityType; + } + } + } + + private static bool TryReadString(JsonObject jsonObject, string name, out string value) + { + value = ""; + if (!jsonObject.TryGetPropertyValue(name, out var node) || node is not JsonValue jsonValue || !jsonValue.TryGetValue(out var result) || string.IsNullOrWhiteSpace(result)) + return false; + + value = result; + return true; + } + + private static AIValidationDiagnostic Error(string code, string message, string path) => + new() { Code = code, Message = message, Path = path, Severity = AIValidationSeverity.Error }; + + private static AIValidationDiagnostic Warning(string code, string message, string path) => + new() { Code = code, Message = message, Path = path, Severity = AIValidationSeverity.Warning }; +} diff --git a/src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs b/src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs new file mode 100644 index 000000000..a297765d1 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs @@ -0,0 +1,127 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Abstractions.Models; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Models; + +namespace Elsa.AI.Host.Services; + +public class WorkflowGroundingMapper +{ + public WorkflowGroundingSummary Map(WorkflowDefinitionSummary summary) => + new() + { + Id = summary.Id, + DefinitionId = summary.DefinitionId, + Name = summary.Name, + Description = summary.Description, + Version = summary.Version, + IsLatest = summary.IsLatest, + IsPublished = summary.IsPublished, + IsReadonly = summary.IsReadonly, + MaterializerName = summary.MaterializerName, + ProviderName = summary.ProviderName, + CreatedAt = summary.CreatedAt + }; + + public WorkflowGroundingSummary Map(WorkflowDefinition definition) + { + var graph = GetGraph(definition); + return new WorkflowGroundingSummary + { + Id = definition.Id, + DefinitionId = definition.DefinitionId, + Name = definition.Name, + Description = definition.Description, + Version = definition.Version, + IsLatest = definition.IsLatest, + IsPublished = definition.IsPublished, + IsReadonly = definition.IsReadonly, + MaterializerName = definition.MaterializerName, + ProviderName = definition.ProviderName, + CreatedAt = definition.CreatedAt, + ActivityTypes = graph.ActivityTypes, + Variables = definition.Variables.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + Inputs = definition.Inputs.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + Outputs = definition.Outputs.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList() + }; + } + + public JsonObject MapGraph(WorkflowDefinition definition) + { + var graph = GetGraph(definition); + return new JsonObject + { + ["definitionId"] = definition.DefinitionId, + ["versionId"] = definition.Id, + ["version"] = definition.Version, + ["activityTypes"] = AIGroundingJson.ToJsonArray(graph.ActivityTypes), + ["activityCount"] = graph.ActivityCount, + ["activities"] = AIGroundingJson.ToJsonArray(graph.Activities), + ["variables"] = AIGroundingJson.ToJsonArray(definition.Variables.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x))), + ["inputs"] = AIGroundingJson.ToJsonArray(definition.Inputs.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x))), + ["outputs"] = AIGroundingJson.ToJsonArray(definition.Outputs.Select(x => x.Name).Where(x => !string.IsNullOrWhiteSpace(x))) + }; + } + + public WorkflowGraphSummary GetGraph(WorkflowDefinition definition) + { + var root = TryParseWorkflowJson(definition); + var activities = root == null ? [] : FindActivities(root).Take(200).ToList(); + var activityTypes = activities + .Select(x => x.Type) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new WorkflowGraphSummary(activities, activityTypes, activities.Count); + } + + private static JsonNode? TryParseWorkflowJson(WorkflowDefinition definition) + { + var json = !string.IsNullOrWhiteSpace(definition.OriginalSource) ? definition.OriginalSource : definition.StringData; + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonNode.Parse(json); + } + catch + { + return null; + } + } + + private static IEnumerable FindActivities(JsonNode node) + { + if (node is JsonObject jsonObject) + { + var type = ReadString(jsonObject, "type") ?? ReadString(jsonObject, "typeName") ?? ReadString(jsonObject, "activityType"); + var id = ReadString(jsonObject, "id") ?? ReadString(jsonObject, "activityId") ?? ReadString(jsonObject, "nodeId"); + if (!string.IsNullOrWhiteSpace(type)) + yield return new WorkflowActivitySummary(id, type, ReadString(jsonObject, "name") ?? ReadString(jsonObject, "displayName")); + + foreach (var child in jsonObject.Select(x => x.Value).OfType()) + { + foreach (var activity in FindActivities(child)) + yield return activity; + } + } + else if (node is JsonArray jsonArray) + { + foreach (var child in jsonArray.OfType()) + { + foreach (var activity in FindActivities(child)) + yield return activity; + } + } + } + + private static string? ReadString(JsonObject jsonObject, string name) => + jsonObject.TryGetPropertyValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var result) ? result : null; +} + +public record WorkflowGraphSummary(IReadOnlyCollection Activities, IReadOnlyCollection ActivityTypes, int ActivityCount); + +public record WorkflowActivitySummary(string? Id, string Type, string? Name); diff --git a/src/modules/Elsa.AI.Host/Services/WorkflowProposalDiffService.cs b/src/modules/Elsa.AI.Host/Services/WorkflowProposalDiffService.cs new file mode 100644 index 000000000..043c0256c --- /dev/null +++ b/src/modules/Elsa.AI.Host/Services/WorkflowProposalDiffService.cs @@ -0,0 +1,52 @@ +using Elsa.AI.Abstractions.Models; + +namespace Elsa.AI.Host.Services; + +public class WorkflowProposalDiffService +{ + public AIGraphDiff CreateDiff(JsonObject draft, JsonObject? baseline = null) + { + var baselineIds = GetActivityIds(baseline).ToHashSet(StringComparer.OrdinalIgnoreCase); + var draftIds = GetActivityIds(draft).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return new AIGraphDiff + { + AddedActivityIds = draftIds.Except(baselineIds, StringComparer.OrdinalIgnoreCase).Order().ToList(), + RemovedActivityIds = baselineIds.Except(draftIds, StringComparer.OrdinalIgnoreCase).Order().ToList(), + ChangedActivityIds = [], + Data = new JsonObject + { + ["baselineActivityCount"] = baselineIds.Count, + ["draftActivityCount"] = draftIds.Count + } + }; + } + + private static IEnumerable GetActivityIds(JsonNode? node) + { + if (node is JsonObject jsonObject) + { + var type = ReadString(jsonObject, "type") ?? ReadString(jsonObject, "typeName") ?? ReadString(jsonObject, "activityType"); + var id = ReadString(jsonObject, "id") ?? ReadString(jsonObject, "activityId") ?? ReadString(jsonObject, "nodeId"); + if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(id)) + yield return id; + + foreach (var child in jsonObject.Select(x => x.Value)) + { + foreach (var childId in GetActivityIds(child)) + yield return childId; + } + } + else if (node is JsonArray jsonArray) + { + foreach (var child in jsonArray) + { + foreach (var childId in GetActivityIds(child)) + yield return childId; + } + } + } + + private static string? ReadString(JsonObject jsonObject, string name) => + jsonObject.TryGetPropertyValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var result) ? result : null; +} diff --git a/src/modules/Elsa.AI.Host/Tools/Activities/ActivitiesSearchTool.cs b/src/modules/Elsa.AI.Host/Tools/Activities/ActivitiesSearchTool.cs new file mode 100644 index 000000000..07d457767 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Activities/ActivitiesSearchTool.cs @@ -0,0 +1,42 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.AI.Host.Tools; +using Elsa.Workflows; + +namespace Elsa.AI.Host.Tools.Activities; + +public class ActivitiesSearchTool(IServiceProvider serviceProvider, ActivityGroundingSearchService searchService, AIGroundingResultFormatter formatter) : GroundingToolBase +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "activities.search", + "Search activities", + "Search installed Activity Registry descriptors by name, category, type, version, ports, and trigger behavior.", + GroundingToolSchemas.WithProperties( + ("query", GroundingToolSchemas.String("Free-text search term.")), + ("category", GroundingToolSchemas.String("Activity category.")), + ("type", GroundingToolSchemas.String("Activity type name or short name.")), + ("version", GroundingToolSchemas.Integer("Activity version.")), + ("input", GroundingToolSchemas.String("Input name or display name.")), + ("output", GroundingToolSchemas.String("Output name or display name.")), + ("trigger", GroundingToolSchemas.Boolean("Whether the activity can start a workflow.")))); + + public override ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var registry = serviceProvider.GetService(typeof(IActivityRegistry)) as IActivityRegistry; + if (registry == null) + return ValueTask.FromResult(formatter.Unavailable("Activity Registry")); + + var descriptors = searchService.Search( + registry, + GetString(context.Arguments, "query"), + GetString(context.Arguments, "category"), + GetString(context.Arguments, "type"), + GetInt(context.Arguments, "version"), + GetString(context.Arguments, "input"), + GetString(context.Arguments, "output"), + GetBool(context.Arguments, "trigger")); + var items = descriptors.Select(searchService.Map); + + return ValueTask.FromResult(formatter.CreateResult($"Found {descriptors.Count} installed activities.", items, descriptors.Count, ["ActivityRegistry"])); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Activities/ActivityDescriptorTool.cs b/src/modules/Elsa.AI.Host/Tools/Activities/ActivityDescriptorTool.cs new file mode 100644 index 000000000..d1c0b4337 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Activities/ActivityDescriptorTool.cs @@ -0,0 +1,37 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.AI.Host.Tools; +using Elsa.Workflows; + +namespace Elsa.AI.Host.Tools.Activities; + +public class ActivityDescriptorTool(IServiceProvider serviceProvider, ActivityGroundingSearchService searchService, AIGroundingResultFormatter formatter) : GroundingToolBase +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "activities.getDescriptor", + "Get activity descriptor", + "Get model-safe metadata for one installed activity descriptor.", + GroundingToolSchemas.WithProperties( + ("type", GroundingToolSchemas.String("Activity type name or short name.")), + ("version", GroundingToolSchemas.Integer("Optional activity version.")))); + + public override ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var registry = serviceProvider.GetService(typeof(IActivityRegistry)) as IActivityRegistry; + if (registry == null) + return ValueTask.FromResult(formatter.Unavailable("Activity Registry")); + + var type = GetString(context.Arguments, "type"); + var version = GetInt(context.Arguments, "version"); + var descriptor = string.IsNullOrWhiteSpace(type) + ? null + : version != null + ? registry.Find(type, version.Value) + : registry.Find(x => string.Equals(x.TypeName, type, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Name, type, StringComparison.OrdinalIgnoreCase)); + + if (descriptor == null) + return ValueTask.FromResult(new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = $"Activity '{type}' was not found." }); + + return ValueTask.FromResult(formatter.CreateResult($"Resolved descriptor for {descriptor.TypeName} v{descriptor.Version}.", [searchService.Map(descriptor)], 1, ["ActivityRegistry"])); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/GroundingToolBase.cs b/src/modules/Elsa.AI.Host/Tools/GroundingToolBase.cs new file mode 100644 index 000000000..83208c439 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/GroundingToolBase.cs @@ -0,0 +1,56 @@ +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; + +namespace Elsa.AI.Host.Tools; + +public abstract class GroundingToolBase : IAITool +{ + public abstract AIToolDefinition Definition { get; } + public abstract ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default); + + public void Dispose() + { + } + + protected static string? GetString(JsonObject arguments, string name) => + arguments.TryGetPropertyValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var result) && !string.IsNullOrWhiteSpace(result) + ? result + : null; + + protected static int? GetInt(JsonObject arguments, string name) => + arguments.TryGetPropertyValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var result) + ? result + : null; + + protected static bool? GetBool(JsonObject arguments, string name) => + arguments.TryGetPropertyValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var result) + ? result + : null; + + protected static JsonObject? GetObject(JsonObject arguments, string name) => + arguments.TryGetPropertyValue(name, out var node) && node is JsonObject jsonObject + ? jsonObject + : null; + + protected static AIToolDefinition ReadOnlyDefinition(string name, string displayName, string description, JsonObject schema) => + new() + { + Name = name, + DisplayName = displayName, + Description = description, + Schema = schema, + Mutability = AIToolMutability.ReadOnly, + DangerLevel = AIToolDangerLevel.Low + }; + + protected static AIToolDefinition ProposalDefinition(string name, string displayName, string description, JsonObject schema) => + new() + { + Name = name, + DisplayName = displayName, + Description = description, + Schema = schema, + Mutability = AIToolMutability.Proposal, + DangerLevel = AIToolDangerLevel.Medium + }; +} diff --git a/src/modules/Elsa.AI.Host/Tools/GroundingToolSchemas.cs b/src/modules/Elsa.AI.Host/Tools/GroundingToolSchemas.cs new file mode 100644 index 000000000..b7a9c7b7b --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/GroundingToolSchemas.cs @@ -0,0 +1,52 @@ +namespace Elsa.AI.Host.Tools; + +internal static class GroundingToolSchemas +{ + public static JsonObject Empty() => + new() + { + ["type"] = "object", + ["properties"] = new JsonObject() + }; + + public static JsonObject WithProperties(params (string Name, JsonObject Schema)[] properties) + { + var propertyObject = new JsonObject(); + foreach (var (name, schema) in properties) + propertyObject[name] = schema; + + return new JsonObject + { + ["type"] = "object", + ["properties"] = propertyObject + }; + } + + public static JsonObject String(string description) => + new() + { + ["type"] = "string", + ["description"] = description + }; + + public static JsonObject Integer(string description) => + new() + { + ["type"] = "integer", + ["description"] = description + }; + + public static JsonObject Boolean(string description) => + new() + { + ["type"] = "boolean", + ["description"] = description + }; + + public static JsonObject Object(string description) => + new() + { + ["type"] = "object", + ["description"] = description + }; +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs new file mode 100644 index 000000000..32c912328 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs @@ -0,0 +1,38 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Runtime; + +public class IncidentTool(IServiceProvider serviceProvider, RuntimeGroundingMapper mapper, AIGroundingResultFormatter formatter) : RuntimeToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "incidents.get", + "Get incident", + "Get a redacted incident by workflow instance and activity or node ID.", + GroundingToolSchemas.WithProperties( + ("instanceId", GroundingToolSchemas.String("Workflow instance ID.")), + ("activityId", GroundingToolSchemas.String("Activity ID.")), + ("activityNodeId", GroundingToolSchemas.String("Activity node ID.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + if (WorkflowInstanceStore == null) + return InstanceStoreUnavailable(); + + var instance = await FindAuthorizedInstanceAsync(context.Arguments, context.TenantId, cancellationToken); + if (instance == null) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Workflow instance was not found." }; + + var activityId = GetString(context.Arguments, "activityId"); + var activityNodeId = GetString(context.Arguments, "activityNodeId"); + var incidents = instance.WorkflowState.Incidents + .Where(x => Matches(x.ActivityId, activityId) || Matches(x.ActivityNodeId, activityNodeId)) + .Select(x => mapper.MapIncident(instance.Id, x)) + .ToList(); + + return Formatter.CreateResult($"Resolved {incidents.Count} incidents for workflow instance {instance.Id}.", incidents.Select(AIGroundingJson.ToJsonObject), incidents.Count, ["WorkflowInstanceStore"]); + } + + private static bool Matches(string? value, string? expected) => + !string.IsNullOrWhiteSpace(value) && !string.IsNullOrWhiteSpace(expected) && string.Equals(value, expected, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/IncidentsSearchTool.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/IncidentsSearchTool.cs new file mode 100644 index 000000000..75fdd8af5 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/IncidentsSearchTool.cs @@ -0,0 +1,38 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Common.Models; + +namespace Elsa.AI.Host.Tools.Runtime; + +public class IncidentsSearchTool(IServiceProvider serviceProvider, RuntimeGroundingMapper mapper, AIGroundingResultFormatter formatter) : RuntimeToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "incidents.search", + "Search incidents", + "Search incidents across authorized workflow instances in an explicit workflow, status, or time scope.", + GroundingToolSchemas.WithProperties( + ("definitionId", GroundingToolSchemas.String("Workflow definition ID.")), + ("instanceId", GroundingToolSchemas.String("Workflow instance ID.")), + ("query", GroundingToolSchemas.String("Free-text incident or instance search term.")), + ("from", GroundingToolSchemas.String("Updated-at range start.")), + ("to", GroundingToolSchemas.String("Updated-at range end.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = WorkflowInstanceStore; + if (store == null) + return InstanceStoreUnavailable(); + + var filter = CreateInstanceFilter(context.Arguments); + filter.HasIncidents = true; + var page = await store.FindManyAsync(filter, PageArgs.FromRange(0, 100), cancellationToken); + var incidents = page.Items + .Where(x => IsTenantAllowed(x, context.TenantId)) + .SelectMany(x => x.WorkflowState.Incidents.Select(incident => mapper.MapIncident(x.Id, incident))) + .OrderByDescending(x => x.Timestamp) + .ToList(); + var items = incidents.Select(AIGroundingJson.ToJsonObject); + + return Formatter.CreateResult($"Found {incidents.Count} incidents.", items, incidents.Count, ["WorkflowInstanceStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/InstancesSearchTool.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/InstancesSearchTool.cs new file mode 100644 index 000000000..04c181dc9 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/InstancesSearchTool.cs @@ -0,0 +1,35 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Common.Models; + +namespace Elsa.AI.Host.Tools.Runtime; + +public class InstancesSearchTool(IServiceProvider serviceProvider, RuntimeGroundingMapper mapper, AIGroundingResultFormatter formatter) : RuntimeToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "instances.search", + "Search workflow instances", + "Search authorized workflow instances by workflow, status, incident flag, correlation ID, and time range.", + GroundingToolSchemas.WithProperties( + ("query", GroundingToolSchemas.String("Free-text search term.")), + ("definitionId", GroundingToolSchemas.String("Workflow definition ID.")), + ("status", GroundingToolSchemas.String("Workflow status.")), + ("subStatus", GroundingToolSchemas.String("Workflow sub-status.")), + ("correlationId", GroundingToolSchemas.String("Correlation ID.")), + ("hasIncidents", GroundingToolSchemas.Boolean("Whether incidents are present.")), + ("from", GroundingToolSchemas.String("Updated-at range start.")), + ("to", GroundingToolSchemas.String("Updated-at range end.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = WorkflowInstanceStore; + if (store == null) + return InstanceStoreUnavailable(); + + var page = await store.FindManyAsync(CreateInstanceFilter(context.Arguments), PageArgs.FromRange(0, 100), cancellationToken); + var instances = page.Items.Where(x => IsTenantAllowed(x, context.TenantId)).ToList(); + var items = instances.Select(x => AIGroundingJson.ToJsonObject(mapper.Map(x))); + + return Formatter.CreateResult($"Found {instances.Count} authorized workflow instances.", items, instances.Count, ["WorkflowInstanceStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/RuntimeToolBase.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/RuntimeToolBase.cs new file mode 100644 index 000000000..9890dcd91 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/RuntimeToolBase.cs @@ -0,0 +1,83 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.AI.Host.Tools; +using Elsa.Common.Models; +using Elsa.Workflows; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; + +namespace Elsa.AI.Host.Tools.Runtime; + +public abstract class RuntimeToolBase(IServiceProvider serviceProvider, AIGroundingResultFormatter formatter) : GroundingToolBase +{ + protected AIGroundingResultFormatter Formatter { get; } = formatter; + protected IWorkflowInstanceStore? WorkflowInstanceStore => serviceProvider.GetService(typeof(IWorkflowInstanceStore)) as IWorkflowInstanceStore; + + protected AIToolResult InstanceStoreUnavailable() => + Formatter.Unavailable("Workflow instance store"); + + protected static bool IsTenantAllowed(WorkflowInstance instance, string? tenantId) => + string.Equals(NormalizeTenant(instance.TenantId), NormalizeTenant(tenantId), StringComparison.Ordinal); + + protected static WorkflowInstanceFilter CreateInstanceFilter(JsonObject arguments) + { + var filter = new WorkflowInstanceFilter + { + Id = GetString(arguments, "instanceId") ?? GetString(arguments, "id"), + DefinitionId = GetString(arguments, "definitionId"), + DefinitionVersionId = GetString(arguments, "definitionVersionId"), + CorrelationId = GetString(arguments, "correlationId"), + SearchTerm = GetString(arguments, "query") ?? GetString(arguments, "searchTerm"), + HasIncidents = GetBool(arguments, "hasIncidents") + }; + + if (Enum.TryParse(GetString(arguments, "status"), true, out var status)) + filter.WorkflowStatus = status; + + if (Enum.TryParse(GetString(arguments, "subStatus"), true, out var subStatus)) + filter.WorkflowSubStatus = subStatus; + + var from = GetDateTimeOffset(arguments, "from"); + var to = GetDateTimeOffset(arguments, "to"); + var timestampFilters = new List(); + if (from != null) + timestampFilters.Add(new TimestampFilter + { + Column = nameof(WorkflowInstance.UpdatedAt), + Operator = TimestampFilterOperator.GreaterThanOrEqual, + Timestamp = from.Value + }); + if (to != null) + timestampFilters.Add(new TimestampFilter + { + Column = nameof(WorkflowInstance.UpdatedAt), + Operator = TimestampFilterOperator.LessThanOrEqual, + Timestamp = to.Value + }); + if (timestampFilters.Count > 0) + filter.TimestampFilters = timestampFilters; + + return filter; + } + + protected async ValueTask FindAuthorizedInstanceAsync(JsonObject arguments, string? tenantId, CancellationToken cancellationToken) + { + var store = WorkflowInstanceStore; + if (store == null) + return null; + + var instance = await store.FindAsync(CreateInstanceFilter(arguments), cancellationToken); + return instance != null && IsTenantAllowed(instance, tenantId) ? instance : null; + } + + private static DateTimeOffset? GetDateTimeOffset(JsonObject arguments, string name) => + arguments.TryGetPropertyValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var result) + ? result + : null; + + private static string NormalizeTenant(string? tenantId) => + string.IsNullOrWhiteSpace(tenantId) ? "" : tenantId; +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceActivityStateTool.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceActivityStateTool.cs new file mode 100644 index 000000000..4f4ebaf50 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceActivityStateTool.cs @@ -0,0 +1,49 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Runtime; + +public class WorkflowInstanceActivityStateTool(IServiceProvider serviceProvider, AIGroundingResultFormatter formatter) : RuntimeToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "instances.getActivityState", + "Get instance activity state", + "Get bounded state for a specific activity in a workflow instance.", + GroundingToolSchemas.WithProperties( + ("instanceId", GroundingToolSchemas.String("Workflow instance ID.")), + ("activityId", GroundingToolSchemas.String("Activity ID.")), + ("activityNodeId", GroundingToolSchemas.String("Activity node ID.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + if (WorkflowInstanceStore == null) + return InstanceStoreUnavailable(); + + var instance = await FindAuthorizedInstanceAsync(context.Arguments, context.TenantId, cancellationToken); + if (instance == null) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Workflow instance was not found." }; + + var activityId = GetString(context.Arguments, "activityId"); + var activityNodeId = GetString(context.Arguments, "activityNodeId"); + var item = new JsonObject + { + ["instanceId"] = instance.Id, + ["activityId"] = activityId, + ["activityNodeId"] = activityNodeId, + ["incidents"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.Incidents + .Where(x => Matches(x.ActivityId, activityId) || Matches(x.ActivityNodeId, activityNodeId)) + .Select(x => new { x.ActivityId, x.ActivityNodeId, x.ActivityType, x.Message, x.Timestamp })), + ["scheduledActivities"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.ScheduledActivities + .Where(x => Matches(x.ActivityNodeId, activityNodeId) || Matches(x.ActivityNodeId, activityId)) + .Select(x => new { x.ActivityNodeId, x.OwnerContextId, x.ExistingActivityExecutionContextId })), + ["bookmarks"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.Bookmarks + .Where(x => Matches(x.ActivityId, activityId)) + .Select(x => new { x.Id, x.Name, x.ActivityId })) + }; + + return Formatter.CreateResult($"Resolved activity state for workflow instance {instance.Id}.", [Formatter.RedactObject(item)], 1, ["WorkflowInstanceStore"]); + } + + private static bool Matches(string? value, string? expected) => + !string.IsNullOrWhiteSpace(value) && !string.IsNullOrWhiteSpace(expected) && string.Equals(value, expected, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceExecutionHistoryTool.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceExecutionHistoryTool.cs new file mode 100644 index 000000000..c265051c8 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceExecutionHistoryTool.cs @@ -0,0 +1,35 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Runtime; + +public class WorkflowInstanceExecutionHistoryTool(IServiceProvider serviceProvider, RuntimeGroundingMapper mapper, AIGroundingResultFormatter formatter) : RuntimeToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "instances.getExecutionHistory", + "Get instance execution history", + "Get bounded execution-history evidence from the workflow instance state.", + GroundingToolSchemas.WithProperties( + ("instanceId", GroundingToolSchemas.String("Workflow instance ID.")), + ("id", GroundingToolSchemas.String("Workflow instance ID.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + if (WorkflowInstanceStore == null) + return InstanceStoreUnavailable(); + + var instance = await FindAuthorizedInstanceAsync(context.Arguments, context.TenantId, cancellationToken); + if (instance == null) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Workflow instance was not found." }; + + var item = new JsonObject + { + ["instance"] = AIGroundingJson.ToJsonObject(mapper.Map(instance)), + ["scheduledActivities"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.ScheduledActivities.Select(x => new { x.ActivityNodeId, x.OwnerContextId, x.ExistingActivityExecutionContextId })), + ["bookmarks"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.Bookmarks.Select(x => new { x.Id, x.Name, x.ActivityId })), + ["incidents"] = AIGroundingJson.ToJsonArray(instance.WorkflowState.Incidents.Select(x => mapper.MapIncident(instance.Id, x)).OrderBy(x => x.Timestamp)) + }; + + return Formatter.CreateResult($"Resolved execution history for workflow instance {instance.Id}.", [item], 1, ["WorkflowInstanceStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceTool.cs b/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceTool.cs new file mode 100644 index 000000000..12ebda0bc --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Runtime/WorkflowInstanceTool.cs @@ -0,0 +1,30 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Runtime; + +public class WorkflowInstanceTool(IServiceProvider serviceProvider, RuntimeGroundingMapper mapper, AIGroundingResultFormatter formatter) : RuntimeToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "instances.get", + "Get workflow instance", + "Get a redacted workflow instance summary and bounded state evidence.", + GroundingToolSchemas.WithProperties( + ("instanceId", GroundingToolSchemas.String("Workflow instance ID.")), + ("id", GroundingToolSchemas.String("Workflow instance ID.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + if (WorkflowInstanceStore == null) + return InstanceStoreUnavailable(); + + var instance = await FindAuthorizedInstanceAsync(context.Arguments, context.TenantId, cancellationToken); + if (instance == null) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Workflow instance was not found." }; + + var item = AIGroundingJson.ToJsonObject(mapper.Map(instance)); + item["state"] = mapper.MapState(instance); + + return Formatter.CreateResult($"Resolved workflow instance {instance.Id}.", [item], 1, ["WorkflowInstanceStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionGraphTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionGraphTool.cs new file mode 100644 index 000000000..e9e9f6464 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionGraphTool.cs @@ -0,0 +1,30 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowDefinitionGraphTool(IServiceProvider serviceProvider, WorkflowGroundingMapper mapper, AIGroundingResultFormatter formatter) : WorkflowToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "workflows.getDefinitionGraph", + "Get workflow graph", + "Get a bounded graph summary for an authorized workflow definition.", + GroundingToolSchemas.WithProperties( + ("id", GroundingToolSchemas.String("Workflow definition version ID.")), + ("versionId", GroundingToolSchemas.String("Workflow definition version ID.")), + ("definitionId", GroundingToolSchemas.String("Logical workflow definition ID.")), + ("version", GroundingToolSchemas.Integer("Specific workflow version.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = WorkflowDefinitionStore; + if (store == null) + return WorkflowStoreUnavailable(); + + var definition = await store.FindAsync(CreateDefinitionFilter(context.Arguments), cancellationToken); + if (definition == null || !IsTenantAllowed(definition, context.TenantId)) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Workflow definition was not found." }; + + return Formatter.CreateResult($"Resolved graph for workflow definition {definition.DefinitionId} v{definition.Version}.", [mapper.MapGraph(definition)], 1, ["WorkflowDefinitionStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionTool.cs new file mode 100644 index 000000000..33324642a --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowDefinitionTool.cs @@ -0,0 +1,31 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowDefinitionTool(IServiceProvider serviceProvider, WorkflowGroundingMapper mapper, AIGroundingResultFormatter formatter) : WorkflowToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "workflows.getDefinition", + "Get workflow definition", + "Get a model-safe workflow definition summary with version metadata and graph hints.", + GroundingToolSchemas.WithProperties( + ("id", GroundingToolSchemas.String("Workflow definition version ID.")), + ("versionId", GroundingToolSchemas.String("Workflow definition version ID.")), + ("definitionId", GroundingToolSchemas.String("Logical workflow definition ID.")), + ("version", GroundingToolSchemas.Integer("Specific workflow version.")), + ("published", GroundingToolSchemas.Boolean("Resolve the published version instead of the latest version.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = WorkflowDefinitionStore; + if (store == null) + return WorkflowStoreUnavailable(); + + var definition = await store.FindAsync(CreateDefinitionFilter(context.Arguments), cancellationToken); + if (definition == null || !IsTenantAllowed(definition, context.TenantId)) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Workflow definition was not found." }; + + return Formatter.CreateResult($"Resolved workflow definition {definition.DefinitionId} v{definition.Version}.", [AIGroundingJson.ToJsonObject(mapper.Map(definition))], 1, ["WorkflowDefinitionStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeCreateTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeCreateTool.cs new file mode 100644 index 000000000..9ecfa079a --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeCreateTool.cs @@ -0,0 +1,41 @@ +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowProposeCreateTool(IServiceProvider serviceProvider, WorkflowDraftValidationService validationService, WorkflowProposalDiffService diffService, AIGroundingResultFormatter formatter) : GroundingToolBase +{ + public override AIToolDefinition Definition { get; } = ProposalDefinition( + "workflows.proposeCreate", + "Propose workflow creation", + "Create a reviewable AI workflow creation proposal. This never persists a workflow definition.", + GroundingToolSchemas.WithProperties( + ("draft", GroundingToolSchemas.Object("Workflow draft JSON payload.")), + ("rationale", GroundingToolSchemas.String("Why the draft should be created.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = serviceProvider.GetService(typeof(IAIProposalStore)) as IAIProposalStore; + if (store == null) + return formatter.Unavailable("AI proposal store"); + + var draft = GetObject(context.Arguments, "draft") ?? []; + var diagnostics = validationService.Validate(draft); + var proposal = new AIProposal + { + TenantId = context.TenantId, + ConversationId = context.ConversationId, + Kind = AIProposalKind.WorkflowCreate, + Status = diagnostics.Any(x => x.Severity == AIValidationSeverity.Error) ? AIProposalStatus.Blocked : AIProposalStatus.Validated, + WorkflowPayload = (JsonObject)draft.DeepClone(), + Rationale = GetString(context.Arguments, "rationale") ?? "", + ValidationDiagnostics = diagnostics.ToList(), + GraphDiff = diffService.CreateDiff(draft), + CreatedBy = context.ActorId + }; + await store.SaveAsync(proposal, cancellationToken); + + return formatter.CreateResult($"Created workflow creation proposal {proposal.Id}.", [AIGroundingJson.ToJsonObject(proposal)], 1, ["AIProposalStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeUpdateTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeUpdateTool.cs new file mode 100644 index 000000000..987ab8d6c --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowProposeUpdateTool.cs @@ -0,0 +1,57 @@ +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowProposeUpdateTool(IServiceProvider serviceProvider, WorkflowDraftValidationService validationService, WorkflowProposalDiffService diffService, WorkflowGroundingMapper workflowMapper, AIGroundingResultFormatter formatter) : WorkflowToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ProposalDefinition( + "workflows.proposeUpdate", + "Propose workflow update", + "Create a reviewable AI workflow update proposal against a baseline workflow version. This never persists a workflow definition.", + GroundingToolSchemas.WithProperties( + ("definitionId", GroundingToolSchemas.String("Logical workflow definition ID.")), + ("baselineVersionId", GroundingToolSchemas.String("Baseline workflow version ID.")), + ("draft", GroundingToolSchemas.Object("Workflow draft JSON payload.")), + ("rationale", GroundingToolSchemas.String("Why the draft should update the workflow.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var proposalStore = ServiceProvider.GetService(typeof(IAIProposalStore)) as IAIProposalStore; + if (proposalStore == null) + return Formatter.Unavailable("AI proposal store"); + + var workflowStore = WorkflowDefinitionStore; + if (workflowStore == null) + return WorkflowStoreUnavailable(); + + var baselineVersionId = GetString(context.Arguments, "baselineVersionId"); + var baseline = string.IsNullOrWhiteSpace(baselineVersionId) + ? null + : await workflowStore.FindAsync(new() { Id = baselineVersionId }, cancellationToken); + if (baseline == null || !IsTenantAllowed(baseline, context.TenantId)) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "Baseline workflow definition was not found." }; + + var latest = await workflowStore.FindAsync(new() { DefinitionId = baseline.DefinitionId, VersionOptions = Elsa.Common.Models.VersionOptions.Latest }, cancellationToken); + var draft = GetObject(context.Arguments, "draft") ?? []; + var diagnostics = validationService.Validate(draft, baselineVersionId, latest?.Id); + var proposal = new AIProposal + { + TenantId = context.TenantId, + ConversationId = context.ConversationId, + Kind = AIProposalKind.WorkflowUpdate, + Status = diagnostics.Any(x => x.Severity == AIValidationSeverity.Error) ? AIProposalStatus.Blocked : AIProposalStatus.Validated, + BaselineWorkflowDefinitionId = baseline.DefinitionId, + BaselineVersionId = baseline.Id, + WorkflowPayload = (JsonObject)draft.DeepClone(), + Rationale = GetString(context.Arguments, "rationale") ?? "", + ValidationDiagnostics = diagnostics.ToList(), + GraphDiff = diffService.CreateDiff(draft, workflowMapper.MapGraph(baseline)), + CreatedBy = context.ActorId + }; + await proposalStore.SaveAsync(proposal, cancellationToken); + + return Formatter.CreateResult($"Created workflow update proposal {proposal.Id}.", [AIGroundingJson.ToJsonObject(proposal)], 1, ["AIProposalStore", "WorkflowDefinitionStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowToolBase.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowToolBase.cs new file mode 100644 index 000000000..ac652d3a1 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowToolBase.cs @@ -0,0 +1,46 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.AI.Host.Tools; +using Elsa.Common.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; + +namespace Elsa.AI.Host.Tools.Workflows; + +public abstract class WorkflowToolBase(IServiceProvider serviceProvider, AIGroundingResultFormatter formatter) : GroundingToolBase +{ + protected IServiceProvider ServiceProvider { get; } = serviceProvider; + protected AIGroundingResultFormatter Formatter { get; } = formatter; + protected IWorkflowDefinitionStore? WorkflowDefinitionStore => ServiceProvider.GetService(typeof(IWorkflowDefinitionStore)) as IWorkflowDefinitionStore; + + protected AIToolResult WorkflowStoreUnavailable() => + Formatter.Unavailable("Workflow definition store"); + + protected static bool IsTenantAllowed(WorkflowDefinition definition, string? tenantId) => + string.Equals(NormalizeTenant(definition.TenantId), NormalizeTenant(tenantId), StringComparison.Ordinal); + + protected static WorkflowDefinitionFilter CreateDefinitionFilter(JsonObject arguments) + { + var filter = new WorkflowDefinitionFilter + { + Id = GetString(arguments, "versionId") ?? GetString(arguments, "id"), + DefinitionId = GetString(arguments, "definitionId"), + SearchTerm = GetString(arguments, "query") ?? GetString(arguments, "searchTerm"), + Name = GetString(arguments, "name") + }; + + var version = GetInt(arguments, "version"); + if (version != null) + filter.VersionOptions = VersionOptions.SpecificVersion(version.Value); + else if (GetBool(arguments, "published") == true) + filter.VersionOptions = VersionOptions.Published; + else + filter.VersionOptions = VersionOptions.Latest; + + return filter; + } + + protected static string NormalizeTenant(string? tenantId) => + string.IsNullOrWhiteSpace(tenantId) ? "" : tenantId; +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowUsageSearchTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowUsageSearchTool.cs new file mode 100644 index 000000000..94ce2936c --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowUsageSearchTool.cs @@ -0,0 +1,41 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Common.Models; +using Elsa.Workflows.Management.Filters; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowUsageSearchTool(IServiceProvider serviceProvider, WorkflowGroundingMapper mapper, AIGroundingResultFormatter formatter) : WorkflowToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "workflows.findUsages", + "Find workflow activity usages", + "Find authorized workflow definitions that reference an activity type in their serialized graph.", + GroundingToolSchemas.WithProperties( + ("activityType", GroundingToolSchemas.String("Activity type to find.")), + ("query", GroundingToolSchemas.String("Optional workflow search term.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = WorkflowDefinitionStore; + if (store == null) + return WorkflowStoreUnavailable(); + + var activityType = GetString(context.Arguments, "activityType"); + if (string.IsNullOrWhiteSpace(activityType)) + return new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = "activityType is required." }; + + var page = await store.FindManyAsync(new WorkflowDefinitionFilter + { + SearchTerm = GetString(context.Arguments, "query"), + VersionOptions = VersionOptions.Latest + }, PageArgs.FromRange(0, 200), cancellationToken); + var definitions = page.Items + .Where(x => IsTenantAllowed(x, context.TenantId)) + .Where(x => mapper.GetGraph(x).ActivityTypes.Contains(activityType, StringComparer.OrdinalIgnoreCase)) + .ToList(); + var items = definitions.Select(x => AIGroundingJson.ToJsonObject(mapper.Map(x))); + + return Formatter.CreateResult($"Found {definitions.Count} workflow definitions using {activityType}.", items, definitions.Count, ["WorkflowDefinitionStore"]); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowValidateDraftTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowValidateDraftTool.cs new file mode 100644 index 000000000..b01843fc9 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowValidateDraftTool.cs @@ -0,0 +1,30 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowValidateDraftTool(WorkflowDraftValidationService validationService, AIGroundingResultFormatter formatter) : GroundingToolBase +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "workflows.validateDraft", + "Validate workflow draft", + "Validate a workflow draft against installed activity descriptors and baseline metadata.", + GroundingToolSchemas.WithProperties( + ("draft", GroundingToolSchemas.Object("Workflow draft JSON payload.")), + ("baselineVersionId", GroundingToolSchemas.String("Baseline workflow version ID.")), + ("expectedBaselineVersionId", GroundingToolSchemas.String("Expected current baseline version ID.")))); + + public override ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var draft = GetObject(context.Arguments, "draft") ?? []; + var diagnostics = validationService.Validate(draft, GetString(context.Arguments, "baselineVersionId"), GetString(context.Arguments, "expectedBaselineVersionId")); + var hasErrors = diagnostics.Any(x => x.Severity == AIValidationSeverity.Error); + var result = formatter.CreateResult( + hasErrors ? "Workflow draft validation failed." : "Workflow draft validation completed.", + diagnostics.Select(AIGroundingJson.ToJsonObject), + diagnostics.Count, + ["ActivityRegistry"]); + + return ValueTask.FromResult(result with { Status = hasErrors ? AIToolInvocationStatus.Failed : AIToolInvocationStatus.Completed }); + } +} diff --git a/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowsSearchTool.cs b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowsSearchTool.cs new file mode 100644 index 000000000..27c5bb830 --- /dev/null +++ b/src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowsSearchTool.cs @@ -0,0 +1,32 @@ +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Common.Models; + +namespace Elsa.AI.Host.Tools.Workflows; + +public class WorkflowsSearchTool(IServiceProvider serviceProvider, WorkflowGroundingMapper mapper, AIGroundingResultFormatter formatter) : WorkflowToolBase(serviceProvider, formatter) +{ + public override AIToolDefinition Definition { get; } = ReadOnlyDefinition( + "workflows.search", + "Search workflows", + "Search authorized workflow definitions by name, id, description, and version scope.", + GroundingToolSchemas.WithProperties( + ("query", GroundingToolSchemas.String("Free-text search term.")), + ("name", GroundingToolSchemas.String("Workflow name.")), + ("definitionId", GroundingToolSchemas.String("Logical workflow definition ID.")), + ("version", GroundingToolSchemas.Integer("Specific workflow version.")), + ("published", GroundingToolSchemas.Boolean("Search the published version instead of the latest version.")))); + + public override async ValueTask ExecuteAsync(AIToolExecutionContext context, CancellationToken cancellationToken = default) + { + var store = WorkflowDefinitionStore; + if (store == null) + return WorkflowStoreUnavailable(); + + var page = await store.FindManyAsync(CreateDefinitionFilter(context.Arguments), PageArgs.FromRange(0, 100), cancellationToken); + var definitions = page.Items.Where(x => IsTenantAllowed(x, context.TenantId)).ToList(); + var items = definitions.Select(x => AIGroundingJson.ToJsonObject(mapper.Map(x))); + + return Formatter.CreateResult($"Found {definitions.Count} authorized workflow definitions.", items, definitions.Count, ["WorkflowDefinitionStore"]); + } +} diff --git a/test/integration/Elsa.AI.IntegrationTests/AIActivityGroundingToolTests.cs b/test/integration/Elsa.AI.IntegrationTests/AIActivityGroundingToolTests.cs new file mode 100644 index 000000000..f272cf7e3 --- /dev/null +++ b/test/integration/Elsa.AI.IntegrationTests/AIActivityGroundingToolTests.cs @@ -0,0 +1,72 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.AI.IntegrationTests; + +public class AIActivityGroundingToolTests +{ + [Fact(DisplayName = "Activity grounding tools search and return installed descriptors")] + public async Task ActivityGroundingToolsSearchAndReturnInstalledDescriptors() + { + var services = new ServiceCollection(); + services.AddAIHostServices(); + services.AddSingleton(new TestActivityRegistry( + new ActivityDescriptor + { + TypeName = "Elsa.Http.HttpEndpoint", + Namespace = "Elsa.Http", + Name = "HttpEndpoint", + DisplayName = "HTTP Endpoint", + Category = "HTTP", + Description = "Receives HTTP requests", + Version = 1, + IsStart = true + })); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + using var tool = await registry.FindAsync("activities.search", new AIToolQuery { ActorId = "user-1" }); + + var result = await tool!.ExecuteAsync(new AIToolExecutionContext + { + ActorId = "user-1", + ConversationId = "conversation-1", + Arguments = new JsonObject { ["query"] = "http", ["trigger"] = true } + }); + + Assert.Equal(AIToolInvocationStatus.Completed, result.Status); + Assert.Equal(1, result.Data["returned"]!.GetValue()); + var item = result.Data["items"]!.AsArray()[0]!.AsObject(); + Assert.Equal("Elsa.Http.HttpEndpoint", item["type"]!.GetValue()); + Assert.True(item["isTrigger"]!.GetValue()); + } + + private class TestActivityRegistry(params ActivityDescriptor[] descriptors) : IActivityRegistry + { + private readonly List _descriptors = descriptors.ToList(); + + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) => + ValueTask.FromResult>(_descriptors); + + public void Add(Type providerType, ActivityDescriptor descriptor) => _descriptors.Add(descriptor); + public void Remove(Type providerType, ActivityDescriptor descriptor) => _descriptors.Remove(descriptor); + public IEnumerable ListAll() => _descriptors; + public IEnumerable ListByProvider(Type providerType) => _descriptors; + public ActivityDescriptor? Find(string type) => _descriptors.FirstOrDefault(x => string.Equals(x.TypeName, type, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Name, type, StringComparison.OrdinalIgnoreCase)); + public ActivityDescriptor? Find(string type, int version) => _descriptors.FirstOrDefault(x => (string.Equals(x.TypeName, type, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Name, type, StringComparison.OrdinalIgnoreCase)) && x.Version == version); + public ActivityDescriptor? Find(Func predicate) => _descriptors.FirstOrDefault(predicate); + public IEnumerable FindMany(Func predicate) => _descriptors.Where(predicate); + public void Register(ActivityDescriptor descriptor) => _descriptors.Add(descriptor); + public Task RegisterAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RegisterAsync(IEnumerable activityTypes, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RefreshDescriptorsAsync(IEnumerable activityProviders, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Clear() => _descriptors.Clear(); + public void ClearProvider(Type providerType) => _descriptors.Clear(); + } +} diff --git a/test/integration/Elsa.AI.IntegrationTests/AICapabilitiesEndpointTests.cs b/test/integration/Elsa.AI.IntegrationTests/AICapabilitiesEndpointTests.cs index bbbf04e42..37d853fc2 100644 --- a/test/integration/Elsa.AI.IntegrationTests/AICapabilitiesEndpointTests.cs +++ b/test/integration/Elsa.AI.IntegrationTests/AICapabilitiesEndpointTests.cs @@ -3,6 +3,7 @@ using Elsa.AI.Abstractions.Models; using Elsa.AI.Host.Endpoints.AI.Capabilities; using Elsa.AI.Host.Options; using Elsa.AI.Host.Services; +using Microsoft.Extensions.DependencyInjection; using MicrosoftOptions = Microsoft.Extensions.Options.Options; namespace Elsa.AI.IntegrationTests; @@ -16,7 +17,8 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions { ConversationPersistenceEnabled = true }), [new TestAIProvider()], [new TestConversationStore()], - [new TestProposalStore()]); + [new TestProposalStore()], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); @@ -25,9 +27,13 @@ public class AICapabilitiesEndpointTests Assert.True(response.ProposalReview); Assert.Contains("WorkflowDefinition", response.SupportedAttachmentKinds); Assert.Contains("WorkflowInstance", response.SupportedAttachmentKinds); - Assert.DoesNotContain("ActivitySelection", response.SupportedAttachmentKinds); - Assert.DoesNotContain("DiagnosticsScope", response.SupportedAttachmentKinds); - Assert.DoesNotContain("TimeRange", response.SupportedAttachmentKinds); + Assert.Contains("Activity", response.SupportedAttachmentKinds); + Assert.Contains("DiagnosticsScope", response.SupportedAttachmentKinds); + Assert.Contains("TimeRange", response.SupportedAttachmentKinds); + Assert.Contains(response.Grounding, x => x.Family == "activities"); + Assert.Contains(response.Grounding, x => x.Family == "workflows"); + Assert.Contains(response.Grounding, x => x.Family == "proposals"); + Assert.Contains(response.Grounding, x => x.Family == "runtime"); } [Fact(DisplayName = "Capabilities endpoint hides unavailable capabilities")] @@ -37,7 +43,8 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions { StreamingEnabled = false, ConversationPersistenceEnabled = true }), [new TestAIProvider()], [new TestConversationStore()], - []); + [], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); @@ -53,7 +60,8 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions()), [new TestAIProvider("provider-1"), new TestAIProvider("provider-2")], [new TestConversationStore()], - []); + [], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); @@ -67,7 +75,8 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions { DefaultProviderName = "provider-2" }), [new TestAIProvider("provider-1"), new TestAIProvider("provider-2")], [new TestConversationStore()], - []); + [], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); @@ -81,7 +90,8 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions()), [new TestAIProvider()], [new TestConversationStore()], - []); + [], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); @@ -95,7 +105,8 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions { ConversationPersistenceEnabled = true }), [new TestAIProvider()], [new InMemoryAIConversationStore()], - [new TestProposalStore()]); + [new TestProposalStore()], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); @@ -109,13 +120,17 @@ public class AICapabilitiesEndpointTests MicrosoftOptions.Create(new AIHostOptions { ConversationPersistenceEnabled = false }), [new TestAIProvider()], [new TestConversationStore()], - [new TestProposalStore()]); + [new TestProposalStore()], + CreateScopeFactory()); var response = await endpoint.ExecuteAsync(CancellationToken.None); Assert.False(response.ConversationPersistence); } + private static IServiceScopeFactory CreateScopeFactory() => + new ServiceCollection().BuildServiceProvider().GetRequiredService(); + private class TestAIProvider(string name = "test") : IAIProvider { public string Name => name; diff --git a/test/integration/Elsa.AI.IntegrationTests/AIChatEndpointTests.cs b/test/integration/Elsa.AI.IntegrationTests/AIChatEndpointTests.cs index 30ae7e5d0..8b94f386e 100644 --- a/test/integration/Elsa.AI.IntegrationTests/AIChatEndpointTests.cs +++ b/test/integration/Elsa.AI.IntegrationTests/AIChatEndpointTests.cs @@ -451,7 +451,9 @@ public class AIChatEndpointTests // Intentionally drain the stream to completion. } - Assert.Empty(provider.Requests.Single().Tools); + var tools = provider.Requests.Single().Tools; + Assert.DoesNotContain(tools, x => x.Name == "disabled-echo"); + Assert.Contains(tools, x => x.Name == "activities.search"); } [Fact(DisplayName = "Chat orchestration audits unresolved tool calls")] diff --git a/test/integration/Elsa.AI.IntegrationTests/AIRuntimeGroundingToolTests.cs b/test/integration/Elsa.AI.IntegrationTests/AIRuntimeGroundingToolTests.cs new file mode 100644 index 000000000..954725144 --- /dev/null +++ b/test/integration/Elsa.AI.IntegrationTests/AIRuntimeGroundingToolTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Models; +using Elsa.Workflows.State; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.AI.IntegrationTests; + +public class AIRuntimeGroundingToolTests +{ + [Fact(DisplayName = "Runtime grounding tools return redacted incident evidence")] + public async Task RuntimeGroundingToolsReturnRedactedIncidentEvidence() + { + var instance = new WorkflowInstance + { + Id = "instance-1", + DefinitionId = "workflow-1", + DefinitionVersionId = "version-1", + Version = 1, + Status = WorkflowStatus.Finished, + SubStatus = WorkflowSubStatus.Faulted, + IncidentCount = 1, + WorkflowState = new WorkflowState + { + Incidents = + { + new ActivityIncident("activity-1", "node-1", "Elsa.Http.HttpEndpoint", "API key password leaked", null, DateTimeOffset.UtcNow) + }, + Input = new Dictionary { ["password"] = "secret" } + } + }; + var services = new ServiceCollection(); + services.AddAIHostServices(); + services.AddSingleton(new TestWorkflowInstanceStore(instance)); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + + using var tool = await registry.FindAsync("incidents.search", new AIToolQuery { ActorId = "user-1" }); + var result = await tool!.ExecuteAsync(new AIToolExecutionContext + { + ActorId = "user-1", + ConversationId = "conversation-1", + Arguments = new JsonObject { ["definitionId"] = "workflow-1" } + }); + + Assert.Equal(1, result.Data["returned"]!.GetValue()); + var incident = result.Data["items"]!.AsArray()[0]!.AsObject(); + Assert.Equal("instance-1", incident["workflowInstanceId"]!.GetValue()); + Assert.DoesNotContain("secret", result.Data.ToJsonString(), StringComparison.OrdinalIgnoreCase); + } + + private class TestWorkflowInstanceStore(params WorkflowInstance[] instances) : IWorkflowInstanceStore + { + private readonly List _instances = instances.ToList(); + + public ValueTask FindAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => + ValueTask.FromResult(Apply(filter).FirstOrDefault()); + + public ValueTask> FindManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) + { + var items = Apply(filter).ToList(); + return ValueTask.FromResult(Page.Of(items, items.Count)); + } + + public ValueTask> FindManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) => + FindManyAsync(filter, pageArgs, cancellationToken); + + public ValueTask> FindManyAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => + ValueTask.FromResult>(Apply(filter).ToList()); + + public ValueTask> FindManyAsync(WorkflowInstanceFilter filter, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) => + FindManyAsync(filter, cancellationToken); + + public ValueTask CountAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => + ValueTask.FromResult((long)Apply(filter).Count()); + + public ValueTask> SummarizeManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) + { + var items = Apply(filter).Select(WorkflowInstanceSummary.FromInstance).ToList(); + return ValueTask.FromResult(Page.Of(items, items.Count)); + } + + public ValueTask> SummarizeManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) => + SummarizeManyAsync(filter, pageArgs, cancellationToken); + + public ValueTask> FindManyIdsAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => + ValueTask.FromResult>(Apply(filter).Select(x => x.Id).ToList()); + + public ValueTask> FindManyIdsAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) + { + var ids = Apply(filter).Select(x => x.Id).ToList(); + return ValueTask.FromResult(Page.Of(ids, ids.Count)); + } + + public ValueTask> FindManyIdsAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) => + FindManyIdsAsync(filter, pageArgs, cancellationToken); + + public ValueTask> SummarizeManyAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => + ValueTask.FromResult>(Apply(filter).Select(WorkflowInstanceSummary.FromInstance).ToList()); + + public ValueTask> SummarizeManyAsync(WorkflowInstanceFilter filter, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) => + SummarizeManyAsync(filter, cancellationToken); + + public ValueTask SaveAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask AddAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask UpdateAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask SaveManyAsync(IEnumerable instances, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public ValueTask DeleteAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(0L); + public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffset value, CancellationToken cancellationToken = default) => Task.CompletedTask; + + private IEnumerable Apply(WorkflowInstanceFilter filter) + { + var query = _instances.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter.Id)) + query = query.Where(x => x.Id == filter.Id); + if (!string.IsNullOrWhiteSpace(filter.DefinitionId)) + query = query.Where(x => x.DefinitionId == filter.DefinitionId); + if (filter.HasIncidents != null) + query = query.Where(x => filter.HasIncidents == true ? x.IncidentCount > 0 : x.IncidentCount == 0); + return query; + } + } +} diff --git a/test/integration/Elsa.AI.IntegrationTests/AIToolsEndpointTests.cs b/test/integration/Elsa.AI.IntegrationTests/AIToolsEndpointTests.cs index 8756bd124..dfba89ca5 100644 --- a/test/integration/Elsa.AI.IntegrationTests/AIToolsEndpointTests.cs +++ b/test/integration/Elsa.AI.IntegrationTests/AIToolsEndpointTests.cs @@ -25,7 +25,9 @@ public class AIToolsEndpointTests var tools = await endpoint.ExecuteAsync(new Request(), CancellationToken.None); - Assert.Empty(tools); + Assert.Contains(tools, x => x.Name == "activities.search"); + Assert.Contains(tools, x => x.Name == "workflows.search"); + Assert.Contains(tools, x => x.Name == "instances.search"); } [Fact(DisplayName = "Tools endpoint forwards agent scope to registry")] @@ -41,8 +43,8 @@ public class AIToolsEndpointTests var tools = await endpoint.ExecuteAsync(new Request { Agent = "workflow-author" }, CancellationToken.None); - var tool = Assert.Single(tools); - Assert.Equal("workflow.author", tool.Name); + Assert.Contains(tools, tool => tool.Name == "workflow.author"); + Assert.DoesNotContain(tools, tool => tool.Name == "workflow.editor"); } [Fact(DisplayName = "Tool registry caches definitions across list calls")] @@ -61,6 +63,23 @@ public class AIToolsEndpointTests Assert.Equal(1, CountingTool.ConstructorCount); } + [Fact(DisplayName = "Tools endpoint lists built-in grounding tools")] + public async Task ToolsEndpointListsBuiltInGroundingTools() + { + var services = new ServiceCollection(); + services.AddAIHostServices(); + using var provider = services.BuildServiceProvider(); + var endpoint = new ToolsEndpoint(provider.GetRequiredService(), MicrosoftOptions.Create(new AIHostOptions())); + + var tools = await endpoint.ExecuteAsync(new Request(), CancellationToken.None); + + Assert.Contains(tools, tool => tool.Name == "activities.getDescriptor"); + Assert.Contains(tools, tool => tool.Name == "workflows.getDefinitionGraph"); + Assert.Contains(tools, tool => tool.Name == "workflows.validateDraft"); + Assert.Contains(tools, tool => tool.Name == "incidents.search"); + Assert.Contains(tools, tool => tool.Name == "workflows.proposeCreate" && !tool.IsEnabled); + } + private class WorkflowAuthorTool : IAITool { public AIToolDefinition Definition { get; } = new() diff --git a/test/integration/Elsa.AI.IntegrationTests/AIWorkflowGroundingToolTests.cs b/test/integration/Elsa.AI.IntegrationTests/AIWorkflowGroundingToolTests.cs new file mode 100644 index 000000000..7c484a205 --- /dev/null +++ b/test/integration/Elsa.AI.IntegrationTests/AIWorkflowGroundingToolTests.cs @@ -0,0 +1,132 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.AI.IntegrationTests; + +public class AIWorkflowGroundingToolTests +{ + [Fact(DisplayName = "Workflow grounding tools search and return graph summaries")] + public async Task WorkflowGroundingToolsSearchAndReturnGraphSummaries() + { + var definition = new WorkflowDefinition + { + Id = "version-1", + DefinitionId = "workflow-1", + Name = "Order intake", + Description = "Receives orders", + Version = 1, + IsLatest = true, + MaterializerName = "Json", + StringData = """{ "activities": [ { "id": "a1", "type": "Elsa.Http.HttpEndpoint" } ] }""" + }; + var services = new ServiceCollection(); + services.AddAIHostServices(); + services.AddSingleton(new TestWorkflowDefinitionStore(definition)); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + + using var searchTool = await registry.FindAsync("workflows.search", new AIToolQuery { ActorId = "user-1" }); + var searchResult = await searchTool!.ExecuteAsync(new AIToolExecutionContext + { + ActorId = "user-1", + ConversationId = "conversation-1", + Arguments = new JsonObject { ["query"] = "order" } + }); + + using var graphTool = await registry.FindAsync("workflows.getDefinitionGraph", new AIToolQuery { ActorId = "user-1" }); + var graphResult = await graphTool!.ExecuteAsync(new AIToolExecutionContext + { + ActorId = "user-1", + ConversationId = "conversation-1", + Arguments = new JsonObject { ["id"] = "version-1" } + }); + + Assert.Equal(1, searchResult.Data["returned"]!.GetValue()); + var graph = graphResult.Data["items"]!.AsArray()[0]!.AsObject(); + Assert.Equal(1, graph["activityCount"]!.GetValue()); + Assert.Equal("Elsa.Http.HttpEndpoint", graph["activityTypes"]!.AsArray()[0]!.GetValue()); + } + + private class TestWorkflowDefinitionStore(params WorkflowDefinition[] definitions) : IWorkflowDefinitionStore + { + private readonly List _definitions = definitions.ToList(); + + public Task FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => + Task.FromResult(Apply(filter).FirstOrDefault()); + + public Task FindAsync(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder order, CancellationToken cancellationToken = default) => + FindAsync(filter, cancellationToken); + + public Task> FindManyAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) + { + var items = Apply(filter).ToList(); + return Task.FromResult(Page.Of(items, items.Count)); + } + + public Task> FindManyAsync(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder order, PageArgs pageArgs, CancellationToken cancellationToken = default) => + FindManyAsync(filter, pageArgs, cancellationToken); + + public Task> FindManyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => + Task.FromResult>(Apply(filter).ToList()); + + public Task> FindManyAsync(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder order, CancellationToken cancellationToken = default) => + FindManyAsync(filter, cancellationToken); + + public Task> FindSummariesAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) + { + var items = Apply(filter).Select(WorkflowDefinitionSummary.FromDefinition).ToList(); + return Task.FromResult(Page.Of(items, items.Count)); + } + + public Task> FindSummariesAsync(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder order, PageArgs pageArgs, CancellationToken cancellationToken = default) => + FindSummariesAsync(filter, pageArgs, cancellationToken); + + public Task> FindSummariesAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => + Task.FromResult>(Apply(filter).Select(WorkflowDefinitionSummary.FromDefinition).ToList()); + + public Task> FindSummariesAsync(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder order, CancellationToken cancellationToken = default) => + FindSummariesAsync(filter, cancellationToken); + + public Task FindLastVersionAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken) => + Task.FromResult(Apply(filter).OrderByDescending(x => x.Version).FirstOrDefault()); + + public Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) + { + _definitions.RemoveAll(x => x.Id == definition.Id); + _definitions.Add(definition); + return Task.CompletedTask; + } + + public Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) + { + foreach (var definition in definitions) + _definitions.Add(definition); + return Task.CompletedTask; + } + + public Task DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(0L); + public Task AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(Apply(filter).Any()); + public Task CountDistinctAsync(CancellationToken cancellationToken = default) => Task.FromResult((long)_definitions.Select(x => x.DefinitionId).Distinct().Count()); + public Task GetIsNameUnique(string name, string? definitionId = null, CancellationToken cancellationToken = default) => Task.FromResult(!_definitions.Any(x => x.Name == name && x.DefinitionId != definitionId)); + + private IEnumerable Apply(WorkflowDefinitionFilter filter) + { + var query = _definitions.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter.Id)) + query = query.Where(x => x.Id == filter.Id); + if (!string.IsNullOrWhiteSpace(filter.DefinitionId)) + query = query.Where(x => x.DefinitionId == filter.DefinitionId); + if (!string.IsNullOrWhiteSpace(filter.SearchTerm)) + query = query.Where(x => (x.Name?.Contains(filter.SearchTerm, StringComparison.OrdinalIgnoreCase) ?? false) || x.DefinitionId.Contains(filter.SearchTerm, StringComparison.OrdinalIgnoreCase)); + return query; + } + } +} diff --git a/test/integration/Elsa.AI.IntegrationTests/AIWorkflowProposalToolTests.cs b/test/integration/Elsa.AI.IntegrationTests/AIWorkflowProposalToolTests.cs new file mode 100644 index 000000000..e285fc946 --- /dev/null +++ b/test/integration/Elsa.AI.IntegrationTests/AIWorkflowProposalToolTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Services; +using Elsa.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.AI.IntegrationTests; + +public class AIWorkflowProposalToolTests +{ + [Fact(DisplayName = "Proposal tool writes reviewable proposal only")] + public async Task ProposalToolWritesReviewableProposalOnly() + { + var proposalStore = new CapturingProposalStore(); + var services = new ServiceCollection(); + services.AddAIHostServices(); + services.AddSingleton(proposalStore); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Enable("workflows.proposeCreate"); + var registry = provider.GetRequiredService(); + + using var tool = await registry.FindAsync("workflows.proposeCreate", new AIToolQuery { ActorId = "user-1" }); + var result = await tool!.ExecuteAsync(new AIToolExecutionContext + { + ActorId = "user-1", + ConversationId = "conversation-1", + Arguments = new JsonObject + { + ["draft"] = new JsonObject + { + ["activities"] = new JsonArray + { + new JsonObject { ["id"] = "a1", ["type"] = "Elsa.Http.HttpEndpoint" } + } + }, + ["rationale"] = "Create an HTTP-triggered workflow." + } + }); + + Assert.Equal(AIToolInvocationStatus.Completed, result.Status); + var proposal = Assert.Single(proposalStore.Proposals); + Assert.Equal(AIProposalKind.WorkflowCreate, proposal.Kind); + Assert.Equal("conversation-1", proposal.ConversationId); + Assert.NotEqual(AIProposalStatus.Applied, proposal.Status); + } + + private class CapturingProposalStore : IAIProposalStore + { + public List Proposals { get; } = []; + + public ValueTask FindAsync(string id, string? tenantId, CancellationToken cancellationToken = default) => + ValueTask.FromResult(Proposals.FirstOrDefault(x => x.Id == id && x.TenantId == tenantId)); + + public ValueTask SaveAsync(AIProposal proposal, CancellationToken cancellationToken = default) + { + Proposals.Add(proposal); + return ValueTask.CompletedTask; + } + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Context/AIContextResolverTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Context/AIContextResolverTests.cs index c9ac0aadb..fcd3e8340 100644 --- a/test/unit/Elsa.AI.Host.UnitTests/Context/AIContextResolverTests.cs +++ b/test/unit/Elsa.AI.Host.UnitTests/Context/AIContextResolverTests.cs @@ -1,7 +1,10 @@ using Elsa.AI.Abstractions.Contracts; using Elsa.AI.Abstractions.Models; using Elsa.AI.Host.Context; +using Elsa.AI.Host.Options; +using Elsa.AI.Host.Services; using Microsoft.Extensions.DependencyInjection; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; using System.Text.Json.Nodes; namespace Elsa.AI.Host.UnitTests.Context; @@ -120,6 +123,9 @@ public class AIContextResolverTests var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new AIGroundingResultFormatter(MicrosoftOptions.Create(new AIHostOptions()))); + services.AddSingleton(); configure(services); return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); } diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingCapabilityTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingCapabilityTests.cs new file mode 100644 index 000000000..70f205cab --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingCapabilityTests.cs @@ -0,0 +1,37 @@ +using Elsa.AI.Abstractions.Contracts; +using Elsa.AI.Abstractions.Models; +using Elsa.AI.Host.Endpoints.AI.Capabilities; +using Elsa.AI.Host.Options; +using Microsoft.Extensions.DependencyInjection; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class AIGroundingCapabilityTests +{ + [Fact(DisplayName = "Capability descriptor reports disabled grounding family reason")] + public async Task CapabilityDescriptorReportsDisabledGroundingFamilyReason() + { + using var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var options = new AIHostOptions(); + options.Grounding.ActivityGroundingEnabled = false; + var endpoint = new Endpoint( + MicrosoftOptions.Create(options), + [], + [new TestConversationStore()], + [], + serviceProvider.GetRequiredService()); + + var response = await endpoint.ExecuteAsync(CancellationToken.None); + + var activities = Assert.Single(response.Grounding, x => x.Family == "activities"); + Assert.False(activities.Available); + Assert.Contains("Grounding family is disabled by configuration.", activities.DisabledReasons); + } + + private class TestConversationStore : IAIConversationStore + { + public ValueTask FindAsync(string id, CancellationToken cancellationToken = default) => ValueTask.FromResult(null); + public ValueTask SaveAsync(AIConversation conversation, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingResultFormatterTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingResultFormatterTests.cs new file mode 100644 index 000000000..de2148efc --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/AIGroundingResultFormatterTests.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Host.Options; +using Elsa.AI.Host.Services; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class AIGroundingResultFormatterTests +{ + private readonly AIGroundingResultFormatter _formatter = new(MicrosoftOptions.Create(new AIHostOptions + { + Grounding = new AIGroundingOptions + { + MaxItems = 1, + MaxResultBytes = 16 * 1024 + } + })); + + [Fact(DisplayName = "Formatter redacts sensitive keys before returning tool data")] + public void FormatterRedactsSensitiveKeys() + { + var result = _formatter.CreateResult( + "done", + [ + new JsonObject + { + ["name"] = "HTTP", + ["apiKey"] = "secret-value", + ["nested"] = new JsonObject { ["password"] = "also-secret" } + } + ], + 1); + + var item = result.Data["items"]!.AsArray()[0]!.AsObject(); + + Assert.Equal("HTTP", item["name"]!.GetValue()); + Assert.Equal("***", item["apiKey"]!.GetValue()); + Assert.Equal("***", item["nested"]!.AsObject()["password"]!.GetValue()); + } + + [Fact(DisplayName = "Formatter clamps result item count")] + public void FormatterClampsResultItems() + { + var result = _formatter.CreateResult("done", [new JsonObject { ["id"] = "1" }, new JsonObject { ["id"] = "2" }], 2); + + Assert.True(result.Data["truncated"]!.GetValue()); + Assert.Equal(1, result.Data["returned"]!.GetValue()); + Assert.Single(result.Data["items"]!.AsArray()); + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/ActivityGroundingMapperTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/ActivityGroundingMapperTests.cs new file mode 100644 index 000000000..fdf1b7b13 --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/ActivityGroundingMapperTests.cs @@ -0,0 +1,51 @@ +using Elsa.AI.Host.Services; +using Elsa.Workflows.Models; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class ActivityGroundingMapperTests +{ + [Fact(DisplayName = "Activity mapper emits model-safe descriptor metadata")] + public void ActivityMapperEmitsModelSafeDescriptorMetadata() + { + var mapper = new ActivityGroundingMapper(); + var descriptor = new ActivityDescriptor + { + TypeName = "Elsa.Http.HttpEndpoint", + Namespace = "Elsa.Http", + Name = "HttpEndpoint", + DisplayName = "HTTP Endpoint", + Description = "Receives HTTP requests", + Category = "HTTP", + Version = 2, + IsStart = true, + IsBrowsable = true, + Inputs = + { + new InputDescriptor + { + Name = "ApiKey", + DisplayName = "API key", + Description = "A sensitive input", + Type = typeof(string), + UIHint = "single-line", + IsSensitive = true + } + }, + Outputs = + { + new OutputDescriptor { Name = "Body", DisplayName = "Body", Type = typeof(string) } + }, + Ports = { new Port { Name = "Done" } } + }; + + var summary = mapper.Map(descriptor); + + Assert.Equal("Elsa.Http.HttpEndpoint", summary.Type); + Assert.True(summary.IsTrigger); + var input = Assert.Single(summary.Inputs); + Assert.True(input.IsSensitive); + Assert.Equal("String", input.Type); + Assert.Contains("Done", summary.Ports); + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/RuntimeGroundingMapperTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/RuntimeGroundingMapperTests.cs new file mode 100644 index 000000000..522f430ce --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/RuntimeGroundingMapperTests.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Host.Options; +using Elsa.AI.Host.Services; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.State; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class RuntimeGroundingMapperTests +{ + [Fact(DisplayName = "Runtime mapper redacts sensitive workflow state")] + public void RuntimeMapperRedactsSensitiveWorkflowState() + { + var formatter = new AIGroundingResultFormatter(MicrosoftOptions.Create(new AIHostOptions())); + var mapper = new RuntimeGroundingMapper(formatter); + var instance = new WorkflowInstance + { + Id = "instance-1", + DefinitionId = "workflow-1", + DefinitionVersionId = "version-1", + WorkflowState = new WorkflowState + { + Input = new Dictionary { ["password"] = "secret" }, + Output = new Dictionary { ["result"] = "ok" } + } + }; + + var state = mapper.MapState(instance); + + Assert.Equal("***", state["input"]!.AsObject()["password"]!.GetValue()); + Assert.Equal("ok", state["output"]!.AsObject()["result"]!.GetValue()); + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowDraftValidationTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowDraftValidationTests.cs new file mode 100644 index 000000000..47cf2df8f --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowDraftValidationTests.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; +using Elsa.AI.Host.Services; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class WorkflowDraftValidationTests +{ + [Fact(DisplayName = "Draft validation reports stale baseline")] + public void DraftValidationReportsStaleBaseline() + { + var validator = new WorkflowDraftValidationService(new ServiceCollection().BuildServiceProvider()); + + var diagnostics = validator.Validate(new JsonObject { ["name"] = "Draft" }, "old-version", "new-version"); + + Assert.Contains(diagnostics, x => x.Code == "baseline.stale"); + } + + [Fact(DisplayName = "Draft validation reports missing draft")] + public void DraftValidationReportsMissingDraft() + { + var validator = new WorkflowDraftValidationService(new ServiceCollection().BuildServiceProvider()); + + var diagnostics = validator.Validate([]); + + Assert.Contains(diagnostics, x => x.Code == "draft.empty"); + } + + [Fact(DisplayName = "Draft validation reports unavailable activity descriptors")] + public void DraftValidationReportsUnavailableActivityDescriptors() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestActivityRegistry(new ActivityDescriptor { TypeName = "Elsa.WriteLine", Name = "WriteLine", Version = 1 })); + using var serviceProvider = services.BuildServiceProvider(); + var validator = new WorkflowDraftValidationService(serviceProvider); + var draft = new JsonObject + { + ["activities"] = new JsonArray + { + new JsonObject { ["id"] = "installed", ["type"] = "Elsa.WriteLine" }, + new JsonObject { ["id"] = "missing", ["type"] = "Elsa.Missing" } + } + }; + + var diagnostics = validator.Validate(draft); + + Assert.Contains(diagnostics, x => x.Code == "activity.unavailable" && x.Message.Contains("Elsa.Missing")); + Assert.DoesNotContain(diagnostics, x => x.Message.Contains("Elsa.WriteLine")); + } + + private class TestActivityRegistry(params ActivityDescriptor[] descriptors) : IActivityRegistry + { + private readonly List _descriptors = descriptors.ToList(); + + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) => + ValueTask.FromResult>(_descriptors); + + public void Add(Type providerType, ActivityDescriptor descriptor) => _descriptors.Add(descriptor); + public void Remove(Type providerType, ActivityDescriptor descriptor) => _descriptors.Remove(descriptor); + public IEnumerable ListAll() => _descriptors; + public IEnumerable ListByProvider(Type providerType) => _descriptors; + public ActivityDescriptor? Find(string type) => _descriptors.FirstOrDefault(x => string.Equals(x.TypeName, type, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Name, type, StringComparison.OrdinalIgnoreCase)); + public ActivityDescriptor? Find(string type, int version) => _descriptors.FirstOrDefault(x => (string.Equals(x.TypeName, type, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Name, type, StringComparison.OrdinalIgnoreCase)) && x.Version == version); + public ActivityDescriptor? Find(Func predicate) => _descriptors.FirstOrDefault(predicate); + public IEnumerable FindMany(Func predicate) => _descriptors.Where(predicate); + public void Register(ActivityDescriptor descriptor) => _descriptors.Add(descriptor); + public Task RegisterAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RegisterAsync(IEnumerable activityTypes, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RefreshDescriptorsAsync(IEnumerable activityProviders, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Clear() => _descriptors.Clear(); + public void ClearProvider(Type providerType) => _descriptors.Clear(); + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowGroundingMapperTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowGroundingMapperTests.cs new file mode 100644 index 000000000..6ebcb2912 --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowGroundingMapperTests.cs @@ -0,0 +1,38 @@ +using Elsa.AI.Host.Services; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class WorkflowGroundingMapperTests +{ + [Fact(DisplayName = "Workflow mapper extracts activity types from serialized graph")] + public void WorkflowMapperExtractsActivityTypesFromSerializedGraph() + { + var mapper = new WorkflowGroundingMapper(); + var definition = new WorkflowDefinition + { + Id = "version-1", + DefinitionId = "workflow-1", + Name = "Order intake", + Version = 1, + MaterializerName = "Json", + StringData = """ + { + "root": { + "id": "a1", + "type": "Elsa.Http.HttpEndpoint", + "activities": [ + { "id": "a2", "typeName": "Elsa.Email.SendEmail" } + ] + } + } + """ + }; + + var graph = mapper.GetGraph(definition); + + Assert.Equal(2, graph.ActivityCount); + Assert.Contains("Elsa.Http.HttpEndpoint", graph.ActivityTypes); + Assert.Contains("Elsa.Email.SendEmail", graph.ActivityTypes); + } +} diff --git a/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowProposalDiffServiceTests.cs b/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowProposalDiffServiceTests.cs new file mode 100644 index 000000000..761391158 --- /dev/null +++ b/test/unit/Elsa.AI.Host.UnitTests/Grounding/WorkflowProposalDiffServiceTests.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using Elsa.AI.Host.Services; + +namespace Elsa.AI.Host.UnitTests.Grounding; + +public class WorkflowProposalDiffServiceTests +{ + [Fact(DisplayName = "Workflow proposal diff compares draft against baseline graph")] + public void WorkflowProposalDiffComparesDraftAgainstBaselineGraph() + { + var service = new WorkflowProposalDiffService(); + var baseline = new JsonObject + { + ["activities"] = new JsonArray + { + new JsonObject { ["id"] = "kept", ["type"] = "Elsa.WriteLine" }, + new JsonObject { ["id"] = "removed", ["type"] = "Elsa.Http.HttpEndpoint" } + } + }; + var draft = new JsonObject + { + ["activities"] = new JsonArray + { + new JsonObject { ["id"] = "kept", ["type"] = "Elsa.WriteLine" }, + new JsonObject { ["id"] = "added", ["type"] = "Elsa.SendEmail" } + } + }; + + var diff = service.CreateDiff(draft, baseline); + + Assert.Equal(["added"], diff.AddedActivityIds); + Assert.Equal(["removed"], diff.RemovedActivityIds); + } +}