diff --git a/docs/architecture.md b/docs/architecture.md index 4ba5917a..5ab00112 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,9 +104,9 @@ Canonical Management resources and provider-neutral ports live in `Management.Ab | Agents | management definitions plus isolated MAF runtime adapter | sessions, execution budgets, richer tool policies | | Workflows | normalize → analyze → remember | parallel, routing, handoff, supervisor, HITL | | Scheduling | Workspace-scoped Trigger resources, durable occurrences, Quartz.NET projection in the selected SQLite or PostgreSQL store, startup reconciliation, explicit misfire/concurrency policy and authorized Work submission | webhook/event/condition sources, workload identities, clustered scheduling | -| Tools | persisted ToolProvider/Tool resources, AEP contribution resolution, MCP schema catalog, and an Agentstration-owned runtime execution boundary before MCP `tools/call` | richer permissions, credentials, connection policies, and execution hooks | -| Notifications | Work and Workplace notification records | email, Teams, webhook channels | -| MCP | generic governed MCP provider/client infrastructure; no built-in legacy platform tools | managed server-side tools and authorization | +| Tools | persisted ToolProvider/Tool resources, AEP contribution resolution, MCP schema catalog, governed execution, and Workspace ToolDefinitions that publish bounded Flow-backed Tools | richer permissions, credentials, connection policies, and additional built-in hook handlers | +| Notifications | Work and Workplace notification records, the idempotent `work.notification.create` internal MCP Tool, and reusable delivery Flows | packaged external-provider delivery recipes | +| MCP | generic governed provider/client infrastructure, bounded internal Tools, and dynamic `tools/list`/`tools/call` publication of enabled ToolDefinitions; no generic Flow launcher or legacy platform tools | resources, prompts, and richer task progress | | Observability | OTel traces/metrics/log correlation tags | dashboards and SLOs | ## Principal contracts @@ -173,19 +173,26 @@ POST /api/flows POST /api/flows/{id}/versions -> immutable FlowVersion snapshot -> optional active-version pointer update -WorkItem -> optional FlowReference (exact or active) -Flow Run -> resolves exact published FlowReference -> local graph execution or isolated MAF orchestration adapter +Entry / Trigger / REST / Console -> RootFlowSubmissionService + -> trusted FlowRunScope authorization and exact published FlowReference + -> one idempotent WorkItem plus one deterministic root FlowRun +WorkItem execution -> revalidates durable scope -> local graph execution or isolated MAF orchestration adapter ``` -The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The local executor traverses `Input`, `Agent`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially without referencing Microsoft Agent Framework; Infrastructure adapts agent steps and Management resource lookups. +The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. + +A Flow call creates a deterministic durable child Flow Run for the parent step attempt. The parent persists `WaitingForChild`, clears its execution lease, and releases the worker. The child captures the resolved immutable version, parent/root causality, nesting depth, and the parent's tenant, Workspace, Principal, interaction, and Work Task identities. A terminal child atomically moves a waiting parent back to `Pending`; replay reconstructs completed step outputs and resumes at the calling step. Startup recovery requeues lost parents or children, cancellation walks the active descendant tree, and configured depth and descendant limits bound composition. Both local SQLite and optional PostgreSQL persist these additions inside the existing Flow document payload, so this increment does not require a relational schema migration. + +Root Flow callers share `RootFlowSubmissionService`, an Application-owned boundary distinct from child execution. It obtains tenant, Workspace, and Principal scope from the authenticated execution context, reauthorizes submission, resolves active references to an immutable version, validates input before creating Work, and persists structured origin, caller, causation, correlation, and idempotency metadata. A caller-supplied idempotency key deterministically identifies the functional WorkItem; the root FlowRun derives from that Work identity. Both the submission path and the local worker use the same idempotent root-run gateway, so a crash or concurrent queue delivery recovers the same pair. Trigger occurrences retain their existing WorkItem identifier. Execution revalidates the durable scope before observing the run. The existing JSON Work metadata and Flow payload carry these fields, so no relational migration is required. ### Flow Run vertical ```text -Console / API / future Work adapter +Entry / Trigger / REST / Console + -> RootFlowSubmissionService returns or recovers the WorkItem and published root Flow Run -> POST published Flow Run returns 202 Accepted - -> bounded local Flow queue - -> validate input and persist the exact draft or published definition snapshot + -> bounded local Work and Flow queues + -> persist the exact validated published definition snapshot -> traverse typed steps or execute a bounded provider-neutral orchestration through the runtime adapter -> persist differential events, transitions, diagnostics, usage, and failures -> SignalR updates with persisted replay, cancellation, global and per-Flow history @@ -280,6 +287,10 @@ Agent tool reference: Agentstration.Tools/tools/{name} AEP owns extension identity, presentation metadata, server declarations, and the mapping from a lightweight contribution to MCP. It deliberately carries no tool schema, invocation payload, result, or operational MCP error. MCP remains authoritative for `tools/list`, schema/annotations, `tools/call`, results, and protocol failures. Agentstration owns persistent `ToolProviderResource` and `ToolResource` documents, discovery state, assignment by canonical resource ID, enablement, and approval policy. Direct external MCP is a ToolProvider and does not pass through AEP. The catalog is independent of MAF; the Runtime adapter consumes its provider-neutral `IAgentTool` and reuses the official SDK's native `AITool` when available. A governed tool marked `requiresApproval` is exposed as an `ApprovalRequiredAIFunction`; MAF's external request then follows the durable `InputRequest` suspension and resume path. +The reserved `agentstration` MCP provider is materialized from Workspace-owned `ToolDefinition` resources. An enabled definition publishes one stable MCP Tool whose exact or active Flow implementation has the same JSON input/output schemas. `tools/call` and assigned Agent invocations both create or recover the root WorkItem/FlowRun through `RootFlowSubmissionService`; trusted scope never comes from Tool arguments. Calls execute within the configured bound, return the Flow output, and retain a receipt containing the effective immutable Flow version and durable identifiers. Active Flow publication is guarded against breaking an enabled definition. See ADR-0106. + +The same internal provider publishes a deliberately bounded atomic `work.notification.create` Tool and projects it as an ordinary governed Tool in each Workspace. It accepts presentation fields plus an explicit delivery key, while scope and Flow/Tool causality come only from the trusted execution context. A reusable delivery Flow maps its stable contract to that Tool; parents call the delivery Flow, and a contract-compatible active version can instead map to Slack, Teams, email, or another MCP provider without changing them. There is no notification-specific graph step or channel resource. See ADR-0107. + Discovery is performed on provider create/update and by an explicit refresh operation. It materializes new tools as disabled, updates provider-owned metadata while preserving administrator enablement, marks disappeared tools unavailable without deleting them, and restores availability if they reappear. Runtime usability requires provider enabled, tool enabled, tool available, and an Agent assignment. Extension endpoints are resolved from `Agentstration:Extensions:{extensionId}:Endpoint`, Aspire connection strings named `*-extension`, and workspace-owned `ExtensionRegistration` Management resources. The Extensions inventory treats enabled registrations as discovery candidates and does not scan the local network. Manual registrations have their own ETag-protected CRUD surface and can be disabled without being deleted. Relative MCP endpoints in AEP discovery are resolved against that extension base URL; absolute endpoints must use HTTP(S). The earlier AEP chat `AepToolDefinition`, `AepToolCall`, and `AepToolResult` contracts describe model-provider function-calling exchange only and are not an operational extension-tool protocol. @@ -323,7 +334,7 @@ SQLite schema evolution for the workspace-scope hardening increment is reset-onl 3. **Delivered runtime vertical:** isolated Microsoft Agent Framework adapter, in-process/shared-host provisioners, runtime registry, periodic reconciliation, single-agent routing, execution, and standalone sample data. 4. **Retired legacy vertical:** the historical content ingestion, memory search and Mission monitoring stack was removed after the Management, Work, Flow, Runtime and Trigger modules superseded its responsibilities. See ADR-0071. 5. **Delivered Work vertical:** domain-controlled lifecycle, typed identifiers, interactions, idempotent Runtime events, independent SQLite persistence, local execution gateway, canonical REST API, metrics, traces, and tests. -6. **Delivered Flow authoring vertical:** independent projects, typed seven-step graphs, draft revisions and ETags, structural/resource/expression validation, YAML/JSON source, immutable publication, visual authoring, Work references, OpenAPI, and SQLite. +6. **Delivered Flow authoring vertical:** independent projects, a finite typed graph vocabulary including the generic Flow-call authoring primitive, draft revisions and ETags, structural/resource/expression/schema/dependency validation, YAML/JSON source, immutable publication, visual authoring, Work references, OpenAPI, and SQLite. 7. **Delivered Flow Runtime vertical:** durable FlowRun contracts and event history, immutable draft/published snapshots, bounded sequential typed-graph execution, input validation, cancellation, SignalR replay, telemetry, and the Flow-centered console. 8. **Next Work increment:** durable execution dispatch/recovery, requester authorization, external artifact storage, cancel propagation, and retry/relaunch operations. 9. **Delivered Runtime Run increment:** durable Run resources, local queue, exact agent-generation resolution, SQLite history, SSE observation, cancellation, retry, and Agent Runner console. @@ -362,6 +373,9 @@ SQLite schema evolution for the workspace-scope hardening increment is reset-onl 42. **Delivered Source registry lifecycle increment:** Platform administrators manage independent official, community, and private Registry endpoints as instance-owned, ETag-protected registrations with explicit trust, network, authentication, refresh, and cache policies. Opt-in periodic refresh reuses the Source scheduling worker with persisted timeout/backoff/jitter, last-known-good, staleness, recovery, and bounded cache-retention state. Credentials remain instance-scoped Secret references resolved only for same-origin requests, while deletion preserves retained observations for provenance. See ADR-0098 and ADR-0101. 43. **Delivered Source registry trust increment:** Registry origin, publisher assertion, exact SourceVersion verification, and Snapshot verification remain independent decisions. Current trust is recalculated from local registration policy and immutable cached observations; revocation and conflicting accepted digests fail closed, while every contributing observation remains exposed as provenance. Agentstration-owned HTTPS host classification is informational, and only the stable built-in official registration receives official-origin classification. See ADR-0103. 44. **Delivered Source registry discovery/import increment:** Platform administrators can search and page a deterministic merge of current compatible Registry shards, inspect every equal or conflicting observation, and import one exact retained observation. The selected manifest alone is fetched and revalidated for origin, identity, opaque version, and canonical digest before the normal immutable Source import runs. SourceVersion and downstream Pack provenance retain the Registry registration, observation, index/shard evidence, validators, publisher assertion, and trust snapshot; no Channel is materialized and `latest` remains shard-local. See ADR-0104. +45. **Delivered Flow-backed ToolDefinition increment:** namespaced Workspace ETag CRUD, Console authoring, published-Flow contract guards, reserved internal MCP provider projection, dynamic MCP publication, governed Agent assignment, bounded root Flow invocation, deterministic replay, and operation receipts. See ADR-0106. +46. **Delivered reusable notification Flow increment:** bounded internal `work.notification.create` MCP Tool, ordinary Tool projection and governance, Workspace-scoped deterministic delivery, causal receipts, and samples composing parent → delivery Flow → terminal Tool. See ADR-0107. +47. **Delivered composable Flow Run diagnostics increment:** bounded Workspace-scoped causal projection from invocation origin through nested Flow Runs, Agent steps, logical Tool calls, physical attempts and governance deep links, without duplicating execution state or exposing sensitive payloads. See ADR-0108. ## ADR catalog @@ -436,3 +450,7 @@ SQLite schema evolution for the workspace-scope hardening increment is reset-onl - ADR-0088: Source verification binds exact definitions and snapshots - ADR-0089: Source Bootstrap profiles reuse administrative applications - ADR-0102: Source Providers follow hierarchical resource visibility +- ADR-0105: Flows compose reusable Flows and governed Tools +- ADR-0106: ToolDefinitions publish Flow-backed MCP Tools +- ADR-0107: notification channels are delivery Flows +- ADR-0108: Flow Run causality is a bounded read model diff --git a/docs/concepts/flow-modes.md b/docs/concepts/flow-modes.md index ea9ce041..c460af08 100644 --- a/docs/concepts/flow-modes.md +++ b/docs/concepts/flow-modes.md @@ -74,7 +74,7 @@ Choose Routing instead of Orchestration when exactly one Agent should run. A Workflow models an application-controlled graph. The executable representation is `FlowGraphDefinition`, containing one entry step, typed steps, transitions, optional input/output schemas, and designer metadata. -### Executable step types +### Graph step types | Step | Responsibility | Main emitted transition event | | --- | --- | --- | @@ -83,11 +83,14 @@ A Workflow models an application-controlled graph. The executable representation | `router` | Selects a declared candidate or fallback. | `selected` or `failed` | | `condition` | Evaluates a constrained simple condition or expression. | `true` or `false` | | `transform` | Produces a mapped or expression-derived value. | `completed` | +| `flow` | Selects an accessible published Flow by active or exact version and maps its declared input. Durable child execution is delivered by the nested FlowRun increment. | `completed` or `failed` | | `output` | Maps the terminal Flow output. | `completed` | | `failure` | Terminates the Run with a declared code and message. | Terminal failure | Transitions connect `fromStep`, event, and `toStep`; an optional condition and priority refine selection. Execution is bounded and rejects a step reached twice, so cycles are not currently supported. Business logic remains in application services and agents, not in the designer or transport endpoints. +The palette contains one generic Flow card, independently of the number of Flows installed in the Workspace. Its namespace-aware selector includes Pack Flows. Changing the target preserves existing mappings; validation reports properties that are no longer compatible with the selected input schema. An active reference follows the target's active published version, while an exact reference pins one immutable version. Publication resolves both forms and rejects dependency cycles before a Run can be created. + The polymorphic `WorkflowFlowDefinition` contract also exposes generic nodes and edges. A directly-created Workflow definition without a stored `FlowGraphDefinition` is structurally valid but is not executable by the current FlowRun engine. The Console draft/publish path stores the executable graph and is the supported Workflow execution path. Choose Workflow when ordering, branching, transformations, and failure paths must be deterministic and reviewable before execution. diff --git a/docs/concepts/flows.md b/docs/concepts/flows.md index 2473efaf..75a70c9d 100644 --- a/docs/concepts/flows.md +++ b/docs/concepts/flows.md @@ -4,6 +4,8 @@ A Flow defines how work is routed and processed. Editable drafts can be validate The five Flow kinds are Direct, Routing, Workflow, Orchestration, and Composite. They do not provide the same execution semantics or current implementation level. See [Flow modes](flow-modes.md) for the decision guide, exact behavior, orchestration strategies, limits, and current restrictions. -The implemented graph executor supports typed Input, Agent, Router, Condition, Transform, Output, and Failure steps. Published Workplace Entries always resolve to an exact executable Flow, including Agent selections normalized through system-managed Direct Agent Flows. +The typed graph supports Input, Agent, Flow, Router, Condition, Transform, Output, and Failure steps. A generic Flow card selects any accessible published Flow by active or exact version, maps its declared input schema, and exposes its output schema to later steps. Publication rejects missing versions, incompatible mappings, and direct or indirect dependency cycles. Durable child-Flow execution is delivered separately; graphs containing a Flow step are authorable but are not executable until that runtime increment is present. + +Published Workplace Entries always resolve to an exact executable Flow, including Agent selections normalized through system-managed Direct Agent Flows. See [Flow definitions](../flow.md) and the [Flow execution architecture](../architecture/flow-execution.md). diff --git a/docs/decisions/0105-flows-compose-flows-and-governed-tools.md b/docs/decisions/0105-flows-compose-flows-and-governed-tools.md new file mode 100644 index 00000000..3417f594 --- /dev/null +++ b/docs/decisions/0105-flows-compose-flows-and-governed-tools.md @@ -0,0 +1,31 @@ +# ADR-0105 — Flows compose reusable Flows and governed Tools + +## Status + +Accepted + +## Context + +Agentstration already has one durable Flow runtime, immutable published Flow versions, and a governed Tool execution pipeline. Reusable orchestration nevertheless cannot be expressed in the typed graph: authors must duplicate a graph, delegate deterministic effects to an Agent, or introduce application-specific execution code. Provider-specific cards and notification-specific nodes would expand the engine vocabulary whenever a resource or integration is added. + +The same business capability must also be invocable from Entries, Triggers, APIs, MCP clients, authorized Agents, the Console, and another Flow without granting a caller unrestricted access to every Flow. + +## Decision + +- The typed graph gains exactly two generic composition primitives: a Flow call step and a governed Tool step. Resource catalogs populate these cards; a resource never creates a new node type. +- A Flow call selects a logical Flow in the current Workspace and resolves either its active published version or an explicitly selected immutable version. Its input is an explicit schema mapping and its declared output schema becomes available to subsequent steps. +- Publication resolves the referenced Flow in the caller's Workspace and namespace, validates its published contract, and rejects direct or indirect dependency cycles. Dynamic Flow names are not accepted. +- Runtime execution creates a durable child FlowRun inside the existing WorkItem. The child captures the exact resolved version and causality; the parent releases its worker while waiting and resumes from persisted state. Recursion and descendant counts are bounded. +- A Tool step selects an ordinary governed Tool resource and executes through the existing Tool execution pipeline, including enablement, approval, hooks, audit, and provider invocation. +- Root Flow submission converges on one application boundary. Entry, Trigger, REST, Console, internal MCP, and authorized Agent adapters provide trusted caller context outside model-controlled input. Nested Flow calls use a separate child-run boundary. +- Workspace-scoped ToolDefinition resources may publish individually bounded, Flow-backed Tools through Agentstration's internal MCP server. They do not expose generic Flow-start or Management CRUD authority. +- Delivery channels, including notification delivery, are ordinary reusable Flows whose terminal effects are governed Tools. There is no NotificationChannel resource or notification-specific engine node. +- Packs distribute the same generic graph definitions and retain their Flow and Tool dependencies. Recipes may create ordinary editable graphs but do not extend runtime semantics. + +## Consequences + +The palette stays finite while installed Flows and Tools remain dynamically selectable. Active references allow a reusable implementation to change without editing its callers; every execution still records the exact effective version. Exact references provide reproducibility where callers require it. + +Flow Application remains provider-neutral and owns composition validation and durable orchestration. MCP and provider details stay behind the existing Tool boundary. Workspace authorization, cancellation, restart recovery, idempotency, and at-least-once external-effect semantics must be explicit in the executable increments. + +This decision supersedes the deferred subflow limitation in ADR-0019 without changing its ownership of FlowRun state. It does not revive CompositeFlowDefinition as a parallel authoring model and does not introduce a second automation runtime, an external broker, arbitrary code handlers, direct HTTP steps, or exactly-once external effects. diff --git a/docs/decisions/0106-tool-definitions-publish-flow-backed-mcp-tools.md b/docs/decisions/0106-tool-definitions-publish-flow-backed-mcp-tools.md new file mode 100644 index 00000000..b80fb319 --- /dev/null +++ b/docs/decisions/0106-tool-definitions-publish-flow-backed-mcp-tools.md @@ -0,0 +1,26 @@ +# ADR-0106 — ToolDefinitions publish Flow-backed MCP Tools + +## Status + +Accepted + +## Context + +Agentstration already consumes external MCP Tools through governed `ToolProvider` and `Tool` resources, while ADR-0105 requires bounded business capabilities to be invocable by Agents and MCP clients without exposing a generic Flow launcher. Static attributed server methods cannot represent Workspace-authored Tools or follow an active published Flow version. + +A Flow-backed Tool must preserve one public contract, capture the effective immutable Flow version on every call, participate in the ordinary Tool assignment and governance path, and keep Tenant, Workspace, and Principal scope outside model-controlled arguments. + +## Decision + +- `ToolDefinition` is a Workspace-owned, namespaced Management resource with ETag CRUD. It owns the MCP name, presentation, enablement, approval intent, JSON input/output schemas, bounded invocation timeout, and one exact or active published Flow reference. +- Saving or enabling a definition resolves its Flow and requires its public schemas to equal the published Flow schemas. Activating a new Flow version is rejected while it would break an enabled active-reference definition. +- Each definition materializes a normal `ToolResource` under a reserved namespaced `agentstration` MCP provider. Agents select that Tool individually and reuse the existing catalog, approval wrapper, execution hooks, lifecycle audit, and runtime adapter. +- The internal MCP server publishes only enabled definitions through dynamic `tools/list`. `tools/call` resolves the selected definition in the authenticated Workspace and invokes the shared root Flow submission boundary; it exposes no generic Flow start or Management CRUD Tool. +- A logical Tool call deterministically identifies its root WorkItem and FlowRun. Execution is cancellation-aware and bounded, returns the Flow output, and attaches the durable WorkItem, FlowRun, effective Flow version, and correlation identifiers as the MCP operation receipt. +- Tenant, Workspace, Principal, origin, causation, and correlation are derived from authenticated transport or runtime execution context. Tool arguments are only the public Flow input and cannot override execution scope. + +## Consequences + +Changing a notification or other capability from an internal implementation to another Flow does not change Agent assignments or workflow Tool cards. The provider remains an MCP provider from the governance perspective, but its invocation adapter is local and does not loop through an unauthenticated HTTP connection to the same process. + +Control-plane storage remains document-based, so adding the resource kind requires no relational migration. A disabled definition remains editable and materialized as a disabled Tool; deletion removes its materialized Tool while the reserved provider may remain for other definitions. Arbitrary scripts, CLR handlers, direct HTTP handlers, automatic publication of every Flow, and unrestricted platform Tools remain out of scope. diff --git a/docs/decisions/0107-notification-channels-are-delivery-flows.md b/docs/decisions/0107-notification-channels-are-delivery-flows.md new file mode 100644 index 00000000..23a874d9 --- /dev/null +++ b/docs/decisions/0107-notification-channels-are-delivery-flows.md @@ -0,0 +1,24 @@ +# ADR-0107 — Notification channels are delivery Flows + +## Status + +Accepted + +## Context + +Workflows need to deliver messages through the in-product notification center or external MCP providers without coupling every parent graph to a provider schema. MCP does not define a universal notification contract, so Agentstration cannot infer equivalent Slack, Teams, email, and vendor-specific Tools. + +## Decision + +- A notification channel is an ordinary reusable Flow with an explicit stable input/output contract. It is called from parent graphs through the generic Flow step. +- Agentstration publishes the bounded atomic internal MCP Tool `work.notification.create`. It creates a durable Workspace notification through `WorkplaceService`; Tenant, Workspace, Principal, run, step, Tool-call, and correlation context remain trusted runtime data rather than Tool arguments. +- An explicit delivery key deterministically identifies a notification inside a Workspace. Replaying the same logical delivery returns the existing notification without emitting duplicate creation events. +- Internal MCP Tools are described through the same MCP schema abstraction and projected as ordinary Tool resources under the reserved `agentstration` provider. Flow and Agent invocation therefore reuse Tool validation, governance, execution hooks, and audit. +- A `ToolDefinition` such as `notification.send` may expose the delivery Flow. The delivery Flow terminates on the atomic Tool, not on its own Flow-backed ToolDefinition, preventing recursion. +- Replacing the channel means publishing and activating a contract-compatible delivery Flow version that maps to another MCP Tool. Parent Flows and active-reference ToolDefinitions remain unchanged. + +## Consequences + +There is no `NotificationChannel` resource, notification engine step, provider-name inference, or external credential in the local default. Fan-out, conditions, transforms, fallback, and failure behavior use ordinary graph primitives. Explicit Workflow delivery remains distinct from event-driven observables in #251, which may call the same Flow after deciding that an event is actionable. + +The notification record retains delivery and causal identifiers but no complete Tool arguments or provider payload. Its deterministic identifier uses the Workspace and delivery key, so persistence requires no relational migration. diff --git a/docs/decisions/0108-flow-run-causality-is-a-read-model.md b/docs/decisions/0108-flow-run-causality-is-a-read-model.md new file mode 100644 index 00000000..7620f081 --- /dev/null +++ b/docs/decisions/0108-flow-run-causality-is-a-read-model.md @@ -0,0 +1,22 @@ +# ADR-0108 — Flow Run causality is a bounded read model + +## Status + +Accepted + +## Context + +Composable orchestration spans a root invocation, nested Flow Runs, Agent steps, and governed Tool attempts. Operators need one diagnostic path, but Flow Runs and Tool lifecycle events already durably own these facts. A second global event store would duplicate state and create consistency and retention problems. + +## Decision + +- Flow Run causality is reconstructed on demand from the requested run's scoped root, its validated child links, step execution state, and existing Tool lifecycle events. +- The root retains the invocation origin, caller, causation, correlation, and WorkItem identity. Every root and child Run retains whether its exact immutable version was resolved from an active reference. +- The read model is a deterministic breadth-first list with explicit parent Run and parent step identities. It is bounded to 100 nodes per API page and preserves parent identities across page boundaries. +- Tool diagnostics group lifecycle events first by logical `ToolCallId`, then by physical `InvocationId`. Provider identity, attempt state, duration, stable error classification, and governance evaluation count are exposed for deep linking to the existing governance audit. +- The projection is authorized with the complete durable Flow Run scope for every node. Invalid, missing, or cross-scope child links are not traversed. +- Arguments, prompts, Flow inputs and outputs, provider results, error messages, and governance payloads are excluded from this read model regardless of their optional retention elsewhere. + +## Consequences + +No new persistence, distributed trace backend, or cross-Workspace query is introduced. Diagnostics reflect the durable state already owned by Flow execution and remain available offline. Tree reconstruction performs bounded per-Run reads; a future indexed projection may optimize very large trees without changing the transport contract. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index 34a254dc..15981671 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -134,3 +134,7 @@ Use **Proposed** when implementation or repository evidence does not establish a 102. [ADR-0102 — Source Providers follow hierarchical resource visibility](0102-source-providers-follow-hierarchical-resource-visibility.md) 103. [ADR-0103 — Source registry trust evaluates independent evidence dimensions](0103-source-registry-trust-evaluates-independent-evidence-dimensions.md) 104. [ADR-0104 — Source registry discovery imports retained observations exactly](0104-source-registry-discovery-imports-retained-observations.md) +105. [ADR-0105 — Flows compose reusable Flows and governed Tools](0105-flows-compose-flows-and-governed-tools.md) +106. [ADR-0106 — ToolDefinitions publish Flow-backed MCP Tools](0106-tool-definitions-publish-flow-backed-mcp-tools.md) +107. [ADR-0107 — Notification channels are delivery Flows](0107-notification-channels-are-delivery-flows.md) +108. [ADR-0108 — Flow Run causality is a bounded read model](0108-flow-run-causality-is-a-read-model.md) diff --git a/docs/flow.md b/docs/flow.md index 021b994a..355346fe 100644 --- a/docs/flow.md +++ b/docs/flow.md @@ -37,7 +37,7 @@ GET /api/flows/{id}/versions/{version} POST /api/flows/{id}/versions ``` -Deletion currently removes the logical Flow and its published versions. Direct self-reference is rejected for Composite Flows; indirect recursion analysis is intentionally deferred. +Deletion currently removes the logical Flow and its published versions. Generic Flow-call steps are validated against published targets in the current Workspace; direct and indirect dependency cycles block publication. ## Runtime boundary diff --git a/docs/reference/current-capabilities.md b/docs/reference/current-capabilities.md index 209460ec..efec5eb5 100644 --- a/docs/reference/current-capabilities.md +++ b/docs/reference/current-capabilities.md @@ -144,7 +144,7 @@ Runtime Agents ``` -The Flow module manages editable graph drafts, immutable published versions, and durable Flow Runs. Its local sequential executor supports typed `Input`, `Agent`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps through provider-neutral contracts. The earlier `Direct`, `Routing`, `Workflow`, `Orchestration`, and `Composite` specifications remain compatible with the same Flow resource and storage boundary. Orchestration Runs can suspend durably for text, choice, or confirmation input, survive process reconstruction through opaque SQLite-backed MAF checkpoints, and resume with the exact Flow snapshot and Agent revisions selected at first execution. +The Flow module manages editable graph drafts, immutable published versions, and durable Flow Runs. The designer adds one generic `Flow` call card that selects a namespaced published Flow by active or exact version, maps its input schema, exposes its output schema, and blocks missing targets, incompatible mappings, and dependency cycles at publication. Durable child-Flow execution is not yet available. The local sequential executor supports typed `Input`, `Agent`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps through provider-neutral contracts. The earlier `Direct`, `Routing`, `Workflow`, `Orchestration`, and `Composite` specifications remain compatible with the same Flow resource and storage boundary. Orchestration Runs can suspend durably for text, choice, or confirmation input, survive process reconstruction through opaque SQLite-backed MAF checkpoints, and resume with the exact Flow snapshot and Agent revisions selected at first execution. The standalone vertical uses SQLite for management resources and runs without Azure, Foundry, a remote model, or an API key. It seeds `dotnet-expert` and `sql-expert`, compiles immutable revisions, deploys them in-process, reconciles their runtime state, routes each request to one agent, and executes that agent through Microsoft Agent Framework. diff --git a/samples/notification-delivery/README.md b/samples/notification-delivery/README.md new file mode 100644 index 00000000..617f5b2b --- /dev/null +++ b/samples/notification-delivery/README.md @@ -0,0 +1,13 @@ +# Reusable notification delivery + +This sample keeps delivery as an ordinary Flow rather than an engine-specific notification step or a `NotificationChannel` resource. + +- `flows/notification-delivery.yaml` maps a stable delivery contract to the atomic internal MCP Tool `work.notification.create`. +- `flows/news-alert-parent.yaml` calls that delivery Flow through the generic Flow card. +- `tooldefinitions/notification-send.yaml` optionally publishes the delivery Flow as `notification.send` for Agents and external MCP clients. + +List the Tools in the Console once before importing the Flow so the workspace-scoped internal Tool projection is available to the designer and validator. The local implementation needs no external service or credential. + +To move delivery to Slack, Teams, email, or another MCP provider, create a new `notification-delivery` draft version, replace the Tool card with that provider's ordinary Tool, explicitly map the stable input to its schema, publish, and activate the version. The parent Flow and `notification.send` ToolDefinition keep their active Flow reference and do not change. Ordinary Condition, Transform, Tool, and Failure cards can implement fan-out and fallback. + +This is explicit delivery requested by a Workflow. Event-driven rules that observe run outcomes and decide whether to notify belong to #251; they can invoke the same delivery Flow. diff --git a/samples/notification-delivery/flows/news-alert-parent.yaml b/samples/notification-delivery/flows/news-alert-parent.yaml new file mode 100644 index 00000000..28563a0c --- /dev/null +++ b/samples/notification-delivery/flows/news-alert-parent.yaml @@ -0,0 +1,34 @@ +apiVersion: agentstration.io/v1 +kind: Flow +metadata: + name: news-alert-parent +definition: + displayName: News alert parent + description: Demonstrates a parent Workflow calling the delivery Flow through the generic Flow card. + version: 1.0.0 + enabled: true + spec: + flowKind: direct + target: + kind: agent + id: unused-graph-target + graph: + entryStep: input + steps: + - { type: input, name: input, displayName: News alert } + - type: flow + name: deliver + displayName: Deliver notification + flow: + resourceId: notification-delivery + versionStrategy: active + inputMapping: "${input}" + - type: output + name: output + displayName: Delivery receipt + outputMapping: "${steps.deliver.output}" + transitions: + - { id: input-to-delivery, fromStep: input, event: completed, toStep: deliver } + - { id: delivery-to-output, fromStep: deliver, event: completed, toStep: output } + publish: true + activate: true diff --git a/samples/notification-delivery/flows/notification-delivery.yaml b/samples/notification-delivery/flows/notification-delivery.yaml new file mode 100644 index 00000000..d122fe14 --- /dev/null +++ b/samples/notification-delivery/flows/notification-delivery.yaml @@ -0,0 +1,55 @@ +apiVersion: agentstration.io/v1 +kind: Flow +metadata: + name: notification-delivery +definition: + displayName: In-product notification delivery + description: Maps the stable notification contract to the internal atomic MCP Tool. + version: 1.0.0 + enabled: true + spec: + flowKind: direct + target: + kind: agent + id: unused-graph-target + graph: + entryStep: input + inputSchema: + type: object + properties: + deliveryKey: { type: string } + title: { type: string } + message: { type: string } + actionUrl: { type: string } + required: [deliveryKey, title, message] + outputSchema: + type: object + properties: + notificationId: { type: string } + deliveryKey: { type: string } + createdAt: { type: string } + recovered: { type: boolean } + required: [notificationId, deliveryKey, createdAt, recovered] + steps: + - type: input + name: input + displayName: Delivery request + - type: tool + name: create-notification + displayName: Create in-product notification + tool: + resourceId: agentstration.work.notification.create + argumentsMapping: + deliveryKey: "${input.deliveryKey}" + title: "${input.title}" + message: "${input.message}" + actionUrl: "${input.actionUrl}" + - type: output + name: output + displayName: Delivery receipt + outputMapping: "${steps.create-notification.output}" + transitions: + - { id: input-to-tool, fromStep: input, event: completed, toStep: create-notification } + - { id: tool-to-output, fromStep: create-notification, event: completed, toStep: output } + publish: true + activate: true diff --git a/samples/notification-delivery/tooldefinitions/notification-send.yaml b/samples/notification-delivery/tooldefinitions/notification-send.yaml new file mode 100644 index 00000000..3feb2aff --- /dev/null +++ b/samples/notification-delivery/tooldefinitions/notification-send.yaml @@ -0,0 +1,29 @@ +apiVersion: agentstration.io/v1 +kind: ToolDefinition +metadata: + name: notification.send +definition: + displayName: Send notification + description: Delivers a notification through the active reusable delivery Flow. + enabled: true + requiresApproval: false + inputSchema: + type: object + properties: + deliveryKey: { type: string } + title: { type: string } + message: { type: string } + actionUrl: { type: string } + required: [deliveryKey, title, message] + outputSchema: + type: object + properties: + notificationId: { type: string } + deliveryKey: { type: string } + createdAt: { type: string } + recovered: { type: boolean } + required: [notificationId, deliveryKey, createdAt, recovered] + flow: + name: notification-delivery + useActiveVersion: true + invocationTimeoutSeconds: 90 diff --git a/src/Agentstration.Application/Work/RootFlowSubmissionService.cs b/src/Agentstration.Application/Work/RootFlowSubmissionService.cs new file mode 100644 index 00000000..1bd8e77b --- /dev/null +++ b/src/Agentstration.Application/Work/RootFlowSubmissionService.cs @@ -0,0 +1,231 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Resources; +using Agentstration.Work; +using Agentstration.Work.Storage.Abstractions; + +namespace Agentstration.Application.Work; + +public sealed record ResolvedRootFlowTarget(FlowReference Reference); + +public interface IRootFlowTargetResolver +{ + Task ResolveAsync( + FlowRunScope scope, + FlowReference target, + JsonElement input, + CancellationToken cancellationToken); +} + +public sealed record RootFlowRunRequest( + string RunId, + FlowReference Target, + FlowRunTrigger Trigger, + FlowInvocationOrigin Origin, + string CallerId, + string? CausationId, + string? IdempotencyKey, + string CorrelationId, + JsonElement Input, + WorkItemId WorkItemId, + bool ResolvedFromActiveReference, + string? ParentFlowRunId, + string? InteractionId, + string? WorkTaskId, + string? TriggerMessageId, + FlowRunScope Scope); + +public sealed record RootFlowRunResult(FlowRun Run, string ETag); + +public interface IRootFlowRunGateway +{ + Task EnsureAsync(RootFlowRunRequest request, CancellationToken cancellationToken); +} + +public interface IRootFlowSubmissionAuthorizer +{ + Task AuthorizeAsync(FlowRunScope scope, CancellationToken cancellationToken); +} + +public sealed record SubmitRootFlowCommand( + WorkspaceId WorkspaceId, + FlowReference Target, + JsonElement Input, + FlowInvocationOrigin Origin, + string CallerId, + FlowRunTrigger Trigger, + string? IdempotencyKey = null, + string? CausationId = null, + string? CorrelationId = null, + string? Type = null, + string? Instruction = null, + string? Title = null, + string? Description = null, + IReadOnlyDictionary? Metadata = null, + IReadOnlyList? WorkInputs = null, + IReadOnlyList? Attachments = null, + string? InteractionId = null, + string? WorkTaskId = null, + string? TriggerMessageId = null, + WorkItemId? WorkItemId = null); + +public sealed record RootFlowSubmission(StoredWorkItem WorkItem, RootFlowRunResult FlowRun, bool Recovered); + +public sealed class RootFlowSubmissionService( + WorkItemService workItems, + IWorkItemRepository repository, + IRootFlowTargetResolver targets, + IRootFlowRunGateway runs, + IRootFlowSubmissionAuthorizer authorizer, + IEnumerable executionScopeAccessors) +{ + public const string RootRunIdMetadata = "flowInvocation.rootRunId"; + public const string OriginMetadata = "flowInvocation.origin"; + public const string TriggerMetadata = "flowInvocation.trigger"; + public const string CallerMetadata = "flowInvocation.caller"; + public const string CausationMetadata = "flowInvocation.causationId"; + public const string IdempotencyMetadata = "flowInvocation.idempotencyKey"; + public const string InputHashMetadata = "flowInvocation.inputHash"; + public const string ActiveReferenceMetadata = "flowInvocation.resolvedFromActiveReference"; + + public Task SubmitAsync(SubmitRootFlowCommand command, CancellationToken cancellationToken) => + SubmitAsync(command, null, cancellationToken); + + internal async Task SubmitAsync( + SubmitRootFlowCommand command, + Func? beforeExecutionConfirmed, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + if (string.IsNullOrWhiteSpace(command.CallerId)) + throw new WorkValidationException("flow_invocation_caller_required", "A root Flow invocation requires a caller identity."); + var scope = executionScopeAccessors.Select(accessor => accessor.Current).FirstOrDefault(value => value is not null) + ?? throw new WorkValidationException("work_execution_scope_required", "Root Flow submission requires an authenticated Workspace scope."); + if (scope.WorkspaceId != command.WorkspaceId) + throw new WorkValidationException("flow_invocation_scope_mismatch", "The root Flow invocation and execution scope must belong to the same Workspace."); + await authorizer.AuthorizeAsync(scope, cancellationToken); + + var workItemId = command.WorkItemId ?? (string.IsNullOrWhiteSpace(command.IdempotencyKey) + ? Agentstration.Work.WorkItemId.New() + : DeterministicWorkItemId(scope, command.Origin, command.IdempotencyKey)); + var runId = $"flowrun-root-{workItemId.Value:N}"; + var inputHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(command.Input.GetRawText()))).ToLowerInvariant(); + var existing = await repository.GetAsync(command.WorkspaceId, workItemId, cancellationToken); + if (existing is not null) + { + ValidateExisting(existing.Value, command, runId, inputHash); + var recoveredRun = await EnsureRunAsync(existing.Value, command, scope, runId, command.Target.UseActiveVersion, cancellationToken); + return new(existing, recoveredRun, true); + } + + var resolved = await targets.ResolveAsync(scope, command.Target, command.Input, cancellationToken); + var correlation = string.IsNullOrWhiteSpace(command.CorrelationId) + ? $"flow:{workItemId.Value:N}" + : command.CorrelationId; + var metadata = command.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(command.Metadata, StringComparer.Ordinal); + metadata[RootRunIdMetadata] = runId; + metadata[OriginMetadata] = command.Origin.ToString(); + metadata[TriggerMetadata] = command.Trigger.ToString(); + metadata[CallerMetadata] = command.CallerId; + metadata[InputHashMetadata] = inputHash; + metadata[ActiveReferenceMetadata] = command.Target.UseActiveVersion.ToString(); + if (!string.IsNullOrWhiteSpace(command.CausationId)) metadata[CausationMetadata] = command.CausationId; + if (!string.IsNullOrWhiteSpace(command.IdempotencyKey)) metadata[IdempotencyMetadata] = command.IdempotencyKey; + + var submit = new SubmitWorkItemCommand( + command.WorkspaceId, + command.Type ?? "flow", + command.Instruction ?? $"Execute Flow '{resolved.Reference.FlowId}'.", + command.Title, + command.Description, + command.CallerId, + new WorkCorrelationId(correlation), + Metadata: metadata, + Inputs: command.WorkInputs is null + ? [new WorkInput(Structured: command.Input.Clone())] + : [new WorkInput(Structured: command.Input.Clone()), .. command.WorkInputs], + Attachments: command.Attachments, + Flow: resolved.Reference, + Id: workItemId); + + StoredWorkItem stored; + try + { + stored = await workItems.SubmitAsync(submit, beforeExecutionConfirmed, cancellationToken); + } + catch (WorkItemConcurrencyException) + { + var recovered = await repository.GetAsync(command.WorkspaceId, workItemId, cancellationToken); + if (recovered is null) throw; + stored = recovered; + ValidateExisting(stored.Value, command, runId, inputHash); + } + + var rootRun = await EnsureRunAsync(stored.Value, command with { Target = resolved.Reference, CorrelationId = correlation }, scope, runId, command.Target.UseActiveVersion, cancellationToken); + return new(stored, rootRun, false); + } + + private async Task EnsureRunAsync( + WorkItem item, + SubmitRootFlowCommand command, + FlowRunScope scope, + string runId, + bool resolvedFromActiveReference, + CancellationToken cancellationToken) + { + var target = item.Flow ?? throw new WorkValidationException("flow_invocation_target_missing", "The WorkItem has no root Flow target."); + return await runs.EnsureAsync(new RootFlowRunRequest( + runId, + target, + command.Trigger, + command.Origin, + command.CallerId, + command.CausationId, + command.IdempotencyKey, + command.CorrelationId ?? item.CorrelationId.Value, + command.Input, + item.Id, + resolvedFromActiveReference, + item.Metadata.GetValueOrDefault("workplace.parentFlowRunId"), + command.InteractionId ?? item.Metadata.GetValueOrDefault("workplace.interactionId"), + command.WorkTaskId ?? item.Metadata.GetValueOrDefault("workplace.taskId") ?? item.Id.Value.ToString("D"), + command.TriggerMessageId ?? item.Metadata.GetValueOrDefault("workplace.triggerMessageId"), + scope), cancellationToken); + } + + private static void ValidateExisting(WorkItem item, SubmitRootFlowCommand command, string runId, string inputHash) + { + if (item.Flow is null + || item.Flow.FlowId != command.Target.Resolve(command.Target.FlowId.Namespace) + || !VersionMatches(item.Flow, command.Target) + || !item.Metadata.TryGetValue(RootRunIdMetadata, out var storedRunId) + || !string.Equals(storedRunId, runId, StringComparison.Ordinal) + || !item.Metadata.TryGetValue(OriginMetadata, out var origin) + || !string.Equals(origin, command.Origin.ToString(), StringComparison.Ordinal) + || !item.Metadata.TryGetValue(TriggerMetadata, out var trigger) + || !string.Equals(trigger, command.Trigger.ToString(), StringComparison.Ordinal) + || !item.Metadata.TryGetValue(CallerMetadata, out var caller) + || !string.Equals(caller, command.CallerId, StringComparison.Ordinal) + || !item.Metadata.TryGetValue(InputHashMetadata, out var storedInputHash) + || !string.Equals(storedInputHash, inputHash, StringComparison.Ordinal) + || !Matches(item.Metadata, CausationMetadata, command.CausationId) + || !Matches(item.Metadata, IdempotencyMetadata, command.IdempotencyKey)) + throw new WorkValidationException("flow_invocation_idempotency_conflict", "The idempotency key is already bound to another root Flow invocation."); + } + + private static bool Matches(IReadOnlyDictionary metadata, string key, string? value) => + metadata.TryGetValue(key, out var stored) ? string.Equals(stored, value, StringComparison.Ordinal) : string.IsNullOrWhiteSpace(value); + + private static bool VersionMatches(FlowReference stored, FlowReference requested) => + requested.UseActiveVersion || string.Equals(stored.Version, requested.Version, StringComparison.Ordinal); + + private static WorkItemId DeterministicWorkItemId(FlowRunScope scope, FlowInvocationOrigin origin, string key) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{scope.TenantId:D}\n{scope.WorkspaceId.Value:D}\n{origin}\n{key.Trim()}")); + return new WorkItemId(new Guid(bytes.AsSpan(0, 16))); + } +} diff --git a/src/Agentstration.Application/Work/WorkplaceService.Interactions.cs b/src/Agentstration.Application/Work/WorkplaceService.Interactions.cs index 1f9e4236..de292481 100644 --- a/src/Agentstration.Application/Work/WorkplaceService.Interactions.cs +++ b/src/Agentstration.Application/Work/WorkplaceService.Interactions.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Agentstration.Flow; using Agentstration.Resources; using Agentstration.Work; using Agentstration.Work.Contracts; @@ -212,11 +213,30 @@ public async Task AddMessageAsync(WorkspaceId workspaceId, }; if (!string.IsNullOrWhiteSpace(context.LastFlowRunId)) metadata[ParentFlowRunMetadata] = context.LastFlowRunId; var target = entry.Behavior.Conversation?.ContinuationTarget ?? entry.ResolvedTarget; - var stored = await workItems.SubmitAsync(new SubmitWorkItemCommand( - interaction.WorkspaceId, "entry-continuation", message.Content, entry.DisplayName, $"Continuation of {entry.DisplayName}", Metadata: metadata, - Inputs: [new WorkInput(Structured: JsonSerializer.SerializeToElement(context))], - Flow: WorkplaceValidation.FlowReferenceFrom(target)), cancellationToken); - var task = ToTask(stored.Value, interaction.TaskId); + var continuationInput = JsonSerializer.SerializeToElement(new + { + prompt = message.Content, + inputs = new[] { JsonSerializer.SerializeToElement(context) } + }); + var rootSubmission = await RequiredRootFlows().SubmitAsync(new SubmitRootFlowCommand( + interaction.WorkspaceId, + WorkplaceValidation.FlowReferenceFrom(target)!, + continuationInput, + FlowInvocationOrigin.Entry, + "workplace-user", + FlowRunTrigger.WorkItem, + $"entry-continuation:{message.Id:N}", + message.Id.ToString("D"), + Metadata: metadata, + Type: "entry-continuation", + Instruction: message.Content, + Title: entry.DisplayName, + Description: $"Continuation of {entry.DisplayName}", + WorkInputs: [new WorkInput(Structured: JsonSerializer.SerializeToElement(context))], + InteractionId: interaction.Id.ToString(), + WorkTaskId: interaction.TaskId.Value.ToString(), + TriggerMessageId: message.Id.ToString("D")), cancellationToken); + var task = ToTask(rootSubmission.WorkItem.Value, interaction.TaskId); var action = new CreateTaskAction(interaction.TaskId.Value, task.Title, task.Description, $"/tasks/{interaction.TaskId.Value}"); var processing = interaction with { @@ -239,8 +259,30 @@ private async Task CreateTaskAsync(WorkplaceInteraction interac WorkTask? task = null; CreateTaskAction? action = null; WorkplaceInteraction? updated = null; - var stored = await workItems.SubmitAsync( - new SubmitWorkItemCommand(interaction.WorkspaceId, "entry", Instruction(entry, values), entry.DisplayName, entry.Description, Metadata: metadata, Inputs: inputs, Attachments: attachments, Flow: WorkplaceValidation.FlowReferenceFrom(entry.ResolvedTarget)), + var instruction = Instruction(entry, values); + var flowInput = JsonSerializer.SerializeToElement(new + { + prompt = instruction, + inputs = inputs.Select(value => value.Structured ?? JsonSerializer.SerializeToElement(value.Text)).ToArray() + }); + var root = await RequiredRootFlows().SubmitAsync( + new SubmitRootFlowCommand( + interaction.WorkspaceId, + WorkplaceValidation.FlowReferenceFrom(entry.ResolvedTarget)!, + flowInput, + FlowInvocationOrigin.Entry, + "workplace-user", + FlowRunTrigger.WorkItem, + $"entry:{interaction.Id.Value:N}", + interaction.Id.ToString(), + Type: "entry", + Instruction: instruction, + Title: entry.DisplayName, + Description: entry.Description, + Metadata: metadata, + WorkInputs: inputs, + Attachments: attachments, + InteractionId: interaction.Id.ToString()), async (queued, cancellationToken) => { task = ToTask(queued.Value); @@ -250,7 +292,7 @@ private async Task CreateTaskAsync(WorkplaceInteraction interac await repository.SaveInteractionAsync(updated, expectedInteractionVersion, cancellationToken); }, token); - task ??= ToTask(stored.Value); + task ??= ToTask(root.WorkItem.Value); action ??= new CreateTaskAction(task.Id, task.Title, task.Description, $"/tasks/{task.Id}"); updated ??= interaction with { Status = InteractionStatus.Processing, TaskId = task.Id, PendingActionId = null, ImmediateResult = action, Version = expectedInteractionVersion + 1 }; await PublishInteractionAsync(updated, token); diff --git a/src/Agentstration.Application/Work/WorkplaceService.Notifications.cs b/src/Agentstration.Application/Work/WorkplaceService.Notifications.cs index df6b9fa4..22673766 100644 --- a/src/Agentstration.Application/Work/WorkplaceService.Notifications.cs +++ b/src/Agentstration.Application/Work/WorkplaceService.Notifications.cs @@ -10,6 +10,19 @@ namespace Agentstration.Application.Work; public sealed partial class WorkplaceService { + public sealed record DeliverNotificationCommand( + WorkspaceId WorkspaceId, + string DeliveryKey, + string Title, + string Message, + string? ActionUrl = null, + string? CorrelationId = null, + string? SourceRunId = null, + string? SourceStepId = null, + string? SourceToolCallId = null); + + public sealed record NotificationDelivery(WorkNotification Notification, bool Recovered); + public Task> ListNotificationsAsync(WorkspaceId workspaceId, bool? unreadOnly, CancellationToken token) => repository.ListNotificationsAsync(workspaceId, unreadOnly, token); public async Task UnreadCountAsync(WorkspaceId workspaceId, CancellationToken token) => (await repository.ListNotificationsAsync(workspaceId, true, token)).Count; @@ -18,6 +31,49 @@ public sealed partial class WorkplaceService public async Task MarkAllNotificationsReadAsync(WorkspaceId workspaceId, CancellationToken token) { foreach (var value in await repository.ListNotificationsAsync(workspaceId, true, token)) await MarkNotificationReadAsync(workspaceId, value.Id, token); await PublishAsync(new UnreadNotificationCountChangedEvent(EventId(), workspaceId.Value, Sequence(), timeProvider.GetUtcNow(), 0), token); } + public async Task DeliverNotificationAsync(DeliverNotificationCommand command, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(command); + var deliveryKey = Required(command.DeliveryKey, nameof(command.DeliveryKey), 256); + var title = Required(command.Title, nameof(command.Title), 200); + var message = Required(command.Message, nameof(command.Message), 4_000); + if (command.ActionUrl is { Length: > 2_048 }) throw new WorkValidationException("notification_action_url_too_long", "Notification actionUrl cannot exceed 2048 characters."); + if (command.ActionUrl is { } actionUrl && (actionUrl.Length == 0 || actionUrl[0] != '/' || actionUrl.StartsWith("//", StringComparison.Ordinal) || actionUrl.Contains('\\'))) + throw new WorkValidationException("notification_action_url_invalid", "Notification actionUrl must be a local absolute path."); + var id = NotificationId(command.WorkspaceId, deliveryKey); + var existing = await repository.GetNotificationAsync(command.WorkspaceId, id, token); + if (existing is not null) return new(existing, true); + + var notification = new WorkNotification + { + Id = id, + WorkspaceId = command.WorkspaceId, + Kind = WorkNotificationKind.Information, + Title = title, + Message = message, + CreatedAt = timeProvider.GetUtcNow(), + ActionUrl = command.ActionUrl, + DeliveryKey = deliveryKey, + CorrelationId = command.CorrelationId, + SourceRunId = command.SourceRunId, + SourceStepId = command.SourceStepId, + SourceToolCallId = command.SourceToolCallId + }; + try + { + await repository.CreateNotificationAsync(notification, token); + } + catch (Exception) when (!token.IsCancellationRequested) + { + existing = await repository.GetNotificationAsync(command.WorkspaceId, id, token); + if (existing is not null) return new(existing, true); + throw; + } + await PublishAsync(new NotificationCreatedEvent(EventId(), command.WorkspaceId.Value, Sequence(), notification.CreatedAt, notification), token); + await PublishAsync(new UnreadNotificationCountChangedEvent(EventId(), command.WorkspaceId.Value, Sequence(), notification.CreatedAt, await UnreadCountAsync(command.WorkspaceId, token)), token); + return new(notification, false); + } + private async Task CreateNotificationAsync(WorkspaceId workspaceId, WorkNotificationKind kind, string title, string message, InteractionId? interactionId, WorkTaskId? taskId, PendingActionId? actionId, string? url, CancellationToken token) { var notification = new WorkNotification { Id = WorkNotificationId.New(), WorkspaceId = workspaceId, Kind = kind, Title = title, Message = message, CreatedAt = timeProvider.GetUtcNow(), InteractionId = interactionId, WorkTaskId = taskId, PendingActionId = actionId, ActionUrl = url }; await repository.CreateNotificationAsync(notification, token); await PublishAsync(new NotificationCreatedEvent(EventId(), workspaceId.Value, Sequence(), notification.CreatedAt, notification), token); await PublishAsync(new UnreadNotificationCountChangedEvent(EventId(), workspaceId.Value, Sequence(), notification.CreatedAt, await UnreadCountAsync(workspaceId, token)), token); @@ -28,5 +84,19 @@ private async Task CreateNotificationAsync(WorkspaceId workspaceId, WorkNotifica private long Sequence() => Interlocked.Increment(ref eventSequence); private static string EventId() => Guid.NewGuid().ToString("N"); + + private static string Required(string value, string name, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) throw new WorkValidationException("notification_field_required", $"Notification {name} is required."); + value = value.Trim(); + if (value.Length > maximumLength) throw new WorkValidationException("notification_field_too_long", $"Notification {name} cannot exceed {maximumLength} characters."); + return value; + } + + private static WorkNotificationId NotificationId(WorkspaceId workspaceId, string deliveryKey) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{workspaceId.Value:D}\n{deliveryKey}")); + return new(new Guid(hash.AsSpan(0, 16))); + } } diff --git a/src/Agentstration.Application/Work/WorkplaceService.cs b/src/Agentstration.Application/Work/WorkplaceService.cs index 4f3acbda..b4af31fa 100644 --- a/src/Agentstration.Application/Work/WorkplaceService.cs +++ b/src/Agentstration.Application/Work/WorkplaceService.cs @@ -43,7 +43,8 @@ public sealed partial class WorkplaceService( TimeProvider timeProvider, IEnumerable eventSinks, IEnumerable externalInputResponders, - IWorkplaceContext context) + IWorkplaceContext context, + RootFlowSubmissionService? rootFlows = null) { private const int WorkItemQueryPageSize = 200; private const string WorkspaceMetadata = "workplace.workspaceId"; @@ -56,6 +57,9 @@ public sealed partial class WorkplaceService( private const string ContinuationMetadata = "workplace.continuation"; private long eventSequence; + private RootFlowSubmissionService RequiredRootFlows() => rootFlows + ?? throw new WorkValidationException("root_flow_submission_unavailable", "Root Flow submission is not configured."); + diff --git a/src/Agentstration.Flow.Application/FlowDraftService.cs b/src/Agentstration.Flow.Application/FlowDraftService.cs index f7f2a6a3..a20a3b3c 100644 --- a/src/Agentstration.Flow.Application/FlowDraftService.cs +++ b/src/Agentstration.Flow.Application/FlowDraftService.cs @@ -64,13 +64,13 @@ public async Task SaveAsync(WorkspaceId workspaceId, FlowId flo public async ValueTask ValidateAsync(WorkspaceId workspaceId, FlowId flowId, CancellationToken cancellationToken) { var draft = await RequiredAsync(workspaceId, flowId, cancellationToken); - return await validator.ValidateAsync(draft.Value.Definition, new FlowValidationContext(), cancellationToken); + return await validator.ValidateAsync(draft.Value.Definition, new FlowValidationContext(true, workspaceId, flowId), cancellationToken); } public async Task PublishAsync(WorkspaceId workspaceId, FlowId flowId, string version, string? releaseNotes, bool activate, CancellationToken cancellationToken) { var draft = await RequiredAsync(workspaceId, flowId, cancellationToken); - var validation = await validator.ValidateAsync(draft.Value.Definition, new FlowValidationContext(), cancellationToken); + var validation = await validator.ValidateAsync(draft.Value.Definition, new FlowValidationContext(true, workspaceId, flowId), cancellationToken); if (!validation.IsValid) throw new FlowValidationException("flow_validation_failed", "The Flow Draft contains validation errors and cannot be published."); var definition = await repository.GetAsync(workspaceId, flowId, cancellationToken) ?? throw new FlowNotFoundException(flowId); await flows.UpdateAsync(workspaceId, flowId, new UpdateFlowCommand(draft.Value.Description, version, true, FlowDraftSnapshotAdapter.ToRoutingDefinition(draft.Value.Definition), draft.Value.Tags, diff --git a/src/Agentstration.Flow.Application/FlowRunService.Causality.cs b/src/Agentstration.Flow.Application/FlowRunService.Causality.cs new file mode 100644 index 00000000..9631443c --- /dev/null +++ b/src/Agentstration.Flow.Application/FlowRunService.Causality.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Text.Json; + +namespace Agentstration.Flow.Application; + +public sealed partial class FlowRunService +{ + public async Task GetCausalityAsync( + string runId, + int skip, + int take, + FlowRunScope scope, + CancellationToken cancellationToken) + { + if (skip < 0) throw new FlowValidationException("flow_run_causality_skip_invalid", "Causality pagination offset cannot be negative."); + if (take is < 1 or > 100) throw new FlowValidationException("flow_run_causality_take_invalid", "Causality page size must be between 1 and 100."); + + var requested = await RequiredAsync(runId, scope, cancellationToken); + var rootId = requested.Value.RootFlowRunId ?? requested.Value.Id; + var root = string.Equals(rootId, requested.Value.Id, StringComparison.Ordinal) + ? requested + : await RequiredAsync(rootId, scope, cancellationToken); + var flattened = await FlattenCausalityAsync(root.Value, scope, cancellationToken); + var page = flattened.Skip(skip).Take(take).ToArray(); + return new FlowRunCausalityPage( + new FlowRunCausalityOrigin( + root.Value.Id, + root.Value.InvocationOrigin, + root.Value.Trigger, + root.Value.CallerId, + root.Value.CausationId, + root.Value.CorrelationId, + root.Value.WorkItemResourceId), + page, + flattened.Count, + skip + page.Length < flattened.Count); + } + + private async Task> FlattenCausalityAsync( + FlowRun root, + FlowRunScope scope, + CancellationToken cancellationToken) + { + var result = new List(); + var queue = new Queue<(FlowRun Run, string? ParentStepName)>(); + var visited = new HashSet(StringComparer.Ordinal); + queue.Enqueue((root, null)); + + while (queue.TryDequeue(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!visited.Add(current.Run.Id)) continue; + var events = await ListEventsAsync(scope, current.Run.Id, 0, cancellationToken); + result.Add(ProjectCausalityNode(current.Run, current.ParentStepName, events)); + + foreach (var step in current.Run.Steps.Where(value => !string.IsNullOrWhiteSpace(value.ChildFlowRunId))) + { + var child = await GetAsync(step.ChildFlowRunId!, scope, cancellationToken); + if (child is null + || child.Value.ParentFlowRunId != current.Run.Id + || child.Value.RootFlowRunId != root.Id + || child.Value.NestingDepth != current.Run.NestingDepth + 1) + continue; + queue.Enqueue((child.Value, step.StepName)); + } + } + + return result; + } + + private static FlowRunCausalityNode ProjectCausalityNode( + FlowRun run, + string? parentStepName, + IReadOnlyList events) + { + var agents = run.Steps + .Where(step => !string.IsNullOrWhiteSpace(step.AgentResourceId)) + .Select(step => new FlowRunAgentExecution( + step.StepName, + step.Status, + step.AgentResourceId!, + step.AgentVersion, + step.ModelProfileResourceId, + step.Provider, + step.StartedAt, + step.CompletedAt, + step.Error?.Code)) + .ToArray(); + return new FlowRunCausalityNode( + run.Id, + run.FlowId, + run.FlowVersion, + run.ResolvedFromActiveReference, + run.DefinitionState, + run.Status, + run.ParentFlowRunId, + parentStepName, + run.NestingDepth, + run.CreatedAt, + run.StartedAt, + run.CompletedAt, + run.Error?.Code, + agents, + ProjectToolCalls(events)); + } + + private static IReadOnlyList ProjectToolCalls(IReadOnlyList events) + { + var toolEvents = events + .Where(value => value.Type is FlowRunEventType.ToolCallStarted + or FlowRunEventType.ToolCallGovernanceEvaluated + or FlowRunEventType.ToolCallCompleted + or FlowRunEventType.ToolCallFailed) + .Select(value => new ToolEvent(value, Property(value.Payload, "ToolCallId"), Property(value.Payload, "InvocationId"))) + .Where(value => !string.IsNullOrWhiteSpace(value.ToolCallId) && !string.IsNullOrWhiteSpace(value.InvocationId)) + .ToArray(); + + return toolEvents + .GroupBy(value => value.ToolCallId!, StringComparer.Ordinal) + .Select(group => + { + var first = group.OrderBy(value => value.Event.Sequence).First(); + var last = group.OrderBy(value => value.Event.Sequence).Last(); + var attempts = group + .GroupBy(value => value.InvocationId!, StringComparer.Ordinal) + .Select((attempt, index) => ProjectAttempt(attempt, index + 1)) + .OrderBy(value => value.Attempt) + .ThenBy(value => value.StartedAt) + .ToArray(); + return new FlowRunToolCall( + group.Key, + first.Event.StepId ?? Property(first.Event.Payload, "FlowStepId"), + Property(first.Event.Payload, "ToolId"), + Property(first.Event.Payload, "ToolNamespace"), + Property(first.Event.Payload, "ToolName"), + Property(first.Event.Payload, "ProviderId"), + Property(first.Event.Payload, "ProviderNamespace"), + Property(first.Event.Payload, "ExternalToolId"), + Property(last.Event.Payload, "Outcome") ?? ToolStatus(last.Event.Type), + Property(first.Event.Payload, "CorrelationId"), + attempts); + }) + .OrderBy(value => value.Attempts.FirstOrDefault()?.StartedAt) + .ThenBy(value => value.LogicalCallId, StringComparer.Ordinal) + .ToArray(); + } + + private static FlowRunToolAttempt ProjectAttempt(IGrouping group, int ordinal) + { + var ordered = group.OrderBy(value => value.Event.Sequence).ToArray(); + var first = ordered[0]; + var last = ordered[^1]; + var completed = ordered.FirstOrDefault(value => value.Event.Type is FlowRunEventType.ToolCallCompleted or FlowRunEventType.ToolCallFailed); + var attempt = ParseAttempt(group.Key) ?? ordinal; + return new FlowRunToolAttempt( + group.Key, + attempt, + Property(last.Event.Payload, "Outcome") ?? ToolStatus(last.Event.Type), + first.Event.Timestamp, + completed?.Event.Timestamp, + Number(completed?.Event.Payload, "DurationMilliseconds"), + Property(completed?.Event.Payload, "ErrorCode"), + Property(completed?.Event.Payload, "FailureKind"), + ordered.Where(value => value.Event.Type == FlowRunEventType.ToolCallGovernanceEvaluated) + .Sum(value => ArrayLength(value.Event.Payload, "Governance"))); + } + + private static string ToolStatus(FlowRunEventType type) => type switch + { + FlowRunEventType.ToolCallStarted => "running", + FlowRunEventType.ToolCallGovernanceEvaluated => "governed", + FlowRunEventType.ToolCallCompleted => "succeeded", + FlowRunEventType.ToolCallFailed => "failed", + _ => "unknown" + }; + + private static int? ParseAttempt(string invocationId) + { + const string marker = ":attempt:"; + var index = invocationId.LastIndexOf(marker, StringComparison.Ordinal); + return index >= 0 && int.TryParse(invocationId[(index + marker.Length)..], NumberStyles.None, CultureInfo.InvariantCulture, out var value) + ? value + : null; + } + + private static string? Property(JsonElement? payload, string name) => + payload is { ValueKind: JsonValueKind.Object } value + && value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static double? Number(JsonElement? payload, string name) => + payload is { ValueKind: JsonValueKind.Object } value + && value.TryGetProperty(name, out var property) + && property.TryGetDouble(out var result) + ? result + : null; + + private static int ArrayLength(JsonElement? payload, string name) => + payload is { ValueKind: JsonValueKind.Object } value + && value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.Array + ? property.GetArrayLength() + : 0; + + private sealed record ToolEvent(FlowRunEvent Event, string? ToolCallId, string? InvocationId); +} diff --git a/src/Agentstration.Flow.Application/FlowRunService.Children.cs b/src/Agentstration.Flow.Application/FlowRunService.Children.cs new file mode 100644 index 00000000..a583e30c --- /dev/null +++ b/src/Agentstration.Flow.Application/FlowRunService.Children.cs @@ -0,0 +1,228 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Agentstration.Flow.Storage.Abstractions; + +namespace Agentstration.Flow.Application; + +public sealed partial class FlowRunService +{ + private static string ChildFlowRunId(FlowRun parent, string stepName, int attempt) + { + var identity = $"{parent.WorkspaceId}:{parent.Id}:{stepName}:{attempt}"; + var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"flowrun-child-{hash[..32]}"; + } + + private async Task SuspendForChildAsync( + StoredFlowRun stored, + string stepName, + string childRunId, + CancellationToken cancellationToken) + { + var steps = stored.Value.Steps.Select(step => step.StepName == stepName + ? step with { ChildFlowRunId = childRunId } + : step).ToArray(); + var suspended = await SaveAsync(stored, stored.Value with + { + Status = FlowRunStatus.WaitingForChild, + Steps = steps, + ExecutionLeaseId = null, + ExecutionLeaseExpiresAt = null + }, cancellationToken); + await EmitAsync( + suspended.Value.WorkspaceId, + suspended.Value.Id, + FlowRunEventType.FlowRunWaitingForChild, + stepName, + JsonSerializer.SerializeToElement(new { childFlowRunId = childRunId }), + cancellationToken); + return suspended; + } + + private async Task EnsureChildFlowRunAsync( + FlowRun parent, + FlowCallStepDefinition call, + JsonElement input, + string childRunId, + CancellationToken cancellationToken) + { + var existing = await repository.GetRunAsync(parent.WorkspaceId, childRunId, cancellationToken); + if (existing is not null) + { + ValidateChildIdentity(parent, call, existing.Value, childRunId); + return existing; + } + + var depth = parent.NestingDepth + 1; + if (depth > executionOptions.MaximumNestingDepth) + throw new FlowValidationException("flow_nesting_depth_exceeded", $"Nested Flow depth exceeds the configured limit of {executionOptions.MaximumNestingDepth}."); + var rootRunId = parent.RootFlowRunId ?? parent.Id; + if (await CountDescendantsAsync(parent, rootRunId, cancellationToken) >= executionOptions.MaximumDescendantRuns) + throw new FlowValidationException("flow_descendant_limit_exceeded", $"The root Flow Run already has the configured limit of {executionOptions.MaximumDescendantRuns} descendants."); + + var targetId = call.Flow.Resolve(parent.FlowId.Namespace); + var requestedVersion = call.Flow.VersionStrategy == FlowCallVersionStrategy.Exact ? call.Flow.Version : null; + var resolved = await ResolveVersionAsync(parent.WorkspaceId, targetId, requestedVersion, cancellationToken); + ValidateInput(resolved.Graph?.InputSchema, input); + var now = timeProvider.GetUtcNow(); + var child = new FlowRun + { + WorkspaceId = parent.WorkspaceId, + Id = childRunId, + FlowId = targetId, + FlowVersion = resolved.Version, + DefinitionState = FlowDefinitionState.Published, + DefinitionHash = resolved.DefinitionHash, + DefinitionSnapshotId = $"{targetId.Value}:{resolved.Version}:{resolved.DefinitionHash ?? "legacy"}", + DefinitionSnapshot = resolved, + DeploymentResourceId = parent.DeploymentResourceId, + Trigger = FlowRunTrigger.Flow, + StartedBy = parent.StartedBy, + CorrelationId = parent.CorrelationId, + ResolvedFromActiveReference = call.Flow.VersionStrategy == FlowCallVersionStrategy.Active, + ParentFlowRunId = parent.Id, + RootFlowRunId = rootRunId, + NestingDepth = depth, + InteractionId = parent.InteractionId, + WorkTaskId = parent.WorkTaskId, + TriggerMessageId = parent.TriggerMessageId, + Scope = parent.Scope, + Input = input.Clone(), + CreatedAt = now, + Steps = CreateSteps(resolved, input) + }; + + StoredFlowRun created; + try + { + created = await repository.CreateRunAsync(child, cancellationToken); + } + catch (FlowConcurrencyException) + { + var recovered = await repository.GetRunAsync(parent.WorkspaceId, childRunId, cancellationToken); + if (recovered is null) throw; + created = recovered; + ValidateChildIdentity(parent, call, created.Value, childRunId); + return created; + } + + RunsCreated.Add(1, new KeyValuePair("flow.definition.state", child.DefinitionState.ToString())); + try + { + await EmitAsync(child.WorkspaceId, child.Id, FlowRunEventType.FlowRunCreated, null, + JsonSerializer.SerializeToElement(new { child.Status, child.DefinitionState, child.ParentFlowRunId, child.RootFlowRunId, child.NestingDepth }), cancellationToken); + await EmitAsync(parent.WorkspaceId, parent.Id, FlowRunEventType.ChildFlowRunCreated, call.Name, + JsonSerializer.SerializeToElement(new { childFlowRunId = child.Id, flowId = child.FlowId.Value, flowNamespace = child.FlowId.Namespace.Value, flowVersion = child.FlowVersion }), cancellationToken); + } + finally + { + await queue.EnqueueAsync(new(child.Id, child.Scope), cancellationToken); + } + return created; + } + + private async Task CountDescendantsAsync( + FlowRun parent, + string rootRunId, + CancellationToken cancellationToken) + { + const int pageSize = 200; + var count = 0; + for (var skip = 0; ; skip += pageSize) + { + var keys = await repository.ListRunKeysAsync(skip, pageSize, cancellationToken); + foreach (var key in keys.Where(key => key.WorkspaceId == parent.WorkspaceId)) + { + var run = await repository.GetRunAsync(parent.WorkspaceId, key.RunId, cancellationToken); + if (run?.Value.RootFlowRunId == rootRunId + && run.Value.Scope == parent.Scope + && ++count >= executionOptions.MaximumDescendantRuns) + return count; + } + if (keys.Count < pageSize) return count; + } + } + + private static void ValidateChildIdentity(FlowRun parent, FlowCallStepDefinition call, FlowRun child, string expectedChildRunId) + { + var expectedFlowId = call.Flow.Resolve(parent.FlowId.Namespace); + if (child.Id != expectedChildRunId + || child.ParentFlowRunId != parent.Id + || child.RootFlowRunId != (parent.RootFlowRunId ?? parent.Id) + || child.NestingDepth != parent.NestingDepth + 1 + || child.FlowId != expectedFlowId + || child.Trigger != FlowRunTrigger.Flow + || child.Scope != parent.Scope + || child.WorkspaceId != parent.WorkspaceId) + throw new FlowValidationException("child_flow_identity_mismatch", $"Persisted child Flow Run for step '{call.Name}' does not match the declared target and parent execution scope."); + } + + private async Task ResumeParentAfterChildAsync(FlowRun child, CancellationToken cancellationToken) + { + if (child.ParentFlowRunId is null) return; + var parent = await repository.GetRunAsync(child.WorkspaceId, child.ParentFlowRunId, cancellationToken); + if (parent is null + || parent.Value.Status != FlowRunStatus.WaitingForChild + || parent.Value.Scope != child.Scope + || !parent.Value.Steps.Any(step => step.Status == FlowStepRunStatus.Running && step.ChildFlowRunId == child.Id)) + return; + + StoredFlowRun resumed; + try + { + resumed = await repository.UpdateRunAsync(parent.Value with + { + Status = FlowRunStatus.Pending, + ExecutionLeaseId = null, + ExecutionLeaseExpiresAt = null + }, parent.ETag, cancellationToken); + } + catch (FlowConcurrencyException) + { + return; + } + try + { + await EmitAsync(resumed.Value.WorkspaceId, resumed.Value.Id, FlowRunEventType.FlowRunResumedFromChild, + resumed.Value.Steps.Single(step => step.ChildFlowRunId == child.Id).StepName, + JsonSerializer.SerializeToElement(new { childFlowRunId = child.Id, childStatus = child.Status }), cancellationToken); + } + finally + { + await queue.EnqueueAsync(new(resumed.Value.Id, resumed.Value.Scope), cancellationToken); + } + } + + private async Task> ListActiveDescendantsAsync( + FlowRun parent, + CancellationToken cancellationToken) + { + const int pageSize = 200; + var rootRunId = parent.RootFlowRunId ?? parent.Id; + var all = new List(); + for (var skip = 0; ; skip += pageSize) + { + var keys = await repository.ListRunKeysAsync(skip, pageSize, cancellationToken); + foreach (var key in keys.Where(key => key.WorkspaceId == parent.WorkspaceId)) + { + var run = await repository.GetRunAsync(parent.WorkspaceId, key.RunId, cancellationToken); + if (run is not null && run.Value.Scope == parent.Scope && run.Value.RootFlowRunId == rootRunId) all.Add(run); + } + if (keys.Count < pageSize) break; + } + + var result = new List(); + var parents = new Queue(); + parents.Enqueue(parent.Id); + while (parents.TryDequeue(out var parentId)) + { + foreach (var child in all.Where(run => run.Value.ParentFlowRunId == parentId)) + { + parents.Enqueue(child.Value.Id); + if (!child.Value.Status.IsTerminal()) result.Add(child); + } + } + return result.OrderBy(run => run.Value.NestingDepth).ToArray(); + } +} diff --git a/src/Agentstration.Flow.Application/FlowRunService.Execution.cs b/src/Agentstration.Flow.Application/FlowRunService.Execution.cs index 4e025fcd..298e20c4 100644 --- a/src/Agentstration.Flow.Application/FlowRunService.Execution.cs +++ b/src/Agentstration.Flow.Application/FlowRunService.Execution.cs @@ -143,7 +143,7 @@ private static IReadOnlyList CreateSteps(FlowVersion version, JsonE return steps; } - private static void ValidateInput(JsonElement? schema, JsonElement input) + public static void ValidateInput(JsonElement? schema, JsonElement input) { if (schema is null || schema.Value.ValueKind != JsonValueKind.Object) return; if (schema.Value.TryGetProperty("type", out var rootType) && rootType.GetString() == "object" && input.ValueKind != JsonValueKind.Object) @@ -297,7 +297,18 @@ await EmitAsync(workspaceId, runId, eventType, private async Task EmitAsync(WorkspaceId workspaceId, string runId, FlowRunEventType type, string? stepId, JsonElement? payload, CancellationToken token) { var runEvent = await repository.AppendRunEventAsync(new FlowRunEvent(workspaceId, runId, 0, type, stepId, payload?.Clone(), timeProvider.GetUtcNow()), token); - await eventSink.PublishAsync(runEvent, token); + try + { + await eventSink.PublishAsync(runEvent, token); + } + finally + { + if (type is FlowRunEventType.FlowRunCompleted or FlowRunEventType.FlowRunFailed or FlowRunEventType.FlowRunCancelled or FlowRunEventType.FlowRunTimedOut) + { + var terminal = await repository.GetRunAsync(workspaceId, runId, token); + if (terminal is not null) await ResumeParentAfterChildAsync(terminal.Value, token); + } + } } } diff --git a/src/Agentstration.Flow.Application/FlowRunService.Graph.cs b/src/Agentstration.Flow.Application/FlowRunService.Graph.cs index 23ba73d8..697ea4e4 100644 --- a/src/Agentstration.Flow.Application/FlowRunService.Graph.cs +++ b/src/Agentstration.Flow.Application/FlowRunService.Graph.cs @@ -13,16 +13,25 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st { var stored = initial; var graph = stored.Value.DefinitionSnapshot.Graph!; - var outputs = new Dictionary(StringComparer.Ordinal); - var currentName = graph.EntryStep; - var executed = new HashSet(StringComparer.Ordinal); + var outputs = stored.Value.Steps + .Where(step => step.Status is FlowStepRunStatus.Succeeded or FlowStepRunStatus.Failed) + .ToDictionary(step => step.StepName, step => step.Output?.Clone(), StringComparer.Ordinal); + var resumedChildStep = stored.Value.Steps.SingleOrDefault(step => + step.Status == FlowStepRunStatus.Running && step.ChildFlowRunId is not null); + var currentName = resumedChildStep?.StepName ?? graph.EntryStep; + var executed = stored.Value.Steps + .Where(step => step.Status is FlowStepRunStatus.Succeeded or FlowStepRunStatus.Failed) + .Select(step => step.StepName) + .ToHashSet(StringComparer.Ordinal); JsonElement? finalOutput = null; - for (var count = 0; count < graph.Steps.Count; count++) + for (var count = executed.Count; count < graph.Steps.Count; count++) { runToken.ThrowIfCancellationRequested(); var step = graph.Steps.Single(item => item.Name == currentName); if (!executed.Add(step.Name)) throw new FlowValidationException("flow_cycle_detected", $"Step '{step.Name}' was reached more than once."); - stored = await StartStepAsync(stored, step.Name, runToken); + if (resumedChildStep?.StepName != step.Name) + stored = await StartStepAsync(stored, step.Name, runToken); + resumedChildStep = null; var context = new FlowExecutionContext(stored.Value.Input, outputs); JsonElement? output; string eventName; @@ -60,6 +69,69 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st ? await EvaluateExpressionAsync(transform.Expression!, context, runToken) : transform.Mapping is null ? JsonSerializer.SerializeToElement(new { }) : await ResolveJsonAsync(transform.Mapping.Value, context, runToken); eventName = "completed"; break; + case ToolFlowStepDefinition tool: + var arguments = tool.ArgumentsMapping is null + ? JsonSerializer.SerializeToElement(new { }) + : await ResolveJsonAsync(tool.ArgumentsMapping.Value, context, runToken); + try + { + output = await toolExecutor.ExecuteAsync(new FlowToolExecutionRequest( + stored.Value.Scope, + stored.Value.Id, + stored.Value.FlowId, + step.Name, + stored.Value.Steps.Single(item => item.StepName == step.Name).Attempt, + stored.Value.CorrelationId!, + tool.Tool, + arguments), runToken); + eventName = "completed"; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + output = JsonSerializer.SerializeToElement(new { error = exception.Message }); + eventName = "failed"; + stepError = exception is FlowValidationException validation + ? new FlowRunError(validation.Code, "The Tool step failed.", validation.Message) + : new FlowRunError("tool_step_failed", "The Tool step failed.", exception.Message); + } + break; + case FlowCallStepDefinition flowCall: + var callInput = flowCall.InputMapping is null + ? stored.Value.Input.Clone() + : await ResolveJsonAsync(flowCall.InputMapping.Value, context, runToken); + var stepRun = stored.Value.Steps.Single(item => item.StepName == step.Name); + var childRunId = stepRun.ChildFlowRunId ?? ChildFlowRunId(stored.Value, step.Name, stepRun.Attempt); + var child = await repository.GetRunAsync(stored.Value.WorkspaceId, childRunId, runToken); + if (child is null) + { + stored = await SuspendForChildAsync(stored, step.Name, childRunId, runToken); + await EnsureChildFlowRunAsync(stored.Value, flowCall, callInput, childRunId, runToken); + return; + } + ValidateChildIdentity(stored.Value, flowCall, child.Value, childRunId); + if (!child.Value.Status.IsTerminal()) + { + await SuspendForChildAsync(stored, step.Name, childRunId, runToken); + return; + } + output = child.Value.Output?.Clone() ?? JsonSerializer.SerializeToElement(null); + eventName = child.Value.Status switch + { + FlowRunStatus.Succeeded => "completed", + FlowRunStatus.Failed => "failed", + FlowRunStatus.TimedOut => "timedOut", + FlowRunStatus.Cancelled => "cancelled", + _ => throw new InvalidOperationException($"Child Flow Run '{childRunId}' has unsupported terminal status '{child.Value.Status}'.") + }; + if (child.Value.Status != FlowRunStatus.Succeeded) + { + var childError = child.Value.Error; + stepError = new FlowRunError( + childError?.Code ?? $"child_flow_{eventName}", + $"Child Flow Run '{childRunId}' {eventName}.", + childError?.Details ?? childError?.Message); + } + break; case OutputFlowStepDefinition terminal: output = terminal.OutputMapping is null ? outputs.Values.LastOrDefault(value => value is not null)?.Clone() : await ResolveJsonAsync(terminal.OutputMapping.Value, context, runToken); finalOutput = output; eventName = "completed"; break; @@ -187,6 +259,6 @@ private async Task ResolveJsonAsync(JsonElement value, FlowExecutio return value.Clone(); } - private static JsonElement? StepDeclaredInput(FlowStepDefinition step) => step switch { AgentFlowStepDefinition agent => agent.InputMapping?.Clone(), TransformFlowStepDefinition transform => transform.Mapping?.Clone(), OutputFlowStepDefinition output => output.OutputMapping?.Clone(), _ => null }; + private static JsonElement? StepDeclaredInput(FlowStepDefinition step) => step switch { AgentFlowStepDefinition agent => agent.InputMapping?.Clone(), FlowCallStepDefinition flow => flow.InputMapping?.Clone(), ToolFlowStepDefinition tool => tool.ArgumentsMapping?.Clone(), TransformFlowStepDefinition transform => transform.Mapping?.Clone(), OutputFlowStepDefinition output => output.OutputMapping?.Clone(), _ => null }; } diff --git a/src/Agentstration.Flow.Application/FlowRunService.Lifecycle.cs b/src/Agentstration.Flow.Application/FlowRunService.Lifecycle.cs index 082216dd..89ad0d9f 100644 --- a/src/Agentstration.Flow.Application/FlowRunService.Lifecycle.cs +++ b/src/Agentstration.Flow.Application/FlowRunService.Lifecycle.cs @@ -36,6 +36,28 @@ public async Task InitializeAsync(CancellationToken cancellationToken) await queue.EnqueueAsync(new(run.Value.Id, run.Value.Scope), cancellationToken); continue; } + if (run.Value.Status == FlowRunStatus.WaitingForChild) + { + var childRunId = run.Value.Steps.SingleOrDefault(step => + step.Status == FlowStepRunStatus.Running && step.ChildFlowRunId is not null)?.ChildFlowRunId; + var child = childRunId is null + ? null + : await repository.GetRunAsync(key.WorkspaceId, childRunId, cancellationToken); + if (child is null) + { + try + { + var pending = await repository.UpdateRunAsync(run.Value with { Status = FlowRunStatus.Pending }, run.ETag, cancellationToken); + await queue.EnqueueAsync(new(pending.Value.Id, pending.Value.Scope), cancellationToken); + } + catch (FlowConcurrencyException) { } + } + else if (child.Value.Status.IsTerminal()) + { + await ResumeParentAfterChildAsync(child.Value, cancellationToken); + } + continue; + } if (run.Value.Status == FlowRunStatus.Pending || run.Value.Status == FlowRunStatus.Running && run.Value.ExecutionLeaseExpiresAt <= now) await queue.EnqueueAsync(new(run.Value.Id, run.Value.Scope), cancellationToken); @@ -240,7 +262,16 @@ public async Task CancelAsync(string runId, FlowRunScope scope, C { var stored = await RequiredAsync(runId, scope, cancellationToken); if (stored.Value.Status.IsTerminal()) return stored; - cancellations.Cancel(new FlowRunKey(scope.WorkspaceId, runId)); + var cancelled = await CancelSingleAsync(stored, cancellationToken); + foreach (var descendant in await ListActiveDescendantsAsync(cancelled.Value, cancellationToken)) + await CancelSingleAsync(descendant, cancellationToken); + return cancelled; + } + + private async Task CancelSingleAsync(StoredFlowRun stored, CancellationToken cancellationToken) + { + if (stored.Value.Status.IsTerminal()) return stored; + cancellations.Cancel(new FlowRunKey(stored.Value.WorkspaceId, stored.Value.Id)); var now = timeProvider.GetUtcNow(); var steps = stored.Value.Steps.Select(step => step.Status is FlowStepRunStatus.NotStarted or FlowStepRunStatus.Running ? step with { Status = FlowStepRunStatus.Cancelled, CompletedAt = now } @@ -254,9 +285,9 @@ public async Task CancelAsync(string runId, FlowRunScope scope, C ExecutionLeaseId = null, ExecutionLeaseExpiresAt = null }, stored.ETag, cancellationToken); - foreach (var input in await repository.ListInputRequestsAsync(scope.WorkspaceId, runId, InputRequestStatus.Pending, cancellationToken)) + foreach (var input in await repository.ListInputRequestsAsync(stored.Value.WorkspaceId, stored.Value.Id, InputRequestStatus.Pending, cancellationToken)) await repository.UpdateInputRequestAsync(input.Value with { Status = InputRequestStatus.Cancelled }, input.ETag, cancellationToken); - await EmitAsync(scope.WorkspaceId, runId, FlowRunEventType.FlowRunCancelled, null, null, cancellationToken); + await EmitAsync(stored.Value.WorkspaceId, stored.Value.Id, FlowRunEventType.FlowRunCancelled, null, null, cancellationToken); return cancelled; } diff --git a/src/Agentstration.Flow.Application/FlowRunService.Roots.cs b/src/Agentstration.Flow.Application/FlowRunService.Roots.cs new file mode 100644 index 00000000..9eda4b00 --- /dev/null +++ b/src/Agentstration.Flow.Application/FlowRunService.Roots.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using Agentstration.Flow.Storage.Abstractions; +using Agentstration.Resources; + +namespace Agentstration.Flow.Application; + +public sealed record EnsureRootFlowRunCommand( + string RunId, + FlowId FlowId, + string? Version, + string? DeploymentResourceId, + FlowRunTrigger Trigger, + FlowInvocationOrigin Origin, + string CallerId, + string? CausationId, + string? IdempotencyKey, + string CorrelationId, + JsonElement Input, + string WorkItemResourceId, + bool ResolvedFromActiveReference, + string? ParentFlowRunId, + string? InteractionId, + string? WorkTaskId, + string? TriggerMessageId, + FlowRunScope Scope); + +public sealed partial class FlowRunService +{ + public async Task EnsureRootAsync( + EnsureRootFlowRunCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + var existing = await repository.GetRunAsync(command.Scope.WorkspaceId, command.RunId, cancellationToken); + if (existing is not null) + { + ValidateRootIdentity(existing.Value, command); + return existing; + } + + var resolved = await ResolveVersionAsync(command.Scope.WorkspaceId, command.FlowId, command.Version, cancellationToken); + ValidateInput(resolved.Graph?.InputSchema, command.Input); + var now = timeProvider.GetUtcNow(); + var run = new FlowRun + { + WorkspaceId = command.Scope.WorkspaceId, + Id = command.RunId, + FlowId = command.FlowId, + FlowVersion = resolved.Version, + DeploymentResourceId = string.IsNullOrWhiteSpace(command.DeploymentResourceId) ? "local" : command.DeploymentResourceId, + Trigger = command.Trigger, + StartedBy = command.CallerId, + InvocationOrigin = command.Origin, + CallerId = command.CallerId, + CausationId = command.CausationId, + IdempotencyKey = command.IdempotencyKey, + CorrelationId = command.CorrelationId, + WorkItemResourceId = command.WorkItemResourceId, + ResolvedFromActiveReference = command.ResolvedFromActiveReference, + ParentFlowRunId = command.ParentFlowRunId, + InteractionId = command.InteractionId, + WorkTaskId = command.WorkTaskId, + TriggerMessageId = command.TriggerMessageId, + Scope = command.Scope, + Input = command.Input.Clone(), + CreatedAt = now, + DefinitionSnapshot = resolved, + DefinitionHash = resolved.DefinitionHash, + DefinitionState = FlowDefinitionState.Published, + DefinitionSnapshotId = $"{command.FlowId.Value}:{resolved.Version}:{resolved.DefinitionHash ?? "legacy"}", + Steps = CreateSteps(resolved, command.Input) + }; + + StoredFlowRun stored; + try + { + stored = await repository.CreateRunAsync(run, cancellationToken); + } + catch (FlowConcurrencyException) + { + var recovered = await repository.GetRunAsync(command.Scope.WorkspaceId, command.RunId, cancellationToken); + if (recovered is null) throw; + stored = recovered; + ValidateRootIdentity(stored.Value, command); + return stored; + } + + RunsCreated.Add(1, new KeyValuePair("flow.definition.state", run.DefinitionState.ToString())); + await EmitAsync(run.WorkspaceId, run.Id, FlowRunEventType.FlowRunCreated, null, + JsonSerializer.SerializeToElement(new { run.Status, run.DefinitionState, run.InvocationOrigin, run.CausationId }), cancellationToken); + await queue.EnqueueAsync(new(run.Id, run.Scope), cancellationToken); + return stored; + } + + private static void ValidateRootIdentity(FlowRun run, EnsureRootFlowRunCommand command) + { + if (run.Scope != command.Scope + || run.FlowId != command.FlowId + || run.RootFlowRunId is not null + || run.NestingDepth != 0 + || run.Trigger != command.Trigger + || !string.Equals(run.WorkItemResourceId, command.WorkItemResourceId, StringComparison.Ordinal) + || run.ResolvedFromActiveReference != command.ResolvedFromActiveReference + || !string.Equals(run.ParentFlowRunId, command.ParentFlowRunId, StringComparison.Ordinal) + || run.InvocationOrigin != command.Origin + || !string.Equals(run.CallerId, command.CallerId, StringComparison.Ordinal) + || !string.Equals(run.CausationId, command.CausationId, StringComparison.Ordinal) + || !string.Equals(run.IdempotencyKey, command.IdempotencyKey, StringComparison.Ordinal) + || !string.Equals(run.CorrelationId, command.CorrelationId, StringComparison.Ordinal) + || !string.Equals(run.InteractionId, command.InteractionId, StringComparison.Ordinal) + || !string.Equals(run.WorkTaskId, command.WorkTaskId, StringComparison.Ordinal) + || !string.Equals(run.TriggerMessageId, command.TriggerMessageId, StringComparison.Ordinal) + || !JsonElement.DeepEquals(run.Input, command.Input)) + throw new FlowValidationException("flow_root_identity_conflict", "The root Flow Run identity is already bound to another invocation."); + + if (!string.IsNullOrWhiteSpace(command.Version) + && !string.Equals(run.FlowVersion, command.Version, StringComparison.Ordinal)) + throw new FlowValidationException("flow_root_version_conflict", "The root Flow Run is already bound to another Flow version."); + } +} diff --git a/src/Agentstration.Flow.Application/FlowRunService.cs b/src/Agentstration.Flow.Application/FlowRunService.cs index c4431ccf..0bf42458 100644 --- a/src/Agentstration.Flow.Application/FlowRunService.cs +++ b/src/Agentstration.Flow.Application/FlowRunService.cs @@ -21,6 +21,30 @@ public interface IFlowAgentExecutor { Task ExecuteAsync(FlowTargetReference target, JsonElement input, string correlationId, CancellationToken cancellationToken); } + +public sealed record FlowToolExecutionRequest( + FlowRunScope Scope, + string RunId, + FlowId OwnerFlowId, + string StepName, + int Attempt, + string CorrelationId, + FlowToolReference Tool, + JsonElement Arguments); + +public interface IFlowToolExecutor +{ + Task ExecuteAsync(FlowToolExecutionRequest request, CancellationToken cancellationToken); +} + +public sealed class UnsupportedFlowToolExecutor : IFlowToolExecutor +{ + public static UnsupportedFlowToolExecutor Instance { get; } = new(); + private UnsupportedFlowToolExecutor() { } + + public Task ExecuteAsync(FlowToolExecutionRequest request, CancellationToken cancellationToken) => + Task.FromException(new FlowValidationException("flow_tool_executor_unavailable", "No governed Tool executor is configured for Flow Runs.")); +} public interface IFlowRunQueue { ValueTask EnqueueAsync(FlowRunQueueItem item, CancellationToken cancellationToken); @@ -71,6 +95,8 @@ public sealed record FlowRunExecutionOptions public TimeSpan OrchestrationTimeout { get; init; } = TimeSpan.FromMinutes(10); public TimeSpan InputRequestTimeout { get; init; } = TimeSpan.FromDays(7); public TimeSpan ExecutionLeaseDuration { get; init; } = TimeSpan.FromMinutes(15); + public int MaximumNestingDepth { get; init; } = 16; + public int MaximumDescendantRuns { get; init; } = 256; } public sealed record FlowRevisionUsage( @@ -102,7 +128,8 @@ public sealed partial class FlowRunService( IFlowRunExecutionScope executionScope, TimeProvider timeProvider, FlowRunExecutionOptions? executionOptions = null, - IFlowInputRequestSink? inputRequestSink = null) + IFlowInputRequestSink? inputRequestSink = null, + IFlowToolExecutor? configuredToolExecutor = null) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly FlowRunExecutionOptions executionOptions = executionOptions is null @@ -110,8 +137,11 @@ public sealed partial class FlowRunService( : executionOptions.OrchestrationTimeout > TimeSpan.Zero && executionOptions.InputRequestTimeout > TimeSpan.Zero && executionOptions.ExecutionLeaseDuration > executionOptions.OrchestrationTimeout + && executionOptions.MaximumNestingDepth is >= 1 and <= 64 + && executionOptions.MaximumDescendantRuns is >= 1 and <= 4096 ? executionOptions : throw new ArgumentOutOfRangeException(nameof(executionOptions), "Execution and input timeouts must be positive, and the execution lease must exceed the orchestration timeout."); + private readonly IFlowToolExecutor toolExecutor = configuredToolExecutor ?? UnsupportedFlowToolExecutor.Instance; public static readonly ActivitySource ActivitySource = new("Agentstration.Flow"); public static readonly Meter Meter = new("Agentstration.Flow"); private static readonly Counter RunsCreated = Meter.CreateCounter("agentstration.flow.runs.created"); diff --git a/src/Agentstration.Flow.Application/FlowService.cs b/src/Agentstration.Flow.Application/FlowService.cs index 40852099..dad4a5b3 100644 --- a/src/Agentstration.Flow.Application/FlowService.cs +++ b/src/Agentstration.Flow.Application/FlowService.cs @@ -12,7 +12,16 @@ public interface IFlowDeletionGuard Task ValidateDeleteAsync(WorkspaceId workspaceId, FlowId flowId, CancellationToken cancellationToken); } -public sealed class FlowService(IFlowRepository repository, TimeProvider timeProvider, IEnumerable deletionGuards) +public interface IFlowVersionActivationGuard +{ + Task ValidateActivationAsync(WorkspaceId workspaceId, FlowVersion version, CancellationToken cancellationToken); +} + +public sealed class FlowService( + IFlowRepository repository, + TimeProvider timeProvider, + IEnumerable deletionGuards, + IEnumerable? activationGuards = null) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -80,6 +89,8 @@ public async Task PublishVersionAsync(WorkspaceId workspaceId var published = new FlowVersion(workspaceId, id, version, stored.Value.Description, stored.Value.Definition, stored.Value.Metadata, timeProvider.GetUtcNow(), stored.Value.Graph, stored.Value.Graph is null ? null : FlowDefinitionHash.Compute(stored.Value.Graph), releaseNotes); FlowValidator.ValidateVersion(published); + if (activate) + foreach (var guard in activationGuards ?? []) await guard.ValidateActivationAsync(workspaceId, published, cancellationToken); var created = await repository.CreateVersionAsync(published, cancellationToken); if (activate) { diff --git a/src/Agentstration.Flow.Application/FlowValidationService.cs b/src/Agentstration.Flow.Application/FlowValidationService.cs index 85609525..2ed072c7 100644 --- a/src/Agentstration.Flow.Application/FlowValidationService.cs +++ b/src/Agentstration.Flow.Application/FlowValidationService.cs @@ -3,7 +3,27 @@ namespace Agentstration.Flow.Application; -public sealed record FlowValidationContext(bool ResolveResources = true); +using Agentstration.Resources; + +public sealed record FlowValidationContext( + bool ResolveResources = true, + WorkspaceId? WorkspaceId = null, + FlowId? OwnerFlowId = null); + +public sealed record ResolvedFlowCall( + FlowId FlowId, + string Version, + JsonElement? InputSchema, + JsonElement? OutputSchema); + +public sealed record ResolvedFlowTool( + string ResourceId, + ResourceNamespace Namespace, + JsonElement InputSchema, + JsonElement? OutputSchema, + bool Enabled, + bool Available, + bool RequiresApproval); public interface IFlowDefinitionValidator { @@ -13,6 +33,23 @@ public interface IFlowDefinitionValidator public interface IFlowResourceReferenceResolver { Task ExistsAsync(string resourceId, CancellationToken cancellationToken); + Task ResolveFlowAsync( + WorkspaceId workspaceId, + ResourceNamespace ownerNamespace, + FlowCallReference reference, + CancellationToken cancellationToken) => Task.FromResult(null); + + Task CreatesFlowCycleAsync( + WorkspaceId workspaceId, + FlowId ownerFlowId, + ResolvedFlowCall target, + CancellationToken cancellationToken) => Task.FromResult(false); + + Task ResolveToolAsync( + WorkspaceId workspaceId, + ResourceNamespace ownerNamespace, + FlowToolReference reference, + CancellationToken cancellationToken) => Task.FromResult(null); } public sealed partial class FlowGraphValidator(IFlowResourceReferenceResolver resources) : IFlowDefinitionValidator @@ -87,12 +124,129 @@ private async Task ValidateStepAsync(FlowStepDefinition step, FlowValidationCont if (transform.Mode.Equals("Expression", StringComparison.OrdinalIgnoreCase)) ValidateExpression(transform.Expression, issues, step.Name, property: "expression"); else ValidateJsonExpressions(transform.Mapping, issues, step.Name, "mapping"); break; + case FlowCallStepDefinition flowCall: + await ValidateFlowCallAsync(flowCall, context, issues, token); + break; + case ToolFlowStepDefinition tool: + await ValidateToolAsync(tool, context, issues, token); + break; case OutputFlowStepDefinition output: ValidateJsonExpressions(output.OutputMapping, issues, step.Name, "outputMapping"); break; } } + private async Task ValidateToolAsync( + ToolFlowStepDefinition step, + FlowValidationContext context, + List issues, + CancellationToken token) + { + if (string.IsNullOrWhiteSpace(step.Tool.ResourceId) || step.Tool.ResourceId.Contains('/', StringComparison.Ordinal)) + { + issues.Add(Error("tool_reference_invalid", "Tool references must use a logical Tool name.", step.Name, property: "tool.resourceId")); + return; + } + + ValidateJsonExpressions(step.ArgumentsMapping, issues, step.Name, "argumentsMapping"); + if (!context.ResolveResources || context.WorkspaceId is null || context.OwnerFlowId is null) return; + + var target = await resources.ResolveToolAsync(context.WorkspaceId.Value, context.OwnerFlowId.Value.Namespace, step.Tool, token); + if (target is null) + { + issues.Add(Error("tool_resource_not_found", $"Tool '{step.Tool.ResourceId}' was not found.", step.Name, property: "tool")); + return; + } + if (!target.Enabled) + issues.Add(Error("tool_disabled", $"Tool '{step.Tool.ResourceId}' is disabled.", step.Name, property: "tool")); + if (!target.Available) + issues.Add(Error("tool_unavailable", $"Tool '{step.Tool.ResourceId}' is unavailable from its provider.", step.Name, property: "tool")); + if (!ValidContractSchema(target.InputSchema)) + { + issues.Add(Error("tool_input_schema_invalid", "The selected Tool has an invalid or unsupported input schema.", step.Name, property: "tool.inputSchema")); + return; + } + ValidateMappingAgainstSchema(step.Name, "argumentsMapping", step.ArgumentsMapping, target.InputSchema, "tool_argument", issues); + } + + private async Task ValidateFlowCallAsync( + FlowCallStepDefinition step, + FlowValidationContext context, + List issues, + CancellationToken token) + { + var reference = step.Flow; + if (string.IsNullOrWhiteSpace(reference.ResourceId) || reference.ResourceId.Contains('/', StringComparison.Ordinal)) + { + issues.Add(Error("flow_reference_invalid", "Flow references must use a logical Flow name.", step.Name, property: "flow.resourceId")); + return; + } + + if (reference.VersionStrategy == FlowCallVersionStrategy.Exact && string.IsNullOrWhiteSpace(reference.Version)) + issues.Add(Error("flow_version_required", "An exact Flow reference requires a version.", step.Name, property: "flow.version")); + if (reference.VersionStrategy == FlowCallVersionStrategy.Active && reference.Version is not null) + issues.Add(Error("flow_active_version_must_be_implicit", "An active Flow reference cannot declare an exact version.", step.Name, property: "flow.version")); + + ValidateJsonExpressions(step.InputMapping, issues, step.Name, "inputMapping"); + if (!context.ResolveResources || context.WorkspaceId is null || context.OwnerFlowId is null) return; + + var target = await resources.ResolveFlowAsync(context.WorkspaceId.Value, context.OwnerFlowId.Value.Namespace, reference, token); + if (target is null) + { + issues.Add(Error("flow_resource_not_found", $"Published Flow '{reference.ResourceId}' was not found for the selected version strategy.", step.Name, property: "flow")); + return; + } + + if (!ValidContractSchema(target.InputSchema)) + issues.Add(Error("flow_input_schema_invalid", "The selected Flow has an invalid or unsupported input schema.", step.Name, property: "flow.inputSchema")); + if (!ValidContractSchema(target.OutputSchema)) + issues.Add(Error("flow_output_schema_invalid", "The selected Flow has an invalid or unsupported output schema.", step.Name, property: "flow.outputSchema")); + ValidateMappingAgainstSchema(step, target.InputSchema, issues); + if (await resources.CreatesFlowCycleAsync(context.WorkspaceId.Value, context.OwnerFlowId.Value, target, token)) + issues.Add(Error("flow_dependency_cycle", $"Calling Flow '{target.FlowId}' would create a direct or indirect dependency cycle.", step.Name, property: "flow")); + } + + private static void ValidateMappingAgainstSchema(FlowCallStepDefinition step, JsonElement? schema, List issues) + { + if (schema is not { ValueKind: JsonValueKind.Object } value) return; + ValidateMappingAgainstSchema(step.Name, "inputMapping", step.InputMapping, value, "flow_input_mapping", issues); + } + + private static void ValidateMappingAgainstSchema( + string stepName, + string propertyPath, + JsonElement? candidate, + JsonElement schema, + string codePrefix, + List issues) + { + if (candidate is not { ValueKind: JsonValueKind.Object } mapping) + { + issues.Add(Error($"{codePrefix}_object_required", "The selected resource requires an object mapping.", stepName, property: propertyPath)); + return; + } + + var allowed = schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object + ? properties.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal) + : []; + foreach (var property in mapping.EnumerateObject().Where(property => !allowed.Contains(property.Name))) + issues.Add(Error($"{codePrefix}_unknown", $"Mapping property '{property.Name}' is not declared by the selected resource.", stepName, property: $"{propertyPath}.{property.Name}")); + + if (!schema.TryGetProperty("required", out var required) || required.ValueKind != JsonValueKind.Array) return; + var mapped = mapping.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal); + foreach (var property in required.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String).Select(item => item.GetString()!).Where(property => !mapped.Contains(property))) + issues.Add(Error($"{codePrefix}_required", $"Required property '{property}' is not mapped.", stepName, property: $"{propertyPath}.{property}")); + } + + private static bool ValidContractSchema(JsonElement? schema) + { + if (schema is null) return true; + if (schema.Value.ValueKind != JsonValueKind.Object) return false; + if (schema.Value.TryGetProperty("type", out var type) + && (type.ValueKind != JsonValueKind.String || !string.Equals(type.GetString(), "object", StringComparison.Ordinal))) return false; + return !schema.Value.TryGetProperty("properties", out var properties) || properties.ValueKind == JsonValueKind.Object; + } + private async Task ValidateResourceAsync(string resourceId, string step, string property, FlowValidationContext context, List issues, CancellationToken token) { if (resourceId.StartsWith("${", StringComparison.Ordinal)) diff --git a/src/Agentstration.Flow.Contracts/FlowContracts.cs b/src/Agentstration.Flow.Contracts/FlowContracts.cs index 48ad0336..93f6c8eb 100644 --- a/src/Agentstration.Flow.Contracts/FlowContracts.cs +++ b/src/Agentstration.Flow.Contracts/FlowContracts.cs @@ -31,6 +31,11 @@ public sealed record FlowVersionResponse(string FlowId, string Version, string? public sealed record FlowPageResponse(IReadOnlyList Value, string? NextLink); public sealed record CreateFlowRunRequest(JsonElement Input, string? Version = null, string? DeploymentResourceId = "local", FlowRunTrigger Trigger = FlowRunTrigger.Manual, string? CorrelationId = null, IReadOnlyDictionary? Options = null); public sealed record FlowRunPageResponse(IReadOnlyList Value, string? NextLink); +public sealed record FlowRunCausalityPageResponse( + FlowRunCausalityOrigin Origin, + IReadOnlyList Value, + int TotalCount, + string? NextLink); public sealed record SubmitInputResponseRequest(JsonElement Value); public sealed record CreateFlowDraftRequest(string Name, string DisplayName, string? Description = null, IReadOnlyDictionary? Tags = null, string Template = "AgentRouting"); public sealed record UpdateFlowDraftRequest(string DisplayName, string? Description, IReadOnlyDictionary? Tags, FlowGraphDefinition Definition, string UpdatedBy = "local-user"); diff --git a/src/Agentstration.Flow.Storage.PostgreSql/PostgreSqlFlowRepository.cs b/src/Agentstration.Flow.Storage.PostgreSql/PostgreSqlFlowRepository.cs index a4e850df..e4a9fdc8 100644 --- a/src/Agentstration.Flow.Storage.PostgreSql/PostgreSqlFlowRepository.cs +++ b/src/Agentstration.Flow.Storage.PostgreSql/PostgreSqlFlowRepository.cs @@ -241,7 +241,7 @@ public async Task> ListRecoverableRunsAsync(int skip, await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var documents = await context.Documents.AsNoTracking().Where(value => value.Kind == RunKind) .OrderBy(value => value.UpdatedAt).Skip(skip).Take(Math.Min(take, 1000)).ToArrayAsync(cancellationToken); - return documents.Select(ToRun).Where(value => value.Value.Status is FlowRunStatus.Pending or FlowRunStatus.Running or FlowRunStatus.WaitingForInput) + return documents.Select(ToRun).Where(value => value.Value.Status is FlowRunStatus.Pending or FlowRunStatus.Running or FlowRunStatus.WaitingForInput or FlowRunStatus.WaitingForChild) .Select(value => new FlowRunKey(value.Value.WorkspaceId, value.Value.Id)).ToArray(); } diff --git a/src/Agentstration.Flow.Storage.Sqlite/SqliteFlowRepository.cs b/src/Agentstration.Flow.Storage.Sqlite/SqliteFlowRepository.cs index 1a6b9cb4..9ef14818 100644 --- a/src/Agentstration.Flow.Storage.Sqlite/SqliteFlowRepository.cs +++ b/src/Agentstration.Flow.Storage.Sqlite/SqliteFlowRepository.cs @@ -221,7 +221,7 @@ public async Task> ListRecoverableRunsAsync(int skip, await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var documents = await context.Documents.AsNoTracking().Where(value => value.Kind == RunKind) .OrderBy(value => value.UpdatedAt).Skip(skip).Take(Math.Min(take, 1000)).ToArrayAsync(cancellationToken); - return documents.Select(ToRun).Where(value => value.Value.Status is FlowRunStatus.Pending or FlowRunStatus.Running or FlowRunStatus.WaitingForInput) + return documents.Select(ToRun).Where(value => value.Value.Status is FlowRunStatus.Pending or FlowRunStatus.Running or FlowRunStatus.WaitingForInput or FlowRunStatus.WaitingForChild) .Select(value => new FlowRunKey(value.Value.WorkspaceId, value.Value.Id)).ToArray(); } diff --git a/src/Agentstration.Flow/FlowGraphModel.cs b/src/Agentstration.Flow/FlowGraphModel.cs index 3c4e0b6f..38a655c3 100644 --- a/src/Agentstration.Flow/FlowGraphModel.cs +++ b/src/Agentstration.Flow/FlowGraphModel.cs @@ -23,6 +23,8 @@ public sealed record FlowDesignerMetadata [JsonDerivedType(typeof(RouterFlowStepDefinition), "router")] [JsonDerivedType(typeof(ConditionFlowStepDefinition), "condition")] [JsonDerivedType(typeof(TransformFlowStepDefinition), "transform")] +[JsonDerivedType(typeof(FlowCallStepDefinition), "flow")] +[JsonDerivedType(typeof(ToolFlowStepDefinition), "tool")] [JsonDerivedType(typeof(OutputFlowStepDefinition), "output")] [JsonDerivedType(typeof(FailureFlowStepDefinition), "failure")] public abstract record FlowStepDefinition @@ -72,6 +74,43 @@ public sealed record TransformFlowStepDefinition : FlowStepDefinition public string? Expression { get; init; } } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FlowCallVersionStrategy +{ + [JsonStringEnumMemberName("active")] + Active, + [JsonStringEnumMemberName("exact")] + Exact +} + +public sealed record FlowCallReference( + string ResourceId, + FlowCallVersionStrategy VersionStrategy = FlowCallVersionStrategy.Active, + string? Version = null, + ResourceNamespace? Namespace = null) +{ + public FlowId Resolve(ResourceNamespace ownerNamespace) => new(ResourceId, Namespace ?? ownerNamespace); +} + +public sealed record FlowCallStepDefinition : FlowStepDefinition +{ + public required FlowCallReference Flow { get; init; } + public JsonElement? InputMapping { get; init; } +} + +public sealed record FlowToolReference( + string ResourceId, + ResourceNamespace? Namespace = null) +{ + public ResourceNamespace ResolveNamespace(ResourceNamespace ownerNamespace) => Namespace ?? ownerNamespace; +} + +public sealed record ToolFlowStepDefinition : FlowStepDefinition +{ + public required FlowToolReference Tool { get; init; } + public JsonElement? ArgumentsMapping { get; init; } +} + public sealed record OutputFlowStepDefinition : FlowStepDefinition { public JsonElement? OutputMapping { get; init; } @@ -136,6 +175,8 @@ public static class FlowStepDefinitionExtensions RouterFlowStepDefinition => "router", ConditionFlowStepDefinition => "condition", TransformFlowStepDefinition => "transform", + FlowCallStepDefinition => "flow", + ToolFlowStepDefinition => "tool", OutputFlowStepDefinition => "output", FailureFlowStepDefinition => "failure", _ => throw new ArgumentOutOfRangeException(nameof(step)) diff --git a/src/Agentstration.Flow/FlowModel.cs b/src/Agentstration.Flow/FlowModel.cs index e374d6ad..a442981e 100644 --- a/src/Agentstration.Flow/FlowModel.cs +++ b/src/Agentstration.Flow/FlowModel.cs @@ -20,8 +20,9 @@ public enum FlowRoutingStrategy { Deterministic, Capabilities, Semantic, Llm, Hy public enum FlowNodeKind { Input, Agent, Router, Condition, Transform, Output, Failure, Flow, Function, ExternalCall, HumanApproval, Custom } public enum FlowOrchestrationStrategy { Sequential, Concurrent, Handoff, GroupChat, Magentic } public enum FlowCompositionMode { Sequential, Concurrent, Custom } -public enum FlowRunStatus { Pending, Running, WaitingForInput, Succeeded, Failed, Cancelled, TimedOut } +public enum FlowRunStatus { Pending, Running, WaitingForInput, Succeeded, Failed, Cancelled, TimedOut, WaitingForChild } public enum FlowRunTrigger { Manual, Api, WorkItem, Flow, Schedule, Event } +public enum FlowInvocationOrigin { Entry, Trigger, Api, Mcp, Agent, Console } public enum FlowStepRunStatus { NotStarted, Running, Succeeded, Failed, Skipped, Cancelled } public enum FlowRunEventType { @@ -45,7 +46,10 @@ public enum FlowRunEventType ToolCallGovernanceEvaluated, ToolCallCompleted, ToolCallFailed, - ParticipantHandoff + ParticipantHandoff, + ChildFlowRunCreated, + FlowRunWaitingForChild, + FlowRunResumedFromChild } public enum InputRequestType { Text, Choice, Confirmation } diff --git a/src/Agentstration.Flow/FlowRunCausality.cs b/src/Agentstration.Flow/FlowRunCausality.cs new file mode 100644 index 00000000..b4401470 --- /dev/null +++ b/src/Agentstration.Flow/FlowRunCausality.cs @@ -0,0 +1,68 @@ +namespace Agentstration.Flow; + +public sealed record FlowRunCausalityPage( + FlowRunCausalityOrigin Origin, + IReadOnlyList Items, + int TotalCount, + bool HasMore); + +public sealed record FlowRunCausalityOrigin( + string RootFlowRunId, + FlowInvocationOrigin? InvocationOrigin, + FlowRunTrigger Trigger, + string? CallerId, + string? CausationId, + string? CorrelationId, + string? WorkItemResourceId); + +public sealed record FlowRunCausalityNode( + string FlowRunId, + FlowId FlowId, + string FlowVersion, + bool ResolvedFromActiveReference, + FlowDefinitionState DefinitionState, + FlowRunStatus Status, + string? ParentFlowRunId, + string? ParentStepName, + int NestingDepth, + DateTimeOffset CreatedAt, + DateTimeOffset? StartedAt, + DateTimeOffset? CompletedAt, + string? ErrorCode, + IReadOnlyList AgentExecutions, + IReadOnlyList ToolCalls); + +public sealed record FlowRunAgentExecution( + string StepName, + FlowStepRunStatus Status, + string AgentResourceId, + long? AgentVersion, + string? ModelProfileResourceId, + string? Provider, + DateTimeOffset? StartedAt, + DateTimeOffset? CompletedAt, + string? ErrorCode); + +public sealed record FlowRunToolCall( + string LogicalCallId, + string? StepName, + string? ToolId, + string? ToolNamespace, + string? ToolName, + string? ProviderId, + string? ProviderNamespace, + string? ExternalToolId, + string Status, + string? CorrelationId, + IReadOnlyList Attempts); + +public sealed record FlowRunToolAttempt( + string InvocationId, + int Attempt, + string Status, + DateTimeOffset StartedAt, + DateTimeOffset? CompletedAt, + double? DurationMilliseconds, + string? ErrorCode, + string? FailureKind, + int GovernanceEvaluationCount); diff --git a/src/Agentstration.Flow/FlowRuns.cs b/src/Agentstration.Flow/FlowRuns.cs index cb9446d9..92956b78 100644 --- a/src/Agentstration.Flow/FlowRuns.cs +++ b/src/Agentstration.Flow/FlowRuns.cs @@ -44,6 +44,7 @@ public sealed record FlowStepRun public IReadOnlyList Logs { get; init; } = []; public FlowStepRunUsage? Usage { get; init; } public FlowRunError? Error { get; init; } + public string? ChildFlowRunId { get; init; } } public sealed record FlowRun @@ -60,9 +61,16 @@ public sealed record FlowRun public FlowRunStatus Status { get; init; } = FlowRunStatus.Pending; public FlowRunTrigger Trigger { get; init; } public string? StartedBy { get; init; } + public FlowInvocationOrigin? InvocationOrigin { get; init; } + public string? CallerId { get; init; } + public string? CausationId { get; init; } + public string? IdempotencyKey { get; init; } public string? CorrelationId { get; init; } public string? WorkItemResourceId { get; init; } + public bool ResolvedFromActiveReference { get; init; } public string? ParentFlowRunId { get; init; } + public string? RootFlowRunId { get; init; } + public int NestingDepth { get; init; } public string? InteractionId { get; init; } public string? WorkTaskId { get; init; } public string? TriggerMessageId { get; init; } diff --git a/src/Agentstration.Infrastructure/DependencyInjection.cs b/src/Agentstration.Infrastructure/DependencyInjection.cs index 39d13027..27c0ae84 100644 --- a/src/Agentstration.Infrastructure/DependencyInjection.cs +++ b/src/Agentstration.Infrastructure/DependencyInjection.cs @@ -7,6 +7,7 @@ using Agentstration.Infrastructure.Bootstrap; using Agentstration.Infrastructure.Events; using Agentstration.Infrastructure.Flows; +using Agentstration.Infrastructure.Notifications; using Agentstration.Infrastructure.Packs; using Agentstration.Infrastructure.Runtime; using Agentstration.Infrastructure.Sources; @@ -217,6 +218,7 @@ public static IServiceCollection AddAgentstration( services.AddSingleton(); services.AddSingleton(services => services.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -274,6 +276,7 @@ public static IServiceCollection AddAgentstration( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); if (storageProvider == AgentstrationStorageProvider.PostgreSql) services.AddPostgreSqlWorkPlane(storageOptions.ConnectionString!); @@ -288,6 +291,13 @@ public static IServiceCollection AddAgentstration( services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => new Lazy>( + () => provider.GetServices())); services.AddSingleton(); services.AddSingleton(); if (storageProvider == AgentstrationStorageProvider.PostgreSql) @@ -298,6 +308,9 @@ public static IServiceCollection AddAgentstration( services.AddSqliteFlowStorage(flowConnectionString); } services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); @@ -309,6 +322,9 @@ public static IServiceCollection AddAgentstration( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.TryAddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -325,6 +341,9 @@ public static IServiceCollection AddAgentstration( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => new Lazy(provider.GetRequiredService)); return services; } } diff --git a/src/Agentstration.Infrastructure/Flows/FlowToolExecutionEventSink.cs b/src/Agentstration.Infrastructure/Flows/FlowToolExecutionEventSink.cs index 3b59b43b..eeeeab39 100644 --- a/src/Agentstration.Infrastructure/Flows/FlowToolExecutionEventSink.cs +++ b/src/Agentstration.Infrastructure/Flows/FlowToolExecutionEventSink.cs @@ -46,7 +46,7 @@ public async ValueTask PublishAsync( context.RunId, 0, EventType(executionEvent), - null, + context.FlowStepId, Payload(executionEvent), executionEvent.Timestamp), cancellationToken); await eventSink.PublishAsync(runEvent, cancellationToken); @@ -69,9 +69,12 @@ private JsonElement Payload(ToolExecutionLifecycleEvent executionEvent) context.ToolCallId, context.InvocationId, context.ToolId, + ToolNamespace = context.ToolNamespace?.Value, context.ToolName, ProviderId = context.ToolProviderId, + ProviderNamespace = context.ToolProviderNamespace?.Value, context.ExternalToolId, + context.FlowStepId, context.AgentId, context.AgentVersion, context.AgentGeneration, diff --git a/src/Agentstration.Infrastructure/Flows/LocalFlowExecution.cs b/src/Agentstration.Infrastructure/Flows/LocalFlowExecution.cs index eacda73d..0f0f57f9 100644 --- a/src/Agentstration.Infrastructure/Flows/LocalFlowExecution.cs +++ b/src/Agentstration.Infrastructure/Flows/LocalFlowExecution.cs @@ -4,10 +4,13 @@ using System.Threading.Channels; using Agentstration.Flow; using Agentstration.Flow.Application; +using Agentstration.Flow.Storage.Abstractions; using Agentstration.Management.Abstractions; using Agentstration.Management.Core; using Agentstration.Resources; +using Agentstration.Runtime.Abstractions; using Agentstration.Runtime.AgentFramework; +using Agentstration.Tools.Mcp; using Agentstration.Work; namespace Agentstration.Infrastructure.Flows; @@ -164,8 +167,185 @@ public async IAsyncEnumerable ExecuteAsync( } } -public sealed class ManagementFlowResourceReferenceResolver(IControlPlaneStore store) : IFlowResourceReferenceResolver +public sealed class ManagementFlowResourceReferenceResolver(IControlPlaneStore store, IFlowRepository flows) : IFlowResourceReferenceResolver { public async Task ExistsAsync(string resourceId, CancellationToken cancellationToken) => await store.GetAsync(new ResourceKey(ResourceKinds.Agent, resourceId), cancellationToken) is not null; + + public async Task ResolveFlowAsync( + WorkspaceId workspaceId, + ResourceNamespace ownerNamespace, + FlowCallReference reference, + CancellationToken cancellationToken) + { + var id = reference.Resolve(ownerNamespace); + var version = reference.Version; + if (reference.VersionStrategy == FlowCallVersionStrategy.Active) + { + var current = await flows.GetAsync(workspaceId, id, cancellationToken); + if (current is null || !current.Value.Enabled || current.Value.ActiveVersion is null) return null; + version = current.Value.ActiveVersion; + } + else if (string.IsNullOrWhiteSpace(version)) + { + return null; + } + + var published = await flows.GetVersionAsync(workspaceId, id, version, cancellationToken); + return published is null + ? null + : new ResolvedFlowCall(id, published.Value.Version, published.Value.Graph?.InputSchema, published.Value.Graph?.OutputSchema); + } + + public async Task ResolveToolAsync( + WorkspaceId workspaceId, + ResourceNamespace ownerNamespace, + FlowToolReference reference, + CancellationToken cancellationToken) + { + _ = workspaceId; + var toolNamespace = reference.ResolveNamespace(ownerNamespace); + var stored = await store.GetAsync( + new ResourceKey(ResourceKinds.Tool, reference.ResourceId, toolNamespace), + cancellationToken); + if (stored is null || stored.Value.Definition.Schema is null) return null; + var tool = stored.Value; + return new ResolvedFlowTool( + tool.Metadata.Name, + tool.Metadata.Namespace, + tool.Definition.Schema.Input.Clone(), + tool.Definition.Schema.Output?.Clone(), + tool.Definition.Enabled, + tool.Definition.Discovery?.Available == true, + tool.Definition.RequiresApproval); + } + + public async Task CreatesFlowCycleAsync( + WorkspaceId workspaceId, + FlowId ownerFlowId, + ResolvedFlowCall target, + CancellationToken cancellationToken) + { + var pending = new Queue<(FlowId Id, string Version)>(); + var visited = new HashSet<(FlowId Id, string Version)>(); + pending.Enqueue((target.FlowId, target.Version)); + while (pending.TryDequeue(out var current)) + { + if (current.Id == ownerFlowId) return true; + if (!visited.Add(current)) continue; + var published = await flows.GetVersionAsync(workspaceId, current.Id, current.Version, cancellationToken); + if (published?.Value.Graph is null) continue; + foreach (var call in published.Value.Graph.Steps.OfType()) + { + var resolved = await ResolveFlowAsync(workspaceId, current.Id.Namespace, call.Flow, cancellationToken); + if (resolved is not null) pending.Enqueue((resolved.FlowId, resolved.Version)); + } + } + return false; + } +} + +public sealed class ManagedFlowToolExecutor( + IControlPlaneStore store, + IToolExecutionPipeline pipeline) : IFlowToolExecutor +{ + public async Task ExecuteAsync( + FlowToolExecutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var toolNamespace = request.Tool.ResolveNamespace(request.OwnerFlowId.Namespace); + var stored = await store.GetAsync( + new ResourceKey(ResourceKinds.Tool, request.Tool.ResourceId, toolNamespace), + cancellationToken); + if (stored is null) + throw Error("tool_not_found", $"Tool resource '{toolNamespace}/{request.Tool.ResourceId}' was not found."); + + var tool = stored.Value; + if (!tool.Definition.Enabled) + throw Error("tool_disabled", $"Tool resource '{tool.Address}' is disabled."); + if (tool.Definition.Discovery?.Available != true) + throw Error("tool_unavailable", $"Tool resource '{tool.Address}' is no longer available from its provider."); + if (tool.Definition.RequiresApproval) + throw Error("tool_approval_required", $"Tool resource '{tool.Address}' requires approval and cannot be invoked without an approval response."); + var providerId = tool.Definition.Provider?.Name + ?? throw Error("tool_mapping_invalid", $"Tool resource '{tool.Address}' has no ToolProvider mapping."); + var externalId = tool.Definition.ExternalId + ?? throw Error("tool_mapping_invalid", $"Tool resource '{tool.Address}' has no external Tool identity."); + var schema = tool.Definition.Schema?.Input + ?? throw Error("tool_schema_missing", $"Tool resource '{tool.Address}' has no input schema."); + ValidateArguments(request.Arguments, schema); + + var logicalCallId = $"flow:{request.RunId}:step:{request.StepName}"; + var invocationId = $"{logicalCallId}:attempt:{request.Attempt}"; + try + { + var result = await pipeline.ExecuteAsync(new ToolExecutionContext + { + OwnerKind = ToolExecutionOwnerKind.FlowRun, + ToolCallId = logicalCallId, + InvocationId = invocationId, + ToolId = tool.Metadata.Name, + ToolNamespace = tool.Metadata.Namespace, + ToolName = externalId, + ToolProviderId = providerId, + ToolProviderNamespace = tool.Definition.Provider!.Namespace ?? tool.Metadata.Namespace, + ExternalToolId = externalId, + TenantId = request.Scope.TenantId, + WorkspaceId = request.Scope.WorkspaceId, + PrincipalId = request.Scope.PrincipalId, + RunId = request.RunId, + FlowStepId = request.StepName, + CorrelationId = request.CorrelationId, + Arguments = request.Arguments.Clone() + }, cancellationToken); + return result?.Clone(); + } + catch (ToolExecutionDeniedException exception) + { + throw Error(exception.Code, exception.Message, exception); + } + catch (ToolResolutionException exception) + { + throw Error(exception.Code, exception.Message, exception); + } + } + + private static void ValidateArguments(JsonElement arguments, JsonElement schema) + { + if (arguments.ValueKind != JsonValueKind.Object) + throw Error("tool_arguments_object_required", "Tool arguments must be a JSON object."); + if (schema.ValueKind != JsonValueKind.Object) + throw Error("tool_input_schema_invalid", "The Tool input schema must be a JSON object schema."); + + if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array) + foreach (var item in required.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String)) + if (!arguments.TryGetProperty(item.GetString()!, out _)) + throw Error("tool_argument_required", $"Tool argument '{item.GetString()}' is required."); + + if (!schema.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Object) return; + var schemas = properties.EnumerateObject().ToDictionary(property => property.Name, property => property.Value, StringComparer.Ordinal); + foreach (var argument in arguments.EnumerateObject()) + { + if (!schemas.TryGetValue(argument.Name, out var propertySchema)) + throw Error("tool_argument_unknown", $"Tool argument '{argument.Name}' is not declared by the Tool schema."); + if (!propertySchema.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String) continue; + var valid = type.GetString() switch + { + "string" => argument.Value.ValueKind == JsonValueKind.String, + "number" => argument.Value.ValueKind == JsonValueKind.Number, + "integer" => argument.Value.ValueKind == JsonValueKind.Number && argument.Value.TryGetInt64(out _), + "boolean" => argument.Value.ValueKind is JsonValueKind.True or JsonValueKind.False, + "object" => argument.Value.ValueKind == JsonValueKind.Object, + "array" => argument.Value.ValueKind == JsonValueKind.Array, + "null" => argument.Value.ValueKind == JsonValueKind.Null, + _ => true + }; + if (!valid) + throw Error("tool_argument_type_invalid", $"Tool argument '{argument.Name}' does not match schema type '{type.GetString()}'."); + } + } + + private static FlowValidationException Error(string code, string message, Exception? exception = null) => + new(code, exception is null ? message : $"{message} ({exception.GetType().Name})"); } diff --git a/src/Agentstration.Infrastructure/Flows/RootFlowSubmissionAdapters.cs b/src/Agentstration.Infrastructure/Flows/RootFlowSubmissionAdapters.cs new file mode 100644 index 00000000..66f6e906 --- /dev/null +++ b/src/Agentstration.Infrastructure/Flows/RootFlowSubmissionAdapters.cs @@ -0,0 +1,52 @@ +using Agentstration.Application.Work; +using Agentstration.Flow; +using Agentstration.Flow.Application; + +namespace Agentstration.Infrastructure.Flows; + +public sealed class RootFlowTargetResolver(FlowService flows) : IRootFlowTargetResolver +{ + public async Task ResolveAsync( + FlowRunScope scope, + FlowReference target, + System.Text.Json.JsonElement input, + CancellationToken cancellationToken) + { + var resolved = await flows.ResolveAsync(scope.WorkspaceId, target, target.FlowId.Namespace, cancellationToken); + FlowRunService.ValidateInput(resolved.Graph?.InputSchema, input); + return new(new FlowReference(resolved.FlowId, resolved.Version, false, resolved.FlowId.Namespace)); + } +} + +public sealed class RootFlowRunGateway(FlowRunService runs) : IRootFlowRunGateway +{ + public async Task EnsureAsync(RootFlowRunRequest request, CancellationToken cancellationToken) + { + var stored = await runs.EnsureRootAsync(new EnsureRootFlowRunCommand( + request.RunId, + request.Target.FlowId, + request.Target.UseActiveVersion ? null : request.Target.Version, + "local", + request.Trigger, + request.Origin, + request.CallerId, + request.CausationId, + request.IdempotencyKey, + request.CorrelationId, + request.Input, + request.WorkItemId.Value.ToString("D"), + request.ResolvedFromActiveReference, + request.ParentFlowRunId, + request.InteractionId, + request.WorkTaskId, + request.TriggerMessageId, + request.Scope), cancellationToken); + return new(stored.Value, stored.ETag); + } +} + +public sealed class RootFlowSubmissionAuthorizer(IFlowRunExecutionScope executionScope) : IRootFlowSubmissionAuthorizer +{ + public async Task AuthorizeAsync(FlowRunScope scope, CancellationToken cancellationToken) => + await executionScope.ValidateAsync(scope, cancellationToken); +} diff --git a/src/Agentstration.Infrastructure/Flows/ToolDefinitionFlowAdapters.cs b/src/Agentstration.Infrastructure/Flows/ToolDefinitionFlowAdapters.cs new file mode 100644 index 00000000..c0649d37 --- /dev/null +++ b/src/Agentstration.Infrastructure/Flows/ToolDefinitionFlowAdapters.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using Agentstration.Application.Work; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Flow.Storage.Abstractions; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.Resources; + +namespace Agentstration.Infrastructure.Flows; + +public sealed class ToolDefinitionFlowResolver(FlowService flows) : IToolDefinitionFlowResolver +{ + public async Task ResolveAsync( + ResourceScopeRef scopeRef, + ResourceNamespace ownerNamespace, + ToolDefinitionFlowTarget target, + CancellationToken cancellationToken) + { + if (scopeRef.Kind != ResourceScopeKind.Workspace || scopeRef.TargetId is not { } workspaceId) + throw new ToolDefinitionValidationException("tool_definition_scope_invalid", "A ToolDefinition must belong to a Workspace scope."); + var flowNamespace = target.Namespace ?? ownerNamespace; + var reference = new FlowReference(new FlowId(target.Name, flowNamespace), target.Version, target.UseActiveVersion, flowNamespace); + var resolved = await flows.ResolveAsync(new WorkspaceId(workspaceId), reference, ownerNamespace, cancellationToken); + return Contract(resolved); + } + + internal static ResolvedToolDefinitionFlowContract Contract(FlowVersion version) => new( + version.FlowId.Value, + version.FlowId.Namespace, + version.Version, + version.Graph?.InputSchema?.Clone(), + version.Graph?.OutputSchema?.Clone()); +} + +public sealed class ToolDefinitionFlowActivationGuard( + IControlPlaneStore store, + IRequestContextScopeFactory requestScopes) : IFlowVersionActivationGuard +{ + public async Task ValidateActivationAsync(WorkspaceId workspaceId, FlowVersion version, CancellationToken cancellationToken) + { + using var requestScope = requestScopes.PushSystem(); + var definitions = await store.ListAllAsync(ResourceKinds.ToolDefinition, cancellationToken); + var contract = ToolDefinitionFlowResolver.Contract(version); + foreach (var stored in definitions.Where(value => + value.Value.ScopeRef is { Kind: ResourceScopeKind.Workspace, TargetId: { } targetId } + && targetId == workspaceId.Value + && value.Value.Definition.Enabled + && value.Value.Definition.Flow.UseActiveVersion + && string.Equals(value.Value.Definition.Flow.Name, version.FlowId.Value, StringComparison.Ordinal) + && (value.Value.Definition.Flow.Namespace ?? value.Value.Namespace) == version.FlowId.Namespace)) + { + try { ToolDefinitionService.ValidateContract(stored.Value.Definition, contract); } + catch (ToolDefinitionValidationException exception) + { + throw new FlowValidationException("flow_tool_definition_contract_incompatible", $"Flow version '{version.FlowId}:{version.Version}' is incompatible with enabled ToolDefinition '{stored.Value.Address}': {exception.Message}"); + } + } + } +} + +public sealed class ToolDefinitionFlowDeletionGuard( + IControlPlaneStore store, + IRequestContextScopeFactory requestScopes) : IFlowDeletionGuard +{ + public async Task ValidateDeleteAsync(WorkspaceId workspaceId, FlowId flowId, CancellationToken cancellationToken) + { + using var requestScope = requestScopes.PushSystem(); + var definitions = await store.ListAllAsync(ResourceKinds.ToolDefinition, cancellationToken); + var usage = definitions.Select(value => value.Value).FirstOrDefault(value => + value.ScopeRef is { Kind: ResourceScopeKind.Workspace, TargetId: { } targetId } + && targetId == workspaceId.Value + && string.Equals(value.Definition.Flow.Name, flowId.Value, StringComparison.Ordinal) + && (value.Definition.Flow.Namespace ?? value.Namespace) == flowId.Namespace); + if (usage is not null) + throw new FlowValidationException("flow_in_use_by_tool_definition", $"Flow '{flowId}' is referenced by ToolDefinition '{usage.Address}'."); + } +} + +public sealed class ToolDefinitionExecutor( + ToolDefinitionService definitions, + RootFlowSubmissionService submissions, + FlowRunService runs) : IToolDefinitionExecutor +{ + public async Task ExecuteAsync(ToolDefinitionInvocation invocation, CancellationToken cancellationToken) + { + var stored = await definitions.GetAsync(invocation.ToolName, invocation.Namespace, cancellationToken) + ?? throw new ToolDefinitionInvocationException("tool_definition_not_found", $"ToolDefinition '{invocation.Namespace}/{invocation.ToolName}' was not found."); + if (!stored.Value.Definition.Enabled) + throw new ToolDefinitionInvocationException("tool_definition_disabled", $"ToolDefinition '{stored.Value.Address}' is disabled."); + if (stored.Value.ScopeRef is not { Kind: ResourceScopeKind.Workspace, TargetId: { } ownerWorkspace } + || ownerWorkspace != invocation.WorkspaceId.Value) + throw new ToolDefinitionInvocationException("tool_definition_scope_mismatch", "The ToolDefinition is not owned by the invocation Workspace."); + if (invocation.Arguments.GetRawText().Length > 65_536) + throw new ToolDefinitionInvocationException("tool_definition_input_too_large", "ToolDefinition input cannot exceed 65536 JSON characters."); + try { FlowRunService.ValidateInput(stored.Value.Definition.InputSchema, invocation.Arguments); } + catch (FlowValidationException exception) + { + throw new ToolDefinitionInvocationException("tool_definition_input_invalid", exception.Message, exception); + } + + var target = stored.Value.Definition.Flow; + var flowNamespace = target.Namespace ?? stored.Value.Namespace; + var scope = new FlowRunScope(invocation.TenantId, invocation.WorkspaceId, invocation.PrincipalId); + var origin = invocation.CallerKind == ToolDefinitionCallerKind.Agent ? FlowInvocationOrigin.Agent : FlowInvocationOrigin.Mcp; + var callerId = invocation.CallerId ?? invocation.PrincipalId.ToString("D"); + RootFlowSubmission submission; + try + { + submission = await submissions.SubmitAsync(new SubmitRootFlowCommand( + invocation.WorkspaceId, + new FlowReference(new FlowId(target.Name, flowNamespace), target.Version, target.UseActiveVersion, flowNamespace), + invocation.Arguments, + origin, + callerId, + invocation.CallerKind == ToolDefinitionCallerKind.Flow ? FlowRunTrigger.Flow : FlowRunTrigger.Api, + $"tool:{stored.Value.Uid:N}:{invocation.CallId}", + invocation.CallId, + invocation.CorrelationId, + Type: "tool-definition", + Instruction: $"Execute ToolDefinition '{stored.Value.Address}'.", + Title: stored.Value.Definition.DisplayName, + Metadata: new Dictionary + { + ["toolDefinition.uid"] = stored.Value.Uid.ToString("D"), + ["toolDefinition.name"] = stored.Value.Name, + ["toolDefinition.namespace"] = stored.Value.Namespace.Value, + ["toolDefinition.callId"] = invocation.CallId + }), cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw new ToolDefinitionInvocationException("tool_definition_submission_failed", exception.Message, exception); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(stored.Value.Definition.InvocationTimeoutSeconds)); + StoredFlowRun completed; + try + { + await runs.ExecuteAsync(new FlowRunQueueItem(submission.FlowRun.Run.Id, scope), timeout.Token); + completed = await AwaitCompletionAsync(submission.FlowRun.Run.Id, scope, timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new ToolDefinitionInvocationException("tool_definition_timed_out", $"ToolDefinition '{stored.Value.Address}' exceeded its execution timeout."); + } + + if (completed.Value.Status != FlowRunStatus.Succeeded) + throw new ToolDefinitionInvocationException( + completed.Value.Status == FlowRunStatus.WaitingForInput ? "tool_definition_input_required" : "tool_definition_execution_failed", + completed.Value.Error?.Message ?? $"ToolDefinition Flow Run ended with status '{completed.Value.Status}'."); + var output = completed.Value.Output?.Clone(); + if (output is null && stored.Value.Definition.OutputSchema is not null) + throw new ToolDefinitionInvocationException("tool_definition_output_invalid", "The Flow returned no output for a ToolDefinition with an output schema."); + if (output is not null) + { + try { FlowRunService.ValidateInput(stored.Value.Definition.OutputSchema, output.Value); } + catch (FlowValidationException exception) + { + throw new ToolDefinitionInvocationException("tool_definition_output_invalid", exception.Message, exception); + } + } + + return new(output, new ToolDefinitionOperationReceipt( + submission.WorkItem.Value.Id.Value.ToString("D"), + completed.Value.Id, + completed.Value.FlowId.Value, + completed.Value.FlowId.Namespace, + completed.Value.FlowVersion, + completed.Value.CorrelationId ?? string.Empty, + submission.Recovered)); + } + + private async Task AwaitCompletionAsync(string runId, FlowRunScope scope, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var stored = await runs.GetAsync(runId, scope, cancellationToken) + ?? throw new ToolDefinitionInvocationException("tool_definition_flow_run_missing", "The ToolDefinition Flow Run could not be reloaded."); + if (stored.Value.Status.IsTerminal() || stored.Value.Status == FlowRunStatus.WaitingForInput) return stored; + if (stored.Value.Status == FlowRunStatus.WaitingForChild + && stored.Value.Steps.SingleOrDefault(step => step.ChildFlowRunId is not null)?.ChildFlowRunId is { } childRunId) + { + await runs.ExecuteAsync(new FlowRunQueueItem(childRunId, scope), cancellationToken); + await runs.ExecuteAsync(new FlowRunQueueItem(runId, scope), cancellationToken); + continue; + } + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + } + } +} diff --git a/src/Agentstration.Infrastructure/Notifications/InternalNotificationMcpTool.cs b/src/Agentstration.Infrastructure/Notifications/InternalNotificationMcpTool.cs new file mode 100644 index 00000000..fa6a5962 --- /dev/null +++ b/src/Agentstration.Infrastructure/Notifications/InternalNotificationMcpTool.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using Agentstration.Application.Work; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.Resources; +using Agentstration.Work; + +namespace Agentstration.Infrastructure.Notifications; + +public sealed class WorkNotificationMcpToolDefinitionProvider : IInternalMcpToolDefinitionProvider +{ + public InternalMcpToolDefinition Definition { get; } = new( + AgentstrationInternalTools.NotificationCreate, + "Create in-product notification", + "Creates one durable notification in the current Agentstration Workspace.", + JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + deliveryKey = new { type = "string", maxLength = 256 }, + title = new { type = "string", maxLength = 200 }, + message = new { type = "string", maxLength = 4000 }, + actionUrl = new { type = "string", maxLength = 2048 } + }, + required = new[] { "deliveryKey", "title", "message" }, + additionalProperties = false + }), + JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + notificationId = new { type = "string" }, + deliveryKey = new { type = "string" }, + createdAt = new { type = "string" }, + recovered = new { type = "boolean" } + }, + required = new[] { "notificationId", "deliveryKey", "createdAt", "recovered" }, + additionalProperties = false + })); +} + +public sealed class WorkNotificationMcpTool( + WorkplaceService workplace, + WorkNotificationMcpToolDefinitionProvider definitionProvider) : IInternalMcpToolHandler +{ + public InternalMcpToolDefinition Definition => definitionProvider.Definition; + + public async Task ExecuteAsync(InternalMcpToolInvocation invocation, CancellationToken cancellationToken) + { + if (invocation.Arguments.ValueKind != JsonValueKind.Object) + throw new ToolDefinitionInvocationException("notification_arguments_invalid", "Notification arguments must be a JSON object."); + var allowed = new HashSet(["deliveryKey", "title", "message", "actionUrl"], StringComparer.Ordinal); + var unknown = invocation.Arguments.EnumerateObject().Select(value => value.Name).FirstOrDefault(value => !allowed.Contains(value)); + if (unknown is not null) + throw new ToolDefinitionInvocationException("notification_argument_unknown", $"Notification argument '{unknown}' is not declared by the Tool schema."); + WorkplaceService.NotificationDelivery delivery; + try + { + delivery = await workplace.DeliverNotificationAsync(new WorkplaceService.DeliverNotificationCommand( + invocation.WorkspaceId, + Required(invocation.Arguments, "deliveryKey"), + Required(invocation.Arguments, "title"), + Required(invocation.Arguments, "message"), + Optional(invocation.Arguments, "actionUrl"), + invocation.CorrelationId, + invocation.RunId, + invocation.FlowStepId, + invocation.CallId), cancellationToken); + } + catch (WorkValidationException exception) + { + throw new ToolDefinitionInvocationException(exception.Code, exception.Message, exception); + } + return JsonSerializer.SerializeToElement(new + { + notificationId = delivery.Notification.Id.Value, + deliveryKey = delivery.Notification.DeliveryKey, + createdAt = delivery.Notification.CreatedAt, + recovered = delivery.Recovered + }); + } + + private static string Required(JsonElement arguments, string name) => + Optional(arguments, name) ?? throw new ToolDefinitionInvocationException("notification_argument_required", $"Notification argument '{name}' is required."); + + private static string? Optional(JsonElement arguments, string name) => + arguments.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; +} + +public sealed class InternalMcpToolProjectionService( + IControlPlaneStore store, + IEnumerable definitions, + TimeProvider timeProvider) +{ + public async Task EnsureAsync(ResourceScopeRef workspaceScope, ResourceNamespace @namespace, CancellationToken cancellationToken) + { + if (workspaceScope.Kind != ResourceScopeKind.Workspace) + throw new ToolResourceValidationException("Internal MCP Tools require a Workspace scope."); + var providerKey = new ResourceKey(ResourceKinds.ToolProvider, AgentstrationToolProvider.Name, @namespace); + if (await store.GetAsync(providerKey, cancellationToken) is { } provider) + { + if (provider.Value.Definition.Mcp?.Internal != true || provider.Value.ScopeRef != workspaceScope) + throw new ToolResourceValidationException("The reserved Agentstration ToolProvider identity is already in use."); + } + else + await store.PutAsync(Provider(workspaceScope, @namespace), null, true, cancellationToken); + + var now = timeProvider.GetUtcNow(); + foreach (var definition in definitions.Select(value => value.Definition)) + { + var name = AgentstrationToolProvider.ToolResourceName(definition.Name); + var key = new ResourceKey(ResourceKinds.Tool, name, @namespace); + var existing = await store.GetAsync(key, cancellationToken); + if (existing is not null) continue; + await store.PutAsync(new ToolResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.Tool, + Metadata = new ResourceMetadata { Name = name, Namespace = @namespace }, + ScopeRef = workspaceScope, + Generation = 1, + Status = new ResourceStatus { ProvisioningState = ProvisioningState.Succeeded }, + Definition = new ToolResourceProperties + { + DisplayName = definition.DisplayName, + Description = definition.Description, + Enabled = true, + RequiresApproval = definition.RequiresApproval, + Provider = new ResourceReference(AgentstrationToolProvider.Name, workspaceScope, @namespace), + ExternalId = definition.Name, + Discovery = new ToolDiscoveryState { Available = true, FirstSeenAt = now, LastSeenAt = now }, + Schema = new ToolSchema { Input = definition.InputSchema.Clone(), Output = definition.OutputSchema?.Clone() }, + Metadata = new Dictionary + { + ["agentstration.implementation"] = JsonSerializer.SerializeToElement("internal") + } + } + }, null, true, cancellationToken); + } + } + + private static ToolProviderResource Provider(ResourceScopeRef scope, ResourceNamespace @namespace) => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolProvider, + Metadata = new ResourceMetadata { Name = AgentstrationToolProvider.Name, Namespace = @namespace }, + ScopeRef = scope, + Generation = 1, + Status = new ResourceStatus { ProvisioningState = ProvisioningState.Succeeded }, + Definition = new ToolProviderProperties + { + DisplayName = "Agentstration", + ProviderType = ToolProviderType.Mcp, + Mcp = new McpToolProviderConfiguration { Internal = true }, + Discovery = new ToolProviderDiscoveryState + { + Status = "connected", + Capabilities = new Dictionary { ["tools"] = true } + } + } + }; +} diff --git a/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs b/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs index 21334236..3b77c6e4 100644 --- a/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs +++ b/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs @@ -329,6 +329,18 @@ private IEnumerable FlowDependencies(FlowResource flo if (router.Fallback is { } fallback && !Dynamic(fallback.ResourceId)) yield return IncludeDependency(fallback.ResourceId, fallback.Namespace ?? flow.Id.Namespace, ResourceKinds.Agent, "routerFallback"); } + else if (step is FlowCallStepDefinition flowCall) + { + yield return IncludeDependency(flowCall.Flow.ResourceId, flowCall.Flow.Namespace ?? flow.Id.Namespace, ResourceKinds.Flow, "graphFlow"); + } + else if (step is ToolFlowStepDefinition tool) + { + yield return UnsupportedDependency( + new ResourceReference(tool.Tool.ResourceId, @namespace: tool.Tool.Namespace), + flow.Id.Namespace, + ResourceKinds.Tool, + "graphTool"); + } } } diff --git a/src/Agentstration.Infrastructure/Triggers/TriggerAdapters.cs b/src/Agentstration.Infrastructure/Triggers/TriggerAdapters.cs index 49984d8f..e2e1aa96 100644 --- a/src/Agentstration.Infrastructure/Triggers/TriggerAdapters.cs +++ b/src/Agentstration.Infrastructure/Triggers/TriggerAdapters.cs @@ -105,7 +105,7 @@ public IDisposable Enter(TriggerExecutionScope executionScope) => scopes.Push(new RequestContext(executionScope.PrincipalId, executionScope.TenantId, executionScope.WorkspaceId)); } -public sealed class TriggerWorkSubmitter(FlowService flows, WorkItemService work, IWorkItemRepository repository) : ITriggerWorkSubmitter +public sealed class TriggerWorkSubmitter(RootFlowSubmissionService rootFlows, IWorkItemRepository repository) : ITriggerWorkSubmitter { public async Task GetExistingAsync(Guid workspaceId, Guid occurrenceId, CancellationToken cancellationToken) { @@ -127,8 +127,6 @@ public async Task SubmitAsync(TriggerResource trigger, Trigge var workspaceId = new WorkspaceId(occurrence.WorkspaceId); var ownerNamespace = trigger.Namespace; var reference = new FlowReference(new FlowId(target.Name, target.Namespace ?? ownerNamespace), target.Version, target.Version is null, target.Namespace); - var resolved = await flows.ResolveAsync(workspaceId, reference, ownerNamespace, cancellationToken); - var immutable = new FlowReference(resolved.FlowId, resolved.Version, false, resolved.FlowId.Namespace); var metadata = new Dictionary(StringComparer.Ordinal) { ["origin"] = "trigger", @@ -142,18 +140,29 @@ public async Task SubmitAsync(TriggerResource trigger, Trigge IReadOnlyList inputs = trigger.Definition.Input.ValueKind == System.Text.Json.JsonValueKind.Undefined ? [] : [new WorkInput(Structured: trigger.Definition.Input)]; - var stored = await work.SubmitAsync(new SubmitWorkItemCommand( + var instruction = $"Triggered execution of Flow '{reference.FlowId}'."; + var flowInput = System.Text.Json.JsonSerializer.SerializeToElement(new + { + prompt = instruction, + inputs = inputs.Select(value => value.Structured ?? System.Text.Json.JsonSerializer.SerializeToElement(value.Text)).ToArray() + }); + var submission = await rootFlows.SubmitAsync(new SubmitRootFlowCommand( workspaceId, + reference, + flowInput, + FlowInvocationOrigin.Trigger, + trigger.Definition.ExecutionScope?.PrincipalId.ToString("D") ?? "trigger-system", + FlowRunTrigger.Schedule, + occurrence.Id.ToString("N"), + occurrence.Id.ToString("D"), + $"trigger:{trigger.Uid:N}:{occurrence.Id:N}", "trigger", - $"Triggered execution of Flow '{resolved.FlowId}'.", + instruction, trigger.Definition.DisplayName, trigger.Definition.Description, - trigger.Definition.ExecutionScope?.PrincipalId.ToString("D"), - new WorkCorrelationId($"trigger:{trigger.Uid:N}:{occurrence.Id:N}"), Metadata: metadata, - Inputs: inputs, - Flow: immutable, - Id: new WorkItemId(occurrence.Id)), cancellationToken); - return new(stored.Value.Id.ToString()); + WorkInputs: inputs, + WorkItemId: new WorkItemId(occurrence.Id)), cancellationToken); + return new(submission.WorkItem.Value.Id.ToString()); } } diff --git a/src/Agentstration.Management.Abstractions/ManagementResources.cs b/src/Agentstration.Management.Abstractions/ManagementResources.cs index a980cdb1..23cd7f49 100644 --- a/src/Agentstration.Management.Abstractions/ManagementResources.cs +++ b/src/Agentstration.Management.Abstractions/ManagementResources.cs @@ -30,6 +30,7 @@ public static class ResourceKinds public const string Secret = "Secret"; public const string Vault = "Vault"; public const string Tool = "Tool"; + public const string ToolDefinition = "ToolDefinition"; public const string ToolProvider = "ToolProvider"; public const string ToolExecutionHook = "ToolExecutionHook"; public const string Trigger = "Trigger"; diff --git a/src/Agentstration.Management.Abstractions/ToolDefinitionResources.cs b/src/Agentstration.Management.Abstractions/ToolDefinitionResources.cs new file mode 100644 index 00000000..79b33aad --- /dev/null +++ b/src/Agentstration.Management.Abstractions/ToolDefinitionResources.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using Agentstration.Resources; + +namespace Agentstration.Management.Abstractions; + +public static class AgentstrationToolProvider +{ + public const string Name = "agentstration"; + public static string ToolResourceName(string definitionName) => $"{Name}.{definitionName}"; +} + +public sealed record ToolDefinitionFlowTarget +{ + public required string Name { get; init; } + public ResourceNamespace? Namespace { get; init; } + public string? Version { get; init; } + public bool UseActiveVersion { get; init; } = true; +} + +public sealed record ToolDefinitionProperties +{ + public required string DisplayName { get; init; } + public string? Description { get; init; } + public bool Enabled { get; init; } = true; + public bool RequiresApproval { get; init; } + public required JsonElement InputSchema { get; init; } + public JsonElement? OutputSchema { get; init; } + public required ToolDefinitionFlowTarget Flow { get; init; } + public int InvocationTimeoutSeconds { get; init; } = 90; +} + +public sealed record ToolDefinitionResource : Resource +{ + public ToolDefinitionProperties Definition { get; init; } = null!; +} + +public sealed record ResolvedToolDefinitionFlowContract( + string Name, + ResourceNamespace Namespace, + string Version, + JsonElement? InputSchema, + JsonElement? OutputSchema); + +public interface IToolDefinitionFlowResolver +{ + Task ResolveAsync( + ResourceScopeRef scopeRef, + ResourceNamespace ownerNamespace, + ToolDefinitionFlowTarget target, + CancellationToken cancellationToken); +} + +public enum ToolDefinitionCallerKind { Mcp, Agent, Flow } + +public sealed record ToolDefinitionInvocation( + Guid TenantId, + WorkspaceId WorkspaceId, + Guid PrincipalId, + ResourceNamespace Namespace, + string ToolName, + string CallId, + string? CorrelationId, + JsonElement Arguments, + ToolDefinitionCallerKind CallerKind, + string? CallerId = null); + +public sealed record ToolDefinitionOperationReceipt( + string WorkItemId, + string FlowRunId, + string FlowName, + ResourceNamespace FlowNamespace, + string FlowVersion, + string CorrelationId, + bool Recovered); + +public sealed record ToolDefinitionInvocationResult(JsonElement? Output, ToolDefinitionOperationReceipt Receipt); + +public interface IToolDefinitionExecutor +{ + Task ExecuteAsync(ToolDefinitionInvocation invocation, CancellationToken cancellationToken); +} + +public sealed class ToolDefinitionInvocationException(string code, string message, Exception? innerException = null) + : Exception(message, innerException) +{ + public string Code { get; } = code; +} + +public sealed record InternalMcpToolDefinition( + string Name, + string DisplayName, + string? Description, + JsonElement InputSchema, + JsonElement? OutputSchema = null, + bool RequiresApproval = false); + +public sealed record InternalMcpToolInvocation( + Guid TenantId, + WorkspaceId WorkspaceId, + Guid PrincipalId, + string CallId, + string? CorrelationId, + JsonElement Arguments, + ToolDefinitionCallerKind CallerKind, + string? CallerId = null, + string? RunId = null, + string? FlowStepId = null); + +public interface IInternalMcpToolDefinitionProvider +{ + InternalMcpToolDefinition Definition { get; } +} + +public interface IInternalMcpToolHandler : IInternalMcpToolDefinitionProvider +{ + Task ExecuteAsync(InternalMcpToolInvocation invocation, CancellationToken cancellationToken); +} + +public static class AgentstrationInternalTools +{ + public const string NotificationCreate = "work.notification.create"; +} diff --git a/src/Agentstration.Management.Abstractions/ToolResources.cs b/src/Agentstration.Management.Abstractions/ToolResources.cs index 84a8ea1f..d7f21cb0 100644 --- a/src/Agentstration.Management.Abstractions/ToolResources.cs +++ b/src/Agentstration.Management.Abstractions/ToolResources.cs @@ -16,6 +16,7 @@ public sealed record AepToolProviderConfiguration public sealed record McpToolProviderConfiguration { + public bool Internal { get; init; } public McpToolProviderTransport Transport { get; init; } = McpToolProviderTransport.Stdio; public Uri? Endpoint { get; init; } public string? Command { get; init; } @@ -83,6 +84,21 @@ public sealed record ToolResource : Resource public ToolResourceProperties Definition { get; init; } = null!; } +public static class ToolResourceIdentity +{ + public static string CatalogId(ResourceNamespace @namespace, string name) => + @namespace.IsDefault ? name : $"{@namespace.Value}/{name}"; + + public static (ResourceNamespace Namespace, string Name) ParseCatalogId(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + var separator = value.IndexOf('/'); + return separator < 0 + ? (ResourceNamespace.Default, value) + : (ResourceNamespace.Parse(value[..separator]), value[(separator + 1)..]); + } +} + public static class ToolExecutionHookHandlers { public const string Deny = "deny"; diff --git a/src/Agentstration.Management.Contracts/ToolManagementContracts.cs b/src/Agentstration.Management.Contracts/ToolManagementContracts.cs index 65d00a77..02f80495 100644 --- a/src/Agentstration.Management.Contracts/ToolManagementContracts.cs +++ b/src/Agentstration.Management.Contracts/ToolManagementContracts.cs @@ -8,5 +8,8 @@ public sealed record PutToolProviderRequest(ToolProviderProperties Properties); public sealed record SetToolEnabledRequest(bool Enabled); public sealed record ToolDiscoveryDiffResponse(int New, int Changed, int Unchanged, int Unavailable, int Total); public sealed record ToolConnectionTestResponse(string Status, int ToolCount, IReadOnlyDictionary Capabilities, IReadOnlyDictionary ServerMetadata); +public sealed record CreateToolDefinitionRequest(string Name, ToolDefinitionProperties Properties, string? Namespace = null); +public sealed record PutToolDefinitionRequest(ToolDefinitionProperties Properties); +public sealed record SetToolDefinitionEnabledRequest(bool Enabled); public sealed record CreateToolExecutionHookRequest(string Name, ToolExecutionHookProperties Properties, string? Namespace = null); public sealed record PutToolExecutionHookRequest(ToolExecutionHookProperties Properties); diff --git a/src/Agentstration.Management.Core/AgentDefinitionCompiler.cs b/src/Agentstration.Management.Core/AgentDefinitionCompiler.cs index bbf4f3ae..8ac1b1a2 100644 --- a/src/Agentstration.Management.Core/AgentDefinitionCompiler.cs +++ b/src/Agentstration.Management.Core/AgentDefinitionCompiler.cs @@ -30,7 +30,12 @@ public ResolvedAgentDefinition Compile(AgentResource resource, AgentDeploymentSp throw new AgentDefinitionValidationException("handler_not_supported", $"Handler '{agent.Handler}' is not supported."); var instructions = NormalizeInstructions(agent.Instructions); - var tools = agent.Tools.Select(reference => reference.Name).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + var tools = agent.Tools + .Select(reference => reference.Resolve(resource.Namespace, ResourceKinds.Tool)) + .Select(address => ToolResourceIdentity.CatalogId(address.Namespace, address.Name)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); var middleware = agent.Middleware.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); var contextProviders = agent.ContextProviders.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); var capabilities = agent.Behaviors.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); diff --git a/src/Agentstration.Management.Core/ResourceScopePolicy.cs b/src/Agentstration.Management.Core/ResourceScopePolicy.cs index d430d49a..55cc7bd5 100644 --- a/src/Agentstration.Management.Core/ResourceScopePolicy.cs +++ b/src/Agentstration.Management.Core/ResourceScopePolicy.cs @@ -26,7 +26,7 @@ or ResourceKinds.SourceObservedState or ResourceKinds.SourceImportRecord or ResourceKinds.SourceChannelSnapshot or ResourceKinds.SourceChannelObservedState or ResourceKinds.SourceChannelRefreshRecord => InstanceTenantWorkspace, ResourceKinds.Vault or ResourceKinds.Secret => InstanceTenantWorkspace, - ResourceKinds.ToolProvider or ResourceKinds.Tool or ResourceKinds.ToolExecutionHook => WorkspaceOnly, + ResourceKinds.ToolProvider or ResourceKinds.Tool or ResourceKinds.ToolDefinition or ResourceKinds.ToolExecutionHook => WorkspaceOnly, ResourceKinds.Agent or ResourceKinds.AgentRevision or ResourceKinds.AgentDeployment or ResourceKinds.Trigger => WorkspaceOnly, ResourceKinds.InstalledPack => InstanceTenantWorkspace, PackAuthoringKinds.PackProject or PackAuthoringKinds.PackProjectBuild => WorkspaceOnly, diff --git a/src/Agentstration.Management.Core/ToolDefinitionService.cs b/src/Agentstration.Management.Core/ToolDefinitionService.cs new file mode 100644 index 00000000..e393f4f4 --- /dev/null +++ b/src/Agentstration.Management.Core/ToolDefinitionService.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Agentstration.Management.Abstractions; +using Agentstration.Resources; + +namespace Agentstration.Management.Core; + +public sealed class ToolDefinitionValidationException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +public sealed class ToolDefinitionService( + IControlPlaneStore store, + IToolDefinitionFlowResolver flows, + TimeProvider timeProvider, + IEnumerable? internalTools = null) +{ + public Task>> ListAsync(CancellationToken cancellationToken) => + store.ListAllAsync(ResourceKinds.ToolDefinition, cancellationToken); + + public Task?> GetAsync( + string name, + ResourceNamespace @namespace, + CancellationToken cancellationToken) => + store.GetAsync(new ResourceKey(ResourceKinds.ToolDefinition, name, @namespace), cancellationToken); + + public async Task> PutAsync( + ToolDefinitionResource resource, + string? ifMatch, + bool ifNoneMatch, + CancellationToken cancellationToken) + { + Validate(resource); + if ((internalTools ?? []).Any(value => string.Equals(value.Definition.Name, resource.Name, StringComparison.Ordinal))) + throw new ToolDefinitionValidationException("tool_definition_name_reserved", $"ToolDefinition name '{resource.Name}' is reserved by an internal Agentstration MCP Tool."); + var scopeRef = resource.ScopeRef!.Value; + var resolved = await flows.ResolveAsync(scopeRef, resource.Namespace, resource.Definition.Flow, cancellationToken); + ValidateContract(resource.Definition, resolved); + var existing = await GetAsync(resource.Name, resource.Namespace, cancellationToken); + var value = resource with + { + Uid = existing?.Value.Uid ?? Guid.Empty, + Generation = existing is null ? 1 : checked(existing.Value.Generation + 1), + Status = new ResourceStatus { ProvisioningState = ProvisioningState.Succeeded } + }; + + await EnsureProviderAsync(scopeRef, resource.Namespace, cancellationToken); + var stored = await store.PutAsync(value, ifMatch, ifNoneMatch, cancellationToken); + await MaterializeAsync(stored.Value, cancellationToken); + return stored; + } + + public async Task> SetEnabledAsync( + string name, + ResourceNamespace @namespace, + bool enabled, + string? ifMatch, + CancellationToken cancellationToken) + { + var current = await GetAsync(name, @namespace, cancellationToken) + ?? throw new ControlPlaneResourceNotFoundException(new(ResourceKinds.ToolDefinition, name, @namespace)); + return await PutAsync( + current.Value with { Definition = current.Value.Definition with { Enabled = enabled } }, + ifMatch ?? current.ETag, + false, + cancellationToken); + } + + public async Task DeleteAsync(string name, ResourceNamespace @namespace, string? ifMatch, CancellationToken cancellationToken) + { + var current = await GetAsync(name, @namespace, cancellationToken) + ?? throw new ControlPlaneResourceNotFoundException(new(ResourceKinds.ToolDefinition, name, @namespace)); + var tool = new ResourceKey(ResourceKinds.Tool, AgentstrationToolProvider.ToolResourceName(name), @namespace); + if (await store.GetAsync(tool, cancellationToken) is { } materialized) + await store.DeleteAsync(tool, materialized.ETag, cancellationToken); + await store.DeleteAsync(new ResourceKey(ResourceKinds.ToolDefinition, name, @namespace), ifMatch ?? current.ETag, cancellationToken); + } + + public static void ValidateContract(ToolDefinitionProperties definition, ResolvedToolDefinitionFlowContract flow) + { + if (!SameSchema(definition.InputSchema, flow.InputSchema)) + throw new ToolDefinitionValidationException("tool_definition_input_schema_incompatible", "The Tool input schema must match the published Flow input schema."); + if (!SameSchema(definition.OutputSchema, flow.OutputSchema)) + throw new ToolDefinitionValidationException("tool_definition_output_schema_incompatible", "The Tool output schema must match the published Flow output schema."); + } + + public static bool SameSchema(JsonElement? left, JsonElement? right) + { + if (left is null || left.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return right is null || right.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined; + if (right is null || right.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return false; + return JsonNode.DeepEquals(JsonNode.Parse(left.Value.GetRawText()), JsonNode.Parse(right.Value.GetRawText())); + } + + private static void Validate(ToolDefinitionResource resource) + { + if (resource.Kind != ResourceKinds.ToolDefinition || resource.ApiVersion != ManagementApiVersions.CoreV1) + throw new ToolDefinitionValidationException("tool_definition_identity_invalid", "ToolDefinition kind and apiVersion are required."); + if (resource.ScopeRef is not { Kind: ResourceScopeKind.Workspace }) + throw new ToolDefinitionValidationException("tool_definition_scope_invalid", "A ToolDefinition must belong to a Workspace scope."); + if (string.IsNullOrWhiteSpace(resource.Name) || resource.Name.Length > 128 + || resource.Name.Any(character => !char.IsLetterOrDigit(character) && character is not '.' and not '-' and not '_')) + throw new ToolDefinitionValidationException("tool_definition_name_invalid", "ToolDefinition names must contain only letters, digits, '.', '-' or '_'."); + if (string.IsNullOrWhiteSpace(resource.Definition.DisplayName)) + throw new ToolDefinitionValidationException("tool_definition_display_name_required", "ToolDefinition displayName is required."); + if (string.IsNullOrWhiteSpace(resource.Definition.Flow.Name)) + throw new ToolDefinitionValidationException("tool_definition_flow_required", "A ToolDefinition requires a Flow implementation."); + if ((resource.Definition.Flow.UseActiveVersion && !string.IsNullOrWhiteSpace(resource.Definition.Flow.Version)) + || (!resource.Definition.Flow.UseActiveVersion && string.IsNullOrWhiteSpace(resource.Definition.Flow.Version))) + throw new ToolDefinitionValidationException("tool_definition_flow_reference_invalid", "Select either the active Flow version or one exact version."); + if (resource.Definition.InputSchema.ValueKind != JsonValueKind.Object) + throw new ToolDefinitionValidationException("tool_definition_input_schema_invalid", "The Tool input schema must be a JSON object."); + if (resource.Definition.InvocationTimeoutSeconds is < 1 or > 900) + throw new ToolDefinitionValidationException("tool_definition_timeout_invalid", "Tool invocation timeout must be between 1 and 900 seconds."); + } + + private async Task EnsureProviderAsync(ResourceScopeRef scopeRef, ResourceNamespace @namespace, CancellationToken cancellationToken) + { + var key = new ResourceKey(ResourceKinds.ToolProvider, AgentstrationToolProvider.Name, @namespace); + if (await store.GetAsync(key, cancellationToken) is { } existing) + { + if (existing.Value.Definition.Mcp?.Internal != true || existing.Value.ScopeRef != scopeRef) + throw new ToolDefinitionValidationException("tool_definition_provider_conflict", "The reserved Agentstration ToolProvider identity is already in use."); + return; + } + await store.PutAsync(new ToolProviderResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolProvider, + Metadata = new ResourceMetadata { Name = AgentstrationToolProvider.Name, Namespace = @namespace }, + ScopeRef = scopeRef, + Generation = 1, + Status = new ResourceStatus { ProvisioningState = ProvisioningState.Succeeded }, + Definition = new ToolProviderProperties + { + DisplayName = "Agentstration", + ProviderType = ToolProviderType.Mcp, + Mcp = new McpToolProviderConfiguration { Internal = true }, + Discovery = new ToolProviderDiscoveryState { Status = "connected", Capabilities = new Dictionary { ["tools"] = true } } + } + }, null, true, cancellationToken); + } + + private async Task MaterializeAsync(ToolDefinitionResource definition, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var name = AgentstrationToolProvider.ToolResourceName(definition.Name); + var key = new ResourceKey(ResourceKinds.Tool, name, definition.Namespace); + var existing = await store.GetAsync(key, cancellationToken); + var firstSeen = existing?.Value.Definition.Discovery?.FirstSeenAt ?? now; + var resource = new ToolResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.Tool, + Metadata = new ResourceMetadata { Name = name, Namespace = definition.Namespace }, + ScopeRef = definition.ScopeRef, + Uid = existing?.Value.Uid ?? Guid.Empty, + Generation = existing is null ? 1 : checked(existing.Value.Generation + 1), + Status = new ResourceStatus { ProvisioningState = ProvisioningState.Succeeded }, + Definition = new ToolResourceProperties + { + DisplayName = definition.Definition.DisplayName, + Description = definition.Definition.Description, + Enabled = definition.Definition.Enabled, + RequiresApproval = definition.Definition.RequiresApproval, + Provider = new ResourceReference(AgentstrationToolProvider.Name, definition.ScopeRef, definition.Namespace), + ExternalId = definition.Name, + Discovery = new ToolDiscoveryState { Available = true, FirstSeenAt = firstSeen, LastSeenAt = now }, + Schema = new ToolSchema { Input = definition.Definition.InputSchema.Clone(), Output = definition.Definition.OutputSchema?.Clone() }, + Metadata = new Dictionary + { + ["agentstration.toolDefinitionUid"] = JsonSerializer.SerializeToElement(definition.Uid), + ["agentstration.toolDefinitionGeneration"] = JsonSerializer.SerializeToElement(definition.Generation) + } + } + }; + await store.PutAsync(resource, existing?.ETag, existing is null, cancellationToken); + } +} diff --git a/src/Agentstration.Management.Core/ToolManagementService.cs b/src/Agentstration.Management.Core/ToolManagementService.cs index b04c59fd..3b6a5efc 100644 --- a/src/Agentstration.Management.Core/ToolManagementService.cs +++ b/src/Agentstration.Management.Core/ToolManagementService.cs @@ -21,6 +21,8 @@ public sealed class ToolManagementService(IControlPlaneStore store, IEnumerable< public async Task> PutProviderAsync(ToolProviderResource resource, string? ifMatch, bool ifNoneMatch, CancellationToken cancellationToken) { + if (string.Equals(resource.Name, AgentstrationToolProvider.Name, StringComparison.Ordinal)) + throw new ToolResourceValidationException("The Agentstration MCP provider is managed by ToolDefinitions."); ValidateProvider(resource); var existing = await GetProviderAsync(resource.Metadata.Name, cancellationToken); return await store.PutAsync(resource with @@ -35,6 +37,8 @@ public async Task> PutProviderAsync(ToolPro public async Task TestConnectionAsync(ToolProviderResource provider, CancellationToken cancellationToken) { ValidateProvider(provider); + if (provider.Definition.Mcp?.Internal == true) + throw new ToolResourceValidationException("The Agentstration MCP provider is managed by ToolDefinitions."); try { return await DiscoveryFor(provider).DiscoverAsync(provider, cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { throw new ToolProviderDiscoveryFailedException(exception.Message, exception); } } @@ -44,6 +48,8 @@ public async Task RefreshDiscoveryAsync(string providerName, var storedProvider = await GetProviderAsync(providerName, cancellationToken) ?? throw new ControlPlaneResourceNotFoundException(new(ResourceKinds.ToolProvider, providerName)); var provider = storedProvider.Value; + if (provider.Definition.Mcp?.Internal == true) + throw new ToolResourceValidationException("The Agentstration MCP provider is materialized by ToolDefinitions and cannot be refreshed."); var now = timeProvider.GetUtcNow(); ToolProviderDiscoveryResult result; try { result = await DiscoveryFor(provider).DiscoverAsync(provider, cancellationToken); } @@ -167,6 +173,12 @@ public static void ValidateProvider(ToolProviderResource resource) { var mcp = resource.Definition.Mcp; if (mcp is null || resource.Definition.Aep is not null) throw new ToolResourceValidationException("An MCP provider requires only an mcp configuration."); + if (mcp.Internal) + { + if (mcp.Endpoint is not null || !string.IsNullOrWhiteSpace(mcp.Command) || mcp.Arguments.Count > 0 || mcp.EnvironmentReferences.Count > 0) + throw new ToolResourceValidationException("An internal MCP provider cannot configure an external transport."); + return; + } if (mcp.Transport == McpToolProviderTransport.Stdio && string.IsNullOrWhiteSpace(mcp.Command)) throw new ToolResourceValidationException("STDIO MCP command is required."); if (mcp.Transport == McpToolProviderTransport.StreamableHttp && (mcp.Endpoint is null || !mcp.Endpoint.IsAbsoluteUri || mcp.Endpoint.Scheme is not ("http" or "https"))) throw new ToolResourceValidationException("Streamable HTTP MCP endpoint must be an absolute HTTP(S) URI."); diff --git a/src/Agentstration.Runtime.Abstractions/RuntimeContracts.cs b/src/Agentstration.Runtime.Abstractions/RuntimeContracts.cs index 9e62c164..84ee83c7 100644 --- a/src/Agentstration.Runtime.Abstractions/RuntimeContracts.cs +++ b/src/Agentstration.Runtime.Abstractions/RuntimeContracts.cs @@ -237,6 +237,8 @@ public interface IAgentTool string Name { get; } string? Description { get; } string? ProviderId { get; } + ResourceNamespace? Namespace => null; + ResourceNamespace? ProviderNamespace => null; string? ExternalId { get; } JsonElement InputSchema { get; } JsonElement? OutputSchema { get; } @@ -268,13 +270,16 @@ public sealed record ToolExecutionContext public required string ToolCallId { get; init; } public required string InvocationId { get; init; } public required string ToolId { get; init; } + public ResourceNamespace? ToolNamespace { get; init; } public required string ToolName { get; init; } public string? ToolProviderId { get; init; } + public ResourceNamespace? ToolProviderNamespace { get; init; } public string? ExternalToolId { get; init; } public Guid? TenantId { get; init; } public WorkspaceId? WorkspaceId { get; init; } public Guid? PrincipalId { get; init; } public string? RunId { get; init; } + public string? FlowStepId { get; init; } public string? AgentId { get; init; } public long? AgentVersion { get; init; } public long? AgentGeneration { get; init; } diff --git a/src/Agentstration.Runtime.AgentFramework/AgentFrameworkRuntime.cs b/src/Agentstration.Runtime.AgentFramework/AgentFrameworkRuntime.cs index 72f0a1f1..7addb712 100644 --- a/src/Agentstration.Runtime.AgentFramework/AgentFrameworkRuntime.cs +++ b/src/Agentstration.Runtime.AgentFramework/AgentFrameworkRuntime.cs @@ -282,8 +282,10 @@ private sealed class AgentstrationToolFunction( ToolCallId = callId, InvocationId = Guid.NewGuid().ToString("N"), ToolId = tool.Id, + ToolNamespace = tool.Namespace, ToolName = tool.Name, ToolProviderId = tool.ProviderId, + ToolProviderNamespace = tool.ProviderNamespace, ExternalToolId = tool.ExternalId, Arguments = serializedArguments }, cancellationToken); diff --git a/src/Agentstration.Tools.Mcp/McpToolCatalog.cs b/src/Agentstration.Tools.Mcp/McpToolCatalog.cs index 3f2dd3b7..2637597f 100644 --- a/src/Agentstration.Tools.Mcp/McpToolCatalog.cs +++ b/src/Agentstration.Tools.Mcp/McpToolCatalog.cs @@ -2,6 +2,7 @@ using Agentstration.Aep.Abstractions; using Agentstration.Aep.Client; using Agentstration.Management.Abstractions; +using Agentstration.Resources; using Agentstration.Runtime.Abstractions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -166,6 +167,8 @@ private static IAgentTool Wrap(ToolResource resource, McpClientTool native) native.Name, resource.Definition.Description ?? native.Description, resource.Definition.Provider?.Name, + resource.Namespace, + resource.Definition.Provider?.Namespace ?? resource.Namespace, resource.Definition.ExternalId, native.JsonSchema.Clone(), native.ReturnJsonSchema?.Clone(), @@ -217,7 +220,8 @@ public async ValueTask> ResolveAsync(IEnumerable var resources = new List(); foreach (var id in toolIds.Distinct(StringComparer.Ordinal)) { - var tool = await store.GetAsync(new ResourceKey(ResourceKinds.Tool, id), cancellationToken) ?? throw new ToolResolutionException("tool_not_found", $"Tool resource '{id}' was not found."); + var identity = ToolResourceIdentity.ParseCatalogId(id); + var tool = await store.GetAsync(new ResourceKey(ResourceKinds.Tool, identity.Name, identity.Namespace), cancellationToken) ?? throw new ToolResolutionException("tool_not_found", $"Tool resource '{id}' was not found."); if (!tool.Value.Definition.Enabled) throw new ToolResolutionException("tool_disabled", $"Tool resource '{id}' is disabled."); if (tool.Value.Definition.Discovery?.Available != true) throw new ToolResolutionException("tool_unavailable", $"Tool resource '{id}' is no longer available from its provider."); if (tool.Value.Definition.Provider is null) throw new ToolResolutionException("tool_mapping_invalid", $"Tool resource '{id}' has no ToolProvider mapping."); @@ -225,14 +229,31 @@ public async ValueTask> ResolveAsync(IEnumerable } var resolved = new List(); - foreach (var group in resources.GroupBy(value => value.Definition.Provider!.Name, StringComparer.Ordinal)) + foreach (var group in resources.GroupBy(value => ( + value.Definition.Provider!.Name, + Namespace: value.Definition.Provider.Namespace ?? value.Namespace))) { - var provider = await store.GetAsync(new ResourceKey(ResourceKinds.ToolProvider, group.Key), cancellationToken) ?? throw new ToolResolutionException("tool_provider_not_found", $"ToolProvider '{group.Key}' was not found."); + var provider = await store.GetAsync(new ResourceKey(ResourceKinds.ToolProvider, group.Key.Name, group.Key.Namespace), cancellationToken) ?? throw new ToolResolutionException("tool_provider_not_found", $"ToolProvider '{group.Key.Name}' was not found."); if (!provider.Value.Definition.Enabled) throw new ToolResolutionException("tool_provider_disabled", $"ToolProvider '{provider.Value.Metadata.Name}' is disabled."); - resolved.AddRange(await providers.ResolveAsync(provider.Value, group.ToArray(), cancellationToken)); + if (provider.Value.Definition.Mcp?.Internal == true) + resolved.AddRange(group.Select(Tool)); + else + resolved.AddRange(await providers.ResolveAsync(provider.Value, group.ToArray(), cancellationToken)); } return resolved; } + + private static IAgentTool Tool(ToolResource resource) => new McpAgentTool( + resource.Name, + resource.Definition.ExternalId ?? resource.Name, + resource.Definition.Description, + resource.Definition.Provider?.Name, + resource.Namespace, + resource.Definition.Provider?.Namespace ?? resource.Namespace, + resource.Definition.ExternalId, + resource.Definition.Schema?.Input ?? JsonSerializer.SerializeToElement(new { type = "object" }), + resource.Definition.Schema?.Output, + resource.Definition.RequiresApproval); } internal sealed record McpAgentTool( @@ -240,16 +261,22 @@ internal sealed record McpAgentTool( string Name, string? Description, string? ProviderId, + ResourceNamespace? Namespace, + ResourceNamespace? ProviderNamespace, string? ExternalId, JsonElement InputSchema, JsonElement? OutputSchema, bool RequiresApproval) : IAgentTool; -public sealed class McpToolInvoker(IControlPlaneStore store, ToolProviderAdapter providers) : IToolInvoker +public sealed class McpToolInvoker( + IControlPlaneStore store, + ToolProviderAdapter providers, + Lazy? internalTools = null, + Lazy>? builtInTools = null) : IToolInvoker { public async ValueTask InvokeAsync(ToolExecutionContext context, CancellationToken cancellationToken = default) { - var tool = await store.GetAsync(new ResourceKey(ResourceKinds.Tool, context.ToolId), cancellationToken) + var tool = await store.GetAsync(new ResourceKey(ResourceKinds.Tool, context.ToolId, context.ToolNamespace ?? default), cancellationToken) ?? throw new ToolResolutionException("tool_not_found", $"Tool resource '{context.ToolId}' was not found."); if (!tool.Value.Definition.Enabled) throw new ToolResolutionException("tool_disabled", $"Tool resource '{context.ToolId}' is disabled."); if (tool.Value.Definition.Discovery?.Available != true) throw new ToolResolutionException("tool_unavailable", $"Tool resource '{context.ToolId}' is no longer available from its provider."); @@ -259,9 +286,41 @@ public sealed class McpToolInvoker(IControlPlaneStore store, ToolProviderAdapter throw new ToolResolutionException("tool_provider_mismatch", $"Tool resource '{context.ToolId}' no longer maps to provider '{context.ToolProviderId}'."); if (context.ExternalToolId is not null && !string.Equals(context.ExternalToolId, tool.Value.Definition.ExternalId, StringComparison.Ordinal)) throw new ToolResolutionException("external_tool_mismatch", $"Tool resource '{context.ToolId}' no longer maps to external Tool '{context.ExternalToolId}'."); - var provider = await store.GetAsync(new ResourceKey(ResourceKinds.ToolProvider, providerId), cancellationToken) + var provider = await store.GetAsync(new ResourceKey(ResourceKinds.ToolProvider, providerId, context.ToolProviderNamespace ?? default), cancellationToken) ?? throw new ToolResolutionException("tool_provider_not_found", $"ToolProvider '{providerId}' was not found."); if (!provider.Value.Definition.Enabled) throw new ToolResolutionException("tool_provider_disabled", $"ToolProvider '{providerId}' is disabled."); + if (provider.Value.Definition.Mcp?.Internal == true) + { + if (context.TenantId is not { } tenantId || context.WorkspaceId is not { } workspaceId || context.PrincipalId is not { } principalId) + throw new ToolResolutionException("tool_execution_scope_required", "An internal Tool invocation requires trusted Tenant, Workspace, and Principal scope."); + var externalId = tool.Value.Definition.ExternalId ?? tool.Value.Name; + var builtIn = builtInTools?.Value.SingleOrDefault(value => string.Equals(value.Definition.Name, externalId, StringComparison.Ordinal)); + if (builtIn is not null) + return await builtIn.ExecuteAsync(new InternalMcpToolInvocation( + tenantId, + workspaceId, + principalId, + context.ToolCallId, + context.CorrelationId, + context.Arguments ?? JsonSerializer.SerializeToElement(new { }), + context.AgentId is not null ? ToolDefinitionCallerKind.Agent : context.OwnerKind == ToolExecutionOwnerKind.FlowRun ? ToolDefinitionCallerKind.Flow : ToolDefinitionCallerKind.Agent, + context.AgentId is not null ? $"agent:{context.AgentId}" : context.RunId is not null ? $"flow:{context.RunId}" : null, + context.RunId, + context.FlowStepId), cancellationToken); + if (internalTools is null) throw new ToolResolutionException("internal_tool_executor_unavailable", "The Agentstration ToolDefinition executor is unavailable."); + var result = await internalTools.Value.ExecuteAsync(new ToolDefinitionInvocation( + tenantId, + workspaceId, + principalId, + tool.Value.Namespace, + externalId, + context.ToolCallId, + context.CorrelationId, + context.Arguments ?? JsonSerializer.SerializeToElement(new { }), + context.AgentId is not null ? ToolDefinitionCallerKind.Agent : context.OwnerKind == ToolExecutionOwnerKind.FlowRun ? ToolDefinitionCallerKind.Flow : ToolDefinitionCallerKind.Agent, + context.AgentId is not null ? $"agent:{context.AgentId}" : context.RunId is not null ? $"flow:{context.RunId}" : null), cancellationToken); + return result.Output?.Clone(); + } return await providers.InvokeAsync(provider.Value, tool.Value, context.Arguments, cancellationToken); } } diff --git a/src/Agentstration.Web.FlowDesigner/Backend/IFlowDesignerResourceProvider.cs b/src/Agentstration.Web.FlowDesigner/Backend/IFlowDesignerResourceProvider.cs index 3b007a98..4eaa4fed 100644 --- a/src/Agentstration.Web.FlowDesigner/Backend/IFlowDesignerResourceProvider.cs +++ b/src/Agentstration.Web.FlowDesigner/Backend/IFlowDesignerResourceProvider.cs @@ -1,8 +1,28 @@ +using System.Text.Json; +using Agentstration.Resources; + namespace Agentstration.Web.FlowDesigner.Backend; public sealed record FlowDesignerAgent(string Name, string DisplayName); +public sealed record FlowDesignerFlow(string Name, string DisplayName, ResourceNamespace Namespace, string? ActiveVersion); +public sealed record FlowDesignerFlowVersion(string Version, JsonElement? InputSchema, JsonElement? OutputSchema); +public sealed record FlowDesignerTool( + string Name, + string DisplayName, + ResourceNamespace Namespace, + JsonElement InputSchema, + JsonElement? OutputSchema, + bool Enabled, + bool Available, + bool RequiresApproval); public interface IFlowDesignerResourceProvider { Task> GetAgentsAsync(CancellationToken cancellationToken); + Task> GetFlowsAsync(CancellationToken cancellationToken) => + Task.FromResult>([]); + Task> GetFlowVersionsAsync(ResourceNamespace @namespace, string name, CancellationToken cancellationToken) => + Task.FromResult>([]); + Task> GetToolsAsync(CancellationToken cancellationToken) => + Task.FromResult>([]); } diff --git a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor index a89caedb..c2cd007a 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor @@ -92,6 +92,83 @@ else @foreach (var item in agents) { } } + else if (SelectedStep is FlowCallStepDefinition flowCall) + { + + + @if (flowCall.Flow.VersionStrategy == FlowCallVersionStrategy.Exact) + { + + } + @if (SelectedFlowVersion?.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema + && inputSchema.TryGetProperty("properties", out var inputProperties) + && inputProperties.ValueKind == JsonValueKind.Object) + { +
@T("InputMapping") + @foreach (var property in inputProperties.EnumerateObject()) + { + var propertyName = property.Name; + + } +
+ } + @if (SelectedFlowVersion?.OutputSchema is { ValueKind: JsonValueKind.Object } outputSchema + && outputSchema.TryGetProperty("properties", out var outputProperties) + && outputProperties.ValueKind == JsonValueKind.Object) + { +

@T("AvailableOutput"): @string.Join(", ", outputProperties.EnumerateObject().Select(property => property.Name))

+ } + } + else if (SelectedStep is ToolFlowStepDefinition toolStep) + { + + @if (SelectedTool is { } selectedTool) + { + @if (!selectedTool.Enabled || !selectedTool.Available || selectedTool.RequiresApproval) + { +

@ToolState(selectedTool)

+ } + @if (selectedTool.InputSchema.ValueKind == JsonValueKind.Object + && selectedTool.InputSchema.TryGetProperty("properties", out var toolProperties) + && toolProperties.ValueKind == JsonValueKind.Object) + { +
@T("ArgumentsMapping") + @foreach (var property in toolProperties.EnumerateObject()) + { + var propertyName = property.Name; + + } +
+ } + @if (selectedTool.OutputSchema is { ValueKind: JsonValueKind.Object } toolOutput + && toolOutput.TryGetProperty("properties", out var toolOutputProperties) + && toolOutputProperties.ValueKind == JsonValueKind.Object) + { +

@T("AvailableOutput"): @string.Join(", ", toolOutputProperties.EnumerateObject().Select(property => property.Name))

+ } + } + } else if (SelectedStep is RouterFlowStepDefinition router) {
@@ -163,12 +240,15 @@ else } @code { - private static readonly (string Type, string Icon)[] Palette = [("input", "●"), ("agent", "◎"), ("router", "◇"), ("condition", "?"), ("transform", "ƒ"), ("output", "□"), ("failure", "!")]; + private static readonly (string Type, string Icon)[] Palette = [("input", "●"), ("agent", "◎"), ("flow", "↳"), ("tool", "⌁"), ("router", "◇"), ("condition", "?"), ("transform", "ƒ"), ("output", "□"), ("failure", "!")]; private readonly CancellationTokenSource cancellation = new(); private CancellationTokenSource? autosave; private FlowCanvas? canvas; private IReadOnlyList agents = []; private IReadOnlyList routerAgents = []; + private IReadOnlyList flows = []; + private IReadOnlyList flowVersions = []; + private IReadOnlyList tools = []; private string? loadedResource; private string sourceText = string.Empty; private string transitionFrom = string.Empty; @@ -187,6 +267,14 @@ else [Parameter] public ResourceNamespace Namespace { get; set; } = ResourceNamespace.Default; private FlowStepDefinition? SelectedStep => Editor.State.Resource?.Definition.Steps.FirstOrDefault(step => step.Name == Editor.State.Selection.StepName); + private FlowDesignerFlowVersion? SelectedFlowVersion => SelectedStep is FlowCallStepDefinition call + ? flowVersions.FirstOrDefault(version => version.Version == (call.Flow.VersionStrategy == FlowCallVersionStrategy.Exact + ? call.Flow.Version + : flows.FirstOrDefault(flow => FlowKey(flow.Namespace, flow.Name) == FlowKey(call.Flow))?.ActiveVersion)) + : null; + private FlowDesignerTool? SelectedTool => SelectedStep is ToolFlowStepDefinition tool + ? tools.FirstOrDefault(item => ToolKey(item.Namespace, item.Name) == ToolKey(tool.Tool)) + : null; private string SaveText => T($"SaveState.{Editor.State.SaveState}"); private string T(string key, params object[] arguments) => Localizer[key, arguments].Value; private FlowDesignerTarget Target => new(Namespace, ResourceId); @@ -205,6 +293,9 @@ else sourceText = loaded.Source; Editor.Load(loaded, Target); agents = routerAgents = await Resources.GetAgentsAsync(cancellation.Token); + flows = await Resources.GetFlowsAsync(cancellation.Token); + tools = await Resources.GetToolsAsync(cancellation.Token); + if (SelectedStep is FlowCallStepDefinition call) await LoadFlowVersionsAsync(call.Flow); transitionFrom = loaded.Resource.Definition.EntryStep; transitionTo = loaded.Resource.Definition.Steps.Skip(1).FirstOrDefault()?.Name ?? transitionFrom; } @@ -261,12 +352,15 @@ else { "input" => new InputFlowStepDefinition { Name = name, DisplayName = "Input" }, "agent" => new AgentFlowStepDefinition { Name = name, DisplayName = "Agent", Agent = new(agents.FirstOrDefault()?.Name ?? UnconfiguredAgent) }, + "flow" => new FlowCallStepDefinition { Name = name, DisplayName = "Flow", Flow = DefaultFlowReference(), InputMapping = JsonSerializer.SerializeToElement(new { }) }, + "tool" => new ToolFlowStepDefinition { Name = name, DisplayName = T("Step.tool"), Tool = DefaultToolReference(), ArgumentsMapping = JsonSerializer.SerializeToElement(new { }) }, "router" => new RouterFlowStepDefinition { Name = name, DisplayName = "Router" }, "condition" => new ConditionFlowStepDefinition { Name = name, DisplayName = "Condition", Left = "${input.prompt}", Right = "" }, "transform" => new TransformFlowStepDefinition { Name = name, DisplayName = "Transform", Mapping = JsonSerializer.SerializeToElement(new { }) }, "output" => new OutputFlowStepDefinition { Name = name, DisplayName = "Output", OutputMapping = JsonSerializer.SerializeToElement("${input}") }, _ => new FailureFlowStepDefinition { Name = name, DisplayName = "Failure" } }; + if (step is FlowCallStepDefinition flowCall) await LoadFlowVersionsAsync(flowCall.Flow); await DispatchAsync(new AddStepCommand(step, new(40 + index * 180, 250))); Editor.SelectStep(name); } @@ -274,6 +368,47 @@ else private async Task RemoveSelectedAsync() { if (SelectedStep is not null) { await DispatchAsync(new RemoveStepCommand(SelectedStep.Name)); Editor.SelectStep(null); } } private Task UpdateDisplayNameAsync(ChangeEventArgs e) => UpdateSelectedAsync(SelectedStep! with { DisplayName = Text(e) }); private Task UpdateAgentAsync(ChangeEventArgs e) => UpdateSelectedAsync(((AgentFlowStepDefinition)SelectedStep!) with { Agent = new(Text(e)) }); + private async Task UpdateFlowTargetAsync(ChangeEventArgs e) + { + if (SelectedStep is not FlowCallStepDefinition call || !TryParseFlowKey(Text(e), out var target)) return; + var reference = call.Flow with { ResourceId = target.Name, Namespace = target.Namespace == Namespace ? null : target.Namespace }; + await LoadFlowVersionsAsync(reference); + if (reference.VersionStrategy == FlowCallVersionStrategy.Exact && flowVersions.All(version => version.Version != reference.Version)) + reference = reference with { Version = flowVersions.FirstOrDefault()?.Version }; + await UpdateSelectedAsync(call with { Flow = reference }); + } + private async Task UpdateFlowVersionStrategyAsync(ChangeEventArgs e) + { + if (SelectedStep is not FlowCallStepDefinition call || !Enum.TryParse(Text(e), out var strategy)) return; + var reference = call.Flow with { VersionStrategy = strategy, Version = strategy == FlowCallVersionStrategy.Exact ? flowVersions.FirstOrDefault()?.Version : null }; + await UpdateSelectedAsync(call with { Flow = reference }); + } + private Task UpdateFlowExactVersionAsync(ChangeEventArgs e) => SelectedStep is FlowCallStepDefinition call + ? UpdateSelectedAsync(call with { Flow = call.Flow with { Version = Text(e) } }) + : Task.CompletedTask; + private Task UpdateFlowMappingAsync(string propertyName, ChangeEventArgs e) + { + if (SelectedStep is not FlowCallStepDefinition call) return Task.CompletedTask; + var mapping = call.InputMapping is { ValueKind: JsonValueKind.Object } value + ? value.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.Clone(), StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e)); + return UpdateSelectedAsync(call with { InputMapping = JsonSerializer.SerializeToElement(mapping) }); + } + private Task UpdateToolTargetAsync(ChangeEventArgs e) + { + if (SelectedStep is not ToolFlowStepDefinition tool || !TryParseToolKey(Text(e), out var target)) return Task.CompletedTask; + return UpdateSelectedAsync(tool with { Tool = new(target.Name, target.Namespace == Namespace ? null : target.Namespace), ArgumentsMapping = JsonSerializer.SerializeToElement(new { }) }); + } + private Task UpdateToolMappingAsync(string propertyName, ChangeEventArgs e) + { + if (SelectedStep is not ToolFlowStepDefinition tool) return Task.CompletedTask; + var mapping = tool.ArgumentsMapping is { ValueKind: JsonValueKind.Object } value + ? value.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.Clone(), StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e)); + return UpdateSelectedAsync(tool with { ArgumentsMapping = JsonSerializer.SerializeToElement(mapping) }); + } private Task UpdateSelectedAsync(FlowStepDefinition step) => DispatchAsync(new UpdateStepCommand(step)); private async Task LoadAgentsAsync() { try { loadingAgents = true; routerAgents = await Resources.GetAgentsAsync(cancellation.Token); } catch (Exception exception) { error = exception.Message; } finally { loadingAgents = false; } } @@ -291,10 +426,42 @@ else private static string UniqueRoute(string prefix, IReadOnlyCollection candidates) { var route = prefix; var i = 1; while (candidates.Any(candidate => candidate.Route == route)) route = $"{prefix}-{++i}"; return route; } private static string RouteKey(string value) { var result = new string(value.Trim().ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()).Trim('-'); return string.IsNullOrWhiteSpace(result) ? "route" : result; } private string AgentLabel(string name) => routerAgents.Concat(agents).FirstOrDefault(item => item.Name == name)?.DisplayName ?? name; + private FlowCallReference DefaultFlowReference() + { + var target = flows.FirstOrDefault(item => item.Name != ResourceId || item.Namespace != Namespace); + return target is null ? new(UnconfiguredFlow) : new(target.Name, Namespace: target.Namespace == Namespace ? null : target.Namespace); + } + private FlowToolReference DefaultToolReference() + { + var target = tools.FirstOrDefault(); + return target is null ? new(UnconfiguredTool) : new(target.Name, target.Namespace == Namespace ? null : target.Namespace); + } + private async Task LoadFlowVersionsAsync(FlowCallReference reference) => flowVersions = await Resources.GetFlowVersionsAsync(reference.Namespace ?? Namespace, reference.ResourceId, cancellation.Token); + private static string FlowKey(ResourceNamespace @namespace, string name) => $"{@namespace.Value}|{name}"; + private string FlowKey(FlowCallReference reference) => FlowKey(reference.Namespace ?? Namespace, reference.ResourceId); + private static bool TryParseFlowKey(string value, out (ResourceNamespace Namespace, string Name) target) + { + var parts = value.Split('|', 2); + if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[1])) { target = (ResourceNamespace.Parse(parts[0]), parts[1]); return true; } + target = default; return false; + } + private static string ToolKey(ResourceNamespace @namespace, string name) => $"{@namespace.Value}|{name}"; + private string ToolKey(FlowToolReference reference) => ToolKey(reference.Namespace ?? Namespace, reference.ResourceId); + private static bool TryParseToolKey(string value, out (ResourceNamespace Namespace, string Name) target) => TryParseFlowKey(value, out target); + private string ToolState(FlowDesignerTool tool) => !tool.Enabled + ? T("ToolDisabled") + : !tool.Available + ? T("ToolUnavailable") + : tool.RequiresApproval ? T("ToolApprovalRequired") : string.Empty; + private static string MappingValue(JsonElement? mapping, string propertyName) => mapping is { ValueKind: JsonValueKind.Object } value && value.TryGetProperty(propertyName, out var property) + ? property.ValueKind == JsonValueKind.String ? property.GetString() ?? string.Empty : property.GetRawText() + : string.Empty; private static string Text(ChangeEventArgs e) => e.Value?.ToString()?.Trim() ?? string.Empty; private static string? EmptyToNull(string value) => string.IsNullOrWhiteSpace(value) ? null : value; private static string NextVersion(string value) { var parts = value.Split('.'); return parts.Length == 3 && int.TryParse(parts[2], out var patch) ? $"{parts[0]}.{parts[1]}.{patch + 1}" : "1.0.0"; } private const string UnconfiguredAgent = "unconfigured"; + private const string UnconfiguredFlow = "unconfigured"; + private const string UnconfiguredTool = "unconfigured"; public ValueTask DisposeAsync() { diff --git a/src/Agentstration.Web.FlowDesigner/Components/FlowTopology.cs b/src/Agentstration.Web.FlowDesigner/Components/FlowTopology.cs index 0ce361d5..1c641f13 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowTopology.cs +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowTopology.cs @@ -443,6 +443,8 @@ private static IReadOnlyList LinearEdges(IReadOnlyList $"{router.Candidates.Count} routes", ConditionFlowStepDefinition condition => condition.Mode, TransformFlowStepDefinition transform => transform.Mode, + FlowCallStepDefinition flow => flow.Flow.VersionStrategy == FlowCallVersionStrategy.Exact ? $"{flow.Flow.ResourceId} · {flow.Flow.Version}" : $"{flow.Flow.ResourceId} · active", + ToolFlowStepDefinition tool => tool.Tool.ResourceId, FailureFlowStepDefinition failure => failure.Code, _ => null }; diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx index 71303e47..a081ef16 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx @@ -16,10 +16,17 @@ EnregistrerVersionVersion à publierPublier Étapes EntréeAgentRouteurConditionTransformationSortieÉchec + Flow + Tool Disposition horizontaleDisposition verticaleCentrer InspecteurSélectionnez une étape pour la modifier. Nom d’affichageNom techniqueType AgentSortie du routeur + Flow appeléStratégie de version + Version activeVersion exacte + Version exacteMappage d’entréePropriétés de sortie disponibles + Tool appeléMappage des arguments + Ce Tool est désactivé.Ce Tool est indisponible auprès de son fournisseur.Ce Tool nécessite une approbation avant son appel. Charger les agentsTout ajouter {0} agent disponible. Les routes sont explicites et restent des références immuables après publication. {0} agents disponibles. Les routes sont explicites et restent des références immuables après publication. diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx index df687f9e..bd6aa0cf 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx @@ -22,10 +22,17 @@ RouterCondition TransformOutput Failure + Flow + Tool Horizontal layoutVertical layoutCenter InspectorSelect a step to edit it. Display nameTechnical nameType AgentRouter output + Called FlowVersion strategy + Active versionExact version + Exact versionInput mappingAvailable output properties + Called ToolArguments mapping + This Tool is disabled.This Tool is unavailable from its provider.This Tool requires approval before invocation. Load agentsAdd all {0} agent available. Routes are explicit and remain immutable references when published. {0} agents available. Routes are explicit and remain immutable references when published. diff --git a/src/Agentstration.Web.FlowDesigner/State/FlowEditorStore.cs b/src/Agentstration.Web.FlowDesigner/State/FlowEditorStore.cs index 0a873b1a..f8ce24f0 100644 --- a/src/Agentstration.Web.FlowDesigner/State/FlowEditorStore.cs +++ b/src/Agentstration.Web.FlowDesigner/State/FlowEditorStore.cs @@ -16,7 +16,7 @@ public static FlowDesignerDocument From(FlowGraphDefinition definition) { var nodes = definition.Steps.Select((step, index) => new FlowDesignerNode(step.Name, step.Type(), step.DisplayName ?? step.Name, definition.Designer.NodePositions.TryGetValue(step.Name, out var position) ? position : new(index * 200, 50), - step switch { AgentFlowStepDefinition agent => agent.Agent.ResourceId, RouterFlowStepDefinition router => $"{router.Candidates.Count} routes", _ => null })).ToArray(); + step switch { AgentFlowStepDefinition agent => agent.Agent.ResourceId, RouterFlowStepDefinition router => $"{router.Candidates.Count} routes", FlowCallStepDefinition flow => flow.Flow.ResourceId, ToolFlowStepDefinition tool => tool.Tool.ResourceId, _ => null })).ToArray(); return new(nodes, definition.Transitions.Select(transition => new FlowDesignerLink(transition.Id, transition.FromStep, transition.ToStep, transition.Event)).ToArray()); } } diff --git a/src/Agentstration.Web/Api/AgentstrationMcpHandlers.cs b/src/Agentstration.Web/Api/AgentstrationMcpHandlers.cs new file mode 100644 index 00000000..d2754442 --- /dev/null +++ b/src/Agentstration.Web/Api/AgentstrationMcpHandlers.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Agentstration.Infrastructure.Notifications; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.Resources; +using Agentstration.Runtime.Abstractions; +using Agentstration.Tools.Mcp; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Agentstration.Web.Api; + +internal static class AgentstrationMcpHandlers +{ + public static async ValueTask ListToolsAsync( + RequestContext request, + CancellationToken cancellationToken) + { + var services = request.Services ?? throw new InvalidOperationException("MCP request services are unavailable."); + var current = services.GetRequiredService().Current; + await services.GetRequiredService().EnsureAsync( + ResourceScopeRef.Workspace(current.WorkspaceId), ResourceNamespace.Default, cancellationToken); + var definitions = services.GetRequiredService(); + var values = await definitions.ListAsync(cancellationToken); + var projected = (await services.GetRequiredService() + .ListAllAsync(ResourceKinds.Tool, cancellationToken)) + .Select(value => value.Value) + .Where(value => value.Namespace.IsDefault && value.Definition.Provider?.Name == AgentstrationToolProvider.Name) + .ToDictionary(value => value.Definition.ExternalId ?? string.Empty, StringComparer.Ordinal); + var builtIns = services.GetServices() + .Select(value => value.Definition) + .Where(value => projected.TryGetValue(value.Name, out var tool) + && tool.Definition.Enabled + && tool.Definition.Discovery?.Available == true) + .Select(value => new Tool + { + Name = value.Name, + Title = value.DisplayName, + Description = value.Description, + InputSchema = value.InputSchema.Clone(), + OutputSchema = value.OutputSchema?.Clone(), + Meta = new JsonObject + { + ["agentstration/namespace"] = ResourceNamespace.Default.Value, + ["agentstration/requiresApproval"] = value.RequiresApproval, + ["agentstration/implementation"] = "internal" + } + }); + return new ListToolsResult + { + Tools = builtIns.Concat(values + .Select(value => value.Value) + .Where(value => value.Definition.Enabled) + .OrderBy(value => value.Namespace.Value, StringComparer.Ordinal) + .ThenBy(value => value.Name, StringComparer.Ordinal) + .Select(value => new Tool + { + Name = PublicName(value), + Title = value.Definition.DisplayName, + Description = value.Definition.Description, + InputSchema = value.Definition.InputSchema.Clone(), + OutputSchema = value.Definition.OutputSchema?.Clone(), + Meta = new JsonObject + { + ["agentstration/namespace"] = value.Namespace.Value, + ["agentstration/requiresApproval"] = value.Definition.RequiresApproval, + ["agentstration/implementation"] = "flow" + } + })) + .OrderBy(value => value.Name, StringComparer.Ordinal) + .ToList() + }; + } + + public static async ValueTask CallToolAsync( + RequestContext request, + CancellationToken cancellationToken) + { + try + { + var parameters = request.Params ?? throw new ToolDefinitionInvocationException("tool_call_invalid", "Tool call parameters are required."); + var services = request.Services ?? throw new ToolDefinitionInvocationException("tool_execution_scope_required", "MCP request services are unavailable."); + var context = services.GetRequiredService(); + var current = context.Current; + await services.GetRequiredService().EnsureAsync( + ResourceScopeRef.Workspace(current.WorkspaceId), ResourceNamespace.Default, cancellationToken); + var arguments = JsonSerializer.SerializeToElement(parameters.Arguments ?? new Dictionary()); + var builtIn = services.GetServices() + .SingleOrDefault(value => string.Equals(value.Definition.Name, parameters.Name, StringComparison.Ordinal)); + if (builtIn is not null) + { + var callId = IdempotencyKey(parameters.Meta) ?? request.JsonRpcRequest.Id.ToString(); + var resourceName = AgentstrationToolProvider.ToolResourceName(builtIn.Definition.Name); + var builtInOutput = await services.GetRequiredService().ExecuteAsync(new ToolExecutionContext + { + ToolCallId = callId, + InvocationId = $"{callId}:attempt:1", + ToolId = resourceName, + ToolNamespace = ResourceNamespace.Default, + ToolName = builtIn.Definition.Name, + ToolProviderId = AgentstrationToolProvider.Name, + ToolProviderNamespace = ResourceNamespace.Default, + ExternalToolId = builtIn.Definition.Name, + TenantId = current.TenantId, + WorkspaceId = new WorkspaceId(current.WorkspaceId), + PrincipalId = current.PrincipalId, + CorrelationId = Correlation(parameters.Meta), + Arguments = arguments + }, cancellationToken); + return new CallToolResult + { + Content = [new TextContentBlock { Text = builtInOutput?.GetRawText() ?? "null" }], + StructuredContent = builtInOutput + }; + } + var definitions = await services.GetRequiredService().ListAsync(cancellationToken); + var definition = definitions.Select(value => value.Value).SingleOrDefault(value => + value.Definition.Enabled && string.Equals(PublicName(value), parameters.Name, StringComparison.Ordinal)) + ?? throw new ToolDefinitionInvocationException("tool_definition_not_found", $"Tool '{parameters.Name}' is not published by the Agentstration MCP server."); + if (definition.Definition.RequiresApproval) + throw new ToolDefinitionInvocationException("tool_approval_required", $"Tool '{parameters.Name}' requires an approved Agent invocation."); + var invocation = new ToolDefinitionInvocation( + current.TenantId, + new WorkspaceId(current.WorkspaceId), + current.PrincipalId, + definition.Namespace, + definition.Name, + IdempotencyKey(parameters.Meta) ?? request.JsonRpcRequest.Id.ToString(), + Correlation(parameters.Meta), + arguments, + ToolDefinitionCallerKind.Mcp); + var result = await services.GetRequiredService().ExecuteAsync(invocation, cancellationToken); + var output = result.Output?.Clone(); + return new CallToolResult + { + Content = [new TextContentBlock { Text = output?.GetRawText() ?? "null" }], + StructuredContent = output, + Meta = JsonSerializer.SerializeToNode(result.Receipt)?.AsObject() + }; + } + catch (ToolDefinitionInvocationException exception) + { + return Error(exception.Code, exception.Message); + } + catch (ToolExecutionDeniedException exception) + { + return Error(exception.Code, exception.Message); + } + catch (ToolResolutionException exception) + { + return Error(exception.Code, exception.Message); + } + } + + private static CallToolResult Error(string code, string message) => new() + { + IsError = true, + Content = [new TextContentBlock { Text = message }], + Meta = new JsonObject { ["agentstration/errorCode"] = code } + }; + + private static string PublicName(ToolDefinitionResource definition) => definition.Namespace.IsDefault + ? definition.Name + : $"{definition.Namespace.Value}.{definition.Name}"; + + private static string? Correlation(JsonObject? metadata) => + metadata?["agentstration/correlationId"]?.GetValue(); + + private static string? IdempotencyKey(JsonObject? metadata) => + metadata?["agentstration/idempotencyKey"]?.GetValue(); +} diff --git a/src/Agentstration.Web/Api/FlowEndpoints.Runs.cs b/src/Agentstration.Web/Api/FlowEndpoints.Runs.cs index dce22ab9..fd1ca3d2 100644 --- a/src/Agentstration.Web/Api/FlowEndpoints.Runs.cs +++ b/src/Agentstration.Web/Api/FlowEndpoints.Runs.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Agentstration.Application.Work; using Agentstration.Flow; using Agentstration.Flow.Application; using Agentstration.Flow.Contracts; @@ -11,20 +12,34 @@ namespace Agentstration.Web; public static partial class FlowEndpoints { - private static Task CreateRunAsync(string id, CreateFlowRunRequest body, HttpContext context, HttpResponse response, FlowRunService service, ICurrentRequestContext requestContext, CancellationToken token) => + private static Task CreateRunAsync(string id, CreateFlowRunRequest body, HttpContext context, HttpResponse response, RootFlowSubmissionService service, ICurrentRequestContext requestContext, CancellationToken token) => CreateRunCoreAsync(new FlowId(id), body, context, response, service, requestContext, token); - private static Task CreateNamespacedRunAsync(string @namespace, string id, CreateFlowRunRequest body, HttpContext context, HttpResponse response, FlowRunService service, ICurrentRequestContext requestContext, CancellationToken token) => + private static Task CreateNamespacedRunAsync(string @namespace, string id, CreateFlowRunRequest body, HttpContext context, HttpResponse response, RootFlowSubmissionService service, ICurrentRequestContext requestContext, CancellationToken token) => CreateRunCoreAsync(new FlowId(id, ResourceNamespace.Parse(@namespace)), body, context, response, service, requestContext, token); - private static Task CreateRunCoreAsync(FlowId flowId, CreateFlowRunRequest body, HttpContext context, HttpResponse response, FlowRunService service, ICurrentRequestContext requestContext, CancellationToken token) => ExecuteAsync(async () => + private static Task CreateRunCoreAsync(FlowId flowId, CreateFlowRunRequest body, HttpContext context, HttpResponse response, RootFlowSubmissionService service, ICurrentRequestContext requestContext, CancellationToken token) => ExecuteAsync(async () => { var scope = CurrentScope(requestContext); var startedBy = context.Features.Get()?.Principal.DisplayName ?? scope.PrincipalId.ToString("D"); - var stored = await service.CreateAsync(flowId, body.Version, body.DeploymentResourceId, body.Trigger, startedBy, body.CorrelationId, body.Input, scope, token); - response.Headers.Location = $"/api/flowRuns/{stored.Value.Id}"; - response.Headers.ETag = stored.ETag; - return Results.Accepted($"/api/flowRuns/{stored.Value.Id}", stored.Value); + var origin = string.Equals(context.Request.Headers["X-Agentstration-Origin"].FirstOrDefault(), "Console", StringComparison.OrdinalIgnoreCase) + ? FlowInvocationOrigin.Console + : FlowInvocationOrigin.Api; + var submission = await service.SubmitAsync(new SubmitRootFlowCommand( + scope.WorkspaceId, + new FlowReference(flowId, body.Version, body.Version is null, flowId.Namespace), + body.Input, + origin, + startedBy, + body.Trigger, + context.Request.Headers["Idempotency-Key"].FirstOrDefault(), + context.Request.Headers["X-Causation-Id"].FirstOrDefault(), + body.CorrelationId, + Type: "flow-api", + Instruction: $"Execute Flow '{flowId}'."), token); + response.Headers.Location = $"/api/flowRuns/{submission.FlowRun.Run.Id}"; + response.Headers.ETag = submission.FlowRun.ETag; + return Results.Accepted($"/api/flowRuns/{submission.FlowRun.Run.Id}", submission.FlowRun.Run); }); private static Task ListFlowRunsAsync(string id, FlowRunStatus? status, int? skip, int? top, FlowRunService service, ICurrentRequestContext requestContext, CancellationToken token) => @@ -71,6 +86,23 @@ private static Task GetRunAsync(string runId, HttpResponse response, Fl return Results.Ok(stored.Value); }); + private static Task GetRunCausalityAsync( + string runId, + int? skip, + int? top, + FlowRunService service, + ICurrentRequestContext requestContext, + CancellationToken token) => ExecuteAsync(async () => + { + var actualSkip = Math.Max(0, skip ?? 0); + var actualTop = Math.Clamp(top ?? 25, 1, 100); + var page = await service.GetCausalityAsync(runId, actualSkip, actualTop, CurrentScope(requestContext), token); + var next = page.HasMore + ? $"/api/flowRuns/{Uri.EscapeDataString(runId)}/causality?skip={actualSkip + page.Items.Count}&top={actualTop}" + : null; + return Results.Ok(new FlowRunCausalityPageResponse(page.Origin, page.Items, page.TotalCount, next)); + }); + private static Task DeleteRunAsync(string runId, HttpRequest request, FlowRunService service, ICurrentRequestContext requestContext, CancellationToken token) => ExecuteAsync(async () => { var expectedETag = request.Headers.IfMatch.FirstOrDefault() diff --git a/src/Agentstration.Web/Api/FlowEndpoints.Support.cs b/src/Agentstration.Web/Api/FlowEndpoints.Support.cs index 8cd9afee..b8305e7e 100644 --- a/src/Agentstration.Web/Api/FlowEndpoints.Support.cs +++ b/src/Agentstration.Web/Api/FlowEndpoints.Support.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Agentstration.Application.Work; using Agentstration.Flow; using Agentstration.Flow.Application; using Agentstration.Flow.Contracts; @@ -6,6 +7,7 @@ using Agentstration.Management.Abstractions; using Agentstration.Resources; using Agentstration.Web.Security; +using Agentstration.Work; namespace Agentstration.Web; @@ -31,6 +33,8 @@ private static async Task ExecuteAsync(Func> action) catch (InputRequestAlreadyResolvedException exception) { return Results.Problem(statusCode: 409, title: "input_request_already_resolved", detail: exception.Message); } catch (FlowConcurrencyException exception) { return Results.Problem(statusCode: 412, title: "precondition_failed", detail: exception.Message); } catch (FlowValidationException exception) { return Results.Problem(statusCode: 400, title: exception.Code, detail: exception.Message); } + catch (WorkValidationException exception) when (exception.Code == "flow_invocation_idempotency_conflict") { return Results.Problem(statusCode: 409, title: exception.Code, detail: exception.Message); } + catch (WorkValidationException exception) { return Results.Problem(statusCode: 400, title: exception.Code, detail: exception.Message); } catch (ArgumentException exception) { return Results.Problem(statusCode: 400, title: "validation_failed", detail: exception.Message); } } diff --git a/src/Agentstration.Web/Api/FlowEndpoints.cs b/src/Agentstration.Web/Api/FlowEndpoints.cs index 3298bd10..643c5be1 100644 --- a/src/Agentstration.Web/Api/FlowEndpoints.cs +++ b/src/Agentstration.Web/Api/FlowEndpoints.cs @@ -48,6 +48,7 @@ public static IEndpointRouteBuilder MapAgentstrationFlowApi(this IEndpointRouteB var runs = endpoints.MapGroup("/api/flowRuns").RequireAuthorization(AgentstrationPolicies.Authenticated); runs.MapGet("/", ListRunsAsync).RequireAuthorization(AgentstrationPolicies.CanReadRuns); runs.MapGet("/{runId}", GetRunAsync).RequireAuthorization(AgentstrationPolicies.CanReadRuns); + runs.MapGet("/{runId}/causality", GetRunCausalityAsync).RequireAuthorization(AgentstrationPolicies.CanReadRuns); runs.MapDelete("/{runId}", DeleteRunAsync).RequireAuthorization(AgentstrationPolicies.CanDeleteRuns); runs.MapGet("/{runId}/events", ObserveRunAsync).RequireAuthorization(AgentstrationPolicies.CanReadRuns); runs.MapGet("/{runId}/eventHistory", ListRunEventsAsync).RequireAuthorization(AgentstrationPolicies.CanReadRuns); diff --git a/src/Agentstration.Web/Api/ModelManagementEndpoints.cs b/src/Agentstration.Web/Api/ModelManagementEndpoints.cs index 7e23ed72..4ea34848 100644 --- a/src/Agentstration.Web/Api/ModelManagementEndpoints.cs +++ b/src/Agentstration.Web/Api/ModelManagementEndpoints.cs @@ -30,6 +30,7 @@ public static IEndpointRouteBuilder MapAgentstrationModelManagementApi(this IEnd RuntimeProfileEndpoints.Map(endpoints.MapGroup("/api/runtimeprofiles")); ToolProviderEndpoints.Map(endpoints); + ToolDefinitionEndpoints.Map(endpoints.MapGroup("/api/tooldefinitions")); ToolExecutionHookEndpoints.Map(endpoints.MapGroup("/api/toolexecutionhooks")); SecretEndpoints.Map(endpoints); ResourceScopeEndpoints.Map(endpoints); diff --git a/src/Agentstration.Web/Api/Models/ModelManagementHttp.cs b/src/Agentstration.Web/Api/Models/ModelManagementHttp.cs index 06df8fd9..598b9e9e 100644 --- a/src/Agentstration.Web/Api/Models/ModelManagementHttp.cs +++ b/src/Agentstration.Web/Api/Models/ModelManagementHttp.cs @@ -53,6 +53,7 @@ public static async Task ExecuteAsync(Func> action) } catch (RuntimeProfileValidationException exception) { return Problem("runtime-profile-invalid", "Invalid runtime profile", 422, exception.Message); } catch (ToolResourceValidationException exception) { return Problem("tool-resource-invalid", "Invalid tool resource", 422, exception.Message); } + catch (ToolDefinitionValidationException exception) { return Problem(exception.Code, "Invalid ToolDefinition", 422, exception.Message); } catch (ToolExecutionHookValidationException exception) { return Problem("tool-execution-hook-invalid", "Invalid Tool execution hook", 422, exception.Message); } catch (ToolProviderDiscoveryFailedException exception) { return Problem("tool-provider-unavailable", "Tool provider unavailable", 503, exception.Message); } catch (ModelProfileInUseException exception) @@ -114,6 +115,12 @@ public static IResult ResourceResult(StoredResource stored, HttpRe return Results.Json(stored.Value, statusCode: statusCode); } + public static IResult ResourceResult(StoredResource stored, HttpResponse response, int statusCode) + { + response.Headers.ETag = stored.ETag; + return Results.Json(stored.Value, statusCode: statusCode); + } + public static IResult ResourceResult(StoredResource stored, HttpResponse response, int statusCode) { response.Headers.ETag = stored.ETag; diff --git a/src/Agentstration.Web/Api/Models/ToolDefinitionEndpoints.cs b/src/Agentstration.Web/Api/Models/ToolDefinitionEndpoints.cs new file mode 100644 index 00000000..a6d403c1 --- /dev/null +++ b/src/Agentstration.Web/Api/Models/ToolDefinitionEndpoints.cs @@ -0,0 +1,124 @@ +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Management.Core; +using Agentstration.Resources; +using Agentstration.Security; +using Agentstration.Web.Security; + +namespace Agentstration.Web.Api.Models; + +internal static class ToolDefinitionEndpoints +{ + public static void Map(RouteGroupBuilder definitions) + { + definitions.MapGet("/", ListAsync) + .Produces>() + .WithSummary("List ToolDefinitions") + .RequireAuthorization(AgentstrationPolicies.CanReadResources); + definitions.MapGet("/{name}", GetAsync) + .Produces() + .WithSummary("Get a ToolDefinition") + .RequireAuthorization(AgentstrationPolicies.CanReadResources); + definitions.MapPost("/", CreateAsync) + .Produces(StatusCodes.Status201Created) + .WithSummary("Create a ToolDefinition") + .RequireAuthorization(AgentstrationPolicies.CanWriteResources); + definitions.MapPut("/{name}", PutAsync) + .Produces() + .WithSummary("Update a ToolDefinition") + .RequireAuthorization(AgentstrationPolicies.CanWriteResources); + definitions.MapPut("/{name}/enabled", SetEnabledAsync) + .Produces() + .WithSummary("Enable or disable a ToolDefinition") + .RequireAuthorization(AgentstrationPolicies.CanWriteResources); + definitions.MapDelete("/{name}", DeleteAsync) + .Produces(StatusCodes.Status204NoContent) + .WithSummary("Delete a ToolDefinition") + .RequireAuthorization(AgentstrationPolicies.CanWriteResources); + } + + private static Task ListAsync(string? @namespace, ToolDefinitionService service, CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => + { + var values = (await service.ListAsync(cancellationToken)).Select(value => value.Value); + if (!string.IsNullOrWhiteSpace(@namespace)) + { + var parsed = ResourceNamespace.Parse(@namespace); + values = values.Where(value => value.Namespace == parsed); + } + return Results.Ok(new ValueResponse(values.ToArray())); + }); + + private static Task GetAsync(string name, string? @namespace, HttpResponse response, ToolDefinitionService service, CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => + { + var ns = ResourceNamespace.Parse(@namespace); + var stored = await service.GetAsync(name, ns, cancellationToken) + ?? throw new ControlPlaneResourceNotFoundException(new(ResourceKinds.ToolDefinition, name, ns)); + return ModelManagementHttp.ResourceResult(stored, response, 200); + }); + + private static Task CreateAsync( + CreateToolDefinitionRequest body, + HttpResponse response, + ToolDefinitionService service, + ICurrentRequestContext context, + CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => + { + var ns = ResourceNamespace.Parse(body.Namespace); + var resource = Resource(body.Name, ns, body.Properties, ResourceScopeRef.Workspace(context.Current.WorkspaceId)); + var stored = await service.PutAsync(resource, null, true, cancellationToken); + response.Headers.Location = $"/api/tooldefinitions/{Uri.EscapeDataString(body.Name)}?namespace={Uri.EscapeDataString(ns.Value)}"; + return ModelManagementHttp.ResourceResult(stored, response, 201); + }); + + private static Task PutAsync( + string name, + string? @namespace, + PutToolDefinitionRequest body, + HttpRequest request, + HttpResponse response, + ToolDefinitionService service, + ICurrentRequestContext context, + CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => + { + var ns = ResourceNamespace.Parse(@namespace); + var resource = Resource(name, ns, body.Properties, ResourceScopeRef.Workspace(context.Current.WorkspaceId)); + return ModelManagementHttp.ResourceResult( + await service.PutAsync(resource, ModelManagementHttp.IfMatch(request), false, cancellationToken), response, 200); + }); + + private static Task SetEnabledAsync( + string name, + string? @namespace, + SetToolDefinitionEnabledRequest body, + HttpRequest request, + HttpResponse response, + ToolDefinitionService service, + CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => ModelManagementHttp.ResourceResult( + await service.SetEnabledAsync(name, ResourceNamespace.Parse(@namespace), body.Enabled, ModelManagementHttp.IfMatch(request), cancellationToken), response, 200)); + + private static Task DeleteAsync( + string name, + string? @namespace, + HttpRequest request, + ToolDefinitionService service, + CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => + { + await service.DeleteAsync(name, ResourceNamespace.Parse(@namespace), ModelManagementHttp.IfMatch(request), cancellationToken); + return Results.NoContent(); + }); + + private static ToolDefinitionResource Resource(string name, ResourceNamespace @namespace, ToolDefinitionProperties properties, ResourceScopeRef scopeRef) => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolDefinition, + Metadata = new ResourceMetadata { Name = name, Namespace = @namespace }, + ScopeRef = scopeRef, + Definition = properties + }; +} diff --git a/src/Agentstration.Web/Api/Models/ToolProviderEndpoints.cs b/src/Agentstration.Web/Api/Models/ToolProviderEndpoints.cs index 9f5cc708..8f1a6db7 100644 --- a/src/Agentstration.Web/Api/Models/ToolProviderEndpoints.cs +++ b/src/Agentstration.Web/Api/Models/ToolProviderEndpoints.cs @@ -1,6 +1,8 @@ +using Agentstration.Infrastructure.Notifications; using Agentstration.Management.Abstractions; using Agentstration.Management.Contracts; using Agentstration.Management.Core; +using Agentstration.Resources; using Agentstration.Web.Security; namespace Agentstration.Web.Api.Models; @@ -24,8 +26,12 @@ public static void Map(IEndpointRouteBuilder endpoints) tools.MapPut("/{toolName}/enabled", SetEnabledAsync).RequireAuthorization(AgentstrationPolicies.CanWriteResources); } - private static Task ListProvidersAsync(ToolManagementService service, CancellationToken cancellationToken) => - ModelManagementHttp.ExecuteAsync(async () => Results.Ok(new ValueResponse((await service.ListProvidersAsync(cancellationToken)).Select(value => value.Value).ToArray()))); + private static Task ListProvidersAsync(ToolManagementService service, InternalMcpToolProjectionService internalTools, ICurrentRequestContext context, CancellationToken cancellationToken) => + ModelManagementHttp.ExecuteAsync(async () => + { + await internalTools.EnsureAsync(ResourceScopeRef.Workspace(context.Current.WorkspaceId), ResourceNamespace.Default, cancellationToken); + return Results.Ok(new ValueResponse((await service.ListProvidersAsync(cancellationToken)).Select(value => value.Value).ToArray())); + }); private static Task GetProviderAsync(string providerName, HttpResponse response, ToolManagementService service, CancellationToken cancellationToken) => ModelManagementHttp.ExecuteAsync(async () => @@ -80,9 +86,10 @@ private static Task ListProviderToolsAsync(string providerName, ToolMan return Results.Ok(new ValueResponse(tools.Where(value => value.Value.Definition.Provider?.Name == providerId).Select(value => value.Value).ToArray())); }); - private static Task ListToolsAsync(bool? enabled, bool? available, ToolManagementService service, CancellationToken cancellationToken) => + private static Task ListToolsAsync(bool? enabled, bool? available, ToolManagementService service, InternalMcpToolProjectionService internalTools, ICurrentRequestContext context, CancellationToken cancellationToken) => ModelManagementHttp.ExecuteAsync(async () => { + await internalTools.EnsureAsync(ResourceScopeRef.Workspace(context.Current.WorkspaceId), ResourceNamespace.Default, cancellationToken); var values = (await service.ListToolsAsync(cancellationToken)).Select(value => value.Value); if (enabled.HasValue) values = values.Where(value => value.Definition.Enabled == enabled.Value); if (available.HasValue) values = values.Where(value => value.Definition.Discovery?.Available == available.Value); diff --git a/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor b/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor index 8a5fd035..6fc96a0b 100644 --- a/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor +++ b/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor @@ -30,7 +30,7 @@ else @if (run.Error is not null) @@ -78,7 +78,9 @@ else
@T("Deployment")@(run.DeploymentResourceId ?? "—")
@T("TriggerAndStartedBy")@TriggerLabel(run.Trigger) · @(run.StartedBy ?? "—")
+ @if (run.InvocationOrigin is { } origin) {
@T("OriginAndCaller")@OriginLabel(origin) · @(run.CallerId ?? "—")
}
@T("CorrelationId")@run.CorrelationId
+ @if (!string.IsNullOrWhiteSpace(run.CausationId)) {
@T("CausationId")@run.CausationId
}
@@ -96,7 +98,7 @@ else } else { -
@T("Status")
@StepStatusLabel(selected.Status)
@T("Type")
@selected.StepType
@T("Duration")
@Duration(selected.StartedAt, selected.CompletedAt)
@T("Attempt")
@selected.Attempt
@T("Transition")
@(selected.SelectedTransition ?? "—")
@T("Agent")
@(selected.AgentResourceId ?? "—")
@T("AgentVersion")
@(selected.AgentVersion?.ToString() ?? "—")
@T("ModelProfile")
@(selected.ModelProfileResourceId ?? "—")
@T("Provider")
@(selected.Provider ?? "—")
@T("Tokens")
@TokenUsage(selected.Usage)
@T("Tools")
@(selected.Tools.Count == 0 ? "—" : string.Join(", ", selected.Tools))
+
@T("Status")
@StepStatusLabel(selected.Status)
@T("Type")
@selected.StepType
@T("Duration")
@Duration(selected.StartedAt, selected.CompletedAt)
@T("Attempt")
@selected.Attempt
@T("Transition")
@(selected.SelectedTransition ?? "—")
@T("ChildFlowRun")
@if (selected.ChildFlowRunId is { } childRunId) { @childRunId } else { }
@T("Agent")
@(selected.AgentResourceId ?? "—")
@T("AgentVersion")
@(selected.AgentVersion?.ToString() ?? "—")
@T("ModelProfile")
@(selected.ModelProfileResourceId ?? "—")
@T("Provider")
@(selected.Provider ?? "—")
@T("Tokens")
@TokenUsage(selected.Usage)
@T("Tools")
@(selected.Tools.Count == 0 ? "—" : string.Join(", ", selected.Tools))
@T("DeclaredInput")
@Pretty(selected.DeclaredInput)
@T("ResolvedInput")
@Pretty(selected.ResolvedInput)
@T("Output")
@Pretty(selected.Output)
@T("Logs")
@string.Join(Environment.NewLine, selected.Logs)
@if (selected.Error is not null) {
@selected.Error.Code

@selected.Error.Message

} } @@ -105,6 +107,50 @@ else } + else if (activeTab == RunTab.Causality) + { + @if (causality is null) + { +

@(causalityError ?? T("CausalityLoading"))

+ } + else + { +
+
+

@T("InvocationOrigin")

@causality.TotalCount @T("FlowRuns")
+
@T("Origin")
@(causality.Origin.InvocationOrigin is { } origin ? OriginLabel(origin) : "—")
@T("Trigger")
@TriggerLabel(causality.Origin.Trigger)
@T("Caller")
@(causality.Origin.CallerId ?? "—")
@T("CausationId")
@(causality.Origin.CausationId ?? "—")
@T("CorrelationId")
@(causality.Origin.CorrelationId ?? "—")
+
+
    + @foreach (var node in causality.Value) + { +
  1. +
    + +
    @node.FlowRunId@(node.ResolvedFromActiveReference ? T("ResolvedFromActive") : T("ExactReference"))@if (node.ParentStepName is { } parentStep) { @T("CalledByStep", parentStep) }
    + @if (node.AgentExecutions.Count > 0) + { +

    @T("AgentExecutions")

    +
      @foreach (var agent in node.AgentExecutions) {
    • @agent.StepName@agent.AgentResourceId @if (agent.AgentVersion is { } version) { · v@version }@StepStatusLabel(agent.Status) · @(agent.Provider ?? "—")
    • }
    + } + @if (node.ToolCalls.Count > 0) + { +

    @T("ToolCalls")

    + + } +
    +
  2. + } +
+
+ } + } else if (activeTab == RunTab.InputOutput) {
@@ -125,11 +171,14 @@ else private string T(string key, params object[] arguments) => Localizer[key, arguments]; private string StatusLabel(FlowRunStatus value) => Localizer[$"Status.{value}"]; private string TriggerLabel(FlowRunTrigger value) => Localizer[$"Trigger.{value}"]; + private string OriginLabel(FlowInvocationOrigin value) => Localizer[$"Origin.{value}"]; private string StepStatusLabel(FlowStepRunStatus value) => Localizer[$"StepStatus.{value}"]; private string TokenUsage(FlowStepRunUsage? usage) => usage is null ? "—" : T("TokenUsage", usage.InputTokens ?? 0, usage.OutputTokens ?? 0); private readonly CancellationTokenSource cancellation = new(); private readonly FlowRunEventBuffer eventBuffer = new(); private FlowRun? run; + private FlowRunCausalityPageResponse? causality; + private string? causalityError; private InputRequest? pendingInput; private FlowStepRun? selected; private Microsoft.AspNetCore.SignalR.Client.HubConnection? hub; @@ -149,8 +198,8 @@ else private IReadOnlyList? replayEvents; private CancellationTokenSource? replayCancellation; private RunTab activeTab; - private static readonly RunTab[] StandardTabs = [RunTab.Summary, RunTab.Activity, RunTab.InputOutput, RunTab.RawEvents]; - private static readonly RunTab[] HandoffTabs = [RunTab.Summary, RunTab.Activity, RunTab.Handoffs, RunTab.InputOutput, RunTab.RawEvents]; + private static readonly RunTab[] StandardTabs = [RunTab.Summary, RunTab.Causality, RunTab.Activity, RunTab.InputOutput, RunTab.RawEvents]; + private static readonly RunTab[] HandoffTabs = [RunTab.Summary, RunTab.Causality, RunTab.Activity, RunTab.Handoffs, RunTab.InputOutput, RunTab.RawEvents]; private IReadOnlyList AvailableTabs => IsHandoffRun ? HandoffTabs : StandardTabs; private IReadOnlyDictionary ParticipantUrls => BuildParticipantUrls(); private FlowRunTimeline.TimelineView SelectedTimelineView => activeTab switch @@ -170,6 +219,7 @@ else private string TabLabel(RunTab tab) => T(tab switch { RunTab.Summary => "Summary", + RunTab.Causality => "Causality", RunTab.Activity => "Activity", RunTab.Handoffs => "Handoffs", RunTab.InputOutput => "InputOutputTab", @@ -219,6 +269,7 @@ else return; } await RefreshInputAsync(); + await RefreshCausalityAsync(); if (!IsHandoffRun && activeTab == RunTab.Handoffs) activeTab = RunTab.Summary; selected = run.Steps.FirstOrDefault(); eventBuffer.Clear(); @@ -289,8 +340,36 @@ else run = await Client.GetFlowRunAsync(RunId, cancellation.Token); await RefreshInputAsync(); if (selected is not null) selected = run.Steps.FirstOrDefault(value => value.StepName == selected.StepName); + await RefreshCausalityAsync(); } + private async Task RefreshCausalityAsync() + { + try + { + causality = await Client.GetFlowRunCausalityAsync(RunId, cancellation.Token); + causalityError = null; + } + catch (NotSupportedException) { causality = null; } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + catch (Exception exception) + { + Logger.LogWarning(exception, "Flow Run causality is unavailable for {FlowRunId}", RunId); + causality = null; + causalityError = T("CausalityUnavailable"); + } + } + + private async Task SelectTabAsync(RunTab tab) + { + activeTab = tab; + if (tab == RunTab.Causality && causality is null) await RefreshCausalityAsync(); + } + + private static string GovernanceUrl(string flowRunId, string toolCallId, string? invocationId = null) => + $"/tool-governance/flow/{Uri.EscapeDataString(flowRunId)}?toolCallId={Uri.EscapeDataString(toolCallId)}" + + (invocationId is null ? string.Empty : $"&invocationId={Uri.EscapeDataString(invocationId)}"); + private async Task RefreshInputAsync() { pendingInput = run?.Status == FlowRunStatus.WaitingForInput @@ -466,5 +545,5 @@ else cancellation.Dispose(); } - private enum RunTab { Summary, Activity, Handoffs, InputOutput, RawEvents } + private enum RunTab { Summary, Causality, Activity, Handoffs, InputOutput, RawEvents } } diff --git a/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor.css b/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor.css index c95bc46f..37bfe328 100644 --- a/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor.css +++ b/src/Agentstration.Web/Components/Pages/FlowRunDetails.razor.css @@ -1,2 +1,3 @@ .run-timeline{margin-bottom:18px}.run-timeline ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.run-timeline li{display:grid;grid-template-columns:90px 1fr;gap:14px;padding:11px 0;border-top:1px solid var(--border)}.run-timeline li:first-child{border-top:0}.run-timeline time{color:var(--muted);font-family:var(--font-mono);font-size:.72rem}.run-timeline li>div{padding-left:12px;border-left:3px solid var(--border)}.run-timeline li.event-stepoutputdelta>div{border-left-color:var(--primary)}.run-timeline li.event-flowruncompleted>div,.run-timeline li.event-stepruncompleted>div{border-left-color:#2bb673}.run-timeline li.event-flowrunfailed>div,.run-timeline li.event-steprunfailed>div{border-left-color:#e45562}.run-timeline p{margin:5px 0;white-space:pre-wrap}.run-timeline small{color:var(--muted)}@media(max-width:600px){.run-timeline li{grid-template-columns:1fr;gap:4px}.run-timeline li>div{margin-left:4px}} .realtime-warning{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px;border-color:#e8a23b;color:var(--muted)} +.causality-view{display:grid;gap:16px}.causality-tree,.causality-details,.tool-attempts{list-style:none;margin:0;padding:0}.causality-tree{display:grid;gap:12px}.causality-tree>li{margin-left:calc(var(--causality-depth) * min(32px,4vw))}.causality-node{border-left:3px solid var(--primary)}.causality-meta{display:flex;flex-wrap:wrap;gap:8px 16px;color:var(--muted);font-size:.82rem}.causality-node h3{margin:16px 0 8px;font-size:.85rem;text-transform:uppercase;color:var(--muted)}.causality-details{display:grid;gap:8px}.causality-details>li{display:grid;gap:4px;padding:10px;border:1px solid var(--border);border-radius:8px}.tool-attempts{display:grid;gap:5px;margin-top:5px}.tool-attempts li{display:flex;justify-content:space-between;gap:12px;font-size:.82rem}@media(max-width:600px){.causality-tree>li{margin-left:calc(var(--causality-depth) * 12px)}.tool-attempts li{align-items:flex-start;flex-direction:column}} diff --git a/src/Agentstration.Web/Components/Pages/ToolDefinitionEditor.razor b/src/Agentstration.Web/Components/Pages/ToolDefinitionEditor.razor new file mode 100644 index 00000000..63c1a768 --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/ToolDefinitionEditor.razor @@ -0,0 +1,53 @@ +@page "/tools/definitions/new" +@page "/tools/definitions/{Name}" +@inject IToolDefinitionsClient Client +@inject IFlowApiClient Flows +@inject NavigationManager Navigation +@inject Microsoft.Extensions.Localization.IStringLocalizer Localizer + +@T(IsNew ? "NewDefinition" : "EditDefinition") · Agentstration + + +@if (loading) { } +else +{ + @if (error is not null) { } +

@T("Definition")

+
+ + + + + + + +
+
+

@T("FlowImplementation")

+
+ + + @if (!useActiveVersion) { } +
+ + +
+
+
@T("Cancel")@if(!IsNew){}
+} + +@code { + [Parameter] public string? Name { get; set; } + [SupplyParameterFromQuery(Name="namespace")] public string? NamespaceQuery { get; set; } + private IReadOnlyList flows=[]; private string name="",namespaceValue="default",displayName="",description="",selectedFlow="",flowVersion="",inputSchema="{\n \"type\": \"object\"\n}",outputSchema=""; private bool enabled=true,requiresApproval,useActiveVersion=true,loading=true,saving; private int timeoutSeconds=90; private string? etag,error; + private bool IsNew => string.IsNullOrWhiteSpace(Name); + private string T(string key, params object[] arguments)=>Localizer[key,arguments]; + protected override async Task OnParametersSetAsync(){loading=true;error=null;try{flows=await Flows.GetFlowsAsync(default);if(!IsNew){var snapshot=await Client.GetAsync(Name!,ResourceNamespace.Parse(NamespaceQuery),default);From(snapshot.Value);etag=snapshot.ETag;}}catch(Exception ex){error=ex.Message;}finally{loading=false;}} + private void From(ToolDefinitionResource value){name=value.Name;namespaceValue=value.Namespace.Value;displayName=value.Definition.DisplayName;description=value.Definition.Description??"";enabled=value.Definition.Enabled;requiresApproval=value.Definition.RequiresApproval;timeoutSeconds=value.Definition.InvocationTimeoutSeconds;useActiveVersion=value.Definition.Flow.UseActiveVersion;flowVersion=value.Definition.Flow.Version??"";selectedFlow=$"{(value.Definition.Flow.Namespace??value.Namespace).Value}|{value.Definition.Flow.Name}";inputSchema=Pretty(value.Definition.InputSchema);outputSchema=value.Definition.OutputSchema is null?"":Pretty(value.Definition.OutputSchema.Value);} + private async Task LoadContractAsync(){error=null;try{var flow=Selected()??throw new InvalidOperationException(T("SelectFlow"));var version=useActiveVersion?flow.ActiveVersion:flowVersion;if(string.IsNullOrWhiteSpace(version))throw new InvalidOperationException(T("FlowVersionRequired"));var published=await Flows.GetFlowVersionAsync(flow.Namespace,flow.Id,version,default);inputSchema=published.Graph?.InputSchema is {} input?Pretty(input):"{}";outputSchema=published.Graph?.OutputSchema is {} output?Pretty(output):"";flowVersion=version;}catch(Exception ex){error=ex.Message;}} + private async Task SaveAsync(){saving=true;error=null;try{var flow=Selected()??throw new InvalidOperationException(T("SelectFlow"));using var input=JsonDocument.Parse(inputSchema);JsonElement? output=null;JsonDocument? outputDocument=null;try{if(!string.IsNullOrWhiteSpace(outputSchema)){outputDocument=JsonDocument.Parse(outputSchema);output=outputDocument.RootElement.Clone();}var properties=new ToolDefinitionProperties{DisplayName=displayName,Description=string.IsNullOrWhiteSpace(description)?null:description,Enabled=enabled,RequiresApproval=requiresApproval,InvocationTimeoutSeconds=timeoutSeconds,InputSchema=input.RootElement.Clone(),OutputSchema=output,Flow=new ToolDefinitionFlowTarget{Name=flow.Id,Namespace=flow.Namespace,UseActiveVersion=useActiveVersion,Version=useActiveVersion?null:flowVersion}};if(IsNew)await Client.CreateAsync(new CreateToolDefinitionRequest(name,properties,namespaceValue),default);else await Client.UpdateAsync(name,ResourceNamespace.Parse(namespaceValue),new PutToolDefinitionRequest(properties),etag!,default);Navigation.NavigateTo("/tools/definitions");}finally{outputDocument?.Dispose();}}catch(Exception ex){error=ex.Message;}finally{saving=false;}} + private async Task DeleteAsync(){try{await Client.DeleteAsync(name,ResourceNamespace.Parse(namespaceValue),etag!,default);Navigation.NavigateTo("/tools/definitions");}catch(Exception ex){error=ex.Message;}} + private FlowSummary? Selected()=>flows.SingleOrDefault(value=>FlowKey(value)==selectedFlow); + private static string FlowKey(FlowSummary flow)=>$"{flow.Namespace.Value}|{flow.Id}"; + private static string Pretty(JsonElement value)=>JsonSerializer.Serialize(value,new JsonSerializerOptions{WriteIndented=true}); +} diff --git a/src/Agentstration.Web/Components/Pages/ToolDefinitions.razor b/src/Agentstration.Web/Components/Pages/ToolDefinitions.razor new file mode 100644 index 00000000..4ac0ac82 --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/ToolDefinitions.razor @@ -0,0 +1,33 @@ +@page "/tools/definitions" +@inject IToolDefinitionsClient Client +@inject Microsoft.Extensions.Localization.IStringLocalizer Localizer + +@T("DefinitionsTitle") · Agentstration + + @T("NewDefinition") + +@T("Catalog")@T("Definitions")@T("Providers") + +@if (loading) { } +else if (error is not null) { } +else if (definitions.Count == 0) { } +else +{ + +
@T("Tool")@T("FlowImplementation")@T("Enabled")@T("RequiresApproval")
+ @definition.Definition.DisplayName@PublicName(definition)@Flow(definition)@BooleanLabel(definition.Definition.Enabled)@BooleanLabel(definition.Definition.RequiresApproval)@T("Edit") +
+} + +@code { + private IReadOnlyList definitions = []; + private bool loading = true; + private string? error; + private string T(string key, params object[] arguments) => Localizer[key, arguments]; + private string BooleanLabel(bool value) => T(value ? "Yes" : "No"); + protected override Task OnInitializedAsync() => LoadAsync(); + private async Task LoadAsync() { loading=true;error=null;try{definitions=await Client.GetAsync(default);}catch(Exception ex){error=ex.Message;}finally{loading=false;} } + private static string PublicName(ToolDefinitionResource value) => value.Namespace.IsDefault ? value.Name : $"{value.Namespace.Value}.{value.Name}"; + private static string Flow(ToolDefinitionResource value) => $"{value.Definition.Flow.Namespace ?? value.Namespace}/{value.Definition.Flow.Name}:{(value.Definition.Flow.UseActiveVersion ? "active" : value.Definition.Flow.Version)}"; + private static string EditUrl(ToolDefinitionResource value) => $"/tools/definitions/{Uri.EscapeDataString(value.Name)}?namespace={Uri.EscapeDataString(value.Namespace.Value)}"; +} diff --git a/src/Agentstration.Web/Components/Pages/ToolProviders.razor b/src/Agentstration.Web/Components/Pages/ToolProviders.razor index e95e0e89..34e1ea12 100644 --- a/src/Agentstration.Web/Components/Pages/ToolProviders.razor +++ b/src/Agentstration.Web/Components/Pages/ToolProviders.razor @@ -4,7 +4,7 @@ @T("Title") · Agentstration @T("Catalog")@T("AddProvider") -@T("Catalog")@T("Providers") +@T("Catalog")@T("Definitions")@T("Providers") @if (loading) { } else if (providers.Count == 0) { } else {
@T("Name")@T("Type")@T("Status")@T("Tools")@T("Enabled")
@provider.Definition.DisplayName@provider.Name@provider.Definition.ProviderType@provider.Definition.Discovery.ToolCount@BooleanLabel(provider.Definition.Enabled)@T("Manage")
} diff --git a/src/Agentstration.Web/Components/Pages/Tools.razor b/src/Agentstration.Web/Components/Pages/Tools.razor index dc01414b..6518bcac 100644 --- a/src/Agentstration.Web/Components/Pages/Tools.razor +++ b/src/Agentstration.Web/Components/Pages/Tools.razor @@ -6,7 +6,7 @@ @T("Providers") -@T("Catalog")@T("Providers") +@T("Catalog")@T("Definitions")@T("Providers") @if (loading) { } else if (error is not null) { } diff --git a/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs index d3969443..1f86b87e 100644 --- a/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs +++ b/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs @@ -72,6 +72,7 @@ public static IServiceCollection AddAgentstrationWebConsole(this IServiceCollect AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); + AddClient(services, configured.ManagementApi); AddSensitiveClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); diff --git a/src/Agentstration.Web/Console/ApiClients.cs b/src/Agentstration.Web/Console/ApiClients.cs index e7257e3a..47fc5389 100644 --- a/src/Agentstration.Web/Console/ApiClients.cs +++ b/src/Agentstration.Web/Console/ApiClients.cs @@ -138,6 +138,8 @@ async Task GetFlowVersionAsync(ResourceNamespace @namespace Task> GetFlowRunsAsync(ResourceNamespace @namespace, string flowId, CancellationToken cancellationToken) => @namespace.IsDefault ? GetFlowRunsAsync(flowId, cancellationToken) : throw new NotSupportedException("This client does not support namespaced Flows."); Task GetFlowRunAsync(string runId, CancellationToken cancellationToken); + Task GetFlowRunCausalityAsync(string runId, CancellationToken cancellationToken) => + throw new NotSupportedException("This client does not support Flow Run causality diagnostics."); Task> GetFlowRunEventsAsync(string runId, long afterSequence, CancellationToken cancellationToken); Task> GetFlowRunInputsAsync(string runId, CancellationToken cancellationToken); Task RespondToFlowRunInputAsync(string runId, string inputId, JsonElement value, CancellationToken cancellationToken); diff --git a/src/Agentstration.Web/Console/ConsoleModels.cs b/src/Agentstration.Web/Console/ConsoleModels.cs index 83b86bcf..2bddbcaf 100644 --- a/src/Agentstration.Web/Console/ConsoleModels.cs +++ b/src/Agentstration.Web/Console/ConsoleModels.cs @@ -35,6 +35,7 @@ public sealed record WorkSummary(Guid Id, string Title, string Type, string Stat public sealed record FlowSummary(string Id, string Name, string Kind, string Version, string Status, int Steps, int ActiveExecutions, DateTimeOffset UpdatedAt) { public ResourceNamespace Namespace { get; init; } = ResourceNamespace.Default; + public string? ActiveVersion { get; init; } public string DetailsUrl => ConsoleResourceUrls.Flow(new FlowId(Id, Namespace)); } diff --git a/src/Agentstration.Web/Console/FlowApiClient.cs b/src/Agentstration.Web/Console/FlowApiClient.cs index 7f1b80be..cd70ea2e 100644 --- a/src/Agentstration.Web/Console/FlowApiClient.cs +++ b/src/Agentstration.Web/Console/FlowApiClient.cs @@ -23,7 +23,7 @@ public sealed class FlowApiClient(HttpClient httpClient) : IFlowApiClient public async Task> GetFlowsAsync(CancellationToken cancellationToken) { var page = await ApiResponse.ReadAsync(httpClient, "api/flows?allNamespaces=true&top=100", cancellationToken); - return page.Value.Select(item => new FlowSummary(item.Id, item.Name, item.FlowKind.ToString(), item.ActiveVersion ?? item.Version, item.Enabled ? "Active" : "Disabled", 0, 0, item.UpdatedAt) { Namespace = item.Namespace }).ToArray(); + return page.Value.Select(item => new FlowSummary(item.Id, item.Name, item.FlowKind.ToString(), item.ActiveVersion ?? item.Version, item.Enabled ? "Active" : "Disabled", 0, 0, item.UpdatedAt) { Namespace = item.Namespace, ActiveVersion = item.ActiveVersion }).ToArray(); } public Task GetFlowAsync(string flowId, CancellationToken cancellationToken) => @@ -112,6 +112,31 @@ private static string NormalizeFlowRunPageLink(string link) public Task GetFlowRunAsync(string runId, CancellationToken cancellationToken) => ApiResponse.ReadAsync(httpClient, $"api/flowRuns/{Uri.EscapeDataString(runId)}", cancellationToken); + public async Task GetFlowRunCausalityAsync(string runId, CancellationToken cancellationToken) + { + var values = new List(); + FlowRunCausalityOrigin? origin = null; + var totalCount = 0; + var visited = new HashSet(StringComparer.Ordinal); + string? path = $"api/flowRuns/{Uri.EscapeDataString(runId)}/causality?top=100"; + while (path is not null) + { + path = NormalizeFlowRunPageLink(path); + if (!visited.Add(path)) + throw new AgentstrationApiException("Flow causality API returned a repeated pagination link.", Guid.NewGuid().ToString("N")); + var page = await ApiResponse.ReadAsync(httpClient, path, cancellationToken); + origin ??= page.Origin; + totalCount = page.TotalCount; + values.AddRange(page.Value); + path = string.IsNullOrWhiteSpace(page.NextLink) ? null : page.NextLink; + } + return new FlowRunCausalityPageResponse( + origin ?? throw new AgentstrationApiException("Flow causality API returned no invocation origin.", Guid.NewGuid().ToString("N")), + values, + totalCount, + null); + } + public async Task> GetFlowRunEventsAsync(string runId, long afterSequence, CancellationToken cancellationToken) => await ApiResponse.ReadAsync(httpClient, $"api/flowRuns/{Uri.EscapeDataString(runId)}/eventHistory?afterSequence={Math.Max(0, afterSequence)}", cancellationToken); @@ -134,7 +159,12 @@ public async Task CreateFlowRunAsync(string flowId, CreateFlowRunReques public async Task CreateFlowRunAsync(ResourceNamespace @namespace, string flowId, CreateFlowRunRequest request, CancellationToken cancellationToken) { - using var response = await httpClient.PostAsJsonAsync($"{FlowPath(@namespace, flowId)}/runs", request, JsonOptions, cancellationToken); + using var message = new HttpRequestMessage(HttpMethod.Post, $"{FlowPath(@namespace, flowId)}/runs") + { + Content = JsonContent.Create(request, options: JsonOptions) + }; + message.Headers.Add("X-Agentstration-Origin", "Console"); + using var response = await httpClient.SendAsync(message, cancellationToken); await ApiResponse.EnsureSuccessAsync(response, cancellationToken); return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) ?? throw new AgentstrationApiException("Flow API returned an empty Run.", Guid.NewGuid().ToString("N")); diff --git a/src/Agentstration.Web/Console/PlatformDashboardService.cs b/src/Agentstration.Web/Console/PlatformDashboardService.cs index 66b4e224..e8d42cac 100644 --- a/src/Agentstration.Web/Console/PlatformDashboardService.cs +++ b/src/Agentstration.Web/Console/PlatformDashboardService.cs @@ -172,7 +172,7 @@ private static async Task BuildSnapshotAsync( public static UiStatus ToStatus(string status) => status.ToLowerInvariant() switch { "operational" or "healthy" or "ready" or "active" or "running" or "completed" => UiStatus.Success, - "attention required" or "degraded" or "waiting" or "needsinput" or "actionrequired" or "paused" or "queued" or "draft" => UiStatus.Warning, + "attention required" or "degraded" or "waiting" or "waitingforinput" or "waitingforchild" or "needsinput" or "actionrequired" or "paused" or "queued" or "draft" => UiStatus.Warning, "partially unavailable" or "failed" or "error" or "unavailable" or "cancelled" or "canceled" => UiStatus.Danger, "no active deployments" => UiStatus.Info, _ => UiStatus.Neutral diff --git a/src/Agentstration.Web/Console/ToolDefinitionApiClient.cs b/src/Agentstration.Web/Console/ToolDefinitionApiClient.cs new file mode 100644 index 00000000..5d5264a3 --- /dev/null +++ b/src/Agentstration.Web/Console/ToolDefinitionApiClient.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Resources; + +namespace Agentstration.Web.Console; + +public interface IToolDefinitionsClient +{ + Task> GetAsync(CancellationToken cancellationToken); + Task> GetAsync(string name, ResourceNamespace @namespace, CancellationToken cancellationToken); + Task> CreateAsync(CreateToolDefinitionRequest request, CancellationToken cancellationToken); + Task> UpdateAsync(string name, ResourceNamespace @namespace, PutToolDefinitionRequest request, string etag, CancellationToken cancellationToken); + Task> SetEnabledAsync(string name, ResourceNamespace @namespace, bool enabled, string etag, CancellationToken cancellationToken); + Task DeleteAsync(string name, ResourceNamespace @namespace, string etag, CancellationToken cancellationToken); +} + +public sealed class ToolDefinitionsApiClient(HttpClient httpClient) : IToolDefinitionsClient +{ + public async Task> GetAsync(CancellationToken cancellationToken) => + (await ApiResponse.ReadAsync>(httpClient, "api/tooldefinitions", cancellationToken)).Value; + + public async Task> GetAsync(string name, ResourceNamespace @namespace, CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync(Path(name, @namespace), cancellationToken); + return await ReadAsync(response, cancellationToken); + } + + public async Task> CreateAsync(CreateToolDefinitionRequest request, CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync("api/tooldefinitions", request, cancellationToken); + return await ReadAsync(response, cancellationToken); + } + + public async Task> UpdateAsync(string name, ResourceNamespace @namespace, PutToolDefinitionRequest request, string etag, CancellationToken cancellationToken) => + await SendAsync(HttpMethod.Put, Path(name, @namespace), request, etag, cancellationToken); + + public async Task> SetEnabledAsync(string name, ResourceNamespace @namespace, bool enabled, string etag, CancellationToken cancellationToken) => + await SendAsync(HttpMethod.Put, ChildPath(name, @namespace, "enabled"), new SetToolDefinitionEnabledRequest(enabled), etag, cancellationToken); + + public async Task DeleteAsync(string name, ResourceNamespace @namespace, string etag, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, Path(name, @namespace)); + request.Headers.IfMatch.Add(EntityTagHeaderValue.Parse(etag)); + using var response = await httpClient.SendAsync(request, cancellationToken); + await ApiResponse.EnsureSuccessAsync(response, cancellationToken); + } + + private async Task> SendAsync(HttpMethod method, string path, T body, string etag, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path) { Content = JsonContent.Create(body) }; + request.Headers.IfMatch.Add(EntityTagHeaderValue.Parse(etag)); + using var response = await httpClient.SendAsync(request, cancellationToken); + return await ReadAsync(response, cancellationToken); + } + + private static async Task> ReadAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + await ApiResponse.EnsureSuccessAsync(response, cancellationToken); + var value = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("ToolDefinition API returned an empty response."); + return new(value, response.Headers.ETag?.ToString() ?? throw new InvalidOperationException("Missing ETag.")); + } + + private static string Path(string name, ResourceNamespace @namespace) => + $"api/tooldefinitions/{Uri.EscapeDataString(name)}?namespace={Uri.EscapeDataString(@namespace.Value)}"; + private static string ChildPath(string name, ResourceNamespace @namespace, string child) => + $"api/tooldefinitions/{Uri.EscapeDataString(name)}/{child}?namespace={Uri.EscapeDataString(@namespace.Value)}"; +} diff --git a/src/Agentstration.Web/Features/Flows/Designer/FlowDesignerResourceProvider.cs b/src/Agentstration.Web/Features/Flows/Designer/FlowDesignerResourceProvider.cs index b421c64d..8118f152 100644 --- a/src/Agentstration.Web/Features/Flows/Designer/FlowDesignerResourceProvider.cs +++ b/src/Agentstration.Web/Features/Flows/Designer/FlowDesignerResourceProvider.cs @@ -3,11 +3,40 @@ namespace Agentstration.Web.Features.Flows.Designer; -public sealed class FlowDesignerResourceProvider(IManagementApiClient client) : IFlowDesignerResourceProvider +public sealed class FlowDesignerResourceProvider(IManagementApiClient client, IFlowApiClient flowClient, IToolsClient toolsClient) : IFlowDesignerResourceProvider { public async Task> GetAgentsAsync(CancellationToken cancellationToken) { var agents = await client.GetAgentsAsync(cancellationToken); return agents.Select(agent => new FlowDesignerAgent(agent.Name, agent.Name)).ToArray(); } + + public async Task> GetFlowsAsync(CancellationToken cancellationToken) + { + var flows = await flowClient.GetFlowsAsync(cancellationToken); + return flows.Select(flow => new FlowDesignerFlow(flow.Id, flow.Name, flow.Namespace, flow.ActiveVersion)).ToArray(); + } + + public async Task> GetFlowVersionsAsync( + Agentstration.Resources.ResourceNamespace @namespace, + string name, + CancellationToken cancellationToken) + { + var versions = await flowClient.GetFlowVersionsAsync(@namespace, name, cancellationToken); + return versions.Select(version => new FlowDesignerFlowVersion(version.Version, version.Graph?.InputSchema, version.Graph?.OutputSchema)).ToArray(); + } + + public async Task> GetToolsAsync(CancellationToken cancellationToken) + { + var tools = await toolsClient.GetToolsAsync(cancellationToken: cancellationToken); + return tools.Where(tool => tool.Definition.Schema is not null).Select(tool => new FlowDesignerTool( + tool.Metadata.Name, + tool.Definition.DisplayName, + tool.Metadata.Namespace, + tool.Definition.Schema!.Input.Clone(), + tool.Definition.Schema.Output?.Clone(), + tool.Definition.Enabled, + tool.Definition.Discovery?.Available == true, + tool.Definition.RequiresApproval)).ToArray(); + } } diff --git a/src/Agentstration.Web/Hosting/LocalWorkExecutionWorker.cs b/src/Agentstration.Web/Hosting/LocalWorkExecutionWorker.cs index ad6f2caa..aa1ce323 100644 --- a/src/Agentstration.Web/Hosting/LocalWorkExecutionWorker.cs +++ b/src/Agentstration.Web/Hosting/LocalWorkExecutionWorker.cs @@ -13,6 +13,7 @@ public sealed class LocalWorkExecutionWorker( WorkItemService workItems, AgentExecutionCoordinator agentExecution, FlowRunService flowRuns, + IRootFlowRunGateway rootFlowRuns, IFlowRunExecutionScope executionScopes, TimeProvider timeProvider, ILogger logger) : BackgroundService @@ -76,24 +77,59 @@ private async Task ExecuteFlowAsync(LocalWorkExecution execution, CancellationTo await workItems.ApplyExecutionEventAsync(new WorkExecutionStarted( Guid.NewGuid(), execution.Request.WorkspaceId, execution.Request.WorkItemId, execution.Accepted.ExecutionId, timeProvider.GetUtcNow(), selectedAgent), cancellationToken); - var input = JsonSerializer.SerializeToElement(new - { - prompt = execution.Request.Instruction, - inputs = execution.Request.Inputs.Select(value => value.Structured ?? JsonSerializer.SerializeToElement(value.Text)).ToArray() - }); + var input = execution.Request.Metadata.ContainsKey(RootFlowSubmissionService.RootRunIdMetadata) + && execution.Request.Inputs.FirstOrDefault()?.Structured is { } rootInput + ? rootInput + : JsonSerializer.SerializeToElement(new + { + prompt = execution.Request.Instruction, + inputs = execution.Request.Inputs.Select(value => value.Structured ?? JsonSerializer.SerializeToElement(value.Text)).ToArray() + }); var scope = execution.Request.ExecutionScope!; - var created = await flowRuns.CreateAsync( - flow.FlowId, flow.UseActiveVersion ? null : flow.Version, "local", FlowRunTrigger.WorkItem, - "workplace", - execution.Request.CorrelationId.Value, input, - execution.Request.Metadata.GetValueOrDefault("workplace.parentFlowRunId"), - execution.Request.Metadata.GetValueOrDefault("workplace.interactionId"), - execution.Request.Metadata.GetValueOrDefault("workplace.taskId") ?? execution.Request.WorkItemId.Value.ToString("D"), - execution.Request.Metadata.GetValueOrDefault("workplace.triggerMessageId"), - scope, - cancellationToken); - FlowRun current = created.Value; - await foreach (var observed in flowRuns.ObserveAsync(created.Value.Id, scope, cancellationToken)) current = observed; + FlowRun created; + if (execution.Request.Metadata.TryGetValue(RootFlowSubmissionService.RootRunIdMetadata, out var rootRunId)) + { + var origin = Enum.TryParse(execution.Request.Metadata.GetValueOrDefault(RootFlowSubmissionService.OriginMetadata), out var parsedOrigin) + ? parsedOrigin + : FlowInvocationOrigin.Api; + var trigger = Enum.TryParse(execution.Request.Metadata.GetValueOrDefault(RootFlowSubmissionService.TriggerMetadata), out var parsedTrigger) + ? parsedTrigger + : TriggerFor(origin); + var ensured = await rootFlowRuns.EnsureAsync(new RootFlowRunRequest( + rootRunId, + flow, + trigger, + origin, + execution.Request.Metadata.GetValueOrDefault(RootFlowSubmissionService.CallerMetadata) ?? execution.Request.RequestedAgentId ?? "local-user", + execution.Request.Metadata.GetValueOrDefault(RootFlowSubmissionService.CausationMetadata), + execution.Request.Metadata.GetValueOrDefault(RootFlowSubmissionService.IdempotencyMetadata), + execution.Request.CorrelationId.Value, + input, + execution.Request.WorkItemId, + bool.TryParse(execution.Request.Metadata.GetValueOrDefault(RootFlowSubmissionService.ActiveReferenceMetadata), out var resolvedFromActiveReference) + && resolvedFromActiveReference, + execution.Request.Metadata.GetValueOrDefault("workplace.parentFlowRunId"), + execution.Request.Metadata.GetValueOrDefault("workplace.interactionId"), + execution.Request.Metadata.GetValueOrDefault("workplace.taskId") ?? execution.Request.WorkItemId.Value.ToString("D"), + execution.Request.Metadata.GetValueOrDefault("workplace.triggerMessageId"), + scope), cancellationToken); + created = ensured.Run; + } + else + { + created = (await flowRuns.CreateAsync( + flow.FlowId, flow.UseActiveVersion ? null : flow.Version, "local", FlowRunTrigger.WorkItem, + "workplace", + execution.Request.CorrelationId.Value, input, + execution.Request.Metadata.GetValueOrDefault("workplace.parentFlowRunId"), + execution.Request.Metadata.GetValueOrDefault("workplace.interactionId"), + execution.Request.Metadata.GetValueOrDefault("workplace.taskId") ?? execution.Request.WorkItemId.Value.ToString("D"), + execution.Request.Metadata.GetValueOrDefault("workplace.triggerMessageId"), + scope, + cancellationToken)).Value; + } + FlowRun current = created; + await foreach (var observed in flowRuns.ObserveAsync(created.Id, scope, cancellationToken)) current = observed; if (!await WaitUntilTaskCanCompleteAsync(execution.Request.WorkspaceId, execution.Request.WorkItemId, cancellationToken)) return; if (current.Status != FlowRunStatus.Succeeded) { @@ -116,6 +152,13 @@ await workItems.ApplyExecutionEventAsync(new WorkExecutionCompleted( Guid.NewGuid(), execution.Request.WorkspaceId, execution.Request.WorkItemId, execution.Accepted.ExecutionId, timeProvider.GetUtcNow(), result), cancellationToken); } + private static FlowRunTrigger TriggerFor(FlowInvocationOrigin origin) => origin switch + { + FlowInvocationOrigin.Trigger => FlowRunTrigger.Schedule, + FlowInvocationOrigin.Api or FlowInvocationOrigin.Console or FlowInvocationOrigin.Mcp or FlowInvocationOrigin.Agent => FlowRunTrigger.Api, + _ => FlowRunTrigger.WorkItem + }; + private async Task WaitUntilTaskCanCompleteAsync(Agentstration.Resources.WorkspaceId workspaceId, WorkItemId workItemId, CancellationToken cancellationToken) { while (true) diff --git a/src/Agentstration.Web/Program.cs b/src/Agentstration.Web/Program.cs index 5ce2e9e9..06819ce6 100644 --- a/src/Agentstration.Web/Program.cs +++ b/src/Agentstration.Web/Program.cs @@ -168,7 +168,11 @@ await context.HttpContext.Response.WriteAsJsonAsync( ])); builder.Services.AddSingleton(); builder.Services.AddAgentstrationWebConsole(builder.Configuration, builder.Environment); -builder.Services.AddMcpServer().WithHttpTransport().WithToolsFromAssembly(); +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithToolsFromAssembly() + .WithListToolsHandler(AgentstrationMcpHandlers.ListToolsAsync) + .WithCallToolHandler(AgentstrationMcpHandlers.CallToolAsync); if (hostedServicesEnabled) { builder.Services.AddHostedService(); diff --git a/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.fr-FR.resx index 7d1f5881..f89ba498 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.fr-FR.resx @@ -49,10 +49,12 @@ En attente En cours En attente d’une saisie + En attente d’un Flow enfant Réussie Échouée Annulée Délai dépassé + Exécution du Flow enfant Manuel API Élément de travail @@ -149,4 +151,28 @@ Gouvernance de l’outil évaluée Appel d’outil terminé Appel d’outil échoué + Origine / appelant + Identifiant de causalité + Point d’entrée + Déclencheur + API + MCP + Agent + Console + Causalité + Arbre causal d’exécution du Flow Run + Chargement du diagnostic causal… + Le diagnostic causal est temporairement indisponible. + Origine de l’appel + Flow Runs + Origine + Déclencheur + Appelant + Résolu depuis la version active + Référence de version exacte + Appelé par l’étape {0} + Exécutions d’agents + Appels de tools + Appel logique + Gouvernance ({0}) diff --git a/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.resx index eb1dd764..3192e375 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/FlowRunDetailsStrings.resx @@ -49,10 +49,12 @@ Pending Running Waiting for input + Waiting for child Flow Succeeded Failed Cancelled Timed out + Child Flow Run Manual API Work item @@ -149,4 +151,28 @@ Tool governance evaluated Tool call completed Tool call failed + Origin / caller + Causation ID + Entry + Trigger + API + MCP + Agent + Console + Causality + Flow Run causal execution tree + Loading causal diagnostics… + Causal diagnostics are temporarily unavailable. + Invocation origin + Flow Runs + Origin + Trigger + Caller + Resolved from active version + Exact version reference + Called by step {0} + Agent executions + Tool calls + Logical call + Governance ({0}) diff --git a/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.fr-FR.resx index f751da77..be5e6833 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.fr-FR.resx @@ -26,6 +26,7 @@ En attente En cours En attente d’une saisie + En attente d’un Flow enfant Réussie Échouée Annulée diff --git a/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.resx index 439db70b..e5380ac6 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/FlowRunsStrings.resx @@ -26,6 +26,7 @@ Pending Running Waiting for input + Waiting for child Flow Succeeded Failed Cancelled diff --git a/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.fr-FR.resx index 30c38733..bf98240d 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.fr-FR.resx @@ -7,7 +7,7 @@ Exécution{0} exécution(s)Aucune exécution de Flow disponible.{0} résultat(s){0} artefact(s)ParenteNon démarrée RésultatsAucun résultat disponible.· DernierVersionArtefactsAucun artefact disponible.Télécharger Actions en attenteAucune action en attente.La permission runs/execute est requise pour répondre.Ouvrir l’interaction dans le WorkplaceLecture seule{0} message(s)ActivitéAucune activité fonctionnelle.Travail autonome planifiéAutonome - En attenteEn coursEn attente d’une saisieRéussieÉchecAnnulée + En attenteEn coursEn attente d’une saisieEn attente d’un Flow enfantRéussieÉchecAnnulée En attenteRésolueExpiréeAnnulée UtilisateurAssistantSystèmeOutil diff --git a/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.resx index 2289bc7a..1f87ba76 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/TaskDetailsStrings.resx @@ -7,7 +7,7 @@ Execution{0} run(s)FlowRun unavailable.{0} result(s){0} artifact(s)ParentNot started ResultsResults unavailable.· LatestVersionArtifactsArtifact unavailable.Download Pending actionsNo pending action.runs/execute permission is required to respond.Open interaction in WorkplaceRead only{0} message(s)ActivityNo functional activity.Scheduled autonomous workAutonomous - PendingRunningWaiting for inputSucceededFailedCancelled + PendingRunningWaiting for inputWaiting for child FlowSucceededFailedCancelled PendingResolvedExpiredCancelled UserAssistantSystemTool diff --git a/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.fr-FR.resx index ebf5de3f..9e3669eb 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.fr-FR.resx @@ -4,5 +4,5 @@ FlowRun de la tâcheFlowRun introuvableCette exécution de Flow n’existe pas ou n’appartient pas à la tâche sélectionnée.ActualiserRetour à la tâche ÉtatFlow / versionÉtat de la définitionDéclencheur / lancée parIdentifiant de corrélationDurée Entrée et sortie de l’exécutionEntréeSortieInfos de l’étapeSélectionnez une étape pour inspecter son exécution.TypeTentativeTransitionAgentFournisseurJetonsen entréeen sortieEntrée déclaréeEntrée résolueJournauxTopologie du Flow - En attenteEn coursEn attente d’une saisieRéussieÉchecAnnuléePubliéeBrouillon + En attenteEn coursEn attente d’une saisieEn attente d’un Flow enfantRéussieÉchecAnnuléePubliéeBrouillon diff --git a/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.resx index 585d95b6..0923ab05 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/TaskFlowRunDetailsStrings.resx @@ -4,5 +4,5 @@ Task FlowRunFlowRun not foundThis FlowRun does not exist or does not belong to the selected Task.RefreshBack to Task StatusFlow / versionDefinition stateTrigger / started byCorrelation IDDuration Run input and outputInputOutputStep detailsSelect a step to inspect its execution.TypeAttemptTransitionAgentProviderTokensinoutDeclared inputResolved inputLogsFlow topology - PendingRunningWaiting for inputSucceededFailedCancelledPublishedDraft + PendingRunningWaiting for inputWaiting for child FlowSucceededFailedCancelledPublishedDraft diff --git a/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.fr-FR.resx index bf1bf1c8..a1559a7e 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.fr-FR.resx @@ -16,6 +16,7 @@ Type État Outils + Définitions Activé Oui Non diff --git a/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.resx index 87681518..7e62fe17 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/ToolProvidersStrings.resx @@ -16,6 +16,7 @@ Type Status Tools + Definitions Enabled Yes No diff --git a/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.fr-FR.resx index 4d0a9142..3a7f18b7 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.fr-FR.resx @@ -21,4 +21,33 @@ Oui Non Détails → + Définitions + Définitions d’outils + Publiez des outils MCP stables implémentés par des Flows gouvernés. + Nouvelle définition + Modifier la définition + Choisissez un Flow publié et exposez son contrat comme outil MCP Agentstration. + Chargement des définitions d’outils… + Aucune définition d’outil + Créez une définition pour exposer un Flow publié comme outil MCP délimité. + Définition + Nom + Espace de noms + Nom affiché + Description + Approbation requise + Délai maximal (secondes) + Implémentation Flow + Flow + Suivre la version active + Version + Charger le contrat du Flow + Schéma d’entrée + Schéma de sortie + Sélectionnez un Flow publié. + Le Flow sélectionné ne possède aucune version utilisable. + Enregistrer + Annuler + Supprimer + Modifier → diff --git a/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.resx index 1e385a17..fd66a741 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/ToolsStrings.resx @@ -21,4 +21,33 @@ Yes No Details → + Definitions + Tool definitions + Publish stable MCP tools implemented by governed Flows. + New definition + Edit definition + Choose a published Flow and expose its contract as an Agentstration MCP tool. + Loading tool definitions… + No tool definitions + Create a definition to expose one published Flow as a bounded MCP tool. + Definition + Name + Namespace + Display name + Description + Requires approval + Timeout (seconds) + Flow implementation + Flow + Follow the active version + Version + Load Flow contract + Input schema + Output schema + Select a published Flow. + The selected Flow has no usable version. + Save + Cancel + Delete + Edit → diff --git a/src/Agentstration.Work/WorkplaceInteractions.cs b/src/Agentstration.Work/WorkplaceInteractions.cs index 9b78f9fb..494f9ac7 100644 --- a/src/Agentstration.Work/WorkplaceInteractions.cs +++ b/src/Agentstration.Work/WorkplaceInteractions.cs @@ -63,6 +63,11 @@ public sealed record WorkNotification public InteractionId? InteractionId { get; init; } public PendingActionId? PendingActionId { get; init; } public string? ActionUrl { get; init; } + public string? DeliveryKey { get; init; } + public string? CorrelationId { get; init; } + public string? SourceRunId { get; init; } + public string? SourceStepId { get; init; } + public string? SourceToolCallId { get; init; } public long Version { get; init; } = 1; } diff --git a/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs new file mode 100644 index 00000000..c4fa23d9 --- /dev/null +++ b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Infrastructure.Flows; +using Agentstration.Resources; + +namespace Agentstration.Application.Tests; + +public sealed partial class FlowTests +{ + [TestMethod] + public void FlowCallRoundTripsThroughJsonAndYamlAndContributesToTheDefinitionHash() + { + var graph = Graph(new FlowCallStepDefinition + { + Name = "analyze", + Flow = new("news-analysis", FlowCallVersionStrategy.Exact, "2.1.0", new("pack.news")), + InputMapping = JsonSerializer.SerializeToElement(new { article = "${input.article}" }) + }); + + var json = JsonSerializer.Serialize(graph, JsonOptions); + var restored = JsonSerializer.Deserialize(json, JsonOptions)!; + var yaml = FlowDraftService.ToYaml(graph); + var parser = new FlowDraftService(null!, null!, null!, TimeProvider.System); + var yamlRestored = parser.ParseSource(yaml, "yaml"); + + StringAssert.Contains(json, "\"type\":\"flow\""); + StringAssert.Contains(json, "\"versionStrategy\":\"exact\""); + var call = Assert.IsInstanceOfType(restored.Steps[1]); + Assert.AreEqual(FlowCallVersionStrategy.Exact, call.Flow.VersionStrategy); + Assert.AreEqual("pack.news", call.Flow.Namespace?.Value); + Assert.AreEqual(FlowDefinitionHash.Compute(graph), FlowDefinitionHash.Compute(yamlRestored)); + Assert.AreNotEqual(FlowDefinitionHash.Compute(graph), FlowDefinitionHash.Compute(graph with + { + Steps = graph.Steps.Select(step => step is FlowCallStepDefinition current + ? current with { Flow = current.Flow with { Version = "2.2.0" } } + : step).ToArray() + })); + } + + [TestMethod] + public async Task FlowCallValidationUsesWorkspaceVersionSchemasAndRejectsIncompatibleMappings() + { + var inputSchema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new { article = new { type = "string" }, locale = new { type = "string" } }, + required = new[] { "article" } + }); + var resolver = new FlowCallResolverStub(new(new("analysis", new("pack.news")), "3.0.0", inputSchema, JsonSerializer.SerializeToElement(new { type = "object" }))); + var graph = Graph(new FlowCallStepDefinition + { + Name = "analyze", + Flow = new("analysis", FlowCallVersionStrategy.Active, Namespace: new("pack.news")), + InputMapping = JsonSerializer.SerializeToElement(new { unexpected = "${input.article}" }) + }); + + var result = await new FlowGraphValidator(resolver).ValidateAsync( + graph, + new FlowValidationContext(true, TestScope.WorkspaceId, new("parent")), + default); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Issues.Any(issue => issue.Code == "flow_input_mapping_unknown")); + Assert.IsTrue(result.Issues.Any(issue => issue.Code == "flow_input_mapping_required")); + Assert.AreEqual(TestScope.WorkspaceId, resolver.WorkspaceId); + Assert.AreEqual(ResourceNamespace.Default, resolver.OwnerNamespace); + } + + [TestMethod] + public async Task RepositoryResolverFindsNamespacedPublishedVersionsAndIndirectCycles() + { + await using var fixture = await FlowFixture.CreateAsync(); + var parent = await fixture.Service.CreateAsync(TestScope.WorkspaceId, new CreateFlowCommand( + "parent", null, "1.0.0", true, PlaceholderDefinition(), Graph: Graph()), default); + await fixture.Service.PublishVersionAsync(TestScope.WorkspaceId, parent.Value.Id, "1.0.0", true, default); + + var childGraph = Graph(new FlowCallStepDefinition + { + Name = "parent-call", + Flow = new("parent", FlowCallVersionStrategy.Exact, "1.0.0"), + InputMapping = JsonSerializer.SerializeToElement(new { }) + }); + var child = await fixture.Service.CreateAsync(TestScope.WorkspaceId, new CreateFlowCommand( + "child", null, "1.0.0", true, PlaceholderDefinition(), Graph: childGraph), default); + await fixture.Service.PublishVersionAsync(TestScope.WorkspaceId, child.Value.Id, "1.0.0", true, default); + + var resolver = new ManagementFlowResourceReferenceResolver(null!, fixture.Repository); + var target = await resolver.ResolveFlowAsync(TestScope.WorkspaceId, ResourceNamespace.Default, new("child"), default); + + Assert.IsNotNull(target); + Assert.AreEqual("1.0.0", target.Version); + Assert.IsTrue(await resolver.CreatesFlowCycleAsync(TestScope.WorkspaceId, parent.Value.Id, target, default)); + var otherWorkspace = new WorkspaceId(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + Assert.IsNull(await resolver.ResolveFlowAsync(otherWorkspace, ResourceNamespace.Default, new("child"), default)); + } + + private static FlowGraphDefinition Graph(FlowCallStepDefinition? call = null) + { + var schema = JsonSerializer.SerializeToElement(new { type = "object", properties = new { article = new { type = "string" } } }); + var steps = new List { new InputFlowStepDefinition { Name = "input", Schema = schema } }; + if (call is not null) steps.Add(call); + steps.Add(new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement(new { result = call is null ? "${input.article}" : $"${{steps.{call.Name}.output}}" }) }); + var transitions = call is null + ? new[] { new FlowTransitionDefinition("input-output", "input", "completed", "output") } + : new[] { new FlowTransitionDefinition("input-call", "input", "completed", call.Name), new FlowTransitionDefinition("call-output", call.Name, "completed", "output") }; + return new FlowGraphDefinition { EntryStep = "input", InputSchema = schema, OutputSchema = JsonSerializer.SerializeToElement(new { type = "object", properties = new { result = new { } } }), Steps = steps, Transitions = transitions }; + } + + private static RoutingFlowDefinition PlaceholderDefinition() => + new(FlowRoutingStrategy.Deterministic, [new(FlowTargetKind.Agent, "placeholder")]); + + private sealed class FlowCallResolverStub(ResolvedFlowCall resolved) : IFlowResourceReferenceResolver + { + public WorkspaceId? WorkspaceId { get; private set; } + public ResourceNamespace? OwnerNamespace { get; private set; } + public Task ExistsAsync(string resourceId, CancellationToken cancellationToken) => Task.FromResult(true); + public Task ResolveFlowAsync(WorkspaceId workspaceId, ResourceNamespace ownerNamespace, FlowCallReference reference, CancellationToken cancellationToken) + { + WorkspaceId = workspaceId; + OwnerNamespace = ownerNamespace; + return Task.FromResult(resolved); + } + } +} diff --git a/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs b/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs new file mode 100644 index 00000000..b3cbee3c --- /dev/null +++ b/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs @@ -0,0 +1,330 @@ +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Resources; + +namespace Agentstration.Application.Tests; + +public sealed partial class FlowTests +{ + [TestMethod] + public async Task FlowCallSuspendsCreatesOneDurableChildAndResumesWithItsOutput() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", ChildGraph()); + var parent = await CreatePublishedGraphAsync(fixture, "parent", ParentGraph()); + var queue = new TestFlowRunQueue(); + var runs = Service(fixture, queue); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + + var pending = await runs.CreateAsync( + parent.Value.Id, "1.0.0", "local", FlowRunTrigger.WorkItem, "tester", "root-correlation", input.RootElement, + null, "interaction-1", "task-1", "message-1", TestScope, default); + await runs.ExecuteAsync(new(pending.Value.Id, TestScope), default); + + var waiting = (await runs.GetAsync(TestScope.WorkspaceId, pending.Value.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.WaitingForChild, waiting.Status); + Assert.IsNull(waiting.ExecutionLeaseId); + var callStep = waiting.Steps.Single(step => step.StepName == "analyze"); + Assert.IsNotNull(callStep.ChildFlowRunId); + var child = (await runs.GetAsync(TestScope.WorkspaceId, callStep.ChildFlowRunId, default))!.Value; + Assert.AreEqual(waiting.Id, child.ParentFlowRunId); + Assert.AreEqual(waiting.Id, child.RootFlowRunId); + Assert.AreEqual(1, child.NestingDepth); + Assert.AreEqual("1.0.0", child.FlowVersion); + Assert.AreEqual(FlowRunTrigger.Flow, child.Trigger); + Assert.AreEqual(waiting.Scope, child.Scope); + Assert.AreEqual(waiting.WorkTaskId, child.WorkTaskId); + Assert.AreEqual(waiting.InteractionId, child.InteractionId); + var otherWorkspace = TestScope with { WorkspaceId = new WorkspaceId(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")) }; + Assert.IsNull(await runs.GetAsync(child.Id, otherWorkspace, default)); + + await runs.ExecuteAsync(new(waiting.Id, TestScope), default); + var redelivered = (await runs.GetAsync(TestScope.WorkspaceId, waiting.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.WaitingForChild, redelivered.Status); + Assert.AreEqual(callStep.ChildFlowRunId, redelivered.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId); + Assert.HasCount(2, (await runs.ListAsync(null, null, 0, 20, TestScope, default)).Items); + + await runs.ExecuteAsync(new(child.Id, TestScope), default); + var resumed = (await runs.GetAsync(TestScope.WorkspaceId, waiting.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.Pending, resumed.Status); + await runs.ExecuteAsync(new(resumed.Id, TestScope), default); + + var completed = (await runs.GetAsync(TestScope.WorkspaceId, waiting.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.Succeeded, completed.Status); + Assert.AreEqual("new item", completed.Output?.GetProperty("summary").GetString()); + Assert.AreEqual("new item", completed.Steps.Single(step => step.StepName == "analyze").Output?.GetProperty("summary").GetString()); + var events = await runs.ListEventsAsync(TestScope, completed.Id, 0, default); + Assert.AreEqual(1, events.Count(item => item.Type == FlowRunEventType.ChildFlowRunCreated)); + Assert.AreEqual(1, events.Count(item => item.Type == FlowRunEventType.FlowRunResumedFromChild)); + } + + [TestMethod] + public async Task CancellingAWaitingParentPropagatesToItsActiveChild() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", ChildGraph()); + var parent = await CreatePublishedGraphAsync(fixture, "parent", ParentGraph()); + var runs = Service(fixture, new TestFlowRunQueue()); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var pending = await runs.CreateAsync(parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "cancel-tree", input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(pending.Value.Id, TestScope), default); + var waiting = (await runs.GetAsync(TestScope.WorkspaceId, pending.Value.Id, default))!.Value; + var childId = waiting.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId!; + + await runs.CancelAsync(waiting.Id, TestScope, default); + + Assert.AreEqual(FlowRunStatus.Cancelled, (await runs.GetAsync(TestScope.WorkspaceId, waiting.Id, default))!.Value.Status); + Assert.AreEqual(FlowRunStatus.Cancelled, (await runs.GetAsync(TestScope.WorkspaceId, childId, default))!.Value.Status); + } + + [TestMethod] + public async Task RecoveryRequeuesAWaitingParentWhenItsDeterministicChildIsMissing() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", ChildGraph()); + var parent = await CreatePublishedGraphAsync(fixture, "parent", ParentGraph()); + var firstQueue = new TestFlowRunQueue(); + var first = Service(fixture, firstQueue); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var pending = await first.CreateAsync(parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "recover-child", input.RootElement, TestScope, default); + await first.ExecuteAsync(new(pending.Value.Id, TestScope), default); + var waiting = (await first.GetAsync(TestScope.WorkspaceId, pending.Value.Id, default))!; + var childId = waiting.Value.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId!; + var child = (await first.GetAsync(TestScope.WorkspaceId, childId, default))!; + await fixture.Repository.DeleteRunAsync(TestScope.WorkspaceId, childId, child.ETag, default); + + var recoveryQueue = new TestFlowRunQueue(); + var recovered = Service(fixture, recoveryQueue); + await recovered.InitializeAsync(default); + Assert.IsTrue(recoveryQueue.Enqueued.Any(item => item.RunId == waiting.Value.Id)); + + await recovered.ExecuteAsync(new(waiting.Value.Id, TestScope), default); + var recreated = await recovered.GetAsync(TestScope.WorkspaceId, childId, default); + Assert.IsNotNull(recreated); + Assert.AreEqual(waiting.Value.Id, recreated.Value.ParentFlowRunId); + Assert.HasCount(2, (await recovered.ListAsync(null, null, 0, 20, TestScope, default)).Items); + } + + [TestMethod] + public async Task ChildFailureResumesTheExplicitFailedTransition() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", FailingChildGraph()); + var parent = await CreatePublishedGraphAsync(fixture, "parent", ParentGraph()); + var runs = Service(fixture, new TestFlowRunQueue()); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var pending = await runs.CreateAsync(parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "child-failure", input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(pending.Value.Id, TestScope), default); + var waiting = (await runs.GetAsync(TestScope.WorkspaceId, pending.Value.Id, default))!.Value; + var childId = waiting.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId!; + + await runs.ExecuteAsync(new(childId, TestScope), default); + await runs.ExecuteAsync(new(waiting.Id, TestScope), default); + + var failed = (await runs.GetAsync(TestScope.WorkspaceId, waiting.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.Failed, failed.Status); + Assert.AreEqual("CHILD_FLOW_FAILED", failed.Error?.Code); + var call = failed.Steps.Single(step => step.StepName == "analyze"); + Assert.AreEqual(FlowStepRunStatus.Failed, call.Status); + Assert.AreEqual("analyze-failed", call.SelectedTransition); + Assert.AreEqual("CHILD_FAILURE", call.Error?.Code); + } + + [TestMethod] + public async Task NestedFlowDepthIsBoundedWithAStableFailure() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "grandchild", ChildGraph()); + await CreatePublishedGraphAsync(fixture, "child", CallingGraph("grandchild")); + var parent = await CreatePublishedGraphAsync(fixture, "parent", CallingGraph("child")); + var runs = Service(fixture, new TestFlowRunQueue(), new FlowRunExecutionOptions { MaximumNestingDepth = 1 }); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var root = await runs.CreateAsync(parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "bounded", input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(root.Value.Id, TestScope), default); + var parentWaiting = (await runs.GetAsync(TestScope.WorkspaceId, root.Value.Id, default))!.Value; + var childId = parentWaiting.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId!; + + await runs.ExecuteAsync(new(childId, TestScope), default); + + var failedChild = (await runs.GetAsync(TestScope.WorkspaceId, childId, default))!.Value; + Assert.AreEqual(FlowRunStatus.Failed, failedChild.Status); + Assert.AreEqual("flow_nesting_depth_exceeded", failedChild.Error?.Code); + Assert.HasCount(2, (await runs.ListAsync(null, null, 0, 20, TestScope, default)).Items); + } + + [TestMethod] + public async Task DescendantRunCountIsBoundedWithAStableFailure() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", ChildGraph()); + var parent = await CreatePublishedGraphAsync(fixture, "parent", TwoCallsGraph()); + var runs = Service(fixture, new TestFlowRunQueue(), new FlowRunExecutionOptions { MaximumDescendantRuns = 1 }); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var root = await runs.CreateAsync(parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "bounded-count", input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(root.Value.Id, TestScope), default); + var waiting = (await runs.GetAsync(TestScope.WorkspaceId, root.Value.Id, default))!.Value; + var firstChildId = waiting.Steps.Single(step => step.StepName == "first").ChildFlowRunId!; + await runs.ExecuteAsync(new(firstChildId, TestScope), default); + await runs.ExecuteAsync(new(root.Value.Id, TestScope), default); + + var failed = (await runs.GetAsync(TestScope.WorkspaceId, root.Value.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.Failed, failed.Status); + Assert.AreEqual("flow_descendant_limit_exceeded", failed.Error?.Code); + Assert.HasCount(2, (await runs.ListAsync(null, null, 0, 20, TestScope, default)).Items); + } + + [TestMethod] + public async Task ChildTimeoutResumesTheExplicitTimeoutTransition() + { + await using var fixture = await FlowFixture.CreateAsync(); + var child = await fixture.Service.CreateAsync(TestScope.WorkspaceId, new CreateFlowCommand( + "child", + null, + "1.0.0", + true, + new OrchestrationFlowDefinition( + [new(FlowTargetKind.Agent, "agent-a"), new(FlowTargetKind.Agent, "agent-b")], + new SequentialOrchestrationPattern())), default); + await fixture.Service.PublishVersionAsync(TestScope.WorkspaceId, child.Value.Id, "1.0.0", true, default); + var parent = await CreatePublishedGraphAsync(fixture, "parent", ParentGraph()); + var runs = Service( + fixture, + new TestFlowRunQueue(), + new FlowRunExecutionOptions { OrchestrationTimeout = TimeSpan.FromMilliseconds(25) }, + new StalledOrchestrationEngine()); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var root = await runs.CreateAsync(parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "timeout-child", input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(root.Value.Id, TestScope), default); + var waiting = (await runs.GetAsync(TestScope.WorkspaceId, root.Value.Id, default))!.Value; + var childId = waiting.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId!; + + await runs.ExecuteAsync(new(childId, TestScope), default); + await runs.ExecuteAsync(new(root.Value.Id, TestScope), default); + + var failed = (await runs.GetAsync(TestScope.WorkspaceId, root.Value.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.Failed, failed.Status); + var call = failed.Steps.Single(step => step.StepName == "analyze"); + Assert.AreEqual("analyze-timeout", call.SelectedTransition); + Assert.AreEqual("flow_run_timed_out", call.Error?.Code); + } + + private static FlowRunService Service( + FlowFixture fixture, + TestFlowRunQueue queue, + FlowRunExecutionOptions? options = null, + IFlowOrchestrationEngine? orchestration = null) + { + var expressions = new FlowExpressionParser(); + return new FlowRunService( + fixture.Repository, + queue, + new TestCancellationRegistry(), + new TestAgentExecutor(), + orchestration ?? new UnsupportedFlowOrchestrationEngine(), + expressions, + expressions, + new NullFlowRunEventSink(), + new TestFlowRunExecutionScope(), + TimeProvider.System, + options); + } + + private static async Task CreatePublishedGraphAsync( + FlowFixture fixture, + string name, + FlowGraphDefinition graph) + { + var created = await fixture.Service.CreateAsync(TestScope.WorkspaceId, new CreateFlowCommand( + name, + null, + "1.0.0", + true, + PlaceholderDefinition(), + Graph: graph), default); + await fixture.Service.PublishVersionAsync(TestScope.WorkspaceId, created.Value.Id, "1.0.0", true, default); + return created; + } + + private static FlowGraphDefinition ChildGraph() => new() + { + EntryStep = "input", + InputSchema = JsonSerializer.SerializeToElement(new { type = "object", properties = new { article = new { type = "string" } }, required = new[] { "article" } }), + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new TransformFlowStepDefinition { Name = "summarize", Mapping = JsonSerializer.SerializeToElement(new { summary = "${input.article}" }) }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.summarize.output}") } + ], + Transitions = + [ + new("input-summarize", "input", "completed", "summarize"), + new("summarize-output", "summarize", "completed", "output") + ] + }; + + private static FlowGraphDefinition FailingChildGraph() => new() + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new FailureFlowStepDefinition { Name = "failure", Code = "CHILD_FAILURE", Message = "The child failed." } + ], + Transitions = [new("input-failure", "input", "completed", "failure")] + }; + + private static FlowGraphDefinition CallingGraph(string childName) + { + var graph = ParentGraph(); + return graph with + { + Steps = graph.Steps.Select(step => step is FlowCallStepDefinition call + ? call with { Flow = new(childName, FlowCallVersionStrategy.Exact, "1.0.0") } + : step).ToArray() + }; + } + + private static FlowGraphDefinition TwoCallsGraph() => new() + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new FlowCallStepDefinition { Name = "first", Flow = new("child", FlowCallVersionStrategy.Exact, "1.0.0"), InputMapping = JsonSerializer.SerializeToElement(new { article = "${input.article}" }) }, + new FlowCallStepDefinition { Name = "second", Flow = new("child", FlowCallVersionStrategy.Exact, "1.0.0"), InputMapping = JsonSerializer.SerializeToElement(new { article = "${input.article}" }) }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.second.output}") } + ], + Transitions = + [ + new("input-first", "input", "completed", "first"), + new("first-second", "first", "completed", "second"), + new("second-output", "second", "completed", "output") + ] + }; + + private static FlowGraphDefinition ParentGraph() => new() + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new FlowCallStepDefinition + { + Name = "analyze", + Flow = new("child", FlowCallVersionStrategy.Exact, "1.0.0"), + InputMapping = JsonSerializer.SerializeToElement(new { article = "${input.article}" }) + }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.analyze.output}") }, + new FailureFlowStepDefinition { Name = "failure", Code = "CHILD_FLOW_FAILED", Message = "Child Flow failed." } + ], + Transitions = + [ + new("input-analyze", "input", "completed", "analyze"), + new("analyze-output", "analyze", "completed", "output"), + new("analyze-failed", "analyze", "failed", "failure"), + new("analyze-timeout", "analyze", "timedOut", "failure"), + new("analyze-cancelled", "analyze", "cancelled", "failure") + ] + }; +} diff --git a/tests/Agentstration.Application.Tests/FlowRunCausalityTests.cs b/tests/Agentstration.Application.Tests/FlowRunCausalityTests.cs new file mode 100644 index 00000000..175bee33 --- /dev/null +++ b/tests/Agentstration.Application.Tests/FlowRunCausalityTests.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Flow.Storage.Abstractions; + +namespace Agentstration.Application.Tests; + +public sealed partial class FlowTests +{ + [TestMethod] + public async Task CausalityProjectsOriginDescendantsToolAttemptsAndWorkspaceIsolationWithoutSensitivePayloads() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", ChildGraph()); + var graph = ParentGraph(); + graph = graph with + { + Steps = graph.Steps.Select(step => step is FlowCallStepDefinition call + ? call with { Flow = new("child", FlowCallVersionStrategy.Active) } + : step).ToArray() + }; + var parent = await CreatePublishedGraphAsync(fixture, "parent", graph); + var runs = Service(fixture, new TestFlowRunQueue()); + using var input = JsonDocument.Parse("""{"article":"new item"}"""); + var root = await runs.EnsureRootAsync(new EnsureRootFlowRunCommand( + "root-causal", + parent.Value.Id, + null, + "local", + FlowRunTrigger.Api, + FlowInvocationOrigin.Mcp, + "news-agent", + "news-42", + "delivery-42", + "correlation-42", + input.RootElement, + Guid.NewGuid().ToString("D"), + true, + null, + null, + null, + null, + TestScope), default); + await runs.ExecuteAsync(new(root.Value.Id, TestScope), default); + var waiting = (await runs.GetAsync(root.Value.Id, TestScope, default))!.Value; + var childId = waiting.Steps.Single(step => step.StepName == "analyze").ChildFlowRunId!; + var startedAt = DateTimeOffset.Parse("2026-09-10T08:00:00Z", CultureInfo.InvariantCulture); + var invocationId = $"flow:{childId}:step:notify:attempt:2"; + var toolCallId = $"flow:{childId}:step:notify"; + var common = new + { + ToolCallId = toolCallId, + InvocationId = invocationId, + ToolId = "notification.send", + ToolNamespace = "agentstration", + ToolName = "notification.send", + ProviderId = "agentstration.internal", + ProviderNamespace = "agentstration", + ExternalToolId = "work.notification.create", + FlowStepId = "notify", + CorrelationId = "correlation-42", + Arguments = new { secret = "must-not-leak" } + }; + await fixture.Repository.AppendRunEventAsync(new( + TestScope.WorkspaceId, childId, 0, FlowRunEventType.ToolCallStarted, "notify", + JsonSerializer.SerializeToElement(common), startedAt), default); + await fixture.Repository.AppendRunEventAsync(new( + TestScope.WorkspaceId, childId, 0, FlowRunEventType.ToolCallGovernanceEvaluated, "notify", + JsonSerializer.SerializeToElement(new + { + common.ToolCallId, + common.InvocationId, + common.ToolId, + common.ToolName, + common.ProviderId, + Governance = new[] { new { Decision = "allowed", HookId = "workspace-policy" } } + }), startedAt.AddMilliseconds(5)), default); + await fixture.Repository.AppendRunEventAsync(new( + TestScope.WorkspaceId, childId, 0, FlowRunEventType.ToolCallCompleted, "notify", + JsonSerializer.SerializeToElement(new + { + common.ToolCallId, + common.InvocationId, + common.ToolId, + common.ToolNamespace, + common.ToolName, + common.ProviderId, + common.ProviderNamespace, + common.ExternalToolId, + common.CorrelationId, + Outcome = "succeeded", + DurationMilliseconds = 12d + }), startedAt.AddMilliseconds(12)), default); + + var first = await runs.GetCausalityAsync(childId, 0, 1, TestScope, default); + Assert.AreEqual("root-causal", first.Origin.RootFlowRunId); + Assert.AreEqual(FlowInvocationOrigin.Mcp, first.Origin.InvocationOrigin); + Assert.AreEqual("news-agent", first.Origin.CallerId); + Assert.AreEqual("news-42", first.Origin.CausationId); + Assert.AreEqual(2, first.TotalCount); + Assert.IsTrue(first.HasMore); + Assert.HasCount(1, first.Items); + Assert.IsTrue(first.Items[0].ResolvedFromActiveReference); + + var second = await runs.GetCausalityAsync(childId, 1, 1, TestScope, default); + Assert.HasCount(1, second.Items); + var child = second.Items.Single(); + Assert.AreEqual("root-causal", child.ParentFlowRunId); + Assert.AreEqual("analyze", child.ParentStepName); + Assert.AreEqual("1.0.0", child.FlowVersion); + Assert.IsTrue(child.ResolvedFromActiveReference); + Assert.HasCount(1, child.ToolCalls); + var call = child.ToolCalls.Single(); + Assert.AreEqual(toolCallId, call.LogicalCallId); + Assert.AreEqual("agentstration.internal", call.ProviderId); + Assert.HasCount(1, call.Attempts); + var attempt = call.Attempts.Single(); + Assert.AreEqual(2, attempt.Attempt); + Assert.AreEqual("succeeded", attempt.Status); + Assert.AreEqual(1, attempt.GovernanceEvaluationCount); + Assert.DoesNotContain("must-not-leak", JsonSerializer.Serialize(second), StringComparison.Ordinal); + + var otherScope = TestScope with { WorkspaceId = new(Guid.NewGuid()) }; + await Assert.ThrowsExactlyAsync(() => + runs.GetCausalityAsync(childId, 0, 10, otherScope, default)); + } +} diff --git a/tests/Agentstration.Application.Tests/FlowTests.Execution.cs b/tests/Agentstration.Application.Tests/FlowTests.Execution.cs index 0151b52f..27254de9 100644 --- a/tests/Agentstration.Application.Tests/FlowTests.Execution.cs +++ b/tests/Agentstration.Application.Tests/FlowTests.Execution.cs @@ -152,5 +152,70 @@ public async Task GraphWithoutFailureTransitionPreservesAgentError() Assert.AreEqual("simulated agent failure", completed.Error?.Message); } + [TestMethod] + public async Task GraphToolStepMapsArgumentsAndExposesItsOutput() + { + await using var fixture = await FlowFixture.CreateAsync(); + var graph = new FlowGraphDefinition + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new ToolFlowStepDefinition + { + Name = "notify", + Tool = new("notification.send"), + ArgumentsMapping = JsonSerializer.SerializeToElement(new { message = "${input.message}" }) + }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.notify.output}") } + ], + Transitions = + [ + new("input-notify", "input", "completed", "notify"), + new("notify-output", "notify", "completed", "output") + ] + }; + var now = TimeProvider.System.GetUtcNow(); + var draft = new FlowDraft { WorkspaceId = TestScope.WorkspaceId, Id = "tool-draft", FlowId = new("tool-run"), DisplayName = "Tool run", Definition = graph, CreatedAt = now, UpdatedAt = now }; + var tool = new RecordingFlowToolExecutor(); + var expressions = new FlowExpressionParser(); + var runs = new FlowRunService( + fixture.Repository, + new TestFlowRunQueue(), + new TestCancellationRegistry(), + new TestAgentExecutor(), + new UnsupportedFlowOrchestrationEngine(), + expressions, + expressions, + new NullFlowRunEventSink(), + new TestFlowRunExecutionScope(), + TimeProvider.System, + configuredToolExecutor: tool); + using var input = JsonDocument.Parse("""{"message":"A new article is available."}"""); + + var pending = await runs.CreateDraftAsync(draft, FlowRunTrigger.Manual, "tester", "tool-correlation", input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(pending.Value.Id, TestScope), default); + + var completed = (await runs.GetAsync(TestScope.WorkspaceId, pending.Value.Id, default))!.Value; + Assert.AreEqual(FlowRunStatus.Succeeded, completed.Status); + Assert.AreEqual("sent", completed.Output?.GetString()); + Assert.AreEqual("A new article is available.", tool.Request!.Arguments.GetProperty("message").GetString()); + Assert.AreEqual("notify", tool.Request.StepName); + Assert.AreEqual(1, tool.Request.Attempt); + Assert.AreEqual(TestScope, tool.Request.Scope); + } + + private sealed class RecordingFlowToolExecutor : IFlowToolExecutor + { + public FlowToolExecutionRequest? Request { get; private set; } + + public Task ExecuteAsync(FlowToolExecutionRequest request, CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(JsonSerializer.SerializeToElement("sent")); + } + } + } diff --git a/tests/Agentstration.Application.Tests/FlowTests.SecurityAndApi.cs b/tests/Agentstration.Application.Tests/FlowTests.SecurityAndApi.cs index 3cd95f3c..48d2de97 100644 --- a/tests/Agentstration.Application.Tests/FlowTests.SecurityAndApi.cs +++ b/tests/Agentstration.Application.Tests/FlowTests.SecurityAndApi.cs @@ -11,6 +11,7 @@ using Agentstration.Resources; using Agentstration.Runtime.Abstractions; using Agentstration.Work; +using Agentstration.Work.Storage.Abstractions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Routing; @@ -269,19 +270,77 @@ public async Task FlowRunApiCreatesAndListsRunsFromTheSameContract() Assert.AreEqual(new FlowRunScope(requestContext.TenantId, new(requestContext.WorkspaceId), requestContext.PrincipalId), run.Scope); var principal = await factory.Services.GetRequiredService().GetPrincipalAsync(requestContext.PrincipalId, default); Assert.AreEqual(principal?.DisplayName, run.StartedBy); + Assert.AreEqual(FlowInvocationOrigin.Api, run.InvocationOrigin); + Assert.AreEqual(principal?.DisplayName, run.CallerId); + Assert.IsNotNull(run.WorkItemResourceId); + Assert.IsNull(run.ParentFlowRunId); + Assert.AreEqual(0, run.NestingDepth); Assert.IsNull(typeof(CreateFlowRunRequest).GetProperty("StartedBy")); var global = await client.GetFromJsonAsync("/api/flowRuns", JsonOptions); Assert.IsTrue(global!.Value.Any(item => item.Id == run.Id)); var scoped = await client.GetFromJsonAsync("/api/flows/api-run-flow/runs", JsonOptions); Assert.IsTrue(scoped!.Value.Any(item => item.Id == run.Id)); + var causality = await client.GetFromJsonAsync($"/api/flowRuns/{run.Id}/causality?top=1", JsonOptions); + Assert.IsNotNull(causality); + Assert.AreEqual(run.Id, causality.Origin.RootFlowRunId); + Assert.AreEqual(FlowInvocationOrigin.Api, causality.Origin.InvocationOrigin); + Assert.AreEqual(1, causality.TotalCount); + Assert.HasCount(1, causality.Value); + Assert.IsTrue(causality.Value[0].ResolvedFromActiveReference); + Assert.IsNull(causality.NextLink); var routes = factory.Services.GetRequiredService().Endpoints .OfType() .Select(endpoint => endpoint.RoutePattern.RawText) .ToArray(); Assert.Contains("/api/flowRuns/{runId}", routes); + Assert.Contains("/api/flowRuns/{runId}/causality", routes); Assert.DoesNotContain("/flowRuns/{runId}", routes); } + [TestMethod] + public async Task FlowRunApiIdempotencyRecoversTheSameRootWorkAndRejectsConflicts() + { + await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => builder.UseEnvironment("Testing")); + using var client = factory.CreateClient(); + var definition = new CreateFlowRequest("idempotent-api-flow", null, "1.0.0", true, + new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "sql-expert"))); + Assert.AreEqual(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/flows", definition, JsonOptions)).StatusCode); + Assert.AreEqual(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/flows/idempotent-api-flow/versions", new CreateFlowVersionRequest("1.0.0"))).StatusCode); + + static HttpRequestMessage Request(int value) + { + var message = new HttpRequestMessage(HttpMethod.Post, "/api/flows/idempotent-api-flow/runs") + { + Content = JsonContent.Create(new CreateFlowRunRequest(JsonSerializer.SerializeToElement(new { value })), options: JsonOptions) + }; + message.Headers.Add("Idempotency-Key", "api-request-42"); + message.Headers.Add("X-Causation-Id", "external-request-42"); + return message; + } + + using var firstRequest = Request(1); + using var firstResponse = await client.SendAsync(firstRequest); + using var secondRequest = Request(1); + using var secondResponse = await client.SendAsync(secondRequest); + Assert.AreEqual(HttpStatusCode.Accepted, firstResponse.StatusCode, await firstResponse.Content.ReadAsStringAsync()); + Assert.AreEqual(HttpStatusCode.Accepted, secondResponse.StatusCode, await secondResponse.Content.ReadAsStringAsync()); + var first = await firstResponse.Content.ReadFromJsonAsync(JsonOptions); + var second = await secondResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual(first?.Id, second?.Id); + Assert.AreEqual("api-request-42", first?.IdempotencyKey); + Assert.AreEqual("external-request-42", first?.CausationId); + + var workspace = await factory.Services.GetRequiredService().EnsureInitializedAsync(default); + var work = factory.Services.GetRequiredService(); + Assert.HasCount(1, (await work.QueryAsync(new WorkItemQuery(new WorkspaceId(workspace.WorkspaceId), Type: "flow-api"), default)).Items); + + using var conflictingRequest = Request(2); + using var conflict = await client.SendAsync(conflictingRequest); + Assert.AreEqual(HttpStatusCode.Conflict, conflict.StatusCode); + using var problem = JsonDocument.Parse(await conflict.Content.ReadAsStreamAsync()); + Assert.AreEqual("flow_invocation_idempotency_conflict", problem.RootElement.GetProperty("title").GetString()); + } + [TestMethod] public async Task FlowRunDeleteApiRequiresTerminalStatusAndCurrentETag() { diff --git a/tests/Agentstration.Application.Tests/FlowToolExecutorTests.cs b/tests/Agentstration.Application.Tests/FlowToolExecutorTests.cs new file mode 100644 index 00000000..6550f8e1 --- /dev/null +++ b/tests/Agentstration.Application.Tests/FlowToolExecutorTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Infrastructure.Flows; +using Agentstration.Management.Abstractions; +using Agentstration.Resources; +using Agentstration.Runtime.Abstractions; + +namespace Agentstration.Application.Tests; + +[TestClass] +public sealed class FlowToolExecutorTests +{ + [TestMethod] + public async Task ExecutorBuildsStableFlowRunIdentitiesAndPassesTheResolvedArguments() + { + var store = await StoreAsync(); + var pipeline = new RecordingPipeline(); + var executor = new ManagedFlowToolExecutor(store, pipeline); + var request = Request(JsonSerializer.SerializeToElement(new { message = "hello" }), 1); + + var output = await executor.ExecuteAsync(request, default); + await executor.ExecuteAsync(request, default); + await executor.ExecuteAsync(request with { Attempt = 2 }, default); + + Assert.AreEqual("sent", output?.GetString()); + Assert.HasCount(3, pipeline.Contexts); + var first = pipeline.Contexts[0]; + Assert.AreEqual(ToolExecutionOwnerKind.FlowRun, first.OwnerKind); + Assert.AreEqual("flow:run-42:step:notify", first.ToolCallId); + Assert.AreEqual("flow:run-42:step:notify:attempt:1", first.InvocationId); + Assert.AreEqual(first.ToolCallId, pipeline.Contexts[2].ToolCallId); + Assert.AreEqual(first.InvocationId, pipeline.Contexts[1].InvocationId); + Assert.AreNotEqual(first.InvocationId, pipeline.Contexts[2].InvocationId); + Assert.AreEqual(Scope.WorkspaceId, first.WorkspaceId); + Assert.AreEqual(Scope.PrincipalId, first.PrincipalId); + Assert.AreEqual("notify", first.FlowStepId); + Assert.AreEqual("hello", first.Arguments?.GetProperty("message").GetString()); + } + + [TestMethod] + public async Task InvalidArgumentsAndApprovalRequirementsFailBeforeTheProviderPipeline() + { + var store = await StoreAsync(); + var pipeline = new RecordingPipeline(); + var executor = new ManagedFlowToolExecutor(store, pipeline); + + var invalid = await Assert.ThrowsExactlyAsync(() => + executor.ExecuteAsync(Request(JsonSerializer.SerializeToElement(new { message = 42 }), 1), default)); + Assert.AreEqual("tool_argument_type_invalid", invalid.Code); + Assert.HasCount(0, pipeline.Contexts); + + await store.PutAsync(Tool(requiresApproval: true), null, false, default); + var approval = await Assert.ThrowsExactlyAsync(() => + executor.ExecuteAsync(Request(JsonSerializer.SerializeToElement(new { message = "hello" }), 1), default)); + Assert.AreEqual("tool_approval_required", approval.Code); + Assert.HasCount(0, pipeline.Contexts); + } + + private static readonly FlowRunScope Scope = new( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + new WorkspaceId(Guid.Parse("22222222-2222-2222-2222-222222222222")), + Guid.Parse("33333333-3333-3333-3333-333333333333")); + + private static FlowToolExecutionRequest Request(JsonElement arguments, int attempt) => new( + Scope, + "run-42", + new FlowId("parent"), + "notify", + attempt, + "correlation-42", + new("notification.send"), + arguments); + + private static async Task StoreAsync() + { + var store = new MemoryStore(); + await store.PutAsync(Tool(), null, true, default); + return store; + } + + private static ToolResource Tool(bool requiresApproval = false) => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.Tool, + Metadata = new ResourceMetadata { Name = "notification.send" }, + Definition = new ToolResourceProperties + { + DisplayName = "Send notification", + Enabled = true, + RequiresApproval = requiresApproval, + Provider = new("internal"), + ExternalId = "notification_send", + Discovery = new ToolDiscoveryState + { + Available = true, + FirstSeenAt = DateTimeOffset.UnixEpoch, + LastSeenAt = DateTimeOffset.UnixEpoch + }, + Schema = new ToolSchema + { + Input = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new { message = new { type = "string" } }, + required = new[] { "message" } + }) + } + } + }; + + private sealed class RecordingPipeline : IToolExecutionPipeline + { + public List Contexts { get; } = []; + + public ValueTask ExecuteAsync(ToolExecutionContext context, CancellationToken cancellationToken = default) + { + Contexts.Add(context); + return ValueTask.FromResult(JsonSerializer.SerializeToElement("sent")); + } + } + + private sealed class MemoryStore : IControlPlaneStore + { + private readonly Dictionary values = []; + private long version; + + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task?> GetAsync(ResourceKey key, CancellationToken cancellationToken) where T : Resource => + Task.FromResult(values.TryGetValue(key, out var entry) && entry.Value is T typed + ? new StoredResource(typed, entry.ETag, entry.At) + : null); + + public Task>> ListAsync(string kind, int skip, int take, CancellationToken cancellationToken) where T : Resource => + Task.FromResult>>([]); + + public Task> PutAsync(T resource, string? ifMatch, bool ifNoneMatch, CancellationToken cancellationToken) where T : Resource + { + var key = new ResourceKey(resource.Kind, resource.Name, resource.Namespace); + var etag = $"\"{Interlocked.Increment(ref version)}\""; + var now = DateTimeOffset.UnixEpoch; + values[key] = (resource, etag, now); + return Task.FromResult(new StoredResource(resource, etag, now)); + } + + public Task> CreateImmutableAsync(T resource, CancellationToken cancellationToken) where T : Resource => + PutAsync(resource, null, true, cancellationToken); + + public Task DeleteAsync(ResourceKey key, string? ifMatch, CancellationToken cancellationToken) + { + values.Remove(key); + return Task.CompletedTask; + } + } +} diff --git a/tests/Agentstration.Application.Tests/FlowToolStepTests.cs b/tests/Agentstration.Application.Tests/FlowToolStepTests.cs new file mode 100644 index 00000000..d0f4ad59 --- /dev/null +++ b/tests/Agentstration.Application.Tests/FlowToolStepTests.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Resources; + +namespace Agentstration.Application.Tests; + +[TestClass] +public sealed class FlowToolStepTests +{ + [TestMethod] + public async Task ToolStepRoundTripsAndValidatesMappedSchema() + { + var schema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new { message = new { type = "string" } }, + required = new[] { "message" } + }); + var graph = Graph(JsonSerializer.SerializeToElement(new { message = "${input.text}" })); + var yaml = FlowDraftService.ToYaml(graph); + var restored = new FlowDraftService(null!, null!, null!, TimeProvider.System).ParseSource(yaml, "yaml"); + var step = Assert.IsInstanceOfType(restored.Steps[1]); + Assert.AreEqual("notification.send", step.Tool.ResourceId); + + var validator = new FlowGraphValidator(new ToolResolver(schema)); + var valid = await validator.ValidateAsync(restored, new(true, Workspace, new FlowId("parent")), default); + Assert.IsTrue(valid.IsValid, string.Join(Environment.NewLine, valid.Issues.Select(issue => issue.Message))); + + var invalid = Graph(JsonSerializer.SerializeToElement(new { unexpected = "value" })); + var result = await validator.ValidateAsync(invalid, new(true, Workspace, new FlowId("parent")), default); + Assert.IsTrue(result.Issues.Any(issue => issue.Code == "tool_argument_unknown")); + Assert.IsTrue(result.Issues.Any(issue => issue.Code == "tool_argument_required")); + } + + private static FlowGraphDefinition Graph(JsonElement mapping) => new() + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new ToolFlowStepDefinition { Name = "notify", Tool = new("notification.send"), ArgumentsMapping = mapping }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.notify.output}") } + ], + Transitions = + [ + new("input-notify", "input", "completed", "notify"), + new("notify-output", "notify", "completed", "output") + ] + }; + + private static readonly WorkspaceId Workspace = new(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + + private sealed class ToolResolver(JsonElement schema) : IFlowResourceReferenceResolver + { + public Task ExistsAsync(string resourceId, CancellationToken cancellationToken) => Task.FromResult(true); + + public Task ResolveToolAsync( + WorkspaceId workspaceId, + ResourceNamespace ownerNamespace, + FlowToolReference reference, + CancellationToken cancellationToken) => Task.FromResult(new( + reference.ResourceId, + reference.ResolveNamespace(ownerNamespace), + schema, + null, + Enabled: true, + Available: true, + RequiresApproval: false)); + } +} diff --git a/tests/Agentstration.Application.Tests/RootFlowSubmissionTests.cs b/tests/Agentstration.Application.Tests/RootFlowSubmissionTests.cs new file mode 100644 index 00000000..249f4322 --- /dev/null +++ b/tests/Agentstration.Application.Tests/RootFlowSubmissionTests.cs @@ -0,0 +1,239 @@ +using System.Text.Json; +using Agentstration.Application.Work; +using Agentstration.Flow; +using Agentstration.Resources; +using Agentstration.Work; +using Agentstration.Work.Storage.Abstractions; +using Agentstration.Work.Storage.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Agentstration.Application.Tests; + +[TestClass] +public sealed class RootFlowSubmissionTests +{ + private static readonly FlowRunScope Scope = new( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + new WorkspaceId(Guid.Parse("22222222-2222-2222-2222-222222222222")), + Guid.Parse("33333333-3333-3333-3333-333333333333")); + + [TestMethod] + public async Task RepeatedSubmissionRecoversOneWorkItemAndOneRootRun() + { + await using var fixture = await Fixture.CreateAsync(); + var command = Command("daily-news", JsonSerializer.SerializeToElement(new { topic = "ai" })); + + var first = await fixture.Submissions.SubmitAsync(command, default); + var second = await fixture.Submissions.SubmitAsync(command, default); + + Assert.AreEqual(first.WorkItem.Value.Id, second.WorkItem.Value.Id); + Assert.AreEqual(first.FlowRun.Run.Id, second.FlowRun.Run.Id); + Assert.IsFalse(first.Recovered); + Assert.IsTrue(second.Recovered); + Assert.HasCount(1, (await fixture.Repository.QueryAsync(new WorkItemQuery(Scope.WorkspaceId), default)).Items); + Assert.HasCount(1, fixture.Runs.RunIds.Distinct(StringComparer.Ordinal)); + Assert.IsNull(first.FlowRun.Run.ParentFlowRunId); + Assert.IsNull(first.FlowRun.Run.RootFlowRunId); + Assert.AreEqual(0, first.FlowRun.Run.NestingDepth); + Assert.AreEqual(FlowInvocationOrigin.Api, first.FlowRun.Run.InvocationOrigin); + Assert.AreEqual("api-user", first.FlowRun.Run.CallerId); + Assert.AreEqual("request-42", first.FlowRun.Run.CausationId); + Assert.AreEqual(first.WorkItem.Value.Id.Value.ToString("D"), first.FlowRun.Run.WorkItemResourceId); + } + + [TestMethod] + public async Task ReusingIdempotencyKeyWithDifferentInputIsRejected() + { + await using var fixture = await Fixture.CreateAsync(); + await fixture.Submissions.SubmitAsync(Command("same", JsonSerializer.SerializeToElement(new { value = 1 })), default); + + var exception = await Assert.ThrowsExactlyAsync(() => + fixture.Submissions.SubmitAsync(Command("same", JsonSerializer.SerializeToElement(new { value = 2 })), default)); + + Assert.AreEqual("flow_invocation_idempotency_conflict", exception.Code); + } + + [TestMethod] + public async Task SubmissionResolvesActiveTargetToImmutableVersionAndPersistsCausality() + { + await using var fixture = await Fixture.CreateAsync(); + + var result = await fixture.Submissions.SubmitAsync(Command("immutable", JsonSerializer.SerializeToElement(new { })), default); + + Assert.IsFalse(result.WorkItem.Value.Flow!.UseActiveVersion); + Assert.AreEqual("2.1.0", result.WorkItem.Value.Flow.Version); + Assert.AreEqual("Api", result.WorkItem.Value.Metadata[RootFlowSubmissionService.OriginMetadata]); + Assert.AreEqual("api-user", result.WorkItem.Value.Metadata[RootFlowSubmissionService.CallerMetadata]); + Assert.AreEqual("request-42", result.WorkItem.Value.Metadata[RootFlowSubmissionService.CausationMetadata]); + Assert.AreEqual("immutable", result.WorkItem.Value.Metadata[RootFlowSubmissionService.IdempotencyMetadata]); + Assert.AreEqual(1, fixture.Targets.ResolutionCount); + Assert.AreEqual(2, result.WorkItem.Value.Inputs.Count); + Assert.AreEqual(JsonValueKind.Object, result.WorkItem.Value.Inputs[0].Structured!.Value.ValueKind); + } + + [TestMethod] + public async Task TriggerCanPreserveItsOccurrenceWorkItemIdentity() + { + await using var fixture = await Fixture.CreateAsync(); + var occurrenceId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + var command = Command("occurrence", JsonSerializer.SerializeToElement(new { })) with + { + Origin = FlowInvocationOrigin.Trigger, + Trigger = FlowRunTrigger.Schedule, + WorkItemId = new WorkItemId(occurrenceId) + }; + + var result = await fixture.Submissions.SubmitAsync(command, default); + + Assert.AreEqual(occurrenceId, result.WorkItem.Value.Id.Value); + Assert.AreEqual($"flowrun-root-{occurrenceId:N}", result.FlowRun.Run.Id); + } + + [TestMethod] + public async Task ScopeIsAuthorizedAndCannotBeSuppliedThroughInvocationInput() + { + await using var fixture = await Fixture.CreateAsync(); + var otherWorkspace = new WorkspaceId(Guid.NewGuid()); + + var exception = await Assert.ThrowsExactlyAsync(() => fixture.Submissions.SubmitAsync( + Command("wrong-scope", JsonSerializer.SerializeToElement(new { tenantId = Guid.NewGuid(), workspaceId = otherWorkspace.Value })) with + { + WorkspaceId = otherWorkspace + }, default)); + + Assert.AreEqual("flow_invocation_scope_mismatch", exception.Code); + Assert.AreEqual(0, fixture.Authorizer.CallCount); + } + + private static SubmitRootFlowCommand Command(string key, JsonElement input) => new( + Scope.WorkspaceId, + new FlowReference(new FlowId("news")), + input, + FlowInvocationOrigin.Api, + "api-user", + FlowRunTrigger.Api, + key, + "request-42", + "correlation-42", + WorkInputs: [new WorkInput("visible request")]); + + private sealed class Fixture : IAsyncDisposable + { + private readonly string directory; + private readonly ServiceProvider provider; + + private Fixture(string directory, ServiceProvider provider, IWorkItemRepository repository, RootFlowSubmissionService submissions, TargetResolver targets, RunGateway runs, Authorizer authorizer) + { + this.directory = directory; + this.provider = provider; + Repository = repository; + Submissions = submissions; + Targets = targets; + Runs = runs; + Authorizer = authorizer; + } + + public IWorkItemRepository Repository { get; } + public RootFlowSubmissionService Submissions { get; } + public TargetResolver Targets { get; } + public RunGateway Runs { get; } + public Authorizer Authorizer { get; } + + public static async Task CreateAsync() + { + var directory = Path.Combine(Path.GetTempPath(), $"agentstration-root-flow-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var services = new ServiceCollection(); + services.AddSqliteWorkPlane($"Data Source={Path.Combine(directory, "work.db")};Pooling=False"); + var provider = services.BuildServiceProvider(); + var repository = provider.GetRequiredService(); + await repository.InitializeAsync(default); + var execution = new ExecutionGateway(); + var accessor = new ScopeAccessor(); + var work = new WorkItemService(repository, execution, TimeProvider.System, NullLogger.Instance, [], [accessor]); + var targets = new TargetResolver(); + var runs = new RunGateway(); + var authorizer = new Authorizer(); + var submissions = new RootFlowSubmissionService(work, repository, targets, runs, authorizer, [accessor]); + return new(directory, provider, repository, submissions, targets, runs, authorizer); + } + + public async ValueTask DisposeAsync() + { + await provider.DisposeAsync(); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + } + + private sealed class ScopeAccessor : IWorkExecutionScopeAccessor + { + public FlowRunScope Current => Scope; + } + + private sealed class Authorizer : IRootFlowSubmissionAuthorizer + { + public int CallCount { get; private set; } + public Task AuthorizeAsync(FlowRunScope scope, CancellationToken cancellationToken) + { + CallCount++; + Assert.AreEqual(Scope, scope); + return Task.CompletedTask; + } + } + + private sealed class TargetResolver : IRootFlowTargetResolver + { + public int ResolutionCount { get; private set; } + public Task ResolveAsync(FlowRunScope scope, FlowReference target, JsonElement input, CancellationToken cancellationToken) + { + ResolutionCount++; + return Task.FromResult(new ResolvedRootFlowTarget(new FlowReference(target.FlowId, "2.1.0", false, target.FlowId.Namespace))); + } + } + + private sealed class RunGateway : IRootFlowRunGateway + { + private readonly Dictionary runs = new(StringComparer.Ordinal); + public IReadOnlyCollection RunIds => runs.Keys; + + public Task EnsureAsync(RootFlowRunRequest request, CancellationToken cancellationToken) + { + if (!runs.TryGetValue(request.RunId, out var result)) + { + var version = new FlowVersion(request.Scope.WorkspaceId, request.Target.FlowId, request.Target.Version!, null, + new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "assistant")), new Dictionary(), DateTimeOffset.UtcNow); + var run = new FlowRun + { + WorkspaceId = request.Scope.WorkspaceId, + Id = request.RunId, + FlowId = request.Target.FlowId, + FlowVersion = request.Target.Version!, + Trigger = request.Trigger, + InvocationOrigin = request.Origin, + CallerId = request.CallerId, + CausationId = request.CausationId, + IdempotencyKey = request.IdempotencyKey, + CorrelationId = request.CorrelationId, + WorkItemResourceId = request.WorkItemId.Value.ToString("D"), + ParentFlowRunId = request.ParentFlowRunId, + Scope = request.Scope, + Input = request.Input.Clone(), + CreatedAt = DateTimeOffset.UtcNow, + DefinitionSnapshot = version + }; + result = new(run, "\"1\""); + runs.Add(request.RunId, result); + } + return Task.FromResult(result); + } + } + + private sealed class ExecutionGateway : IWorkExecutionGateway + { + public Task RequestExecutionAsync(WorkExecutionRequest request, CancellationToken cancellationToken) => + Task.FromResult(new WorkExecutionAccepted(WorkExecutionId.New(), null, DateTimeOffset.UtcNow, Guid.NewGuid())); + + public Task ConfirmQueuedAsync(WorkExecutionAccepted accepted, CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Agentstration.Management.Tests/ManagementPlaneTests.cs b/tests/Agentstration.Management.Tests/ManagementPlaneTests.cs index 3f9de51d..cb7b1627 100644 --- a/tests/Agentstration.Management.Tests/ManagementPlaneTests.cs +++ b/tests/Agentstration.Management.Tests/ManagementPlaneTests.cs @@ -112,7 +112,7 @@ public void CompilerIsDeterministicAndOrdersLogicalToolNames() Assert.AreEqual(first.DefinitionHash, second.DefinitionHash); Assert.AreEqual("Answer carefully.\nUse sources.", first.EffectiveInstructions); - CollectionAssert.AreEqual(new[] { "alpha", "zeta" }, first.EffectiveToolNames.ToArray()); + CollectionAssert.AreEqual(new[] { "agentstration.sample-pack/alpha", "agentstration.sample-pack/zeta" }, first.EffectiveToolNames.ToArray()); Assert.AreEqual("reasoning-default", first.ModelProfileName); Assert.AreEqual(new ResourceNamespace("agentstration.sample-pack"), first.ModelProfileNamespace); Assert.AreEqual("maf-builtin", first.RuntimeProfileName); diff --git a/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs b/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs new file mode 100644 index 00000000..dac4b894 --- /dev/null +++ b/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs @@ -0,0 +1,308 @@ +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Infrastructure.Declarative; +using Agentstration.Infrastructure.Notifications; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Management.Core; +using Agentstration.Resources; +using Agentstration.Work; +using Agentstration.Work.Storage.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; + +namespace Agentstration.Management.Tests; + +public sealed partial class ModelManagementApiTests +{ + [TestMethod] + public void NotificationDeliverySamplesUseOnlyGenericFlowAndToolSteps() + { + var sampleRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "samples", "notification-delivery")); + var delivery = ResourceManifestSerializer.FromYaml>( + File.ReadAllText(Path.Combine(sampleRoot, "flows", "notification-delivery.yaml"))); + var parent = ResourceManifestSerializer.FromYaml>( + File.ReadAllText(Path.Combine(sampleRoot, "flows", "news-alert-parent.yaml"))); + var definition = ResourceManifestSerializer.FromYaml( + File.ReadAllText(Path.Combine(sampleRoot, "tooldefinitions", "notification-send.yaml"))); + + Assert.IsInstanceOfType(delivery.Definition.Graph!.Steps.Single(value => value.Name == "create-notification")); + Assert.IsInstanceOfType(parent.Definition.Graph!.Steps.Single(value => value.Name == "deliver")); + Assert.AreEqual("notification-delivery", definition.Definition.Flow.Name); + Assert.IsTrue(definition.Definition.Flow.UseActiveVersion); + } + + [TestMethod] + public async Task InternalNotificationToolIsPublishedAndExplicitDeliveryKeyIsIdempotent() + { + await using var factory = Factory(); + var context = await GetBootstrapContextAsync(factory); + using var requestScope = factory.Services.GetRequiredService().Push(context); + var http = factory.CreateClient(); + var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new Uri(http.BaseAddress!, "mcp"), Name = "notification-test" }, + http, + NullLoggerFactory.Instance, + ownsHttpClient: false); + await using var mcp = await McpClient.CreateAsync(transport, loggerFactory: NullLoggerFactory.Instance); + var tool = (await mcp.ListToolsAsync()).Single(value => value.Name == AgentstrationInternalTools.NotificationCreate); + var arguments = new AIFunctionArguments(new Dictionary + { + ["deliveryKey"] = "daily-news-2026-09-10", + ["title"] = "Daily news", + ["message"] = "A new article is ready." + }); + + await tool.InvokeAsync(arguments); + await tool.InvokeAsync(arguments); + + var notifications = await factory.Services.GetRequiredService() + .ListNotificationsAsync(new WorkspaceId(context.WorkspaceId), null, default); + Assert.HasCount(1, notifications); + Assert.AreEqual("daily-news-2026-09-10", notifications[0].DeliveryKey); + Assert.AreEqual(context.WorkspaceId, notifications[0].WorkspaceId.Value); + var handler = factory.Services.GetRequiredService(); + var spoof = await Assert.ThrowsAsync(async () => await handler.ExecuteAsync(new( + context.TenantId, + new WorkspaceId(context.WorkspaceId), + context.PrincipalId, + "spoof-call", + null, + JsonSerializer.SerializeToElement(new { deliveryKey = "spoof", title = "Spoof", message = "Spoof", workspaceId = Guid.NewGuid() }), + ToolDefinitionCallerKind.Mcp), default)); + Assert.AreEqual("notification_argument_unknown", spoof.Code); + var projected = await factory.Services.GetRequiredService().GetAsync( + new(ResourceKinds.Tool, AgentstrationToolProvider.ToolResourceName(AgentstrationInternalTools.NotificationCreate)), default); + Assert.IsNotNull(projected); + Assert.AreEqual(AgentstrationInternalTools.NotificationCreate, projected.Value.Definition.ExternalId); + var reserved = await Assert.ThrowsAsync(async () => await factory.Services + .GetRequiredService() + .PutAsync(NotificationToolDefinition( + AgentstrationInternalTools.NotificationCreate, + "unused", + ResourceScopeRef.Workspace(context.WorkspaceId), + NotificationSchemas()), null, true, default)); + Assert.AreEqual("tool_definition_name_reserved", reserved.Code); + } + + [TestMethod] + public async Task ParentAndToolDefinitionReuseDeliveryFlowWithoutNotificationRecursion() + { + await using var factory = Factory(); + var context = await GetBootstrapContextAsync(factory); + using var requestScope = factory.Services.GetRequiredService().Push(context); + var workspaceId = new WorkspaceId(context.WorkspaceId); + var scope = ResourceScopeRef.Workspace(context.WorkspaceId); + await factory.Services.GetRequiredService() + .EnsureAsync(scope, ResourceNamespace.Default, default); + var schemas = NotificationSchemas(); + var flows = factory.Services.GetRequiredService(); + + await CreateAndPublishAsync(flows, workspaceId, "notification-delivery", "1.0.0", DeliveryGraph(schemas.Input, schemas.Output), true); + await CreateAndPublishAsync(flows, workspaceId, "news-parent", "1.0.0", ParentGraph(schemas.Input, schemas.Output), true); + var definitions = factory.Services.GetRequiredService(); + await definitions.PutAsync(NotificationToolDefinition("notification.send", "notification-delivery", scope, schemas), null, true, default); + await definitions.PutAsync(NotificationToolDefinition("news.alert", "news-parent", scope, schemas), null, true, default); + var arguments = JsonSerializer.SerializeToElement(new + { + deliveryKey = "article-42", + title = "News detected", + message = "Article 42 requires review.", + actionUrl = "/tasks/article-42" + }); + var executor = factory.Services.GetRequiredService(); + var invocation = new ToolDefinitionInvocation( + context.TenantId, + workspaceId, + context.PrincipalId, + ResourceNamespace.Default, + "news.alert", + "news-alert-call-1", + "news-correlation-1", + arguments, + ToolDefinitionCallerKind.Mcp); + + var first = await executor.ExecuteAsync(invocation, default); + var replay = await executor.ExecuteAsync(invocation, default); + + Assert.IsFalse(first.Receipt.Recovered); + Assert.IsTrue(replay.Receipt.Recovered); + Assert.AreEqual(first.Receipt.FlowRunId, replay.Receipt.FlowRunId); + var root = await factory.Services.GetRequiredService().GetAsync( + first.Receipt.FlowRunId, + new FlowRunScope(context.TenantId, workspaceId, context.PrincipalId), + default); + Assert.IsNotNull(root); + var childId = root.Value.Steps.Single(value => value.StepName == "deliver").ChildFlowRunId; + Assert.IsNotNull(childId); + var delivery = await factory.Services.GetRequiredService().GetAsync( + childId, + new FlowRunScope(context.TenantId, workspaceId, context.PrincipalId), + default); + Assert.IsNotNull(delivery); + Assert.AreEqual("notification-delivery", delivery.Value.FlowId.Value); + Assert.AreEqual("1.0.0", delivery.Value.FlowVersion); + var notification = (await factory.Services.GetRequiredService() + .ListNotificationsAsync(workspaceId, null, default)).Single(); + Assert.AreEqual(delivery.Value.Id, notification.SourceRunId); + Assert.AreEqual("create-notification", notification.SourceStepId); + StringAssert.Contains(notification.SourceToolCallId, "step:create-notification"); + + var current = await flows.GetAsync(workspaceId, new FlowId("notification-delivery"), default); + Assert.IsNotNull(current); + await AddExternalDeliveryToolAsync(factory.Services.GetRequiredService(), scope, schemas); + var updated = await flows.UpdateAsync(workspaceId, current.Value.Id, new UpdateFlowCommand( + current.Value.Description, + "2.0.0", + true, + current.Value.Definition, + current.Value.Metadata, + DeliveryGraph(schemas.Input, schemas.Output, "third-party.messages.send")), current.ETag, default); + await flows.PublishVersionAsync(workspaceId, updated.Value.Id, "2.0.0", true, default); + var parent = await flows.GetAsync(workspaceId, new FlowId("news-parent"), default); + Assert.AreEqual(FlowCallVersionStrategy.Active, Assert.IsInstanceOfType(parent!.Value.Graph!.Steps.Single(value => value.Name == "deliver")).Flow.VersionStrategy); + } + + private static async Task CreateAndPublishAsync(FlowService flows, WorkspaceId workspaceId, string name, string version, FlowGraphDefinition graph, bool activate) + { + await flows.CreateAsync(workspaceId, new CreateFlowCommand( + name, + null, + version, + true, + new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "unused")), + Graph: graph), ResourceNamespace.Default, default); + await flows.PublishVersionAsync(workspaceId, new FlowId(name), version, activate, default); + } + + private static ToolDefinitionResource NotificationToolDefinition( + string name, + string flow, + ResourceScopeRef scope, + (JsonElement Input, JsonElement Output) schemas) => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolDefinition, + Metadata = new ResourceMetadata { Name = name }, + ScopeRef = scope, + Definition = new ToolDefinitionProperties + { + DisplayName = name, + InputSchema = schemas.Input.Clone(), + OutputSchema = schemas.Output.Clone(), + Flow = new ToolDefinitionFlowTarget { Name = flow, UseActiveVersion = true } + } + }; + + private static FlowGraphDefinition DeliveryGraph(JsonElement input, JsonElement output, string? toolResourceId = null) => new() + { + EntryStep = "input", + InputSchema = input.Clone(), + OutputSchema = output.Clone(), + Steps = + [ + new InputFlowStepDefinition { Name = "input", Schema = input.Clone() }, + new ToolFlowStepDefinition + { + Name = "create-notification", + Tool = new(toolResourceId ?? AgentstrationToolProvider.ToolResourceName(AgentstrationInternalTools.NotificationCreate)), + ArgumentsMapping = JsonSerializer.SerializeToElement(new + { + deliveryKey = "${input.deliveryKey}", + title = "${input.title}", + message = "${input.message}", + actionUrl = "${input.actionUrl}" + }) + }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.create-notification.output}") } + ], + Transitions = + [ + new("input-tool", "input", "completed", "create-notification"), + new("tool-output", "create-notification", "completed", "output") + ] + }; + + private static FlowGraphDefinition ParentGraph(JsonElement input, JsonElement output) => new() + { + EntryStep = "input", + InputSchema = input.Clone(), + OutputSchema = output.Clone(), + Steps = + [ + new InputFlowStepDefinition { Name = "input", Schema = input.Clone() }, + new FlowCallStepDefinition { Name = "deliver", Flow = new("notification-delivery"), InputMapping = JsonSerializer.SerializeToElement("${input}") }, + new OutputFlowStepDefinition { Name = "output", OutputMapping = JsonSerializer.SerializeToElement("${steps.deliver.output}") } + ], + Transitions = [new("input-delivery", "input", "completed", "deliver"), new("delivery-output", "deliver", "completed", "output")] + }; + + private static (JsonElement Input, JsonElement Output) NotificationSchemas() + { + var input = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + deliveryKey = new { type = "string" }, + title = new { type = "string" }, + message = new { type = "string" }, + actionUrl = new { type = "string" } + }, + required = new[] { "deliveryKey", "title", "message" } + }); + var output = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + notificationId = new { type = "string" }, + deliveryKey = new { type = "string" }, + createdAt = new { type = "string" }, + recovered = new { type = "boolean" } + }, + required = new[] { "notificationId", "deliveryKey", "createdAt", "recovered" } + }); + return (input, output); + } + + private static async Task AddExternalDeliveryToolAsync( + IControlPlaneStore store, + ResourceScopeRef scope, + (JsonElement Input, JsonElement Output) schemas) + { + var now = DateTimeOffset.UnixEpoch; + await store.PutAsync(new ToolProviderResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolProvider, + Metadata = new ResourceMetadata { Name = "third-party" }, + ScopeRef = scope, + Definition = new ToolProviderProperties + { + DisplayName = "Third-party MCP", + ProviderType = ToolProviderType.Mcp, + Mcp = new McpToolProviderConfiguration { Transport = McpToolProviderTransport.StreamableHttp, Endpoint = new Uri("http://127.0.0.1:9/mcp") } + } + }, null, true, default); + await store.PutAsync(new ToolResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.Tool, + Metadata = new ResourceMetadata { Name = "third-party.messages.send" }, + ScopeRef = scope, + Definition = new ToolResourceProperties + { + DisplayName = "Send external message", + Provider = new ResourceReference("third-party"), + ExternalId = "messages.send", + Enabled = true, + Discovery = new ToolDiscoveryState { Available = true, FirstSeenAt = now, LastSeenAt = now }, + Schema = new ToolSchema { Input = schemas.Input.Clone(), Output = schemas.Output.Clone() } + } + }, null, true, default); + } +} diff --git a/tests/Agentstration.Management.Tests/ToolDefinitionTests.cs b/tests/Agentstration.Management.Tests/ToolDefinitionTests.cs new file mode 100644 index 00000000..5b5846b6 --- /dev/null +++ b/tests/Agentstration.Management.Tests/ToolDefinitionTests.cs @@ -0,0 +1,275 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Agentstration.Flow; +using Agentstration.Flow.Application; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Management.Core; +using Agentstration.Resources; +using Agentstration.Runtime.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; + +namespace Agentstration.Management.Tests; + +public sealed partial class ModelManagementApiTests +{ + [TestMethod] + public async Task ToolDefinitionCrudMaterializesInternalProviderAndGovernedTool() + { + await using var factory = Factory(); + var requestContext = await GetBootstrapContextAsync(factory); + using var requestScope = factory.Services.GetRequiredService().Push(requestContext); + var contract = await CreatePublishedFlowAsync(factory.Services, requestContext, "notification-flow"); + using var client = factory.CreateClient(); + + using var createdResponse = await client.PostAsJsonAsync("/api/tooldefinitions", new CreateToolDefinitionRequest( + "notification.send", + Properties("Send notification", "notification-flow", contract.Input, contract.Output))); + + Assert.AreEqual(HttpStatusCode.Created, createdResponse.StatusCode); + Assert.IsNotNull(createdResponse.Headers.ETag); + var created = await createdResponse.Content.ReadFromJsonAsync(); + Assert.IsNotNull(created); + Assert.AreEqual(ResourceScopeRef.Workspace(requestContext.WorkspaceId), created.ScopeRef); + + var store = factory.Services.GetRequiredService(); + var provider = await store.GetAsync(new(ResourceKinds.ToolProvider, AgentstrationToolProvider.Name), default); + var tool = await store.GetAsync(new(ResourceKinds.Tool, AgentstrationToolProvider.ToolResourceName("notification.send")), default); + Assert.IsNotNull(provider); + Assert.IsTrue(provider.Value.Definition.Mcp?.Internal); + Assert.IsNotNull(tool); + Assert.AreEqual("notification.send", tool.Value.Definition.ExternalId); + Assert.AreEqual(created.Uid, tool.Value.Definition.Metadata["agentstration.toolDefinitionUid"].GetGuid()); + Assert.IsTrue(tool.Value.Definition.Enabled); + + using var update = new HttpRequestMessage(HttpMethod.Put, "/api/tooldefinitions/notification.send?namespace=default") + { + Content = JsonContent.Create(new PutToolDefinitionRequest(Properties("Send an alert", "notification-flow", contract.Input, contract.Output))) + }; + update.Headers.IfMatch.Add(createdResponse.Headers.ETag!); + using var updatedResponse = await client.SendAsync(update); + Assert.AreEqual(HttpStatusCode.OK, updatedResponse.StatusCode); + var updated = await updatedResponse.Content.ReadFromJsonAsync(); + Assert.AreEqual("Send an alert", updated!.Definition.DisplayName); + + using var disable = new HttpRequestMessage(HttpMethod.Put, "/api/tooldefinitions/notification.send/enabled?namespace=default") + { + Content = JsonContent.Create(new SetToolDefinitionEnabledRequest(false)) + }; + disable.Headers.IfMatch.Add(updatedResponse.Headers.ETag!); + using var disabledResponse = await client.SendAsync(disable); + Assert.AreEqual(HttpStatusCode.OK, disabledResponse.StatusCode); + var disabled = await disabledResponse.Content.ReadFromJsonAsync(); + Assert.IsFalse(disabled!.Definition.Enabled); + tool = await store.GetAsync(new(ResourceKinds.Tool, AgentstrationToolProvider.ToolResourceName("notification.send")), default); + Assert.IsFalse(tool!.Value.Definition.Enabled); + + using var delete = new HttpRequestMessage(HttpMethod.Delete, "/api/tooldefinitions/notification.send?namespace=default"); + delete.Headers.IfMatch.Add(disabledResponse.Headers.ETag!); + using var deleted = await client.SendAsync(delete); + Assert.AreEqual(HttpStatusCode.NoContent, deleted.StatusCode); + Assert.IsNull(await store.GetAsync(new(ResourceKinds.ToolDefinition, "notification.send"), default)); + Assert.IsNull(await store.GetAsync(new(ResourceKinds.Tool, AgentstrationToolProvider.ToolResourceName("notification.send")), default)); + } + + [TestMethod] + public async Task ToolDefinitionRejectsIncompatibleContractAndBlocksIncompatibleActiveFlowVersion() + { + await using var factory = Factory(); + var requestContext = await GetBootstrapContextAsync(factory); + using var requestScope = factory.Services.GetRequiredService().Push(requestContext); + var contract = await CreatePublishedFlowAsync(factory.Services, requestContext, "analysis-flow"); + var service = factory.Services.GetRequiredService(); + var scope = ResourceScopeRef.Workspace(requestContext.WorkspaceId); + var invalid = Resource("news.analyze", scope, Properties( + "Analyze news", + "analysis-flow", + JsonSerializer.SerializeToElement(new { type = "object", required = new[] { "url" } }), + contract.Output)); + + var incompatible = await Assert.ThrowsAsync(async () => + await service.PutAsync(invalid, null, true, default)); + Assert.AreEqual("tool_definition_input_schema_incompatible", incompatible.Code); + + var stored = await service.PutAsync(Resource("news.analyze", scope, Properties("Analyze news", "analysis-flow", contract.Input, contract.Output)), null, true, default); + Assert.AreEqual(1, stored.Value.Generation); + var exact = Properties("Analyze news v1", "analysis-flow", contract.Input, contract.Output) with + { + Flow = new ToolDefinitionFlowTarget { Name = "analysis-flow", Version = "1.0.0", UseActiveVersion = false } + }; + var exactStored = await service.PutAsync(Resource("news.analyze.v1", scope, exact), null, true, default); + Assert.AreEqual("1.0.0", exactStored.Value.Definition.Flow.Version); + + var flows = factory.Services.GetRequiredService(); + var workspaceId = new WorkspaceId(requestContext.WorkspaceId); + var current = await flows.GetAsync(workspaceId, new FlowId("analysis-flow"), default); + Assert.IsNotNull(current); + var deletion = await Assert.ThrowsAsync(async () => + await flows.DeleteAsync(workspaceId, new FlowId("analysis-flow"), current.ETag, default)); + Assert.AreEqual("flow_in_use_by_tool_definition", deletion.Code); + var changedInput = JsonSerializer.SerializeToElement(new { type = "object", required = new[] { "headline" }, properties = new { headline = new { type = "string" } } }); + var updated = await flows.UpdateAsync(workspaceId, new FlowId("analysis-flow"), new UpdateFlowCommand( + current.Value.Description, + "2.0.0", + true, + current.Value.Definition, + current.Value.Metadata, + Graph(changedInput, contract.Output)), current.ETag, default); + + var activation = await Assert.ThrowsAsync(async () => + await flows.PublishVersionAsync(workspaceId, updated.Value.Id, "2.0.0", true, default)); + Assert.AreEqual("flow_tool_definition_contract_incompatible", activation.Code); + } + + [TestMethod] + public async Task EnabledDefinitionIsListedByMcpAndExecutesItsFlowIdempotently() + { + await using var factory = Factory(); + var requestContext = await GetBootstrapContextAsync(factory); + using var requestScope = factory.Services.GetRequiredService().Push(requestContext); + var contract = await CreatePublishedFlowAsync(factory.Services, requestContext, "echo-tool-flow"); + var definitions = factory.Services.GetRequiredService(); + await definitions.PutAsync(Resource( + "document.review", + ResourceScopeRef.Workspace(requestContext.WorkspaceId), + Properties("Review document", "echo-tool-flow", contract.Input, contract.Output)), null, true, default); + + var http = factory.CreateClient(); + var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new Uri(http.BaseAddress!, "mcp"), Name = "agentstration-test" }, + http, + NullLoggerFactory.Instance, + ownsHttpClient: false); + await using var mcp = await McpClient.CreateAsync(transport, loggerFactory: NullLoggerFactory.Instance); + var published = await mcp.ListToolsAsync(); + var publishedTool = published.Single(value => value.Name == "document.review"); + Assert.IsFalse(published.Any(value => value.Name is "flow.start" or "management.execute")); + var mcpResult = await publishedTool.InvokeAsync(new AIFunctionArguments(new Dictionary { ["message"] = "via mcp" })); + StringAssert.Contains(JsonSerializer.Serialize(mcpResult), "via mcp"); + + var assigned = await factory.Services.GetRequiredService() + .ResolveAsync([AgentstrationToolProvider.ToolResourceName("document.review")]); + var assignedTool = assigned.Single(); + Assert.AreEqual("document.review", assignedTool.Name); + Assert.AreEqual(AgentstrationToolProvider.Name, assignedTool.ProviderId); + + var arguments = JsonSerializer.SerializeToElement(new + { + message = "review this", + workspaceId = Guid.NewGuid(), + principalId = Guid.NewGuid() + }); + var invocation = new ToolDefinitionInvocation( + requestContext.TenantId, + new WorkspaceId(requestContext.WorkspaceId), + requestContext.PrincipalId, + ResourceNamespace.Default, + "document.review", + "logical-call-1", + "correlation-1", + arguments, + ToolDefinitionCallerKind.Mcp); + var executor = factory.Services.GetRequiredService(); + var invalidInput = await Assert.ThrowsAsync(async () => + await executor.ExecuteAsync(invocation with { CallId = "invalid-call", Arguments = JsonSerializer.SerializeToElement(new { }) }, default)); + Assert.AreEqual("tool_definition_input_invalid", invalidInput.Code); + var first = await executor.ExecuteAsync(invocation, default); + var replay = await executor.ExecuteAsync(invocation, default); + + Assert.AreEqual(arguments.GetRawText(), first.Output?.GetRawText()); + Assert.AreEqual(first.Receipt.WorkItemId, replay.Receipt.WorkItemId); + Assert.AreEqual(first.Receipt.FlowRunId, replay.Receipt.FlowRunId); + Assert.AreEqual("1.0.0", first.Receipt.FlowVersion); + Assert.IsFalse(first.Receipt.Recovered); + Assert.IsTrue(replay.Receipt.Recovered); + var persistedRun = await factory.Services.GetRequiredService().GetAsync( + first.Receipt.FlowRunId, + new FlowRunScope(requestContext.TenantId, new WorkspaceId(requestContext.WorkspaceId), requestContext.PrincipalId), + default); + Assert.IsNotNull(persistedRun); + Assert.AreEqual(requestContext.WorkspaceId, persistedRun.Value.Scope.WorkspaceId.Value); + Assert.AreEqual(requestContext.PrincipalId, persistedRun.Value.Scope.PrincipalId); + } + + [TestMethod] + public async Task ToolDefinitionNamespaceFlowsThroughMaterializedAgentAssignment() + { + await using var factory = Factory(); + var requestContext = await GetBootstrapContextAsync(factory); + using var requestScope = factory.Services.GetRequiredService().Push(requestContext); + var ns = new ResourceNamespace("communications"); + var contract = await CreatePublishedFlowAsync(factory.Services, requestContext, "delivery", ns); + var properties = Properties("Send notification", "delivery", contract.Input, contract.Output) with + { + Flow = new ToolDefinitionFlowTarget { Name = "delivery", Namespace = ns } + }; + var resource = Resource("notification.send", ResourceScopeRef.Workspace(requestContext.WorkspaceId), properties) with + { + Metadata = new ResourceMetadata { Name = "notification.send", Namespace = ns } + }; + await factory.Services.GetRequiredService().PutAsync(resource, null, true, default); + + var assigned = await factory.Services.GetRequiredService().ResolveAsync([ + ToolResourceIdentity.CatalogId(ns, AgentstrationToolProvider.ToolResourceName("notification.send")) + ]); + + Assert.AreEqual(ns, assigned.Single().Namespace); + Assert.AreEqual(ns, assigned.Single().ProviderNamespace); + } + + private static ToolDefinitionResource Resource(string name, ResourceScopeRef scope, ToolDefinitionProperties properties) => new() + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolDefinition, + Metadata = new ResourceMetadata { Name = name }, + ScopeRef = scope, + Definition = properties + }; + + private static ToolDefinitionProperties Properties(string displayName, string flowName, JsonElement input, JsonElement output) => new() + { + DisplayName = displayName, + InputSchema = input.Clone(), + OutputSchema = output.Clone(), + Flow = new ToolDefinitionFlowTarget { Name = flowName, UseActiveVersion = true } + }; + + private static async Task<(JsonElement Input, JsonElement Output)> CreatePublishedFlowAsync( + IServiceProvider services, + RequestContext context, + string name, + ResourceNamespace? resourceNamespace = null) + { + var input = JsonSerializer.SerializeToElement(new { type = "object", required = new[] { "message" }, properties = new { message = new { type = "string" } } }); + var output = input.Clone(); + var flows = services.GetRequiredService(); + var workspaceId = new WorkspaceId(context.WorkspaceId); + var ns = resourceNamespace ?? ResourceNamespace.Default; + await flows.CreateAsync(workspaceId, new CreateFlowCommand( + name, + null, + "1.0.0", + true, + new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "unused")), + Graph: Graph(input, output)), ns, default); + await flows.PublishVersionAsync(workspaceId, new FlowId(name, ns), "1.0.0", true, default); + return (input, output); + } + + private static FlowGraphDefinition Graph(JsonElement input, JsonElement output) => new() + { + EntryStep = "input", + InputSchema = input.Clone(), + OutputSchema = output.Clone(), + Steps = + [ + new InputFlowStepDefinition { Name = "input", DisplayName = "Input", Schema = input.Clone() }, + new OutputFlowStepDefinition { Name = "output", DisplayName = "Output", OutputMapping = JsonSerializer.SerializeToElement("${input}") } + ], + Transitions = [new FlowTransitionDefinition("input-output", "input", "completed", "output")] + }; +} diff --git a/tests/Agentstration.Runtime.Tests/ToolExecutionLifecycleTests.cs b/tests/Agentstration.Runtime.Tests/ToolExecutionLifecycleTests.cs index bd265042..14de980d 100644 --- a/tests/Agentstration.Runtime.Tests/ToolExecutionLifecycleTests.cs +++ b/tests/Agentstration.Runtime.Tests/ToolExecutionLifecycleTests.cs @@ -528,11 +528,12 @@ public async Task FlowProjectionUsesTheSamePipelineAndOmitsArgumentsAndResults() [new FlowToolExecutionEventSink(repository, published)], new AdvancingTimeProvider()); - await pipeline.ExecuteAsync(Context() with { OwnerKind = ToolExecutionOwnerKind.FlowRun }, default); + await pipeline.ExecuteAsync(Context() with { OwnerKind = ToolExecutionOwnerKind.FlowRun, FlowStepId = "notify" }, default); var events = await repository.ListRunEventsAsync(Workspace, "run-1", 0, default); Assert.HasCount(3, events); Assert.AreEqual(FlowRunEventType.ToolCallStarted, events[0].Type); + Assert.AreEqual("notify", events[0].StepId); Assert.AreEqual(FlowRunEventType.ToolCallGovernanceEvaluated, events[1].Type); Assert.AreEqual(FlowRunEventType.ToolCallCompleted, events[2].Type); Assert.HasCount(3, published.Events); diff --git a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs index 83ca2d33..7cfc8d16 100644 --- a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs +++ b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs @@ -6,6 +6,7 @@ using Agentstration.Web.FlowDesigner.Backend; using Agentstration.Web.FlowDesigner.Components; using Agentstration.Web.FlowDesigner.State; +using AngleSharp.Dom; using Blazor.Diagrams.Core.Geometry; using Bunit; using Bunit.JSInterop; @@ -76,22 +77,100 @@ public void ReadOnlyModeUsesTheSelectedFrenchCulture() Assert.AreEqual("2 agents disponibles. Les routes sont explicites et restent des références immuables après publication.", strings["AvailableAgents.Many", 2].Value); } + [TestMethod] + public void EditablePaletteUsesOneGenericFlowCardAndShowsTheSelectedContract() + { + using var culture = new CultureScope("en-US"); + using var context = new BunitContext(); + context.Services.AddSingleton(new BackendStub(readOnly: false)); + context.Services.AddSingleton(new ResourceProviderStub()); + context.Services.AddSingleton(); + context.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.JSInterop.Setup("ZBlazorDiagrams.getBoundingClientRect", _ => true) + .SetResult(new Rectangle(0, 0, 1024, 768)); + + var rendered = context.Render(parameters => parameters + .Add(component => component.ResourceId, "parent")); + IElement[] cards = []; + rendered.WaitForAssertion(() => + { + cards = rendered.FindAll(".step-palette button").Where(button => button.TextContent.Trim().EndsWith("Flow", StringComparison.Ordinal)).ToArray(); + Assert.HasCount(1, cards); + }); + cards[0].Click(); + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Called Flow"); + StringAssert.Contains(rendered.Markup, "article"); + StringAssert.Contains(rendered.Markup, "summary"); + }); + } + + [TestMethod] + public void EditablePaletteUsesOneGenericToolCardAndShowsTheSelectedSchema() + { + using var culture = new CultureScope("en-US"); + using var context = new BunitContext(); + context.Services.AddSingleton(new BackendStub(readOnly: false)); + context.Services.AddSingleton(new ResourceProviderStub()); + context.Services.AddSingleton(); + context.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.JSInterop.Setup("ZBlazorDiagrams.getBoundingClientRect", _ => true) + .SetResult(new Rectangle(0, 0, 1024, 768)); + + var rendered = context.Render(parameters => parameters + .Add(component => component.ResourceId, "parent")); + IElement[] cards = []; + rendered.WaitForAssertion(() => + { + cards = rendered.FindAll(".step-palette button").Where(button => button.TextContent.Trim().EndsWith("Tool", StringComparison.Ordinal)).ToArray(); + Assert.HasCount(1, cards); + }); + cards[0].Click(); + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Called Tool"); + StringAssert.Contains(rendered.Markup, "message"); + StringAssert.Contains(rendered.Markup, "deliveryId"); + }); + } + private sealed class ResourceProviderStub : IFlowDesignerResourceProvider { public Task> GetAgentsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetFlowsAsync(CancellationToken cancellationToken) => + Task.FromResult>([new("analysis", "News analysis", new("pack.news"), "2.0.0")]); + public Task> GetFlowVersionsAsync(ResourceNamespace @namespace, string name, CancellationToken cancellationToken) => + Task.FromResult>([new("2.0.0", + JsonSerializer.SerializeToElement(new { type = "object", properties = new { article = new { type = "string" } } }), + JsonSerializer.SerializeToElement(new { type = "object", properties = new { summary = new { type = "string" } } }))]); + public Task> GetToolsAsync(CancellationToken cancellationToken) => + Task.FromResult>([new( + "notification.send", + "Send notification", + ResourceNamespace.Default, + JsonSerializer.SerializeToElement(new { type = "object", properties = new { message = new { type = "string" } } }), + JsonSerializer.SerializeToElement(new { type = "object", properties = new { deliveryId = new { type = "string" } } }), + Enabled: true, + Available: true, + RequiresApproval: false)]); } private sealed class BackendStub : IFlowDesignerBackend { + private readonly bool readOnly; private readonly FlowDraftResponse draft = CreateDraft(); + public BackendStub(bool readOnly = true) => this.readOnly = readOnly; public int SaveCount { get; private set; } public FlowDesignerTarget? LoadedTarget { get; private set; } public Task LoadAsync(FlowDesignerTarget target, CancellationToken cancellationToken) { LoadedTarget = target; var value = draft.Value; - return Task.FromResult(new FlowDesignerLoadResult(new(value.FlowId, value.DisplayName, value.Description, value.Tags, value.Definition), "entryStep: input", PublishedVersion: "2.1.0")); + return Task.FromResult(new FlowDesignerLoadResult(new(value.FlowId, value.DisplayName, value.Description, value.Tags, value.Definition), "entryStep: input", ETag: readOnly ? null : draft.ETag, PublishedVersion: readOnly ? "2.1.0" : null)); } public Task GetSourceAsync(FlowDesignerTarget target, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SaveDraftAsync(FlowDesignerTarget target, UpdateFlowDraftRequest request, string etag, CancellationToken cancellationToken) { SaveCount++; return Task.FromResult(draft); } diff --git a/tests/Agentstration.Web.Tests/ApiClientTests.Flow.cs b/tests/Agentstration.Web.Tests/ApiClientTests.Flow.cs index 530e42eb..361cc2f7 100644 --- a/tests/Agentstration.Web.Tests/ApiClientTests.Flow.cs +++ b/tests/Agentstration.Web.Tests/ApiClientTests.Flow.cs @@ -32,6 +32,38 @@ namespace Agentstration.Web.Tests; public sealed partial class ApiClientTests { + [TestMethod] + public async Task FlowConsoleClientMarksRootInvocationOrigin() + { + string? origin = null; + var now = DateTimeOffset.UtcNow; + var flowId = new FlowId("console-run"); + var definition = new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "assistant")); + var version = new FlowVersion(new WorkspaceId(Guid.NewGuid()), flowId, "1.0.0", null, definition, new Dictionary(), now); + var run = new FlowRun + { + WorkspaceId = version.WorkspaceId, + Id = "flowrun-console", + FlowId = flowId, + FlowVersion = version.Version, + Scope = new(Guid.NewGuid(), version.WorkspaceId, Guid.NewGuid()), + Input = JsonSerializer.SerializeToElement(new { }), + CreatedAt = now, + DefinitionSnapshot = version + }; + using var httpClient = new HttpClient(new StubHandler(request => + { + origin = request.Headers.GetValues("X-Agentstration-Origin").Single(); + return new HttpResponseMessage(HttpStatusCode.Accepted) { Content = JsonContent.Create(run) }; + })) + { BaseAddress = new Uri("http://localhost/") }; + + _ = await new FlowApiClient(httpClient).CreateFlowRunAsync(flowId.Value, + new CreateFlowRunRequest(JsonSerializer.SerializeToElement(new { })), default); + + Assert.AreEqual("Console", origin); + } + [TestMethod] public void FlowConsoleUrlPreservesTheResourceNamespace() { @@ -260,6 +292,47 @@ await Assert.ThrowsExactlyAsync(() => Assert.AreEqual(1, repeatedRequests); } + [TestMethod] + public async Task FlowClientLoadsEveryCausalityPageWithoutDroppingDescendants() + { + var rootRun = CreateFlowRun("root"); + var root = new FlowRunCausalityNode(rootRun.Id, rootRun.FlowId, rootRun.FlowVersion, true, FlowDefinitionState.Published, + FlowRunStatus.WaitingForChild, null, null, 0, rootRun.CreatedAt, null, null, null, [], []); + var child = root with { FlowRunId = "child", ParentFlowRunId = root.FlowRunId, ParentStepName = "analyze", NestingDepth = 1 }; + var origin = new FlowRunCausalityOrigin(root.FlowRunId, FlowInvocationOrigin.Trigger, FlowRunTrigger.Event, "watcher", "news-42", "correlation-42", null); + var requests = new List(); + using var httpClient = new HttpClient(new StubHandler(request => + { + var path = request.RequestUri!.PathAndQuery; + requests.Add(path); + return path switch + { + "/api/flowRuns/child/causality?top=100" => new(HttpStatusCode.OK) + { + Content = JsonContent.Create(new FlowRunCausalityPageResponse(origin, [root], 2, "/api/flowRuns/child/causality?skip=1&top=100")) + }, + "/api/flowRuns/child/causality?skip=1&top=100" => new(HttpStatusCode.OK) + { + Content = JsonContent.Create(new FlowRunCausalityPageResponse(origin, [child], 2, null)) + }, + _ => new(HttpStatusCode.NotFound) + }; + })) + { BaseAddress = new Uri("http://localhost/") }; + + var page = await new FlowApiClient(httpClient).GetFlowRunCausalityAsync("child", default); + + CollectionAssert.AreEqual(new[] { "root", "child" }, page.Value.Select(value => value.FlowRunId).ToArray()); + Assert.AreEqual("analyze", page.Value[1].ParentStepName); + Assert.AreEqual(2, page.TotalCount); + Assert.IsNull(page.NextLink); + CollectionAssert.AreEqual(new[] + { + "/api/flowRuns/child/causality?top=100", + "/api/flowRuns/child/causality?skip=1&top=100" + }, requests); + } + [TestMethod] [DataRow("https://example.test/api/flowRuns?skip=1&top=200")] [DataRow("//example.test/api/flowRuns?skip=1&top=200")] diff --git a/tests/Agentstration.Web.Tests/ApiClientTests.ToolDefinitions.cs b/tests/Agentstration.Web.Tests/ApiClientTests.ToolDefinitions.cs new file mode 100644 index 00000000..3ffc6b66 --- /dev/null +++ b/tests/Agentstration.Web.Tests/ApiClientTests.ToolDefinitions.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Resources; +using Agentstration.Web.Console; + +namespace Agentstration.Web.Tests; + +public sealed partial class ApiClientTests +{ + [TestMethod] + public async Task ToolDefinitionClientKeepsNamespaceAndEtagOnUpdate() + { + HttpRequestMessage? captured = null; + var resource = new ToolDefinitionResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolDefinition, + Metadata = new ResourceMetadata { Name = "notification.send", Namespace = new ResourceNamespace("communications") }, + Definition = new ToolDefinitionProperties + { + DisplayName = "Send notification", + InputSchema = JsonSerializer.SerializeToElement(new { type = "object" }), + Flow = new ToolDefinitionFlowTarget { Name = "delivery" } + } + }; + using var http = new HttpClient(new StubHandler(request => + { + captured = request; + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(resource) }; + response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v2\""); + return response; + })) + { BaseAddress = new Uri("http://localhost/") }; + + var result = await new ToolDefinitionsApiClient(http).UpdateAsync( + resource.Name, + resource.Namespace, + new PutToolDefinitionRequest(resource.Definition), + "\"v1\"", + default); + + Assert.AreEqual("/api/tooldefinitions/notification.send?namespace=communications", captured!.RequestUri!.PathAndQuery); + Assert.AreEqual("\"v1\"", captured.Headers.IfMatch.Single().Tag); + Assert.AreEqual("\"v2\"", result.ETag); + } +} diff --git a/tests/Agentstration.Web.Tests/FlowDetailsDesignerTests.cs b/tests/Agentstration.Web.Tests/FlowDetailsDesignerTests.cs index 8daeb5a4..e3e5f95d 100644 --- a/tests/Agentstration.Web.Tests/FlowDetailsDesignerTests.cs +++ b/tests/Agentstration.Web.Tests/FlowDetailsDesignerTests.cs @@ -88,6 +88,60 @@ public void OrchestrationDefinitionTabEmbedsTheEditableEditorWithoutAnIntermedia Assert.IsTrue(rendered.FindAll("button").Any(button => button.TextContent.Contains(editorStrings["PublishAndActivate"].Value, StringComparison.Ordinal))); } + [TestMethod] + public void FlowRunCausalityTabShowsVersionsAttemptsAndGovernanceDeepLink() + { + using var context = new BunitContext(); + context.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + var flowId = new FlowId("news-analysis"); + var workspaceId = new WorkspaceId(Guid.NewGuid()); + var definition = new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "analyst")); + var run = new FlowRun + { + WorkspaceId = workspaceId, + Id = "flowrun-news", + FlowId = flowId, + FlowVersion = "2.0.0", + ResolvedFromActiveReference = true, + Status = FlowRunStatus.Succeeded, + InvocationOrigin = FlowInvocationOrigin.Trigger, + Trigger = FlowRunTrigger.Event, + CallerId = "news-watcher", + CorrelationId = "news-42", + Scope = new FlowRunScope(Guid.NewGuid(), workspaceId, Guid.NewGuid()), + Input = JsonSerializer.SerializeToElement(new { }), + CreatedAt = FlowClientStub.Now, + DefinitionSnapshot = new FlowVersion(workspaceId, flowId, "2.0.0", null, definition, new Dictionary(), FlowClientStub.Now) + }; + const string logicalCallId = "flow:flowrun-news:step:notify"; + const string invocationId = "flow:flowrun-news:step:notify:attempt:1"; + var node = new FlowRunCausalityNode( + run.Id, flowId, run.FlowVersion, true, FlowDefinitionState.Published, run.Status, null, null, 0, + run.CreatedAt, run.StartedAt, run.CompletedAt, null, [], + [new(logicalCallId, "notify", "notification.send", "agentstration", "notification.send", "agentstration.internal", "agentstration", "work.notification.create", "succeeded", run.CorrelationId, + [new(invocationId, 1, "succeeded", run.CreatedAt, run.CreatedAt.AddMilliseconds(12), 12, null, null, 1)])]); + var causality = new FlowRunCausalityPageResponse( + new(run.Id, FlowInvocationOrigin.Trigger, FlowRunTrigger.Event, "news-watcher", "news-item-42", "news-42", Guid.NewGuid().ToString("D")), + [node], 1, null); + context.Services.AddSingleton(new FlowClientStub(run: run, causality: causality)); + context.Services.AddSingleton(new ConsoleRealtimeSession(new HttpContextAccessor(), new UninitializedRequestContext())); + context.Services.AddSingleton(TimeProvider.System); + var strings = context.Services.GetRequiredService>(); + + var rendered = context.Render(parameters => parameters.Add(component => component.RunId, run.Id)); + rendered.FindAll("nav.section-tabs button").Single(button => button.TextContent.Trim() == strings["Causality"].Value).Click(); + + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "news-analysis · 2.0.0"); + StringAssert.Contains(rendered.Markup, strings["ResolvedFromActive"].Value); + StringAssert.Contains(rendered.Markup, "notification.send"); + var link = rendered.Find(".tool-attempts a"); + StringAssert.Contains(link.GetAttribute("href"), "toolCallId=flow%3Aflowrun-news%3Astep%3Anotify"); + StringAssert.Contains(link.GetAttribute("href"), "invocationId=flow%3Aflowrun-news%3Astep%3Anotify%3Aattempt%3A1"); + }); + } + [TestMethod] public void MissingFlowRunRendersNotFoundStateInsteadOfThrowing() { @@ -108,12 +162,19 @@ public void MissingFlowRunRendersNotFoundStateInsteadOfThrowing() private sealed class FlowClientStub : IFlowApiClient { - private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-08-15T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture); + public static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-08-15T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture); private static readonly FlowGraphDefinition Graph = new() { EntryStep = "input", Steps = [new InputFlowStepDefinition { Name = "input" }], Transitions = [] }; private readonly FlowDefinition definition; - public FlowClientStub(bool orchestration = false) => definition = orchestration - ? new OrchestrationFlowDefinition([new(FlowTargetKind.Agent, "agent-a"), new(FlowTargetKind.Agent, "agent-b")], new SequentialOrchestrationPattern()) - : new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "agent")); + private readonly FlowRun? run; + private readonly FlowRunCausalityPageResponse? causality; + public FlowClientStub(bool orchestration = false, FlowRun? run = null, FlowRunCausalityPageResponse? causality = null) + { + definition = orchestration + ? new OrchestrationFlowDefinition([new(FlowTargetKind.Agent, "agent-a"), new(FlowTargetKind.Agent, "agent-b")], new SequentialOrchestrationPattern()) + : new DirectFlowDefinition(new FlowTargetReference(FlowTargetKind.Agent, "agent")); + this.run = run; + this.causality = causality; + } public ResourceNamespace RequestedNamespace { get; private set; } public Task GetFlowAsync(ResourceNamespace @namespace, string flowId, CancellationToken cancellationToken) @@ -134,11 +195,16 @@ public async Task GetFlowSnapshotAsync(string flowId, Canc public Task CreateFlowVersionAsync(string flowId, CreateFlowVersionRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetFlowVersionsAsync(string flowId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetFlowRunsAsync(string? flowId, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task GetFlowRunAsync(string runId, CancellationToken cancellationToken) => - throw new AgentstrationApiException("The Flow Run was not found.", "missing-flow-run", System.Net.HttpStatusCode.NotFound, "flow_run_not_found"); + public Task GetFlowRunAsync(string runId, CancellationToken cancellationToken) => run is not null && run.Id == runId + ? Task.FromResult(run) + : throw new AgentstrationApiException("The Flow Run was not found.", "missing-flow-run", System.Net.HttpStatusCode.NotFound, "flow_run_not_found"); + public Task GetFlowRunCausalityAsync(string runId, CancellationToken cancellationToken) => causality is not null + ? Task.FromResult(causality) + : throw new NotSupportedException(); public Task> GetFlowRunInputsAsync(string runId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task RespondToFlowRunInputAsync(string runId, string inputId, JsonElement value, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task> GetFlowRunEventsAsync(string runId, long afterSequence, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetFlowRunEventsAsync(string runId, long afterSequence, CancellationToken cancellationToken) => + Task.FromResult>([]); public Task CreateFlowRunAsync(string flowId, CreateFlowRunRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task CancelFlowRunAsync(string runId, CancellationToken cancellationToken) => throw new NotSupportedException(); public async IAsyncEnumerable ObserveFlowRunAsync(string runId, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } diff --git a/tests/Agentstration.Web.Tests/ToolDefinitionConsoleTests.cs b/tests/Agentstration.Web.Tests/ToolDefinitionConsoleTests.cs new file mode 100644 index 00000000..9c008b74 --- /dev/null +++ b/tests/Agentstration.Web.Tests/ToolDefinitionConsoleTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using Agentstration.Management.Abstractions; +using Agentstration.Resources; +using Agentstration.Web.Components.Pages; +using Agentstration.Web.Console; +using Bunit; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Web.Tests; + +[TestClass] +public sealed class ToolDefinitionConsoleTests +{ + [TestMethod] + public void DefinitionsPageListsFlowBackedMcpTools() + { + using var context = new BunitContext(); + context.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + context.Services.AddSingleton(new DefinitionClient()); + + var rendered = context.Render(); + + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Send notification"); + StringAssert.Contains(rendered.Markup, "notification-flow"); + StringAssert.Contains(rendered.Markup, "/tools/definitions/notification.send?namespace=default"); + }); + } + + private sealed class DefinitionClient : IToolDefinitionsClient + { + public Task> GetAsync(CancellationToken cancellationToken) => Task.FromResult>([ + new ToolDefinitionResource + { + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.ToolDefinition, + Metadata = new ResourceMetadata { Name = "notification.send" }, + ScopeRef = ResourceScopeRef.Workspace(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), + Definition = new ToolDefinitionProperties + { + DisplayName = "Send notification", + InputSchema = JsonSerializer.SerializeToElement(new { type = "object" }), + Flow = new ToolDefinitionFlowTarget { Name = "notification-flow" } + } + } + ]); + + public Task> GetAsync(string name, ResourceNamespace @namespace, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> CreateAsync(Agentstration.Management.Contracts.CreateToolDefinitionRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> UpdateAsync(string name, ResourceNamespace @namespace, Agentstration.Management.Contracts.PutToolDefinitionRequest request, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> SetEnabledAsync(string name, ResourceNamespace @namespace, bool enabled, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteAsync(string name, ResourceNamespace @namespace, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + } +}