diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bfe307b..d1b6aaa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 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 A session is the working unit of the product. It binds together: diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index 40f33fe..da8fd16 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -12,6 +12,34 @@ public sealed class PatternAgentDefinitionDto 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 Nodes { get; init; } = []; + public IReadOnlyList Edges { get; init; } = []; +} + public sealed class PatternDefinitionDto { public string Id { get; init; } = string.Empty; @@ -23,6 +51,7 @@ public sealed class PatternDefinitionDto public int MaxIterations { get; init; } public ApprovalPolicyDto? ApprovalPolicy { get; init; } public IReadOnlyList Agents { get; init; } = []; + public PatternGraphDto? Graph { get; init; } public string CreatedAt { get; init; } = string.Empty; public string UpdatedAt { get; init; } = string.Empty; } diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs index ae29079..c6c3117 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs @@ -94,9 +94,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable { return pattern.Mode switch { - "single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents), - "sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents), - "concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, Agents), + "single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)), + "sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)), + "concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)), "handoff" => BuildHandoffWorkflow(pattern), "group-chat" => BuildGroupChatWorkflow(pattern), "magentic" => throw new NotSupportedException( @@ -116,30 +116,30 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern) { - AIAgent firstAgent = Agents[0]; - PatternAgentDefinitionDto triageDefinition = pattern.Agents[0]; - IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists = - Agents.Skip(1) - .Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition)) - .ToList(); + Dictionary agentMap = BuildAgentMap(pattern); + Dictionary definitionMap = pattern.Agents.ToDictionary( + definition => definition.Id, + definition => definition, + StringComparer.Ordinal); + 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()); - foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists) + foreach (PatternHandoffRoute route in topology.Routes) { - builder = builder.WithHandoff( - firstAgent, - specialist, - HandoffWorkflowGuidance.CreateForwardReason(definition)); - } + if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent) + || !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent) + || !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition)) + { + continue; + } - foreach ((AIAgent specialist, _) in specialists) - { builder = builder.WithHandoff( - specialist, - firstAgent, - HandoffWorkflowGuidance.CreateReturnReason(triageDefinition)); + sourceAgent, + targetAgent, + HandoffWorkflowGuidance.CreateForwardReason(targetDefinition)); } return builder.Build(); @@ -155,7 +155,30 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable { MaximumIterationCount = maximumIterations, }) - .AddParticipants(Agents.ToArray()) + .AddParticipants(ResolveOrderedAgents(pattern).ToArray()) .Build(); } + + private IReadOnlyList ResolveOrderedAgents(PatternDefinitionDto pattern) + { + Dictionary agentMap = BuildAgentMap(pattern); + List orderedAgents = PatternGraphResolver.ResolveOrderedAgentIds(pattern) + .Select(agentId => agentMap.TryGetValue(agentId, out AIAgent? agent) ? agent : null) + .Where(agent => agent is not null) + .Cast() + .ToList(); + + return orderedAgents.Count == Agents.Count ? orderedAgents : Agents; + } + + private Dictionary BuildAgentMap(PatternDefinitionDto pattern) + { + Dictionary agentMap = new(StringComparer.Ordinal); + foreach ((PatternAgentDefinitionDto definition, AIAgent agent) in pattern.Agents.Zip(Agents)) + { + agentMap[definition.Id] = agent; + } + + return agentMap; + } } diff --git a/sidecar/src/Eryx.AgentHost/Services/PatternGraphResolver.cs b/sidecar/src/Eryx.AgentHost/Services/PatternGraphResolver.cs new file mode 100644 index 0000000..eb8cb42 --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/PatternGraphResolver.cs @@ -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 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 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 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 nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node); + Dictionary> outgoing = BuildOutgoingLookup(graph); + List orderedAgentIds = []; + HashSet visitedNodeIds = []; + string currentNodeId = inputNode.Id; + + while (visitedNodeIds.Add(currentNodeId)) + { + if (!outgoing.TryGetValue(currentNodeId, out List? 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 ResolveAgentOrder(PatternDefinitionDto pattern, PatternGraphDto graph) + { + Dictionary fallbackOrder = pattern.Agents + .Select((agent, index) => new { agent.Id, Index = index }) + .ToDictionary(item => item.Id, item => item.Index); + + List 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 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 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> BuildOutgoingLookup(PatternGraphDto graph) + { + Dictionary> 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? 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 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 agentNodes = agents + .Select((agent, index) => CreateAgentNode(agent, index, 220 * (index + 1), 0)) + .ToList(); + List edges = []; + List 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 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 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 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 specialistNodes = agents + .Skip(1) + .Select((agent, index) => CreateAgentNode(agent, index + 1, 540, SpreadY(index, Math.Max(agents.Count - 1, 1), 220))) + .ToList(); + + List 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 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 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 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; +} diff --git a/sidecar/src/Eryx.AgentHost/Services/PatternValidator.cs b/sidecar/src/Eryx.AgentHost/Services/PatternValidator.cs index 8e59f92..6ceb1e0 100644 --- a/sidecar/src/Eryx.AgentHost/Services/PatternValidator.cs +++ b/sidecar/src/Eryx.AgentHost/Services/PatternValidator.cs @@ -4,6 +4,8 @@ namespace Eryx.AgentHost.Services; public sealed class PatternValidator { + private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase; + public IReadOnlyList Validate(PatternDefinitionDto pattern) { List issues = []; @@ -93,6 +95,479 @@ public sealed class PatternValidator } } + ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), issues); return issues; } + + private static void ValidateGraph( + PatternDefinitionDto pattern, + PatternGraphDto graph, + List issues) + { + if (graph.Nodes.Count == 0) + { + AddGraphIssue(issues, "Pattern graph must include nodes."); + return; + } + + HashSet nodeIds = new(StringComparer.Ordinal); + HashSet edgeIds = new(StringComparer.Ordinal); + HashSet agentIds = pattern.Agents.Select(agent => agent.Id).ToHashSet(StringComparer.Ordinal); + HashSet seenAgentIds = new(StringComparer.Ordinal); + HashSet seenAgentOrders = []; + Dictionary 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 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> incoming = BuildIncomingLookup(graph); + Dictionary> outgoing = BuildOutgoingLookup(graph); + List 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 visited = new(StringComparer.Ordinal); + string currentNodeId = inputNode.Id; + while (visited.Add(currentNodeId)) + { + List 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 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 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> incoming = BuildIncomingLookup(graph); + Dictionary> outgoing = BuildOutgoingLookup(graph); + List agentNodes = GetAgentNodes(graph); + HashSet distributorTargets = outgoing.GetValueOrDefault(distributorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal); + HashSet 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 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> incoming = BuildIncomingLookup(graph); + Dictionary> outgoing = BuildOutgoingLookup(graph); + List agentNodes = GetAgentNodes(graph); + HashSet agentNodeIds = agentNodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal); + List entryEdges = outgoing.GetValueOrDefault(inputNode.Id, []); + List 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 reachable = new(StringComparer.Ordinal); + Stack stack = new Stack([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 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> incoming = BuildIncomingLookup(graph); + Dictionary> outgoing = BuildOutgoingLookup(graph); + List agentNodes = GetAgentNodes(graph); + HashSet orchestratorTargets = outgoing.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal); + HashSet 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 expectedKinds, + List issues) + { + Dictionary counts = graph.Nodes + .GroupBy(node => node.Kind, Comparer) + .ToDictionary(group => group.Key, group => group.Count(), Comparer); + HashSet 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 GetAgentNodes(PatternGraphDto graph) + => graph.Nodes.Where(node => Comparer.Equals(node.Kind, "agent")).ToList(); + + private static Dictionary> BuildIncomingLookup(PatternGraphDto graph) + { + Dictionary> 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? edges)) + { + edges = []; + incoming[edge.Target] = edges; + } + + edges.Add(edge); + } + + return incoming; + } + + private static Dictionary> BuildOutgoingLookup(PatternGraphDto graph) + { + Dictionary> 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? edges)) + { + edges = []; + outgoing[edge.Source] = edges; + } + + edges.Add(edge); + } + + return outgoing; + } + + private static void AddGraphIssue(List issues, string message) + => issues.Add(new PatternValidationIssueDto + { + Field = "graph", + Message = message, + }); } diff --git a/sidecar/tests/Eryx.AgentHost.Tests/PatternGraphResolverTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/PatternGraphResolverTests.cs new file mode 100644 index 0000000..84b5963 --- /dev/null +++ b/sidecar/tests/Eryx.AgentHost.Tests/PatternGraphResolverTests.cs @@ -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 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 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, + }; +} diff --git a/sidecar/tests/Eryx.AgentHost.Tests/UnitTest1.cs b/sidecar/tests/Eryx.AgentHost.Tests/UnitTest1.cs index c6c36a6..4ddc4b7 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/UnitTest1.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/UnitTest1.cs @@ -73,12 +73,46 @@ public sealed class PatternValidatorTests && issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void SequentialPattern_WithBranchedGraph_IsReportedAsInvalid() + { + IReadOnlyList 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( string mode, IReadOnlyList agents, string availability = "available", string? unavailabilityReason = null, - string name = "Pattern") + string name = "Pattern", + PatternGraphDto? graph = null) { return new PatternDefinitionDto { @@ -88,6 +122,7 @@ public sealed class PatternValidatorTests Availability = availability, UnavailabilityReason = unavailabilityReason, Agents = agents, + Graph = graph, }; } @@ -105,4 +140,30 @@ public sealed class PatternValidatorTests 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, + }; } diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 8bf3ef1..0f58ec5 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -20,6 +20,7 @@ import { } from '@shared/domain/models'; import { isReasoningEffort, + syncPatternGraph, type PatternDefinition, type ReasoningEffort, validatePatternDefinition, @@ -212,8 +213,9 @@ export class EryxAppService extends EventEmitter { async savePattern(pattern: PatternDefinition): Promise { const workspace = await this.loadWorkspace(); const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace); + const synchronizedPattern = syncPatternGraph(pattern); const issues = validatePatternDefinition( - pattern, + synchronizedPattern, knownApprovalToolNames, ).filter((issue) => issue.level === 'error'); if (issues.length > 0) { @@ -222,8 +224,8 @@ export class EryxAppService extends EventEmitter { const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id); const candidate: PatternDefinition = { - ...pattern, - approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy), + ...synchronizedPattern, + approvalPolicy: normalizeApprovalPolicy(synchronizedPattern.approvalPolicy), isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite, createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(), updatedAt: nowIso(), diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 1034a5d..affe969 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -1,6 +1,6 @@ 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 { mergeScratchpadProject } from '@shared/domain/project'; import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; @@ -71,6 +71,7 @@ export class WorkspaceRepository { patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({ ...pattern, approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy), + graph: resolvePatternGraph(pattern), })), projects, sessions: (stored.sessions ?? []).map((session) => ({ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6b0bf29..7ca18df 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -21,7 +21,7 @@ import { normalizePatternModels, resolveReasoningEffort, } 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 { applyScratchpadSessionConfig } from '@shared/domain/session'; 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 { const timestamp = nowIso(); - return { + return syncPatternGraph({ id: createId('custom-pattern'), name: 'New Pattern', description: '', @@ -49,7 +49,7 @@ function createDraftPattern(defaultModelId: string, defaultReasoningEffort: Patt ], createdAt: timestamp, updatedAt: timestamp, - }; + }); } function createDraftMcpServer(): McpServerDefinition { diff --git a/src/renderer/components/PatternEditor.tsx b/src/renderer/components/PatternEditor.tsx index 5dfbb96..f91ec27 100644 --- a/src/renderer/components/PatternEditor.tsx +++ b/src/renderer/components/PatternEditor.tsx @@ -24,6 +24,7 @@ import { type ModelDefinition, } from '@shared/domain/models'; import { + syncPatternGraph, validatePatternDefinition, type OrchestrationMode, type PatternDefinition, @@ -220,8 +221,12 @@ export function PatternEditor({ }: PatternEditorProps) { const issues = validatePatternDefinition(pattern); + function emitChange(nextPattern: PatternDefinition) { + onChange(syncPatternGraph(nextPattern)); + } + function updateAgent(agentId: string, patch: Partial) { - onChange({ + emitChange({ ...pattern, 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) { - onChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) }); + emitChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) }); } function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean { @@ -358,14 +363,14 @@ export function PatternEditor({ onChange({ ...pattern, name: v })} + onChange={(v) => emitChange({ ...pattern, name: v })} placeholder="Pattern name" value={pattern.name} /> onChange({ ...pattern, description: v })} + onChange={(v) => emitChange({ ...pattern, description: v })} placeholder="What this pattern does..." value={pattern.description} /> @@ -394,7 +399,7 @@ export function PatternEditor({ }`} disabled={disabled} key={mode} - onClick={() => onChange({ ...pattern, mode })} + onClick={() => emitChange({ ...pattern, mode })} type="button" >
@@ -430,7 +435,7 @@ export function PatternEditor({