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:
Sipke Schoorstra 2026-06-08 15:40:10 +02:00 committed by GitHub
parent c84891cdac
commit c66f9aed45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3448 additions and 52 deletions

View file

@ -1,3 +1,3 @@
{
"feature_directory": "specs/008-weaver-ai-copilot"
"feature_directory": "specs/012-weaver-grounding-tools"
}

View file

@ -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.

View file

@ -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

View 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.

View 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.

View 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`

View 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.

View 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.

View 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
```

View 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.

View 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.

View 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.

View file

@ -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>

View file

@ -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";
}

View 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; }
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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";
}

View file

@ -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>();

View file

@ -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;
}
}

View file

@ -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; } = "";

View 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.

View 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;
}
}

View 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));
}
}

View file

@ -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;
}

View 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))}>";
}
}

View file

@ -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);
}

View 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
});
}

View file

@ -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 };
}

View 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);

View file

@ -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;
}

View file

@ -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"]));
}
}

View file

@ -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"]));
}
}

View 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
};
}

View 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
};
}

View 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);
}

View file

@ -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"]);
}
}

View file

@ -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"]);
}
}

View 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;
}

View file

@ -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);
}

View file

@ -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"]);
}
}

View file

@ -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"]);
}
}

View file

@ -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"]);
}
}

View file

@ -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"]);
}
}

View file

@ -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"]);
}
}

View file

@ -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"]);
}
}

View 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;
}

View file

@ -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"]);
}
}

View file

@ -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 });
}
}

View file

@ -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"]);
}
}

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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")]

View file

@ -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;
}
}
}

View file

@ -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()

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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 });
}

View file

@ -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;
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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>());
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}