mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 21:03:58 +02:00
feat: add graph-backed orchestration topology
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -138,6 +138,12 @@ Their runtime semantics follow the Agent Framework orchestration model: sequenti
|
|||||||
|
|
||||||
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
|
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
|
||||||
|
|
||||||
|
Patterns now persist an explicit graph-backed topology alongside the flat agent list. Agent nodes carry stable agent ids, ordering, and layout metadata, while system nodes such as user input/output, distributor, collector, and orchestrator make mode-specific flow visible in the saved contract.
|
||||||
|
|
||||||
|
That graph is now the execution contract for the sidecar: sequential order comes from the saved path, handoff routes come from directed graph edges, and concurrent/group-chat participant ordering can be derived from graph node metadata instead of hard-coded runtime assumptions.
|
||||||
|
|
||||||
|
Until the dedicated canvas editor lands, the current form-based pattern editor keeps the graph synchronized by rebuilding the saved topology from the selected mode and agent list on change/save. This is an intentional temporary adapter so backend/runtime work can land before the renderer graph UX is replaced.
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
A session is the working unit of the product. It binds together:
|
A session is the working unit of the product. It binds together:
|
||||||
|
|||||||
@@ -12,6 +12,34 @@ public sealed class PatternAgentDefinitionDto
|
|||||||
public string? ReasoningEffort { get; init; }
|
public string? ReasoningEffort { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class PatternGraphPositionDto
|
||||||
|
{
|
||||||
|
public double X { get; init; }
|
||||||
|
public double Y { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatternGraphNodeDto
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string Kind { get; init; } = string.Empty;
|
||||||
|
public PatternGraphPositionDto Position { get; init; } = new();
|
||||||
|
public string? AgentId { get; init; }
|
||||||
|
public int? Order { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatternGraphEdgeDto
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
public string Target { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatternGraphDto
|
||||||
|
{
|
||||||
|
public IReadOnlyList<PatternGraphNodeDto> Nodes { get; init; } = [];
|
||||||
|
public IReadOnlyList<PatternGraphEdgeDto> Edges { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class PatternDefinitionDto
|
public sealed class PatternDefinitionDto
|
||||||
{
|
{
|
||||||
public string Id { get; init; } = string.Empty;
|
public string Id { get; init; } = string.Empty;
|
||||||
@@ -23,6 +51,7 @@ public sealed class PatternDefinitionDto
|
|||||||
public int MaxIterations { get; init; }
|
public int MaxIterations { get; init; }
|
||||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
||||||
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
||||||
|
public PatternGraphDto? Graph { get; init; }
|
||||||
public string CreatedAt { get; init; } = string.Empty;
|
public string CreatedAt { get; init; } = string.Empty;
|
||||||
public string UpdatedAt { get; init; } = string.Empty;
|
public string UpdatedAt { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
return pattern.Mode switch
|
return pattern.Mode switch
|
||||||
{
|
{
|
||||||
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents),
|
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||||
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents),
|
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||||
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, Agents),
|
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||||
"handoff" => BuildHandoffWorkflow(pattern),
|
"handoff" => BuildHandoffWorkflow(pattern),
|
||||||
"group-chat" => BuildGroupChatWorkflow(pattern),
|
"group-chat" => BuildGroupChatWorkflow(pattern),
|
||||||
"magentic" => throw new NotSupportedException(
|
"magentic" => throw new NotSupportedException(
|
||||||
@@ -116,30 +116,30 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
|
|
||||||
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
|
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
|
||||||
{
|
{
|
||||||
AIAgent firstAgent = Agents[0];
|
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||||
PatternAgentDefinitionDto triageDefinition = pattern.Agents[0];
|
Dictionary<string, PatternAgentDefinitionDto> definitionMap = pattern.Agents.ToDictionary(
|
||||||
IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists =
|
definition => definition.Id,
|
||||||
Agents.Skip(1)
|
definition => definition,
|
||||||
.Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition))
|
StringComparer.Ordinal);
|
||||||
.ToList();
|
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
||||||
|
AIAgent entryAgent = agentMap.GetValueOrDefault(topology.EntryAgentId) ?? Agents[0];
|
||||||
|
|
||||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent)
|
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||||
|
|
||||||
foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists)
|
foreach (PatternHandoffRoute route in topology.Routes)
|
||||||
{
|
{
|
||||||
builder = builder.WithHandoff(
|
if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent)
|
||||||
firstAgent,
|
|| !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent)
|
||||||
specialist,
|
|| !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition))
|
||||||
HandoffWorkflowGuidance.CreateForwardReason(definition));
|
{
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ((AIAgent specialist, _) in specialists)
|
|
||||||
{
|
|
||||||
builder = builder.WithHandoff(
|
builder = builder.WithHandoff(
|
||||||
specialist,
|
sourceAgent,
|
||||||
firstAgent,
|
targetAgent,
|
||||||
HandoffWorkflowGuidance.CreateReturnReason(triageDefinition));
|
HandoffWorkflowGuidance.CreateForwardReason(targetDefinition));
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
@@ -155,7 +155,30 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
MaximumIterationCount = maximumIterations,
|
MaximumIterationCount = maximumIterations,
|
||||||
})
|
})
|
||||||
.AddParticipants(Agents.ToArray())
|
.AddParticipants(ResolveOrderedAgents(pattern).ToArray())
|
||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
|
||||||
|
{
|
||||||
|
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||||
|
List<AIAgent> orderedAgents = PatternGraphResolver.ResolveOrderedAgentIds(pattern)
|
||||||
|
.Select(agentId => agentMap.TryGetValue(agentId, out AIAgent? agent) ? agent : null)
|
||||||
|
.Where(agent => agent is not null)
|
||||||
|
.Cast<AIAgent>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return orderedAgents.Count == Agents.Count ? orderedAgents : Agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, AIAgent> BuildAgentMap(PatternDefinitionDto pattern)
|
||||||
|
{
|
||||||
|
Dictionary<string, AIAgent> agentMap = new(StringComparer.Ordinal);
|
||||||
|
foreach ((PatternAgentDefinitionDto definition, AIAgent agent) in pattern.Agents.Zip(Agents))
|
||||||
|
{
|
||||||
|
agentMap[definition.Id] = agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentMap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
using Eryx.AgentHost.Contracts;
|
||||||
|
|
||||||
|
namespace Eryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal sealed record PatternHandoffRoute(string SourceAgentId, string TargetAgentId);
|
||||||
|
|
||||||
|
internal sealed record PatternHandoffTopology(string EntryAgentId, IReadOnlyList<PatternHandoffRoute> Routes);
|
||||||
|
|
||||||
|
internal static class PatternGraphResolver
|
||||||
|
{
|
||||||
|
private const string UserInputKind = "user-input";
|
||||||
|
private const string UserOutputKind = "user-output";
|
||||||
|
private const string AgentKind = "agent";
|
||||||
|
private const string DistributorKind = "distributor";
|
||||||
|
private const string CollectorKind = "collector";
|
||||||
|
private const string OrchestratorKind = "orchestrator";
|
||||||
|
|
||||||
|
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||||
|
|
||||||
|
public static PatternGraphDto Resolve(PatternDefinitionDto pattern)
|
||||||
|
=> pattern.Graph ?? CreateDefault(pattern);
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> ResolveOrderedAgentIds(PatternDefinitionDto pattern)
|
||||||
|
{
|
||||||
|
PatternGraphDto graph = Resolve(pattern);
|
||||||
|
|
||||||
|
return pattern.Mode switch
|
||||||
|
{
|
||||||
|
"single" or "sequential" or "magentic" => ResolveLinearAgentIds(pattern, graph),
|
||||||
|
"concurrent" or "group-chat" or "handoff" => ResolveAgentOrder(pattern, graph),
|
||||||
|
_ => pattern.Agents.Select(agent => agent.Id).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PatternHandoffTopology ResolveHandoff(PatternDefinitionDto pattern)
|
||||||
|
{
|
||||||
|
return TryResolveHandoff(pattern, Resolve(pattern))
|
||||||
|
?? TryResolveHandoff(pattern, CreateDefault(pattern))
|
||||||
|
?? new PatternHandoffTopology(
|
||||||
|
pattern.Agents.FirstOrDefault()?.Id ?? string.Empty,
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PatternGraphDto CreateDefault(PatternDefinitionDto pattern)
|
||||||
|
{
|
||||||
|
return pattern.Mode switch
|
||||||
|
{
|
||||||
|
"single" or "sequential" or "magentic" => CreateLinearGraph(pattern.Agents),
|
||||||
|
"concurrent" => CreateConcurrentGraph(pattern.Agents),
|
||||||
|
"handoff" => CreateHandoffGraph(pattern.Agents),
|
||||||
|
"group-chat" => CreateGroupChatGraph(pattern.Agents),
|
||||||
|
_ => CreateLinearGraph(pattern.Agents)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ResolveLinearAgentIds(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||||
|
{
|
||||||
|
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
||||||
|
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, UserOutputKind);
|
||||||
|
if (inputNode is null || outputNode is null)
|
||||||
|
{
|
||||||
|
return pattern.Agents.Select(agent => agent.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||||
|
List<string> orderedAgentIds = [];
|
||||||
|
HashSet<string> visitedNodeIds = [];
|
||||||
|
string currentNodeId = inputNode.Id;
|
||||||
|
|
||||||
|
while (visitedNodeIds.Add(currentNodeId))
|
||||||
|
{
|
||||||
|
if (!outgoing.TryGetValue(currentNodeId, out List<PatternGraphEdgeDto>? edges) || edges.Count != 1)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string nextNodeId = edges[0].Target;
|
||||||
|
if (!nodesById.TryGetValue(nextNodeId, out PatternGraphNodeDto? nextNode))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Comparer.Equals(nextNode.Id, outputNode.Id))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Comparer.Equals(nextNode.Kind, AgentKind) && !string.IsNullOrWhiteSpace(nextNode.AgentId))
|
||||||
|
{
|
||||||
|
orderedAgentIds.Add(nextNode.AgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNodeId = nextNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedAgentIds.Count == pattern.Agents.Count
|
||||||
|
? orderedAgentIds
|
||||||
|
: pattern.Agents.Select(agent => agent.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ResolveAgentOrder(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||||
|
{
|
||||||
|
Dictionary<string, int> fallbackOrder = pattern.Agents
|
||||||
|
.Select((agent, index) => new { agent.Id, Index = index })
|
||||||
|
.ToDictionary(item => item.Id, item => item.Index);
|
||||||
|
|
||||||
|
List<string> orderedAgentIds = graph.Nodes
|
||||||
|
.Where(node => Comparer.Equals(node.Kind, AgentKind) && !string.IsNullOrWhiteSpace(node.AgentId))
|
||||||
|
.OrderBy(node => node.Order ?? int.MaxValue)
|
||||||
|
.ThenBy(node => fallbackOrder.GetValueOrDefault(node.AgentId!, int.MaxValue))
|
||||||
|
.Select(node => node.AgentId!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return orderedAgentIds.Count == pattern.Agents.Count
|
||||||
|
? orderedAgentIds
|
||||||
|
: pattern.Agents.Select(agent => agent.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternHandoffTopology? TryResolveHandoff(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||||
|
{
|
||||||
|
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
||||||
|
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
||||||
|
string? entryAgentId = null;
|
||||||
|
|
||||||
|
if (inputNode is not null)
|
||||||
|
{
|
||||||
|
entryAgentId = graph.Edges
|
||||||
|
.Where(edge => Comparer.Equals(edge.Source, inputNode.Id))
|
||||||
|
.Select(edge => nodesById.TryGetValue(edge.Target, out PatternGraphNodeDto? targetNode)
|
||||||
|
? targetNode.AgentId
|
||||||
|
: null)
|
||||||
|
.FirstOrDefault(agentId => !string.IsNullOrWhiteSpace(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PatternHandoffRoute> routes = graph.Edges
|
||||||
|
.Select(edge => (SourceNode: nodesById.GetValueOrDefault(edge.Source), TargetNode: nodesById.GetValueOrDefault(edge.Target)))
|
||||||
|
.Where(item =>
|
||||||
|
item.SourceNode is not null
|
||||||
|
&& item.TargetNode is not null
|
||||||
|
&& Comparer.Equals(item.SourceNode.Kind, AgentKind)
|
||||||
|
&& Comparer.Equals(item.TargetNode.Kind, AgentKind)
|
||||||
|
&& !string.IsNullOrWhiteSpace(item.SourceNode.AgentId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(item.TargetNode.AgentId))
|
||||||
|
.Select(item => new PatternHandoffRoute(item.SourceNode!.AgentId!, item.TargetNode!.AgentId!))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(entryAgentId) || routes.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PatternHandoffTopology(entryAgentId!, routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
||||||
|
{
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> lookup = new(StringComparer.Ordinal);
|
||||||
|
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||||
|
{
|
||||||
|
lookup[node.Id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||||
|
{
|
||||||
|
if (!lookup.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
||||||
|
{
|
||||||
|
edges = [];
|
||||||
|
lookup[edge.Source] = edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
edges.Add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
||||||
|
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
||||||
|
|
||||||
|
private static PatternGraphDto CreateLinearGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||||
|
{
|
||||||
|
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||||
|
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 220 * Math.Max(agents.Count + 1, 2), 0);
|
||||||
|
List<PatternGraphNodeDto> agentNodes = agents
|
||||||
|
.Select((agent, index) => CreateAgentNode(agent, index, 220 * (index + 1), 0))
|
||||||
|
.ToList();
|
||||||
|
List<PatternGraphEdgeDto> edges = [];
|
||||||
|
List<string> path = [inputNode.Id, .. agentNodes.Select(node => node.Id), outputNode.Id];
|
||||||
|
for (int index = 0; index < path.Count - 1; index += 1)
|
||||||
|
{
|
||||||
|
edges.Add(CreateEdge(path[index], path[index + 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes = [inputNode, .. agentNodes, outputNode],
|
||||||
|
Edges = edges
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternGraphDto CreateConcurrentGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||||
|
{
|
||||||
|
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||||
|
PatternGraphNodeDto distributorNode = CreateSystemNode("system-distributor", DistributorKind, 190, 0);
|
||||||
|
PatternGraphNodeDto collectorNode = CreateSystemNode("system-collector", CollectorKind, 650, 0);
|
||||||
|
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
||||||
|
List<PatternGraphNodeDto> agentNodes = agents
|
||||||
|
.Select((agent, index) => CreateAgentNode(agent, index, 430, SpreadY(index, Math.Max(agents.Count, 1), 170)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes = [inputNode, distributorNode, .. agentNodes, collectorNode, outputNode],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
CreateEdge(inputNode.Id, distributorNode.Id),
|
||||||
|
.. agentNodes.Select(node => CreateEdge(distributorNode.Id, node.Id)),
|
||||||
|
.. agentNodes.Select(node => CreateEdge(node.Id, collectorNode.Id)),
|
||||||
|
CreateEdge(collectorNode.Id, outputNode.Id)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternGraphDto CreateHandoffGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||||
|
{
|
||||||
|
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||||
|
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
||||||
|
PatternAgentDefinitionDto? entryAgent = agents.FirstOrDefault();
|
||||||
|
PatternGraphNodeDto? entryNode = entryAgent is null
|
||||||
|
? null
|
||||||
|
: CreateAgentNode(entryAgent, 0, 220, 0);
|
||||||
|
List<PatternGraphNodeDto> specialistNodes = agents
|
||||||
|
.Skip(1)
|
||||||
|
.Select((agent, index) => CreateAgentNode(agent, index + 1, 540, SpreadY(index, Math.Max(agents.Count - 1, 1), 220)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<PatternGraphEdgeDto> edges = [];
|
||||||
|
if (entryNode is not null)
|
||||||
|
{
|
||||||
|
edges.Add(CreateEdge(inputNode.Id, entryNode.Id));
|
||||||
|
edges.Add(CreateEdge(entryNode.Id, outputNode.Id));
|
||||||
|
|
||||||
|
foreach (PatternGraphNodeDto specialistNode in specialistNodes)
|
||||||
|
{
|
||||||
|
edges.Add(CreateEdge(entryNode.Id, specialistNode.Id));
|
||||||
|
edges.Add(CreateEdge(specialistNode.Id, entryNode.Id));
|
||||||
|
edges.Add(CreateEdge(specialistNode.Id, outputNode.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PatternGraphNodeDto> nodes = [inputNode];
|
||||||
|
if (entryNode is not null)
|
||||||
|
{
|
||||||
|
nodes.Add(entryNode);
|
||||||
|
}
|
||||||
|
nodes.AddRange(specialistNodes);
|
||||||
|
nodes.Add(outputNode);
|
||||||
|
|
||||||
|
return new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes = nodes,
|
||||||
|
Edges = edges
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternGraphDto CreateGroupChatGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||||
|
{
|
||||||
|
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||||
|
PatternGraphNodeDto orchestratorNode = CreateSystemNode("system-orchestrator", OrchestratorKind, 250, 0);
|
||||||
|
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 900, 0);
|
||||||
|
const double centerX = 560;
|
||||||
|
const double centerY = 0;
|
||||||
|
const double radiusX = 190;
|
||||||
|
const double radiusY = 170;
|
||||||
|
|
||||||
|
List<PatternGraphNodeDto> agentNodes = agents
|
||||||
|
.Select((agent, index) =>
|
||||||
|
{
|
||||||
|
double angle = agents.Count <= 1
|
||||||
|
? 0
|
||||||
|
: (Math.PI * 2 * index) / agents.Count - (Math.PI / 2);
|
||||||
|
return CreateAgentNode(
|
||||||
|
agent,
|
||||||
|
index,
|
||||||
|
Math.Round(centerX + Math.Cos(angle) * radiusX),
|
||||||
|
Math.Round(centerY + Math.Sin(angle) * radiusY));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes = [inputNode, orchestratorNode, .. agentNodes, outputNode],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
CreateEdge(inputNode.Id, orchestratorNode.Id),
|
||||||
|
.. agentNodes.SelectMany(node => new[]
|
||||||
|
{
|
||||||
|
CreateEdge(orchestratorNode.Id, node.Id),
|
||||||
|
CreateEdge(node.Id, orchestratorNode.Id)
|
||||||
|
}),
|
||||||
|
CreateEdge(orchestratorNode.Id, outputNode.Id)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto CreateSystemNode(string id, string kind, double x, double y)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Kind = kind,
|
||||||
|
Position = new PatternGraphPositionDto
|
||||||
|
{
|
||||||
|
X = x,
|
||||||
|
Y = y
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto CreateAgentNode(PatternAgentDefinitionDto agent, int order, double x, double y)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"agent-node-{agent.Id}",
|
||||||
|
Kind = AgentKind,
|
||||||
|
AgentId = agent.Id,
|
||||||
|
Order = order,
|
||||||
|
Position = new PatternGraphPositionDto
|
||||||
|
{
|
||||||
|
X = x,
|
||||||
|
Y = y
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"edge-{source}-to-{target}",
|
||||||
|
Source = source,
|
||||||
|
Target = target
|
||||||
|
};
|
||||||
|
|
||||||
|
private static double SpreadY(int index, int count, double gap)
|
||||||
|
=> (index - ((count - 1) / 2d)) * gap;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace Eryx.AgentHost.Services;
|
|||||||
|
|
||||||
public sealed class PatternValidator
|
public sealed class PatternValidator
|
||||||
{
|
{
|
||||||
|
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||||
|
|
||||||
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
|
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
|
||||||
{
|
{
|
||||||
List<PatternValidationIssueDto> issues = [];
|
List<PatternValidationIssueDto> issues = [];
|
||||||
@@ -93,6 +95,479 @@ public sealed class PatternValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), issues);
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateGraph(
|
||||||
|
PatternDefinitionDto pattern,
|
||||||
|
PatternGraphDto graph,
|
||||||
|
List<PatternValidationIssueDto> issues)
|
||||||
|
{
|
||||||
|
if (graph.Nodes.Count == 0)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Pattern graph must include nodes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> nodeIds = new(StringComparer.Ordinal);
|
||||||
|
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
||||||
|
HashSet<string> agentIds = pattern.Agents.Select(agent => agent.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
HashSet<string> seenAgentIds = new(StringComparer.Ordinal);
|
||||||
|
HashSet<int> seenAgentOrders = [];
|
||||||
|
Dictionary<string, PatternGraphNodeDto> nodesById = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||||
|
{
|
||||||
|
if (!nodeIds.Add(node.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph contains duplicate node \"{node.Id}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
nodesById[node.Id] = node;
|
||||||
|
|
||||||
|
if (Comparer.Equals(node.Kind, "agent"))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(node.AgentId) || !agentIds.Contains(node.AgentId))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must reference a known agent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(node.AgentId) && !seenAgentIds.Add(node.AgentId))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph contains multiple nodes for agent \"{node.AgentId}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.Order.HasValue)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must define an order.");
|
||||||
|
}
|
||||||
|
else if (!seenAgentOrders.Add(node.Order.Value))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph contains duplicate agent order \"{node.Order.Value}\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(node.AgentId))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"System node \"{node.Id}\" cannot reference an agent.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
||||||
|
{
|
||||||
|
if (!seenAgentIds.Contains(agent.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph is missing node metadata for agent \"{agent.Id}\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||||
|
{
|
||||||
|
if (!edgeIds.Add(edge.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph contains duplicate edge \"{edge.Id}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph edge \"{edge.Id}\" must connect known nodes.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (pattern.Mode)
|
||||||
|
{
|
||||||
|
case "single":
|
||||||
|
case "sequential":
|
||||||
|
case "magentic":
|
||||||
|
ValidateLinearGraph(pattern, graph, issues);
|
||||||
|
break;
|
||||||
|
case "concurrent":
|
||||||
|
ValidateConcurrentGraph(pattern, graph, issues);
|
||||||
|
break;
|
||||||
|
case "handoff":
|
||||||
|
ValidateHandoffGraph(graph, issues);
|
||||||
|
break;
|
||||||
|
case "group-chat":
|
||||||
|
ValidateGroupChatGraph(pattern, graph, issues);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateLinearGraph(
|
||||||
|
PatternDefinitionDto pattern,
|
||||||
|
PatternGraphDto graph,
|
||||||
|
List<PatternValidationIssueDto> issues)
|
||||||
|
{
|
||||||
|
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
||||||
|
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||||
|
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||||
|
if (inputNode is null || outputNode is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||||
|
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||||
|
|
||||||
|
if (graph.Edges.Count != pattern.Agents.Count + 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Linear orchestration graphs must be a single path from user input through every agent to user output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "User input must start exactly one path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.GetValueOrDefault(outputNode.Id, []).Count != 1 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "User output must terminate exactly one path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphNodeDto node in agentNodes)
|
||||||
|
{
|
||||||
|
if (incoming.GetValueOrDefault(node.Id, []).Count != 1 || outgoing.GetValueOrDefault(node.Id, []).Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Each agent in a linear orchestration must have exactly one incoming and one outgoing edge.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||||
|
string currentNodeId = inputNode.Id;
|
||||||
|
while (visited.Add(currentNodeId))
|
||||||
|
{
|
||||||
|
List<PatternGraphEdgeDto> nextEdges = outgoing.GetValueOrDefault(currentNodeId, []);
|
||||||
|
if (nextEdges.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextEdges.Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Linear orchestration nodes may only branch to one next step.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNodeId = nextEdges[0].Target;
|
||||||
|
if (Comparer.Equals(currentNodeId, outputNode.Id))
|
||||||
|
{
|
||||||
|
visited.Add(currentNodeId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> expectedVisited = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
inputNode.Id,
|
||||||
|
outputNode.Id
|
||||||
|
};
|
||||||
|
foreach (PatternGraphNodeDto node in agentNodes)
|
||||||
|
{
|
||||||
|
expectedVisited.Add(node.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedVisited.SetEquals(visited))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Linear orchestration graphs must visit every agent exactly once.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateConcurrentGraph(
|
||||||
|
PatternDefinitionDto pattern,
|
||||||
|
PatternGraphDto graph,
|
||||||
|
List<PatternValidationIssueDto> issues)
|
||||||
|
{
|
||||||
|
ValidateSystemNodeCounts(graph, ["user-input", "distributor", "collector", "user-output"], issues);
|
||||||
|
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||||
|
PatternGraphNodeDto? distributorNode = GetNodeByKind(graph, "distributor");
|
||||||
|
PatternGraphNodeDto? collectorNode = GetNodeByKind(graph, "collector");
|
||||||
|
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||||
|
if (inputNode is null || distributorNode is null || collectorNode is null || outputNode is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||||
|
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||||
|
HashSet<string> distributorTargets = outgoing.GetValueOrDefault(distributorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
||||||
|
HashSet<string> collectorSources = incoming.GetValueOrDefault(collectorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Concurrent orchestration graphs must fan out from the distributor and fan back into the collector.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "User input must connect only to the distributor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.GetValueOrDefault(distributorNode.Id, []).Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Distributor must receive exactly one edge from user input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outgoing.GetValueOrDefault(collectorNode.Id, []).Count != 1 || incoming.GetValueOrDefault(outputNode.Id, []).Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Collector must forward exactly one edge to user output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||||
|
{
|
||||||
|
if (!distributorTargets.Contains(agentNode.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Distributor must connect to agent \"{agentNode.AgentId}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collectorSources.Contains(agentNode.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect to the collector.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateHandoffGraph(
|
||||||
|
PatternGraphDto graph,
|
||||||
|
List<PatternValidationIssueDto> issues)
|
||||||
|
{
|
||||||
|
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
||||||
|
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||||
|
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||||
|
if (inputNode is null || outputNode is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||||
|
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||||
|
HashSet<string> agentNodeIds = agentNodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
List<PatternGraphEdgeDto> entryEdges = outgoing.GetValueOrDefault(inputNode.Id, []);
|
||||||
|
List<PatternGraphEdgeDto> completionEdges = incoming.GetValueOrDefault(outputNode.Id, []);
|
||||||
|
|
||||||
|
if (entryEdges.Count != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Handoff graphs must connect user input to exactly one entry agent.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentNodeIds.Contains(entryEdges[0].Target))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Handoff entry edges must target an agent node.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionEdges.Count == 0)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Handoff graphs must allow at least one agent to complete back to user output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasAgentToAgentRoute = false;
|
||||||
|
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||||
|
{
|
||||||
|
if (Comparer.Equals(edge.Source, inputNode.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Comparer.Equals(edge.Target, outputNode.Id))
|
||||||
|
{
|
||||||
|
if (!agentNodeIds.Contains(edge.Source))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Only agent nodes may complete to user output.");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentNodeIds.Contains(edge.Source) || !agentNodeIds.Contains(edge.Target))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Handoff routes may only connect agents to agents or agents to user output.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Comparer.Equals(edge.Source, edge.Target))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Handoff routes cannot target the same agent node.");
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAgentToAgentRoute = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAgentToAgentRoute && agentNodes.Count > 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Handoff graphs must include at least one agent-to-agent handoff route.");
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> reachable = new(StringComparer.Ordinal);
|
||||||
|
Stack<string> stack = new Stack<string>([entryEdges[0].Target]);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
string nodeId = stack.Pop();
|
||||||
|
if (!reachable.Add(nodeId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphEdgeDto edge in outgoing.GetValueOrDefault(nodeId, []))
|
||||||
|
{
|
||||||
|
if (agentNodeIds.Contains(edge.Target) && !reachable.Contains(edge.Target))
|
||||||
|
{
|
||||||
|
stack.Push(edge.Target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||||
|
{
|
||||||
|
if (!reachable.Contains(agentNode.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Handoff entry agent must be able to reach \"{agentNode.AgentId}\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "User input cannot have incoming edges and user output cannot have outgoing edges.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateGroupChatGraph(
|
||||||
|
PatternDefinitionDto pattern,
|
||||||
|
PatternGraphDto graph,
|
||||||
|
List<PatternValidationIssueDto> issues)
|
||||||
|
{
|
||||||
|
ValidateSystemNodeCounts(graph, ["user-input", "orchestrator", "user-output"], issues);
|
||||||
|
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||||
|
PatternGraphNodeDto? orchestratorNode = GetNodeByKind(graph, "orchestrator");
|
||||||
|
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||||
|
if (inputNode is null || orchestratorNode is null || outputNode is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||||
|
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||||
|
HashSet<string> orchestratorTargets = outgoing.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
||||||
|
HashSet<string> orchestratorSources = incoming.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Group chat graphs must connect the orchestrator to every participant and then back to user output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outgoing.GetValueOrDefault(inputNode.Id, []).Any(edge => !Comparer.Equals(edge.Target, orchestratorNode.Id)))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "User input must only connect to the orchestrator.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outgoing.GetValueOrDefault(orchestratorNode.Id, []).Any(edge => Comparer.Equals(edge.Target, outputNode.Id)))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, "Group chat orchestrator must connect to user output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||||
|
{
|
||||||
|
if (!orchestratorTargets.Contains(agentNode.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Orchestrator must connect to agent \"{agentNode.AgentId}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orchestratorSources.Contains(agentNode.Id))
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect back to the orchestrator.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateSystemNodeCounts(
|
||||||
|
PatternGraphDto graph,
|
||||||
|
IReadOnlyList<string> expectedKinds,
|
||||||
|
List<PatternValidationIssueDto> issues)
|
||||||
|
{
|
||||||
|
Dictionary<string, int> counts = graph.Nodes
|
||||||
|
.GroupBy(node => node.Kind, Comparer)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), Comparer);
|
||||||
|
HashSet<string> expected = expectedKinds.ToHashSet(Comparer);
|
||||||
|
|
||||||
|
foreach (string kind in expectedKinds)
|
||||||
|
{
|
||||||
|
if (counts.GetValueOrDefault(kind, 0) != 1)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph must include exactly one \"{kind}\" node.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((string kind, int count) in counts)
|
||||||
|
{
|
||||||
|
if (Comparer.Equals(kind, "agent"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expected.Contains(kind) && count > 0)
|
||||||
|
{
|
||||||
|
AddGraphIssue(issues, $"Pattern graph does not allow \"{kind}\" nodes in this mode.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
||||||
|
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
||||||
|
|
||||||
|
private static List<PatternGraphNodeDto> GetAgentNodes(PatternGraphDto graph)
|
||||||
|
=> graph.Nodes.Where(node => Comparer.Equals(node.Kind, "agent")).ToList();
|
||||||
|
|
||||||
|
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildIncomingLookup(PatternGraphDto graph)
|
||||||
|
{
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> incoming = new(StringComparer.Ordinal);
|
||||||
|
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||||
|
{
|
||||||
|
incoming[node.Id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||||
|
{
|
||||||
|
if (!incoming.TryGetValue(edge.Target, out List<PatternGraphEdgeDto>? edges))
|
||||||
|
{
|
||||||
|
edges = [];
|
||||||
|
incoming[edge.Target] = edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
edges.Add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
||||||
|
{
|
||||||
|
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = new(StringComparer.Ordinal);
|
||||||
|
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||||
|
{
|
||||||
|
outgoing[node.Id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||||
|
{
|
||||||
|
if (!outgoing.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
||||||
|
{
|
||||||
|
edges = [];
|
||||||
|
outgoing[edge.Source] = edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
edges.Add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outgoing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddGraphIssue(List<PatternValidationIssueDto> issues, string message)
|
||||||
|
=> issues.Add(new PatternValidationIssueDto
|
||||||
|
{
|
||||||
|
Field = "graph",
|
||||||
|
Message = message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Eryx.AgentHost.Contracts;
|
||||||
|
using Eryx.AgentHost.Services;
|
||||||
|
|
||||||
|
namespace Eryx.AgentHost.Tests;
|
||||||
|
|
||||||
|
public sealed class PatternGraphResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResolveOrderedAgentIds_UsesSequentialGraphPath()
|
||||||
|
{
|
||||||
|
PatternDefinitionDto pattern = CreatePattern(
|
||||||
|
"sequential",
|
||||||
|
[
|
||||||
|
CreateAgent("agent-1", "Analyst"),
|
||||||
|
CreateAgent("agent-2", "Builder"),
|
||||||
|
CreateAgent("agent-3", "Reviewer"),
|
||||||
|
],
|
||||||
|
new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
CreateSystemNode("system-user-input", "user-input"),
|
||||||
|
CreateAgentNode("agent-1", 0),
|
||||||
|
CreateAgentNode("agent-2", 1),
|
||||||
|
CreateAgentNode("agent-3", 2),
|
||||||
|
CreateSystemNode("system-user-output", "user-output"),
|
||||||
|
],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
CreateEdge("system-user-input", "agent-node-agent-3"),
|
||||||
|
CreateEdge("agent-node-agent-3", "agent-node-agent-1"),
|
||||||
|
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
|
||||||
|
CreateEdge("agent-node-agent-2", "system-user-output"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
IReadOnlyList<string> orderedAgentIds = PatternGraphResolver.ResolveOrderedAgentIds(pattern);
|
||||||
|
|
||||||
|
Assert.Equal(["agent-3", "agent-1", "agent-2"], orderedAgentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveHandoff_UsesExplicitEntryAndRoutes()
|
||||||
|
{
|
||||||
|
PatternDefinitionDto pattern = CreatePattern(
|
||||||
|
"handoff",
|
||||||
|
[
|
||||||
|
CreateAgent("agent-1", "Triage"),
|
||||||
|
CreateAgent("agent-2", "UX"),
|
||||||
|
CreateAgent("agent-3", "Runtime"),
|
||||||
|
],
|
||||||
|
new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
CreateSystemNode("system-user-input", "user-input"),
|
||||||
|
CreateSystemNode("system-user-output", "user-output"),
|
||||||
|
CreateAgentNode("agent-1", 0),
|
||||||
|
CreateAgentNode("agent-2", 1),
|
||||||
|
CreateAgentNode("agent-3", 2),
|
||||||
|
],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
CreateEdge("system-user-input", "agent-node-agent-3"),
|
||||||
|
CreateEdge("agent-node-agent-3", "agent-node-agent-2"),
|
||||||
|
CreateEdge("agent-node-agent-2", "agent-node-agent-1"),
|
||||||
|
CreateEdge("agent-node-agent-2", "system-user-output"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
||||||
|
|
||||||
|
Assert.Equal("agent-3", topology.EntryAgentId);
|
||||||
|
Assert.Contains(new PatternHandoffRoute("agent-3", "agent-2"), topology.Routes);
|
||||||
|
Assert.Contains(new PatternHandoffRoute("agent-2", "agent-1"), topology.Routes);
|
||||||
|
Assert.DoesNotContain(new PatternHandoffRoute("agent-1", "agent-2"), topology.Routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternDefinitionDto CreatePattern(
|
||||||
|
string mode,
|
||||||
|
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||||
|
PatternGraphDto graph)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"{mode}-pattern",
|
||||||
|
Name = "Pattern",
|
||||||
|
Mode = mode,
|
||||||
|
Availability = "available",
|
||||||
|
Agents = agents,
|
||||||
|
Graph = graph,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the user's request.",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Kind = kind,
|
||||||
|
Position = new PatternGraphPositionDto(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"agent-node-{agentId}",
|
||||||
|
Kind = "agent",
|
||||||
|
AgentId = agentId,
|
||||||
|
Order = order,
|
||||||
|
Position = new PatternGraphPositionDto(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"edge-{source}-to-{target}",
|
||||||
|
Source = source,
|
||||||
|
Target = target,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -73,12 +73,46 @@ public sealed class PatternValidatorTests
|
|||||||
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
|
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SequentialPattern_WithBranchedGraph_IsReportedAsInvalid()
|
||||||
|
{
|
||||||
|
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||||
|
CreatePattern(
|
||||||
|
"sequential",
|
||||||
|
[
|
||||||
|
CreateAgent(id: "agent-1", name: "Analyst"),
|
||||||
|
CreateAgent(id: "agent-2", name: "Builder"),
|
||||||
|
],
|
||||||
|
graph: new PatternGraphDto
|
||||||
|
{
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
CreateSystemNode("system-user-input", "user-input"),
|
||||||
|
CreateAgentNode("agent-1", 0),
|
||||||
|
CreateAgentNode("agent-2", 1),
|
||||||
|
CreateSystemNode("system-user-output", "user-output"),
|
||||||
|
],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
CreateEdge("system-user-input", "agent-node-agent-1"),
|
||||||
|
CreateEdge("system-user-input", "agent-node-agent-2"),
|
||||||
|
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
|
||||||
|
CreateEdge("agent-node-agent-2", "system-user-output"),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Contains(issues, issue =>
|
||||||
|
issue.Field == "graph"
|
||||||
|
&& issue.Message.Contains("single path", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
private static PatternDefinitionDto CreatePattern(
|
private static PatternDefinitionDto CreatePattern(
|
||||||
string mode,
|
string mode,
|
||||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||||
string availability = "available",
|
string availability = "available",
|
||||||
string? unavailabilityReason = null,
|
string? unavailabilityReason = null,
|
||||||
string name = "Pattern")
|
string name = "Pattern",
|
||||||
|
PatternGraphDto? graph = null)
|
||||||
{
|
{
|
||||||
return new PatternDefinitionDto
|
return new PatternDefinitionDto
|
||||||
{
|
{
|
||||||
@@ -88,6 +122,7 @@ public sealed class PatternValidatorTests
|
|||||||
Availability = availability,
|
Availability = availability,
|
||||||
UnavailabilityReason = unavailabilityReason,
|
UnavailabilityReason = unavailabilityReason,
|
||||||
Agents = agents,
|
Agents = agents,
|
||||||
|
Graph = graph,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,4 +140,30 @@ public sealed class PatternValidatorTests
|
|||||||
Instructions = instructions,
|
Instructions = instructions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Kind = kind,
|
||||||
|
Position = new PatternGraphPositionDto(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"agent-node-{agentId}",
|
||||||
|
Kind = "agent",
|
||||||
|
AgentId = agentId,
|
||||||
|
Order = order,
|
||||||
|
Position = new PatternGraphPositionDto(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = $"edge-{source}-to-{target}",
|
||||||
|
Source = source,
|
||||||
|
Target = target,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import {
|
import {
|
||||||
isReasoningEffort,
|
isReasoningEffort,
|
||||||
|
syncPatternGraph,
|
||||||
type PatternDefinition,
|
type PatternDefinition,
|
||||||
type ReasoningEffort,
|
type ReasoningEffort,
|
||||||
validatePatternDefinition,
|
validatePatternDefinition,
|
||||||
@@ -212,8 +213,9 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||||
|
const synchronizedPattern = syncPatternGraph(pattern);
|
||||||
const issues = validatePatternDefinition(
|
const issues = validatePatternDefinition(
|
||||||
pattern,
|
synchronizedPattern,
|
||||||
knownApprovalToolNames,
|
knownApprovalToolNames,
|
||||||
).filter((issue) => issue.level === 'error');
|
).filter((issue) => issue.level === 'error');
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
@@ -222,8 +224,8 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
|
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
|
||||||
const candidate: PatternDefinition = {
|
const candidate: PatternDefinition = {
|
||||||
...pattern,
|
...synchronizedPattern,
|
||||||
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
|
approvalPolicy: normalizeApprovalPolicy(synchronizedPattern.approvalPolicy),
|
||||||
isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite,
|
isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite,
|
||||||
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
|
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
|
||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||||
@@ -71,6 +71,7 @@ export class WorkspaceRepository {
|
|||||||
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
|
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
|
||||||
...pattern,
|
...pattern,
|
||||||
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
|
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
|
||||||
|
graph: resolvePatternGraph(pattern),
|
||||||
})),
|
})),
|
||||||
projects,
|
projects,
|
||||||
sessions: (stored.sessions ?? []).map((session) => ({
|
sessions: (stored.sessions ?? []).map((session) => ({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
normalizePatternModels,
|
normalizePatternModels,
|
||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||||
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||||
@@ -30,7 +30,7 @@ import { createId, nowIso } from '@shared/utils/ids';
|
|||||||
|
|
||||||
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
|
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
return {
|
return syncPatternGraph({
|
||||||
id: createId('custom-pattern'),
|
id: createId('custom-pattern'),
|
||||||
name: 'New Pattern',
|
name: 'New Pattern',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -49,7 +49,7 @@ function createDraftPattern(defaultModelId: string, defaultReasoningEffort: Patt
|
|||||||
],
|
],
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDraftMcpServer(): McpServerDefinition {
|
function createDraftMcpServer(): McpServerDefinition {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type ModelDefinition,
|
type ModelDefinition,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import {
|
import {
|
||||||
|
syncPatternGraph,
|
||||||
validatePatternDefinition,
|
validatePatternDefinition,
|
||||||
type OrchestrationMode,
|
type OrchestrationMode,
|
||||||
type PatternDefinition,
|
type PatternDefinition,
|
||||||
@@ -220,8 +221,12 @@ export function PatternEditor({
|
|||||||
}: PatternEditorProps) {
|
}: PatternEditorProps) {
|
||||||
const issues = validatePatternDefinition(pattern);
|
const issues = validatePatternDefinition(pattern);
|
||||||
|
|
||||||
|
function emitChange(nextPattern: PatternDefinition) {
|
||||||
|
onChange(syncPatternGraph(nextPattern));
|
||||||
|
}
|
||||||
|
|
||||||
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||||
onChange({
|
emitChange({
|
||||||
...pattern,
|
...pattern,
|
||||||
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
|
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
|
||||||
});
|
});
|
||||||
@@ -236,7 +241,7 @@ export function PatternEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) {
|
function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) {
|
||||||
onChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) });
|
emitChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean {
|
function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean {
|
||||||
@@ -358,14 +363,14 @@ export function PatternEditor({
|
|||||||
</h4>
|
</h4>
|
||||||
<InputField
|
<InputField
|
||||||
label="Name"
|
label="Name"
|
||||||
onChange={(v) => onChange({ ...pattern, name: v })}
|
onChange={(v) => emitChange({ ...pattern, name: v })}
|
||||||
placeholder="Pattern name"
|
placeholder="Pattern name"
|
||||||
value={pattern.name}
|
value={pattern.name}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Description"
|
label="Description"
|
||||||
multiline
|
multiline
|
||||||
onChange={(v) => onChange({ ...pattern, description: v })}
|
onChange={(v) => emitChange({ ...pattern, description: v })}
|
||||||
placeholder="What this pattern does..."
|
placeholder="What this pattern does..."
|
||||||
value={pattern.description}
|
value={pattern.description}
|
||||||
/>
|
/>
|
||||||
@@ -394,7 +399,7 @@ export function PatternEditor({
|
|||||||
}`}
|
}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => onChange({ ...pattern, mode })}
|
onClick={() => emitChange({ ...pattern, mode })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -430,7 +435,7 @@ export function PatternEditor({
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({
|
emitChange({
|
||||||
...pattern,
|
...pattern,
|
||||||
agents: [
|
agents: [
|
||||||
...pattern.agents,
|
...pattern.agents,
|
||||||
@@ -471,7 +476,7 @@ export function PatternEditor({
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({
|
emitChange({
|
||||||
...pattern,
|
...pattern,
|
||||||
agents: pattern.agents.filter((a) => a.id !== agent.id),
|
agents: pattern.agents.filter((a) => a.id !== agent.id),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export type OrchestrationMode =
|
|||||||
|
|
||||||
export type PatternAvailability = 'available' | 'preview' | 'unavailable';
|
export type PatternAvailability = 'available' | 'preview' | 'unavailable';
|
||||||
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
export type PatternGraphNodeKind =
|
||||||
|
| 'user-input'
|
||||||
|
| 'user-output'
|
||||||
|
| 'agent'
|
||||||
|
| 'distributor'
|
||||||
|
| 'collector'
|
||||||
|
| 'orchestrator';
|
||||||
|
|
||||||
export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; label: string }> = [
|
export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; label: string }> = [
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: 'low', label: 'Low' },
|
||||||
@@ -32,6 +39,30 @@ export interface PatternAgentDefinition {
|
|||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PatternGraphPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternGraphNode {
|
||||||
|
id: string;
|
||||||
|
kind: PatternGraphNodeKind;
|
||||||
|
position: PatternGraphPosition;
|
||||||
|
agentId?: string;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternGraphEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternGraph {
|
||||||
|
nodes: PatternGraphNode[];
|
||||||
|
edges: PatternGraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PatternDefinition {
|
export interface PatternDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -43,6 +74,7 @@ export interface PatternDefinition {
|
|||||||
maxIterations: number;
|
maxIterations: number;
|
||||||
approvalPolicy?: ApprovalPolicy;
|
approvalPolicy?: ApprovalPolicy;
|
||||||
agents: PatternAgentDefinition[];
|
agents: PatternAgentDefinition[];
|
||||||
|
graph?: PatternGraph;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -61,12 +93,222 @@ const defaultModels = {
|
|||||||
|
|
||||||
const reasoningEffortSet = new Set<ReasoningEffort>(reasoningEffortOptions.map((option) => option.value));
|
const reasoningEffortSet = new Set<ReasoningEffort>(reasoningEffortOptions.map((option) => option.value));
|
||||||
|
|
||||||
|
const SYSTEM_NODE_IDS = {
|
||||||
|
userInput: 'system-user-input',
|
||||||
|
userOutput: 'system-user-output',
|
||||||
|
distributor: 'system-distributor',
|
||||||
|
collector: 'system-collector',
|
||||||
|
orchestrator: 'system-orchestrator',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function isReasoningEffort(value: string | undefined): value is ReasoningEffort {
|
export function isReasoningEffort(value: string | undefined): value is ReasoningEffort {
|
||||||
return value !== undefined && reasoningEffortSet.has(value as ReasoningEffort);
|
return value !== undefined && reasoningEffortSet.has(value as ReasoningEffort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentNodeId(agentId: string): string {
|
||||||
|
return `agent-node-${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeId(source: string, target: string): string {
|
||||||
|
return `edge-${source}-to-${target}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEdge(source: string, target: string): PatternGraphEdge {
|
||||||
|
return {
|
||||||
|
id: edgeId(source, target),
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgentNode(
|
||||||
|
agent: PatternAgentDefinition,
|
||||||
|
order: number,
|
||||||
|
position: PatternGraphPosition,
|
||||||
|
): PatternGraphNode {
|
||||||
|
return {
|
||||||
|
id: agentNodeId(agent.id),
|
||||||
|
kind: 'agent',
|
||||||
|
agentId: agent.id,
|
||||||
|
order,
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spreadY(index: number, count: number, gap = 170): number {
|
||||||
|
return (index - (count - 1) / 2) * gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||||
|
const inputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userInput,
|
||||||
|
kind: 'user-input',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
const outputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userOutput,
|
||||||
|
kind: 'user-output',
|
||||||
|
position: { x: 220 * Math.max(agents.length + 1, 2), y: 0 },
|
||||||
|
};
|
||||||
|
const agentNodes = agents.map((agent, index) =>
|
||||||
|
createAgentNode(agent, index, { x: 220 * (index + 1), y: 0 }),
|
||||||
|
);
|
||||||
|
const edges: PatternGraphEdge[] = [];
|
||||||
|
const path = [inputNode.id, ...agentNodes.map((node) => node.id), outputNode.id];
|
||||||
|
for (let index = 0; index < path.length - 1; index += 1) {
|
||||||
|
edges.push(createEdge(path[index]!, path[index + 1]!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: [inputNode, ...agentNodes, outputNode],
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||||
|
const inputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userInput,
|
||||||
|
kind: 'user-input',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
const distributorNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.distributor,
|
||||||
|
kind: 'distributor',
|
||||||
|
position: { x: 190, y: 0 },
|
||||||
|
};
|
||||||
|
const collectorNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.collector,
|
||||||
|
kind: 'collector',
|
||||||
|
position: { x: 650, y: 0 },
|
||||||
|
};
|
||||||
|
const outputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userOutput,
|
||||||
|
kind: 'user-output',
|
||||||
|
position: { x: 860, y: 0 },
|
||||||
|
};
|
||||||
|
const agentNodes = agents.map((agent, index) =>
|
||||||
|
createAgentNode(agent, index, { x: 430, y: spreadY(index, Math.max(agents.length, 1), 170) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: [inputNode, distributorNode, ...agentNodes, collectorNode, outputNode],
|
||||||
|
edges: [
|
||||||
|
createEdge(inputNode.id, distributorNode.id),
|
||||||
|
...agentNodes.map((node) => createEdge(distributorNode.id, node.id)),
|
||||||
|
...agentNodes.map((node) => createEdge(node.id, collectorNode.id)),
|
||||||
|
createEdge(collectorNode.id, outputNode.id),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||||
|
const inputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userInput,
|
||||||
|
kind: 'user-input',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
const outputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userOutput,
|
||||||
|
kind: 'user-output',
|
||||||
|
position: { x: 860, y: 0 },
|
||||||
|
};
|
||||||
|
const entryAgent = agents[0];
|
||||||
|
const entryNode = entryAgent
|
||||||
|
? createAgentNode(entryAgent, 0, { x: 220, y: 0 })
|
||||||
|
: undefined;
|
||||||
|
const specialistNodes = agents.slice(1).map((agent, index) =>
|
||||||
|
createAgentNode(agent, index + 1, { x: 540, y: spreadY(index, Math.max(agents.length - 1, 1), 220) }),
|
||||||
|
);
|
||||||
|
const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode];
|
||||||
|
const edges: PatternGraphEdge[] = [];
|
||||||
|
|
||||||
|
if (entryNode) {
|
||||||
|
edges.push(createEdge(inputNode.id, entryNode.id));
|
||||||
|
edges.push(createEdge(entryNode.id, outputNode.id));
|
||||||
|
|
||||||
|
for (const specialistNode of specialistNodes) {
|
||||||
|
edges.push(createEdge(entryNode.id, specialistNode.id));
|
||||||
|
edges.push(createEdge(specialistNode.id, entryNode.id));
|
||||||
|
edges.push(createEdge(specialistNode.id, outputNode.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||||
|
const inputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userInput,
|
||||||
|
kind: 'user-input',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
const orchestratorNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.orchestrator,
|
||||||
|
kind: 'orchestrator',
|
||||||
|
position: { x: 250, y: 0 },
|
||||||
|
};
|
||||||
|
const outputNode: PatternGraphNode = {
|
||||||
|
id: SYSTEM_NODE_IDS.userOutput,
|
||||||
|
kind: 'user-output',
|
||||||
|
position: { x: 900, y: 0 },
|
||||||
|
};
|
||||||
|
const centerX = 560;
|
||||||
|
const centerY = 0;
|
||||||
|
const radiusX = 190;
|
||||||
|
const radiusY = 170;
|
||||||
|
const agentNodes = agents.map((agent, index) => {
|
||||||
|
const angle = agents.length <= 1 ? 0 : (Math.PI * 2 * index) / agents.length - Math.PI / 2;
|
||||||
|
return createAgentNode(agent, index, {
|
||||||
|
x: Math.round(centerX + Math.cos(angle) * radiusX),
|
||||||
|
y: Math.round(centerY + Math.sin(angle) * radiusY),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: [inputNode, orchestratorNode, ...agentNodes, outputNode],
|
||||||
|
edges: [
|
||||||
|
createEdge(inputNode.id, orchestratorNode.id),
|
||||||
|
...agentNodes.flatMap((node) => [
|
||||||
|
createEdge(orchestratorNode.id, node.id),
|
||||||
|
createEdge(node.id, orchestratorNode.id),
|
||||||
|
]),
|
||||||
|
createEdge(orchestratorNode.id, outputNode.id),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultPatternGraph(
|
||||||
|
pattern: Pick<PatternDefinition, 'mode' | 'agents'>,
|
||||||
|
): PatternGraph {
|
||||||
|
switch (pattern.mode) {
|
||||||
|
case 'single':
|
||||||
|
case 'sequential':
|
||||||
|
case 'magentic':
|
||||||
|
return createLinearGraph(pattern.agents);
|
||||||
|
case 'concurrent':
|
||||||
|
return createConcurrentGraph(pattern.agents);
|
||||||
|
case 'handoff':
|
||||||
|
return createHandoffGraph(pattern.agents);
|
||||||
|
case 'group-chat':
|
||||||
|
return createGroupChatGraph(pattern.agents);
|
||||||
|
default:
|
||||||
|
return createLinearGraph(pattern.agents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePatternGraph(pattern: PatternDefinition): PatternGraph {
|
||||||
|
return pattern.graph ?? createDefaultPatternGraph(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncPatternGraph(pattern: PatternDefinition): PatternDefinition {
|
||||||
|
return {
|
||||||
|
...pattern,
|
||||||
|
graph: createDefaultPatternGraph(pattern),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
|
export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
|
||||||
return [
|
const patterns: PatternDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 'pattern-single-chat',
|
id: 'pattern-single-chat',
|
||||||
name: '1-on-1 Copilot Chat',
|
name: '1-on-1 Copilot Chat',
|
||||||
@@ -256,6 +498,408 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
|
|||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
return patterns.map((pattern) => syncPatternGraph(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByKind(graph: PatternGraph): Map<PatternGraphNodeKind, number> {
|
||||||
|
const counts = new Map<PatternGraphNodeKind, number>();
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeByKind(graph: PatternGraph, kind: PatternGraphNodeKind): PatternGraphNode | undefined {
|
||||||
|
return graph.nodes.find((node) => node.kind === kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentNodes(graph: PatternGraph): PatternGraphNode[] {
|
||||||
|
return graph.nodes.filter((node) => node.kind === 'agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushGraphIssue(issues: PatternValidationIssue[], message: string, field = 'graph'): void {
|
||||||
|
issues.push({ level: 'error', field, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdjacency(graph: PatternGraph): {
|
||||||
|
incoming: Map<string, PatternGraphEdge[]>;
|
||||||
|
outgoing: Map<string, PatternGraphEdge[]>;
|
||||||
|
} {
|
||||||
|
const incoming = new Map<string, PatternGraphEdge[]>();
|
||||||
|
const outgoing = new Map<string, PatternGraphEdge[]>();
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
incoming.set(node.id, []);
|
||||||
|
outgoing.set(node.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
incoming.get(edge.target)?.push(edge);
|
||||||
|
outgoing.get(edge.source)?.push(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { incoming, outgoing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSystemNodeCounts(
|
||||||
|
graph: PatternGraph,
|
||||||
|
expectedKinds: readonly PatternGraphNodeKind[],
|
||||||
|
issues: PatternValidationIssue[],
|
||||||
|
): void {
|
||||||
|
const counts = countByKind(graph);
|
||||||
|
const expectedSet = new Set(expectedKinds);
|
||||||
|
|
||||||
|
for (const kind of expectedKinds) {
|
||||||
|
if ((counts.get(kind) ?? 0) !== 1) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph must include exactly one "${kind}" node.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [kind, count] of counts) {
|
||||||
|
if (kind === 'agent') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedSet.has(kind) && count > 0) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph does not allow "${kind}" nodes in ${graphModeLabel(expectedKinds)} mode.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function graphModeLabel(expectedKinds: readonly PatternGraphNodeKind[]): string {
|
||||||
|
if (expectedKinds.includes('collector')) {
|
||||||
|
return 'concurrent';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedKinds.includes('orchestrator')) {
|
||||||
|
return 'group chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'this';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLinearGraph(
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
graph: PatternGraph,
|
||||||
|
issues: PatternValidationIssue[],
|
||||||
|
): void {
|
||||||
|
validateSystemNodeCounts(graph, ['user-input', 'user-output'], issues);
|
||||||
|
const inputNode = getNodeByKind(graph, 'user-input');
|
||||||
|
const outputNode = getNodeByKind(graph, 'user-output');
|
||||||
|
if (!inputNode || !outputNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { incoming, outgoing } = buildAdjacency(graph);
|
||||||
|
const agentNodes = getAgentNodes(graph);
|
||||||
|
|
||||||
|
if (graph.edges.length !== pattern.agents.length + 1) {
|
||||||
|
pushGraphIssue(issues, 'Linear orchestration graphs must be a single path from user input through every agent to user output.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((incoming.get(inputNode.id)?.length ?? 0) !== 0 || (outgoing.get(inputNode.id)?.length ?? 0) !== 1) {
|
||||||
|
pushGraphIssue(issues, 'User input must start exactly one path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((incoming.get(outputNode.id)?.length ?? 0) !== 1 || (outgoing.get(outputNode.id)?.length ?? 0) !== 0) {
|
||||||
|
pushGraphIssue(issues, 'User output must terminate exactly one path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of agentNodes) {
|
||||||
|
if ((incoming.get(node.id)?.length ?? 0) !== 1 || (outgoing.get(node.id)?.length ?? 0) !== 1) {
|
||||||
|
pushGraphIssue(issues, 'Each agent in a linear orchestration must have exactly one incoming and one outgoing edge.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let currentNodeId = inputNode.id;
|
||||||
|
|
||||||
|
while (!visited.has(currentNodeId)) {
|
||||||
|
visited.add(currentNodeId);
|
||||||
|
const nextEdge = outgoing.get(currentNodeId);
|
||||||
|
if (!nextEdge || nextEdge.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextEdge.length !== 1) {
|
||||||
|
pushGraphIssue(issues, 'Linear orchestration nodes may only branch to one next step.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNodeId = nextEdge[0]!.target;
|
||||||
|
if (currentNodeId === outputNode.id) {
|
||||||
|
visited.add(currentNodeId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedVisited = new Set<string>([inputNode.id, outputNode.id, ...agentNodes.map((node) => node.id)]);
|
||||||
|
if (visited.size !== expectedVisited.size || [...expectedVisited].some((nodeId) => !visited.has(nodeId))) {
|
||||||
|
pushGraphIssue(issues, 'Linear orchestration graphs must visit every agent exactly once.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConcurrentGraph(
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
graph: PatternGraph,
|
||||||
|
issues: PatternValidationIssue[],
|
||||||
|
): void {
|
||||||
|
validateSystemNodeCounts(graph, ['user-input', 'distributor', 'collector', 'user-output'], issues);
|
||||||
|
const inputNode = getNodeByKind(graph, 'user-input');
|
||||||
|
const distributorNode = getNodeByKind(graph, 'distributor');
|
||||||
|
const collectorNode = getNodeByKind(graph, 'collector');
|
||||||
|
const outputNode = getNodeByKind(graph, 'user-output');
|
||||||
|
if (!inputNode || !distributorNode || !collectorNode || !outputNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { incoming, outgoing } = buildAdjacency(graph);
|
||||||
|
const agentNodes = getAgentNodes(graph);
|
||||||
|
|
||||||
|
if (graph.edges.length !== pattern.agents.length * 2 + 2) {
|
||||||
|
pushGraphIssue(issues, 'Concurrent orchestration graphs must fan out from the distributor and fan back into the collector.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributorTargets = new Set((outgoing.get(distributorNode.id) ?? []).map((edge) => edge.target));
|
||||||
|
const collectorSources = new Set((incoming.get(collectorNode.id) ?? []).map((edge) => edge.source));
|
||||||
|
|
||||||
|
if ((incoming.get(inputNode.id)?.length ?? 0) !== 0 || (outgoing.get(inputNode.id)?.length ?? 0) !== 1) {
|
||||||
|
pushGraphIssue(issues, 'User input must connect only to the distributor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((incoming.get(distributorNode.id)?.length ?? 0) !== 1) {
|
||||||
|
pushGraphIssue(issues, 'Distributor must receive exactly one edge from user input.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((outgoing.get(collectorNode.id)?.length ?? 0) !== 1 || (incoming.get(outputNode.id)?.length ?? 0) !== 1) {
|
||||||
|
pushGraphIssue(issues, 'Collector must forward exactly one edge to user output.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentNode of agentNodes) {
|
||||||
|
if (!distributorTargets.has(agentNode.id)) {
|
||||||
|
pushGraphIssue(issues, `Distributor must connect to agent "${agentNode.agentId}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collectorSources.has(agentNode.id)) {
|
||||||
|
pushGraphIssue(issues, `Agent "${agentNode.agentId}" must connect to the collector.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHandoffGraph(
|
||||||
|
graph: PatternGraph,
|
||||||
|
issues: PatternValidationIssue[],
|
||||||
|
): void {
|
||||||
|
validateSystemNodeCounts(graph, ['user-input', 'user-output'], issues);
|
||||||
|
const inputNode = getNodeByKind(graph, 'user-input');
|
||||||
|
const outputNode = getNodeByKind(graph, 'user-output');
|
||||||
|
if (!inputNode || !outputNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { incoming, outgoing } = buildAdjacency(graph);
|
||||||
|
const agentNodes = getAgentNodes(graph);
|
||||||
|
const agentNodeIds = new Set(agentNodes.map((node) => node.id));
|
||||||
|
const entryEdges = outgoing.get(inputNode.id) ?? [];
|
||||||
|
const completionEdges = incoming.get(outputNode.id) ?? [];
|
||||||
|
|
||||||
|
if (entryEdges.length !== 1) {
|
||||||
|
pushGraphIssue(issues, 'Handoff graphs must connect user input to exactly one entry agent.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentNodeIds.has(entryEdges[0]!.target)) {
|
||||||
|
pushGraphIssue(issues, 'Handoff entry edges must target an agent node.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionEdges.length === 0) {
|
||||||
|
pushGraphIssue(issues, 'Handoff graphs must allow at least one agent to complete back to user output.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasAgentToAgentRoute = false;
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.source === inputNode.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge.target === outputNode.id) {
|
||||||
|
if (!agentNodeIds.has(edge.source)) {
|
||||||
|
pushGraphIssue(issues, 'Only agent nodes may complete to user output.');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentNodeIds.has(edge.source) || !agentNodeIds.has(edge.target)) {
|
||||||
|
pushGraphIssue(issues, 'Handoff routes may only connect agents to agents or agents to user output.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge.source === edge.target) {
|
||||||
|
pushGraphIssue(issues, 'Handoff routes cannot target the same agent node.');
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAgentToAgentRoute = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAgentToAgentRoute && agentNodes.length > 1) {
|
||||||
|
pushGraphIssue(issues, 'Handoff graphs must include at least one agent-to-agent handoff route.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachable = new Set<string>();
|
||||||
|
const stack = [entryEdges[0]!.target];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const nodeId = stack.pop()!;
|
||||||
|
if (reachable.has(nodeId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable.add(nodeId);
|
||||||
|
for (const edge of outgoing.get(nodeId) ?? []) {
|
||||||
|
if (agentNodeIds.has(edge.target) && !reachable.has(edge.target)) {
|
||||||
|
stack.push(edge.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentNode of agentNodes) {
|
||||||
|
if (!reachable.has(agentNode.id)) {
|
||||||
|
pushGraphIssue(issues, `Handoff entry agent must be able to reach "${agentNode.agentId}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((incoming.get(inputNode.id)?.length ?? 0) !== 0 || (outgoing.get(outputNode.id)?.length ?? 0) !== 0) {
|
||||||
|
pushGraphIssue(issues, 'User input cannot have incoming edges and user output cannot have outgoing edges.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGroupChatGraph(
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
graph: PatternGraph,
|
||||||
|
issues: PatternValidationIssue[],
|
||||||
|
): void {
|
||||||
|
validateSystemNodeCounts(graph, ['user-input', 'orchestrator', 'user-output'], issues);
|
||||||
|
const inputNode = getNodeByKind(graph, 'user-input');
|
||||||
|
const orchestratorNode = getNodeByKind(graph, 'orchestrator');
|
||||||
|
const outputNode = getNodeByKind(graph, 'user-output');
|
||||||
|
if (!inputNode || !orchestratorNode || !outputNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { incoming, outgoing } = buildAdjacency(graph);
|
||||||
|
const agentNodes = getAgentNodes(graph);
|
||||||
|
const orchestratorTargets = new Set((outgoing.get(orchestratorNode.id) ?? []).map((edge) => edge.target));
|
||||||
|
const orchestratorSources = new Set((incoming.get(orchestratorNode.id) ?? []).map((edge) => edge.source));
|
||||||
|
|
||||||
|
if (graph.edges.length !== pattern.agents.length * 2 + 2) {
|
||||||
|
pushGraphIssue(issues, 'Group chat graphs must connect the orchestrator to every participant and then back to user output.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((outgoing.get(inputNode.id) ?? []).some((edge) => edge.target !== orchestratorNode.id)) {
|
||||||
|
pushGraphIssue(issues, 'User input must only connect to the orchestrator.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(outgoing.get(orchestratorNode.id) ?? []).some((edge) => edge.target === outputNode.id)) {
|
||||||
|
pushGraphIssue(issues, 'Group chat orchestrator must connect to user output.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentNode of agentNodes) {
|
||||||
|
if (!orchestratorTargets.has(agentNode.id)) {
|
||||||
|
pushGraphIssue(issues, `Orchestrator must connect to agent "${agentNode.agentId}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orchestratorSources.has(agentNode.id)) {
|
||||||
|
pushGraphIssue(issues, `Agent "${agentNode.agentId}" must connect back to the orchestrator.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePatternGraph(
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
graph: PatternGraph,
|
||||||
|
issues: PatternValidationIssue[],
|
||||||
|
): void {
|
||||||
|
if (graph.nodes.length === 0) {
|
||||||
|
pushGraphIssue(issues, 'Pattern graph must include nodes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeIds = new Set<string>();
|
||||||
|
const edgeIds = new Set<string>();
|
||||||
|
const agentIds = new Set(pattern.agents.map((agent) => agent.id));
|
||||||
|
const seenAgentIds = new Set<string>();
|
||||||
|
const seenAgentOrders = new Set<number>();
|
||||||
|
const nodesById = new Map<string, PatternGraphNode>();
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (nodeIds.has(node.id)) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph contains duplicate node "${node.id}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeIds.add(node.id);
|
||||||
|
nodesById.set(node.id, node);
|
||||||
|
|
||||||
|
if (node.kind === 'agent') {
|
||||||
|
if (!node.agentId || !agentIds.has(node.agentId)) {
|
||||||
|
pushGraphIssue(issues, `Agent node "${node.id}" must reference a known agent.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.agentId) {
|
||||||
|
if (seenAgentIds.has(node.agentId)) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph contains multiple nodes for agent "${node.agentId}".`);
|
||||||
|
}
|
||||||
|
seenAgentIds.add(node.agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.order !== 'number' || !Number.isInteger(node.order)) {
|
||||||
|
pushGraphIssue(issues, `Agent node "${node.id}" must define an integer order.`);
|
||||||
|
} else if (seenAgentOrders.has(node.order)) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph contains duplicate agent order "${node.order}".`);
|
||||||
|
} else {
|
||||||
|
seenAgentOrders.add(node.order);
|
||||||
|
}
|
||||||
|
} else if (node.agentId) {
|
||||||
|
pushGraphIssue(issues, `System node "${node.id}" cannot reference an agent.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of pattern.agents) {
|
||||||
|
if (!seenAgentIds.has(agent.id)) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph is missing node metadata for agent "${agent.id}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edgeIds.has(edge.id)) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph contains duplicate edge "${edge.id}".`);
|
||||||
|
}
|
||||||
|
edgeIds.add(edge.id);
|
||||||
|
|
||||||
|
if (!nodesById.has(edge.source) || !nodesById.has(edge.target)) {
|
||||||
|
pushGraphIssue(issues, `Pattern graph edge "${edge.id}" must connect known nodes.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (pattern.mode) {
|
||||||
|
case 'single':
|
||||||
|
case 'sequential':
|
||||||
|
case 'magentic':
|
||||||
|
validateLinearGraph(pattern, graph, issues);
|
||||||
|
break;
|
||||||
|
case 'concurrent':
|
||||||
|
validateConcurrentGraph(pattern, graph, issues);
|
||||||
|
break;
|
||||||
|
case 'handoff':
|
||||||
|
validateHandoffGraph(graph, issues);
|
||||||
|
break;
|
||||||
|
case 'group-chat':
|
||||||
|
validateGroupChatGraph(pattern, graph, issues);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validatePatternDefinition(
|
export function validatePatternDefinition(
|
||||||
@@ -324,6 +968,8 @@ export function validatePatternDefinition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validatePatternGraph(pattern, resolvePatternGraph(pattern), issues);
|
||||||
|
|
||||||
for (const message of validateApprovalPolicy(
|
for (const message of validateApprovalPolicy(
|
||||||
normalizeApprovalPolicy(pattern.approvalPolicy),
|
normalizeApprovalPolicy(pattern.approvalPolicy),
|
||||||
pattern.agents.map((agent) => agent.id),
|
pattern.agents.map((agent) => agent.id),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { createBuiltinPatterns, validatePatternDefinition } from '@shared/domain/pattern';
|
import {
|
||||||
|
createBuiltinPatterns,
|
||||||
|
resolvePatternGraph,
|
||||||
|
syncPatternGraph,
|
||||||
|
validatePatternDefinition,
|
||||||
|
} from '@shared/domain/pattern';
|
||||||
|
|
||||||
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
|
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
|
||||||
|
|
||||||
@@ -145,4 +150,111 @@ describe('pattern validation', () => {
|
|||||||
'Approval auto-approve references unknown tool "unknown.tool".',
|
'Approval auto-approve references unknown tool "unknown.tool".',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builtin patterns seed graph topology for each orchestration mode', () => {
|
||||||
|
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||||
|
const single = patterns.find((pattern) => pattern.mode === 'single');
|
||||||
|
const concurrent = patterns.find((pattern) => pattern.mode === 'concurrent');
|
||||||
|
const handoff = patterns.find((pattern) => pattern.mode === 'handoff');
|
||||||
|
const groupChat = patterns.find((pattern) => pattern.mode === 'group-chat');
|
||||||
|
|
||||||
|
expect(single).toBeDefined();
|
||||||
|
expect(concurrent).toBeDefined();
|
||||||
|
expect(handoff).toBeDefined();
|
||||||
|
expect(groupChat).toBeDefined();
|
||||||
|
|
||||||
|
expect(resolvePatternGraph(single!).nodes.map((node) => node.kind)).toEqual([
|
||||||
|
'user-input',
|
||||||
|
'agent',
|
||||||
|
'user-output',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(resolvePatternGraph(concurrent!).nodes.map((node) => node.kind)).toEqual([
|
||||||
|
'user-input',
|
||||||
|
'distributor',
|
||||||
|
'agent',
|
||||||
|
'agent',
|
||||||
|
'agent',
|
||||||
|
'collector',
|
||||||
|
'user-output',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(resolvePatternGraph(handoff!).edges).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: 'system-user-input',
|
||||||
|
target: 'agent-node-agent-handoff-triage',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(resolvePatternGraph(handoff!).edges).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: 'agent-node-agent-handoff-triage',
|
||||||
|
target: 'agent-node-agent-handoff-ux',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolvePatternGraph(groupChat!).nodes.map((node) => node.kind)).toContain('orchestrator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncPatternGraph rebuilds sequential topology from the current agent list', () => {
|
||||||
|
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||||
|
(pattern) => pattern.mode === 'sequential',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sequential).toBeDefined();
|
||||||
|
|
||||||
|
const updated = syncPatternGraph({
|
||||||
|
...sequential!,
|
||||||
|
agents: [
|
||||||
|
...sequential!.agents,
|
||||||
|
{
|
||||||
|
id: 'agent-sequential-final',
|
||||||
|
name: 'Final Reviewer',
|
||||||
|
description: 'Adds a final pass.',
|
||||||
|
instructions: 'Do a last review.',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = resolvePatternGraph(updated);
|
||||||
|
expect(graph.nodes.filter((node) => node.kind === 'agent')).toHaveLength(4);
|
||||||
|
expect(graph.edges).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: 'agent-node-agent-sequential-reviewer',
|
||||||
|
target: 'agent-node-agent-sequential-final',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(graph.edges).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: 'agent-node-agent-sequential-final',
|
||||||
|
target: 'system-user-output',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('graph validation rejects branched sequential topology', () => {
|
||||||
|
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||||
|
(pattern) => pattern.mode === 'sequential',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sequential).toBeDefined();
|
||||||
|
|
||||||
|
const issues = validatePatternDefinition({
|
||||||
|
...sequential!,
|
||||||
|
graph: {
|
||||||
|
...resolvePatternGraph(sequential!),
|
||||||
|
edges: [
|
||||||
|
...resolvePatternGraph(sequential!).edges,
|
||||||
|
{
|
||||||
|
id: 'edge-system-user-input-to-agent-node-agent-sequential-builder-duplicate',
|
||||||
|
source: 'system-user-input',
|
||||||
|
target: 'agent-node-agent-sequential-builder',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issues.find((issue) => issue.field === 'graph')?.message).toContain('single path');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user