Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Entry / Trigger / REST / Console -> RootFlowSubmissionService
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 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.
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. A Flow call can map fields explicitly or enable the Designer's passthrough checkbox to pass the output carried by the transition actually taken into the step (`${transition.output}`) unchanged. This remains correct when the initial Input step feeds the call and when several branches converge on the same Flow call. 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.

Expand Down
19 changes: 18 additions & 1 deletion src/Agentstration.Flow.Application/FlowRunService.Graph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st
var resumedChildStep = stored.Value.Steps.SingleOrDefault(step =>
step.Status == FlowStepRunStatus.Running && step.ChildFlowRunId is not null);
var currentName = resumedChildStep?.StepName ?? graph.EntryStep;
var incomingTransition = resumedChildStep is null
? null
: SelectedIncomingTransition(graph, stored.Value, currentName);
var executed = stored.Value.Steps
.Where(step => step.Status is FlowStepRunStatus.Succeeded or FlowStepRunStatus.Failed)
.Select(step => step.StepName)
Expand All @@ -32,7 +35,10 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st
if (resumedChildStep?.StepName != step.Name)
stored = await StartStepAsync(stored, step.Name, runToken);
resumedChildStep = null;
var context = new FlowExecutionContext(stored.Value.Input, outputs);
var transitionOutput = incomingTransition is null
? null
: outputs.GetValueOrDefault(incomingTransition.FromStep)?.Clone();
var context = new FlowExecutionContext(stored.Value.Input, outputs, transitionOutput);
JsonElement? output;
string eventName;
FlowAgentExecutionResult? agentResult = null;
Expand Down Expand Up @@ -149,6 +155,7 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st
if (transition is null && stepError is not null)
throw new FlowValidationException(stepError.Code, stepError.Details ?? stepError.Message);
if (transition is null) throw new FlowValidationException("flow_transition_missing", $"No '{eventName}' transition leaves step '{step.Name}'.");
incomingTransition = transition;
currentName = transition.ToStep;
}
if (finalOutput is null) throw new FlowValidationException("flow_output_missing", "The Flow completed without reaching an Output step.");
Expand All @@ -167,6 +174,16 @@ await SaveAsync(stored, stored.Value with
await EmitAsync(stored.Value.WorkspaceId, stored.Value.Id, FlowRunEventType.FlowRunCompleted, null, null, stoppingToken);
}

private static FlowTransitionDefinition? SelectedIncomingTransition(
FlowGraphDefinition graph,
FlowRun run,
string stepName) =>
graph.Transitions.FirstOrDefault(transition =>
transition.ToStep == stepName
&& run.Steps.Any(step =>
step.StepName == transition.FromStep
&& step.SelectedTransition == transition.Id));

private async Task<StoredFlowRun> FinishGraphStepAsync(StoredFlowRun stored, string name, JsonElement? output, string? transition, CancellationToken token)
{
var now = timeProvider.GetUtcNow();
Expand Down
24 changes: 22 additions & 2 deletions src/Agentstration.Flow.Application/FlowValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,19 @@ private async Task ValidateFlowCallAsync(
private static void ValidateMappingAgainstSchema(FlowCallStepDefinition step, JsonElement? schema, List<FlowValidationIssue> issues)
{
if (schema is not { ValueKind: JsonValueKind.Object } value) return;
if (step.InputMapping is { ValueKind: JsonValueKind.String } mapping
&& FlowExpressionParser.TryParse(mapping.GetString()!, out var expression, out _)
&& IsCompleteObjectReference(expression.Body)) return;
ValidateMappingAgainstSchema(step.Name, "inputMapping", step.InputMapping, value, "flow_input_mapping", issues);
}

private static bool IsCompleteObjectReference(string expression) =>
string.Equals(expression, "input", StringComparison.Ordinal)
|| string.Equals(expression, "transition.output", StringComparison.Ordinal)
|| (expression.StartsWith("steps.", StringComparison.Ordinal)
&& expression.EndsWith(".output", StringComparison.Ordinal)
&& expression.Count(character => character == '.') == 2);

private static void ValidateMappingAgainstSchema(
string stepName,
string propertyPath,
Expand Down Expand Up @@ -314,7 +324,10 @@ public sealed record ParsedExpression(string Source, string Body);
public sealed record ExpressionParseResult(ParsedExpression? Expression, string? Error) { public bool IsValid => Expression is not null; }
public sealed record ExpressionValidationResult(bool IsValid, string? Error = null);
public sealed record FlowExpressionContext(IReadOnlyCollection<string> StepNames);
public sealed record FlowExecutionContext(JsonElement Input, IReadOnlyDictionary<string, JsonElement?> StepOutputs);
public sealed record FlowExecutionContext(
JsonElement Input,
IReadOnlyDictionary<string, JsonElement?> StepOutputs,
JsonElement? TransitionOutput = null);

public interface IExpressionParser { ExpressionParseResult Parse(string expression); }
public interface IExpressionValidator { ExpressionValidationResult Validate(ParsedExpression expression, FlowExpressionContext context); }
Expand Down Expand Up @@ -353,7 +366,13 @@ internal static bool TryParse(string expression, out ParsedExpression parsed, ou
var body = expression[2..^1].Trim();
if (body.Length == 0 || body.Contains(';') || body.Contains('(') || body.Contains(')')) { error = "The expression contains unsupported syntax."; return false; }
var first = ComparisonParts(body)[0];
if (!first.StartsWith("input", StringComparison.Ordinal) && !first.StartsWith("steps.", StringComparison.Ordinal)) { error = "Expressions may reference only input or step outputs."; return false; }
if (!first.StartsWith("input", StringComparison.Ordinal)
&& !first.StartsWith("steps.", StringComparison.Ordinal)
&& !first.StartsWith("transition.output", StringComparison.Ordinal))
{
error = "Expressions may reference only input, the incoming transition output, or step outputs.";
return false;
}
parsed = new ParsedExpression(expression, body); return true;
}

Expand All @@ -372,6 +391,7 @@ private static string[] ComparisonParts(string body)
var segments = path.Split('.'); JsonElement? current;
var offset = 1;
if (segments[0] == "input") current = context.Input;
else if (segments[0] == "transition") { current = context.TransitionOutput; offset = 2; }
else { if (segments.Length < 3 || !context.StepOutputs.TryGetValue(segments[1], out current)) return null; offset = segments[2] == "output" ? 3 : 2; }
for (var index = offset; index < segments.Length; index++)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ else

<div class="flow-canvas-wrap">
<FlowCanvas @ref="canvas" Document="Editor.State.Diagram" Revision="Editor.State.LocalRevision" FitRevision="layoutRevision"
IsReadOnly="ReadOnly" SelectedStepChanged="Editor.SelectStep"
IsReadOnly="ReadOnly" SelectedStepChanged="SelectStepAsync"
StepMoved="MoveStepAsync" TransitionCreated="CreateTransitionAsync"
TransitionRemoved="RemoveTransitionAsync" />
</div>
Expand Down Expand Up @@ -113,15 +113,21 @@ else
@foreach (var version in flowVersions) { <option value="@version.Version">@version.Version</option> }
</select></label>
}
@if (SelectedFlowVersion?.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema
&& inputSchema.TryGetProperty("properties", out var inputProperties)
&& inputProperties.ValueKind == JsonValueKind.Object)
@if (SelectedFlowVersion is not null)
{
<fieldset class="flow-mapping-editor"><legend>@T("InputMapping")</legend>
@foreach (var property in inputProperties.EnumerateObject())
<label><input data-testid="flow-pass-transition-output" type="checkbox" disabled="@ReadOnly" checked="@PassesIncomingTransitionOutput(flowCall.InputMapping)" @onchange="UpdateFlowTransitionPassthroughAsync" />@T("PassIncomingTransitionOutput")</label>
<small>@T("PassIncomingTransitionOutputHelp")</small>
@if (!PassesIncomingTransitionOutput(flowCall.InputMapping)
&& SelectedFlowVersion.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema
&& inputSchema.TryGetProperty("properties", out var inputProperties)
&& inputProperties.ValueKind == JsonValueKind.Object)
{
var propertyName = property.Name;
<label>@propertyName<input disabled="@ReadOnly" value="@MappingValue(flowCall.InputMapping, propertyName)" @onchange="e => UpdateFlowMappingAsync(propertyName, e)" /></label>
@foreach (var property in inputProperties.EnumerateObject())
{
var propertyName = property.Name;
<label>@propertyName<input disabled="@ReadOnly" value="@MappingValue(flowCall.InputMapping, propertyName)" @onchange="e => UpdateFlowMappingAsync(propertyName, e)" /></label>
}
}
</fieldset>
}
Expand Down Expand Up @@ -315,6 +321,11 @@ else
private Task MoveStepAsync(MoveStepCommand command) => DispatchAsync(command);
private Task CreateTransitionAsync(FlowTransitionDefinition transition) => DispatchAsync(new AddTransitionCommand(transition));
private Task RemoveTransitionAsync(string id) => DispatchAsync(new RemoveTransitionCommand(id));
private async Task SelectStepAsync(string? name)
{
Editor.SelectStep(name);
if (SelectedStep is FlowCallStepDefinition call) await LoadFlowVersionsAsync(call.Flow);
}
private async Task LayoutAsync(bool vertical)
{
await DispatchAsync(new ApplyAutoLayoutCommand(vertical));
Expand Down Expand Up @@ -395,6 +406,15 @@ else
mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e));
return UpdateSelectedAsync(call with { InputMapping = JsonSerializer.SerializeToElement(mapping) });
}
private Task UpdateFlowTransitionPassthroughAsync(ChangeEventArgs e)
{
if (SelectedStep is not FlowCallStepDefinition call) return Task.CompletedTask;
var enabled = e.Value is bool value && value;
var mapping = enabled
? JsonSerializer.SerializeToElement("${transition.output}")
: JsonSerializer.SerializeToElement(new { });
return UpdateSelectedAsync(call with { InputMapping = mapping });
}
private Task UpdateToolTargetAsync(ChangeEventArgs e)
{
if (SelectedStep is not ToolFlowStepDefinition tool || !TryParseToolKey(Text(e), out var target)) return Task.CompletedTask;
Expand Down Expand Up @@ -456,6 +476,9 @@ else
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 bool PassesIncomingTransitionOutput(JsonElement? mapping) =>
mapping is { ValueKind: JsonValueKind.String } value
&& string.Equals(value.GetString(), "${transition.output}", StringComparison.Ordinal);
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"; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<data name="Agent"><value>Agent</value></data><data name="RouterOutput"><value>Sortie du routeur</value></data>
<data name="CalledFlow"><value>Flow appelé</value></data><data name="VersionStrategy"><value>Stratégie de version</value></data>
<data name="VersionStrategy.Active"><value>Version active</value></data><data name="VersionStrategy.Exact"><value>Version exacte</value></data>
<data name="ExactVersion"><value>Version exacte</value></data><data name="InputMapping"><value>Mappage d’entrée</value></data><data name="AvailableOutput"><value>Propriétés de sortie disponibles</value></data>
<data name="ExactVersion"><value>Version exacte</value></data><data name="InputMapping"><value>Mappage d’entrée</value></data><data name="PassIncomingTransitionOutput"><value>Transmettre la sortie de la transition entrante</value></data><data name="PassIncomingTransitionOutputHelp"><value>Le Flow enfant reçoit la sortie complète portée par la transition empruntée vers cette étape.</value></data><data name="AvailableOutput"><value>Propriétés de sortie disponibles</value></data>
<data name="CalledTool"><value>Tool appelé</value></data><data name="ArgumentsMapping"><value>Mappage des arguments</value></data>
<data name="ToolDisabled"><value>Ce Tool est désactivé.</value></data><data name="ToolUnavailable"><value>Ce Tool est indisponible auprès de son fournisseur.</value></data><data name="ToolApprovalRequired"><value>Ce Tool nécessite une approbation avant son appel.</value></data>
<data name="LoadAgents"><value>Charger les agents</value></data><data name="AddAll"><value>Tout ajouter</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<data name="Agent"><value>Agent</value></data><data name="RouterOutput"><value>Router output</value></data>
<data name="CalledFlow"><value>Called Flow</value></data><data name="VersionStrategy"><value>Version strategy</value></data>
<data name="VersionStrategy.Active"><value>Active version</value></data><data name="VersionStrategy.Exact"><value>Exact version</value></data>
<data name="ExactVersion"><value>Exact version</value></data><data name="InputMapping"><value>Input mapping</value></data><data name="AvailableOutput"><value>Available output properties</value></data>
<data name="ExactVersion"><value>Exact version</value></data><data name="InputMapping"><value>Input mapping</value></data><data name="PassIncomingTransitionOutput"><value>Pass incoming transition output</value></data><data name="PassIncomingTransitionOutputHelp"><value>The child Flow receives the complete output carried by the transition taken into this step.</value></data><data name="AvailableOutput"><value>Available output properties</value></data>
<data name="CalledTool"><value>Called Tool</value></data><data name="ArgumentsMapping"><value>Arguments mapping</value></data>
<data name="ToolDisabled"><value>This Tool is disabled.</value></data><data name="ToolUnavailable"><value>This Tool is unavailable from its provider.</value></data><data name="ToolApprovalRequired"><value>This Tool requires approval before invocation.</value></data>
<data name="LoadAgents"><value>Load agents</value></data><data name="AddAll"><value>Add all</value></data>
Expand Down
27 changes: 27 additions & 0 deletions tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,33 @@ public async Task FlowCallValidationUsesWorkspaceVersionSchemasAndRejectsIncompa
Assert.AreEqual(ResourceNamespace.Default, resolver.OwnerNamespace);
}

[TestMethod]
[DataRow("${input}")]
[DataRow("${transition.output}")]
public async Task FlowCallValidationAcceptsCompleteInputPassthrough(string inputMapping)
{
var inputSchema = JsonSerializer.SerializeToElement(new
{
type = "object",
properties = new { article = new { type = "string" } },
required = new[] { "article" }
});
var resolver = new FlowCallResolverStub(new(new("analysis"), "3.0.0", inputSchema, JsonSerializer.SerializeToElement(new { type = "object" })));
var graph = Graph(new FlowCallStepDefinition
{
Name = "analyze",
Flow = new("analysis"),
InputMapping = JsonSerializer.SerializeToElement(inputMapping)
});

var result = await new FlowGraphValidator(resolver).ValidateAsync(
graph,
new FlowValidationContext(true, TestScope.WorkspaceId, new("parent")),
default);

Assert.IsTrue(result.IsValid, string.Join(Environment.NewLine, result.Issues.Select(issue => issue.Message)));
}

[TestMethod]
public async Task RepositoryResolverFindsNamespacedPublishedVersionsAndIndirectCycles()
{
Expand Down
Loading
Loading