Add Weaver grounding tools
Adds Spec Kit-backed Weaver grounding tools for activities, workflow definitions, workflow proposals, runtime instances, incidents, and Studio capability discovery.
This commit is contained in:
parent
c84891cdac
commit
c66f9aed45
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"feature_directory": "specs/008-weaver-ai-copilot"
|
||||
"feature_directory": "specs/012-weaver-grounding-tools"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable:
|
|||
|
||||
<!-- SPECKIT START -->
|
||||
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`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
specs/012-weaver-grounding-tools/checklists/requirements.md
Normal file
34
specs/012-weaver-grounding-tools/checklists/requirements.md
Normal file
|
|
@ -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.
|
||||
79
specs/012-weaver-grounding-tools/contracts/rest-api.md
Normal file
79
specs/012-weaver-grounding-tools/contracts/rest-api.md
Normal file
|
|
@ -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.
|
||||
124
specs/012-weaver-grounding-tools/contracts/tool-catalog.md
Normal file
124
specs/012-weaver-grounding-tools/contracts/tool-catalog.md
Normal file
|
|
@ -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<ActivityGroundingSummary>`.
|
||||
|
||||
### `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`
|
||||
94
specs/012-weaver-grounding-tools/data-model.md
Normal file
94
specs/012-weaver-grounding-tools/data-model.md
Normal file
|
|
@ -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.
|
||||
85
specs/012-weaver-grounding-tools/plan.md
Normal file
85
specs/012-weaver-grounding-tools/plan.md
Normal file
|
|
@ -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.
|
||||
48
specs/012-weaver-grounding-tools/quickstart.md
Normal file
48
specs/012-weaver-grounding-tools/quickstart.md
Normal file
|
|
@ -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
|
||||
```
|
||||
47
specs/012-weaver-grounding-tools/research.md
Normal file
47
specs/012-weaver-grounding-tools/research.md
Normal file
|
|
@ -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.
|
||||
148
specs/012-weaver-grounding-tools/spec.md
Normal file
148
specs/012-weaver-grounding-tools/spec.md
Normal file
|
|
@ -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.
|
||||
201
specs/012-weaver-grounding-tools/tasks.md
Normal file
201
specs/012-weaver-grounding-tools/tasks.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/ShellFeatures')">
|
||||
<PackageReference Include="Elsa.Platform.PackageManifest.Generator" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/ShellFeatures') and '$(GenerateElsaPackageManifest)' != 'false' and '$(MSBuildProjectName)' != 'Elsa.Api.Common'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)PackageManifestHints.cs" Link="PackageManifestHints.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
104
src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs
Normal file
104
src/modules/Elsa.AI.Abstractions/Models/AIGroundingModels.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
namespace Elsa.AI.Abstractions.Models;
|
||||
|
||||
public record AIGroundingToolResult
|
||||
{
|
||||
public string Summary { get; init; } = "";
|
||||
public IReadOnlyCollection<JsonObject> 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<string> Evidence { get; init; } = [];
|
||||
public IReadOnlyCollection<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
public record AIGroundingCapabilityDescriptor
|
||||
{
|
||||
public string Family { get; init; } = "";
|
||||
public string DisplayName { get; init; } = "";
|
||||
public bool Available { get; init; }
|
||||
public IReadOnlyCollection<string> ToolNames { get; init; } = [];
|
||||
public IReadOnlyCollection<string> AttachmentKinds { get; init; } = [];
|
||||
public IReadOnlyCollection<string> 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<ActivityPortSummary> Inputs { get; init; } = [];
|
||||
public IReadOnlyCollection<ActivityPortSummary> Outputs { get; init; } = [];
|
||||
public IReadOnlyCollection<string> Ports { get; init; } = [];
|
||||
public IReadOnlyCollection<string> 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<string> ActivityTypes { get; init; } = [];
|
||||
public IReadOnlyCollection<string> Variables { get; init; } = [];
|
||||
public IReadOnlyCollection<string> Inputs { get; init; } = [];
|
||||
public IReadOnlyCollection<string> 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder workflow definition context provider. Replace this implementation with a workflow-management-backed resolver before using workflow context for production AI reasoning.
|
||||
/// </summary>
|
||||
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<AIResolvedContext> ResolveAsync(AIContextResolutionRequest request, CancellationToken cancellationToken = default)
|
||||
public async ValueTask<AIResolvedContext> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder workflow instance context provider. Replace this implementation with a runtime-backed resolver before using instance context for production AI reasoning.
|
||||
/// </summary>
|
||||
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<AIResolvedContext> ResolveAsync(AIContextResolutionRequest request, CancellationToken cancellationToken = default)
|
||||
public async ValueTask<AIResolvedContext> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.AI.Abstractions\Elsa.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Common\Elsa.Common.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
|
||||
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
|
||||
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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<AIHostOptions> options,
|
||||
IEnumerable<IAIProvider> providers,
|
||||
IEnumerable<IAIConversationStore> conversationStores,
|
||||
IEnumerable<IAIProposalStore> proposalStores) : ElsaEndpointWithoutRequest<Response>
|
||||
IEnumerable<IAIProposalStore> proposalStores,
|
||||
IServiceScopeFactory serviceScopeFactory) : ElsaEndpointWithoutRequest<Response>
|
||||
{
|
||||
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<AIGroundingCapabilityDescriptor> 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<IActivityRegistry>() != null,
|
||||
[CapabilityToolNames.ActivitiesSearchToolName, CapabilityToolNames.ActivityDescriptorToolName],
|
||||
[AIContextAttachmentKinds.Activity],
|
||||
grounding.ActivityGroundingEnabled,
|
||||
"Activity Registry is not registered."),
|
||||
CreateCapability(
|
||||
"workflows",
|
||||
"Workflow definitions",
|
||||
grounding.WorkflowGroundingEnabled && services.GetService<IWorkflowDefinitionStore>() != 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<IWorkflowInstanceStore>() != 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<string> toolNames,
|
||||
IReadOnlyCollection<string> 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<AIProviderOptions> providerOptions, IReadOnlyCollection<IAIProvider> availableProviders)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(providerName))
|
||||
|
|
@ -57,6 +123,26 @@ public record Response(
|
|||
bool ConversationPersistence,
|
||||
bool ProposalReview,
|
||||
IReadOnlyCollection<string> SupportedAttachmentKinds,
|
||||
IReadOnlyCollection<AIAgentCapability> Agents);
|
||||
IReadOnlyCollection<AIAgentCapability> Agents,
|
||||
IReadOnlyCollection<AIGroundingCapabilityDescriptor> 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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AIContextResolver>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, AIContextProviderValidationHostedService>());
|
||||
services.TryAddSingleton<AIToolEnablementService>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, AIToolEnablementConfigurationHostedService>());
|
||||
services.TryAddSingleton<IAIToolRegistry, AIToolRegistry>();
|
||||
services.TryAddSingleton<AIGroundingResultFormatter>();
|
||||
services.TryAddSingleton<ActivityGroundingMapper>();
|
||||
services.TryAddSingleton<ActivityGroundingSearchService>();
|
||||
services.TryAddSingleton<WorkflowGroundingMapper>();
|
||||
services.TryAddSingleton<RuntimeGroundingMapper>();
|
||||
services.TryAddSingleton<WorkflowDraftValidationService>();
|
||||
services.TryAddSingleton<WorkflowProposalDiffService>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, ActivitiesSearchTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, ActivityDescriptorTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowsSearchTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowDefinitionTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowDefinitionGraphTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowUsageSearchTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowValidateDraftTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowProposeCreateTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowProposeUpdateTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, InstancesSearchTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowInstanceTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowInstanceExecutionHistoryTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, WorkflowInstanceActivityStateTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, IncidentsSearchTool>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Transient<IAITool, IncidentTool>());
|
||||
services.TryAddScoped<IAIOrchestrator, AIOrchestrator>();
|
||||
services.TryAddSingleton<AIStreamSessionManager>();
|
||||
services.TryAddSingleton<AIStreamEventMapper>();
|
||||
|
|
|
|||
|
|
@ -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<AIHostOptions>? ConfigureOptions { get; set; }
|
||||
public Action<AIToolEnablementService>? 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AIProviderOptions> Providers { get; set; } = [];
|
||||
public ICollection<string> SupportedAttachmentKinds { get; set; } = ["WorkflowDefinition", "WorkflowInstance"];
|
||||
public ICollection<string> SupportedAttachmentKinds { get; set; } =
|
||||
[
|
||||
AIContextAttachmentKinds.WorkflowDefinition,
|
||||
AIContextAttachmentKinds.WorkflowInstance,
|
||||
AIContextAttachmentKinds.Activity,
|
||||
AIContextAttachmentKinds.DiagnosticsScope,
|
||||
AIContextAttachmentKinds.TimeRange
|
||||
];
|
||||
public ICollection<AIAgentOptions> 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; } = "";
|
||||
|
|
|
|||
42
src/modules/Elsa.AI.Host/README.md
Normal file
42
src/modules/Elsa.AI.Host/README.md
Normal file
|
|
@ -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.
|
||||
20
src/modules/Elsa.AI.Host/Services/AIGroundingJson.cs
Normal file
20
src/modules/Elsa.AI.Host/Services/AIGroundingJson.cs
Normal file
|
|
@ -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>(T value) =>
|
||||
JsonSerializer.SerializeToNode(value, SerializerOptions) as JsonObject ?? [];
|
||||
|
||||
public static JsonArray ToJsonArray<T>(IEnumerable<T> values)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var value in values)
|
||||
array.Add(JsonSerializer.SerializeToNode(value, SerializerOptions));
|
||||
|
||||
return array;
|
||||
}
|
||||
}
|
||||
108
src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs
Normal file
108
src/modules/Elsa.AI.Host/Services/AIGroundingResultFormatter.cs
Normal file
|
|
@ -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<AIHostOptions> options)
|
||||
{
|
||||
private static readonly string[] SensitiveNameFragments =
|
||||
[
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"connectionstring",
|
||||
"connection_string",
|
||||
"credential"
|
||||
];
|
||||
|
||||
public AIToolResult CreateResult(string summary, IEnumerable<JsonObject> items, int total, IEnumerable<string>? evidence = null, IEnumerable<string>? 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Elsa.AI.Host.Services;
|
||||
|
||||
public class AIToolEnablementConfigurationHostedService(
|
||||
AIToolEnablementService enablementService,
|
||||
IEnumerable<Action<AIToolEnablementService>> configureActions) : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var configure in configureActions)
|
||||
configure(enablementService);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
86
src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs
Normal file
86
src/modules/Elsa.AI.Host/Services/ActivityGroundingMapper.cs
Normal file
|
|
@ -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<string> 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))}>";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Models;
|
||||
|
||||
namespace Elsa.AI.Host.Services;
|
||||
|
||||
public class ActivityGroundingSearchService(ActivityGroundingMapper mapper)
|
||||
{
|
||||
public IReadOnlyCollection<ActivityDescriptor> 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);
|
||||
}
|
||||
72
src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs
Normal file
72
src/modules/Elsa.AI.Host/Services/RuntimeGroundingMapper.cs
Normal file
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
using Elsa.AI.Abstractions.Models;
|
||||
using Elsa.Workflows;
|
||||
|
||||
namespace Elsa.AI.Host.Services;
|
||||
|
||||
public class WorkflowDraftValidationService(IServiceProvider serviceProvider)
|
||||
{
|
||||
public IReadOnlyCollection<AIValidationDiagnostic> Validate(JsonObject draft, string? baselineVersionId = null, string? expectedBaselineVersionId = null)
|
||||
{
|
||||
var diagnostics = new List<AIValidationDiagnostic>();
|
||||
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<string> 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<string>(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 };
|
||||
}
|
||||
127
src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs
Normal file
127
src/modules/Elsa.AI.Host/Services/WorkflowGroundingMapper.cs
Normal file
|
|
@ -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<WorkflowActivitySummary> 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<JsonNode>())
|
||||
{
|
||||
foreach (var activity in FindActivities(child))
|
||||
yield return activity;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray jsonArray)
|
||||
{
|
||||
foreach (var child in jsonArray.OfType<JsonNode>())
|
||||
{
|
||||
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<string>(out var result) ? result : null;
|
||||
}
|
||||
|
||||
public record WorkflowGraphSummary(IReadOnlyCollection<WorkflowActivitySummary> Activities, IReadOnlyCollection<string> ActivityTypes, int ActivityCount);
|
||||
|
||||
public record WorkflowActivitySummary(string? Id, string Type, string? Name);
|
||||
|
|
@ -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<string> 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<string>(out var result) ? result : null;
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]));
|
||||
}
|
||||
}
|
||||
56
src/modules/Elsa.AI.Host/Tools/GroundingToolBase.cs
Normal file
56
src/modules/Elsa.AI.Host/Tools/GroundingToolBase.cs
Normal file
|
|
@ -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<AIToolResult> 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<string>(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<int>(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<bool>(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
|
||||
};
|
||||
}
|
||||
52
src/modules/Elsa.AI.Host/Tools/GroundingToolSchemas.cs
Normal file
52
src/modules/Elsa.AI.Host/Tools/GroundingToolSchemas.cs
Normal file
|
|
@ -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
|
||||
};
|
||||
}
|
||||
38
src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs
Normal file
38
src/modules/Elsa.AI.Host/Tools/Runtime/IncidentTool.cs
Normal file
|
|
@ -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<AIToolResult> 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);
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
83
src/modules/Elsa.AI.Host/Tools/Runtime/RuntimeToolBase.cs
Normal file
83
src/modules/Elsa.AI.Host/Tools/Runtime/RuntimeToolBase.cs
Normal file
|
|
@ -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<WorkflowStatus>(GetString(arguments, "status"), true, out var status))
|
||||
filter.WorkflowStatus = status;
|
||||
|
||||
if (Enum.TryParse<WorkflowSubStatus>(GetString(arguments, "subStatus"), true, out var subStatus))
|
||||
filter.WorkflowSubStatus = subStatus;
|
||||
|
||||
var from = GetDateTimeOffset(arguments, "from");
|
||||
var to = GetDateTimeOffset(arguments, "to");
|
||||
var timestampFilters = new List<TimestampFilter>();
|
||||
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<WorkflowInstance?> 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<DateTimeOffset>(out var result)
|
||||
? result
|
||||
: null;
|
||||
|
||||
private static string NormalizeTenant(string? tenantId) =>
|
||||
string.IsNullOrWhiteSpace(tenantId) ? "" : tenantId;
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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);
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
46
src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowToolBase.cs
Normal file
46
src/modules/Elsa.AI.Host/Tools/Workflows/WorkflowToolBase.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIToolResult> 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IActivityRegistry>(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<IAIToolRegistry>();
|
||||
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<int>());
|
||||
var item = result.Data["items"]!.AsArray()[0]!.AsObject();
|
||||
Assert.Equal("Elsa.Http.HttpEndpoint", item["type"]!.GetValue<string>());
|
||||
Assert.True(item["isTrigger"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
private class TestActivityRegistry(params ActivityDescriptor[] descriptors) : IActivityRegistry
|
||||
{
|
||||
private readonly List<ActivityDescriptor> _descriptors = descriptors.ToList();
|
||||
|
||||
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IEnumerable<ActivityDescriptor>>(_descriptors);
|
||||
|
||||
public void Add(Type providerType, ActivityDescriptor descriptor) => _descriptors.Add(descriptor);
|
||||
public void Remove(Type providerType, ActivityDescriptor descriptor) => _descriptors.Remove(descriptor);
|
||||
public IEnumerable<ActivityDescriptor> ListAll() => _descriptors;
|
||||
public IEnumerable<ActivityDescriptor> 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<ActivityDescriptor, bool> predicate) => _descriptors.FirstOrDefault(predicate);
|
||||
public IEnumerable<ActivityDescriptor> FindMany(Func<ActivityDescriptor, bool> 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<Type> activityTypes, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task RefreshDescriptorsAsync(IEnumerable<IActivityProvider> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IServiceScopeFactory>();
|
||||
|
||||
private class TestAIProvider(string name = "test") : IAIProvider
|
||||
{
|
||||
public string Name => name;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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<string, object> { ["password"] = "secret" }
|
||||
}
|
||||
};
|
||||
var services = new ServiceCollection();
|
||||
services.AddAIHostServices();
|
||||
services.AddSingleton<IWorkflowInstanceStore>(new TestWorkflowInstanceStore(instance));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<IAIToolRegistry>();
|
||||
|
||||
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<int>());
|
||||
var incident = result.Data["items"]!.AsArray()[0]!.AsObject();
|
||||
Assert.Equal("instance-1", incident["workflowInstanceId"]!.GetValue<string>());
|
||||
Assert.DoesNotContain("secret", result.Data.ToJsonString(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private class TestWorkflowInstanceStore(params WorkflowInstance[] instances) : IWorkflowInstanceStore
|
||||
{
|
||||
private readonly List<WorkflowInstance> _instances = instances.ToList();
|
||||
|
||||
public ValueTask<WorkflowInstance?> FindAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(Apply(filter).FirstOrDefault());
|
||||
|
||||
public ValueTask<Page<WorkflowInstance>> FindManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Apply(filter).ToList();
|
||||
return ValueTask.FromResult(Page.Of<WorkflowInstance>(items, items.Count));
|
||||
}
|
||||
|
||||
public ValueTask<Page<WorkflowInstance>> FindManyAsync<TOrderBy>(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
FindManyAsync(filter, pageArgs, cancellationToken);
|
||||
|
||||
public ValueTask<IEnumerable<WorkflowInstance>> FindManyAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IEnumerable<WorkflowInstance>>(Apply(filter).ToList());
|
||||
|
||||
public ValueTask<IEnumerable<WorkflowInstance>> FindManyAsync<TOrderBy>(WorkflowInstanceFilter filter, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
FindManyAsync(filter, cancellationToken);
|
||||
|
||||
public ValueTask<long> CountAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult((long)Apply(filter).Count());
|
||||
|
||||
public ValueTask<Page<WorkflowInstanceSummary>> SummarizeManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Apply(filter).Select(WorkflowInstanceSummary.FromInstance).ToList();
|
||||
return ValueTask.FromResult(Page.Of<WorkflowInstanceSummary>(items, items.Count));
|
||||
}
|
||||
|
||||
public ValueTask<Page<WorkflowInstanceSummary>> SummarizeManyAsync<TOrderBy>(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
SummarizeManyAsync(filter, pageArgs, cancellationToken);
|
||||
|
||||
public ValueTask<IEnumerable<string>> FindManyIdsAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IEnumerable<string>>(Apply(filter).Select(x => x.Id).ToList());
|
||||
|
||||
public ValueTask<Page<string>> FindManyIdsAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = Apply(filter).Select(x => x.Id).ToList();
|
||||
return ValueTask.FromResult(Page.Of<string>(ids, ids.Count));
|
||||
}
|
||||
|
||||
public ValueTask<Page<string>> FindManyIdsAsync<TOrderBy>(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
FindManyIdsAsync(filter, pageArgs, cancellationToken);
|
||||
|
||||
public ValueTask<IEnumerable<WorkflowInstanceSummary>> SummarizeManyAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IEnumerable<WorkflowInstanceSummary>>(Apply(filter).Select(WorkflowInstanceSummary.FromInstance).ToList());
|
||||
|
||||
public ValueTask<IEnumerable<WorkflowInstanceSummary>> SummarizeManyAsync<TOrder>(WorkflowInstanceFilter filter, WorkflowInstanceOrder<TOrder> 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<WorkflowInstance> instances, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
public ValueTask<long> DeleteAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(0L);
|
||||
public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffset value, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
private IEnumerable<WorkflowInstance> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IAIToolRegistry>(), 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()
|
||||
|
|
|
|||
|
|
@ -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<IWorkflowDefinitionStore>(new TestWorkflowDefinitionStore(definition));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<IAIToolRegistry>();
|
||||
|
||||
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<int>());
|
||||
var graph = graphResult.Data["items"]!.AsArray()[0]!.AsObject();
|
||||
Assert.Equal(1, graph["activityCount"]!.GetValue<int>());
|
||||
Assert.Equal("Elsa.Http.HttpEndpoint", graph["activityTypes"]!.AsArray()[0]!.GetValue<string>());
|
||||
}
|
||||
|
||||
private class TestWorkflowDefinitionStore(params WorkflowDefinition[] definitions) : IWorkflowDefinitionStore
|
||||
{
|
||||
private readonly List<WorkflowDefinition> _definitions = definitions.ToList();
|
||||
|
||||
public Task<WorkflowDefinition?> FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Apply(filter).FirstOrDefault());
|
||||
|
||||
public Task<WorkflowDefinition?> FindAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
FindAsync(filter, cancellationToken);
|
||||
|
||||
public Task<Page<WorkflowDefinition>> FindManyAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Apply(filter).ToList();
|
||||
return Task.FromResult(Page.Of<WorkflowDefinition>(items, items.Count));
|
||||
}
|
||||
|
||||
public Task<Page<WorkflowDefinition>> FindManyAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, PageArgs pageArgs, CancellationToken cancellationToken = default) =>
|
||||
FindManyAsync(filter, pageArgs, cancellationToken);
|
||||
|
||||
public Task<IEnumerable<WorkflowDefinition>> FindManyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<WorkflowDefinition>>(Apply(filter).ToList());
|
||||
|
||||
public Task<IEnumerable<WorkflowDefinition>> FindManyAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
FindManyAsync(filter, cancellationToken);
|
||||
|
||||
public Task<Page<WorkflowDefinitionSummary>> FindSummariesAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Apply(filter).Select(WorkflowDefinitionSummary.FromDefinition).ToList();
|
||||
return Task.FromResult(Page.Of<WorkflowDefinitionSummary>(items, items.Count));
|
||||
}
|
||||
|
||||
public Task<Page<WorkflowDefinitionSummary>> FindSummariesAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, PageArgs pageArgs, CancellationToken cancellationToken = default) =>
|
||||
FindSummariesAsync(filter, pageArgs, cancellationToken);
|
||||
|
||||
public Task<IEnumerable<WorkflowDefinitionSummary>> FindSummariesAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<WorkflowDefinitionSummary>>(Apply(filter).Select(WorkflowDefinitionSummary.FromDefinition).ToList());
|
||||
|
||||
public Task<IEnumerable<WorkflowDefinitionSummary>> FindSummariesAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
FindSummariesAsync(filter, cancellationToken);
|
||||
|
||||
public Task<WorkflowDefinition?> 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<WorkflowDefinition> definitions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var definition in definitions)
|
||||
_definitions.Add(definition);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<long> DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(0L);
|
||||
public Task<bool> AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(Apply(filter).Any());
|
||||
public Task<long> CountDistinctAsync(CancellationToken cancellationToken = default) => Task.FromResult((long)_definitions.Select(x => x.DefinitionId).Distinct().Count());
|
||||
public Task<bool> GetIsNameUnique(string name, string? definitionId = null, CancellationToken cancellationToken = default) => Task.FromResult(!_definitions.Any(x => x.Name == name && x.DefinitionId != definitionId));
|
||||
|
||||
private IEnumerable<WorkflowDefinition> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IAIProposalStore>(proposalStore);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<AIToolEnablementService>().Enable("workflows.proposeCreate");
|
||||
var registry = provider.GetRequiredService<IAIToolRegistry>();
|
||||
|
||||
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<AIProposal> Proposals { get; } = [];
|
||||
|
||||
public ValueTask<AIProposal?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AIContextResolver>();
|
||||
services.AddSingleton<WorkflowGroundingMapper>();
|
||||
services.AddSingleton(sp => new AIGroundingResultFormatter(MicrosoftOptions.Create(new AIHostOptions())));
|
||||
services.AddSingleton<RuntimeGroundingMapper>();
|
||||
configure(services);
|
||||
return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IServiceScopeFactory>());
|
||||
|
||||
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<AIConversation?> FindAsync(string id, CancellationToken cancellationToken = default) => ValueTask.FromResult<AIConversation?>(null);
|
||||
public ValueTask SaveAsync(AIConversation conversation, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>());
|
||||
Assert.Equal("***", item["apiKey"]!.GetValue<string>());
|
||||
Assert.Equal("***", item["nested"]!.AsObject()["password"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[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<bool>());
|
||||
Assert.Equal(1, result.Data["returned"]!.GetValue<int>());
|
||||
Assert.Single(result.Data["items"]!.AsArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, object> { ["password"] = "secret" },
|
||||
Output = new Dictionary<string, object> { ["result"] = "ok" }
|
||||
}
|
||||
};
|
||||
|
||||
var state = mapper.MapState(instance);
|
||||
|
||||
Assert.Equal("***", state["input"]!.AsObject()["password"]!.GetValue<string>());
|
||||
Assert.Equal("ok", state["output"]!.AsObject()["result"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IActivityRegistry>(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<ActivityDescriptor> _descriptors = descriptors.ToList();
|
||||
|
||||
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IEnumerable<ActivityDescriptor>>(_descriptors);
|
||||
|
||||
public void Add(Type providerType, ActivityDescriptor descriptor) => _descriptors.Add(descriptor);
|
||||
public void Remove(Type providerType, ActivityDescriptor descriptor) => _descriptors.Remove(descriptor);
|
||||
public IEnumerable<ActivityDescriptor> ListAll() => _descriptors;
|
||||
public IEnumerable<ActivityDescriptor> 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<ActivityDescriptor, bool> predicate) => _descriptors.FirstOrDefault(predicate);
|
||||
public IEnumerable<ActivityDescriptor> FindMany(Func<ActivityDescriptor, bool> 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<Type> activityTypes, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task RefreshDescriptorsAsync(IEnumerable<IActivityProvider> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue