feat(workflows): add phase 4 backend executor support

Add MVP code-executor and function-executor runtime support, request-port
bridging through Aryx user input, state-scope helpers, lockstep execution
mode handling, and validation/tests for new workflow node kinds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-05 20:22:20 +02:00
co-authored by Copilot
parent 7a32c9c0a3
commit 69ac454f29
11 changed files with 1943 additions and 76 deletions
@@ -1,11 +1,13 @@
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Tests;
@@ -799,6 +801,73 @@ public sealed class CopilotWorkflowRunnerTests
});
}
[Fact]
public void CreateExecutionEnvironment_UsesLockstepWhenRequested()
{
RunTurnCommandDto command = new()
{
Workflow = new WorkflowDefinitionDto
{
Settings = new WorkflowSettingsDto
{
ExecutionMode = "lockstep",
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
},
};
InProcessExecutionEnvironment environment = CopilotWorkflowRunner.CreateExecutionEnvironment(command, checkpointManager: null);
Assert.Same(InProcessExecution.Lockstep, environment);
}
[Fact]
public void CoerceRequestPortResponse_ParsesSupportedResponseTypes()
{
Assert.Equal("hello", CopilotWorkflowRunner.CoerceRequestPortResponse("string", "hello"));
Assert.Equal(true, CopilotWorkflowRunner.CoerceRequestPortResponse("bool", "yes"));
Assert.Equal(12.5d, CopilotWorkflowRunner.CoerceRequestPortResponse("number", "12.5"));
JsonElement json = Assert.IsType<JsonElement>(CopilotWorkflowRunner.CoerceRequestPortResponse("json", "{\"ok\":true}"));
Assert.True(json.GetProperty("ok").GetBoolean());
}
[Fact]
public async Task RunTurnAsync_RequestPortWorkflowUsesUserInputBridge()
{
CopilotWorkflowRunner runner = new(new PatternValidator());
List<UserInputRequestedEventDto> requests = [];
IReadOnlyList<ChatMessageDto> messages = await runner.RunTurnAsync(
CreateRequestPortCommand(),
_ => Task.CompletedTask,
_ => Task.CompletedTask,
_ => Task.CompletedTask,
async request =>
{
requests.Add(request);
await runner.ResolveUserInputAsync(
new ResolveUserInputCommandDto
{
UserInputId = request.UserInputId,
Answer = "approved",
WasFreeform = true,
},
CancellationToken.None);
},
_ => Task.CompletedTask,
_ => Task.CompletedTask,
CancellationToken.None);
UserInputRequestedEventDto requestEvent = Assert.Single(requests);
Assert.Equal("Needs approval?", requestEvent.Question);
Assert.Null(requestEvent.AgentId);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("Workflow", message.AuthorName);
Assert.Equal("approved", message.Content);
}
[Fact]
public async Task HandleWorkflowEventAsync_FallsBackToActiveAgentForUnresolvedStreamingUpdates()
{
@@ -2212,6 +2281,96 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static RunTurnCommandDto CreateRequestPortCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-request-port",
SessionId = "session-request-port",
ProjectPath = "c:\\workspace\\personal\\projects\\aryx.worktrees\\copilot-powerful-vulture",
Pattern = new PatternDefinitionDto
{
Id = "pattern-request-port",
Name = "Request Port Pattern",
Mode = "single",
Availability = "available",
Agents = [],
},
Messages =
[
new ChatMessageDto
{
Id = "message-1",
Role = "user",
AuthorName = "User",
Content = "Please continue.",
CreatedAt = "2026-04-05T00:00:00.000Z",
},
],
Workflow = new WorkflowDefinitionDto
{
Id = "workflow-request-port",
Name = "Request Port Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "approval-port",
Kind = "request-port",
Label = "Approval",
Config = new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = "approval",
RequestType = "Question",
ResponseType = "string",
Prompt = "Needs approval?",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-port",
Source = "start",
Target = "approval-port",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-port-end",
Source = "approval-port",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
ExecutionMode = "lockstep",
},
},
};
}
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
=> new()
{
@@ -169,7 +169,7 @@ public sealed class SidecarProtocolHostTests
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "graph.nodes"
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one agent or sub-workflow node.");
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one executable work node.");
},
completionEvent =>
{
@@ -3,6 +3,7 @@ using Aryx.AgentHost.Services;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using System.Text.Json;
namespace Aryx.AgentHost.Tests;
@@ -52,6 +53,112 @@ public sealed class WorkflowRunnerTests
Assert.Contains("unknown workflow", error.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task BuildWorkflow_RunsCodeExecutorAndSurfacesOutput()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateSingleNodeWorkflow(
"code-executor",
new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = "return-text:done",
}),
CreateEmptyPattern(),
[]);
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
ChatMessage message = Assert.Single(output);
Assert.Equal("done", message.Text);
Assert.Equal("Workflow", message.AuthorName);
}
[Fact]
public async Task BuildWorkflow_FunctionExecutorsUseStateScopes()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateStatefulFunctionWorkflow(),
CreateEmptyPattern(),
[]);
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
ChatMessage message = Assert.Single(output);
Assert.Equal("{\"status\":\"complete\"}", message.Text);
}
[Fact]
public async Task BuildWorkflow_RequestPortsRaiseRequestsAndForwardResponses()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateSingleNodeWorkflow(
"request-port",
new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = "approval",
RequestType = "Question",
ResponseType = "string",
Prompt = "Approve the workflow?",
}),
CreateEmptyPattern(),
[]);
ChatMessage[] input =
[
new(ChatRole.User, "Please continue."),
];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is RequestInfoEvent requestInfo)
{
Assert.Equal("approval", requestInfo.Request.PortInfo.PortId);
WorkflowRequestPortPromptRequest payload = Assert.IsType<WorkflowRequestPortPromptRequest>(
requestInfo.Request.Data.As<object>());
Assert.Equal("Approve the workflow?", payload.Prompt);
Assert.Equal("request-port", payload.NodeId);
await run.SendResponseAsync(requestInfo.Request.CreateResponse("approved"));
continue;
}
if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> output = Assert.IsType<List<ChatMessage>>(outputEvent.Data);
ChatMessage message = Assert.Single(output);
Assert.Equal("approved", message.Text);
return;
}
}
Assert.Fail("Workflow never produced an output after the request port response.");
}
[Fact]
public void BuildWorkflow_RejectsUnknownFunctionRefsAtBuildTime()
{
WorkflowRunner runner = new();
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
CreateSingleNodeWorkflow(
"function-executor",
new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "missing-function",
}),
CreateEmptyPattern(),
[]));
Assert.Contains("unsupported functionRef", error.Message, StringComparison.OrdinalIgnoreCase);
}
private static PatternDefinitionDto CreatePattern(string agentId)
{
return new PatternDefinitionDto
@@ -136,6 +243,18 @@ public sealed class WorkflowRunnerTests
};
}
private static PatternDefinitionDto CreateEmptyPattern()
{
return new PatternDefinitionDto
{
Id = "pattern-empty",
Name = "Workflow Pattern",
Mode = "single",
Availability = "available",
Agents = [],
};
}
private static WorkflowDefinitionDto CreateSubworkflowParent(
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
@@ -200,6 +319,203 @@ public sealed class WorkflowRunnerTests
};
}
private static WorkflowDefinitionDto CreateSingleNodeWorkflow(string nodeKind, WorkflowNodeConfigDto config)
{
return new WorkflowDefinitionDto
{
Id = $"workflow-{nodeKind}",
Name = $"{nodeKind} Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = nodeKind,
Kind = nodeKind,
Label = nodeKind,
Config = config,
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-node",
Source = "start",
Target = nodeKind,
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-node-end",
Source = nodeKind,
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
private static WorkflowDefinitionDto CreateStatefulFunctionWorkflow()
{
return new WorkflowDefinitionDto
{
Id = "workflow-stateful-function",
Name = "Stateful Function Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "state-get",
Kind = "function-executor",
Label = "Get State",
Config = new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "state:get",
Parameters = new Dictionary<string, JsonElement>
{
["scope"] = JsonDocument.Parse("\"workflow\"").RootElement.Clone(),
["key"] = JsonDocument.Parse("\"status\"").RootElement.Clone(),
},
},
},
new WorkflowNodeDto
{
Id = "state-set",
Kind = "function-executor",
Label = "Set State",
Config = new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "state:set",
Parameters = new Dictionary<string, JsonElement>
{
["scope"] = JsonDocument.Parse("\"workflow\"").RootElement.Clone(),
["key"] = JsonDocument.Parse("\"status\"").RootElement.Clone(),
["value"] = JsonDocument.Parse("{\"status\":\"complete\"}").RootElement.Clone(),
},
},
},
new WorkflowNodeDto
{
Id = "state-read-back",
Kind = "code-executor",
Label = "Read Back",
Config = new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = "state:get:workflow:status",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-get",
Source = "start",
Target = "state-get",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-get-set",
Source = "state-get",
Target = "state-set",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-set-read",
Source = "state-set",
Target = "state-read-back",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-read-end",
Source = "state-read-back",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
StateScopes =
[
new WorkflowStateScopeDto
{
Name = "workflow",
InitialValues = new Dictionary<string, JsonElement>
{
["status"] = JsonDocument.Parse("\"pending\"").RootElement.Clone(),
},
},
],
},
};
}
private static async Task<List<ChatMessage>> RunWorkflowToOutputAsync(Workflow workflow)
{
ChatMessage[] input =
[
new(ChatRole.User, "Run the workflow."),
];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is WorkflowOutputEvent outputEvent)
{
return Assert.IsType<List<ChatMessage>>(outputEvent.Data);
}
}
Assert.Fail("Workflow did not produce an output.");
return [];
}
private static ChatClientAgent CreateChatClientAgent(string id, string name)
{
return new ChatClientAgent(
@@ -152,6 +152,74 @@ public sealed class WorkflowValidatorTests
Assert.DoesNotContain(issues, issue => issue.Level == "error");
}
[Fact]
public void Validate_AcceptsPhase4ExecutableNodeKinds()
{
WorkflowDefinitionDto codeWorkflow = CreateSingleNodeWorkflow(
"code-executor",
new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = "return-text:done",
});
WorkflowDefinitionDto functionWorkflow = CreateSingleNodeWorkflow(
"function-executor",
new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "identity",
});
WorkflowDefinitionDto requestPortWorkflow = CreateSingleNodeWorkflow(
"request-port",
new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = "approval",
RequestType = "Question",
ResponseType = "string",
});
Assert.DoesNotContain(_validator.Validate(codeWorkflow), issue => issue.Level == "error");
Assert.DoesNotContain(_validator.Validate(functionWorkflow), issue => issue.Level == "error");
Assert.DoesNotContain(_validator.Validate(requestPortWorkflow), issue => issue.Level == "error");
}
[Fact]
public void Validate_RejectsInvalidPhase4ExecutorConfigs()
{
WorkflowDefinitionDto codeWorkflow = CreateSingleNodeWorkflow(
"code-executor",
new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = " ",
});
WorkflowDefinitionDto functionWorkflow = CreateSingleNodeWorkflow(
"function-executor",
new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = string.Empty,
});
WorkflowDefinitionDto requestPortWorkflow = CreateSingleNodeWorkflow(
"request-port",
new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = " ",
RequestType = "",
ResponseType = null,
});
Assert.Contains(_validator.Validate(codeWorkflow), issue => issue.Field == "graph.nodes.config.implementation");
Assert.Contains(_validator.Validate(functionWorkflow), issue => issue.Field == "graph.nodes.config.functionRef");
IReadOnlyList<WorkflowValidationIssueDto> requestPortIssues = _validator.Validate(requestPortWorkflow);
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.portId");
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.requestType");
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.responseType");
}
private static WorkflowDefinitionDto CreateWorkflow(string id = "workflow-1")
{
return new WorkflowDefinitionDto
@@ -278,4 +346,61 @@ public sealed class WorkflowValidatorTests
},
};
}
private static WorkflowDefinitionDto CreateSingleNodeWorkflow(string nodeKind, WorkflowNodeConfigDto config)
{
return new WorkflowDefinitionDto
{
Id = $"workflow-{nodeKind}",
Name = $"{nodeKind} Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = nodeKind,
Kind = nodeKind,
Label = nodeKind,
Config = config,
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-node",
Source = "start",
Target = nodeKind,
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-node-end",
Source = nodeKind,
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
}