mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b8dd0c12 | ||
|
|
7a7b3fcdca | ||
|
|
794794afe4 | ||
|
|
9f85cce418 | ||
|
|
33773c868f | ||
|
|
507bd5408c | ||
|
|
7e37d75116 | ||
|
|
fd9cc03c5c | ||
|
|
8797186a73 | ||
|
|
573efff647 | ||
|
|
b583f39a38 | ||
|
|
3a639813a9 | ||
|
|
0a5fa81111 | ||
|
|
778c3b4a5b | ||
|
|
366200b29d | ||
|
|
cbc05c8f08 | ||
|
|
ecba1be92a | ||
|
|
3a71baf104 | ||
|
|
a3e0bd9244 | ||
|
|
d265db42e5 | ||
|
|
ad2eab73d7 | ||
|
|
e79cbe8df9 | ||
|
|
1ac1cc1d47 | ||
|
|
bf72315735 | ||
|
|
59170a794d | ||
|
|
3564df78d3 | ||
|
|
a68c9ee837 | ||
|
|
5ac6980b3f | ||
|
|
f4cb4c768b | ||
|
|
e265714225 | ||
|
|
805e369b67 | ||
|
|
69d0804161 | ||
|
|
059714326a | ||
|
|
b302ea5979 | ||
|
|
81eb8f7c82 | ||
|
|
e47835c1e8 | ||
|
|
55df0c3b61 | ||
|
|
4971dcf9fc | ||
|
|
f05ec8ac7f | ||
|
|
69ac454f29 | ||
|
|
7a32c9c0a3 | ||
|
|
871609db5b | ||
|
|
41e74c2fa9 | ||
|
|
ea9444ddac | ||
|
|
8f6830dca1 | ||
|
|
f011311514 | ||
|
|
19a764d297 | ||
|
|
4c3198a550 | ||
|
|
c0aaffa39f | ||
|
|
e39fffaf3b | ||
|
|
10316a2871 | ||
|
|
a18803758b | ||
|
|
6eadb36c10 | ||
|
|
bcbdd2ef29 | ||
|
|
5d69d9d855 | ||
|
|
aa7830f01a | ||
|
|
36b37e8915 |
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install Linux native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install Linux native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
+26
-23
@@ -2,7 +2,7 @@
|
||||
|
||||
## What this system is
|
||||
|
||||
Aryx is a desktop workspace for Copilot-powered development work. It combines a persistent session model, project-aware context, reusable multi-agent orchestration patterns, optional external tooling, and live run visibility inside a single Electron application.
|
||||
Aryx is a desktop workspace for Copilot-powered development work. It combines a persistent session model, project-aware context, reusable workflow orchestration, optional external tooling, and live run visibility inside a single Electron application.
|
||||
|
||||
At a high level, the architecture is built around one core idea:
|
||||
|
||||
@@ -23,7 +23,7 @@ The current architecture optimizes for:
|
||||
- **persistent workspaces** rather than disposable chat threads
|
||||
- **project-aware execution** with repository context and optional tooling
|
||||
- **observable AI runs** with streamed output, activity, and history
|
||||
- **extensible orchestration** so patterns, models, and tool integrations can evolve without collapsing boundaries
|
||||
- **extensible orchestration** so workflows, models, and tool integrations can evolve without collapsing boundaries
|
||||
|
||||
## System context
|
||||
|
||||
@@ -56,7 +56,7 @@ flowchart LR
|
||||
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
|
||||
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
|
||||
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
||||
| Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
|
||||
| Sidecar | Capability discovery, workflow validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
|
||||
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
||||
|
||||
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
|
||||
@@ -108,7 +108,8 @@ This flow is important because it shows that Aryx is not architected as a simple
|
||||
The durable state of the app is a **workspace**. The workspace contains:
|
||||
|
||||
- connected projects
|
||||
- orchestration patterns
|
||||
- workflows
|
||||
- workflow templates
|
||||
- sessions
|
||||
- settings
|
||||
- run history
|
||||
@@ -124,38 +125,38 @@ Projects are the container for context. There are two kinds:
|
||||
|
||||
The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations.
|
||||
|
||||
Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `.github/agents/*.agent.md`, and `.github/prompts/*.prompt.md`. The main process owns that scan step and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler.
|
||||
Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`, recursive `.github/instructions/**/*.instructions.md`, recursive `.claude/rules/**/*.md`, recursive `.github/agents/**/*.agent.md`, and recursive `.github/prompts/**/*.prompt.md`. The main process owns that scan step, walks parent directories up to the nearest `.git` root so nested workspace folders inherit repository-level customizations, strips Markdown front matter where supported, expands relative Markdown links into referenced file context, classifies instruction files by application mode (`always`, `file`, `task`, `manual`), and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. It also maintains lightweight filesystem watches over each discovered customization root plus existing `.github/` and `.claude/` subdirectories so customization changes can trigger debounced rescans and renderer updates without manual refreshes.
|
||||
|
||||
For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own.
|
||||
|
||||
### Patterns
|
||||
### Workflows
|
||||
|
||||
Patterns describe how agents collaborate. The architecture supports:
|
||||
Workflows describe how agents collaborate. The architecture supports:
|
||||
|
||||
- one-agent conversations
|
||||
- sequential workflows
|
||||
- concurrent responses
|
||||
- handoff flows
|
||||
- group chat style collaboration
|
||||
- sequential execution
|
||||
- concurrent fan-out / fan-in flows
|
||||
- handoff-style routing
|
||||
- group-chat style collaboration
|
||||
|
||||
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
|
||||
|
||||
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so handoff routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
|
||||
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so workflow agent routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
|
||||
|
||||
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
|
||||
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
|
||||
|
||||
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.
|
||||
Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure 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.
|
||||
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time.
|
||||
|
||||
The pattern editor renders an interactive graph canvas powered by React Flow (`@xyflow/react`). The canvas projects the authoritative `PatternGraph` into React Flow nodes and edges via a view-model layer (`src/renderer/lib/patternGraph.ts`). Users can drag nodes to reposition them, and in handoff mode can draw new agent-to-agent edges directly on the canvas. A right-side inspector panel shows the details of the selected node — system node metadata for system nodes, or the full agent configuration form (model, reasoning, instructions) for agent nodes. The mode selector, pattern metadata, approval checkpoints, and tool auto-approval settings remain below the graph as scrollable settings sections. The `syncPatternGraph()` adapter is still called when agents are added/removed or the mode changes, rebuilding the graph from the current state; direct graph edits (drag positions, handoff edges) are persisted without the adapter.
|
||||
Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol.
|
||||
|
||||
### Sessions
|
||||
|
||||
A session is the working unit of the product. It binds together:
|
||||
|
||||
- a project
|
||||
- a pattern
|
||||
- a workflow
|
||||
- a message history
|
||||
- status and errors
|
||||
- optional per-session tool selection
|
||||
@@ -176,7 +177,7 @@ Each user turn becomes a **run**. A run is more than the final assistant output;
|
||||
- success or failure
|
||||
- optional git baselines and post-run change summaries for project-backed execution
|
||||
|
||||
That run model is what enables the activity panel and historical timeline instead of forcing the user to infer execution from message text alone.
|
||||
That run model is what enables the inline turn activity panel instead of forcing the user to infer execution from message text alone.
|
||||
|
||||
## Communication model
|
||||
|
||||
@@ -190,6 +191,8 @@ Typical examples:
|
||||
|
||||
- load workspace
|
||||
- create session
|
||||
- create a workflow from a template
|
||||
- export or import a workflow definition
|
||||
- send message
|
||||
- update theme
|
||||
- create or restart the integrated terminal
|
||||
@@ -206,7 +209,7 @@ This is a structured stdio protocol used for:
|
||||
|
||||
- capability discovery
|
||||
- on-demand account quota lookup
|
||||
- pattern validation
|
||||
- workflow validation
|
||||
- run execution
|
||||
- streaming partial output
|
||||
- streaming agent activity
|
||||
@@ -235,7 +238,7 @@ The same boundary also supports server-scoped sidecar commands that do not requi
|
||||
|
||||
For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy.
|
||||
|
||||
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from repo-level instruction files and merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. The sidecar then folds those project instructions into the final SDK system message alongside the agent's own instructions and runtime guidance.
|
||||
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from always-on repo instructions plus formatted file-scoped and task-scoped `.instructions.md` / `.claude/rules` entries, while omitting manual-only instruction files from automatic injection. It also merges enabled discovered custom agent profiles into the primary workflow agent's Copilot configuration before sending the command across the stdio boundary. Prompt-file submissions can additionally attach a structured `promptInvocation` payload with prompt identity, resolved prompt body, optional `agent`, optional `model`, and optional `tools` metadata. The main process stores that prompt invocation metadata on the triggering user message so replay and regenerate flows can rebuild it, hydrates missing metadata from the scanned prompt definition, promotes `agent: plan` to a per-turn plan-mode override, applies per-turn prompt model overrides to the effective workflow before execution, and falls back to a lightweight transcript message instead of pasting the full prompt body into chat history. The sidecar then folds both the project instructions and prompt invocation into the final SDK system message, uses prompt agent metadata to override `SessionConfig.Agent`, and narrows available tools for that turn when prompt `tools` metadata is present.
|
||||
|
||||
For handoff workflows, the sidecar now also enables Agent Framework JSON checkpointing backed by a per-turn filesystem store under local app data. Each saved checkpoint is surfaced to the main process, which pairs the durable Agent Framework checkpoint with an in-memory rollback snapshot of `session.messages` and the active run timeline events. If the sidecar child process exits unexpectedly during the same app lifetime, Aryx restores the latest snapshot, clears pending approval/user-input/MCP-auth state for that run, and retries the `run-turn` request once with `resumeFromCheckpoint`. Checkpoint directories are deleted after the turn completes, cancels, or fails. This recovery path is intentionally scoped to same-app sidecar restarts; full app-restart workflow rehydration would require durable rollback snapshots in addition to the Agent Framework checkpoint payloads.
|
||||
|
||||
@@ -297,7 +300,7 @@ Tooling is deliberately split into two levels:
|
||||
- **global definitions** for MCP servers and LSP profiles
|
||||
- **MCP tool discovery** — when MCP server configs declare wildcard tools (empty `tools` array), the main process probes each server directly via the MCP protocol `tools/list` method to discover available tools, using the same auth credentials Aryx manages for OAuth-protected servers
|
||||
- **incremental probe progress** — MCP probing runs concurrently and publishes per-server progress through the pushed workspace snapshot, using the runtime-only `mcpProbingServerIds` field so the renderer can reflect in-flight discovery without persisting transient UI state
|
||||
- **pattern defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
|
||||
- **workflow defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
|
||||
- **per-session overrides** for both tool enablement and tool auto-approval
|
||||
|
||||
This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety.
|
||||
@@ -306,7 +309,7 @@ This lets the application treat tooling as reusable workspace capability while s
|
||||
|
||||
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
|
||||
|
||||
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
|
||||
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the turn activity panel after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
|
||||
|
||||
### Execution observability
|
||||
|
||||
@@ -315,7 +318,7 @@ The architecture treats execution as observable by design:
|
||||
- partial output is streamed
|
||||
- agent activity is surfaced
|
||||
- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed
|
||||
- runs are persisted as timeline history
|
||||
- runs are surfaced inline as collapsible turn activity panels
|
||||
- failures are represented explicitly
|
||||
|
||||
This improves trust and debuggability, especially for multi-agent workflows.
|
||||
|
||||
@@ -18,7 +18,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-agent orchestration** — single, sequential, concurrent, handoff, and group-chat patterns with a visual graph editor.
|
||||
- **Multi-agent orchestration** — single, sequential, concurrent, handoff, and group-chat patterns with a visual graph editor, reusable workflow templates, and workflow import/export.
|
||||
- **Project-grounded** — attach local folders and repos so every conversation has real codebase context.
|
||||
- **Live execution visibility** — watch agents think, delegate, call tools, and consume context in real time.
|
||||
- **Persistent workspace** — sessions survive restarts. Search, pin, archive, branch, and return to past work.
|
||||
@@ -53,11 +53,13 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Orchestration patterns | Single, sequential, concurrent, handoff, and group-chat agent flows |
|
||||
| Workflow templates | Save workflows as reusable templates, bootstrap from built-ins, and upgrade patterns into workflows |
|
||||
| Workflow import/export | Export workflows as YAML, Mermaid, or DOT and import normalized definitions from YAML or JSON |
|
||||
| Visual pattern editor | Drag nodes, draw connections, and inspect each step in a graph view |
|
||||
| Mid-turn steering | Send follow-up messages while an agent is running — input is injected immediately |
|
||||
| Plan review & questions | Agents propose plans and ask clarifying questions before acting |
|
||||
| Run timeline | Structured history of tool calls, delegations, hooks, and context usage |
|
||||
| Copilot customization | Auto-discovers instructions, agent profiles, and prompt files from your repo |
|
||||
| Copilot customization | Auto-discovers repo instructions (`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.instructions.md`, `.claude/rules`), agent profiles, and prompt files across nested repo roots, expands Markdown-linked context, exposes prompt metadata (`tools`, `model`, `argument-hint`), then auto-rescans when those files change |
|
||||
| Model & effort tuning | Choose models, adjust reasoning effort, and set interaction modes per session |
|
||||
|
||||
### Developer tooling
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aryx",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.23",
|
||||
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
public sealed class PatternAgentDefinitionDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string Instructions { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public string? ReasoningEffort { get; init; }
|
||||
public PatternAgentCopilotConfigDto? Copilot { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternAgentCopilotConfigDto
|
||||
public sealed class WorkflowAgentCopilotConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
|
||||
public string? Agent { get; init; }
|
||||
@@ -22,46 +12,145 @@ public sealed class PatternAgentCopilotConfigDto
|
||||
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphPositionDto
|
||||
public sealed class WorkflowPositionDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphNodeDto
|
||||
public sealed class WorkflowNodeConfigDto
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public string? InputType { get; init; }
|
||||
public string? OutputType { get; init; }
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string Instructions { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public string? ReasoningEffort { get; init; }
|
||||
public WorkflowAgentCopilotConfigDto? Copilot { get; init; }
|
||||
public string? WorkspaceAgentId { get; init; }
|
||||
public string? Implementation { get; init; }
|
||||
public string? FunctionRef { get; init; }
|
||||
public IReadOnlyDictionary<string, JsonElement>? Parameters { get; init; }
|
||||
public string? WorkflowId { get; init; }
|
||||
public WorkflowDefinitionDto? InlineWorkflow { get; init; }
|
||||
public string? PortId { get; init; }
|
||||
public string? RequestType { get; init; }
|
||||
public string? ResponseType { get; init; }
|
||||
public string? Prompt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowNodeDto
|
||||
{
|
||||
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 string Label { get; init; } = string.Empty;
|
||||
public WorkflowPositionDto Position { get; init; } = new();
|
||||
public int? Order { get; init; }
|
||||
public WorkflowNodeConfigDto Config { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class PatternGraphEdgeDto
|
||||
public sealed class WorkflowConditionRuleDto
|
||||
{
|
||||
public string PropertyPath { get; init; } = string.Empty;
|
||||
public string Operator { get; init; } = string.Empty;
|
||||
public string Value { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class EdgeConditionDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? TypeName { get; init; }
|
||||
public string? Expression { get; init; }
|
||||
public string? Combinator { get; init; }
|
||||
public IReadOnlyList<WorkflowConditionRuleDto> Rules { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class FanOutConfigDto
|
||||
{
|
||||
public string Strategy { get; init; } = "broadcast";
|
||||
public string? PartitionExpression { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowEdgeDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string Target { get; init; } = string.Empty;
|
||||
public string Kind { get; init; } = "direct";
|
||||
public EdgeConditionDto? Condition { get; init; }
|
||||
public string? Label { get; init; }
|
||||
public FanOutConfigDto? FanOutConfig { get; init; }
|
||||
public bool? IsLoop { get; init; }
|
||||
public int? MaxIterations { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphDto
|
||||
public sealed class WorkflowGraphDto
|
||||
{
|
||||
public IReadOnlyList<PatternGraphNodeDto> Nodes { get; init; } = [];
|
||||
public IReadOnlyList<PatternGraphEdgeDto> Edges { get; init; } = [];
|
||||
public IReadOnlyList<WorkflowNodeDto> Nodes { get; init; } = [];
|
||||
public IReadOnlyList<WorkflowEdgeDto> Edges { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PatternDefinitionDto
|
||||
public sealed class WorkflowCheckpointSettingsDto
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowTelemetrySettingsDto
|
||||
{
|
||||
public bool? OpenTelemetry { get; init; }
|
||||
public bool? SensitiveData { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowStateScopeDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyDictionary<string, JsonElement>? InitialValues { get; init; }
|
||||
}
|
||||
|
||||
public sealed class HandoffModeSettingsDto
|
||||
{
|
||||
public string ToolCallFiltering { get; init; } = "handoff-only";
|
||||
public bool ReturnToPrevious { get; init; }
|
||||
public string? HandoffInstructions { get; init; }
|
||||
public string? TriageAgentNodeId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GroupChatModeSettingsDto
|
||||
{
|
||||
public string SelectionStrategy { get; init; } = "round-robin";
|
||||
public int MaxRounds { get; init; } = 5;
|
||||
}
|
||||
|
||||
public sealed class OrchestrationModeSettingsDto
|
||||
{
|
||||
public HandoffModeSettingsDto? Handoff { get; init; }
|
||||
public GroupChatModeSettingsDto? GroupChat { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowSettingsDto
|
||||
{
|
||||
public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new();
|
||||
public string ExecutionMode { get; init; } = "off-thread";
|
||||
public string? OrchestrationMode { get; init; }
|
||||
public OrchestrationModeSettingsDto? ModeSettings { get; init; }
|
||||
public int? MaxIterations { get; init; }
|
||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
||||
public IReadOnlyList<WorkflowStateScopeDto> StateScopes { get; init; } = [];
|
||||
public WorkflowTelemetrySettingsDto? Telemetry { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowDefinitionDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
public string Availability { get; init; } = "available";
|
||||
public string? UnavailabilityReason { get; init; }
|
||||
public int MaxIterations { get; init; }
|
||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
||||
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
||||
public PatternGraphDto? Graph { get; init; }
|
||||
public bool? IsFavorite { get; init; }
|
||||
public WorkflowGraphDto Graph { get; init; } = new();
|
||||
public WorkflowSettingsDto Settings { get; init; } = new();
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public string UpdatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -98,11 +187,13 @@ public sealed class ChatMessageAttachmentDto
|
||||
public string? DisplayName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternValidationIssueDto
|
||||
public sealed class WorkflowValidationIssueDto
|
||||
{
|
||||
public string Level { get; init; } = "error";
|
||||
public string? Field { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string? NodeId { get; init; }
|
||||
public string? EdgeId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarModeCapabilityDto
|
||||
@@ -172,9 +263,10 @@ public class SidecarCommandEnvelope
|
||||
|
||||
public sealed class DescribeCapabilitiesCommandDto : SidecarCommandEnvelope;
|
||||
|
||||
public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
|
||||
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
@@ -185,12 +277,26 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string Mode { get; init; } = "interactive";
|
||||
public string MessageMode { get; init; } = "enqueue";
|
||||
public string? ProjectInstructions { get; init; }
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
public WorkflowCheckpointResumeDto? ResumeFromCheckpoint { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RunTurnPromptInvocationDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string SourcePath { get; init; } = string.Empty;
|
||||
public string ResolvedPrompt { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public string? Agent { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string TargetRequestId { get; init; } = string.Empty;
|
||||
@@ -312,9 +418,9 @@ public sealed class CapabilitiesEventDto : SidecarEventDto
|
||||
public SidecarCapabilitiesDto Capabilities { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class PatternValidationEventDto : SidecarEventDto
|
||||
public sealed class WorkflowValidationEventDto : SidecarEventDto
|
||||
{
|
||||
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class TurnDeltaEventDto : SidecarEventDto
|
||||
@@ -350,6 +456,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
||||
public string? SourceAgentName { get; init; }
|
||||
public string? ToolName { get; init; }
|
||||
public string? ToolCallId { get; init; }
|
||||
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
|
||||
public IReadOnlyList<ToolCallFileChangeDto>? FileChanges { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ internal static class AgentIdentityResolver
|
||||
private const string GenericAssistantIdentifier = "assistant";
|
||||
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier);
|
||||
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier)
|
||||
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier);
|
||||
if (match is null)
|
||||
{
|
||||
return false;
|
||||
@@ -28,12 +28,12 @@ internal static class AgentIdentityResolver
|
||||
}
|
||||
|
||||
public static bool TryResolveObservedAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent))
|
||||
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -49,13 +49,13 @@ internal static class AgentIdentityResolver
|
||||
}
|
||||
|
||||
public static AgentIdentity ResolveAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentId,
|
||||
string? agentName)
|
||||
{
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId)
|
||||
?? FindKnownAgent(pattern, agentName)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName);
|
||||
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId)
|
||||
?? FindKnownAgent(workflow, agentName)
|
||||
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName);
|
||||
|
||||
return match is not null
|
||||
? ToAgentIdentity(match)
|
||||
@@ -63,16 +63,16 @@ internal static class AgentIdentityResolver
|
||||
}
|
||||
|
||||
public static string ResolveDisplayAuthorName(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? primaryIdentifier,
|
||||
string? fallbackIdentifier = null)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, primaryIdentifier, out AgentIdentity primaryAgent))
|
||||
if (TryResolveKnownAgentIdentity(workflow, primaryIdentifier, out AgentIdentity primaryAgent))
|
||||
{
|
||||
return primaryAgent.AgentName;
|
||||
}
|
||||
|
||||
if (TryResolveKnownAgentIdentity(pattern, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
||||
if (TryResolveKnownAgentIdentity(workflow, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
||||
{
|
||||
return fallbackAgent.AgentName;
|
||||
}
|
||||
@@ -98,26 +98,23 @@ internal static class AgentIdentityResolver
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias(
|
||||
PatternDefinitionDto pattern,
|
||||
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
|
||||
WorkflowDefinitionDto workflow,
|
||||
params string?[] agentIdentifiers)
|
||||
{
|
||||
return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
||||
? pattern.Agents[0]
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
|
||||
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
||||
? agentNodes[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
|
||||
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate)
|
||||
{
|
||||
return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
}
|
||||
|
||||
private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent)
|
||||
{
|
||||
return new AgentIdentity(
|
||||
agent.Id,
|
||||
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name);
|
||||
}
|
||||
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
|
||||
=> new(agent.GetAgentId(), agent.GetAgentName());
|
||||
|
||||
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
|
||||
{
|
||||
@@ -131,22 +128,24 @@ internal static class AgentIdentityResolver
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
|
||||
}
|
||||
|
||||
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
||||
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
string agentId = agent.GetAgentId();
|
||||
string agentName = agent.GetAgentName();
|
||||
if (string.Equals(agentId, candidate, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(agentName, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string normalizedCandidate = NormalizeComparisonKey(candidate);
|
||||
string normalizedId = NormalizeComparisonKey(agent.Id);
|
||||
string normalizedName = NormalizeComparisonKey(agent.Name);
|
||||
string normalizedId = NormalizeComparisonKey(agentId);
|
||||
string normalizedName = NormalizeComparisonKey(agentName);
|
||||
if (normalizedCandidate.Length == 0)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -5,15 +5,17 @@ namespace Aryx.AgentHost.Services;
|
||||
internal static class AgentInstructionComposer
|
||||
{
|
||||
public static string Compose(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowDefinitionDto workflow,
|
||||
WorkflowNodeDto agentNode,
|
||||
int agentIndex,
|
||||
string workspaceKind = "project",
|
||||
string interactionMode = "interactive",
|
||||
string? projectInstructions = null)
|
||||
string? projectInstructions = null,
|
||||
RunTurnPromptInvocationDto? promptInvocation = null)
|
||||
{
|
||||
string baseInstructions = agent.Instructions.Trim();
|
||||
string baseInstructions = agentNode.Config.Instructions.Trim();
|
||||
string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty;
|
||||
string promptInvocationInstructions = FormatPromptInvocation(promptInvocation);
|
||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
||||
? """
|
||||
You are operating in scratchpad mode.
|
||||
@@ -32,7 +34,7 @@ internal static class AgentInstructionComposer
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(workflow.Settings.OrchestrationMode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string groupChatGuidance = agentIndex == 0
|
||||
? """
|
||||
@@ -48,10 +50,21 @@ internal static class AgentInstructionComposer
|
||||
Focus on refining the answer already in progress.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
|
||||
return JoinInstructionBlocks(
|
||||
baseInstructions,
|
||||
repositoryInstructions,
|
||||
promptInvocationInstructions,
|
||||
workspaceGuidance,
|
||||
planModeGuidance,
|
||||
groupChatGuidance);
|
||||
}
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
|
||||
return JoinInstructionBlocks(
|
||||
baseInstructions,
|
||||
repositoryInstructions,
|
||||
promptInvocationInstructions,
|
||||
workspaceGuidance,
|
||||
planModeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
@@ -60,4 +73,52 @@ internal static class AgentInstructionComposer
|
||||
"\n\n",
|
||||
blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim()));
|
||||
}
|
||||
|
||||
private static string FormatPromptInvocation(RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
string? resolvedPrompt = promptInvocation?.ResolvedPrompt?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(resolvedPrompt))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
List<string> lines =
|
||||
[
|
||||
"The current turn was started from a repository prompt file.",
|
||||
"Treat the prompt body below as the task directive for this turn rather than as prior user chat history.",
|
||||
$"Source: {promptInvocation!.SourcePath.Trim()}",
|
||||
$"Name: {promptInvocation.Name.Trim()}"
|
||||
];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(promptInvocation.Description))
|
||||
{
|
||||
lines.Add($"Description: {promptInvocation.Description.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(promptInvocation.Agent))
|
||||
{
|
||||
lines.Add($"Agent: {promptInvocation.Agent.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(promptInvocation.Model))
|
||||
{
|
||||
lines.Add($"Model: {promptInvocation.Model.Trim()}");
|
||||
}
|
||||
|
||||
if (promptInvocation.Tools is not null)
|
||||
{
|
||||
List<string> toolNames = promptInvocation.Tools
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool))
|
||||
.Select(tool => tool.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
lines.Add(toolNames.Count > 0
|
||||
? $"Tools: {string.Join(", ", toolNames)}"
|
||||
: "Tools: none");
|
||||
}
|
||||
|
||||
lines.Add("Prompt instructions:");
|
||||
lines.Add(resolvedPrompt);
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
using System.Threading;
|
||||
using GitHub.Copilot.SDK;
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.GitHub.Copilot;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
{
|
||||
private static readonly string[] RequiredPromptTools =
|
||||
[
|
||||
"ask_user",
|
||||
"report_intent",
|
||||
"task_complete"
|
||||
];
|
||||
private const string HandoffToolPrefix = "handoff_to_";
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
internal CopilotAgentBundle(IReadOnlyList<AIAgent> agents, bool hasConfiguredHooks)
|
||||
@@ -25,14 +31,13 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
|
||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
Func<PatternAgentDefinitionDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
||||
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
|
||||
Func<WorkflowNodeDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
Func<WorkflowNodeDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
||||
Action<WorkflowNodeDto, SessionEvent>? onSessionEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
|
||||
@@ -46,33 +51,46 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
disposables.Add(toolingBundle);
|
||||
}
|
||||
|
||||
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = command.Workflow.GetAllAgentNodes(command.WorkflowLibrary);
|
||||
|
||||
if (agentNodes.Count > 0)
|
||||
{
|
||||
CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
|
||||
SessionConfig sessionConfig = CreateSessionConfig(
|
||||
command,
|
||||
definition,
|
||||
agentIndex,
|
||||
(request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
(request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
evt => onSessionEvent?.Invoke(definition, evt),
|
||||
configuredHooks,
|
||||
hookCommandRunner);
|
||||
// Share a single CopilotClient across all agents to avoid spawning
|
||||
// multiple CLI processes that race on token refresh during auto-login.
|
||||
CopilotClient sharedClient = new(clientOptions);
|
||||
await sharedClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index)))
|
||||
{
|
||||
SessionConfig sessionConfig = CreateSessionConfig(
|
||||
command,
|
||||
definition,
|
||||
agentIndex,
|
||||
(request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
(request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
evt => onSessionEvent?.Invoke(definition, evt),
|
||||
configuredHooks,
|
||||
hookCommandRunner);
|
||||
|
||||
AryxCopilotAgent agent = new(
|
||||
client,
|
||||
sessionConfig,
|
||||
ownsClient: true,
|
||||
id: definition.Id,
|
||||
name: definition.Name,
|
||||
description: definition.Description);
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
|
||||
|
||||
agents.Add(agent);
|
||||
disposables.Add(agent);
|
||||
AryxCopilotAgent agent = new(
|
||||
sharedClient,
|
||||
sessionConfig,
|
||||
ownsClient: false,
|
||||
id: definition.GetAgentId(),
|
||||
name: definition.GetAgentName(),
|
||||
description: NormalizeOptionalString(definition.Config.Description));
|
||||
|
||||
agents.Add(agent);
|
||||
disposables.Add(agent);
|
||||
}
|
||||
|
||||
// The bundle owns the shared client — disposed after all agents.
|
||||
disposables.Add(sharedClient);
|
||||
}
|
||||
|
||||
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
|
||||
@@ -82,7 +100,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
|
||||
internal static SessionConfig CreateSessionConfig(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto definition,
|
||||
WorkflowNodeDto definition,
|
||||
int agentIndex,
|
||||
PermissionRequestHandler? onPermissionRequest = null,
|
||||
UserInputHandler? onUserInputRequest = null,
|
||||
@@ -90,21 +108,20 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
// Let the Copilot SDK allocate session IDs. Explicit custom SessionId values currently
|
||||
// cause turns to complete without assistant output, even for simple single-agent prompts.
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = definition.Model,
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
Model = definition.Config.Model,
|
||||
ReasoningEffort = definition.Config.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
definition,
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode,
|
||||
command.ProjectInstructions),
|
||||
command.ProjectInstructions,
|
||||
command.PromptInvocation),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
@@ -112,11 +129,11 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
||||
OnEvent = onSessionEvent,
|
||||
Streaming = true,
|
||||
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
|
||||
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
|
||||
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
|
||||
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
|
||||
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
|
||||
CustomAgents = CreateCustomAgents(definition.Config.Copilot?.CustomAgents),
|
||||
Agent = ResolveEffectiveAgent(definition.Config.Copilot?.Agent, command.PromptInvocation),
|
||||
SkillDirectories = CreateStringList(definition.Config.Copilot?.SkillDirectories),
|
||||
DisabledSkills = CreateStringList(definition.Config.Copilot?.DisabledSkills),
|
||||
InfiniteSessions = CreateInfiniteSessions(definition.Config.Copilot?.InfiniteSessions),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +153,29 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ApplyPromptInvocation(
|
||||
SessionConfig sessionConfig,
|
||||
RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
IReadOnlyList<string>? allowedTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (allowedTools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sessionConfig.AvailableTools = BuildAvailableTools(sessionConfig.AvailableTools, allowedTools);
|
||||
|
||||
if (sessionConfig.Tools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<AIFunction> filteredTools = sessionConfig.Tools
|
||||
.Where(tool => IsAlwaysAllowedTool(tool.Name) || allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
sessionConfig.Tools = filteredTools.Count > 0 ? filteredTools : null;
|
||||
}
|
||||
|
||||
internal static List<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
@@ -173,34 +213,117 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||
{
|
||||
return values is { Count: > 0 }
|
||||
? [.. values]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
return new AIAgentHostOptions
|
||||
{
|
||||
"single" => BuildSequentialWorkflow(pattern),
|
||||
"sequential" => BuildSequentialWorkflow(pattern),
|
||||
"concurrent" => BuildConcurrentWorkflow(pattern),
|
||||
"handoff" => BuildHandoffWorkflow(pattern),
|
||||
"group-chat" => BuildGroupChatWorkflow(pattern),
|
||||
"magentic" => throw new NotSupportedException(
|
||||
pattern.UnavailabilityReason
|
||||
?? "Magentic orchestration is not yet supported in the .NET Agent Framework."),
|
||||
_ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."),
|
||||
EmitAgentUpdateEvents = null,
|
||||
EmitAgentResponseEvents = false,
|
||||
InterceptUserInputRequests = false,
|
||||
InterceptUnterminatedFunctionCalls = false,
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(
|
||||
AIAgent entryAgent,
|
||||
HandoffModeSettingsDto? settings = null)
|
||||
{
|
||||
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
|
||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
|
||||
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
|
||||
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
|
||||
if (effectiveSettings.ReturnToPrevious)
|
||||
{
|
||||
TryEnableReturnToPrevious(builder);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
internal static Workflow CreateHandoffWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
|
||||
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
|
||||
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
|
||||
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
|
||||
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
|
||||
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
|
||||
|
||||
List<WorkflowNodeDto> specialistNodes = agentNodes
|
||||
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (specialistNodes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto specialistNode in specialistNodes)
|
||||
{
|
||||
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
|
||||
builder.WithHandoff(
|
||||
triageAgent,
|
||||
specialistAgent,
|
||||
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
|
||||
|
||||
if (settings?.ReturnToPrevious != true)
|
||||
{
|
||||
builder.WithHandoff(
|
||||
specialistAgent,
|
||||
triageAgent,
|
||||
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
|
||||
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
|
||||
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
|
||||
participants => new RoundRobinGroupChatManager(participants)
|
||||
{
|
||||
MaximumIterationCount = maxRounds,
|
||||
})
|
||||
.AddParticipants(agents);
|
||||
|
||||
string? name = NormalizeOptionalString(workflowDefinition.Name);
|
||||
if (name is not null)
|
||||
{
|
||||
builder.WithName(name);
|
||||
}
|
||||
|
||||
string? description = NormalizeOptionalString(workflowDefinition.Description);
|
||||
if (description is not null)
|
||||
{
|
||||
builder.WithDescription(description);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
internal static Workflow CreateGroupChatWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
@@ -209,196 +332,167 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||
Dictionary<string, PatternAgentDefinitionDto> definitionMap = pattern.Agents.ToDictionary(
|
||||
definition => definition.Id,
|
||||
definition => definition,
|
||||
StringComparer.Ordinal);
|
||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
||||
string entryAgentId = agentMap.ContainsKey(topology.EntryAgentId)
|
||||
? topology.EntryAgentId
|
||||
: pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId;
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0];
|
||||
return values is { Count: > 0 }
|
||||
? [.. values]
|
||||
: null;
|
||||
}
|
||||
|
||||
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(entryAgent);
|
||||
private static List<string> BuildAvailableTools(
|
||||
ICollection<string>? existingAvailableTools,
|
||||
IReadOnlyList<string> allowedTools)
|
||||
{
|
||||
List<string> availableTools = existingAvailableTools is { Count: > 0 }
|
||||
? existingAvailableTools
|
||||
.Where(tool => allowedTools.Contains(tool, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList()
|
||||
: [.. allowedTools];
|
||||
|
||||
foreach (PatternHandoffRoute route in topology.Routes)
|
||||
foreach (string requiredTool in RequiredPromptTools)
|
||||
{
|
||||
if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent)
|
||||
|| !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent)
|
||||
|| !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition))
|
||||
if (!availableTools.Contains(requiredTool, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
availableTools.Add(requiredTool);
|
||||
}
|
||||
}
|
||||
|
||||
return availableTools;
|
||||
}
|
||||
|
||||
private static bool IsAlwaysAllowedTool(string toolName)
|
||||
{
|
||||
return toolName.StartsWith(HandoffToolPrefix, StringComparison.Ordinal)
|
||||
|| RequiredPromptTools.Contains(toolName, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeToolNames(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (AIAgent agent in agents)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(agent.Id))
|
||||
{
|
||||
agentMap[agent.Id] = agent;
|
||||
}
|
||||
|
||||
string handoffReason = string.Equals(
|
||||
route.TargetAgentId,
|
||||
topology.EntryAgentId,
|
||||
StringComparison.Ordinal)
|
||||
? HandoffWorkflowGuidance.CreateReturnReason(targetDefinition)
|
||||
: HandoffWorkflowGuidance.CreateForwardReason(targetDefinition);
|
||||
|
||||
builder = builder.WithHandoff(
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
handoffReason);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||
{
|
||||
return new AIAgentHostOptions
|
||||
{
|
||||
// Aryx controls per-turn streaming with TurnToken(emitEvents: true), so keep this
|
||||
// null to preserve that behavior while making the host defaults explicit in code.
|
||||
EmitAgentUpdateEvents = null,
|
||||
// Aryx already projects streamed transcript state itself; enabling this would add
|
||||
// extra response events that need separate reconciliation first.
|
||||
EmitAgentResponseEvents = false,
|
||||
InterceptUserInputRequests = false,
|
||||
InterceptUnterminatedFunctionCalls = false,
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent)
|
||||
{
|
||||
return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
// Preserve normal tool-call history across handoffs while still hiding the
|
||||
// workflow's handoff plumbing. Make this explicit so AF default changes
|
||||
// cannot silently alter Aryx handoff behavior.
|
||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
}
|
||||
|
||||
private Workflow BuildSequentialWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
||||
List<ExecutorBinding> agentExecutors = agents
|
||||
.Select(CreateAgentExecutorBinding)
|
||||
.ToList();
|
||||
|
||||
ExecutorBinding previous = agentExecutors[0];
|
||||
WorkflowBuilder builder = new(previous);
|
||||
|
||||
foreach (ExecutorBinding next in agentExecutors.Skip(1))
|
||||
{
|
||||
builder.AddEdge(previous, next);
|
||||
previous = next;
|
||||
}
|
||||
|
||||
WorkflowOutputMessagesExecutor end = new();
|
||||
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
||||
|
||||
if (pattern.Name is not null)
|
||||
{
|
||||
builder = builder.WithName(pattern.Name);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildConcurrentWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
||||
ChatForwardingExecutor start = new("Start");
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
ExecutorBinding[] agentExecutors = agents
|
||||
.Select(CreateAgentExecutorBinding)
|
||||
.ToArray();
|
||||
ExecutorBinding[] accumulators = agentExecutors
|
||||
.Select(executor => CreateAggregateMessagesExecutorBinding($"Batcher/{executor.Id}"))
|
||||
.ToArray();
|
||||
|
||||
builder.AddFanOutEdge(start, agentExecutors);
|
||||
|
||||
for (int index = 0; index < agentExecutors.Length; index++)
|
||||
{
|
||||
builder.AddEdge(agentExecutors[index], accumulators[index]);
|
||||
}
|
||||
|
||||
Func<string, string, ValueTask<WorkflowConcurrentEndExecutor>> endFactory =
|
||||
(_, __) => new(new WorkflowConcurrentEndExecutor(agentExecutors.Length, AggregateConcurrentResults));
|
||||
ExecutorBinding end = endFactory.BindExecutor(WorkflowConcurrentEndExecutor.ExecutorId);
|
||||
|
||||
builder.AddFanInBarrierEdge(accumulators, end);
|
||||
builder = builder.WithOutputFrom(end);
|
||||
|
||||
if (pattern.Name is not null)
|
||||
{
|
||||
builder = builder.WithName(pattern.Name);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
|
||||
AIAgent[] agents = ResolveOrderedAgents(pattern).ToArray();
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(
|
||||
agent => agent,
|
||||
CreateAgentExecutorBinding);
|
||||
|
||||
Func<string, string, ValueTask<WorkflowRoundRobinGroupChatHost>> groupChatHostFactory =
|
||||
(id, _) => new(new WorkflowRoundRobinGroupChatHost(
|
||||
id,
|
||||
agents,
|
||||
agentMap,
|
||||
maximumIterations));
|
||||
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor("GroupChatHost");
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
foreach (ExecutorBinding participant in agentMap.Values)
|
||||
{
|
||||
builder
|
||||
.AddEdge(host, participant)
|
||||
.AddEdge(participant, host);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(host).Build();
|
||||
}
|
||||
|
||||
private static ExecutorBinding CreateAgentExecutorBinding(AIAgent agent)
|
||||
=> agent.BindAsExecutor(CreateAgentHostOptions());
|
||||
|
||||
private static ExecutorBinding CreateAggregateMessagesExecutorBinding(string id)
|
||||
{
|
||||
Func<string, string, ValueTask<WorkflowAggregateTurnMessagesExecutor>> factory =
|
||||
(_, __) => new(new WorkflowAggregateTurnMessagesExecutor(id));
|
||||
return factory.BindExecutor(id);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> AggregateConcurrentResults(IList<List<ChatMessage>> lists)
|
||||
=> [.. from list in lists where list.Count > 0 select list.Last()];
|
||||
|
||||
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;
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
agentMap[agent.Name] = agent;
|
||||
}
|
||||
}
|
||||
|
||||
return agentMap;
|
||||
}
|
||||
|
||||
private static AIAgent ResolveAgentForNode(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, AIAgent> agentsById)
|
||||
{
|
||||
string agentId = node.GetAgentId();
|
||||
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
string agentName = node.GetAgentName();
|
||||
if (agentsById.TryGetValue(agentName, out agent))
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed Copilot agents.");
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto ResolveTriageAgentNode(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes)
|
||||
{
|
||||
if (agentNodes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
|
||||
}
|
||||
|
||||
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
|
||||
if (triageAgentNodeId is null)
|
||||
{
|
||||
return agentNodes[0];
|
||||
}
|
||||
|
||||
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
|
||||
return triageNode ?? throw new InvalidOperationException(
|
||||
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
|
||||
}
|
||||
|
||||
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
|
||||
{
|
||||
return value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"none" => HandoffToolCallFilteringBehavior.None,
|
||||
"all" => HandoffToolCallFilteringBehavior.All,
|
||||
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
|
||||
};
|
||||
}
|
||||
|
||||
private static void TryEnableReturnToPrevious(HandoffsWorkflowBuilder builder)
|
||||
{
|
||||
builder.GetType()
|
||||
.GetMethod("EnableReturnToPrevious", Type.EmptyTypes)?
|
||||
.Invoke(builder, null);
|
||||
}
|
||||
|
||||
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
|
||||
{
|
||||
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
|
||||
if (configuredMaxRounds is > 0)
|
||||
{
|
||||
return configuredMaxRounds.Value;
|
||||
}
|
||||
|
||||
if (workflowDefinition.Settings.MaxIterations is > 0)
|
||||
{
|
||||
return workflowDefinition.Settings.MaxIterations.Value;
|
||||
}
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
private static string? ResolveEffectiveAgent(
|
||||
string? defaultAgent,
|
||||
RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
string? promptAgent = NormalizeOptionalString(promptInvocation?.Agent);
|
||||
if (!string.IsNullOrWhiteSpace(promptAgent)
|
||||
&& !string.Equals(promptAgent, "plan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return promptAgent;
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? promptTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (promptTools is { Count: > 0 })
|
||||
{
|
||||
return "agent";
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(defaultAgent);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
@@ -88,7 +88,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
@@ -108,7 +108,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
}
|
||||
|
||||
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|
||||
|| !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey))
|
||||
|| !RequiresToolCallApproval(command.Workflow.Settings.ApprovalPolicy, agent.GetAgentId(), toolName, autoApprovedToolName, mcpServerApprovalKey))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
@@ -149,7 +149,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
string approvalId,
|
||||
@@ -157,7 +157,8 @@ internal sealed class CopilotApprovalCoordinator
|
||||
{
|
||||
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
|
||||
|
||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||
string agentId = agent.GetAgentId();
|
||||
string agentName = agent.GetAgentName();
|
||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
||||
@@ -191,7 +192,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
SessionId = command.SessionId,
|
||||
ApprovalId = approvalId,
|
||||
ApprovalKind = ToolCallApprovalKind,
|
||||
AgentId = NormalizeOptionalString(agent.Id),
|
||||
AgentId = NormalizeOptionalString(agentId),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
ToolName = normalizedToolName,
|
||||
PermissionKind = permissionKind,
|
||||
@@ -203,7 +204,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
string? toolName)
|
||||
{
|
||||
@@ -218,14 +219,15 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return null;
|
||||
}
|
||||
|
||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||
string agentId = agent.GetAgentId();
|
||||
string agentName = agent.GetAgentName();
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ActivityType = ToolCallingActivityType,
|
||||
AgentId = NormalizeOptionalString(agent.Id),
|
||||
AgentId = NormalizeOptionalString(agentId),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
ToolName = NormalizeOptionalString(toolName),
|
||||
ToolCallId = NormalizeOptionalString(write.ToolCallId),
|
||||
|
||||
@@ -11,7 +11,7 @@ internal sealed class CopilotExitPlanModeCoordinator
|
||||
|
||||
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
@@ -33,7 +33,7 @@ internal sealed class CopilotExitPlanModeCoordinator
|
||||
|
||||
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
@@ -45,8 +45,8 @@ internal sealed class CopilotExitPlanModeCoordinator
|
||||
|
||||
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||
|
||||
return new ExitPlanModeRequestedEventDto
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ internal sealed class CopilotMcpOAuthCoordinator
|
||||
{
|
||||
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
McpOauthRequiredEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
@@ -19,8 +19,8 @@ internal sealed class CopilotMcpOAuthCoordinator
|
||||
|
||||
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||
|
||||
return new McpOauthRequiredEventDto
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ internal static class CopilotSessionHooks
|
||||
|
||||
public static SessionHooks Create(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
@@ -62,7 +62,7 @@ internal static class CopilotSessionHooks
|
||||
|
||||
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PreToolUseHookInput input)
|
||||
@@ -236,7 +236,7 @@ internal static class CopilotSessionHooks
|
||||
|
||||
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
string? toolName = Normalize(input.ToolName);
|
||||
@@ -254,8 +254,8 @@ internal static class CopilotSessionHooks
|
||||
command.Tooling?.McpServers);
|
||||
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Pattern.ApprovalPolicy,
|
||||
agentDefinition.Id,
|
||||
command.Workflow.Settings.ApprovalPolicy,
|
||||
agentDefinition.GetAgentId(),
|
||||
toolName,
|
||||
autoApprovedToolName,
|
||||
mcpServerApprovalKey);
|
||||
|
||||
@@ -54,6 +54,11 @@ internal sealed class CopilotTurnExecutionState
|
||||
}
|
||||
}
|
||||
|
||||
public void QueueCompletedActivity(AgentIdentity agent)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
|
||||
}
|
||||
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (evt is AgentActivityEventDto activity
|
||||
@@ -65,12 +70,12 @@ internal sealed class CopilotTurnExecutionState
|
||||
}
|
||||
}
|
||||
|
||||
public void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
|
||||
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, SessionEvent sessionEvent)
|
||||
{
|
||||
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
_command.Pattern,
|
||||
agentDefinition.Id,
|
||||
agentDefinition.Name);
|
||||
_command.Workflow,
|
||||
agentDefinition.GetAgentId(),
|
||||
agentDefinition.GetAgentName());
|
||||
|
||||
switch (sessionEvent)
|
||||
{
|
||||
@@ -310,6 +315,19 @@ internal sealed class CopilotTurnExecutionState
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
|
||||
{
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||
{
|
||||
return new MessageReclassifiedEventDto
|
||||
|
||||
@@ -32,7 +32,7 @@ internal sealed class CopilotUserInputCoordinator
|
||||
|
||||
public async Task<UserInputResponse> RequestUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
UserInputRequest request,
|
||||
UserInputInvocation invocation,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
@@ -44,6 +44,93 @@ internal sealed class CopilotUserInputCoordinator
|
||||
ArgumentNullException.ThrowIfNull(invocation);
|
||||
ArgumentNullException.ThrowIfNull(onUserInput);
|
||||
|
||||
return await RequestUserInputCoreAsync(
|
||||
command,
|
||||
agent.GetAgentId(),
|
||||
agent.GetAgentName(),
|
||||
request,
|
||||
onUserInput,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<UserInputResponse> RequestUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
UserInputRequest request,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(onUserInput);
|
||||
|
||||
return RequestUserInputCoreAsync(
|
||||
command,
|
||||
agentId: null,
|
||||
agentName: null,
|
||||
request,
|
||||
onUserInput,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
UserInputRequest request,
|
||||
string userInputId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||
|
||||
return new UserInputRequestedEventDto
|
||||
{
|
||||
Type = "user-input-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
UserInputId = userInputId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
|
||||
Choices = NormalizeOptionalStringList(request.Choices ?? []),
|
||||
AllowFreeform = request.AllowFreeform,
|
||||
};
|
||||
}
|
||||
|
||||
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
string? agentId,
|
||||
string? agentName,
|
||||
UserInputRequest request,
|
||||
string userInputId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return new UserInputRequestedEventDto
|
||||
{
|
||||
Type = "user-input-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
UserInputId = userInputId,
|
||||
AgentId = NormalizeOptionalString(agentId),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
|
||||
Choices = NormalizeOptionalStringList(request.Choices ?? []),
|
||||
AllowFreeform = request.AllowFreeform,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<UserInputResponse> RequestUserInputCoreAsync(
|
||||
RunTurnCommandDto command,
|
||||
string? agentId,
|
||||
string? agentName,
|
||||
UserInputRequest request,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PendingUserInputRequest pending = CreatePendingUserInput(command);
|
||||
if (!_pendingUserInputs.TryAdd(pending.UserInputId, pending))
|
||||
{
|
||||
@@ -52,7 +139,7 @@ internal sealed class CopilotUserInputCoordinator
|
||||
|
||||
try
|
||||
{
|
||||
await onUserInput(BuildUserInputRequestedEvent(command, agent, request, pending.UserInputId))
|
||||
await onUserInput(BuildUserInputRequestedEvent(command, agentId, agentName, request, pending.UserInputId))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
@@ -71,33 +158,6 @@ internal sealed class CopilotUserInputCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
UserInputRequest request,
|
||||
string userInputId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
|
||||
return new UserInputRequestedEventDto
|
||||
{
|
||||
Type = "user-input-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
UserInputId = userInputId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
|
||||
Choices = NormalizeOptionalStringList(request.Choices ?? []),
|
||||
AllowFreeform = request.AllowFreeform,
|
||||
};
|
||||
}
|
||||
|
||||
private static PendingUserInputRequest CreatePendingUserInput(RunTurnCommandDto command)
|
||||
{
|
||||
return new PendingUserInputRequest(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
@@ -11,15 +15,16 @@ namespace Aryx.AgentHost.Services;
|
||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private const string HandoffFunctionPrefix = "handoff_to_";
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly WorkflowValidator _workflowValidator;
|
||||
private readonly WorkflowRunner _workflowRunner = new();
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowValidator = workflowValidator ?? new WorkflowValidator();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
@@ -32,10 +37,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
string? validationError = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary)
|
||||
.FirstOrDefault()?.Message;
|
||||
if (validationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException(validationError.Message);
|
||||
throw new InvalidOperationException(validationError);
|
||||
}
|
||||
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
@@ -79,7 +85,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
},
|
||||
runCancellation.Token);
|
||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
@@ -97,6 +103,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo
|
||||
&& await TryHandleRequestPortRequestAsync(
|
||||
command,
|
||||
requestInfo,
|
||||
run,
|
||||
onUserInput,
|
||||
runCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
|
||||
.ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
@@ -131,6 +148,22 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
internal static Workflow BuildWorkflowForCommand(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<AIAgent> agents,
|
||||
WorkflowRunner? workflowRunner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
|
||||
return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch
|
||||
{
|
||||
"handoff" => CopilotAgentBundle.CreateHandoffWorkflow(command.Workflow, agents),
|
||||
"group-chat" => CopilotAgentBundle.CreateGroupChatWorkflow(command.Workflow, agents),
|
||||
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
|
||||
};
|
||||
}
|
||||
|
||||
internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
||||
{
|
||||
if (!ShouldEnableWorkflowCheckpointing(command))
|
||||
@@ -145,7 +178,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase);
|
||||
return command.Workflow.Settings.Checkpointing.Enabled;
|
||||
}
|
||||
|
||||
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
|
||||
@@ -161,30 +194,43 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return Path.Combine(localAppData, "Aryx", "workflow-checkpoints", command.SessionId, command.RequestId);
|
||||
}
|
||||
|
||||
private static string? NormalizeOrchestrationMode(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static ValueTask<StreamingRun> OpenWorkflowRunAsync(
|
||||
RunTurnCommandDto command,
|
||||
Workflow workflow,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CheckpointManager? checkpointManager)
|
||||
{
|
||||
InProcessExecutionEnvironment environment = CreateExecutionEnvironment(command, checkpointManager);
|
||||
if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint)
|
||||
{
|
||||
return InProcessExecution.ResumeStreamingAsync(
|
||||
return environment.ResumeStreamingAsync(
|
||||
workflow,
|
||||
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId),
|
||||
checkpointManager);
|
||||
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId));
|
||||
}
|
||||
|
||||
if (checkpointManager is not null)
|
||||
{
|
||||
return InProcessExecution.RunStreamingAsync(
|
||||
workflow,
|
||||
inputMessages.ToList(),
|
||||
checkpointManager,
|
||||
sessionId: command.RequestId);
|
||||
}
|
||||
return environment.RunStreamingAsync(workflow, inputMessages.ToList(), command.RequestId);
|
||||
}
|
||||
|
||||
return InProcessExecution.RunStreamingAsync(workflow, inputMessages.ToList());
|
||||
internal static InProcessExecutionEnvironment CreateExecutionEnvironment(
|
||||
RunTurnCommandDto command,
|
||||
CheckpointManager? checkpointManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string executionMode = command.Workflow.Settings.ExecutionMode?.Trim() ?? "off-thread";
|
||||
InProcessExecutionEnvironment environment = string.Equals(
|
||||
executionMode,
|
||||
"lockstep",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? InProcessExecution.Lockstep
|
||||
: InProcessExecution.OffThread;
|
||||
|
||||
return checkpointManager is null ? environment : environment.WithCheckpointing(checkpointManager);
|
||||
}
|
||||
|
||||
internal static void ConfigureHookLifecycleEventSuppression(
|
||||
@@ -231,6 +277,30 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> TryHandleRequestPortRequestAsync(
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo,
|
||||
StreamingRun run,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveRequestPortMetadata(command.Workflow, requestInfo, out WorkflowRequestPortMetadata? metadata))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
|
||||
UserInputResponse response = await _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
userInputRequest,
|
||||
onUserInput,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
object coercedResponse = CoerceRequestPortResponse(metadata!.ResponseType, response.Answer);
|
||||
await run.SendResponseAsync(requestInfo.Request.CreateResponse(coercedResponse)).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
@@ -242,7 +312,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
if (evt is ExecutorInvokedEvent invoked)
|
||||
{
|
||||
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
invoked.ExecutorId,
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
@@ -299,12 +369,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
if (evt is ExecutorCompletedEvent completed)
|
||||
{
|
||||
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
completed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity completedAgent))
|
||||
{
|
||||
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
|
||||
state.QueueCompletedActivity(completedAgent);
|
||||
state.ClearActiveAgentIfMatching(completedAgent);
|
||||
}
|
||||
else
|
||||
@@ -324,6 +395,74 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static UserInputRequest CreateRequestPortUserInputRequest(
|
||||
WorkflowRequestPortMetadata metadata,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
ArgumentNullException.ThrowIfNull(requestInfo);
|
||||
|
||||
string question = metadata.Prompt
|
||||
?? BuildRequestPortFallbackQuestion(metadata, requestInfo);
|
||||
|
||||
bool expectsBoolean = IsBooleanResponseType(metadata.ResponseType);
|
||||
return new UserInputRequest
|
||||
{
|
||||
Question = question,
|
||||
Choices = expectsBoolean ? ["true", "false"] : null,
|
||||
AllowFreeform = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal static object CoerceRequestPortResponse(string responseType, string? answer)
|
||||
{
|
||||
string normalizedResponseType = responseType.Trim();
|
||||
string trimmedAnswer = answer?.Trim() ?? string.Empty;
|
||||
|
||||
if (IsStringResponseType(normalizedResponseType))
|
||||
{
|
||||
return trimmedAnswer;
|
||||
}
|
||||
|
||||
if (IsBooleanResponseType(normalizedResponseType))
|
||||
{
|
||||
return trimmedAnswer.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "t" or "yes" or "y" or "1" => true,
|
||||
"false" or "f" or "no" or "n" or "0" => false,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Request port response type \"{responseType}\" requires a boolean answer."),
|
||||
};
|
||||
}
|
||||
|
||||
if (IsNumericResponseType(normalizedResponseType))
|
||||
{
|
||||
if (double.TryParse(trimmedAnswer, NumberStyles.Float, CultureInfo.InvariantCulture, out double numeric))
|
||||
{
|
||||
return numeric;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Request port response type \"{responseType}\" requires a numeric answer.");
|
||||
}
|
||||
|
||||
if (IsJsonResponseType(normalizedResponseType))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(trimmedAnswer).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Request port response type \"{responseType}\" requires a valid JSON answer.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
return trimmedAnswer;
|
||||
}
|
||||
|
||||
private static async Task HandleAgentResponseUpdateAsync(
|
||||
RunTurnCommandDto command,
|
||||
AgentResponseUpdateEvent update,
|
||||
@@ -345,7 +484,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
authorName = observedMessageAgent.AgentName;
|
||||
}
|
||||
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
update.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedUpdateAgent))
|
||||
@@ -468,7 +607,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
case ExecutorFailedEvent executorFailed:
|
||||
{
|
||||
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
executorFailed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedAgent)
|
||||
@@ -531,6 +670,83 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveRequestPortMetadata(
|
||||
WorkflowDefinitionDto? workflow,
|
||||
RequestInfoEvent requestInfo,
|
||||
out WorkflowRequestPortMetadata? metadata)
|
||||
{
|
||||
metadata = null;
|
||||
if (workflow is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string portId = requestInfo.Request.PortInfo.PortId;
|
||||
WorkflowNodeDto? node = workflow.Graph.Nodes.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(candidate.Config.PortId, portId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (node is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
metadata = new WorkflowRequestPortMetadata(
|
||||
node.Id,
|
||||
string.IsNullOrWhiteSpace(node.Label) ? node.Id : node.Label,
|
||||
node.Config.PortId ?? portId,
|
||||
node.Config.RequestType ?? string.Empty,
|
||||
node.Config.ResponseType ?? string.Empty,
|
||||
string.IsNullOrWhiteSpace(node.Config.Prompt) ? null : node.Config.Prompt.Trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildRequestPortFallbackQuestion(
|
||||
WorkflowRequestPortMetadata metadata,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
if (requestInfo.Request.Data.Is<WorkflowRequestPortPromptRequest>(out WorkflowRequestPortPromptRequest? promptRequest))
|
||||
{
|
||||
string baseQuestion = $"Provide a {metadata.ResponseType} response for \"{promptRequest.NodeLabel}\".";
|
||||
if (!string.IsNullOrWhiteSpace(promptRequest.InputSummary))
|
||||
{
|
||||
return $"{baseQuestion} Current input: {promptRequest.InputSummary}";
|
||||
}
|
||||
|
||||
return baseQuestion;
|
||||
}
|
||||
|
||||
return $"Provide a {metadata.ResponseType} response for request port \"{metadata.NodeLabel}\" ({metadata.PortId}).";
|
||||
}
|
||||
|
||||
private static bool IsStringResponseType(string responseType)
|
||||
=> string.Equals(responseType, "string", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "text", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsBooleanResponseType(string responseType)
|
||||
=> string.Equals(responseType, "bool", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "boolean", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsNumericResponseType(string responseType)
|
||||
=> string.Equals(responseType, "number", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "int", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "float", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "double", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "decimal", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsJsonResponseType(string responseType)
|
||||
=> string.Equals(responseType, "json", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "object", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(responseType, "array", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal sealed record WorkflowRequestPortMetadata(
|
||||
string NodeId,
|
||||
string NodeLabel,
|
||||
string PortId,
|
||||
string RequestType,
|
||||
string ResponseType,
|
||||
string? Prompt);
|
||||
|
||||
private static string ResolveDiagnosticMessage(Exception? exception, string fallback)
|
||||
{
|
||||
return ResolveDiagnosticMessage(
|
||||
@@ -551,7 +767,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
private static void TraceHandoff(RunTurnCommandDto command, string message)
|
||||
{
|
||||
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
if (!command.Workflow.IsOrchestrationMode("handoff"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,17 +21,17 @@ internal static class HandoffWorkflowGuidance
|
||||
""";
|
||||
}
|
||||
|
||||
public static string CreateForwardReason(PatternAgentDefinitionDto target)
|
||||
public static string CreateForwardReason(WorkflowNodeDto target)
|
||||
{
|
||||
string specialty = string.IsNullOrWhiteSpace(target.Description)
|
||||
? target.Name
|
||||
: target.Description.TrimEnd('.');
|
||||
string specialty = string.IsNullOrWhiteSpace(target.Config.Description)
|
||||
? target.GetAgentName()
|
||||
: target.Config.Description.TrimEnd('.');
|
||||
|
||||
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.Name} own the substantive response.";
|
||||
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.GetAgentName()} own the substantive response.";
|
||||
}
|
||||
|
||||
public static string CreateReturnReason(PatternAgentDefinitionDto triageAgent)
|
||||
public static string CreateReturnReason(WorkflowNodeDto triageAgent)
|
||||
{
|
||||
return $"Hand off back to {triageAgent.Name} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
|
||||
return $"Hand off back to {triageAgent.GetAgentName()} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.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;
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class PatternValidator
|
||||
{
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
|
||||
{
|
||||
List<PatternValidationIssueDto> issues = [];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Name))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "name",
|
||||
Message = "Pattern name is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Availability, "unavailable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "availability",
|
||||
Message = pattern.UnavailabilityReason ?? "This orchestration mode is currently unavailable.",
|
||||
});
|
||||
}
|
||||
|
||||
if (pattern.Agents.Count == 0)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "At least one agent is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "single", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count != 1)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Single-agent chat requires exactly one agent.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Handoff orchestration requires at least two agents.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Group chat requires at least two agents.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "magentic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "mode",
|
||||
Message = pattern.UnavailabilityReason
|
||||
?? "Magentic orchestration is currently documented as unsupported in the .NET Agent Framework.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents.name",
|
||||
Message = "Every agent needs a name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agent.Model))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents.model",
|
||||
Message = $"Agent \"{agent.Name}\" requires a model identifier.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), 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,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace Aryx.AgentHost.Services;
|
||||
public sealed class SidecarProtocolHost
|
||||
{
|
||||
private const string DescribeCapabilitiesCommandType = "describe-capabilities";
|
||||
private const string ValidatePatternCommandType = "validate-pattern";
|
||||
private const string ValidateWorkflowCommandType = "validate-workflow";
|
||||
private const string RunTurnCommandType = "run-turn";
|
||||
private const string CancelTurnCommandType = "cancel-turn";
|
||||
private const string ResolveApprovalCommandType = "resolve-approval";
|
||||
@@ -41,7 +41,7 @@ public sealed class SidecarProtocolHost
|
||||
];
|
||||
|
||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly WorkflowValidator _workflowValidator;
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
private readonly ICopilotSessionManager _sessionManager;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
@@ -53,18 +53,26 @@ public sealed class SidecarProtocolHost
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public SidecarProtocolHost()
|
||||
: this(new PatternValidator())
|
||||
: this(new WorkflowValidator())
|
||||
{
|
||||
}
|
||||
|
||||
public SidecarProtocolHost(
|
||||
PatternValidator patternValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
ICopilotSessionManager? sessionManager = null)
|
||||
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
|
||||
{
|
||||
}
|
||||
|
||||
public SidecarProtocolHost(
|
||||
WorkflowValidator workflowValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
ICopilotSessionManager? sessionManager = null)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
||||
_workflowValidator = workflowValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_workflowValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
||||
_jsonOptions = JsonSerialization.CreateWebOptions();
|
||||
@@ -73,7 +81,7 @@ public sealed class SidecarProtocolHost
|
||||
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
|
||||
{
|
||||
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
||||
[ValidatePatternCommandType] = HandleValidatePatternAsync,
|
||||
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
|
||||
[RunTurnCommandType] = HandleRunTurnAsync,
|
||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||
@@ -168,15 +176,15 @@ public sealed class SidecarProtocolHost
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleValidatePatternAsync(CommandContext context)
|
||||
private async Task HandleValidateWorkflowAsync(CommandContext context)
|
||||
{
|
||||
ValidatePatternCommandDto command = DeserializeCommand<ValidatePatternCommandDto>(context);
|
||||
ValidateWorkflowCommandDto command = DeserializeCommand<ValidateWorkflowCommandDto>(context);
|
||||
|
||||
await WriteAsync(context.Output, new PatternValidationEventDto
|
||||
await WriteAsync(context.Output, new WorkflowValidationEventDto
|
||||
{
|
||||
Type = "pattern-validation",
|
||||
Type = "workflow-validation",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Issues = _patternValidator.Validate(command.Pattern),
|
||||
Issues = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary),
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class WorkflowConditionEvaluator
|
||||
{
|
||||
private static readonly HashSet<string> SupportedConditionTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"always",
|
||||
"message-type",
|
||||
"expression",
|
||||
"property",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedOperators = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"equals",
|
||||
"not-equals",
|
||||
"contains",
|
||||
"gt",
|
||||
"lt",
|
||||
"regex",
|
||||
};
|
||||
|
||||
private static readonly Regex ComparisonExpression = new(
|
||||
@"^(?<path>[A-Za-z_][A-Za-z0-9_\.]*)\s*(?<operator>==|!=|>|<|contains|matches)\s*(?<value>""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false)$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
internal static bool IsSupportedConditionType(string? type)
|
||||
=> !string.IsNullOrWhiteSpace(type) && SupportedConditionTypes.Contains(type);
|
||||
|
||||
internal static bool IsSupportedOperator(string? @operator)
|
||||
=> !string.IsNullOrWhiteSpace(@operator) && SupportedOperators.Contains(@operator);
|
||||
|
||||
internal static bool IsSupportedExpression(string? expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string trimmed = expression.Trim();
|
||||
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string? delimiter = trimmed.Contains("&&", StringComparison.Ordinal) ? "&&" : null;
|
||||
if (delimiter is null && trimmed.Contains("||", StringComparison.Ordinal))
|
||||
{
|
||||
delimiter = "||";
|
||||
}
|
||||
|
||||
if (delimiter is null)
|
||||
{
|
||||
return ComparisonExpression.IsMatch(trimmed);
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Split(delimiter, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.All(segment => ComparisonExpression.IsMatch(segment));
|
||||
}
|
||||
|
||||
internal static Func<object?, bool>? Compile(WorkflowEdgeDto edge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
|
||||
Func<object?, bool>? baseCondition = edge.Condition is null
|
||||
? null
|
||||
: CompileCondition(edge.Condition);
|
||||
|
||||
if (edge.IsLoop != true)
|
||||
{
|
||||
return baseCondition;
|
||||
}
|
||||
|
||||
int maxIterations = edge.MaxIterations ?? 0;
|
||||
int successfulIterations = 0;
|
||||
return payload =>
|
||||
{
|
||||
if (successfulIterations >= maxIterations)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (baseCondition is not null && !baseCondition(payload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
successfulIterations++;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool Evaluate(EdgeConditionDto condition, object? payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(condition);
|
||||
return CompileCondition(condition)?.Invoke(payload) ?? true;
|
||||
}
|
||||
|
||||
private static Func<object?, bool>? CompileCondition(EdgeConditionDto condition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(condition.Type)
|
||||
|| string.Equals(condition.Type, "always", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(condition.Type, "message-type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string expectedTypeName = condition.TypeName?.Trim() ?? string.Empty;
|
||||
return payload =>
|
||||
{
|
||||
if (payload is null || string.IsNullOrWhiteSpace(expectedTypeName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Type payloadType = payload.GetType();
|
||||
return string.Equals(payloadType.Name, expectedTypeName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(payloadType.FullName, expectedTypeName, StringComparison.OrdinalIgnoreCase);
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(condition.Type, "property", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string combinator = string.Equals(condition.Combinator, "or", StringComparison.OrdinalIgnoreCase) ? "or" : "and";
|
||||
return payload =>
|
||||
{
|
||||
IReadOnlyList<bool> results = condition.Rules
|
||||
.Select(rule => EvaluateRule(rule, payload))
|
||||
.ToArray();
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(combinator, "or", StringComparison.OrdinalIgnoreCase)
|
||||
? results.Any(result => result)
|
||||
: results.All(result => result);
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return payload => EvaluateExpression(condition.Expression, payload);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Condition type \"{condition.Type}\" is not supported.");
|
||||
}
|
||||
|
||||
private static bool EvaluateExpression(string? expression, object? payload)
|
||||
{
|
||||
string trimmed = expression?.Trim() ?? string.Empty;
|
||||
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed.Contains("&&", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed
|
||||
.Split("&&", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.All(segment => EvaluateExpression(segment, payload));
|
||||
}
|
||||
|
||||
if (trimmed.Contains("||", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed
|
||||
.Split("||", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(segment => EvaluateExpression(segment, payload));
|
||||
}
|
||||
|
||||
Match match = ComparisonExpression.Match(trimmed);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string path = match.Groups["path"].Value;
|
||||
string @operator = match.Groups["operator"].Value switch
|
||||
{
|
||||
"==" => "equals",
|
||||
"!=" => "not-equals",
|
||||
">" => "gt",
|
||||
"<" => "lt",
|
||||
"matches" => "regex",
|
||||
_ => match.Groups["operator"].Value,
|
||||
};
|
||||
|
||||
string rawValue = match.Groups["value"].Value;
|
||||
string value = UnwrapLiteral(rawValue);
|
||||
return EvaluateRule(
|
||||
new WorkflowConditionRuleDto
|
||||
{
|
||||
PropertyPath = path,
|
||||
Operator = @operator,
|
||||
Value = value,
|
||||
},
|
||||
payload);
|
||||
}
|
||||
|
||||
private static bool EvaluateRule(WorkflowConditionRuleDto rule, object? payload)
|
||||
{
|
||||
if (payload is null || string.IsNullOrWhiteSpace(rule.PropertyPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryResolvePropertyPath(payload, rule.PropertyPath, out object? actualValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return rule.Operator switch
|
||||
{
|
||||
"equals" => AreEqual(actualValue, rule.Value),
|
||||
"not-equals" => !AreEqual(actualValue, rule.Value),
|
||||
"contains" => ContainsValue(actualValue, rule.Value),
|
||||
"gt" => CompareAsNumberOrString(actualValue, rule.Value) > 0,
|
||||
"lt" => CompareAsNumberOrString(actualValue, rule.Value) < 0,
|
||||
"regex" => Regex.IsMatch(CoerceToString(actualValue), rule.Value, RegexOptions.CultureInvariant),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolvePropertyPath(object payload, string propertyPath, out object? value)
|
||||
{
|
||||
object? current = payload;
|
||||
foreach (string segment in propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!TryResolvePropertySegment(current, segment, out current))
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
value = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolvePropertySegment(object? current, string segment, out object? value)
|
||||
{
|
||||
value = null;
|
||||
if (current is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty jsonProperty in jsonElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(jsonProperty.Name, segment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = jsonProperty.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current is IDictionary dictionary)
|
||||
{
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
if (entry.Key is string key && string.Equals(key, segment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Type type = current.GetType();
|
||||
PropertyInfo? property = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, segment, StringComparison.OrdinalIgnoreCase));
|
||||
if (property is not null)
|
||||
{
|
||||
value = property.GetValue(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AreEqual(object? actualValue, string expectedValue)
|
||||
{
|
||||
if (actualValue is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
|
||||
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
|
||||
{
|
||||
return actualDecimal == expectedDecimal;
|
||||
}
|
||||
|
||||
return string.Equals(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ContainsValue(object? actualValue, string expectedValue)
|
||||
{
|
||||
if (actualValue is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actualValue is string actualString)
|
||||
{
|
||||
return actualString.Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (actualValue is IEnumerable enumerable)
|
||||
{
|
||||
foreach (object? item in enumerable)
|
||||
{
|
||||
if (string.Equals(CoerceToString(item), expectedValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CoerceToString(actualValue).Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int CompareAsNumberOrString(object? actualValue, string expectedValue)
|
||||
{
|
||||
if (actualValue is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
|
||||
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
|
||||
{
|
||||
return actualDecimal.CompareTo(expectedDecimal);
|
||||
}
|
||||
|
||||
return string.Compare(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryConvertToDecimal(object? value, out decimal result)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case byte byteValue:
|
||||
result = byteValue;
|
||||
return true;
|
||||
case short shortValue:
|
||||
result = shortValue;
|
||||
return true;
|
||||
case int intValue:
|
||||
result = intValue;
|
||||
return true;
|
||||
case long longValue:
|
||||
result = longValue;
|
||||
return true;
|
||||
case float floatValue:
|
||||
result = (decimal)floatValue;
|
||||
return true;
|
||||
case double doubleValue:
|
||||
result = (decimal)doubleValue;
|
||||
return true;
|
||||
case decimal decimalValue:
|
||||
result = decimalValue;
|
||||
return true;
|
||||
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetDecimal(out decimal jsonDecimal):
|
||||
result = jsonDecimal;
|
||||
return true;
|
||||
default:
|
||||
return decimal.TryParse(
|
||||
CoerceToString(value),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out result);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CoerceToString(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
return jsonElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => jsonElement.GetString() ?? string.Empty,
|
||||
JsonValueKind.True => bool.TrueString,
|
||||
JsonValueKind.False => bool.FalseString,
|
||||
_ => jsonElement.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string UnwrapLiteral(string rawValue)
|
||||
{
|
||||
if (rawValue.Length >= 2
|
||||
&& ((rawValue.StartsWith('"') && rawValue.EndsWith('"'))
|
||||
|| (rawValue.StartsWith('\'') && rawValue.EndsWith('\''))))
|
||||
{
|
||||
return rawValue[1..^1];
|
||||
}
|
||||
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class WorkflowDefinitionExtensions
|
||||
{
|
||||
public static IReadOnlyList<WorkflowNodeDto> GetAgentNodes(this WorkflowDefinitionDto workflow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
return workflow.Graph.Nodes
|
||||
.Where(IsAgentNode)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WorkflowNodeDto> GetAllAgentNodes(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = CreateWorkflowLibraryMap(workflowLibrary);
|
||||
return GetAllAgentNodes(workflow, workflowLibraryMap);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WorkflowNodeDto> GetAllAgentNodes(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
List<WorkflowNodeDto> agentNodes = [];
|
||||
CollectAgentNodes(
|
||||
workflow,
|
||||
workflowLibrary ?? EmptyWorkflowLibrary,
|
||||
agentNodes,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
return agentNodes;
|
||||
}
|
||||
|
||||
public static bool IsAgentNode(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string GetAgentId(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
|
||||
}
|
||||
|
||||
public static string GetAgentName(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return FirstNonBlank(node.Config.Name, node.Label, node.Id) ?? "agent";
|
||||
}
|
||||
|
||||
public static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
|
||||
this WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
|
||||
if (node.Config.InlineWorkflow is not null)
|
||||
{
|
||||
return node.Config.InlineWorkflow;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
|
||||
&& workflowLibrary is not null
|
||||
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
|
||||
{
|
||||
return workflow;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
|
||||
}
|
||||
|
||||
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mode);
|
||||
|
||||
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
|
||||
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
|
||||
private static void CollectAgentNodes(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
List<WorkflowNodeDto> agentNodes,
|
||||
ISet<string> visitedWorkflowIds,
|
||||
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
|
||||
{
|
||||
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
|
||||
if (workflowId is not null)
|
||||
{
|
||||
if (!visitedWorkflowIds.Add(workflowId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
if (node.IsAgentNode())
|
||||
{
|
||||
agentNodes.Add(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
|
||||
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
return workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
||||
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? FirstNonBlank(params string?[] values)
|
||||
{
|
||||
foreach (string? value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,69 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class WorkflowOutputMessagesExecutor(ChatProtocolExecutorOptions? options = null)
|
||||
: ChatProtocolExecutor(ExecutorId, options, declareCrossRunShareable: true), IResettableExecutor
|
||||
internal sealed class WorkflowOutputMessagesExecutor(string id = "OutputMessages")
|
||||
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "OutputMessages";
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> base.ConfigureProtocol(protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
|
||||
.AddHandler<TurnToken>(static (_, _, _) => default)
|
||||
.AddHandler<ChatMessage>(YieldMessageAsync)
|
||||
.AddHandler<List<ChatMessage>>(YieldMessagesAsync)
|
||||
.AddHandler<ChatMessage[]>(YieldMessageArrayAsync)
|
||||
.AddHandler<IEnumerable<ChatMessage>>(YieldEnumerableMessagesAsync)
|
||||
.AddCatchAll(YieldCatchAllAsync))
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
}
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
private static ValueTask YieldMessageAsync(
|
||||
ChatMessage message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
=> context.YieldOutputAsync(new List<ChatMessage> { message }, cancellationToken);
|
||||
|
||||
private static ValueTask YieldMessagesAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken)
|
||||
=> context.YieldOutputAsync(messages, cancellationToken);
|
||||
|
||||
private static ValueTask YieldMessageArrayAsync(
|
||||
ChatMessage[] messages,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
=> context.YieldOutputAsync(messages.ToList(), cancellationToken);
|
||||
|
||||
private static ValueTask YieldEnumerableMessagesAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
=> context.YieldOutputAsync(messages.ToList(), cancellationToken);
|
||||
|
||||
private static ValueTask YieldCatchAllAsync(
|
||||
PortableValue message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (message.Is<TurnToken>())
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
object payload = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
|
||||
return context.YieldOutputAsync(
|
||||
WorkflowValueSerializer.ToOutputMessages(payload),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => default;
|
||||
}
|
||||
|
||||
@@ -147,3 +191,668 @@ internal sealed class WorkflowRoundRobinGroupChatHost(
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
|
||||
}
|
||||
|
||||
internal sealed class WorkflowStateScopeCatalog
|
||||
{
|
||||
public static WorkflowStateScopeCatalog Empty { get; } = new([]);
|
||||
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, JsonElement>> _scopes;
|
||||
|
||||
public WorkflowStateScopeCatalog(IReadOnlyList<WorkflowStateScopeDto>? stateScopes)
|
||||
{
|
||||
Dictionary<string, IReadOnlyDictionary<string, JsonElement>> scopes = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (WorkflowStateScopeDto scope in stateScopes ?? [])
|
||||
{
|
||||
string? scopeName = NormalizeOptionalString(scope.Name);
|
||||
if (scopeName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, JsonElement> initialValues = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach ((string key, JsonElement value) in scope.InitialValues
|
||||
?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string? normalizedKey = NormalizeOptionalString(key);
|
||||
if (normalizedKey is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
initialValues[normalizedKey] = WorkflowValueSerializer.CloneElement(value);
|
||||
}
|
||||
|
||||
scopes[scopeName] = initialValues;
|
||||
}
|
||||
|
||||
_scopes = scopes;
|
||||
}
|
||||
|
||||
public async ValueTask<JsonElement?> ReadJsonStateAsync(
|
||||
IWorkflowContext context,
|
||||
string scopeName,
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedScope = NormalizeRequired(scopeName, nameof(scopeName));
|
||||
string normalizedKey = NormalizeRequired(key, nameof(key));
|
||||
|
||||
if (TryGetInitialValue(normalizedScope, normalizedKey, out JsonElement initialValue))
|
||||
{
|
||||
JsonElement value = await context.ReadOrInitStateAsync(
|
||||
normalizedKey,
|
||||
() => WorkflowValueSerializer.CloneElement(initialValue),
|
||||
normalizedScope,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return WorkflowValueSerializer.CloneElement(value);
|
||||
}
|
||||
|
||||
JsonElement? existing = await context.ReadStateAsync<JsonElement>(
|
||||
normalizedKey,
|
||||
normalizedScope,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return existing.HasValue ? WorkflowValueSerializer.CloneElement(existing.Value) : null;
|
||||
}
|
||||
|
||||
public ValueTask QueueJsonStateUpdateAsync(
|
||||
IWorkflowContext context,
|
||||
string scopeName,
|
||||
string key,
|
||||
JsonElement value,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedScope = NormalizeRequired(scopeName, nameof(scopeName));
|
||||
string normalizedKey = NormalizeRequired(key, nameof(key));
|
||||
return context.QueueStateUpdateAsync(
|
||||
normalizedKey,
|
||||
WorkflowValueSerializer.CloneElement(value),
|
||||
normalizedScope,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private bool TryGetInitialValue(string scopeName, string key, out JsonElement value)
|
||||
{
|
||||
value = default;
|
||||
return _scopes.TryGetValue(scopeName, out IReadOnlyDictionary<string, JsonElement>? scope)
|
||||
&& scope.TryGetValue(key, out value);
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string value, string paramName)
|
||||
{
|
||||
return NormalizeOptionalString(value)
|
||||
?? throw new InvalidOperationException($"{paramName} is required.");
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal sealed record WorkflowRequestPortNodeDefinition(
|
||||
string NodeId,
|
||||
string NodeLabel,
|
||||
string PortId,
|
||||
string RequestType,
|
||||
string ResponseType,
|
||||
string? Prompt);
|
||||
|
||||
internal sealed class WorkflowRequestPortPromptRequest
|
||||
{
|
||||
public string NodeId { get; init; } = string.Empty;
|
||||
|
||||
public string NodeLabel { get; init; } = string.Empty;
|
||||
|
||||
public string PortId { get; init; } = string.Empty;
|
||||
|
||||
public string RequestType { get; init; } = string.Empty;
|
||||
|
||||
public string ResponseType { get; init; } = string.Empty;
|
||||
|
||||
public string? Prompt { get; init; }
|
||||
|
||||
public string? InputSummary { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class WorkflowCodeExecutor(
|
||||
string id,
|
||||
string implementation,
|
||||
WorkflowStateScopeCatalog stateCatalog)
|
||||
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
private readonly string _implementation = implementation;
|
||||
private readonly WorkflowStateScopeCatalog _stateCatalog = stateCatalog;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
|
||||
.AddHandler<TurnToken>(static (_, _, _) => default)
|
||||
.AddCatchAll(HandleAsync))
|
||||
.SendsMessage<object>();
|
||||
}
|
||||
|
||||
private async ValueTask HandleAsync(
|
||||
PortableValue message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
object input = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
|
||||
object result = await ExecuteAsync(input, context, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ValueTask<object> ExecuteAsync(
|
||||
object input,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(_implementation, "return-input", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(input);
|
||||
}
|
||||
|
||||
if (_implementation.StartsWith("return-text:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult<object>(_implementation["return-text:".Length..]);
|
||||
}
|
||||
|
||||
if (_implementation.StartsWith("return-json:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string rawJson = _implementation["return-json:".Length..];
|
||||
return ValueTask.FromResult<object>(WorkflowValueSerializer.ParseJsonElement(rawJson));
|
||||
}
|
||||
|
||||
if (_implementation.StartsWith("state:set:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ExecuteStateSetAsync(
|
||||
_implementation.Split(':', 5),
|
||||
context,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (_implementation.StartsWith("state:get:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ExecuteStateGetAsync(
|
||||
_implementation.Split(':', 4),
|
||||
context,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Code executor \"{Id}\" does not support implementation \"{_implementation}\". " +
|
||||
"Supported implementations are return-input, return-text:<text>, return-json:<json>, state:set:<scope>:<key>:<json>, and state:get:<scope>:<key>.");
|
||||
}
|
||||
|
||||
private async ValueTask<object> ExecuteStateSetAsync(
|
||||
string[] segments,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (segments.Length != 5)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Code executor \"{Id}\" requires the format state:set:<scope>:<key>:<json>. Received \"{_implementation}\".");
|
||||
}
|
||||
|
||||
JsonElement value = WorkflowValueSerializer.ParseJsonElement(segments[4]);
|
||||
await _stateCatalog.QueueJsonStateUpdateAsync(
|
||||
context,
|
||||
segments[2],
|
||||
segments[3],
|
||||
value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private async ValueTask<object> ExecuteStateGetAsync(
|
||||
string[] segments,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (segments.Length != 4)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Code executor \"{Id}\" requires the format state:get:<scope>:<key>. Received \"{_implementation}\".");
|
||||
}
|
||||
|
||||
JsonElement? value = await _stateCatalog.ReadJsonStateAsync(
|
||||
context,
|
||||
segments[2],
|
||||
segments[3],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return value ?? WorkflowValueSerializer.CreateNullElement();
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
internal sealed class WorkflowFunctionExecutor(
|
||||
string id,
|
||||
string functionRef,
|
||||
IReadOnlyDictionary<string, JsonElement>? parameters,
|
||||
WorkflowStateScopeCatalog stateCatalog)
|
||||
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
private readonly string _functionRef = functionRef;
|
||||
private readonly IReadOnlyDictionary<string, JsonElement> _parameters = parameters ?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly WorkflowStateScopeCatalog _stateCatalog = stateCatalog;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
|
||||
.AddHandler<TurnToken>(static (_, _, _) => default)
|
||||
.AddCatchAll(HandleAsync))
|
||||
.SendsMessage<object>();
|
||||
}
|
||||
|
||||
private async ValueTask HandleAsync(
|
||||
PortableValue message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
object input = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
|
||||
object result = await WorkflowFunctionRegistry.InvokeAsync(
|
||||
_functionRef,
|
||||
input,
|
||||
_parameters,
|
||||
context,
|
||||
_stateCatalog,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
internal static class WorkflowFunctionRegistry
|
||||
{
|
||||
private static readonly HashSet<string> SupportedFunctionRefs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"identity",
|
||||
"return-parameter",
|
||||
"concat-text",
|
||||
"state:get",
|
||||
"state:set",
|
||||
};
|
||||
|
||||
public static bool IsSupported(string? functionRef)
|
||||
=> !string.IsNullOrWhiteSpace(functionRef) && SupportedFunctionRefs.Contains(functionRef.Trim());
|
||||
|
||||
public static async ValueTask<object> InvokeAsync(
|
||||
string functionRef,
|
||||
object input,
|
||||
IReadOnlyDictionary<string, JsonElement> parameters,
|
||||
IWorkflowContext context,
|
||||
WorkflowStateScopeCatalog stateCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedFunctionRef = functionRef.Trim();
|
||||
return normalizedFunctionRef switch
|
||||
{
|
||||
var value when string.Equals(value, "identity", StringComparison.OrdinalIgnoreCase)
|
||||
=> input,
|
||||
var value when string.Equals(value, "return-parameter", StringComparison.OrdinalIgnoreCase)
|
||||
=> ReturnParameter(parameters),
|
||||
var value when string.Equals(value, "concat-text", StringComparison.OrdinalIgnoreCase)
|
||||
=> ConcatText(input, parameters),
|
||||
var value when string.Equals(value, "state:get", StringComparison.OrdinalIgnoreCase)
|
||||
=> await GetStateAsync(parameters, context, stateCatalog, cancellationToken).ConfigureAwait(false),
|
||||
var value when string.Equals(value, "state:set", StringComparison.OrdinalIgnoreCase)
|
||||
=> await SetStateAsync(parameters, context, stateCatalog, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Function executor references unsupported functionRef \"{functionRef}\". Supported refs are: {string.Join(", ", SupportedFunctionRefs.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase))}.")
|
||||
};
|
||||
}
|
||||
|
||||
private static object ReturnParameter(IReadOnlyDictionary<string, JsonElement> parameters)
|
||||
{
|
||||
if (TryGetParameter(parameters, "name", out JsonElement namedParameterSelector)
|
||||
&& namedParameterSelector.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string parameterName = namedParameterSelector.GetString() ?? string.Empty;
|
||||
if (TryGetParameter(parameters, parameterName, out JsonElement namedValue))
|
||||
{
|
||||
return WorkflowValueSerializer.CloneElement(namedValue);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Function executor return-parameter could not find parameter \"{parameterName}\".");
|
||||
}
|
||||
|
||||
if (TryGetParameter(parameters, "value", out JsonElement value))
|
||||
{
|
||||
return WorkflowValueSerializer.CloneElement(value);
|
||||
}
|
||||
|
||||
KeyValuePair<string, JsonElement>[] remaining = parameters
|
||||
.Where(static pair => !string.Equals(pair.Key, "name", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (remaining.Length == 1)
|
||||
{
|
||||
return WorkflowValueSerializer.CloneElement(remaining[0].Value);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Function executor return-parameter requires either a value parameter, a name selector, or exactly one parameter value.");
|
||||
}
|
||||
|
||||
private static object ConcatText(object input, IReadOnlyDictionary<string, JsonElement> parameters)
|
||||
{
|
||||
List<string> parts = [];
|
||||
if (TryGetString(parameters, "prefix", out string? prefix))
|
||||
{
|
||||
parts.Add(prefix!);
|
||||
}
|
||||
|
||||
bool includeInput = !TryGetBoolean(parameters, "includeInput", out bool parsedIncludeInput) || parsedIncludeInput;
|
||||
if (includeInput)
|
||||
{
|
||||
parts.Add(WorkflowValueSerializer.ToDisplayText(input));
|
||||
}
|
||||
|
||||
if (TryGetParameter(parameters, "values", out JsonElement values))
|
||||
{
|
||||
if (values.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException("Function executor concat-text requires values to be a JSON array when provided.");
|
||||
}
|
||||
|
||||
foreach (JsonElement element in values.EnumerateArray())
|
||||
{
|
||||
parts.Add(WorkflowValueSerializer.ToDisplayText(element));
|
||||
}
|
||||
}
|
||||
|
||||
if (TryGetString(parameters, "suffix", out string? suffix))
|
||||
{
|
||||
parts.Add(suffix!);
|
||||
}
|
||||
|
||||
string separator = TryGetString(parameters, "separator", out string? parsedSeparator)
|
||||
? parsedSeparator!
|
||||
: string.Empty;
|
||||
return string.Join(separator, parts);
|
||||
}
|
||||
|
||||
private static async ValueTask<object> GetStateAsync(
|
||||
IReadOnlyDictionary<string, JsonElement> parameters,
|
||||
IWorkflowContext context,
|
||||
WorkflowStateScopeCatalog stateCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string scope = GetRequiredString(parameters, "scope");
|
||||
string key = GetRequiredString(parameters, "key");
|
||||
JsonElement? value = await stateCatalog.ReadJsonStateAsync(
|
||||
context,
|
||||
scope,
|
||||
key,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return value ?? WorkflowValueSerializer.CreateNullElement();
|
||||
}
|
||||
|
||||
private static async ValueTask<object> SetStateAsync(
|
||||
IReadOnlyDictionary<string, JsonElement> parameters,
|
||||
IWorkflowContext context,
|
||||
WorkflowStateScopeCatalog stateCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string scope = GetRequiredString(parameters, "scope");
|
||||
string key = GetRequiredString(parameters, "key");
|
||||
JsonElement value = GetRequiredJson(parameters, "value");
|
||||
await stateCatalog.QueueJsonStateUpdateAsync(
|
||||
context,
|
||||
scope,
|
||||
key,
|
||||
value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string GetRequiredString(IReadOnlyDictionary<string, JsonElement> parameters, string name)
|
||||
{
|
||||
if (TryGetString(parameters, name, out string? value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Function executor requires a non-empty string parameter \"{name}\".");
|
||||
}
|
||||
|
||||
private static JsonElement GetRequiredJson(IReadOnlyDictionary<string, JsonElement> parameters, string name)
|
||||
{
|
||||
if (TryGetParameter(parameters, name, out JsonElement value))
|
||||
{
|
||||
return WorkflowValueSerializer.CloneElement(value);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Function executor requires parameter \"{name}\".");
|
||||
}
|
||||
|
||||
private static bool TryGetString(IReadOnlyDictionary<string, JsonElement> parameters, string name, out string? value)
|
||||
{
|
||||
value = null;
|
||||
if (!TryGetParameter(parameters, name, out JsonElement element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.True => bool.TrueString,
|
||||
JsonValueKind.False => bool.FalseString,
|
||||
JsonValueKind.Number => element.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
return value is not null;
|
||||
}
|
||||
|
||||
private static bool TryGetBoolean(IReadOnlyDictionary<string, JsonElement> parameters, string name, out bool value)
|
||||
{
|
||||
value = false;
|
||||
if (!TryGetParameter(parameters, name, out JsonElement element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
value = element.GetBoolean();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String && bool.TryParse(element.GetString(), out bool parsed))
|
||||
{
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetParameter(IReadOnlyDictionary<string, JsonElement> parameters, string name, out JsonElement value)
|
||||
{
|
||||
foreach ((string key, JsonElement parameterValue) in parameters)
|
||||
{
|
||||
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = parameterValue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRequestPortIngressExecutor(
|
||||
WorkflowRequestPortNodeDefinition definition,
|
||||
RequestPort port)
|
||||
: Executor($"{definition.NodeId}::request-entry", declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
private readonly WorkflowRequestPortNodeDefinition _definition = definition;
|
||||
private readonly RequestPort _port = port;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
|
||||
.AddHandler<TurnToken>(static (_, _, _) => default)
|
||||
.AddCatchAll(HandleAsync))
|
||||
.SendsMessage<WorkflowRequestPortPromptRequest>();
|
||||
}
|
||||
|
||||
private async ValueTask HandleAsync(
|
||||
PortableValue message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
object input = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
|
||||
WorkflowRequestPortPromptRequest request = new()
|
||||
{
|
||||
NodeId = _definition.NodeId,
|
||||
NodeLabel = _definition.NodeLabel,
|
||||
PortId = _definition.PortId,
|
||||
RequestType = _definition.RequestType,
|
||||
ResponseType = _definition.ResponseType,
|
||||
Prompt = _definition.Prompt,
|
||||
InputSummary = WorkflowValueSerializer.ToPromptSummary(input),
|
||||
};
|
||||
|
||||
await context.SendMessageAsync(request, _port.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRequestPortResponseExecutor(string nodeId)
|
||||
: Executor($"{nodeId}::request-exit", declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
|
||||
.AddHandler<TurnToken>(static (_, _, _) => default)
|
||||
.AddHandler<ExternalResponse>(static (_, _, _) => default)
|
||||
.AddCatchAll(ForwardAsync))
|
||||
.SendsMessage<object>();
|
||||
}
|
||||
|
||||
private static ValueTask ForwardAsync(
|
||||
PortableValue message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
object payload = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
|
||||
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
internal static class WorkflowValueSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
|
||||
|
||||
public static JsonElement CloneElement(JsonElement value) => value.Clone();
|
||||
|
||||
public static JsonElement ParseJsonElement(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(json).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid JSON payload: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonElement CreateNullElement() => JsonDocument.Parse("null").RootElement.Clone();
|
||||
|
||||
public static List<ChatMessage> ToOutputMessages(object value)
|
||||
{
|
||||
if (value is List<ChatMessage> chatMessages)
|
||||
{
|
||||
return chatMessages;
|
||||
}
|
||||
|
||||
if (value is ChatMessage chatMessage)
|
||||
{
|
||||
return [chatMessage];
|
||||
}
|
||||
|
||||
if (value is ChatMessage[] chatMessageArray)
|
||||
{
|
||||
return [.. chatMessageArray];
|
||||
}
|
||||
|
||||
if (value is IEnumerable<ChatMessage> enumerable)
|
||||
{
|
||||
return enumerable.ToList();
|
||||
}
|
||||
|
||||
return [
|
||||
new ChatMessage(ChatRole.Assistant, ToDisplayText(value))
|
||||
{
|
||||
AuthorName = "Workflow",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public static string ToDisplayText(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (value is string text)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
return jsonElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => jsonElement.GetString() ?? string.Empty,
|
||||
JsonValueKind.True => bool.TrueString,
|
||||
JsonValueKind.False => bool.FalseString,
|
||||
JsonValueKind.Number => jsonElement.ToString(),
|
||||
JsonValueKind.Null => "null",
|
||||
_ => jsonElement.GetRawText(),
|
||||
};
|
||||
}
|
||||
|
||||
if (value is ChatMessage chatMessage)
|
||||
{
|
||||
return chatMessage.Text ?? string.Empty;
|
||||
}
|
||||
|
||||
if (value is IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
return string.Join(Environment.NewLine, messages.Select(static message => message.Text ?? string.Empty));
|
||||
}
|
||||
|
||||
if (value is bool boolean)
|
||||
{
|
||||
return boolean ? bool.TrueString : bool.FalseString;
|
||||
}
|
||||
|
||||
if (value is IFormattable formattable)
|
||||
{
|
||||
return formattable.ToString(null, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
public static string? ToPromptSummary(object? value)
|
||||
{
|
||||
if (value is null || value is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string summary = ToDisplayText(value);
|
||||
return string.IsNullOrWhiteSpace(summary) ? null : summary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
private const string ToolCallingActivityType = "tool-calling";
|
||||
private const string CodeInterpreterToolName = "code interpreter";
|
||||
private const string ImageGenerationToolName = "image generation";
|
||||
private const int MaxToolArgumentValueLength = 4000;
|
||||
private const string TruncatedToolArgumentValue = "[truncated]";
|
||||
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
|
||||
|
||||
public static AgentActivityEventDto? TryCreateActivityFromRequest(
|
||||
@@ -20,7 +22,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
AgentIdentity? activeAgent,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo);
|
||||
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo);
|
||||
return interpretation switch
|
||||
{
|
||||
HandoffRequestInterpretation handoff =>
|
||||
@@ -35,8 +37,8 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)
|
||||
&& InterpretRequest(command.Pattern, requestInfo) is UnknownRequestInterpretation;
|
||||
return command.Workflow.IsOrchestrationMode("handoff")
|
||||
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation;
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateHandoffActivity(
|
||||
@@ -80,6 +82,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
AgentName = activeAgent.AgentName,
|
||||
ToolName = tool.ToolName,
|
||||
ToolCallId = tool.ToolCallId,
|
||||
ToolArguments = tool.ToolArguments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,21 +98,21 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
}
|
||||
|
||||
private static RequestInterpretation InterpretRequest(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
if (TryGetHandoffTarget(pattern, requestInfo, out AgentIdentity handoffAgent))
|
||||
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent))
|
||||
{
|
||||
return new HandoffRequestInterpretation(handoffAgent);
|
||||
}
|
||||
|
||||
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)
|
||||
? new ToolRequestInterpretation(toolName, toolCallId)
|
||||
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId, out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
? new ToolRequestInterpretation(toolName, toolCallId, toolArguments)
|
||||
: new UnknownRequestInterpretation();
|
||||
}
|
||||
|
||||
private static bool TryGetHandoffTarget(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
RequestInfoEvent requestInfo,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
@@ -128,7 +131,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
}
|
||||
|
||||
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
target.Id,
|
||||
target.Name);
|
||||
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||
@@ -137,33 +140,38 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
private static bool TryGetToolRequestInfo(
|
||||
RequestInfoEvent requestInfo,
|
||||
out string toolName,
|
||||
out string? toolCallId)
|
||||
out string? toolCallId,
|
||||
out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)
|
||||
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId);
|
||||
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments)
|
||||
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments);
|
||||
}
|
||||
|
||||
private static bool TryGetStableToolRequestInfo(
|
||||
PortableValue requestData,
|
||||
out string toolName,
|
||||
out string? toolCallId)
|
||||
out string? toolCallId,
|
||||
out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall))
|
||||
{
|
||||
toolName = NormalizeOptionalString(functionCall.Name) ?? "function";
|
||||
toolCallId = NormalizeOptionalString(functionCall.CallId);
|
||||
toolArguments = NormalizeToolArguments(functionCall.Arguments);
|
||||
return true;
|
||||
}
|
||||
|
||||
toolName = string.Empty;
|
||||
toolCallId = null;
|
||||
toolArguments = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetEvaluationToolRequestInfo(
|
||||
PortableValue requestData,
|
||||
out string toolName,
|
||||
out string? toolCallId)
|
||||
out string? toolCallId,
|
||||
out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
|
||||
{
|
||||
@@ -171,6 +179,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
?? NormalizeOptionalString(mcpToolCall.ServerName)
|
||||
?? string.Empty;
|
||||
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
|
||||
toolArguments = NormalizeToolArguments(mcpToolCall.Arguments);
|
||||
return toolName.Length > 0;
|
||||
}
|
||||
|
||||
@@ -178,6 +187,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
{
|
||||
toolName = CodeInterpreterToolName;
|
||||
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
|
||||
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -185,14 +195,196 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
{
|
||||
toolName = ImageGenerationToolName;
|
||||
toolCallId = null;
|
||||
toolArguments = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
toolName = string.Empty;
|
||||
toolCallId = null;
|
||||
toolArguments = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?>? NormalizeToolArguments(
|
||||
IEnumerable<KeyValuePair<string, object?>>? arguments)
|
||||
{
|
||||
if (arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, object?> argument in arguments)
|
||||
{
|
||||
string? key = NormalizeOptionalString(argument.Key);
|
||||
if (key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? value = NormalizeToolArgumentValue(argument.Value);
|
||||
if (value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[key] = value;
|
||||
}
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?>? NormalizeCodeInterpreterToolArguments(
|
||||
CodeInterpreterToolCallContent codeInterpreterToolCall)
|
||||
{
|
||||
IList<AIContent>? rawInputs = codeInterpreterToolCall.Inputs;
|
||||
if (rawInputs is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<object?> inputs = [];
|
||||
foreach (AIContent input in rawInputs)
|
||||
{
|
||||
object? normalized = input switch
|
||||
{
|
||||
TextContent text => NormalizeToolArgumentValue(text.Text),
|
||||
_ => BuildAiContentFallbackValue(input),
|
||||
};
|
||||
|
||||
if (normalized is not null)
|
||||
{
|
||||
inputs.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return inputs.Count > 0
|
||||
? new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["inputs"] = inputs,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
private static object? NormalizeToolArgumentValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
string text => NormalizeToolArgumentText(text),
|
||||
JsonElement element => NormalizeToolArgumentElement(element),
|
||||
bool boolean => boolean,
|
||||
byte number => number,
|
||||
sbyte number => number,
|
||||
short number => number,
|
||||
ushort number => number,
|
||||
int number => number,
|
||||
uint number => number,
|
||||
long number => number,
|
||||
ulong number => number,
|
||||
float number => number,
|
||||
double number => number,
|
||||
decimal number => number,
|
||||
AIContent content => BuildAiContentFallbackValue(content),
|
||||
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
|
||||
IEnumerable<object?> sequence => NormalizeToolArgumentSequence(sequence),
|
||||
_ => NormalizeUnknownToolArgumentValue(value),
|
||||
};
|
||||
}
|
||||
|
||||
private static object? NormalizeToolArgumentElement(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
JsonValueKind.String => NormalizeToolArgumentText(element.GetString()),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Number => element.Deserialize<object?>(JsonOptions),
|
||||
JsonValueKind.Object => NormalizeToolArgumentObject(element),
|
||||
JsonValueKind.Array => NormalizeToolArgumentArray(element),
|
||||
_ => NormalizeToolArgumentText(element.GetRawText()),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?>? NormalizeToolArgumentObject(JsonElement element)
|
||||
{
|
||||
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
string? key = NormalizeOptionalString(property.Name);
|
||||
if (key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? value = NormalizeToolArgumentElement(property.Value);
|
||||
if (value is not null)
|
||||
{
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object?>? NormalizeToolArgumentArray(JsonElement element)
|
||||
{
|
||||
List<object?> normalized = [];
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
object? value = NormalizeToolArgumentElement(item);
|
||||
if (value is not null)
|
||||
{
|
||||
normalized.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object?>? NormalizeToolArgumentSequence(IEnumerable<object?> sequence)
|
||||
{
|
||||
List<object?> normalized = [];
|
||||
foreach (object? item in sequence)
|
||||
{
|
||||
object? value = NormalizeToolArgumentValue(item);
|
||||
if (value is not null)
|
||||
{
|
||||
normalized.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private static object? NormalizeUnknownToolArgumentValue(object value)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
return NormalizeToolArgumentElement(document.RootElement);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> BuildAiContentFallbackValue(AIContent content)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["type"] = content.GetType().Name,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeToolArgumentText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Length > MaxToolArgumentValueLength
|
||||
? TruncatedToolArgumentValue
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
@@ -208,7 +400,10 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
|
||||
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
|
||||
|
||||
private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation;
|
||||
private sealed record ToolRequestInterpretation(
|
||||
string ToolName,
|
||||
string? ToolCallId,
|
||||
IReadOnlyDictionary<string, object?>? ToolArguments) : RequestInterpretation;
|
||||
|
||||
private sealed record UnknownRequestInterpretation : RequestInterpretation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class WorkflowRunner
|
||||
{
|
||||
public Workflow BuildWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
|
||||
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
||||
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
|
||||
List<string> agentIds = ResolveAgentIds(workflowDefinition, workflowLibraryMap);
|
||||
Dictionary<string, AIAgent> agentMap = agentIds
|
||||
.Zip(agents, (agentId, agent) => (agentId, agent))
|
||||
.ToDictionary(pair => pair.agentId, pair => pair.agent, StringComparer.Ordinal);
|
||||
|
||||
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
|
||||
}
|
||||
|
||||
private Workflow BuildWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
|
||||
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase));
|
||||
WorkflowStateScopeCatalog stateCatalog = new(workflowDefinition.Settings.StateScopes);
|
||||
|
||||
Dictionary<string, WorkflowNodeRoute> routes = new(StringComparer.Ordinal);
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
routes[node.Id] = CreateNodeRoute(node, agentMap, workflowLibrary, stateCatalog);
|
||||
}
|
||||
|
||||
WorkflowBuilder builder = new(routes[startNode.Id].Entry);
|
||||
|
||||
foreach (WorkflowNodeRoute route in routes.Values)
|
||||
{
|
||||
foreach ((ExecutorBinding source, ExecutorBinding target) in route.InternalEdges)
|
||||
{
|
||||
builder.AddEdge(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflowDefinition.Graph.Edges.Where(edge =>
|
||||
string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Func<object?, bool>? condition = WorkflowConditionEvaluator.Compile(edge);
|
||||
ExecutorBinding source = routes[edge.Source].Exit;
|
||||
ExecutorBinding target = routes[edge.Target].Entry;
|
||||
if (condition is null)
|
||||
{
|
||||
builder.AddEdge(source, target);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AddEdge<object>(source, target, condition);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflowDefinition.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
|
||||
{
|
||||
WorkflowEdgeDto[] fanOutEdges = fanOutGroup.ToArray();
|
||||
ExecutorBinding source = routes[fanOutGroup.Key].Exit;
|
||||
ExecutorBinding[] targets = fanOutEdges.Select(edge => routes[edge.Target].Entry).ToArray();
|
||||
Func<object?, bool>?[] compiledConditions = fanOutEdges
|
||||
.Select(WorkflowConditionEvaluator.Compile)
|
||||
.ToArray();
|
||||
bool hasConditionalRouting = fanOutEdges.Any(edge => edge.Condition is not null);
|
||||
if (!hasConditionalRouting)
|
||||
{
|
||||
builder.AddFanOutEdge(source, targets);
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.AddFanOutEdge<object>(
|
||||
source,
|
||||
targets,
|
||||
(payload, _) => fanOutEdges
|
||||
.Select((edge, index) => (edge, index))
|
||||
.Where(pair => compiledConditions[pair.index]?.Invoke(payload) ?? true)
|
||||
.Select(pair => pair.index)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflowDefinition.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
|
||||
{
|
||||
builder.AddFanInBarrierEdge(
|
||||
fanInGroup.Select(edge => routes[edge.Source].Exit).ToArray(),
|
||||
routes[fanInGroup.Key].Entry);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workflowDefinition.Name))
|
||||
{
|
||||
builder = builder.WithName(workflowDefinition.Name);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(routes[endNode.Id].Exit).Build();
|
||||
}
|
||||
|
||||
private WorkflowNodeRoute CreateNodeRoute(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
WorkflowStateScopeCatalog stateCatalog)
|
||||
{
|
||||
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ExecutorBinding binding = new ChatForwardingExecutor(node.Id).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ExecutorBinding binding = new WorkflowOutputMessagesExecutor(node.Id).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string agentId = !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
|
||||
if (!agentMap.TryGetValue(agentId, out AIAgent? agent))
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
|
||||
}
|
||||
|
||||
return new WorkflowNodeRoute(agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()));
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string implementation = NormalizeRequired(node.Config.Implementation, $"Workflow code executor \"{node.Id}\" requires an implementation.");
|
||||
ExecutorBinding binding = new WorkflowCodeExecutor(node.Id, implementation, stateCatalog).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string functionRef = NormalizeRequired(node.Config.FunctionRef, $"Workflow function executor \"{node.Id}\" requires a functionRef.");
|
||||
if (!WorkflowFunctionRegistry.IsSupported(functionRef))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Workflow function executor \"{node.Id}\" references unsupported functionRef \"{functionRef}\".");
|
||||
}
|
||||
|
||||
ExecutorBinding binding = new WorkflowFunctionExecutor(node.Id, functionRef, node.Config.Parameters, stateCatalog).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateRequestPortRoute(node);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WorkflowDefinitionDto subWorkflowDefinition = node.ResolveSubWorkflowDefinition(workflowLibrary);
|
||||
Workflow subWorkflow = BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary);
|
||||
return new WorkflowNodeRoute(subWorkflow.BindAsExecutor(node.Id));
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet.");
|
||||
}
|
||||
|
||||
private static WorkflowNodeRoute CreateRequestPortRoute(WorkflowNodeDto node)
|
||||
{
|
||||
WorkflowRequestPortNodeDefinition definition = new(
|
||||
node.Id,
|
||||
NormalizeOptionalString(node.Label) ?? node.Id,
|
||||
NormalizeRequired(node.Config.PortId, $"Workflow request port \"{node.Id}\" requires a portId."),
|
||||
NormalizeRequired(node.Config.RequestType, $"Workflow request port \"{node.Id}\" requires a requestType."),
|
||||
NormalizeRequired(node.Config.ResponseType, $"Workflow request port \"{node.Id}\" requires a responseType."),
|
||||
NormalizeOptionalString(node.Config.Prompt));
|
||||
|
||||
RequestPort port = new(definition.PortId, typeof(WorkflowRequestPortPromptRequest), typeof(object));
|
||||
ExecutorBinding entry = new WorkflowRequestPortIngressExecutor(definition, port).BindExecutor();
|
||||
ExecutorBinding portBinding = new RequestPortBinding(port, false);
|
||||
ExecutorBinding exit = new WorkflowRequestPortResponseExecutor(node.Id).BindExecutor();
|
||||
return new WorkflowNodeRoute(entry, exit, [(entry, portBinding), (portBinding, exit)]);
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string errorMessage)
|
||||
=> NormalizeOptionalString(value) ?? throw new InvalidOperationException(errorMessage);
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static List<string> ResolveAgentIds(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
return workflowDefinition.GetAllAgentNodes(workflowLibrary)
|
||||
.Select(node => node.GetAgentId())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record WorkflowNodeRoute(
|
||||
ExecutorBinding Entry,
|
||||
ExecutorBinding Exit,
|
||||
IReadOnlyList<(ExecutorBinding Source, ExecutorBinding Target)> InternalEdges)
|
||||
{
|
||||
public WorkflowNodeRoute(ExecutorBinding binding)
|
||||
: this(binding, binding, [])
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ internal static class WorkflowTranscriptProjector
|
||||
List<ChatMessageDto> projectedMessages = [];
|
||||
int fallbackOutputIndex = 0;
|
||||
string createdAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments);
|
||||
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Workflow, segments);
|
||||
List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
|
||||
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
|
||||
|
||||
@@ -84,7 +84,7 @@ internal static class WorkflowTranscriptProjector
|
||||
message,
|
||||
remainingSegments,
|
||||
assistantMessages.Count - messageIndex,
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
fallbackAgent);
|
||||
string content = ResolveProjectedContent(message, matchedSegment);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
@@ -133,7 +133,7 @@ internal static class WorkflowTranscriptProjector
|
||||
?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
||||
AuthorName = ResolveProjectedAuthorName(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
message.AuthorName,
|
||||
matchedSegment?.AuthorName,
|
||||
fallbackAgent),
|
||||
@@ -180,17 +180,17 @@ internal static class WorkflowTranscriptProjector
|
||||
{
|
||||
Id = segment.MessageId,
|
||||
Role = "assistant",
|
||||
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
|
||||
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Workflow, segment.AuthorName),
|
||||
Content = segment.Content,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<TranscriptSegment> PrepareSegmentsForProjection(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<TranscriptSegment> segments)
|
||||
{
|
||||
if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal)
|
||||
if (!workflow.IsOrchestrationMode("concurrent")
|
||||
|| segments.Count <= 1)
|
||||
{
|
||||
return segments.ToList();
|
||||
@@ -205,7 +205,7 @@ internal static class WorkflowTranscriptProjector
|
||||
for (int index = 0; index < segments.Count; index++)
|
||||
{
|
||||
TranscriptSegment segment = segments[index];
|
||||
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName);
|
||||
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName);
|
||||
latestSegmentByAuthor[authorKey] = (segment, index);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ internal static class WorkflowTranscriptProjector
|
||||
ChatMessage message,
|
||||
IReadOnlyList<TranscriptSegment> remainingSegments,
|
||||
int remainingMessageCount,
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
AgentIdentity? fallbackAgent)
|
||||
{
|
||||
if (remainingSegments.Count == 0)
|
||||
@@ -231,7 +231,7 @@ internal static class WorkflowTranscriptProjector
|
||||
if (messageText is not null)
|
||||
{
|
||||
string resolvedAuthorName = ResolveProjectedAuthorName(
|
||||
pattern,
|
||||
workflow,
|
||||
message.AuthorName,
|
||||
fallbackIdentifier: null,
|
||||
fallbackAgent);
|
||||
@@ -240,7 +240,7 @@ internal static class WorkflowTranscriptProjector
|
||||
remainingSegments,
|
||||
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
|
||||
&& string.Equals(
|
||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
||||
AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName),
|
||||
resolvedAuthorName,
|
||||
StringComparison.Ordinal),
|
||||
out TranscriptSegment authorMatchedSegment))
|
||||
@@ -264,7 +264,7 @@ internal static class WorkflowTranscriptProjector
|
||||
&& TryFindLastSegment(
|
||||
remainingSegments,
|
||||
segment => string.Equals(
|
||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
||||
AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName),
|
||||
fallbackAgent.Value.AgentName,
|
||||
StringComparison.Ordinal),
|
||||
out TranscriptSegment fallbackMatchedSegment))
|
||||
@@ -382,7 +382,7 @@ internal static class WorkflowTranscriptProjector
|
||||
}
|
||||
|
||||
private static string ResolveProjectedAuthorName(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? primaryIdentifier,
|
||||
string? fallbackIdentifier,
|
||||
AgentIdentity? fallbackAgent)
|
||||
@@ -399,16 +399,17 @@ internal static class WorkflowTranscriptProjector
|
||||
return fallbackAgent.Value.AgentName;
|
||||
}
|
||||
|
||||
if (pattern.Agents.Count == 1
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
|
||||
if (agentNodes.Count == 1
|
||||
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
||||
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||
{
|
||||
PatternAgentDefinitionDto singleAgent = pattern.Agents[0];
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(pattern, singleAgent.Id, singleAgent.Name);
|
||||
WorkflowNodeDto singleAgent = agentNodes[0];
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(workflow, singleAgent.GetAgentId(), singleAgent.GetAgentName());
|
||||
}
|
||||
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||
pattern,
|
||||
workflow,
|
||||
primaryIdentifier,
|
||||
fallbackIdentifier);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class WorkflowValidator
|
||||
{
|
||||
private static readonly HashSet<string> ExecutableNodeKinds = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"start",
|
||||
"end",
|
||||
"agent",
|
||||
"code-executor",
|
||||
"function-executor",
|
||||
"sub-workflow",
|
||||
"request-port",
|
||||
};
|
||||
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Validate(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
List<WorkflowValidationIssueDto> issues = [];
|
||||
Dictionary<string, WorkflowDefinitionDto>? workflowLibraryById = workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workflow.Name))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "name",
|
||||
Message = "Workflow name is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (workflow.Graph.Nodes.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph",
|
||||
Message = "Workflow graph must include nodes.",
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
Dictionary<string, WorkflowNodeDto> nodesById = new(StringComparer.Ordinal);
|
||||
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> incomingCounts = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> outgoingCounts = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (WorkflowNodeDto node in workflow.Graph.Nodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Id))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.id",
|
||||
Message = "Workflow nodes must have an ID.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodesById.TryAdd(node.Id, node))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.id",
|
||||
NodeId = node.Id,
|
||||
Message = $"Workflow graph contains duplicate node \"{node.Id}\".",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ExecutableNodeKinds.Contains(node.Kind))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.kind",
|
||||
NodeId = node.Id,
|
||||
Message = $"Workflow node kind \"{node.Kind}\" is not executable yet.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Config.Name))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.name",
|
||||
NodeId = node.Id,
|
||||
Message = "Agent nodes require a name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.Model))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.model",
|
||||
NodeId = node.Id,
|
||||
Message = $"Agent node \"{node.Label}\" requires a model.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ValidateExecutableNode(node, issues);
|
||||
ValidateSubWorkflowNode(node, workflowLibraryById, issues);
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.Id))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.id",
|
||||
Message = "Workflow edges must have an ID.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!edgeIds.Add(edge.Id))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.id",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Workflow graph contains duplicate edge \"{edge.Id}\".",
|
||||
});
|
||||
}
|
||||
|
||||
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Workflow edge \"{edge.Id}\" must connect known nodes.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
outgoingCounts[edge.Source] = outgoingCounts.TryGetValue(edge.Source, out int outgoing)
|
||||
? outgoing + 1
|
||||
: 1;
|
||||
incomingCounts[edge.Target] = incomingCounts.TryGetValue(edge.Target, out int incoming)
|
||||
? incoming + 1
|
||||
: 1;
|
||||
|
||||
ValidateEdgeCondition(edge, issues);
|
||||
}
|
||||
|
||||
List<WorkflowNodeDto> startNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> executableWorkNodes = workflow.Graph.Nodes
|
||||
.Where(node =>
|
||||
string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (startNodes.Count != 1)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain exactly one start node.",
|
||||
});
|
||||
}
|
||||
|
||||
if (endNodes.Count != 1)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain exactly one end node.",
|
||||
});
|
||||
}
|
||||
|
||||
if (executableWorkNodes.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain at least one executable work node.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto startNode in startNodes)
|
||||
{
|
||||
if (incomingCounts.GetValueOrDefault(startNode.Id) != 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
NodeId = startNode.Id,
|
||||
Message = "Start nodes cannot have incoming edges.",
|
||||
});
|
||||
}
|
||||
|
||||
if (outgoingCounts.GetValueOrDefault(startNode.Id) == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
NodeId = startNode.Id,
|
||||
Message = "Start nodes must connect to at least one downstream node.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto endNode in endNodes)
|
||||
{
|
||||
if (outgoingCounts.GetValueOrDefault(endNode.Id) != 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
NodeId = endNode.Id,
|
||||
Message = "End nodes cannot have outgoing edges.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflow.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
|
||||
{
|
||||
if (fanOutGroup.Count() < 2)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.kind",
|
||||
NodeId = fanOutGroup.Key,
|
||||
Message = "Fan-out edges require at least two outgoing fan-out connections from the same source.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflow.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
|
||||
{
|
||||
if (fanInGroup.Count() < 2)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.kind",
|
||||
NodeId = fanInGroup.Key,
|
||||
Message = "Fan-in edges require at least two incoming fan-in connections to the same target.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowNodeDto? start = startNodes.FirstOrDefault();
|
||||
if (start is not null && endNodes.Count > 0 && !HasPathToAnyEnd(start.Id, workflow.Graph, endNodes.Select(node => node.Id)))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
Message = "Workflow graph must include a path from the start node to at least one end node.",
|
||||
});
|
||||
}
|
||||
|
||||
if (workflow.Settings.MaxIterations is int workflowMaxIterations
|
||||
&& (workflowMaxIterations < 1 || workflowMaxIterations > 100))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "settings.maxIterations",
|
||||
Message = "Workflow maxIterations must be between 1 and 100.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
||||
{
|
||||
bool participatesInCycle = IsLoopEdge(workflow.Graph, edge);
|
||||
if (!participatesInCycle)
|
||||
{
|
||||
if (edge.IsLoop == true)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Level = "warning",
|
||||
Field = "graph.edges.isLoop",
|
||||
EdgeId = edge.Id,
|
||||
Message = "This edge is marked as a loop but does not currently form a cycle.",
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.kind",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop edges currently support only direct edges.",
|
||||
});
|
||||
}
|
||||
|
||||
if (edge.IsLoop != true)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.isLoop",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Edges that participate in a cycle must be explicitly marked as loops.",
|
||||
});
|
||||
}
|
||||
|
||||
if ((edge.Condition is null || string.Equals(edge.Condition.Type, "always", StringComparison.OrdinalIgnoreCase))
|
||||
&& (edge.MaxIterations is null || edge.MaxIterations < 1))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop edges require either a non-default condition or a maxIterations cap so the loop can terminate.",
|
||||
});
|
||||
}
|
||||
|
||||
if (edge.MaxIterations is null || edge.MaxIterations < 1)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.maxIterations",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop edges require a maxIterations value of at least 1.",
|
||||
});
|
||||
}
|
||||
|
||||
HashSet<string> componentNodes = CollectStronglyConnectedNodes(workflow.Graph, edge.Source);
|
||||
bool hasExitPath = workflow.Graph.Edges.Any(candidate =>
|
||||
componentNodes.Contains(candidate.Source) && !componentNodes.Contains(candidate.Target));
|
||||
if (!hasExitPath)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop cycles must include an exit path to a node outside the loop.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private void ValidateSubWorkflowNode(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibraryById,
|
||||
List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasWorkflowId = !string.IsNullOrWhiteSpace(node.Config.WorkflowId);
|
||||
bool hasInlineWorkflow = node.Config.InlineWorkflow is not null;
|
||||
if (hasWorkflowId == hasInlineWorkflow)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config",
|
||||
NodeId = node.Id,
|
||||
Message = "Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWorkflowId
|
||||
&& workflowLibraryById is not null
|
||||
&& !workflowLibraryById.ContainsKey(node.Config.WorkflowId!))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.workflowId",
|
||||
NodeId = node.Id,
|
||||
Message = $"Sub-workflow node \"{node.Label}\" references unknown workflow \"{node.Config.WorkflowId}\".",
|
||||
});
|
||||
}
|
||||
|
||||
if (node.Config.InlineWorkflow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (WorkflowValidationIssueDto inlineIssue in Validate(node.Config.InlineWorkflow, workflowLibraryById?.Values.ToList()))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Level = inlineIssue.Level,
|
||||
Field = inlineIssue.Field is null
|
||||
? "graph.nodes.config.inlineWorkflow"
|
||||
: $"graph.nodes.config.inlineWorkflow.{inlineIssue.Field}",
|
||||
NodeId = node.Id,
|
||||
EdgeId = inlineIssue.EdgeId,
|
||||
Message = $"Inline workflow for node \"{node.Label}\": {inlineIssue.Message}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateExecutableNode(
|
||||
WorkflowNodeDto node,
|
||||
List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Config.Implementation))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.implementation",
|
||||
NodeId = node.Id,
|
||||
Message = "Code executor nodes require a non-empty implementation.",
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Config.FunctionRef))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.functionRef",
|
||||
NodeId = node.Id,
|
||||
Message = "Function executor nodes require a non-empty functionRef.",
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.PortId))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.portId",
|
||||
NodeId = node.Id,
|
||||
Message = "Request port nodes require a non-empty portId.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.RequestType))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.requestType",
|
||||
NodeId = node.Id,
|
||||
Message = "Request port nodes require a non-empty requestType.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.ResponseType))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.responseType",
|
||||
NodeId = node.Id,
|
||||
Message = "Request port nodes require a non-empty responseType.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (edge.Condition is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Fan-in edges do not support conditions.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!WorkflowConditionEvaluator.IsSupportedConditionType(edge.Condition.Type))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.type",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Condition type \"{edge.Condition.Type}\" is not supported.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Condition.Type, "message-type", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrWhiteSpace(edge.Condition.TypeName))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.typeName",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Message-type conditions require a type name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.Condition.Expression))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.expression",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Expression conditions require a non-empty expression.",
|
||||
});
|
||||
}
|
||||
else if (!WorkflowConditionEvaluator.IsSupportedExpression(edge.Condition.Expression))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.expression",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Expression conditions currently support simple comparisons using ==, !=, >, <, contains, matches, optionally combined with && or ||.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Condition.Type, "property", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (edge.Condition.Rules.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Property conditions require at least one rule.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.Condition.Combinator)
|
||||
&& !string.Equals(edge.Condition.Combinator, "and", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(edge.Condition.Combinator, "or", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.combinator",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Property conditions must use the \"and\" or \"or\" combinator.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (WorkflowConditionRuleDto rule in edge.Condition.Rules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rule.PropertyPath))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules.propertyPath",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Property condition rules require a property path.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!WorkflowConditionEvaluator.IsSupportedOperator(rule.Operator))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules.operator",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Property condition operator \"{rule.Operator}\" is not supported.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(rule.Operator, "regex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new System.Text.RegularExpressions.Regex(rule.Value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules.value",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Regex pattern \"{rule.Value}\" is invalid.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasPathToAnyEnd(
|
||||
string startNodeId,
|
||||
WorkflowGraphDto graph,
|
||||
IEnumerable<string> endNodeIds)
|
||||
{
|
||||
HashSet<string> endSet = endNodeIds.ToHashSet(StringComparer.Ordinal);
|
||||
Dictionary<string, List<string>> outgoing = graph.Edges
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(edge => edge.Target).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Queue<string> queue = new([startNodeId]);
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
string current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (endSet.Contains(current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (string target in outgoing.GetValueOrDefault(current, []))
|
||||
{
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanReachNode(
|
||||
WorkflowGraphDto graph,
|
||||
string startNodeId,
|
||||
string targetNodeId,
|
||||
string? excludedEdgeId = null)
|
||||
{
|
||||
if (string.Equals(startNodeId, targetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Dictionary<string, List<string>> outgoing = graph.Edges
|
||||
.Where(edge => !string.Equals(edge.Id, excludedEdgeId, StringComparison.Ordinal))
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(edge => edge.Target).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Queue<string> queue = new([startNodeId]);
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
string current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string target in outgoing.GetValueOrDefault(current, []))
|
||||
{
|
||||
if (string.Equals(target, targetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsLoopEdge(WorkflowGraphDto graph, WorkflowEdgeDto edge)
|
||||
=> CanReachNode(graph, edge.Target, edge.Source, edge.Id);
|
||||
|
||||
private static HashSet<string> CollectStronglyConnectedNodes(WorkflowGraphDto graph, string nodeId)
|
||||
{
|
||||
HashSet<string> connected = new(StringComparer.Ordinal);
|
||||
foreach (WorkflowNodeDto candidate in graph.Nodes)
|
||||
{
|
||||
if (CanReachNode(graph, nodeId, candidate.Id) && CanReachNode(graph, candidate.Id, nodeId))
|
||||
{
|
||||
connected.Add(candidate.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent("agent-concurrent-architect", "Architect"),
|
||||
CreateAgent("agent-concurrent-product", "Product"),
|
||||
]);
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"Architect_agent_concurrent_architect",
|
||||
out AgentIdentity agent);
|
||||
|
||||
@@ -27,14 +27,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
CreateAgent("agent-single-primary", "Primary Agent"),
|
||||
],
|
||||
mode: "single");
|
||||
orchestrationMode: "single");
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"Primary_Agent_agent_single_primary",
|
||||
out AgentIdentity agent);
|
||||
|
||||
@@ -46,14 +46,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
CreateAgent("agent-single-primary", "Primary Agent"),
|
||||
],
|
||||
mode: "single");
|
||||
orchestrationMode: "single");
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"assistant",
|
||||
out AgentIdentity agent);
|
||||
|
||||
@@ -63,16 +63,16 @@ public sealed class AgentIdentityResolverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentPattern()
|
||||
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentWorkflow()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent("agent-concurrent-architect", "Architect"),
|
||||
CreateAgent("agent-concurrent-product", "Product"),
|
||||
]);
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"assistant",
|
||||
out _);
|
||||
|
||||
@@ -82,14 +82,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void ResolveDisplayAuthorName_UsesCanonicalAgentName()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
||||
CreateAgent("agent-concurrent-implementer", "Implementer"),
|
||||
],
|
||||
mode: "single");
|
||||
orchestrationMode: "single");
|
||||
|
||||
string authorName = AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||
pattern,
|
||||
workflow,
|
||||
"Implementer_agent_concurrent_implementer");
|
||||
|
||||
Assert.Equal("Implementer", authorName);
|
||||
@@ -98,14 +98,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
CreateAgent("agent-handoff-runtime", "Runtime Specialist"),
|
||||
]);
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"assistant",
|
||||
new AgentIdentity("agent-handoff-ux", "UX Specialist"),
|
||||
out AgentIdentity agent);
|
||||
@@ -115,28 +115,43 @@ public sealed class AgentIdentityResolverTests
|
||||
Assert.Equal("UX Specialist", agent.AgentName);
|
||||
}
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(
|
||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||
string mode = "concurrent")
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
IReadOnlyList<WorkflowNodeDto> agents,
|
||||
string orchestrationMode = "concurrent")
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"{mode}-pattern",
|
||||
Name = "Pattern",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents = agents,
|
||||
Id = $"{orchestrationMode}-workflow",
|
||||
Name = "Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
.. agents,
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = orchestrationMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,10 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_LeavesNonHandoffInstructionsUnchanged()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-sequential",
|
||||
Name = "Sequential",
|
||||
Mode = "sequential",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-reviewer",
|
||||
name: "Reviewer",
|
||||
instructions: "Review the proposal.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("sequential");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-reviewer", "Reviewer", "Review the proposal.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, agent, agentIndex: 0);
|
||||
string instructions = AgentInstructionComposer.Compose(workflow, agent, agentIndex: 0);
|
||||
|
||||
Assert.Equal("Review the proposal.", instructions);
|
||||
}
|
||||
@@ -28,24 +19,12 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_StrengthensGroupChatCollaborationRoles()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-group-chat",
|
||||
Name = "Group Chat",
|
||||
Mode = "group-chat",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto writer = CreateAgent(
|
||||
id: "agent-group-writer",
|
||||
name: "Writer",
|
||||
instructions: "Draft an answer.");
|
||||
PatternAgentDefinitionDto reviewer = CreateAgent(
|
||||
id: "agent-group-reviewer",
|
||||
name: "Reviewer",
|
||||
instructions: "Review the draft.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("group-chat");
|
||||
WorkflowNodeDto writer = CreateAgent("agent-group-writer", "Writer", "Draft an answer.");
|
||||
WorkflowNodeDto reviewer = CreateAgent("agent-group-reviewer", "Reviewer", "Review the draft.");
|
||||
|
||||
string writerInstructions = AgentInstructionComposer.Compose(pattern, writer, agentIndex: 0);
|
||||
string reviewerInstructions = AgentInstructionComposer.Compose(pattern, reviewer, agentIndex: 1);
|
||||
string writerInstructions = AgentInstructionComposer.Compose(workflow, writer, agentIndex: 0);
|
||||
string reviewerInstructions = AgentInstructionComposer.Compose(workflow, reviewer, agentIndex: 1);
|
||||
|
||||
Assert.Contains("collaborative multi-turn group chat", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("refine your earlier draft", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -56,19 +35,13 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_LeavesHandoffTriagePromptFocusedOnAgentInstructions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto triage = CreateAgent(
|
||||
id: "agent-handoff-triage",
|
||||
name: "Triage",
|
||||
instructions: "You triage requests and must hand them off to the most appropriate specialist.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("handoff");
|
||||
WorkflowNodeDto triage = CreateAgent(
|
||||
"agent-handoff-triage",
|
||||
"Triage",
|
||||
"You triage requests and must hand them off to the most appropriate specialist.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0);
|
||||
string instructions = AgentInstructionComposer.Compose(workflow, triage, agentIndex: 0);
|
||||
|
||||
Assert.Equal("You triage requests and must hand them off to the most appropriate specialist.", instructions);
|
||||
Assert.DoesNotContain("routing", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -78,19 +51,13 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_LeavesHandoffSpecialistPromptFocusedOnAgentInstructions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto specialist = CreateAgent(
|
||||
id: "agent-handoff-ux",
|
||||
name: "UX Specialist",
|
||||
instructions: "You focus on navigation, UX, and interaction details.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("handoff");
|
||||
WorkflowNodeDto specialist = CreateAgent(
|
||||
"agent-handoff-ux",
|
||||
"UX Specialist",
|
||||
"You focus on navigation, UX, and interaction details.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1);
|
||||
string instructions = AgentInstructionComposer.Compose(workflow, specialist, agentIndex: 1);
|
||||
|
||||
Assert.Equal("You focus on navigation, UX, and interaction details.", instructions);
|
||||
Assert.DoesNotContain("triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -100,20 +67,11 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_AddsScratchpadGuidanceForProjectlessQaSessions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-primary",
|
||||
name: "Primary Agent",
|
||||
instructions: "You are a helpful assistant.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
pattern,
|
||||
workflow,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
workspaceKind: "scratchpad");
|
||||
@@ -127,20 +85,11 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_AddsPlanModeGuidanceWhenRequested()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-primary",
|
||||
name: "Primary Agent",
|
||||
instructions: "You are a helpful assistant.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
pattern,
|
||||
workflow,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
interactionMode: "plan");
|
||||
@@ -154,20 +103,11 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_InsertsProjectInstructionsBetweenBaseAndRuntimeGuidance()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-primary",
|
||||
name: "Primary Agent",
|
||||
instructions: "You are a helpful assistant.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
pattern,
|
||||
workflow,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
workspaceKind: "scratchpad",
|
||||
@@ -184,14 +124,69 @@ public sealed class AgentInstructionComposerTests
|
||||
< instructions.IndexOf("scratchpad mode", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
|
||||
[Fact]
|
||||
public void Compose_AppendsPromptInvocationAsATaskDirective()
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
workflow,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
promptInvocation: new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
Description = "Review docs for missing steps",
|
||||
Agent = "plan",
|
||||
Model = "Claude Sonnet 4.5",
|
||||
Tools = ["view", "glob"],
|
||||
ResolvedPrompt = "Review the docs for missing steps and propose updates."
|
||||
});
|
||||
|
||||
Assert.Contains("repository prompt file", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(@"Source: .github\prompts\docs\doc-review.prompt.md", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Name: doc-review", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Description: Review docs for missing steps", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Agent: plan", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Model: Claude Sonnet 4.5", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Tools: view, glob", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Prompt instructions:\nReview the docs for missing steps and propose updates.",
|
||||
instructions,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(string orchestrationMode)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"{orchestrationMode}-workflow",
|
||||
Name = "Workflow",
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = orchestrationMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateAgent(string id, string name, string instructions)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Instructions = instructions,
|
||||
Model = "gpt-5.4",
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Instructions = instructions,
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -11,6 +11,24 @@ namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotAgentBundleTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAllAgentNodes_IncludesReferencedSubworkflowAgentsInTraversalOrder()
|
||||
{
|
||||
WorkflowDefinitionDto childWorkflow = CreateSubworkflowChild(
|
||||
"child-workflow",
|
||||
CreateAgentNode("agent-child-1", "Child Agent 1"),
|
||||
CreateAgentNode("agent-child-2", "Child Agent 2"));
|
||||
WorkflowDefinitionDto parentWorkflow = CreateSubworkflowParent(
|
||||
CreateAgentNode("agent-parent", "Parent Agent"),
|
||||
workflowId: childWorkflow.Id);
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = parentWorkflow.GetAllAgentNodes([childWorkflow]);
|
||||
|
||||
Assert.Equal(
|
||||
["agent-parent", "agent-child-1", "agent-child-2"],
|
||||
agentNodes.Select(node => node.GetAgentId()).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySessionTooling_MapsMcpServersAndToolsOntoTheSessionConfig()
|
||||
{
|
||||
@@ -54,6 +72,34 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPromptInvocation_RestrictsAvailableToolsAndKeepsHandoffTools()
|
||||
{
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
AvailableTools = ["view", "glob", "edit"],
|
||||
Tools = [CreateTool("view"), CreateTool("edit"), CreateTool("handoff_to_reviewer")],
|
||||
};
|
||||
|
||||
CopilotAgentBundle.ApplyPromptInvocation(
|
||||
sessionConfig,
|
||||
new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
ResolvedPrompt = "Review the docs for missing steps.",
|
||||
Tools = ["view"],
|
||||
});
|
||||
|
||||
Assert.Equal(["view", "ask_user", "report_intent", "task_complete"], sessionConfig.AvailableTools);
|
||||
|
||||
AIFunction[] tools = Assert.IsAssignableFrom<IEnumerable<AIFunction>>(sessionConfig.Tools).ToArray();
|
||||
Assert.Equal(2, tools.Length);
|
||||
Assert.Contains(tools, tool => tool.Name == "view");
|
||||
Assert.Contains(tools, tool => tool.Name == "handoff_to_reviewer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_StoresWhetherHooksAreConfigured()
|
||||
{
|
||||
@@ -130,34 +176,108 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("single", 1)]
|
||||
[InlineData("sequential", 2)]
|
||||
[InlineData("concurrent", 2)]
|
||||
[InlineData("group-chat", 2)]
|
||||
public void BuildWorkflow_ExplicitlyConfiguresAgentHostOptions(string mode, int agentCount)
|
||||
[Fact]
|
||||
public void CreateHandoffWorkflowBuilder_MapsConfiguredFilteringAndInstructions()
|
||||
{
|
||||
CopilotAgentBundle bundle = new(CreateAgents(agentCount), hasConfiguredHooks: false);
|
||||
PatternDefinitionDto pattern = CreatePattern(mode, agentCount);
|
||||
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
|
||||
|
||||
Workflow workflow = bundle.BuildWorkflow(pattern);
|
||||
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
|
||||
entryAgent,
|
||||
new HandoffModeSettingsDto
|
||||
{
|
||||
ToolCallFiltering = "all",
|
||||
ReturnToPrevious = true,
|
||||
HandoffInstructions = "Use custom delegation guidance.",
|
||||
});
|
||||
|
||||
AIAgentBinding[] bindings = workflow.ReflectExecutors().Values
|
||||
.OfType<AIAgentBinding>()
|
||||
.ToArray();
|
||||
FieldInfo filteringField = typeof(HandoffsWorkflowBuilder).GetField(
|
||||
"_toolCallFilteringBehavior",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
|
||||
|
||||
Assert.Equal(agentCount, bindings.Length);
|
||||
Assert.Equal(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder));
|
||||
Assert.Equal("Use custom delegation guidance.", builder.HandoffInstructions);
|
||||
}
|
||||
|
||||
foreach (AIAgentBinding binding in bindings)
|
||||
{
|
||||
AIAgentHostOptions options = Assert.IsType<AIAgentHostOptions>(binding.Options);
|
||||
Assert.Null(options.EmitAgentUpdateEvents);
|
||||
Assert.False(options.EmitAgentResponseEvents);
|
||||
Assert.False(options.InterceptUserInputRequests);
|
||||
Assert.False(options.InterceptUnterminatedFunctionCalls);
|
||||
Assert.True(options.ReassignOtherAgentsAsUsers);
|
||||
Assert.True(options.ForwardIncomingMessages);
|
||||
}
|
||||
[Fact]
|
||||
public void CreateHandoffWorkflow_RejectsUnknownTriageNode()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"handoff",
|
||||
2,
|
||||
modeSettings: new OrchestrationModeSettingsDto
|
||||
{
|
||||
Handoff = new HandoffModeSettingsDto
|
||||
{
|
||||
TriageAgentNodeId = "missing-agent",
|
||||
},
|
||||
});
|
||||
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
|
||||
CopilotAgentBundle.CreateHandoffWorkflow(workflow, CreateAgents(2)));
|
||||
|
||||
Assert.Contains("triage agent node", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateGroupChatWorkflowBuilder_UsesConfiguredRoundsNameAndDescription()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"group-chat",
|
||||
2,
|
||||
modeSettings: new OrchestrationModeSettingsDto
|
||||
{
|
||||
GroupChat = new GroupChatModeSettingsDto
|
||||
{
|
||||
SelectionStrategy = "round-robin",
|
||||
MaxRounds = 7,
|
||||
},
|
||||
},
|
||||
name: "Round Robin Collaboration",
|
||||
description: "Two agents iterate on a shared answer.");
|
||||
IReadOnlyList<AIAgent> agents = CreateAgents(2);
|
||||
|
||||
GroupChatWorkflowBuilder builder = CopilotAgentBundle.CreateGroupChatWorkflowBuilder(workflow, agents);
|
||||
|
||||
FieldInfo managerFactoryField = typeof(GroupChatWorkflowBuilder).GetField(
|
||||
"_managerFactory",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a manager factory field.");
|
||||
FieldInfo participantsField = typeof(GroupChatWorkflowBuilder).GetField(
|
||||
"_participants",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a participant field.");
|
||||
FieldInfo nameField = typeof(GroupChatWorkflowBuilder).GetField(
|
||||
"_name",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a name field.");
|
||||
FieldInfo descriptionField = typeof(GroupChatWorkflowBuilder).GetField(
|
||||
"_description",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a description field.");
|
||||
|
||||
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory =
|
||||
Assert.IsType<Func<IReadOnlyList<AIAgent>, GroupChatManager>>(managerFactoryField.GetValue(builder));
|
||||
RoundRobinGroupChatManager manager = Assert.IsType<RoundRobinGroupChatManager>(managerFactory(agents));
|
||||
HashSet<AIAgent> participants = Assert.IsType<HashSet<AIAgent>>(participantsField.GetValue(builder));
|
||||
|
||||
Assert.Equal(7, manager.MaximumIterationCount);
|
||||
Assert.Equal(2, participants.Count);
|
||||
Assert.Equal("Round Robin Collaboration", Assert.IsType<string>(nameField.GetValue(builder)));
|
||||
Assert.Equal("Two agents iterate on a shared answer.", Assert.IsType<string>(descriptionField.GetValue(builder)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAgentHostOptions_UsesExpectedAryxDefaults()
|
||||
{
|
||||
AIAgentHostOptions options = CopilotAgentBundle.CreateAgentHostOptions();
|
||||
|
||||
Assert.Null(options.EmitAgentUpdateEvents);
|
||||
Assert.False(options.EmitAgentResponseEvents);
|
||||
Assert.False(options.InterceptUserInputRequests);
|
||||
Assert.False(options.InterceptUnterminatedFunctionCalls);
|
||||
Assert.True(options.ReassignOtherAgentsAsUsers);
|
||||
Assert.True(options.ForwardIncomingMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -359,28 +479,12 @@ public sealed class CopilotAgentBundleTests
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
Workflow = CreateWorkflow("single", 1),
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Null(sessionConfig.SessionId);
|
||||
@@ -399,33 +503,74 @@ public sealed class CopilotAgentBundleTests
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
ProjectInstructions = "Follow repository guidance.",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
Workflow = CreateWorkflow("single", 1),
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSessionConfig_UsesPromptAgentOverride()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
PromptInvocation = new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
Agent = "designer",
|
||||
ResolvedPrompt = "Review the docs for missing steps.",
|
||||
},
|
||||
Workflow = CreateWorkflow("single", 1),
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Equal("designer", sessionConfig.Agent);
|
||||
Assert.Contains("Review the docs for missing steps.", sessionConfig.SystemMessage?.Content, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSessionConfig_DefaultsPromptToolInvocationsToAgentMode()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
PromptInvocation = new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
ResolvedPrompt = "Review the docs for missing steps.",
|
||||
Tools = ["view"],
|
||||
},
|
||||
Workflow = CreateWorkflow("single", 1),
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Equal("agent", sessionConfig.Agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse()
|
||||
{
|
||||
@@ -433,13 +578,10 @@ public sealed class CopilotAgentBundleTests
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
Workflow = CreateWorkflow(
|
||||
"single",
|
||||
1,
|
||||
new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
@@ -449,21 +591,10 @@ public sealed class CopilotAgentBundleTests
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0]);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0]);
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
@@ -484,7 +615,7 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal("agent-ux", agentId);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool()
|
||||
private static AIFunction CreateTool(string name = "echo")
|
||||
{
|
||||
ToolTarget target = new();
|
||||
MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo))
|
||||
@@ -495,7 +626,7 @@ public sealed class CopilotAgentBundleTests
|
||||
target,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "echo",
|
||||
Name = name,
|
||||
Description = "Echo test tool",
|
||||
});
|
||||
}
|
||||
@@ -513,25 +644,148 @@ public sealed class CopilotAgentBundleTests
|
||||
.Select(index => (AIAgent)CreateChatClientAgent($"agent-{index}", $"Agent {index}"))
|
||||
.ToArray();
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(string mode, int agentCount)
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
string mode,
|
||||
int agentCount,
|
||||
ApprovalPolicyDto? approvalPolicy = null,
|
||||
OrchestrationModeSettingsDto? modeSettings = null,
|
||||
string? name = null,
|
||||
string? description = null)
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"pattern-{mode}",
|
||||
Name = $"Pattern {mode}",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
.. Enumerable.Range(1, agentCount).Select(index => new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = $"agent-{index}",
|
||||
Name = $"Agent {index}",
|
||||
Description = $"Agent {index} description.",
|
||||
Instructions = $"Agent {index} instructions.",
|
||||
Model = "gpt-5.4",
|
||||
}),
|
||||
],
|
||||
Id = $"workflow-{mode}",
|
||||
Name = name ?? $"Workflow {mode}",
|
||||
Description = description ?? string.Empty,
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
.. Enumerable.Range(1, agentCount).Select(index => new WorkflowNodeDto
|
||||
{
|
||||
Id = $"agent-{index}",
|
||||
Kind = "agent",
|
||||
Label = $"Agent {index}",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = $"agent-{index}",
|
||||
Name = $"Agent {index}",
|
||||
Description = $"Agent {index} description.",
|
||||
Instructions = "Help.",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = mode,
|
||||
ApprovalPolicy = approvalPolicy,
|
||||
ModeSettings = modeSettings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
||||
WorkflowNodeDto directAgent,
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "parent-workflow",
|
||||
Name = "Parent Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
directAgent,
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "sub-workflow",
|
||||
Kind = "sub-workflow",
|
||||
Label = "Nested Workflow",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "sequential",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSubworkflowChild(string id, params WorkflowNodeDto[] agentNodes)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = "Child Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
.. agentNodes,
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "sequential",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateAgentNode(string id, string name)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = $"{name} description.",
|
||||
Instructions = "Help.",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -580,3 +834,4 @@ public sealed class CopilotAgentBundleTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class CopilotExitPlanModeCoordinatorTests
|
||||
|
||||
ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new ExitPlanModeRequestedEvent
|
||||
{
|
||||
Data = new ExitPlanModeRequestedData
|
||||
@@ -50,23 +50,36 @@ public sealed class CopilotExitPlanModeCoordinatorTests
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Plan Mode Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
Id = "workflow-1",
|
||||
Name = "Plan Mode Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Kind = "agent",
|
||||
Label = "Primary",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class CopilotMcpOAuthCoordinatorTests
|
||||
|
||||
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new McpOauthRequiredEvent
|
||||
{
|
||||
Data = new McpOauthRequiredData
|
||||
@@ -49,23 +49,36 @@ public sealed class CopilotMcpOAuthCoordinatorTests
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "MCP OAuth Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
Id = "workflow-1",
|
||||
Name = "MCP OAuth Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Kind = "agent",
|
||||
Label = "Primary",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class CopilotSessionHooksTests
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -64,7 +64,7 @@ public sealed class CopilotSessionHooksTests
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -93,7 +93,7 @@ public sealed class CopilotSessionHooksTests
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -123,7 +123,7 @@ public sealed class CopilotSessionHooksTests
|
||||
public async Task Create_PreToolUseAutoAllowsInternalOrchestrationTools(string toolName)
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -139,7 +139,7 @@ public sealed class CopilotSessionHooksTests
|
||||
public async Task Create_PreToolUseKeepsStoreMemoryUnderApprovalPolicy()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -159,7 +159,7 @@ public sealed class CopilotSessionHooksTests
|
||||
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -177,7 +177,7 @@ public sealed class CopilotSessionHooksTests
|
||||
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
|
||||
["icm-mcp"],
|
||||
["mcp_server:icm-mcp"]);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -193,7 +193,7 @@ public sealed class CopilotSessionHooksTests
|
||||
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -219,7 +219,7 @@ public sealed class CopilotSessionHooksTests
|
||||
ErrorOccurred = [CreateHookCommand("error-hook")],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
await hooks.OnSessionStart!(
|
||||
new SessionStartHookInput
|
||||
@@ -293,7 +293,7 @@ public sealed class CopilotSessionHooksTests
|
||||
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -312,34 +312,17 @@ public sealed class CopilotSessionHooksTests
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
Agents =
|
||||
Rules =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,15 +334,7 @@ public sealed class CopilotSessionHooksTests
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ProjectPath = command.ProjectPath,
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = command.Pattern.Id,
|
||||
Name = command.Pattern.Name,
|
||||
Mode = command.Pattern.Mode,
|
||||
Availability = command.Pattern.Availability,
|
||||
ApprovalPolicy = new ApprovalPolicyDto(),
|
||||
Agents = command.Pattern.Agents,
|
||||
},
|
||||
Workflow = CreateWorkflow(new ApprovalPolicyDto()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,35 +345,18 @@ public sealed class CopilotSessionHooksTests
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = [category],
|
||||
},
|
||||
Agents =
|
||||
Rules =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
AutoApprovedToolNames = [category],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,18 +374,44 @@ public sealed class CopilotSessionHooksTests
|
||||
{
|
||||
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
|
||||
},
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||
{
|
||||
Id = command.Pattern.Id,
|
||||
Name = command.Pattern.Name,
|
||||
Mode = command.Pattern.Mode,
|
||||
Availability = command.Pattern.Availability,
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules = command.Pattern.ApprovalPolicy?.Rules ?? [],
|
||||
AutoApprovedToolNames = autoApprovedToolNames ?? [],
|
||||
},
|
||||
Agents = command.Pattern.Agents,
|
||||
Rules = command.Workflow.Settings.ApprovalPolicy?.Rules ?? [],
|
||||
AutoApprovedToolNames = autoApprovedToolNames ?? [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(ApprovalPolicyDto approvalPolicy)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-1",
|
||||
Name = "Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Kind = "agent",
|
||||
Label = "Primary",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
ApprovalPolicy = approvalPolicy,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -477,3 +461,4 @@ public sealed class CopilotSessionHooksTests
|
||||
string InputJson,
|
||||
string ProjectPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new McpOauthRequiredEvent
|
||||
{
|
||||
Data = new McpOauthRequiredData
|
||||
@@ -37,7 +37,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -66,7 +66,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
@@ -94,6 +94,22 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Equal("handoff_to_specialist", toolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueCompletedActivity_QueuesCompletedAgentActivity()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.QueueCompletedActivity(new AgentIdentity("agent-1", "Primary"));
|
||||
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("completed", activity.ActivityType);
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
Assert.Equal(command.RequestId, activity.RequestId);
|
||||
Assert.Equal(command.SessionId, activity.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
|
||||
{
|
||||
@@ -101,7 +117,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -142,7 +158,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -158,7 +174,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -172,7 +188,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
"""));
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -207,7 +223,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -231,7 +247,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -267,7 +283,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -298,7 +314,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -356,7 +372,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -386,7 +402,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -417,7 +433,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
|
||||
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
|
||||
|
||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||
Assert.Equal("start", evt.Phase);
|
||||
@@ -432,7 +448,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
||||
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
|
||||
|
||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||
Assert.Equal("end", evt.Phase);
|
||||
@@ -450,8 +466,8 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
SuppressHookLifecycleEvents = true,
|
||||
};
|
||||
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
||||
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
|
||||
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
|
||||
|
||||
Assert.Empty(state.DrainPendingEvents());
|
||||
}
|
||||
@@ -463,7 +479,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -526,7 +542,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -561,7 +577,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -624,7 +640,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
|
||||
// Simulate assistant message with tool requests → triggers reclassification
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -672,23 +688,36 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "MCP OAuth Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
Id = "workflow-1",
|
||||
Name = "Execution State Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Kind = "agent",
|
||||
Label = "Primary",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class CopilotUserInputCoordinatorTests
|
||||
|
||||
Task<UserInputResponse> pending = coordinator.RequestUserInputAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new UserInputRequest
|
||||
{
|
||||
Question = "How should I proceed?",
|
||||
@@ -76,33 +76,40 @@ public sealed class CopilotUserInputCoordinatorTests
|
||||
Assert.Contains("is not pending", error.Message);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateUserInputCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "User Input Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
],
|
||||
Id = "workflow-1",
|
||||
Name = "User Input Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Kind = "agent",
|
||||
Label = "Primary",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
@@ -24,6 +26,82 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.True(state.SuppressHookLifecycleEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWorkflowForCommand_UsesGroupChatBuilderForGroupChatMode()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"group-chat",
|
||||
modeSettings: new OrchestrationModeSettingsDto
|
||||
{
|
||||
GroupChat = new GroupChatModeSettingsDto
|
||||
{
|
||||
SelectionStrategy = "round-robin",
|
||||
MaxRounds = 6,
|
||||
},
|
||||
},
|
||||
workflowName: "Collaborative Review",
|
||||
workflowDescription: "Two agents collaborate under a group chat manager.",
|
||||
agents:
|
||||
[
|
||||
CreateAgent("agent-group-writer", "Writer"),
|
||||
CreateAgent("agent-group-reviewer", "Reviewer"),
|
||||
]);
|
||||
|
||||
Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand(
|
||||
command,
|
||||
[
|
||||
CreateChatClientAgent("agent-group-writer", "Writer"),
|
||||
CreateChatClientAgent("agent-group-reviewer", "Reviewer"),
|
||||
]);
|
||||
|
||||
Assert.Equal("Collaborative Review", workflow.Name);
|
||||
Assert.Equal("Two agents collaborate under a group chat manager.", workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflowForCommand_UsesHandoffBuilderForHandoffMode()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"handoff",
|
||||
modeSettings: new OrchestrationModeSettingsDto
|
||||
{
|
||||
Handoff = new HandoffModeSettingsDto
|
||||
{
|
||||
TriageAgentNodeId = "agent-handoff-triage",
|
||||
ToolCallFiltering = "handoff-only",
|
||||
},
|
||||
},
|
||||
agents:
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-runtime", "Runtime Specialist"),
|
||||
]);
|
||||
|
||||
Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand(
|
||||
command,
|
||||
[
|
||||
CreateChatClientAgent("agent-handoff-triage", "Triage"),
|
||||
CreateChatClientAgent("agent-handoff-runtime", "Runtime Specialist"),
|
||||
]);
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||
|
||||
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflowForCommand_UsesGraphWorkflowRunnerForGraphModes()
|
||||
{
|
||||
RunTurnCommandDto command = CreateGraphWorkflowCommand();
|
||||
Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand(
|
||||
command,
|
||||
[
|
||||
CreateChatClientAgent("agent-primary", "Primary Agent"),
|
||||
]);
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||
|
||||
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectNewOutputMessages_SkipsFullTranscriptPrefix()
|
||||
{
|
||||
@@ -93,23 +171,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-concurrent",
|
||||
Name = "Concurrent Brainstorm",
|
||||
Mode = "concurrent",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"concurrent",
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -138,22 +203,9 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_CanonicalizesWorkflowOutputAuthorNames()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"single",
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -176,22 +228,9 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_UsesFinalAssistantPayloadWhenStreamingTextIsMissing()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"single",
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -220,24 +259,11 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-sequential",
|
||||
Name = "Sequential Trio Review",
|
||||
Mode = "sequential",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-sequential-analyst", name: "Analyst"),
|
||||
CreateAgent(id: "agent-sequential-builder", name: "Builder"),
|
||||
CreateAgent(id: "agent-sequential-reviewer", name: "Reviewer"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"sequential",
|
||||
CreateAgent(id: "agent-sequential-analyst", name: "Analyst"),
|
||||
CreateAgent(id: "agent-sequential-builder", name: "Builder"),
|
||||
CreateAgent(id: "agent-sequential-reviewer", name: "Reviewer"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -270,24 +296,11 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_PreservesConcurrentAggregatedResponses()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-concurrent",
|
||||
Name = "Concurrent Brainstorm",
|
||||
Mode = "concurrent",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"concurrent",
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -308,24 +321,11 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_ConcurrentUsesLastStreamedMessagePerAgentForGenericOutput()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-concurrent",
|
||||
Name = "Concurrent Brainstorm",
|
||||
Mode = "concurrent",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"concurrent",
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -377,23 +377,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-group-chat",
|
||||
Name = "Collaborative Group Chat",
|
||||
Mode = "group-chat",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
||||
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"group-chat",
|
||||
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
||||
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -426,23 +413,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_FallsBackToPositionWhenOutputTextDiffers()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-group-chat",
|
||||
Name = "Collaborative Group Chat",
|
||||
Mode = "group-chat",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
||||
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"group-chat",
|
||||
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
||||
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -480,23 +454,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Support Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"handoff",
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -517,23 +478,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_PrefersContentMatchedStreamingSegmentOverPosition()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Support Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"handoff",
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -558,23 +506,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_UsesFallbackAgentForSingleGenericAssistantOutputWithMultipleSegments()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Support Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"handoff",
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -599,23 +534,10 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Support Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
||||
],
|
||||
},
|
||||
};
|
||||
RunTurnCommandDto command = CreateCommand(
|
||||
"handoff",
|
||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"));
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
@@ -799,6 +721,73 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExecutionEnvironment_UsesLockstepWhenRequested()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
ExecutionMode = "lockstep",
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
InProcessExecutionEnvironment environment = CopilotWorkflowRunner.CreateExecutionEnvironment(command, checkpointManager: null);
|
||||
|
||||
Assert.Same(InProcessExecution.Lockstep, environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoerceRequestPortResponse_ParsesSupportedResponseTypes()
|
||||
{
|
||||
Assert.Equal("hello", CopilotWorkflowRunner.CoerceRequestPortResponse("string", "hello"));
|
||||
Assert.Equal(true, CopilotWorkflowRunner.CoerceRequestPortResponse("bool", "yes"));
|
||||
Assert.Equal(12.5d, CopilotWorkflowRunner.CoerceRequestPortResponse("number", "12.5"));
|
||||
|
||||
JsonElement json = Assert.IsType<JsonElement>(CopilotWorkflowRunner.CoerceRequestPortResponse("json", "{\"ok\":true}"));
|
||||
Assert.True(json.GetProperty("ok").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnAsync_RequestPortWorkflowUsesUserInputBridge()
|
||||
{
|
||||
CopilotWorkflowRunner runner = new(new WorkflowValidator());
|
||||
List<UserInputRequestedEventDto> requests = [];
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = await runner.RunTurnAsync(
|
||||
CreateRequestPortCommand(),
|
||||
_ => Task.CompletedTask,
|
||||
_ => Task.CompletedTask,
|
||||
_ => Task.CompletedTask,
|
||||
async request =>
|
||||
{
|
||||
requests.Add(request);
|
||||
await runner.ResolveUserInputAsync(
|
||||
new ResolveUserInputCommandDto
|
||||
{
|
||||
UserInputId = request.UserInputId,
|
||||
Answer = "approved",
|
||||
WasFreeform = true,
|
||||
},
|
||||
CancellationToken.None);
|
||||
},
|
||||
_ => Task.CompletedTask,
|
||||
_ => Task.CompletedTask,
|
||||
CancellationToken.None);
|
||||
|
||||
UserInputRequestedEventDto requestEvent = Assert.Single(requests);
|
||||
Assert.Equal("Needs approval?", requestEvent.Question);
|
||||
Assert.Null(requestEvent.AgentId);
|
||||
|
||||
ChatMessageDto message = Assert.Single(messages);
|
||||
Assert.Equal("Workflow", message.AuthorName);
|
||||
Assert.Equal("approved", message.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_FallsBackToActiveAgentForUnresolvedStreamingUpdates()
|
||||
{
|
||||
@@ -858,6 +847,25 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
public async Task HandleWorkflowEventAsync_EmitsWorkflowCheckpointSavedEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateHandoffCommand();
|
||||
command = new RunTurnCommandDto
|
||||
{
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = command.Workflow.Id,
|
||||
Name = command.Workflow.Name,
|
||||
Graph = command.Workflow.Graph,
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = command.Workflow.Settings.OrchestrationMode,
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto
|
||||
{
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
List<WorkflowCheckpointSavedEventDto> checkpoints = [];
|
||||
|
||||
@@ -937,6 +945,54 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("InvalidOperationException", diagnostic.ExceptionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_QueuesCompletedActivityForCompletedExecutor()
|
||||
{
|
||||
RunTurnCommandDto command = CreateApprovalCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
WorkflowNodeDto primaryAgent = command.Workflow.GetAgentNodes()[0];
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
primaryAgent,
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.message_delta",
|
||||
"data": {
|
||||
"messageId": "msg-1",
|
||||
"deltaContent": "Working"
|
||||
},
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new ExecutorCompletedEvent("agent-1", null),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(_ => Task.CompletedTask),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
Assert.Null(state.ActiveAgent);
|
||||
|
||||
AgentActivityEventDto completed = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("completed", completed.ActivityType);
|
||||
Assert.Equal("agent-1", completed.AgentId);
|
||||
Assert.Equal("Primary", completed.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
|
||||
{
|
||||
@@ -1762,7 +1818,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestCustomTool
|
||||
{
|
||||
Kind = "custom tool",
|
||||
@@ -1806,7 +1862,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestWrite
|
||||
{
|
||||
Kind = "write",
|
||||
@@ -1869,7 +1925,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestCustomTool
|
||||
{
|
||||
Kind = "custom tool",
|
||||
@@ -1903,7 +1959,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
@@ -1938,7 +1994,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
@@ -1979,7 +2035,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
bool sawSecondApproval = false;
|
||||
PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
@@ -2015,7 +2071,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
||||
firstCommand,
|
||||
firstCommand.Pattern.Agents[0],
|
||||
firstCommand.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
@@ -2055,7 +2111,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
RunTurnCommandDto secondCommand = CreateApprovalCommand(requestId: "turn-2");
|
||||
Task<PermissionRequestResult> secondPending = coordinator.RequestApprovalAsync(
|
||||
secondCommand,
|
||||
secondCommand.Pattern.Agents[0],
|
||||
secondCommand.Workflow.GetAgentNodes()[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
@@ -2110,38 +2166,125 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Contains("is not pending", error.Message);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateHandoffCommand()
|
||||
private static RunTurnCommandDto CreateCommand(
|
||||
string orchestrationMode,
|
||||
params WorkflowNodeDto[] agents)
|
||||
{
|
||||
return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand(
|
||||
string orchestrationMode,
|
||||
OrchestrationModeSettingsDto? modeSettings = null,
|
||||
string? workflowName = null,
|
||||
string? workflowDescription = null,
|
||||
params WorkflowNodeDto[] agents)
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
],
|
||||
Id = $"workflow-{orchestrationMode}",
|
||||
Name = workflowName ?? $"Workflow {orchestrationMode}",
|
||||
Description = workflowDescription ?? string.Empty,
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = [.. agents],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = orchestrationMode,
|
||||
ModeSettings = modeSettings,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateGraphWorkflowCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-graph",
|
||||
SessionId = "session-graph",
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-single",
|
||||
Name = "Single Graph Workflow",
|
||||
Description = "Uses the graph workflow runner.",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
CreateAgent("agent-primary", "Primary Agent"),
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-agent",
|
||||
Source = "start",
|
||||
Target = "agent-primary",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-agent-end",
|
||||
Source = "agent-primary",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateHandoffCommand()
|
||||
{
|
||||
return CreateCommand(
|
||||
"handoff",
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"));
|
||||
}
|
||||
|
||||
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
|
||||
{
|
||||
RequestPort port = RequestPort.Create<object, object>("test-port");
|
||||
@@ -2184,30 +2327,117 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
{
|
||||
McpServers = [.. mcpServers],
|
||||
},
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Approval Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
Id = "workflow-1",
|
||||
Name = "Approval Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Rules =
|
||||
Nodes =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = autoApprovedToolNames is null
|
||||
? ["web_fetch"]
|
||||
: [.. autoApprovedToolNames],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateRequestPortCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-request-port",
|
||||
SessionId = "session-request-port",
|
||||
ProjectPath = "c:\\workspace\\personal\\projects\\aryx.worktrees\\copilot-powerful-vulture",
|
||||
Messages =
|
||||
[
|
||||
new ChatMessageDto
|
||||
{
|
||||
Id = "message-1",
|
||||
Role = "user",
|
||||
AuthorName = "User",
|
||||
Content = "Please continue.",
|
||||
CreatedAt = "2026-04-05T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-request-port",
|
||||
Name = "Request Port Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "approval-port",
|
||||
Kind = "request-port",
|
||||
Label = "Approval",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "request-port",
|
||||
PortId = "approval",
|
||||
RequestType = "Question",
|
||||
ResponseType = "string",
|
||||
Prompt = "Needs approval?",
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-port",
|
||||
Source = "start",
|
||||
Target = "approval-port",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-port-end",
|
||||
Source = "approval-port",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = autoApprovedToolNames is null
|
||||
? ["web_fetch"]
|
||||
: [.. autoApprovedToolNames],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
],
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
ExecutionMode = "lockstep",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2248,3 +2478,4 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,20 @@ public sealed class HandoffWorkflowGuidanceTests
|
||||
[Fact]
|
||||
public void CreateForwardReason_UsesTargetSpecialtyAndOwnership()
|
||||
{
|
||||
PatternAgentDefinitionDto specialist = new()
|
||||
WorkflowNodeDto specialist = new()
|
||||
{
|
||||
Id = "agent-handoff-ux",
|
||||
Name = "UX Specialist",
|
||||
Description = "Handles user experience questions.",
|
||||
Instructions = "Focus on UX.",
|
||||
Model = "claude-opus-4.5",
|
||||
Kind = "agent",
|
||||
Label = "UX Specialist",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-handoff-ux",
|
||||
Name = "UX Specialist",
|
||||
Description = "Handles user experience questions.",
|
||||
Instructions = "Focus on UX.",
|
||||
Model = "claude-opus-4.5",
|
||||
},
|
||||
};
|
||||
|
||||
string reason = HandoffWorkflowGuidance.CreateForwardReason(specialist);
|
||||
@@ -42,13 +49,20 @@ public sealed class HandoffWorkflowGuidanceTests
|
||||
[Fact]
|
||||
public void CreateReturnReason_RestrictsReturnToReroutingCases()
|
||||
{
|
||||
PatternAgentDefinitionDto triage = new()
|
||||
WorkflowNodeDto triage = new()
|
||||
{
|
||||
Id = "agent-handoff-triage",
|
||||
Name = "Triage",
|
||||
Description = "Routes the request to the right specialist.",
|
||||
Instructions = "Triages requests.",
|
||||
Model = "gpt-5.4",
|
||||
Kind = "agent",
|
||||
Label = "Triage",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent-handoff-triage",
|
||||
Name = "Triage",
|
||||
Description = "Routes the request to the right specialist.",
|
||||
Instructions = "Triages requests.",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
};
|
||||
|
||||
string reason = HandoffWorkflowGuidance.CreateReturnReason(triage);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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,
|
||||
};
|
||||
}
|
||||
@@ -63,23 +63,50 @@ public sealed class SidecarProtocolHostTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion()
|
||||
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
|
||||
{
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidatePatternCommandDto
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidateWorkflowCommandDto
|
||||
{
|
||||
Type = "validate-pattern",
|
||||
RequestId = "validate-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Type = "validate-workflow",
|
||||
RequestId = "validate-workflow-1",
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "single-pattern",
|
||||
Id = "workflow-1",
|
||||
Name = "",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(),
|
||||
CreateAgent(id: "agent-2", name: "Reviewer", model: ""),
|
||||
],
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-end",
|
||||
Source = "start",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,24 +114,21 @@ public sealed class SidecarProtocolHostTests
|
||||
events,
|
||||
validationEvent =>
|
||||
{
|
||||
Assert.Equal("pattern-validation", validationEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("validate-1", validationEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("workflow-validation", validationEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("validate-workflow-1", validationEvent.GetProperty("requestId").GetString());
|
||||
|
||||
JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray();
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.GetProperty("field").GetString() == "name"
|
||||
&& issue.GetProperty("message").GetString() == "Pattern name is required.");
|
||||
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.GetProperty("field").GetString() == "agents"
|
||||
&& issue.GetProperty("message").GetString() == "Single-agent chat requires exactly one agent.");
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.GetProperty("field").GetString() == "agents.model"
|
||||
&& issue.GetProperty("message").GetString() == "Agent \"Reviewer\" requires a model identifier.");
|
||||
issue.GetProperty("field").GetString() == "graph.nodes"
|
||||
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one executable work node.");
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("validate-1", completionEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("validate-workflow-1", completionEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,7 +136,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onActivity(new AgentActivityEventDto
|
||||
@@ -160,37 +184,7 @@ public sealed class SidecarProtocolHostTests
|
||||
];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
Messages =
|
||||
[
|
||||
new ChatMessageDto
|
||||
{
|
||||
Id = "user-1",
|
||||
Role = "user",
|
||||
AuthorName = "You",
|
||||
Content = "Hello",
|
||||
CreatedAt = "2026-01-01T00:00:00.0000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
host);
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(CreateRunTurnCommand(), host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
@@ -236,7 +230,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onActivity(new WorkflowDiagnosticEventDto
|
||||
@@ -257,25 +251,7 @@ public sealed class SidecarProtocolHostTests
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-diagnostic",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
Messages = [],
|
||||
},
|
||||
CreateRunTurnCommand(requestId: "turn-diagnostic", messages: []),
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
@@ -308,7 +284,7 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
string? capturedMode = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
capturedMode = command.Mode;
|
||||
@@ -316,25 +292,7 @@ public sealed class SidecarProtocolHostTests
|
||||
}));
|
||||
|
||||
await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-plan",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Mode = "plan",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
CreateRunTurnCommand(requestId: "turn-plan", interactionMode: "plan"),
|
||||
host);
|
||||
|
||||
Assert.Equal("plan", capturedMode);
|
||||
@@ -344,7 +302,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onApproval(new ApprovalRequestedEventDto
|
||||
@@ -371,24 +329,7 @@ public sealed class SidecarProtocolHostTests
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-approval",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
CreateRunTurnCommand(requestId: "turn-approval"),
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
@@ -422,7 +363,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task RunTurnCommand_ReturnsUserInputEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onUserInput(new UserInputRequestedEventDto
|
||||
@@ -442,24 +383,7 @@ public sealed class SidecarProtocolHostTests
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-user-input",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
CreateRunTurnCommand(requestId: "turn-user-input"),
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
@@ -495,7 +419,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onMcpOAuthRequired(new McpOauthRequiredEventDto
|
||||
@@ -553,7 +477,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task RunTurnCommand_ReturnsExitPlanModeEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onExitPlanMode(new ExitPlanModeRequestedEventDto
|
||||
@@ -574,25 +498,7 @@ public sealed class SidecarProtocolHostTests
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-plan-mode",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Mode = "plan",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
CreateRunTurnCommand(requestId: "turn-plan-mode", interactionMode: "plan"),
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
@@ -628,7 +534,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
@@ -676,7 +582,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => []));
|
||||
|
||||
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
|
||||
@@ -698,7 +604,7 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
ResolveApprovalCommandDto? captured = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(
|
||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
||||
resolveApprovalHandler: (command, cancellationToken) =>
|
||||
@@ -731,7 +637,7 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
ResolveUserInputCommandDto? captured = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
new FakeWorkflowRunner(
|
||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
||||
resolveUserInputHandler: (command, cancellationToken) =>
|
||||
@@ -855,7 +761,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
sessionManager: new FakeSessionManager
|
||||
{
|
||||
Sessions =
|
||||
@@ -902,7 +808,7 @@ public sealed class SidecarProtocolHostTests
|
||||
],
|
||||
};
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
sessionManager: sessionManager);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
@@ -925,7 +831,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task GetQuotaCommand_ReturnsQuotaResultEvent()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
sessionManager: new FakeSessionManager
|
||||
{
|
||||
QuotaSnapshots = new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal)
|
||||
@@ -967,7 +873,7 @@ public sealed class SidecarProtocolHostTests
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return [];
|
||||
});
|
||||
SidecarProtocolHost host = new(new PatternValidator(), runner);
|
||||
SidecarProtocolHost host = new(new WorkflowValidator(), runner);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
[
|
||||
@@ -1028,7 +934,7 @@ public sealed class SidecarProtocolHostTests
|
||||
private static SidecarProtocolHost CreateHostForTests()
|
||||
{
|
||||
return new SidecarProtocolHost(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -1106,24 +1012,35 @@ public sealed class SidecarProtocolHostTests
|
||||
return events;
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(
|
||||
private static WorkflowNodeDto CreateAgent(
|
||||
string id = "agent-1",
|
||||
string name = "Primary",
|
||||
string model = "gpt-5.4",
|
||||
string instructions = "Help with the user's request.")
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = model,
|
||||
Instructions = instructions,
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = model,
|
||||
Instructions = instructions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateRunTurnCommand(
|
||||
string requestId = "turn-1",
|
||||
string sessionId = "session-1")
|
||||
string sessionId = "session-1",
|
||||
string mode = "single",
|
||||
string interactionMode = "interactive",
|
||||
IReadOnlyList<WorkflowNodeDto>? agents = null,
|
||||
IReadOnlyList<ChatMessageDto>? messages = null)
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
@@ -1131,18 +1048,9 @@ public sealed class SidecarProtocolHostTests
|
||||
RequestId = requestId,
|
||||
SessionId = sessionId,
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
Messages =
|
||||
Mode = interactionMode,
|
||||
Workflow = CreateWorkflow(mode, agents),
|
||||
Messages = messages ??
|
||||
[
|
||||
new ChatMessageDto
|
||||
{
|
||||
@@ -1156,6 +1064,25 @@ public sealed class SidecarProtocolHostTests
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
string mode = "single",
|
||||
IReadOnlyList<WorkflowNodeDto>? agents = null)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"workflow-{mode}",
|
||||
Name = "Single Agent",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = [.. agents ?? [CreateAgent(name: "Primary")]],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = mode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private readonly Func<
|
||||
@@ -1255,3 +1182,4 @@ public sealed class SidecarProtocolHostTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class PatternValidatorTests
|
||||
{
|
||||
private readonly PatternValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
public void SingleAgentPattern_WithExactlyOneAgent_IsValid()
|
||||
{
|
||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||
CreatePattern(
|
||||
"single",
|
||||
[CreateAgent()]));
|
||||
|
||||
Assert.Empty(issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid()
|
||||
{
|
||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||
CreatePattern(
|
||||
"handoff",
|
||||
[CreateAgent()]));
|
||||
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.Field == "agents"
|
||||
&& issue.Message == "Handoff orchestration requires at least two agents.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentWithoutModel_IsReportedAsInvalid()
|
||||
{
|
||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||
CreatePattern(
|
||||
"sequential",
|
||||
[
|
||||
CreateAgent(model: ""),
|
||||
CreateAgent(id: "agent-2", name: "Reviewer"),
|
||||
]));
|
||||
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.Field == "agents.model"
|
||||
&& issue.Message == "Agent \"Primary\" requires a model identifier.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MagenticPattern_IsReportedAsUnavailable()
|
||||
{
|
||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||
CreatePattern(
|
||||
"magentic",
|
||||
[
|
||||
CreateAgent(id: "agent-1", name: "Planner", instructions: "Plan the task."),
|
||||
CreateAgent(
|
||||
id: "agent-2",
|
||||
name: "Specialist",
|
||||
model: "claude-opus-4.5",
|
||||
instructions: "Complete the task."),
|
||||
],
|
||||
availability: "unavailable",
|
||||
unavailabilityReason: "Unsupported in C#.",
|
||||
name: "Magentic"));
|
||||
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.Field == "availability"
|
||||
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.Field == "mode"
|
||||
&& 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(
|
||||
string mode,
|
||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||
string availability = "available",
|
||||
string? unavailabilityReason = null,
|
||||
string name = "Pattern",
|
||||
PatternGraphDto? graph = null)
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
{
|
||||
Id = $"{mode}-pattern",
|
||||
Name = name,
|
||||
Mode = mode,
|
||||
Availability = availability,
|
||||
UnavailabilityReason = unavailabilityReason,
|
||||
Agents = agents,
|
||||
Graph = graph,
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(
|
||||
string id = "agent-1",
|
||||
string name = "Primary",
|
||||
string model = "gpt-5.4",
|
||||
string instructions = "Help with the user's request.")
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = model,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class WorkflowConditionEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_PropertyCondition_MatchesPayload()
|
||||
{
|
||||
EdgeConditionDto condition = new()
|
||||
{
|
||||
Type = "property",
|
||||
Combinator = "and",
|
||||
Rules =
|
||||
[
|
||||
new WorkflowConditionRuleDto
|
||||
{
|
||||
PropertyPath = "Role",
|
||||
Operator = "equals",
|
||||
Value = "user",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 1, "hello"));
|
||||
|
||||
Assert.True(matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExpressionCondition_MatchesPayload()
|
||||
{
|
||||
EdgeConditionDto condition = new()
|
||||
{
|
||||
Type = "expression",
|
||||
Expression = "Iteration < 3 && Role == \"user\"",
|
||||
};
|
||||
|
||||
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 2, "hello"));
|
||||
|
||||
Assert.True(matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_LoopCondition_StopsAfterMaxIterations()
|
||||
{
|
||||
WorkflowEdgeDto edge = new()
|
||||
{
|
||||
Id = "edge-loop",
|
||||
Source = "agent",
|
||||
Target = "agent",
|
||||
Kind = "direct",
|
||||
IsLoop = true,
|
||||
MaxIterations = 2,
|
||||
Condition = new EdgeConditionDto
|
||||
{
|
||||
Type = "property",
|
||||
Rules =
|
||||
[
|
||||
new WorkflowConditionRuleDto
|
||||
{
|
||||
PropertyPath = "Iteration",
|
||||
Operator = "lt",
|
||||
Value = "10",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Func<object?, bool>? compiled = WorkflowConditionEvaluator.Compile(edge);
|
||||
|
||||
Assert.NotNull(compiled);
|
||||
Assert.True(compiled!(new TestPayload("user", 1, "hello")));
|
||||
Assert.True(compiled(new TestPayload("user", 2, "hello")));
|
||||
Assert.False(compiled(new TestPayload("user", 3, "hello")));
|
||||
}
|
||||
|
||||
private sealed record TestPayload(string Role, int Iteration, string Content);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
@@ -15,7 +16,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = @"C:\workspace\file.txt",
|
||||
["viewRange"] = new object[] { 10, 25 },
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
@@ -28,6 +33,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
Assert.Equal("view", activity.ToolName);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
|
||||
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
|
||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
@@ -36,7 +44,15 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateMcpToolCall("call-1", "git.status", "Git MCP"));
|
||||
CreateMcpToolCall(
|
||||
"call-1",
|
||||
"git.status",
|
||||
"Git MCP",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = @"C:\workspace",
|
||||
["includeIgnored"] = true,
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
@@ -47,6 +63,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("git.status", activity.ToolName);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
|
||||
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
|
||||
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
@@ -54,7 +73,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1"));
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
@@ -65,6 +85,10 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("code interpreter", activity.ToolName);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal(
|
||||
["print('hello')"],
|
||||
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
|
||||
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
@@ -83,9 +107,55 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("image generation", activity.ToolName);
|
||||
Assert.Null(activity.ToolArguments);
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||
{
|
||||
["empty"] = " ",
|
||||
["missing"] = null,
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Null(activity.ToolArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent(
|
||||
"call-1",
|
||||
"powershell",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["command"] = new string('x', 4001),
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
|
||||
{
|
||||
@@ -186,54 +256,52 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateSingleAgentCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
=> CreateCommand("single", [CreateAgent("agent-1", "Primary")]);
|
||||
|
||||
private static RunTurnCommandDto CreateHandoffCommand()
|
||||
=> CreateCommand("handoff",
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
]);
|
||||
|
||||
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
],
|
||||
Id = $"{orchestrationMode}-workflow",
|
||||
Name = "Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = [.. agents],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = orchestrationMode,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,22 +312,50 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
return new RequestInfoEvent(request);
|
||||
}
|
||||
|
||||
private static object CreateCodeInterpreterToolCall(string callId)
|
||||
private static object CreateCodeInterpreterToolCall(string callId, params string[] inputs)
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
object instance = Activator.CreateInstance(type)!;
|
||||
type.GetProperty("CallId")!.SetValue(instance, callId);
|
||||
if (inputs.Length > 0)
|
||||
{
|
||||
Type aiContentType = Type.GetType(
|
||||
"Microsoft.Extensions.AI.AIContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
Type textContentType = Type.GetType(
|
||||
"Microsoft.Extensions.AI.TextContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
IList values = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(aiContentType))!;
|
||||
foreach (string input in inputs)
|
||||
{
|
||||
object textContent = Activator.CreateInstance(textContentType, input)!;
|
||||
values.Add(textContent);
|
||||
}
|
||||
|
||||
type.GetProperty("Inputs")!.SetValue(instance, values);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static object CreateMcpToolCall(string callId, string toolName, string serverName)
|
||||
private static object CreateMcpToolCall(
|
||||
string callId,
|
||||
string toolName,
|
||||
string serverName,
|
||||
IReadOnlyDictionary<string, object?>? arguments = null)
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
return Activator.CreateInstance(type, callId, toolName, serverName)!;
|
||||
object instance = Activator.CreateInstance(type, callId, toolName, serverName)!;
|
||||
if (arguments is not null)
|
||||
{
|
||||
type.GetProperty("Arguments")!.SetValue(instance, arguments);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static object CreateImageGenerationToolCall()
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class WorkflowRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_AcceptsInlineSubworkflows()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateSubworkflowParent(inlineWorkflow: CreateAgentWorkflow("child-inline", "agent-child")),
|
||||
[CreateChatClientAgent("agent-child", "Child Agent")]);
|
||||
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||
|
||||
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_AcceptsReferencedSubworkflowsFromWorkflowLibrary()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
WorkflowDefinitionDto childWorkflow = CreateAgentWorkflow("child-ref", "agent-child");
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateSubworkflowParent(workflowId: childWorkflow.Id),
|
||||
[CreateChatClientAgent("agent-child", "Child Agent")],
|
||||
[childWorkflow]);
|
||||
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||
|
||||
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWorkflow_RejectsUnknownReferencedSubworkflows()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
|
||||
CreateSubworkflowParent(workflowId: "missing-child"),
|
||||
[CreateChatClientAgent("agent-child", "Child Agent")],
|
||||
[]));
|
||||
|
||||
Assert.Contains("unknown workflow", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_RunsCodeExecutorAndSurfacesOutput()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateSingleNodeWorkflow(
|
||||
"code-executor",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "code-executor",
|
||||
Implementation = "return-text:done",
|
||||
}),
|
||||
[]);
|
||||
|
||||
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
|
||||
|
||||
ChatMessage message = Assert.Single(output);
|
||||
Assert.Equal("done", message.Text);
|
||||
Assert.Equal("Workflow", message.AuthorName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_FunctionExecutorsUseStateScopes()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateStatefulFunctionWorkflow(),
|
||||
[]);
|
||||
|
||||
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
|
||||
|
||||
ChatMessage message = Assert.Single(output);
|
||||
Assert.Equal("{\"status\":\"complete\"}", message.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_RequestPortsRaiseRequestsAndForwardResponses()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateSingleNodeWorkflow(
|
||||
"request-port",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "request-port",
|
||||
PortId = "approval",
|
||||
RequestType = "Question",
|
||||
ResponseType = "string",
|
||||
Prompt = "Approve the workflow?",
|
||||
}),
|
||||
[]);
|
||||
|
||||
ChatMessage[] input =
|
||||
[
|
||||
new(ChatRole.User, "Please continue."),
|
||||
];
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
Assert.Equal("approval", requestInfo.Request.PortInfo.PortId);
|
||||
WorkflowRequestPortPromptRequest payload = Assert.IsType<WorkflowRequestPortPromptRequest>(
|
||||
requestInfo.Request.Data.As<object>());
|
||||
Assert.Equal("Approve the workflow?", payload.Prompt);
|
||||
Assert.Equal("request-port", payload.NodeId);
|
||||
await run.SendResponseAsync(requestInfo.Request.CreateResponse("approved"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
List<ChatMessage> output = Assert.IsType<List<ChatMessage>>(outputEvent.Data);
|
||||
ChatMessage message = Assert.Single(output);
|
||||
Assert.Equal("approved", message.Text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Fail("Workflow never produced an output after the request port response.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWorkflow_RejectsUnknownFunctionRefsAtBuildTime()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
|
||||
CreateSingleNodeWorkflow(
|
||||
"function-executor",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "function-executor",
|
||||
FunctionRef = "missing-function",
|
||||
}),
|
||||
[]));
|
||||
|
||||
Assert.Contains("unsupported functionRef", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateAgentWorkflow(string id, string agentId)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = "Child Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = agentId,
|
||||
Kind = "agent",
|
||||
Label = "Child Agent",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = agentId,
|
||||
Name = "Child Agent",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-agent",
|
||||
Source = "start",
|
||||
Target = agentId,
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-agent-end",
|
||||
Source = agentId,
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "parent-workflow",
|
||||
Name = "Parent Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "sub-workflow",
|
||||
Kind = "sub-workflow",
|
||||
Label = "Nested Workflow",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-sub",
|
||||
Source = "start",
|
||||
Target = "sub-workflow",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-sub-end",
|
||||
Source = "sub-workflow",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSingleNodeWorkflow(string nodeKind, WorkflowNodeConfigDto config)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"workflow-{nodeKind}",
|
||||
Name = $"{nodeKind} Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = nodeKind,
|
||||
Kind = nodeKind,
|
||||
Label = nodeKind,
|
||||
Config = config,
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-node",
|
||||
Source = "start",
|
||||
Target = nodeKind,
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-node-end",
|
||||
Source = nodeKind,
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateStatefulFunctionWorkflow()
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-stateful-function",
|
||||
Name = "Stateful Function Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "state-get",
|
||||
Kind = "function-executor",
|
||||
Label = "Get State",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "function-executor",
|
||||
FunctionRef = "state:get",
|
||||
Parameters = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["scope"] = JsonDocument.Parse("\"workflow\"").RootElement.Clone(),
|
||||
["key"] = JsonDocument.Parse("\"status\"").RootElement.Clone(),
|
||||
},
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "state-set",
|
||||
Kind = "function-executor",
|
||||
Label = "Set State",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "function-executor",
|
||||
FunctionRef = "state:set",
|
||||
Parameters = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["scope"] = JsonDocument.Parse("\"workflow\"").RootElement.Clone(),
|
||||
["key"] = JsonDocument.Parse("\"status\"").RootElement.Clone(),
|
||||
["value"] = JsonDocument.Parse("{\"status\":\"complete\"}").RootElement.Clone(),
|
||||
},
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "state-read-back",
|
||||
Kind = "code-executor",
|
||||
Label = "Read Back",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "code-executor",
|
||||
Implementation = "state:get:workflow:status",
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-get",
|
||||
Source = "start",
|
||||
Target = "state-get",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-get-set",
|
||||
Source = "state-get",
|
||||
Target = "state-set",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-set-read",
|
||||
Source = "state-set",
|
||||
Target = "state-read-back",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-read-end",
|
||||
Source = "state-read-back",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
StateScopes =
|
||||
[
|
||||
new WorkflowStateScopeDto
|
||||
{
|
||||
Name = "workflow",
|
||||
InitialValues = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["status"] = JsonDocument.Parse("\"pending\"").RootElement.Clone(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>> RunWorkflowToOutputAsync(Workflow workflow)
|
||||
{
|
||||
ChatMessage[] input =
|
||||
[
|
||||
new(ChatRole.User, "Run the workflow."),
|
||||
];
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
return Assert.IsType<List<ChatMessage>>(outputEvent.Data);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Fail("Workflow did not produce an output.");
|
||||
return [];
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateChatClientAgent(string id, string name)
|
||||
{
|
||||
return new ChatClientAgent(
|
||||
new StubChatClient(),
|
||||
id,
|
||||
name,
|
||||
"Stub agent for workflow runner tests.",
|
||||
[],
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class WorkflowValidatorTests
|
||||
{
|
||||
private readonly WorkflowValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsInlineSubworkflowNodes()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateSubworkflowParent(inlineWorkflow: CreateWorkflow(id: "child"));
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsSubworkflowNodesWithoutSingleSource()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateSubworkflowParent();
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.nodes.config");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUnknownReferencedWorkflowIdsWhenLibraryProvided()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateSubworkflowParent(workflowId: "missing-child");
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow, []);
|
||||
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.nodes.config.workflowId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsInvalidConditionOperator()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow();
|
||||
workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = workflow.Id,
|
||||
Name = workflow.Name,
|
||||
Settings = workflow.Settings,
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = workflow.Graph.Nodes,
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-agent",
|
||||
Source = "start",
|
||||
Target = "agent",
|
||||
Kind = "direct",
|
||||
Condition = new EdgeConditionDto
|
||||
{
|
||||
Type = "property",
|
||||
Rules =
|
||||
[
|
||||
new WorkflowConditionRuleDto
|
||||
{
|
||||
PropertyPath = "Role",
|
||||
Operator = "bad-op",
|
||||
Value = "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
workflow.Graph.Edges[1],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.edges.condition.rules.operator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsLoopWithoutMetadata()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow();
|
||||
workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = workflow.Id,
|
||||
Name = workflow.Name,
|
||||
Settings = workflow.Settings,
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = workflow.Graph.Nodes,
|
||||
Edges =
|
||||
[
|
||||
.. workflow.Graph.Edges,
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-loop",
|
||||
Source = "agent",
|
||||
Target = "agent",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.edges.isLoop");
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.edges.condition");
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.edges.maxIterations");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsLoopWithExitPathConditionAndCap()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow();
|
||||
workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = workflow.Id,
|
||||
Name = workflow.Name,
|
||||
Settings = workflow.Settings,
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = workflow.Graph.Nodes,
|
||||
Edges =
|
||||
[
|
||||
.. workflow.Graph.Edges,
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-loop",
|
||||
Source = "agent",
|
||||
Target = "agent",
|
||||
Kind = "direct",
|
||||
IsLoop = true,
|
||||
MaxIterations = 3,
|
||||
Condition = new EdgeConditionDto
|
||||
{
|
||||
Type = "expression",
|
||||
Expression = "Iteration < 3",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsLoopWithAlwaysConditionAndMaxIterations()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow();
|
||||
workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = workflow.Id,
|
||||
Name = workflow.Name,
|
||||
Settings = workflow.Settings,
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = workflow.Graph.Nodes,
|
||||
Edges =
|
||||
[
|
||||
.. workflow.Graph.Edges,
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-loop",
|
||||
Source = "agent",
|
||||
Target = "agent",
|
||||
Kind = "direct",
|
||||
IsLoop = true,
|
||||
MaxIterations = 5,
|
||||
Condition = new EdgeConditionDto
|
||||
{
|
||||
Type = "always",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsPhase4ExecutableNodeKinds()
|
||||
{
|
||||
WorkflowDefinitionDto codeWorkflow = CreateSingleNodeWorkflow(
|
||||
"code-executor",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "code-executor",
|
||||
Implementation = "return-text:done",
|
||||
});
|
||||
WorkflowDefinitionDto functionWorkflow = CreateSingleNodeWorkflow(
|
||||
"function-executor",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "function-executor",
|
||||
FunctionRef = "identity",
|
||||
});
|
||||
WorkflowDefinitionDto requestPortWorkflow = CreateSingleNodeWorkflow(
|
||||
"request-port",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "request-port",
|
||||
PortId = "approval",
|
||||
RequestType = "Question",
|
||||
ResponseType = "string",
|
||||
});
|
||||
|
||||
Assert.DoesNotContain(_validator.Validate(codeWorkflow), issue => issue.Level == "error");
|
||||
Assert.DoesNotContain(_validator.Validate(functionWorkflow), issue => issue.Level == "error");
|
||||
Assert.DoesNotContain(_validator.Validate(requestPortWorkflow), issue => issue.Level == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsInvalidPhase4ExecutorConfigs()
|
||||
{
|
||||
WorkflowDefinitionDto codeWorkflow = CreateSingleNodeWorkflow(
|
||||
"code-executor",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "code-executor",
|
||||
Implementation = " ",
|
||||
});
|
||||
WorkflowDefinitionDto functionWorkflow = CreateSingleNodeWorkflow(
|
||||
"function-executor",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "function-executor",
|
||||
FunctionRef = string.Empty,
|
||||
});
|
||||
WorkflowDefinitionDto requestPortWorkflow = CreateSingleNodeWorkflow(
|
||||
"request-port",
|
||||
new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "request-port",
|
||||
PortId = " ",
|
||||
RequestType = "",
|
||||
ResponseType = null,
|
||||
});
|
||||
|
||||
Assert.Contains(_validator.Validate(codeWorkflow), issue => issue.Field == "graph.nodes.config.implementation");
|
||||
Assert.Contains(_validator.Validate(functionWorkflow), issue => issue.Field == "graph.nodes.config.functionRef");
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> requestPortIssues = _validator.Validate(requestPortWorkflow);
|
||||
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.portId");
|
||||
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.requestType");
|
||||
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.responseType");
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(string id = "workflow-1")
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = "Loop Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "agent",
|
||||
Kind = "agent",
|
||||
Label = "Agent",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = "agent",
|
||||
Name = "Agent",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-agent",
|
||||
Source = "start",
|
||||
Target = "agent",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-agent-end",
|
||||
Source = "agent",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-parent",
|
||||
Name = "Parent Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "sub-workflow",
|
||||
Kind = "sub-workflow",
|
||||
Label = "Nested Workflow",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-sub",
|
||||
Source = "start",
|
||||
Target = "sub-workflow",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-sub-end",
|
||||
Source = "sub-workflow",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSingleNodeWorkflow(string nodeKind, WorkflowNodeConfigDto config)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"workflow-{nodeKind}",
|
||||
Name = $"{nodeKind} Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = nodeKind,
|
||||
Kind = nodeKind,
|
||||
Label = nodeKind,
|
||||
Config = config,
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-node",
|
||||
Source = "start",
|
||||
Target = nodeKind,
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-node-end",
|
||||
Source = nodeKind,
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+648
-144
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { isUtf8 } from 'node:buffer';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type {
|
||||
@@ -32,6 +32,16 @@ const { execFile } = require('node:child_process') as typeof import('node:child_
|
||||
const execFileAsync = promisify(execFile);
|
||||
const GIT_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Ensure `filePath` resolves inside `basePath`; throws on traversal. */
|
||||
function assertPathInsideBase(basePath: string, filePath: string): string {
|
||||
const resolved = resolve(basePath, filePath);
|
||||
const rel = relative(resolve(basePath), resolved);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) {
|
||||
throw new Error(`Path traversal detected: "${filePath}" escapes base directory.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
type GitCommandRunner = (projectPath: string, args: string[]) => Promise<string>;
|
||||
|
||||
type GitCommandResult =
|
||||
@@ -715,7 +725,7 @@ export class GitService {
|
||||
|
||||
if (isPureUntrackedFile(file)) {
|
||||
try {
|
||||
const contents = await readFile(join(projectPath, file.path));
|
||||
const contents = await readFile(assertPathInsideBase(projectPath, file.path));
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
@@ -813,7 +823,7 @@ export class GitService {
|
||||
throw unstageResult.error;
|
||||
}
|
||||
|
||||
await rm(join(projectPath, path), { force: true });
|
||||
await rm(assertPathInsideBase(projectPath, path), { force: true });
|
||||
}
|
||||
|
||||
private async applyPatch(projectPath: string, diff: string): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,10 @@ import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
BranchSessionInput,
|
||||
CancelSessionTurnInput,
|
||||
CommitProjectGitChangesInput,
|
||||
CreateSessionInput,
|
||||
CreateWorkflowFromTemplateInput,
|
||||
CreateWorkflowSessionInput,
|
||||
CreateProjectGitBranchInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
@@ -13,13 +16,15 @@ import type {
|
||||
DeleteSessionInput,
|
||||
DiscardSessionRunGitChangesInput,
|
||||
EditAndResendSessionMessageInput,
|
||||
CommitProjectGitChangesInput,
|
||||
ExportWorkflowInput,
|
||||
ImportWorkflowInput,
|
||||
ProjectGitDetailsInput,
|
||||
ProjectGitFilePreviewInput,
|
||||
ProjectGitFileSelectionInput,
|
||||
ProjectGitInput,
|
||||
PullProjectGitInput,
|
||||
RegenerateSessionMessageInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
StartSessionMcpAuthInput,
|
||||
SuggestProjectGitCommitMessageInput,
|
||||
SwitchProjectGitBranchInput,
|
||||
@@ -30,12 +35,12 @@ import type {
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveSessionApprovalInput,
|
||||
ResolveSessionUserInputInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
SaveWorkflowInput,
|
||||
SaveWorkflowTemplateInput,
|
||||
SaveWorkspaceAgentInput,
|
||||
SendSessionMessageInput,
|
||||
SetPatternFavoriteInput,
|
||||
SetProjectAgentProfileEnabledInput,
|
||||
SetSessionArchivedInput,
|
||||
SetSessionInteractionModeInput,
|
||||
@@ -106,10 +111,22 @@ export function registerIpcHandlers(
|
||||
(_event, input: SetProjectAgentProfileEnabledInput) =>
|
||||
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
||||
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
||||
ipcMain.handle(ipcChannels.saveWorkflowTemplate, (_event, input: SaveWorkflowTemplateInput) =>
|
||||
service.saveWorkflowTemplate(input.workflowId, input.options),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
|
||||
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
|
||||
service.listWorkflowReferences(workflowId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.createWorkflowFromTemplate, (_event, input: CreateWorkflowFromTemplateInput) =>
|
||||
service.createWorkflowFromTemplate(input.templateId, input.options),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.exportWorkflow, (_event, input: ExportWorkflowInput) =>
|
||||
service.exportWorkflow(input.workflowId, input.format),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.importWorkflow, (_event, input: ImportWorkflowInput) =>
|
||||
service.importWorkflow(input.content, input.format, input.options),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||
const result = await service.setTheme(theme);
|
||||
@@ -148,6 +165,12 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
|
||||
service.deleteLspProfile(profileId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.saveWorkspaceAgent, (_event, input: SaveWorkspaceAgentInput) =>
|
||||
service.saveWorkspaceAgent(input.agent),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteWorkspaceAgent, (_event, agentId: string) =>
|
||||
service.deleteWorkspaceAgent(agentId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.describeTerminal, () => service.describeTerminal());
|
||||
ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal());
|
||||
ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal());
|
||||
@@ -171,7 +194,10 @@ export function registerIpcHandlers(
|
||||
service.updateSessionApprovalSettings(input.sessionId, input.autoApprovedToolNames),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||
service.createSession(input.projectId, input.patternId),
|
||||
service.createSession(input.projectId, input.workflowId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
|
||||
service.createWorkflowSession(input.projectId, input.workflowId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
||||
service.duplicateSession(input.sessionId),
|
||||
@@ -201,7 +227,13 @@ export function registerIpcHandlers(
|
||||
service.editAndResendSessionMessage(input.sessionId, input.messageId, input.content, input.attachments),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
|
||||
service.sendSessionMessage(
|
||||
input.sessionId,
|
||||
input.content,
|
||||
input.attachments,
|
||||
input.messageMode,
|
||||
input.promptInvocation,
|
||||
),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
|
||||
service.cancelSessionTurn(input.sessionId),
|
||||
@@ -278,7 +310,6 @@ export function registerIpcHandlers(
|
||||
);
|
||||
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
|
||||
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
|
||||
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
|
||||
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
|
||||
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
|
||||
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
normalizeChatMessageRecord,
|
||||
normalizeSessionBranchOrigin,
|
||||
type SessionRecord,
|
||||
} from '@shared/domain/session';
|
||||
import {
|
||||
normalizeSessionToolingSelection,
|
||||
normalizeWorkspaceSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
import {
|
||||
createBuiltinWorkflowTemplates,
|
||||
normalizeWorkflowTemplateDefinition,
|
||||
type WorkflowTemplateDefinition,
|
||||
} from '@shared/domain/workflowTemplate';
|
||||
import {
|
||||
createBuiltinWorkflows,
|
||||
normalizeWorkflowDefinition,
|
||||
type WorkflowDefinition,
|
||||
} from '@shared/domain/workflow';
|
||||
import {
|
||||
applyDefaultToolApprovalPolicy,
|
||||
normalizePendingApprovalState,
|
||||
normalizeSessionApprovalSettings,
|
||||
} from '@shared/domain/approval';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import {
|
||||
@@ -26,28 +38,47 @@ import {
|
||||
} from '@main/persistence/appPaths';
|
||||
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
|
||||
|
||||
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
|
||||
const builtinTimestamp = nowIso();
|
||||
const builtinPatterns = createBuiltinPatterns(builtinTimestamp);
|
||||
const builtinIds = new Set(builtinPatterns.map((pattern) => pattern.id));
|
||||
const existingMap = new Map(existingPatterns.map((pattern) => [pattern.id, pattern]));
|
||||
function mergeBuiltinWorkflows(existingWorkflows: WorkflowDefinition[]): WorkflowDefinition[] {
|
||||
const builtinWorkflows = createBuiltinWorkflows(nowIso());
|
||||
const builtinIds = new Set(builtinWorkflows.map((workflow) => workflow.id));
|
||||
|
||||
const mergedBuiltins = builtinPatterns.map((builtin) => {
|
||||
const existing = existingMap.get(builtin.id);
|
||||
if (!existing) {
|
||||
return builtin;
|
||||
const customWorkflows = existingWorkflows
|
||||
.filter((workflow) => !builtinIds.has(workflow.id))
|
||||
.map(normalizeWorkflowDefinition);
|
||||
|
||||
return [...builtinWorkflows, ...customWorkflows];
|
||||
}
|
||||
|
||||
function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[]): WorkflowTemplateDefinition[] {
|
||||
const builtinTemplates = createBuiltinWorkflowTemplates(nowIso());
|
||||
const builtinIds = new Set(builtinTemplates.map((template) => template.id));
|
||||
const customTemplates = existingTemplates
|
||||
.map(normalizeWorkflowTemplateDefinition)
|
||||
.filter((template) => template.source !== 'builtin' && !builtinIds.has(template.id));
|
||||
|
||||
return [...builtinTemplates, ...customTemplates];
|
||||
}
|
||||
|
||||
function migrateLegacySessions(
|
||||
sessions: SessionRecord[],
|
||||
workflows: WorkflowDefinition[],
|
||||
): SessionRecord[] {
|
||||
const workflowIds = new Set(workflows.map((workflow) => workflow.id));
|
||||
const fallbackWorkflowId = workflows[0]?.id;
|
||||
|
||||
return sessions.flatMap((session) => {
|
||||
const workflowId = session.workflowId && workflowIds.has(session.workflowId)
|
||||
? session.workflowId
|
||||
: fallbackWorkflowId;
|
||||
if (!workflowId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
availability: builtin.availability,
|
||||
unavailabilityReason: builtin.unavailabilityReason,
|
||||
mode: builtin.mode,
|
||||
};
|
||||
return [{
|
||||
...session,
|
||||
workflowId,
|
||||
}];
|
||||
});
|
||||
|
||||
const customPatterns = existingPatterns.filter((pattern) => !builtinIds.has(pattern.id));
|
||||
return [...mergedBuiltins, ...customPatterns];
|
||||
}
|
||||
|
||||
export class WorkspaceRepository {
|
||||
@@ -57,7 +88,7 @@ export class WorkspaceRepository {
|
||||
async load(): Promise<WorkspaceState> {
|
||||
await mkdir(this.scratchpadPath, { recursive: true });
|
||||
|
||||
const stored = await readJsonFile<WorkspaceState>(this.filePath);
|
||||
const stored = await readJsonFile<WorkspaceState & { patterns?: unknown[] }>(this.filePath);
|
||||
if (!stored) {
|
||||
const seededBase = createWorkspaceSeed();
|
||||
const projects = mergeScratchpadProject([], this.scratchpadPath);
|
||||
@@ -78,44 +109,58 @@ export class WorkspaceRepository {
|
||||
})),
|
||||
this.scratchpadPath,
|
||||
);
|
||||
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||
const normalizedSession: SessionRecord = {
|
||||
...session,
|
||||
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
...normalizePendingApprovalState({
|
||||
pendingApproval: session.pendingApproval,
|
||||
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||
}),
|
||||
};
|
||||
if (!isScratchpadProject(normalizedSession.projectId)) {
|
||||
return normalizedSession;
|
||||
}
|
||||
|
||||
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
|
||||
await mkdir(cwd, { recursive: true });
|
||||
return {
|
||||
...normalizedSession,
|
||||
cwd,
|
||||
};
|
||||
}));
|
||||
const workflows = mergeBuiltinWorkflows((stored.workflows ?? []).map(normalizeWorkflowDefinition))
|
||||
.map((workflow) => ({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
|
||||
},
|
||||
}));
|
||||
|
||||
const sessions = migrateLegacySessions(
|
||||
await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||
const normalizedSession: SessionRecord = {
|
||||
...session,
|
||||
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
|
||||
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
...normalizePendingApprovalState({
|
||||
pendingApproval: session.pendingApproval,
|
||||
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||
}),
|
||||
};
|
||||
if (!isScratchpadProject(normalizedSession.projectId)) {
|
||||
return normalizedSession;
|
||||
}
|
||||
|
||||
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
|
||||
await mkdir(cwd, { recursive: true });
|
||||
return {
|
||||
...normalizedSession,
|
||||
cwd,
|
||||
};
|
||||
})),
|
||||
workflows,
|
||||
);
|
||||
|
||||
const settings = normalizeWorkspaceSettings(stored.settings);
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
...stored,
|
||||
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
|
||||
...pattern,
|
||||
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
|
||||
graph: resolvePatternGraph(pattern),
|
||||
})),
|
||||
workflows,
|
||||
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
|
||||
projects,
|
||||
sessions,
|
||||
settings,
|
||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||
? stored.selectedProjectId
|
||||
: projects[0]?.id,
|
||||
selectedWorkflowId: workflows.some((workflow) => workflow.id === stored.selectedWorkflowId)
|
||||
? stored.selectedWorkflowId
|
||||
: workflows[0]?.id,
|
||||
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
|
||||
};
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
mergeProjectCustomizationState,
|
||||
normalizeProjectCustomizationState,
|
||||
type ProjectAgentProfile,
|
||||
type ProjectInstructionApplicationMode,
|
||||
type ProjectCustomizationState,
|
||||
type ProjectInstructionFile,
|
||||
type ProjectPromptFile,
|
||||
type ProjectPromptVariable,
|
||||
} from '@shared/domain/projectCustomization';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
import { expandMarkdownFileLinks } from '@main/services/projectCustomizationLinkResolver';
|
||||
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
|
||||
|
||||
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
|
||||
|
||||
@@ -22,9 +25,21 @@ export class ProjectCustomizationScanner {
|
||||
current?: ProjectCustomizationState,
|
||||
): Promise<ProjectCustomizationState> {
|
||||
const previous = normalizeProjectCustomizationState(current);
|
||||
const instructions = await this.scanInstructionFiles(projectPath, previous);
|
||||
const agentProfiles = await this.scanAgentProfiles(projectPath, previous);
|
||||
const promptFiles = await this.scanPromptFiles(projectPath, previous);
|
||||
const customizationRoots = await resolveProjectCustomizationRoots(projectPath);
|
||||
const allowedRootPath = customizationRoots.at(-1) ?? projectPath;
|
||||
const instructions = await this.scanInstructionFiles(
|
||||
projectPath,
|
||||
customizationRoots,
|
||||
allowedRootPath,
|
||||
previous,
|
||||
);
|
||||
const agentProfiles = await this.scanAgentProfiles(projectPath, customizationRoots, previous);
|
||||
const promptFiles = await this.scanPromptFiles(
|
||||
projectPath,
|
||||
customizationRoots,
|
||||
allowedRootPath,
|
||||
previous,
|
||||
);
|
||||
|
||||
return mergeProjectCustomizationState(
|
||||
previous,
|
||||
@@ -39,37 +54,61 @@ export class ProjectCustomizationScanner {
|
||||
|
||||
private async scanInstructionFiles(
|
||||
projectPath: string,
|
||||
customizationRoots: ReadonlyArray<string>,
|
||||
allowedRootPath: string,
|
||||
previous: ProjectCustomizationState,
|
||||
): Promise<ProjectInstructionFile[]> {
|
||||
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
|
||||
const sourcePaths = ['.github\\copilot-instructions.md', 'AGENTS.md'] as const;
|
||||
const instructions: ProjectInstructionFile[] = [];
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const filePath = join(projectPath, ...sourcePath.split('\\'));
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
for (const customizationRoot of customizationRoots) {
|
||||
const alwaysOnSourcePaths = [
|
||||
'.github\\copilot-instructions.md',
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
'.claude\\CLAUDE.md',
|
||||
] as const;
|
||||
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
instructions.push(existing);
|
||||
for (const sourcePath of alwaysOnSourcePaths) {
|
||||
const filePath = join(customizationRoot, ...sourcePath.split('\\'));
|
||||
const normalizedSourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, normalizedSourcePath, previousByPath, {
|
||||
applicationMode: 'always',
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
});
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = contents.value.trim();
|
||||
if (!content) {
|
||||
continue;
|
||||
const instructionFilePaths = await this.listProjectFiles(
|
||||
join(customizationRoot, '.github', 'instructions'),
|
||||
'.instructions.md',
|
||||
);
|
||||
for (const filePath of instructionFilePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
});
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
}
|
||||
|
||||
instructions.push({
|
||||
id: buildProjectCustomizationItemId('instruction', sourcePath),
|
||||
sourcePath,
|
||||
content,
|
||||
});
|
||||
const claudeRuleFilePaths = await this.listProjectFiles(join(customizationRoot, '.claude', 'rules'), '.md');
|
||||
for (const filePath of claudeRuleFilePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
usesClaudeRulePaths: true,
|
||||
});
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instructions;
|
||||
@@ -77,55 +116,58 @@ export class ProjectCustomizationScanner {
|
||||
|
||||
private async scanAgentProfiles(
|
||||
projectPath: string,
|
||||
customizationRoots: ReadonlyArray<string>,
|
||||
previous: ProjectCustomizationState,
|
||||
): Promise<ProjectAgentProfile[]> {
|
||||
const previousByPath = new Map(previous.agentProfiles.map((profile) => [profile.sourcePath, profile]));
|
||||
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'agents'), '.agent.md');
|
||||
const profiles: ProjectAgentProfile[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
for (const customizationRoot of customizationRoots) {
|
||||
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'agents'), '.agent.md');
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = readOptionalString(parsedFile.attributes, ['name'])
|
||||
?? basename(filePath, '.agent.md');
|
||||
const prompt = parsedFile.body.trim();
|
||||
if (!name || !prompt) {
|
||||
continue;
|
||||
}
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
id: buildProjectCustomizationItemId('agent', sourcePath),
|
||||
name,
|
||||
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
prompt,
|
||||
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
|
||||
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
|
||||
sourcePath,
|
||||
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
|
||||
});
|
||||
const name = readOptionalString(parsedFile.attributes, ['name'])
|
||||
?? basename(filePath, '.agent.md');
|
||||
const prompt = parsedFile.body.trim();
|
||||
if (!name || !prompt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
id: buildProjectCustomizationItemId('agent', sourcePath),
|
||||
name,
|
||||
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
prompt,
|
||||
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
|
||||
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
|
||||
sourcePath,
|
||||
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
@@ -133,69 +175,92 @@ export class ProjectCustomizationScanner {
|
||||
|
||||
private async scanPromptFiles(
|
||||
projectPath: string,
|
||||
customizationRoots: ReadonlyArray<string>,
|
||||
allowedRootPath: string,
|
||||
previous: ProjectCustomizationState,
|
||||
): Promise<ProjectPromptFile[]> {
|
||||
const previousByPath = new Map(previous.promptFiles.map((promptFile) => [promptFile.sourcePath, promptFile]));
|
||||
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'prompts'), '.prompt.md');
|
||||
const promptFiles: ProjectPromptFile[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
for (const customizationRoot of customizationRoots) {
|
||||
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'prompts'), '.prompt.md');
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const template = parsedFile.body.trim();
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
promptFiles.push({
|
||||
id: buildProjectCustomizationItemId('prompt', sourcePath),
|
||||
name: basename(filePath, '.prompt.md'),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
agent: readOptionalString(parsedFile.attributes, ['agent']),
|
||||
template,
|
||||
variables: extractPromptVariables(template),
|
||||
sourcePath,
|
||||
});
|
||||
const template = await expandMarkdownFileLinks(parsedFile.body, {
|
||||
sourceFilePath: filePath,
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
});
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
|
||||
promptFiles.push({
|
||||
id: buildProjectCustomizationItemId('prompt', sourcePath),
|
||||
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
argumentHint: readOptionalString(parsedFile.attributes, ['argument-hint', 'argumentHint']),
|
||||
agent: readOptionalString(parsedFile.attributes, ['agent']),
|
||||
model: readOptionalString(parsedFile.attributes, ['model']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
template,
|
||||
variables: extractPromptVariables(template),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return promptFiles;
|
||||
}
|
||||
|
||||
private async listProjectFiles(directoryPath: string, suffix: string): Promise<string[]> {
|
||||
const filePaths: string[] = [];
|
||||
await this.collectProjectFiles(directoryPath, suffix, filePaths);
|
||||
return filePaths.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private async collectProjectFiles(directoryPath: string, suffix: string, filePaths: string[]): Promise<void> {
|
||||
try {
|
||||
const entries = await readdir(directoryPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(suffix))
|
||||
.map((entry) => join(directoryPath, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const entryPath = join(directoryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.collectProjectFiles(entryPath, suffix, filePaths);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith(suffix)) {
|
||||
filePaths.push(entryPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to read directory ${directoryPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +283,65 @@ export class ProjectCustomizationScanner {
|
||||
return { kind: 'retain-previous' };
|
||||
}
|
||||
}
|
||||
|
||||
private async scanInstructionFile(
|
||||
filePath: string,
|
||||
sourcePath: string,
|
||||
previousByPath: ReadonlyMap<string, ProjectInstructionFile>,
|
||||
options: {
|
||||
applicationMode?: ProjectInstructionApplicationMode;
|
||||
projectPath: string;
|
||||
allowedRootPath: string;
|
||||
usesClaudeRulePaths?: boolean;
|
||||
},
|
||||
): Promise<ProjectInstructionFile | undefined> {
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'missing') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (contents.kind === 'retain-previous') {
|
||||
return previousByPath.get(sourcePath);
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
return previousByPath.get(sourcePath);
|
||||
}
|
||||
|
||||
const content = await expandMarkdownFileLinks(parsedFile.body, {
|
||||
sourceFilePath: filePath,
|
||||
projectPath: options.projectPath,
|
||||
allowedRootPath: options.allowedRootPath,
|
||||
});
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const description = readOptionalString(parsedFile.attributes, ['description']);
|
||||
const applyTo = readInstructionApplyTo(parsedFile.attributes, options.usesClaudeRulePaths === true);
|
||||
const instruction: ProjectInstructionFile = {
|
||||
id: buildProjectCustomizationItemId('instruction', sourcePath),
|
||||
sourcePath,
|
||||
content,
|
||||
applicationMode: options.applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
|
||||
};
|
||||
|
||||
const name = readOptionalString(parsedFile.attributes, ['name']);
|
||||
if (name) {
|
||||
instruction.name = name;
|
||||
}
|
||||
|
||||
if (description) {
|
||||
instruction.description = description;
|
||||
}
|
||||
|
||||
if (applyTo) {
|
||||
instruction.applyTo = applyTo;
|
||||
}
|
||||
|
||||
return instruction;
|
||||
}
|
||||
}
|
||||
|
||||
function parseProjectFrontmatter(
|
||||
@@ -309,6 +433,43 @@ function readOptionalString(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readInstructionApplyTo(
|
||||
record: Record<string, unknown>,
|
||||
usesClaudeRulePaths: boolean,
|
||||
): string | undefined {
|
||||
const applyTo = readOptionalString(record, ['applyTo']);
|
||||
const paths = readOptionalStringArray(record.paths);
|
||||
|
||||
if (paths && paths.length > 0) {
|
||||
return paths.join(',');
|
||||
}
|
||||
|
||||
if (applyTo) {
|
||||
return applyTo;
|
||||
}
|
||||
|
||||
return usesClaudeRulePaths ? '**' : undefined;
|
||||
}
|
||||
|
||||
function resolveInstructionApplicationMode(
|
||||
applyTo: string | undefined,
|
||||
description: string | undefined,
|
||||
): ProjectInstructionApplicationMode {
|
||||
if (isMatchAllInstructionGlob(applyTo)) {
|
||||
return 'always';
|
||||
}
|
||||
|
||||
if (applyTo) {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
if (description) {
|
||||
return 'task';
|
||||
}
|
||||
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
function readOptionalStringArray(value: unknown): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
@@ -368,3 +529,12 @@ function normalizeYamlValue(value: unknown): unknown {
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isMatchAllInstructionGlob(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim().replaceAll('\\', '/');
|
||||
return normalizedValue === '**' || normalizedValue === '**/*';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||
|
||||
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
|
||||
|
||||
type MarkdownLinkResolutionContext = {
|
||||
projectPath: string;
|
||||
allowedRootPath: string;
|
||||
sourceFilePath: string;
|
||||
seenPaths: Set<string>;
|
||||
ancestry: readonly string[];
|
||||
};
|
||||
|
||||
export async function expandMarkdownFileLinks(
|
||||
content: string,
|
||||
options: {
|
||||
projectPath: string;
|
||||
allowedRootPath: string;
|
||||
sourceFilePath: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent) {
|
||||
return trimmedContent;
|
||||
}
|
||||
|
||||
return expandMarkdownFileLinksRecursive(trimmedContent, {
|
||||
...options,
|
||||
seenPaths: new Set<string>(),
|
||||
ancestry: [options.sourceFilePath],
|
||||
});
|
||||
}
|
||||
|
||||
async function expandMarkdownFileLinksRecursive(
|
||||
content: string,
|
||||
context: MarkdownLinkResolutionContext,
|
||||
): Promise<string> {
|
||||
const referencedBlocks: string[] = [];
|
||||
|
||||
for (const linkTarget of collectLocalMarkdownLinkTargets(content)) {
|
||||
const resolvedPath = resolveMarkdownLinkTarget(context.sourceFilePath, linkTarget);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isPathInsideRoot(resolvedPath, context.allowedRootPath)) {
|
||||
console.warn(
|
||||
`[aryx customization] Ignoring linked file outside the allowed customization root: ${resolvedPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.seenPaths.has(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.ancestry.includes(resolvedPath)) {
|
||||
console.warn(`[aryx customization] Ignoring circular Markdown link reference to ${resolvedPath}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedContent = await readLinkedFile(resolvedPath);
|
||||
if (linkedContent === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
context.seenPaths.add(resolvedPath);
|
||||
const expandedLinkedContent = isMarkdownLikePath(resolvedPath)
|
||||
? await expandMarkdownFileLinksRecursive(linkedContent, {
|
||||
...context,
|
||||
sourceFilePath: resolvedPath,
|
||||
ancestry: [...context.ancestry, resolvedPath],
|
||||
})
|
||||
: linkedContent.trim();
|
||||
|
||||
referencedBlocks.push(
|
||||
formatReferencedFileBlock(
|
||||
toProjectSourcePath(context.projectPath, resolvedPath),
|
||||
expandedLinkedContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (referencedBlocks.length === 0) {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
return `${content.trim()}\n\nReferenced file context:\n\n${referencedBlocks.join('\n\n')}`.trim();
|
||||
}
|
||||
|
||||
function collectLocalMarkdownLinkTargets(content: string): string[] {
|
||||
const targets: string[] = [];
|
||||
const seenTargets = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = markdownLinkPattern.exec(content))) {
|
||||
const target = match[1]?.trim();
|
||||
if (!target || seenTargets.has(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenTargets.add(target);
|
||||
targets.push(target);
|
||||
}
|
||||
|
||||
markdownLinkPattern.lastIndex = 0;
|
||||
return targets;
|
||||
}
|
||||
|
||||
function resolveMarkdownLinkTarget(sourceFilePath: string, rawTarget: string): string | undefined {
|
||||
const target = extractMarkdownLinkDestination(rawTarget);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolve(dirname(sourceFilePath), target);
|
||||
}
|
||||
|
||||
function extractMarkdownLinkDestination(rawTarget: string): string | undefined {
|
||||
let target = rawTarget.trim();
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (target.startsWith('<') && target.endsWith('>')) {
|
||||
target = target.slice(1, -1).trim();
|
||||
} else {
|
||||
const whitespaceIndex = target.search(/\s/);
|
||||
if (whitespaceIndex >= 0) {
|
||||
target = target.slice(0, whitespaceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const hashIndex = target.indexOf('#');
|
||||
if (hashIndex >= 0) {
|
||||
target = target.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedTarget = target.toLowerCase();
|
||||
if (
|
||||
target.startsWith('#')
|
||||
|| isAbsolute(target)
|
||||
|| normalizedTarget.startsWith('http://')
|
||||
|| normalizedTarget.startsWith('https://')
|
||||
|| normalizedTarget.startsWith('mailto:')
|
||||
|| normalizedTarget.startsWith('vscode:')
|
||||
|| normalizedTarget.startsWith('command:')
|
||||
|| normalizedTarget.startsWith('data:')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
async function readLinkedFile(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
if (content.includes('\0')) {
|
||||
console.warn(`[aryx customization] Ignoring binary-linked file ${filePath}.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.warn(`[aryx customization] Linked file not found: ${filePath}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to read linked file ${filePath}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReferencedFileBlock(sourcePath: string, content: string): string {
|
||||
return [
|
||||
`Source: ${sourcePath}`,
|
||||
'Contents:',
|
||||
content.trim() || '[empty file]',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function isMarkdownLikePath(filePath: string): boolean {
|
||||
const normalizedPath = filePath.toLowerCase();
|
||||
return normalizedPath.endsWith('.md') || normalizedPath.endsWith('.markdown');
|
||||
}
|
||||
|
||||
function isPathInsideRoot(filePath: string, rootPath: string): boolean {
|
||||
const relativePath = relative(rootPath, filePath);
|
||||
return relativePath.length === 0
|
||||
|| (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function toProjectSourcePath(projectPath: string, filePath: string): string {
|
||||
const relativePath = relative(projectPath, filePath).trim();
|
||||
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { access } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export async function resolveProjectCustomizationRoots(projectPath: string): Promise<string[]> {
|
||||
if (await hasGitEntry(projectPath)) {
|
||||
return [projectPath];
|
||||
}
|
||||
|
||||
const ancestorPaths: string[] = [];
|
||||
let currentPath = projectPath;
|
||||
while (true) {
|
||||
const parentPath = dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
return [projectPath];
|
||||
}
|
||||
|
||||
ancestorPaths.push(parentPath);
|
||||
if (await hasGitEntry(parentPath)) {
|
||||
return [projectPath, ...ancestorPaths];
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitEntry(directoryPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(join(directoryPath, '.git'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to inspect ${join(directoryPath, '.git')}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { watch } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
|
||||
|
||||
export interface ProjectCustomizationWatchTarget {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
type ProjectWatchHandle = {
|
||||
close(): void;
|
||||
};
|
||||
|
||||
type ProjectWatchFactory = (
|
||||
directoryPath: string,
|
||||
onChange: () => void,
|
||||
) => ProjectWatchHandle;
|
||||
|
||||
type ProjectWatchPathResolver = (projectPath: string) => Promise<string[]>;
|
||||
|
||||
export class ProjectCustomizationWatcher {
|
||||
private readonly watchFactory: ProjectWatchFactory;
|
||||
private readonly resolveWatchPaths: ProjectWatchPathResolver;
|
||||
private readonly debounceMs: number;
|
||||
private readonly watchHandlesByProjectId = new Map<string, Map<string, ProjectWatchHandle>>();
|
||||
private readonly pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(
|
||||
private readonly onChange: (projectId: string) => void | Promise<void>,
|
||||
options?: {
|
||||
watchFactory?: ProjectWatchFactory;
|
||||
resolveWatchPaths?: ProjectWatchPathResolver;
|
||||
debounceMs?: number;
|
||||
},
|
||||
) {
|
||||
this.watchFactory = options?.watchFactory ?? createProjectWatchHandle;
|
||||
this.resolveWatchPaths = options?.resolveWatchPaths ?? collectProjectCustomizationWatchPaths;
|
||||
this.debounceMs = options?.debounceMs ?? 250;
|
||||
}
|
||||
|
||||
async syncProjects(projects: ReadonlyArray<ProjectCustomizationWatchTarget>): Promise<void> {
|
||||
const nextProjectsById = new Map(projects.map((project) => [project.id, project]));
|
||||
|
||||
for (const projectId of this.watchHandlesByProjectId.keys()) {
|
||||
if (!nextProjectsById.has(projectId)) {
|
||||
this.unwatchProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
await this.syncProject(project);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const projectId of this.watchHandlesByProjectId.keys()) {
|
||||
this.unwatchProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncProject(project: ProjectCustomizationWatchTarget): Promise<void> {
|
||||
const nextWatchPaths = new Set(await this.resolveWatchPaths(project.path));
|
||||
const currentWatchHandles = this.watchHandlesByProjectId.get(project.id) ?? new Map<string, ProjectWatchHandle>();
|
||||
|
||||
for (const [watchPath, handle] of currentWatchHandles) {
|
||||
if (nextWatchPaths.has(watchPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handle.close();
|
||||
currentWatchHandles.delete(watchPath);
|
||||
}
|
||||
|
||||
for (const watchPath of nextWatchPaths) {
|
||||
if (currentWatchHandles.has(watchPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
currentWatchHandles.set(watchPath, this.watchFactory(watchPath, () => this.scheduleChange(project.id)));
|
||||
} catch (error) {
|
||||
console.warn(`[aryx customization] Failed to watch ${watchPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWatchHandles.size > 0) {
|
||||
this.watchHandlesByProjectId.set(project.id, currentWatchHandles);
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchHandlesByProjectId.delete(project.id);
|
||||
}
|
||||
|
||||
private unwatchProject(projectId: string): void {
|
||||
const timer = this.pendingTimers.get(projectId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.pendingTimers.delete(projectId);
|
||||
}
|
||||
|
||||
const watchHandles = this.watchHandlesByProjectId.get(projectId);
|
||||
if (!watchHandles) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handle of watchHandles.values()) {
|
||||
handle.close();
|
||||
}
|
||||
|
||||
this.watchHandlesByProjectId.delete(projectId);
|
||||
}
|
||||
|
||||
private scheduleChange(projectId: string): void {
|
||||
const existingTimer = this.pendingTimers.get(projectId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTimers.delete(projectId);
|
||||
void Promise.resolve(this.onChange(projectId)).catch((error) => {
|
||||
console.warn(`[aryx customization] Failed to process watcher update for ${projectId}:`, error);
|
||||
});
|
||||
}, this.debounceMs);
|
||||
timer.unref?.();
|
||||
this.pendingTimers.set(projectId, timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise<string[]> {
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const customizationRoot of await resolveProjectCustomizationRoots(projectPath)) {
|
||||
paths.add(customizationRoot);
|
||||
|
||||
for (const relativeRoot of ['.github', '.claude']) {
|
||||
for (const directoryPath of await collectExistingDirectories(join(customizationRoot, relativeRoot))) {
|
||||
paths.add(directoryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function collectExistingDirectories(rootPath: string): Promise<string[]> {
|
||||
try {
|
||||
const directories = [rootPath];
|
||||
const entries = await readdir(rootPath, { withFileTypes: true });
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
directories.push(...await collectExistingDirectories(join(rootPath, entry.name)));
|
||||
}
|
||||
|
||||
return directories;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to enumerate watch paths under ${rootPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createProjectWatchHandle(directoryPath: string, onChange: () => void): ProjectWatchHandle {
|
||||
return watch(directoryPath, { persistent: false }, () => {
|
||||
onChange();
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
UserInputRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
ValidatePatternCommand,
|
||||
ValidateWorkflowCommand,
|
||||
RunTurnCommand,
|
||||
CopilotSessionListFilter,
|
||||
CopilotSessionInfo,
|
||||
@@ -42,8 +42,8 @@ type PendingCommand =
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'validate-pattern';
|
||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||
kind: 'validate-workflow';
|
||||
resolve: (issues: ValidateWorkflowCommand['workflow'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
@@ -119,11 +119,15 @@ export class SidecarClient {
|
||||
return command;
|
||||
}
|
||||
|
||||
async validatePattern(pattern: ValidatePatternCommand['pattern']): Promise<unknown> {
|
||||
async validateWorkflow(
|
||||
workflow: ValidateWorkflowCommand['workflow'],
|
||||
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
|
||||
): Promise<unknown> {
|
||||
return this.dispatch<unknown>({
|
||||
type: 'validate-pattern',
|
||||
requestId: `validate-${Date.now()}`,
|
||||
pattern,
|
||||
type: 'validate-workflow',
|
||||
requestId: `validate-workflow-${Date.now()}`,
|
||||
workflow,
|
||||
workflowLibrary,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -310,10 +314,10 @@ export class SidecarClient {
|
||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
} else if (command.type === 'validate-workflow') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'validate-pattern',
|
||||
kind: 'validate-workflow',
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
@@ -407,8 +411,8 @@ export class SidecarClient {
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'pattern-validation':
|
||||
if (pending.kind === 'validate-pattern') {
|
||||
case 'workflow-validation':
|
||||
if (pending.kind === 'validate-workflow') {
|
||||
pending.resolve(event.issues);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
|
||||
+10
-4
@@ -23,9 +23,14 @@ const api: ElectronApi = {
|
||||
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
|
||||
setProjectAgentProfileEnabled: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
|
||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
||||
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
|
||||
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
||||
listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, workflowId),
|
||||
createWorkflowFromTemplate: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowFromTemplate, input),
|
||||
exportWorkflow: (input) => ipcRenderer.invoke(ipcChannels.exportWorkflow, input),
|
||||
importWorkflow: (input) => ipcRenderer.invoke(ipcChannels.importWorkflow, input),
|
||||
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
||||
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
||||
@@ -37,6 +42,8 @@ const api: ElectronApi = {
|
||||
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
|
||||
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
|
||||
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
|
||||
saveWorkspaceAgent: (input) => ipcRenderer.invoke(ipcChannels.saveWorkspaceAgent, input),
|
||||
deleteWorkspaceAgent: (agentId) => ipcRenderer.invoke(ipcChannels.deleteWorkspaceAgent, agentId),
|
||||
describeTerminal: () => ipcRenderer.invoke(ipcChannels.describeTerminal),
|
||||
createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal),
|
||||
restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal),
|
||||
@@ -83,7 +90,6 @@ const api: ElectronApi = {
|
||||
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
|
||||
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
|
||||
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
|
||||
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
|
||||
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
|
||||
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
|
||||
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
|
||||
|
||||
+167
-94
@@ -7,7 +7,6 @@ import { ChatPane } from '@renderer/components/ChatPane';
|
||||
import { CommandPalette } from '@renderer/components/CommandPalette';
|
||||
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
||||
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
pruneSessionUsage,
|
||||
pruneSessionRequestUsage,
|
||||
pruneTurnEventLogs,
|
||||
purgeCompletedActivity,
|
||||
type SessionActivityMap,
|
||||
type SessionUsageMap,
|
||||
type SessionRequestUsageMap,
|
||||
@@ -39,44 +39,20 @@ import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
|
||||
import {
|
||||
buildAvailableModelCatalog,
|
||||
findModel,
|
||||
normalizePatternModels,
|
||||
normalizeWorkflowModels,
|
||||
resolveReasoningEffort,
|
||||
} from '@shared/domain/models';
|
||||
import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
|
||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import { type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import { applySessionModelConfig } from '@shared/domain/session';
|
||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
|
||||
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
|
||||
const timestamp = nowIso();
|
||||
return syncPatternGraph({
|
||||
id: createId('custom-pattern'),
|
||||
name: 'New Pattern',
|
||||
description: '',
|
||||
mode: 'single',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
approvalPolicy: createDefaultToolApprovalPolicy(),
|
||||
agents: [
|
||||
{
|
||||
id: createId('agent'),
|
||||
name: 'Primary Agent',
|
||||
description: 'General-purpose assistant.',
|
||||
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
||||
model: defaultModelId,
|
||||
reasoningEffort: defaultReasoningEffort,
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker';
|
||||
|
||||
function createDraftMcpServer(): McpServerDefinition {
|
||||
const timestamp = nowIso();
|
||||
@@ -106,6 +82,64 @@ function createDraftLspProfile(): LspProfileDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefinition {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id: createId('agent'),
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: defaultModelId,
|
||||
reasoningEffort: 'high',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort?: ReasoningEffort): WorkflowDefinition {
|
||||
const timestamp = nowIso();
|
||||
const startId = createId('wf-start');
|
||||
const agentId = createId('wf-agent');
|
||||
const endId = createId('wf-end');
|
||||
return {
|
||||
id: createId('workflow'),
|
||||
name: 'New Workflow',
|
||||
description: '',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: startId, kind: 'start', label: 'Start', position: { x: 0, y: 100 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: agentId,
|
||||
kind: 'agent',
|
||||
label: 'Primary Agent',
|
||||
position: { x: 250, y: 100 },
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: createId('agent'),
|
||||
name: 'Primary Agent',
|
||||
description: 'General-purpose assistant.',
|
||||
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
||||
model: defaultModelId,
|
||||
reasoningEffort: defaultReasoningEffort,
|
||||
},
|
||||
},
|
||||
{ id: endId, kind: 'end', label: 'End', position: { x: 500, y: 100 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: `edge-${startId}-to-${agentId}`, source: startId, target: agentId, kind: 'direct' },
|
||||
{ id: `edge-${agentId}-to-${endId}`, source: agentId, target: endId, kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
maxIterations: 5,
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const api = getElectronApi();
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||
@@ -116,18 +150,21 @@ export default function App() {
|
||||
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
|
||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||
const activityPurgeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
|
||||
// Workflow picker state — holds the projectId we're creating a session for
|
||||
const [workflowPickerProjectId, setWorkflowPickerProjectId] = useState<string | null>(null);
|
||||
|
||||
// Commit composer state
|
||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||
|
||||
@@ -191,12 +228,35 @@ export default function App() {
|
||||
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
|
||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||
setActiveSubagents((current) => applySubagentEvent(current, event));
|
||||
|
||||
// Schedule purge of completed activity labels after grace period
|
||||
if (event.kind === 'status' && event.status === 'idle') {
|
||||
const existing = activityPurgeTimers.current.get(event.sessionId);
|
||||
if (existing) clearTimeout(existing);
|
||||
activityPurgeTimers.current.set(
|
||||
event.sessionId,
|
||||
setTimeout(() => {
|
||||
setSessionActivities((current) => purgeCompletedActivity(current, event.sessionId));
|
||||
activityPurgeTimers.current.delete(event.sessionId);
|
||||
}, 1500),
|
||||
);
|
||||
}
|
||||
// Cancel pending purge if a new run starts
|
||||
if (event.kind === 'status' && event.status === 'running') {
|
||||
const existing = activityPurgeTimers.current.get(event.sessionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
activityPurgeTimers.current.delete(event.sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
offWorkspace();
|
||||
offSessionEvent();
|
||||
for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer);
|
||||
activityPurgeTimers.current.clear();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -226,23 +286,23 @@ export default function App() {
|
||||
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
|
||||
[sidecarCapabilities?.models],
|
||||
);
|
||||
const patternForSession = useMemo(() => {
|
||||
const workflowForSession = useMemo(() => {
|
||||
if (!selectedSession) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const basePattern = workspace?.patterns.find((pattern) => pattern.id === selectedSession.patternId);
|
||||
if (!basePattern) {
|
||||
const baseWorkflow = workspace?.workflows.find((workflow) => workflow.id === selectedSession.workflowId);
|
||||
if (!baseWorkflow) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const patternWithSessionConfig =
|
||||
const workflowWithSessionConfig =
|
||||
projectForSession && selectedSession.sessionModelConfig
|
||||
? applySessionModelConfig(basePattern, selectedSession)
|
||||
: basePattern;
|
||||
? applySessionModelConfig(baseWorkflow, selectedSession)
|
||||
: baseWorkflow;
|
||||
|
||||
return normalizePatternModels(patternWithSessionConfig, availableModels);
|
||||
}, [availableModels, projectForSession, selectedSession, workspace?.patterns]);
|
||||
return normalizeWorkflowModels(workflowWithSessionConfig, availableModels);
|
||||
}, [availableModels, projectForSession, selectedSession, workspace?.workflows]);
|
||||
const activityForSession = useMemo(
|
||||
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
|
||||
[selectedSession, sessionActivities],
|
||||
@@ -301,8 +361,6 @@ export default function App() {
|
||||
commandPaletteOpenRef.current = commandPaletteOpen;
|
||||
const projectSettingsIdRef = useRef(projectSettingsId);
|
||||
projectSettingsIdRef.current = projectSettingsId;
|
||||
const newSessionProjectIdRef = useRef(newSessionProjectId);
|
||||
newSessionProjectIdRef.current = newSessionProjectId;
|
||||
|
||||
// ── Global keyboard shortcuts ──
|
||||
useEffect(() => {
|
||||
@@ -371,11 +429,6 @@ export default function App() {
|
||||
setShowSettings(false);
|
||||
return;
|
||||
}
|
||||
if (newSessionProjectIdRef.current) {
|
||||
e.preventDefault();
|
||||
setNewSessionProjectId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If nothing is open, cancel a running turn on the selected session
|
||||
if (ws) {
|
||||
@@ -400,7 +453,12 @@ export default function App() {
|
||||
ws.selectedProjectId ??
|
||||
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
||||
if (defaultProjectId) {
|
||||
setNewSessionProjectId(defaultProjectId);
|
||||
if (ws.workflows.length <= 1) {
|
||||
const wf = ws.workflows[0];
|
||||
if (wf) void api.createSession({ projectId: defaultProjectId, workflowId: wf.id });
|
||||
} else {
|
||||
setWorkflowPickerProjectId(defaultProjectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -524,20 +582,32 @@ export default function App() {
|
||||
|
||||
const handleCreateScratchpad = useCallback(() => {
|
||||
if (!workspace) return;
|
||||
const singlePatterns = workspace.patterns
|
||||
.filter((p) => p.mode === 'single' && p.availability !== 'unavailable')
|
||||
.sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const defaultPattern = singlePatterns[0];
|
||||
if (defaultPattern) {
|
||||
void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, patternId: defaultPattern.id });
|
||||
if (workspace.workflows.length <= 1) {
|
||||
const wf = workspace.workflows[0];
|
||||
if (wf) void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, workflowId: wf.id });
|
||||
return;
|
||||
}
|
||||
setWorkflowPickerProjectId(SCRATCHPAD_PROJECT_ID);
|
||||
}, [api, workspace]);
|
||||
|
||||
/** Opens the workflow picker, or creates immediately if ≤1 workflow. */
|
||||
const handleNewSession = useCallback((projectId: string) => {
|
||||
if (!workspace) return;
|
||||
if (workspace.workflows.length <= 1) {
|
||||
const wf = workspace.workflows[0];
|
||||
if (wf) void api.createSession({ projectId, workflowId: wf.id });
|
||||
return;
|
||||
}
|
||||
setWorkflowPickerProjectId(projectId);
|
||||
}, [api, workspace]);
|
||||
|
||||
/** Called when a workflow is picked from the picker. */
|
||||
const handleWorkflowPicked = useCallback((workflowId: string) => {
|
||||
if (!workflowPickerProjectId) return;
|
||||
void api.createSession({ projectId: workflowPickerProjectId, workflowId });
|
||||
setWorkflowPickerProjectId(null);
|
||||
}, [api, workflowPickerProjectId]);
|
||||
|
||||
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
|
||||
setSettingsSection(section);
|
||||
setShowSettings(true);
|
||||
@@ -585,14 +655,15 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (selectedSession && patternForSession && projectForSession) {
|
||||
} else if (selectedSession && workflowForSession && projectForSession) {
|
||||
content = (
|
||||
<ChatPane
|
||||
onSend={(c, attachments, messageMode) => api.sendSessionMessage({
|
||||
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
|
||||
sessionId: selectedSession.id,
|
||||
content: c,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
messageMode,
|
||||
promptInvocation,
|
||||
})}
|
||||
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
|
||||
onResolveApproval={(approvalId, decision, alwaysApprove) =>
|
||||
@@ -654,26 +725,26 @@ export default function App() {
|
||||
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
||||
onTerminalToggle={handleTerminalToggle}
|
||||
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
||||
pattern={patternForSession}
|
||||
workflow={workflowForSession}
|
||||
project={projectForSession}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
sessionActivity={activityForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
||||
terminalRunning={terminalRunning}
|
||||
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
|
||||
gitDirty={gitDirty}
|
||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||
onDiscardRunChanges={handleDiscardRunChanges}
|
||||
onOpenCommitComposer={handleOpenCommitComposer}
|
||||
/>
|
||||
);
|
||||
detailPanel = (
|
||||
<ActivityPanel
|
||||
activity={activityForSession}
|
||||
onDiscard={handleDiscardRunChanges}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onOpenCommitComposer={handleOpenCommitComposer}
|
||||
pattern={patternForSession}
|
||||
workflow={workflowForSession}
|
||||
session={selectedSession}
|
||||
sessionRequestUsage={requestUsageForSession}
|
||||
turnEvents={turnEventsForSession}
|
||||
@@ -704,15 +775,14 @@ export default function App() {
|
||||
onDeleteMcpServer={async (id) => {
|
||||
await api.deleteMcpServer(id);
|
||||
}}
|
||||
onDeletePattern={async (id) => {
|
||||
await api.deletePattern(id);
|
||||
onDeleteWorkflow={async (id) => {
|
||||
await api.deleteWorkflow(id);
|
||||
}}
|
||||
onNewLspProfile={createDraftLspProfile}
|
||||
onNewMcpServer={createDraftMcpServer}
|
||||
onNewPattern={() => {
|
||||
onNewWorkflow={() => {
|
||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
||||
|
||||
return createDraftPattern(
|
||||
return createDraftWorkflow(
|
||||
defaultModel?.id ?? 'gpt-5.4',
|
||||
resolveReasoningEffort(defaultModel, 'high'),
|
||||
);
|
||||
@@ -724,9 +794,20 @@ export default function App() {
|
||||
onSaveMcpServer={async (server) => {
|
||||
await api.saveMcpServer({ server });
|
||||
}}
|
||||
onSavePattern={async (pattern) => {
|
||||
await api.savePattern({ pattern });
|
||||
onSaveWorkflow={async (workflow) => {
|
||||
await api.saveWorkflow({ workflow });
|
||||
}}
|
||||
onSaveWorkspaceAgent={async (agent) => {
|
||||
await api.saveWorkspaceAgent({ agent });
|
||||
}}
|
||||
onDeleteWorkspaceAgent={async (id) => {
|
||||
await api.deleteWorkspaceAgent(id);
|
||||
}}
|
||||
onNewWorkspaceAgent={() => {
|
||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
||||
return createDraftWorkspaceAgent(defaultModel?.id ?? 'gpt-5.4');
|
||||
}}
|
||||
workspaceAgents={workspace.settings.agents ?? []}
|
||||
onSetTheme={(theme) => void api.setTheme(theme)}
|
||||
notificationsEnabled={workspace.settings.notificationsEnabled !== false}
|
||||
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
|
||||
@@ -741,7 +822,11 @@ export default function App() {
|
||||
setSessionActivities({});
|
||||
setShowSettings(false);
|
||||
}}
|
||||
patterns={workspace.patterns}
|
||||
workflows={workspace.workflows}
|
||||
workflowTemplates={workspace.workflowTemplates}
|
||||
onCreateWorkflowFromTemplate={async (templateId, name) => {
|
||||
await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined });
|
||||
}}
|
||||
sidecarCapabilities={sidecarCapabilities}
|
||||
theme={workspace.settings.theme}
|
||||
toolingSettings={workspace.settings.tooling}
|
||||
@@ -790,9 +875,7 @@ export default function App() {
|
||||
<Sidebar
|
||||
onAddProject={() => void api.addProject()}
|
||||
onCreateScratchpad={() => handleCreateScratchpad()}
|
||||
onNewProjectSession={(projectId) => {
|
||||
setNewSessionProjectId(projectId);
|
||||
}}
|
||||
onNewProjectSession={(projectId) => handleNewSession(projectId)}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
||||
onProjectSelect={(projectId) => {
|
||||
@@ -827,22 +910,6 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{newSessionProjectId && (
|
||||
<NewSessionModal
|
||||
defaultProjectId={newSessionProjectId}
|
||||
onClose={() => setNewSessionProjectId(undefined)}
|
||||
onCreate={(projectId, patternId) => {
|
||||
setNewSessionProjectId(undefined);
|
||||
void api.createSession({ projectId, patternId });
|
||||
}}
|
||||
onTogglePatternFavorite={(patternId, isFavorite) => {
|
||||
void api.setPatternFavorite({ patternId, isFavorite });
|
||||
}}
|
||||
patterns={workspace.patterns}
|
||||
projects={workspace.projects}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDiscoveryModal && (
|
||||
<DiscoveredToolingModal
|
||||
onClose={() => setShowDiscoveryModal(false)}
|
||||
@@ -893,9 +960,7 @@ export default function App() {
|
||||
onSelectProject={(projectId) => {
|
||||
void api.selectProject(projectId);
|
||||
}}
|
||||
onNewSession={(projectId) => {
|
||||
setNewSessionProjectId(projectId);
|
||||
}}
|
||||
onNewSession={(projectId) => handleNewSession(projectId)}
|
||||
onCreateScratchpad={handleCreateScratchpad}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
||||
@@ -953,6 +1018,14 @@ export default function App() {
|
||||
sessionId={commitComposerCtx.sessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workflowPickerProjectId && workspace && (
|
||||
<WorkflowPicker
|
||||
workflows={workspace.workflows}
|
||||
onSelect={handleWorkflowPicked}
|
||||
onClose={() => setWorkflowPickerProjectId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -15,22 +15,19 @@ import {
|
||||
type SessionRequestUsageState,
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
|
||||
/* ── Mode accent colours ───────────────────────────────────── */
|
||||
|
||||
const modeAccent: Record<OrchestrationMode, { dot: string; bar: string; label: string }> = {
|
||||
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
|
||||
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
|
||||
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
|
||||
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
|
||||
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
|
||||
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
|
||||
magentic: { dot: 'bg-[var(--color-text-muted)]', bar: 'bg-[var(--color-text-muted)] opacity-60', label: 'text-[var(--color-text-muted)]' },
|
||||
};
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
@@ -50,13 +47,12 @@ function formatEffort(effort: string | undefined): string | undefined {
|
||||
return labels[effort] ?? effort;
|
||||
}
|
||||
|
||||
const modeLabels: Record<OrchestrationMode, string> = {
|
||||
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
|
||||
single: 'Single agent',
|
||||
sequential: 'Sequential',
|
||||
concurrent: 'Concurrent',
|
||||
handoff: 'Handoff',
|
||||
'group-chat': 'Group chat',
|
||||
magentic: 'Magentic',
|
||||
};
|
||||
|
||||
/* ── Section header ────────────────────────────────────────── */
|
||||
@@ -79,8 +75,8 @@ function AgentRow({
|
||||
agentUsage,
|
||||
}: {
|
||||
row: AgentActivityRow;
|
||||
agent?: PatternAgentDefinition;
|
||||
accent: (typeof modeAccent)[OrchestrationMode];
|
||||
agent?: AgentNodeConfig;
|
||||
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
|
||||
isLast: boolean;
|
||||
agentUsage?: AgentUsageAccumulator;
|
||||
}) {
|
||||
@@ -210,10 +206,7 @@ function formatTurnEventTimestamp(iso: string): string {
|
||||
|
||||
interface ActivityPanelProps {
|
||||
activity?: SessionActivityState;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
pattern: PatternDefinition;
|
||||
workflow: WorkflowDefinition;
|
||||
session: SessionRecord;
|
||||
sessionRequestUsage?: SessionRequestUsageState;
|
||||
turnEvents?: TurnEventLog;
|
||||
@@ -221,24 +214,29 @@ interface ActivityPanelProps {
|
||||
|
||||
export function ActivityPanel({
|
||||
activity,
|
||||
onJumpToMessage,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
pattern,
|
||||
workflow,
|
||||
session,
|
||||
sessionRequestUsage,
|
||||
turnEvents,
|
||||
}: ActivityPanelProps) {
|
||||
const workflowAgents = useMemo(
|
||||
() => resolveWorkflowAgentNodes(workflow)
|
||||
.map((n) => n.config)
|
||||
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
|
||||
[workflow],
|
||||
);
|
||||
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
|
||||
|
||||
const activityRows = useMemo(
|
||||
() => buildAgentActivityRows(activity, pattern.agents),
|
||||
[activity, pattern.agents],
|
||||
() => buildAgentActivityRows(activity, workflowAgents),
|
||||
[activity, workflowAgents],
|
||||
);
|
||||
|
||||
const isBusy = session.status === 'running';
|
||||
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
||||
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
|
||||
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
|
||||
const accent = modeAccent[pattern.mode] ?? modeAccent.single;
|
||||
const accent = modeAccent[workflowMode] ?? modeAccent.single;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -272,7 +270,7 @@ export function ActivityPanel({
|
||||
{activityRows.length}
|
||||
</span>
|
||||
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
|
||||
{modeLabels[pattern.mode]}
|
||||
{modeLabels[workflowMode]}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
|
||||
@@ -285,7 +283,7 @@ export function ActivityPanel({
|
||||
return (
|
||||
<AgentRow
|
||||
accent={accent}
|
||||
agent={pattern.agents[index]}
|
||||
agent={workflowAgents[index]}
|
||||
agentUsage={agentUsage}
|
||||
isLast={index === activityRows.length - 1}
|
||||
key={row.key}
|
||||
@@ -340,27 +338,6 @@ export function ActivityPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Run timeline section ─────────────────────────── */}
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<Clock className="size-3" />
|
||||
<span>Timeline</span>
|
||||
{session.runs.length > 0 && (
|
||||
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
|
||||
{session.runs.length}
|
||||
</span>
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
<RunTimeline
|
||||
onDiscard={onDiscard}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runs={session.runs}
|
||||
sessionId={session.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Turn events section ─────────────────────────── */}
|
||||
{turnEvents && turnEvents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
providerMeta,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
|
||||
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bookmark, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react';
|
||||
import { AlertCircle, ArrowUp, Bookmark, Bot, ChevronDown, ChevronRight, Circle, ClipboardList, FileText, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||
@@ -10,16 +10,17 @@ import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||
import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||
import { InlinePromptPill, type ArmedPrompt } from '@renderer/components/chat/InlinePromptPill';
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
|
||||
import { TurnActivityPanel } from '@renderer/components/chat/TurnActivityPanel';
|
||||
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
|
||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||
import type { SessionUsageState, SessionActivityState } from '@renderer/lib/sessionActivity';
|
||||
import { summarizeSessionActivity } from '@renderer/lib/sessionActivity';
|
||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||
import {
|
||||
findModel,
|
||||
@@ -27,9 +28,11 @@ import {
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import { isScratchpadProject, type ProjectGitFileReference, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||
import {
|
||||
countApprovedToolsInGroups,
|
||||
groupApprovalToolsByProvider,
|
||||
@@ -43,23 +46,33 @@ import {
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'message'; message: ChatMessageRecord }
|
||||
| { type: 'thinking-group'; messages: ChatMessageRecord[]; turnStartedAt?: string };
|
||||
| {
|
||||
type: 'turn-activity';
|
||||
thinkingMessages: ChatMessageRecord[];
|
||||
run?: SessionRunRecord;
|
||||
turnStartedAt?: string;
|
||||
/** Agent names in this turn group, derived from thinking message authors. Used to scope run events. */
|
||||
agentNames?: ReadonlySet<string>;
|
||||
/** True when this is the last turn-activity panel that shares a given run (controls git summary / discard). */
|
||||
isLastRunPanel?: boolean;
|
||||
};
|
||||
|
||||
interface ChatPaneProps {
|
||||
project: ProjectRecord;
|
||||
pattern: PatternDefinition;
|
||||
workflow: WorkflowDefinition;
|
||||
session: SessionRecord;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
mcpProbingServerIds?: string[];
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
sessionUsage?: SessionUsageState;
|
||||
sessionActivity?: SessionActivityState;
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitPanelOpen?: boolean;
|
||||
gitDirty?: boolean;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode, promptInvocation?: ProjectPromptInvocation) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
|
||||
@@ -79,18 +92,21 @@ interface ChatPaneProps {
|
||||
onPinMessage?: (messageId: string, isPinned: boolean) => void;
|
||||
onRegenerateMessage?: (messageId: string) => void;
|
||||
onEditAndResendMessage?: (messageId: string, content: string) => void;
|
||||
onDiscardRunChanges?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
branchOriginLabel?: string;
|
||||
}
|
||||
|
||||
export function ChatPane({
|
||||
project,
|
||||
pattern,
|
||||
workflow,
|
||||
session,
|
||||
availableModels,
|
||||
toolingSettings,
|
||||
mcpProbingServerIds,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
sessionActivity,
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
@@ -113,6 +129,8 @@ export function ChatPane({
|
||||
onPinMessage,
|
||||
onRegenerateMessage,
|
||||
onEditAndResendMessage,
|
||||
onDiscardRunChanges,
|
||||
onOpenCommitComposer,
|
||||
branchOriginLabel,
|
||||
}: ChatPaneProps) {
|
||||
const [hasComposerContent, setHasComposerContent] = useState(false);
|
||||
@@ -132,38 +150,127 @@ export function ChatPane({
|
||||
const items: DisplayItem[] = [];
|
||||
let pendingThinking: ChatMessageRecord[] = [];
|
||||
let lastUserMessageId: string | undefined;
|
||||
const messages = session.messages;
|
||||
const busy = session.status === 'running';
|
||||
// Track which runs have been attached to a turn-activity item so we
|
||||
// can detect orphaned runs that need their own panel.
|
||||
const consumedRunIds = new Set<string>();
|
||||
|
||||
/** Collect unique author names from a batch of thinking messages. */
|
||||
function collectAgentNames(msgs: ChatMessageRecord[]): Set<string> | undefined {
|
||||
const names = new Set<string>();
|
||||
for (const m of msgs) {
|
||||
if (m.authorName) names.add(m.authorName);
|
||||
}
|
||||
return names.size > 0 ? names : undefined;
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const isLast = i === messages.length - 1;
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.messageKind === 'thinking') {
|
||||
pendingThinking.push(message);
|
||||
} else {
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
pendingThinking = [];
|
||||
}
|
||||
items.push({ type: 'message', message });
|
||||
if (message.role === 'user') {
|
||||
lastUserMessageId = message.id;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optimistic thinking classification: fold a pending assistant
|
||||
// message into the current activity group when it immediately follows
|
||||
// thinking messages during an active turn, OR when it's the very last
|
||||
// message and no kind has been assigned yet. This prevents the brief
|
||||
// flash where content renders as a full chat message before the
|
||||
// message-reclassified event arrives.
|
||||
if (
|
||||
busy
|
||||
&& isLast
|
||||
&& message.role === 'assistant'
|
||||
&& message.pending
|
||||
&& !message.messageKind
|
||||
) {
|
||||
pendingThinking.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
if (run) consumedRunIds.add(run.id);
|
||||
items.push({
|
||||
type: 'turn-activity',
|
||||
thinkingMessages: pendingThinking,
|
||||
run,
|
||||
turnStartedAt: run?.startedAt,
|
||||
agentNames: collectAgentNames(pendingThinking),
|
||||
});
|
||||
pendingThinking = [];
|
||||
}
|
||||
items.push({ type: 'message', message });
|
||||
if (message.role === 'user') {
|
||||
lastUserMessageId = message.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
if (run) consumedRunIds.add(run.id);
|
||||
items.push({
|
||||
type: 'turn-activity',
|
||||
thinkingMessages: pendingThinking,
|
||||
run,
|
||||
turnStartedAt: run?.startedAt,
|
||||
agentNames: collectAgentNames(pendingThinking),
|
||||
});
|
||||
}
|
||||
|
||||
// If the session is busy but no turn-activity was emitted for the
|
||||
// active run (thinking messages haven't been reclassified yet), inject
|
||||
// one so the panel appears as soon as the run starts producing events.
|
||||
if (lastUserMessageId) {
|
||||
const activeRun = runsByTrigger.get(lastUserMessageId);
|
||||
if (activeRun && !consumedRunIds.has(activeRun.id)) {
|
||||
items.push({ type: 'turn-activity', thinkingMessages: [], run: activeRun, turnStartedAt: activeRun.startedAt });
|
||||
}
|
||||
}
|
||||
|
||||
// Tag the last turn-activity panel for each run so only it shows
|
||||
// run-level metadata (git summary, discard button, etc.).
|
||||
const lastPanelIndexByRunId = new Map<string, number>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type === 'turn-activity' && item.run) {
|
||||
lastPanelIndexByRunId.set(item.run.id, i);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type === 'turn-activity' && item.run) {
|
||||
item.isLastRunPanel = lastPanelIndexByRunId.get(item.run.id) === i;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [session.messages, session.runs]);
|
||||
}, [session.messages, session.runs, session.status]);
|
||||
|
||||
const lastThinkingGroupIndex = useMemo(() => {
|
||||
const lastTurnActivityIndex = useMemo(() => {
|
||||
for (let i = displayItems.length - 1; i >= 0; i--) {
|
||||
if (displayItems[i].type === 'thinking-group') return i;
|
||||
if (displayItems[i].type === 'turn-activity') return i;
|
||||
}
|
||||
return -1;
|
||||
}, [displayItems]);
|
||||
|
||||
// A turn activity panel is active when the session is running AND the
|
||||
// group contains at least one pending message (currently streaming) or
|
||||
// the associated run is still running.
|
||||
const isTurnActive = useCallback(
|
||||
(item: DisplayItem & { type: 'turn-activity' }, itemIndex: number): boolean => {
|
||||
if (!isSessionBusy) return false;
|
||||
if (itemIndex !== lastTurnActivityIndex) return false;
|
||||
const hasStreamingMessage = item.thinkingMessages.some((m) => m.pending);
|
||||
const runStillRunning = item.run?.status === 'running';
|
||||
return hasStreamingMessage || !!runStillRunning;
|
||||
},
|
||||
[isSessionBusy, lastTurnActivityIndex],
|
||||
);
|
||||
|
||||
const lastAssistantId = useMemo(() => {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i];
|
||||
@@ -183,8 +290,15 @@ export function ChatPane({
|
||||
const interactionMode: InteractionMode = session.interactionMode ?? 'interactive';
|
||||
const isPlanMode = interactionMode === 'plan';
|
||||
const isScratchpad = isScratchpadProject(project);
|
||||
const isSingleAgent = pattern.agents.length === 1;
|
||||
const primaryAgent = pattern.agents[0];
|
||||
const workflowAgents = useMemo(
|
||||
() => resolveWorkflowAgentNodes(workflow)
|
||||
.map((n) => n.config)
|
||||
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
|
||||
[workflow],
|
||||
);
|
||||
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
|
||||
const isSingleAgent = workflowAgents.length === 1;
|
||||
const primaryAgent = workflowAgents[0];
|
||||
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
|
||||
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
|
||||
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
|
||||
@@ -192,12 +306,37 @@ export function ChatPane({
|
||||
const canSubmitInput = hasComposerContent && !isComposerDisabled;
|
||||
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
|
||||
const promptFiles = useMemo(() => project.customization?.promptFiles ?? [], [project.customization?.promptFiles]);
|
||||
const [armedPrompt, setArmedPrompt] = useState<ArmedPrompt | null>(null);
|
||||
|
||||
// Map assistant message IDs to the actual model that executed the run, but only
|
||||
// when it differs from the session's primary agent model (i.e. a prompt override).
|
||||
const modelOverrideByMessageId = useMemo(() => {
|
||||
const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r]));
|
||||
const map = new Map<string, string>();
|
||||
let activeOverrideModel: string | undefined;
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.role === 'user') {
|
||||
const run = runsByTrigger.get(message.id);
|
||||
const runModel = run?.agents[0]?.model;
|
||||
if (runModel && runModel !== primaryAgent?.model) {
|
||||
const resolved = findModel(runModel, availableModels);
|
||||
activeOverrideModel = resolved?.name ?? runModel;
|
||||
} else {
|
||||
activeOverrideModel = undefined;
|
||||
}
|
||||
} else if (activeOverrideModel && message.messageKind !== 'thinking') {
|
||||
map.set(message.id, activeOverrideModel);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [session.messages, session.runs, primaryAgent?.model, availableModels]);
|
||||
|
||||
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||
const mcpServers = toolingSettings.mcpServers;
|
||||
const lspProfiles = toolingSettings.lspProfiles;
|
||||
const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0;
|
||||
const hasToolCallApproval = pattern.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false;
|
||||
const hasToolCallApproval = workflow.settings.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false;
|
||||
const approvalTools = useMemo(
|
||||
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
|
||||
[runtimeTools, toolingSettings],
|
||||
@@ -207,9 +346,9 @@ export function ChatPane({
|
||||
() => new Set(
|
||||
isApprovalOverridden
|
||||
? session.approvalSettings!.autoApprovedToolNames
|
||||
: pattern.approvalPolicy?.autoApprovedToolNames ?? [],
|
||||
: workflow.settings.approvalPolicy?.autoApprovedToolNames ?? [],
|
||||
),
|
||||
[isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy],
|
||||
[isApprovalOverridden, session.approvalSettings, workflow.settings.approvalPolicy],
|
||||
);
|
||||
const effectiveAutoApprovedCount = useMemo(() => {
|
||||
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
||||
@@ -231,13 +370,24 @@ export function ChatPane({
|
||||
setIsResolvingApproval(false);
|
||||
setIsUpdatingSessionModelConfig(false);
|
||||
setEditingMessageId(undefined);
|
||||
setArmedPrompt(null);
|
||||
}, [session.id]);
|
||||
|
||||
function handleComposerSubmit(content: string) {
|
||||
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
|
||||
const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined;
|
||||
setPendingAttachments([]);
|
||||
void onSend(content, attachments, messageMode);
|
||||
|
||||
if (armedPrompt) {
|
||||
const invocation = { ...armedPrompt.invocation };
|
||||
if (content.trim()) {
|
||||
invocation.resolvedPrompt = `${invocation.resolvedPrompt}\n\n${content.trim()}`;
|
||||
}
|
||||
setArmedPrompt(null);
|
||||
void onSend('', attachments, messageMode, invocation);
|
||||
} else {
|
||||
void onSend(content, attachments, messageMode);
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyMessage = useCallback((content: string) => {
|
||||
@@ -329,8 +479,8 @@ export function ChatPane({
|
||||
<h2 className="font-display truncate text-[13px] font-semibold leading-tight text-[var(--color-text-primary)]">{session.title}</h2>
|
||||
<p className="truncate text-[11px] leading-tight text-[var(--color-text-muted)]">
|
||||
{isScratchpad
|
||||
? `Scratchpad · ${pattern.name}`
|
||||
: `${project.name} · ${pattern.name} · ${pattern.mode}`}
|
||||
? `Scratchpad · ${workflow.name}`
|
||||
: `${project.name} · ${workflow.name} · ${workflowMode}`}
|
||||
{!isScratchpad && project.git?.status === 'ready' && (() => {
|
||||
const git = project.git;
|
||||
const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD'];
|
||||
@@ -379,7 +529,15 @@ export function ChatPane({
|
||||
Awaiting your input
|
||||
</div>
|
||||
)}
|
||||
{isSessionBusy && !pendingApproval && !pendingUserInput && <span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />}
|
||||
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
|
||||
const label = summarizeSessionActivity(sessionActivity);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]">
|
||||
<span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
|
||||
<span className="transition-opacity duration-200">{label ?? 'Working…'}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{session.status === 'error' && (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-status-error)]">
|
||||
<AlertCircle className="size-3.5" />
|
||||
@@ -405,11 +563,11 @@ export function ChatPane({
|
||||
{isScratchpad ? (
|
||||
<>
|
||||
Scratchpad is ready for ad-hoc questions using{' '}
|
||||
<span className="text-[var(--color-text-secondary)]">{pattern.name}</span>
|
||||
<span className="text-[var(--color-text-secondary)]">{workflow.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Using <span className="text-[var(--color-text-secondary)]">{pattern.name}</span> in{' '}
|
||||
Using <span className="text-[var(--color-text-secondary)]">{workflow.name}</span> in{' '}
|
||||
<span className="text-[var(--color-text-secondary)]">{project.name}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -426,14 +584,19 @@ export function ChatPane({
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{displayItems.map((item, itemIndex) => {
|
||||
if (item.type === 'thinking-group') {
|
||||
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
|
||||
if (item.type === 'turn-activity') {
|
||||
return (
|
||||
<div key={`thinking-${item.messages[0].id}`} className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={item.messages}
|
||||
isActive={isSessionBusy && isLastThinkingGroup}
|
||||
<div key={`activity-${item.thinkingMessages[0]?.id ?? item.run?.id ?? itemIndex}`} className="py-2">
|
||||
<TurnActivityPanel
|
||||
thinkingMessages={item.thinkingMessages}
|
||||
run={item.run}
|
||||
isActive={isTurnActive(item, itemIndex)}
|
||||
turnStartedAt={item.turnStartedAt}
|
||||
sessionId={session.id}
|
||||
agentNames={item.agentNames}
|
||||
isLastRunPanel={item.isLastRunPanel}
|
||||
onDiscard={onDiscardRunChanges}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -456,6 +619,7 @@ export function ChatPane({
|
||||
: 'border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]';
|
||||
const phaseLabel =
|
||||
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
||||
const modelOverride = !isUser ? modelOverrideByMessageId.get(message.id) : undefined;
|
||||
const showActions = !isSessionBusy && !message.pending;
|
||||
|
||||
return (
|
||||
@@ -482,6 +646,11 @@ export function ChatPane({
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{modelOverride && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-purple)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-accent-purple)]">
|
||||
{modelOverride}
|
||||
</span>
|
||||
)}
|
||||
{showActions && (
|
||||
<div className="ml-auto">
|
||||
<MessageActions
|
||||
@@ -504,6 +673,8 @@ export function ChatPane({
|
||||
onSave={(content) => handleEditSave(message.id, content)}
|
||||
onCancel={() => setEditingMessageId(undefined)}
|
||||
/>
|
||||
) : isUser && message.promptInvocation ? (
|
||||
<PromptInvocationChrome invocation={message.promptInvocation} />
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
@@ -744,21 +915,23 @@ export function ChatPane({
|
||||
onContentChange={setHasComposerContent}
|
||||
onSubmit={handleComposerSubmit}
|
||||
placeholder={
|
||||
pendingApproval
|
||||
? 'Awaiting approval...'
|
||||
: pendingUserInput
|
||||
? 'Awaiting your input above...'
|
||||
: pendingPlanReview
|
||||
? 'Review the plan above...'
|
||||
: pendingMcpAuth
|
||||
? 'MCP server requires authentication...'
|
||||
: isSessionBusy
|
||||
? 'Steer the agent (sends immediately)...'
|
||||
: isUpdatingSessionModelConfig
|
||||
? 'Saving model settings...'
|
||||
: isPlanMode
|
||||
? 'Describe what to plan...'
|
||||
: 'Message...'
|
||||
armedPrompt?.prompt.argumentHint
|
||||
? armedPrompt.prompt.argumentHint
|
||||
: pendingApproval
|
||||
? 'Awaiting approval...'
|
||||
: pendingUserInput
|
||||
? 'Awaiting your input above...'
|
||||
: pendingPlanReview
|
||||
? 'Review the plan above...'
|
||||
: pendingMcpAuth
|
||||
? 'MCP server requires authentication...'
|
||||
: isSessionBusy
|
||||
? 'Steer the agent (sends immediately)...'
|
||||
: isUpdatingSessionModelConfig
|
||||
? 'Saving model settings...'
|
||||
: isPlanMode
|
||||
? 'Describe what to plan...'
|
||||
: 'Message...'
|
||||
}
|
||||
>
|
||||
{/* Bottom action bar: left = shortcuts, right = buttons */}
|
||||
@@ -782,8 +955,11 @@ export function ChatPane({
|
||||
)}
|
||||
{!isScratchpad && promptFiles.length > 0 && (
|
||||
<InlinePromptPill
|
||||
armedPrompt={armedPrompt}
|
||||
disabled={isComposerDisabled}
|
||||
onSubmit={(content) => void onSend(content)}
|
||||
onArm={setArmedPrompt}
|
||||
onDisarm={() => setArmedPrompt(null)}
|
||||
onSubmit={(promptInvocation) => void onSend('', undefined, undefined, promptInvocation)}
|
||||
promptFiles={promptFiles}
|
||||
/>
|
||||
)}
|
||||
@@ -844,9 +1020,9 @@ export function ChatPane({
|
||||
{/* Send / Stop / Steer button */}
|
||||
<button
|
||||
className={`flex size-8 items-center justify-center rounded-lg transition-all duration-150 ${
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt
|
||||
? 'bg-[var(--color-status-error)]/80 text-white hover:bg-[var(--color-status-error)]'
|
||||
: canSubmitInput || pendingAttachments.length > 0
|
||||
: canSubmitInput || pendingAttachments.length > 0 || armedPrompt
|
||||
? isSessionBusy
|
||||
? 'bg-[var(--color-status-warning)] text-white hover:brightness-110'
|
||||
: isPlanMode
|
||||
@@ -854,9 +1030,9 @@ export function ChatPane({
|
||||
: 'brand-gradient-bg text-white shadow-[0_2px_12px_rgba(36,92,249,0.25)] hover:shadow-[0_4px_20px_rgba(36,92,249,0.35)]'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
|
||||
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0 && !armedPrompt}
|
||||
onClick={() => {
|
||||
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
|
||||
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt) {
|
||||
onCancelTurn?.();
|
||||
} else {
|
||||
composerRef.current?.submit();
|
||||
@@ -864,7 +1040,7 @@ export function ChatPane({
|
||||
}}
|
||||
type="button"
|
||||
aria-label={
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt
|
||||
? 'Stop generating'
|
||||
: isSessionBusy
|
||||
? 'Steer agent'
|
||||
@@ -928,6 +1104,79 @@ export function ChatPane({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Prompt invocation chrome ───────────────────────────────── */
|
||||
|
||||
function formatInvocationSourcePath(sourcePath: string): string {
|
||||
const normalized = sourcePath.replace(/\\/g, '/');
|
||||
const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//;
|
||||
if (ancestorPrefix.test(normalized)) {
|
||||
const segments = normalized.split('/').filter((s) => s !== '..');
|
||||
return `↑ ${segments.join('/')}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvocation }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isAncestor = invocation.sourcePath.startsWith('..');
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3">
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 text-left"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-[var(--color-status-success)]" />
|
||||
<span className="flex-1 text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{invocation.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{invocation.model && (
|
||||
<span className="rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
|
||||
{invocation.model}
|
||||
</span>
|
||||
)}
|
||||
{invocation.agent && (
|
||||
<span className="rounded bg-[var(--color-accent-sky)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
|
||||
{invocation.agent}
|
||||
</span>
|
||||
)}
|
||||
{invocation.tools && invocation.tools.length > 0 && (
|
||||
<span className="rounded bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
|
||||
{invocation.tools.length} tool{invocation.tools.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{expanded
|
||||
? <ChevronDown className="size-3.5 text-[var(--color-text-muted)]" />
|
||||
: <ChevronRight className="size-3.5 text-[var(--color-text-muted)]" />}
|
||||
</div>
|
||||
</button>
|
||||
{invocation.description && (
|
||||
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">{invocation.description}</p>
|
||||
)}
|
||||
<div className="mt-0.5 flex items-center gap-1.5 pl-6">
|
||||
{isAncestor && (
|
||||
<span className="rounded bg-[var(--color-surface-3)] px-1 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
|
||||
parent repo
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]" title={invocation.sourcePath}>
|
||||
{formatInvocationSourcePath(invocation.sourcePath)}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-2.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
|
||||
<div className="max-h-48 overflow-y-auto text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
<MarkdownContent content={invocation.resolvedPrompt} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Branch origin banner ───────────────────────────────────── */
|
||||
|
||||
function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Star, X } from 'lucide-react';
|
||||
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
|
||||
interface NewSessionModalProps {
|
||||
projects: ProjectRecord[];
|
||||
patterns: PatternDefinition[];
|
||||
defaultProjectId?: string;
|
||||
onClose: () => void;
|
||||
onCreate: (projectId: string, patternId: string) => void;
|
||||
onTogglePatternFavorite?: (patternId: string, isFavorite: boolean) => void;
|
||||
}
|
||||
|
||||
export function NewSessionModal({
|
||||
projects,
|
||||
patterns,
|
||||
defaultProjectId,
|
||||
onClose,
|
||||
onCreate,
|
||||
onTogglePatternFavorite,
|
||||
}: NewSessionModalProps) {
|
||||
const userProjects = useMemo(
|
||||
() => projects.filter((p) => !isScratchpadProject(p)),
|
||||
[projects],
|
||||
);
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? userProjects[0]?.id ?? '');
|
||||
const availablePatterns = useMemo(
|
||||
() =>
|
||||
patterns
|
||||
.filter((pattern) => pattern.availability !== 'unavailable')
|
||||
.sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
return 0;
|
||||
}),
|
||||
[patterns],
|
||||
);
|
||||
const [patternId, setPatternId] = useState(availablePatterns[0]?.id ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!availablePatterns.some((pattern) => pattern.id === patternId)) {
|
||||
setPatternId(availablePatterns[0]?.id ?? '');
|
||||
}
|
||||
}, [availablePatterns, patternId]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const canCreate = projectId && patternId;
|
||||
|
||||
return (
|
||||
<div className="overlay-backdrop-enter fixed inset-0 z-50 flex items-center justify-center bg-[#07080e]/90 backdrop-blur-sm" role="dialog" aria-modal="true" aria-labelledby="new-session-title">
|
||||
<div className="overlay-panel-enter w-full max-w-md rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-4">
|
||||
<h2 id="new-session-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">New Session</h2>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Project</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
value={projectId}
|
||||
>
|
||||
{userProjects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Pattern</span>
|
||||
<div className="space-y-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-0)] p-1.5">
|
||||
{availablePatterns.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition ${
|
||||
patternId === p.id
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)] ring-1 ring-[var(--color-border-glow)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)]'
|
||||
}`}
|
||||
onClick={() => setPatternId(p.id)}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{p.name}
|
||||
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">({p.mode})</span>
|
||||
</span>
|
||||
{onTogglePatternFavorite && (
|
||||
<button
|
||||
className={`shrink-0 transition ${
|
||||
p.isFavorite
|
||||
? 'text-[var(--color-status-warning)] hover:text-[var(--color-status-warning)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePatternFavorite(p.id, !p.isFavorite);
|
||||
}}
|
||||
title={p.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
type="button"
|
||||
>
|
||||
<Star className={`size-3.5 ${p.isFavorite ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{patternId && (
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
{availablePatterns.find((p) => p.id === patternId)?.description}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--color-border)] px-5 py-3">
|
||||
<button
|
||||
className="rounded-lg px-4 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-[var(--color-accent-sky)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!canCreate}
|
||||
onClick={() => canCreate && onCreate(projectId, patternId)}
|
||||
type="button"
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeftRight,
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
GitFork,
|
||||
ListOrdered,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
addAgentToGraph,
|
||||
removeAgentFromGraph,
|
||||
resolvePatternGraph,
|
||||
syncPatternGraph,
|
||||
validatePatternDefinition,
|
||||
type OrchestrationMode,
|
||||
type PatternDefinition,
|
||||
type PatternAgentDefinition,
|
||||
type PatternGraph,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
listApprovalToolDefinitions,
|
||||
type ApprovalToolDefinition,
|
||||
type ApprovalToolKind,
|
||||
type RuntimeToolDefinition,
|
||||
type WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||
|
||||
interface PatternEditorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
pattern: PatternDefinition;
|
||||
isBuiltin: boolean;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
onChange: (pattern: PatternDefinition) => void;
|
||||
onDelete?: () => void;
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface ModeInfo {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const modeInfo: Record<OrchestrationMode, ModeInfo> = {
|
||||
single: {
|
||||
icon: MessageSquare,
|
||||
label: 'Single',
|
||||
description: 'Direct conversation with one agent',
|
||||
},
|
||||
sequential: {
|
||||
icon: ListOrdered,
|
||||
label: 'Sequential',
|
||||
description: 'Agents process in order, each refining the result',
|
||||
},
|
||||
concurrent: {
|
||||
icon: GitFork,
|
||||
label: 'Concurrent',
|
||||
description: 'Multiple agents respond in parallel',
|
||||
},
|
||||
handoff: {
|
||||
icon: ArrowLeftRight,
|
||||
label: 'Handoff',
|
||||
description: 'Triage agent routes to specialists',
|
||||
},
|
||||
'group-chat': {
|
||||
icon: Users,
|
||||
label: 'Group Chat',
|
||||
description: 'Agents iterate in managed round-robin',
|
||||
},
|
||||
magentic: {
|
||||
icon: Lock,
|
||||
label: 'Magentic',
|
||||
description: 'Not yet available in .NET',
|
||||
},
|
||||
};
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const baseClasses =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${baseClasses} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={baseClasses}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternEditor({
|
||||
availableModels,
|
||||
pattern,
|
||||
isBuiltin,
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
onChange,
|
||||
onDelete,
|
||||
onSave,
|
||||
onBack,
|
||||
}: PatternEditorProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const issues = validatePatternDefinition(pattern);
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
|
||||
function emitChange(nextPattern: PatternDefinition) {
|
||||
onChange({ ...nextPattern, graph: resolvePatternGraph(nextPattern) });
|
||||
}
|
||||
|
||||
function emitModeChange(nextPattern: PatternDefinition) {
|
||||
onChange(syncPatternGraph(nextPattern));
|
||||
}
|
||||
|
||||
function emitGraphChange(nextGraph: PatternGraph) {
|
||||
onChange({ ...pattern, graph: nextGraph });
|
||||
}
|
||||
|
||||
function addAgent() {
|
||||
const newAgent: PatternAgentDefinition = {
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
};
|
||||
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
|
||||
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
|
||||
}
|
||||
|
||||
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
|
||||
});
|
||||
}
|
||||
|
||||
function removeAgent(agentId: string) {
|
||||
if (pattern.agents.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(graph, pattern.mode, agentId);
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.filter((a) => a.id !== agentId),
|
||||
graph: updatedGraph,
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
|
||||
function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) {
|
||||
emitChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) });
|
||||
}
|
||||
|
||||
function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean {
|
||||
return pattern.approvalPolicy?.rules.some((r) => r.kind === kind) ?? false;
|
||||
}
|
||||
|
||||
function checkpointAgentIds(kind: ApprovalCheckpointKind): string[] | undefined {
|
||||
return pattern.approvalPolicy?.rules.find((r) => r.kind === kind)?.agentIds;
|
||||
}
|
||||
|
||||
function toggleCheckpoint(kind: ApprovalCheckpointKind, enabled: boolean) {
|
||||
updateApprovalPolicy((current) => {
|
||||
const otherRules = (current?.rules ?? []).filter((r) => r.kind !== kind);
|
||||
if (!enabled) {
|
||||
return { rules: otherRules };
|
||||
}
|
||||
return { rules: [...otherRules, { kind }] };
|
||||
});
|
||||
}
|
||||
|
||||
function setCheckpointAgentScope(kind: ApprovalCheckpointKind, agentIds: string[] | undefined) {
|
||||
updateApprovalPolicy((current) => {
|
||||
const rules = (current?.rules ?? []).map((r) =>
|
||||
r.kind === kind ? { ...r, agentIds } : r,
|
||||
);
|
||||
return { rules };
|
||||
});
|
||||
}
|
||||
|
||||
const approvalTools = listApprovalToolDefinitions(toolingSettings, runtimeTools);
|
||||
const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
|
||||
|
||||
function toggleToolAutoApproval(toolId: string) {
|
||||
const current = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
|
||||
if (current.has(toolId)) {
|
||||
current.delete(toolId);
|
||||
} else {
|
||||
current.add(toolId);
|
||||
}
|
||||
updateApprovalPolicy((policy) => ({
|
||||
rules: policy?.rules ?? [],
|
||||
autoApprovedToolNames: current.size > 0 ? [...current] : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{pattern.name || 'Untitled pattern'}
|
||||
</h3>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
{isBuiltin ? 'Built-in pattern' : 'Custom pattern'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="no-drag flex items-center gap-2">
|
||||
{!isBuiltin && onDelete && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body — graph canvas + inspector split */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left column: graph canvas + settings below */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Validation banner */}
|
||||
<div className="px-5 pt-4">
|
||||
{issues.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{issues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Pattern is valid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph canvas */}
|
||||
<div className="flex items-center justify-between px-5 pt-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Topology
|
||||
</h4>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={addAgent}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[300px] flex-1 px-5 py-3">
|
||||
<PatternGraphCanvas
|
||||
pattern={pattern}
|
||||
availableModels={availableModels}
|
||||
onGraphChange={emitGraphChange}
|
||||
onAgentRemove={removeAgent}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable settings below graph */}
|
||||
<div className="max-h-[45%] overflow-y-auto border-t border-[var(--color-border-subtle)] px-5 py-5">
|
||||
<div className="space-y-8">
|
||||
{/* General */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
General
|
||||
</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => emitChange({ ...pattern, name: v })}
|
||||
placeholder="Pattern name"
|
||||
value={pattern.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => emitChange({ ...pattern, description: v })}
|
||||
placeholder="What this pattern does..."
|
||||
value={pattern.description}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mode selector */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => {
|
||||
const info = modeInfo[mode];
|
||||
const Icon = info.icon;
|
||||
const selected = pattern.mode === mode;
|
||||
const disabled = mode === 'magentic';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex flex-col rounded-xl border p-2.5 text-left transition-all duration-200 ${
|
||||
selected
|
||||
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
|
||||
: disabled
|
||||
? 'cursor-not-allowed border-[var(--color-border-subtle)] opacity-40'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border)] hover:bg-[var(--color-glass)]'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={mode}
|
||||
onClick={() => emitModeChange({ ...pattern, mode })}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon
|
||||
className={`size-3.5 ${selected ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-muted)]'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
selected ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-snug text-[var(--color-text-muted)]">
|
||||
{info.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Approval checkpoints */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Approval Checkpoints
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('tool-call')}
|
||||
kind="tool-call"
|
||||
label="Tool call approval"
|
||||
description="Require approval before the agent executes tool calls"
|
||||
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('tool-call')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
|
||||
>
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Auto-approved tools
|
||||
</div>
|
||||
<p className="mb-3 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Tools marked as auto-approved will skip manual review.
|
||||
Sessions can override these defaults from the Activity panel.
|
||||
</p>
|
||||
{approvalTools.length === 0 ? (
|
||||
<p className="py-2 text-center text-[11px] text-[var(--color-text-muted)]">
|
||||
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
|
||||
</p>
|
||||
) : (
|
||||
<ToolApprovalGroupedList
|
||||
autoApprovedSet={autoApprovedSet}
|
||||
onToggle={toggleToolAutoApproval}
|
||||
tools={approvalTools}
|
||||
/>
|
||||
)}
|
||||
</ApprovalCheckpointRow>
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('final-response')}
|
||||
kind="final-response"
|
||||
label="Final response review"
|
||||
description="Review and approve assistant messages before publication"
|
||||
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('final-response')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: node inspector */}
|
||||
<div className="w-[320px] shrink-0 overflow-y-auto border-l border-[var(--color-border-subtle)] bg-[var(--color-glass)]">
|
||||
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Inspector
|
||||
</h4>
|
||||
</div>
|
||||
<PatternGraphInspector
|
||||
availableModels={availableModels}
|
||||
agents={pattern.agents}
|
||||
graph={graph}
|
||||
mode={pattern.mode}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onAgentChange={updateAgent}
|
||||
onAgentRemove={removeAgent}
|
||||
onGraphChange={emitGraphChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Approval checkpoint row ───────────────────────────────── */
|
||||
|
||||
function ApprovalCheckpointRow({
|
||||
agents,
|
||||
children,
|
||||
enabled,
|
||||
kind: _kind,
|
||||
label,
|
||||
description,
|
||||
onToggle,
|
||||
scopedAgentIds,
|
||||
onScopeChange,
|
||||
}: {
|
||||
agents: PatternAgentDefinition[];
|
||||
children?: React.ReactNode;
|
||||
enabled: boolean;
|
||||
kind: ApprovalCheckpointKind;
|
||||
label: string;
|
||||
description: string;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
scopedAgentIds: string[] | undefined;
|
||||
onScopeChange: (agentIds: string[] | undefined) => void;
|
||||
}){
|
||||
const isAllAgents = !scopedAgentIds || scopedAgentIds.length === 0;
|
||||
|
||||
function toggleAgentScope(agentId: string) {
|
||||
const current = scopedAgentIds ?? [];
|
||||
const next = current.includes(agentId)
|
||||
? current.filter((id) => id !== agentId)
|
||||
: [...current, agentId];
|
||||
onScopeChange(next.length > 0 ? next : undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
|
||||
<button className="flex w-full items-center gap-3 text-left" onClick={() => onToggle(!enabled)} type="button">
|
||||
<ShieldCheck className={`size-4 shrink-0 ${enabled ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-muted)]'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-primary)]">{label}</span>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{description}</p>
|
||||
</div>
|
||||
<ToggleSwitch enabled={enabled} />
|
||||
</button>
|
||||
|
||||
{/* Agent scope selector */}
|
||||
{enabled && agents.length > 1 && (
|
||||
<div className="mt-3 border-t border-[var(--color-border-subtle)] pt-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">Scope</span>
|
||||
<button
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition-all duration-200 ${
|
||||
isAllAgents
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onScopeChange(undefined)}
|
||||
type="button"
|
||||
>
|
||||
All agents
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition-all duration-200 ${
|
||||
!isAllAgents
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onScopeChange([])}
|
||||
type="button"
|
||||
>
|
||||
Selected agents
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isAllAgents && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{agents.map((agent) => {
|
||||
const isSelected = scopedAgentIds?.includes(agent.id) ?? false;
|
||||
return (
|
||||
<button
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] ring-1 ring-[var(--color-border-glow)]'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
key={agent.id}
|
||||
onClick={() => toggleAgentScope(agent.id)}
|
||||
type="button"
|
||||
>
|
||||
{agent.name || 'Unnamed'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional additional content (e.g. tool auto-approval list) */}
|
||||
{enabled && children && (
|
||||
<div className="mt-3 border-t border-[var(--color-border-subtle)] pt-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tool auto-approval grouped list ───────────────────────── */
|
||||
|
||||
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
|
||||
const approvalKindLabels: Record<ApprovalToolKind, string> = {
|
||||
builtin: 'Built-in',
|
||||
mcp: 'MCP Servers',
|
||||
lsp: 'Language Servers',
|
||||
mixed: 'Other',
|
||||
};
|
||||
|
||||
function ToolApprovalGroupedList({
|
||||
tools,
|
||||
autoApprovedSet,
|
||||
onToggle,
|
||||
}: {
|
||||
tools: ApprovalToolDefinition[];
|
||||
autoApprovedSet: Set<string>;
|
||||
onToggle: (toolId: string) => void;
|
||||
}) {
|
||||
const groups = approvalKindOrder
|
||||
.map((kind) => ({ kind, tools: tools.filter((t) => t.kind === kind) }))
|
||||
.filter((g) => g.tools.length > 0);
|
||||
const showHeaders = groups.length > 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{groups.map((group, i) => (
|
||||
<div key={group.kind}>
|
||||
{showHeaders && (
|
||||
<div className={`text-[9px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)] ${i > 0 ? 'mt-3' : ''} mb-1`}>
|
||||
{approvalKindLabels[group.kind]}
|
||||
</div>
|
||||
)}
|
||||
{group.tools.map((tool) => (
|
||||
<ToolApprovalToggleRow
|
||||
enabled={autoApprovedSet.has(tool.id)}
|
||||
key={tool.id}
|
||||
onToggle={() => onToggle(tool.id)}
|
||||
tool={tool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalToggleRow({
|
||||
tool,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
tool: ApprovalToolDefinition;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined);
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]/60"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-[12px] font-medium text-[var(--color-text-secondary)]">{tool.label}</span>
|
||||
{detail && <div className="truncate text-[10px] text-[var(--color-text-muted)]">{detail}</div>}
|
||||
</div>
|
||||
<ToggleSwitch enabled={enabled} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,17 @@ import { ToggleSwitch } from '@renderer/components/ui';
|
||||
import type { ProjectRecord, ProjectGitContext } from '@shared/domain/project';
|
||||
import type { DiscoveredMcpServer } from '@shared/domain/discoveredTooling';
|
||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { ProjectAgentProfile, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization';
|
||||
import type { ProjectAgentProfile, ProjectInstructionApplicationMode, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization';
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────── */
|
||||
|
||||
/** Shortens ancestor-relative source paths like `..\..\..\.github\prompts\x.md` to `↑ .github/prompts/x.md` */
|
||||
function formatSettingsSourcePath(sourcePath: string): string {
|
||||
const normalized = sourcePath.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter((s) => s !== '..');
|
||||
return `↑ ${segments.join('/')}`;
|
||||
}
|
||||
|
||||
type ProjectSettingsSection = 'overview' | 'instructions' | 'agents' | 'prompts' | 'mcp-servers' | 'danger-zone';
|
||||
|
||||
interface NavItem {
|
||||
@@ -335,7 +342,7 @@ function InstructionsContent({
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Repository instructions automatically included in every session. Discovered from .github/copilot-instructions.md and AGENTS.md."
|
||||
description="Repository instructions discovered from copilot-instructions.md, AGENTS.md, CLAUDE.md, .github/instructions/**/*.instructions.md, and .claude/rules/**/*.md."
|
||||
title="Instructions"
|
||||
>
|
||||
<RescanButton onClick={onRescan} />
|
||||
@@ -343,7 +350,7 @@ function InstructionsContent({
|
||||
|
||||
{instructions.length === 0 ? (
|
||||
<EmptyState>
|
||||
No instruction files found. Add a <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code> or <code className="text-[var(--color-text-secondary)]">AGENTS.md</code> file to your project root.
|
||||
No instruction files found. Add <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code>, <code className="text-[var(--color-text-secondary)]">AGENTS.md</code>, <code className="text-[var(--color-text-secondary)]">CLAUDE.md</code>, <code className="text-[var(--color-text-secondary)]">*.instructions.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/instructions/</code>, or <code className="text-[var(--color-text-secondary)]">*.md</code> files under <code className="text-[var(--color-text-secondary)]">.claude/rules/</code>.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -359,6 +366,8 @@ function InstructionsContent({
|
||||
function InstructionCard({ instruction }: { instruction: ProjectInstructionFile }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = instruction.content.length > 300;
|
||||
const isAncestor = instruction.sourcePath.startsWith('..');
|
||||
const displayName = instruction.name ?? (isAncestor ? formatSettingsSourcePath(instruction.sourcePath) : instruction.sourcePath);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)]">
|
||||
@@ -368,13 +377,35 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
|
||||
type="button"
|
||||
>
|
||||
<FileCode2 className="size-4 shrink-0 text-[var(--color-text-accent)]" />
|
||||
<span className="flex-1 text-[13px] font-medium text-[var(--color-text-primary)]">{instruction.sourcePath}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]" title={instruction.sourcePath}>{displayName}</span>
|
||||
<InstructionModeBadge mode={instruction.applicationMode} />
|
||||
{isAncestor && (
|
||||
<span className="shrink-0 rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
|
||||
parent repo
|
||||
</span>
|
||||
)}
|
||||
{instruction.applyTo && (
|
||||
<span
|
||||
className="truncate rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
|
||||
title={`Applies to files matching: ${instruction.applyTo}`}
|
||||
>
|
||||
{instruction.applyTo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border)] px-5 py-4">
|
||||
{instruction.description && (
|
||||
<p className="mb-2 text-[11px] italic text-[var(--color-text-muted)]">{instruction.description}</p>
|
||||
)}
|
||||
{instruction.name && (
|
||||
<p className="mb-2 text-[11px] text-[var(--color-text-muted)]">{instruction.sourcePath}</p>
|
||||
)}
|
||||
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{instruction.content}
|
||||
</pre>
|
||||
@@ -382,6 +413,9 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
|
||||
)}
|
||||
{!expanded && (
|
||||
<div className="px-5 pb-3">
|
||||
{instruction.description && (
|
||||
<p className="mb-1 text-[11px] italic text-[var(--color-text-muted)]">{instruction.description}</p>
|
||||
)}
|
||||
<p className={`text-[11px] leading-relaxed text-[var(--color-text-muted)] ${isLong ? 'line-clamp-2' : ''}`}>
|
||||
{instruction.content}
|
||||
</p>
|
||||
@@ -391,6 +425,38 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionModeBadge({ mode }: { mode: ProjectInstructionApplicationMode }) {
|
||||
switch (mode) {
|
||||
case 'always':
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-[var(--color-status-success)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-success)]">
|
||||
always
|
||||
</span>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-[var(--color-text-accent)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-accent)]">
|
||||
file
|
||||
</span>
|
||||
);
|
||||
case 'task':
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
|
||||
task
|
||||
</span>
|
||||
);
|
||||
case 'manual':
|
||||
return (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
|
||||
title="Discovered but not auto-applied to sessions"
|
||||
>
|
||||
manual
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Custom Agents ────────────────────────────────────────── */
|
||||
|
||||
function AgentsContent({
|
||||
@@ -407,7 +473,7 @@ function AgentsContent({
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Custom agent profiles discovered from .github/agents/*.agent.md. Enable or disable individual agents."
|
||||
description="Custom agent profiles discovered from .github/agents/**/*.agent.md. Enable or disable individual agents."
|
||||
title="Custom Agents"
|
||||
>
|
||||
<RescanButton onClick={onRescan} />
|
||||
@@ -415,7 +481,7 @@ function AgentsContent({
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState>
|
||||
No custom agents found. Add <code className="text-[var(--color-text-secondary)]">.agent.md</code> files to <code className="text-[var(--color-text-secondary)]">.github/agents/</code> in your project.
|
||||
No custom agents found. Add <code className="text-[var(--color-text-secondary)]">.agent.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/agents/</code> in your project.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
@@ -496,7 +562,7 @@ function PromptsContent({
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Reusable prompt templates discovered from .github/prompts/*.prompt.md. Use them from the Prompts pill in the chat input."
|
||||
description="Reusable prompt templates discovered from .github/prompts/**/*.prompt.md. Use them from the Prompts pill in the chat input."
|
||||
title="Prompt Files"
|
||||
>
|
||||
<RescanButton onClick={onRescan} />
|
||||
@@ -504,7 +570,7 @@ function PromptsContent({
|
||||
|
||||
{promptFiles.length === 0 ? (
|
||||
<EmptyState>
|
||||
No prompt files found. Add <code className="text-[var(--color-text-secondary)]">.prompt.md</code> files to <code className="text-[var(--color-text-secondary)]">.github/prompts/</code> in your project.
|
||||
No prompt files found. Add <code className="text-[var(--color-text-secondary)]">.prompt.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/prompts/</code> in your project.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -518,6 +584,8 @@ function PromptsContent({
|
||||
}
|
||||
|
||||
function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
|
||||
const isAncestor = prompt.sourcePath.startsWith('..');
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -525,15 +593,47 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{prompt.name}</span>
|
||||
{prompt.model && (
|
||||
<span className="rounded-full bg-[var(--color-accent-purple)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
|
||||
{prompt.model}
|
||||
</span>
|
||||
)}
|
||||
{prompt.agent && (
|
||||
<span className="rounded-full bg-[var(--color-accent-sky)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
|
||||
{prompt.agent}
|
||||
</span>
|
||||
)}
|
||||
{prompt.variables.length > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{isAncestor && (
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
|
||||
parent repo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prompt.description && (
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{prompt.description}</p>
|
||||
)}
|
||||
{prompt.argumentHint && (
|
||||
<p className="mt-1 text-[11px] italic text-[var(--color-text-muted)]">
|
||||
Hint: {prompt.argumentHint}
|
||||
</p>
|
||||
)}
|
||||
{prompt.tools && prompt.tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{prompt.tools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded-md bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{prompt.variables.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{prompt.variables.map((v) => (
|
||||
@@ -547,7 +647,9 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">{prompt.sourcePath}</p>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]" title={prompt.sourcePath}>
|
||||
{isAncestor ? formatSettingsSourcePath(prompt.sourcePath) : prompt.sourcePath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
collapseTimelineEvents,
|
||||
formatEventLabel,
|
||||
formatRunDuration,
|
||||
formatRunStatusLabel,
|
||||
formatRunTimestamp,
|
||||
isTerminalEvent,
|
||||
truncateContent,
|
||||
type CollapsedTimelineEvent,
|
||||
} from '@renderer/lib/runTimelineFormatting';
|
||||
import type { OrchestrationMode } from '@shared/domain/pattern';
|
||||
import type { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
||||
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
||||
|
||||
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
|
||||
|
||||
const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: string }> = {
|
||||
single: { dot: 'bg-[#245CF9]', ring: 'ring-[#245CF9]/30', text: 'text-[#245CF9]' },
|
||||
sequential: { dot: 'bg-[var(--color-status-warning)]', ring: 'ring-[var(--color-status-warning)]/30', text: 'text-[var(--color-status-warning)]' },
|
||||
concurrent: { dot: 'bg-[var(--color-status-success)]', ring: 'ring-[var(--color-status-success)]/30', text: 'text-[var(--color-status-success)]' },
|
||||
handoff: { dot: 'bg-[var(--color-accent-sky)]', ring: 'ring-[var(--color-accent-sky)]/30', text: 'text-[var(--color-accent-sky)]' },
|
||||
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', ring: 'ring-[var(--color-accent-purple)]/30', text: 'text-[var(--color-accent-purple)]' },
|
||||
magentic: { dot: 'bg-[var(--color-text-muted)]', ring: 'ring-[var(--color-text-muted)]/30', text: 'text-[var(--color-text-muted)]' },
|
||||
};
|
||||
|
||||
/* ── Status badges ─────────────────────────────────────────── */
|
||||
|
||||
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
|
||||
running: { icon: <CircleDot className="size-3" />, className: 'text-[var(--color-status-info)]' },
|
||||
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-[var(--color-status-success)]' },
|
||||
cancelled: { icon: <XCircle className="size-3" />, className: 'text-[var(--color-text-muted)]' },
|
||||
error: { icon: <XCircle className="size-3" />, className: 'text-[var(--color-status-error)]' },
|
||||
};
|
||||
|
||||
/* ── Event node icon ───────────────────────────────────────── */
|
||||
|
||||
function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
|
||||
const isRunning = status === 'running';
|
||||
const base = 'size-2.5';
|
||||
|
||||
// Running events use white icons to contrast with the brand-gradient circle
|
||||
if (isRunning) {
|
||||
const pulse = 'animate-pulse';
|
||||
switch (kind) {
|
||||
case 'thinking':
|
||||
return <Brain className={`${base} ${pulse} text-white`} />;
|
||||
case 'approval':
|
||||
return <AlertTriangle className={`${base} ${pulse} text-white`} />;
|
||||
case 'message':
|
||||
return <MessageSquare className={`${base} ${pulse} text-white`} />;
|
||||
default:
|
||||
return <Play className={`${base} text-white`} />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case 'run-started':
|
||||
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
case 'thinking':
|
||||
return <Brain className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
case 'handoff':
|
||||
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
|
||||
case 'tool-call':
|
||||
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||
case 'approval':
|
||||
return <AlertTriangle className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
case 'message':
|
||||
return <MessageSquare className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
case 'run-completed':
|
||||
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
|
||||
case 'run-cancelled':
|
||||
return <XCircle className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
case 'run-failed':
|
||||
return <AlertTriangle className={`${base} text-[var(--color-status-error)]`} />;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Single timeline event row ─────────────────────────────── */
|
||||
|
||||
function TimelineEventRow({
|
||||
event,
|
||||
isLast,
|
||||
onJumpToMessage,
|
||||
}: {
|
||||
event: RunTimelineEventRecord;
|
||||
isLast: boolean;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
}) {
|
||||
const label = formatEventLabel(event);
|
||||
const timestamp = formatRunTimestamp(event.updatedAt ?? event.occurredAt);
|
||||
const preview = event.kind === 'message' ? truncateContent(event.content) : undefined;
|
||||
const isClickable = !!onJumpToMessage && !!event.messageId;
|
||||
const terminal = isTerminalEvent(event.kind);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Vertical connector line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`group flex w-full gap-2.5 text-left transition-all duration-200 ${terminal ? 'py-1' : 'py-1.5'} ${isClickable ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
disabled={!isClickable}
|
||||
onClick={isClickable ? () => onJumpToMessage(event.messageId!) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{/* Node */}
|
||||
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
||||
<div className={`flex size-[18px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
|
||||
<EventIcon kind={event.kind} status={event.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] font-medium ${terminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'} ${isClickable ? 'group-hover:text-[var(--color-text-accent)]' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
{/* Approval kind badge */}
|
||||
{event.kind === 'approval' && event.approvalKind && (
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
|
||||
event.status === 'running'
|
||||
? 'bg-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
|
||||
: event.status === 'completed'
|
||||
? 'bg-[var(--color-status-success)]/15 text-[var(--color-status-success)]'
|
||||
: 'bg-[var(--color-status-error)]/15 text-[var(--color-status-error)]'
|
||||
}`}>
|
||||
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono ml-auto shrink-0 text-[9px] tabular-nums text-[var(--color-text-muted)]">{timestamp}</span>
|
||||
</div>
|
||||
|
||||
{/* Content preview for message events */}
|
||||
{preview && (
|
||||
<p className={`mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)] ${isClickable ? 'group-hover:text-[var(--color-text-secondary)]' : ''}`}>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Approval detail */}
|
||||
{event.kind === 'approval' && event.approvalDetail && (
|
||||
<p className="mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)]">
|
||||
{truncateContent(event.approvalDetail, 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{event.error && (
|
||||
<p className="mt-0.5 text-[10px] leading-snug text-[var(--color-status-error)]/80">
|
||||
{truncateContent(event.error, 120)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* File change preview for tool-call events */}
|
||||
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
|
||||
<div className="relative z-10 ml-[25px] pb-1">
|
||||
<FileChangePreview fileChanges={event.fileChanges} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Collapsed thinking group ──────────────────────────────── */
|
||||
|
||||
function ThinkingGroupRow({
|
||||
events,
|
||||
agentName,
|
||||
isLast,
|
||||
}: {
|
||||
events: RunTimelineEventRecord[];
|
||||
agentName: string;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex w-full gap-2.5 py-1">
|
||||
{!isLast && (
|
||||
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
|
||||
)}
|
||||
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
||||
<div className="flex size-[18px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
|
||||
<Brain className="size-2.5 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{agentName ? `${agentName} thinking` : 'Thinking'} ×{events.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Collapsed event dispatcher ────────────────────────────── */
|
||||
|
||||
function CollapsedEventRow({
|
||||
item,
|
||||
isLast,
|
||||
onJumpToMessage,
|
||||
}: {
|
||||
item: CollapsedTimelineEvent;
|
||||
isLast: boolean;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
}) {
|
||||
if (item.type === 'thinking-group') {
|
||||
return <ThinkingGroupRow agentName={item.agentName} events={item.events} isLast={isLast} />;
|
||||
}
|
||||
return <TimelineEventRow event={item.event} isLast={isLast} onJumpToMessage={onJumpToMessage} />;
|
||||
}
|
||||
|
||||
/* ── Git baseline snapshot ──────────────────────────────────── */
|
||||
|
||||
function RunGitBaseline({ snapshot }: { snapshot: ProjectGitWorkingTreeSnapshot }) {
|
||||
const { branch, changes, changedFileCount } = snapshot;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (changes.staged > 0) parts.push(`${changes.staged} staged`);
|
||||
if (changes.unstaged > 0) parts.push(`${changes.unstaged} modified`);
|
||||
if (changes.untracked > 0) parts.push(`${changes.untracked} untracked`);
|
||||
if (changes.conflicted > 0) parts.push(`${changes.conflicted} conflicted`);
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
|
||||
<GitBranch className="size-2.5 shrink-0" />
|
||||
{branch && <span className="font-mono text-[var(--color-text-secondary)]">{branch}</span>}
|
||||
{changedFileCount > 0 ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[var(--color-status-warning)]">{changedFileCount} changed</span>
|
||||
{parts.length > 0 && (
|
||||
<span className="text-[var(--color-text-muted)]">({parts.join(', ')})</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[var(--color-status-success)]">clean</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Run card ──────────────────────────────────────────────── */
|
||||
|
||||
function RunCard({
|
||||
run,
|
||||
expanded,
|
||||
onToggle,
|
||||
onJumpToMessage,
|
||||
sessionId,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
}: {
|
||||
run: SessionRunRecord;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
sessionId: string;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}) {
|
||||
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
|
||||
const statusStyle = runStatusStyles[run.status];
|
||||
const duration = formatRunDuration(run.startedAt, run.completedAt);
|
||||
|
||||
const collapsedEvents = useMemo(
|
||||
() => collapseTimelineEvents(run.events),
|
||||
[run.events],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="glass-surface rounded-lg">
|
||||
{/* Run header */}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
: <ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />}
|
||||
|
||||
<Bot className={`size-3 shrink-0 ${accent.text}`} />
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{run.patternName}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className={`flex items-center gap-1 shrink-0 ${statusStyle.className}`}>
|
||||
{run.status === 'running' && <span className="size-1.5 animate-pulse rounded-full bg-[var(--color-status-info)]" />}
|
||||
{run.status !== 'running' && statusStyle.icon}
|
||||
<span className="text-[9px] font-medium">{formatRunStatusLabel(run.status)}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded timeline */}
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border-subtle)] px-3 pb-2 pt-1.5">
|
||||
{/* Agent badges */}
|
||||
{run.agents.length > 1 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{run.agents.map((agent) => (
|
||||
<span
|
||||
className="rounded-full bg-[var(--color-surface-2)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]"
|
||||
key={agent.agentId}
|
||||
>
|
||||
{agent.agentName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Git baseline */}
|
||||
{run.preRunGitSnapshot && (
|
||||
<RunGitBaseline snapshot={run.preRunGitSnapshot} />
|
||||
)}
|
||||
|
||||
{/* Timeline events */}
|
||||
<div>
|
||||
{collapsedEvents.map((item, index) => (
|
||||
<CollapsedEventRow
|
||||
isLast={index === collapsedEvents.length - 1}
|
||||
item={item}
|
||||
key={item.type === 'single' ? item.event.id : `thinking-${item.events[0].id}`}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Duration footer */}
|
||||
{duration && (
|
||||
<div className="font-mono mt-1 border-t border-[var(--color-border-subtle)] pt-1.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
|
||||
Duration: {duration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post-run git changes */}
|
||||
{run.postRunGitSummary && run.status !== 'running' && onDiscard && (
|
||||
<div className="mt-2">
|
||||
<RunChangeSummaryCard
|
||||
onDiscard={onDiscard}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runId={run.requestId}
|
||||
sessionId={sessionId}
|
||||
summary={run.postRunGitSummary}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Empty state ───────────────────────────────────────────── */
|
||||
|
||||
function EmptyTimeline() {
|
||||
return (
|
||||
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">
|
||||
Send a message to see the run timeline
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main export ───────────────────────────────────────────── */
|
||||
|
||||
interface RunTimelineProps {
|
||||
runs: readonly SessionRunRecord[];
|
||||
sessionId: string;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}
|
||||
|
||||
export function RunTimeline({ runs, sessionId, onJumpToMessage, onDiscard, onOpenCommitComposer }: RunTimelineProps) {
|
||||
const latestRunId = runs.length > 0 ? runs[0].id : undefined;
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | undefined>(latestRunId);
|
||||
|
||||
// Auto-expand the latest run when it changes
|
||||
useEffect(() => {
|
||||
setExpandedRunId(latestRunId);
|
||||
}, [latestRunId]);
|
||||
|
||||
if (runs.length === 0) {
|
||||
return <EmptyTimeline />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<RunCard
|
||||
expanded={expandedRunId === run.id}
|
||||
key={run.id}
|
||||
onDiscard={onDiscard}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
onToggle={() => setExpandedRunId(expandedRunId === run.id ? undefined : run.id)}
|
||||
run={run}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
|
||||
|
||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate';
|
||||
import {
|
||||
normalizeLspProfileDefinition,
|
||||
normalizeMcpServerDefinition,
|
||||
@@ -20,10 +23,11 @@ import {
|
||||
type McpServerDefinition,
|
||||
type WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
patterns: PatternDefinition[];
|
||||
workflows: WorkflowDefinition[];
|
||||
sidecarCapabilities?: SidecarCapabilities;
|
||||
theme: AppearanceTheme;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
@@ -32,15 +36,19 @@ interface SettingsPanelProps {
|
||||
initialSection?: SettingsSection;
|
||||
onRefreshCapabilities: () => void;
|
||||
onClose: () => void;
|
||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||
onDeletePattern: (patternId: string) => Promise<void>;
|
||||
onNewPattern: () => PatternDefinition;
|
||||
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
|
||||
onDeleteWorkflow: (workflowId: string) => Promise<void>;
|
||||
onNewWorkflow: () => WorkflowDefinition;
|
||||
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
|
||||
onDeleteMcpServer: (serverId: string) => Promise<void>;
|
||||
onNewMcpServer: () => McpServerDefinition;
|
||||
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
|
||||
onDeleteLspProfile: (profileId: string) => Promise<void>;
|
||||
onNewLspProfile: () => LspProfileDefinition;
|
||||
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
|
||||
onDeleteWorkspaceAgent: (agentId: string) => Promise<void>;
|
||||
onNewWorkspaceAgent: () => WorkspaceAgentDefinition;
|
||||
workspaceAgents: WorkspaceAgentDefinition[];
|
||||
onSetTheme: (theme: AppearanceTheme) => void;
|
||||
notificationsEnabled: boolean;
|
||||
onSetNotificationsEnabled: (enabled: boolean) => void;
|
||||
@@ -52,9 +60,11 @@ interface SettingsPanelProps {
|
||||
onResetLocalWorkspace: () => Promise<void>;
|
||||
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
workflowTemplates?: WorkflowTemplateDefinition[];
|
||||
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
@@ -81,9 +91,10 @@ const navGroups: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Orchestration',
|
||||
label: 'Workflows',
|
||||
items: [
|
||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
||||
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
|
||||
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -101,14 +112,9 @@ const navGroups: NavGroup[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function modeBadgeClasses(pattern: PatternDefinition) {
|
||||
if (pattern.availability === 'unavailable') return 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]';
|
||||
return 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]';
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
availableModels,
|
||||
patterns,
|
||||
workflows,
|
||||
sidecarCapabilities,
|
||||
theme,
|
||||
toolingSettings,
|
||||
@@ -117,15 +123,19 @@ export function SettingsPanel({
|
||||
initialSection,
|
||||
onRefreshCapabilities,
|
||||
onClose,
|
||||
onSavePattern,
|
||||
onDeletePattern,
|
||||
onNewPattern,
|
||||
onSaveWorkflow,
|
||||
onDeleteWorkflow,
|
||||
onNewWorkflow,
|
||||
onSaveMcpServer,
|
||||
onDeleteMcpServer,
|
||||
onNewMcpServer,
|
||||
onSaveLspProfile,
|
||||
onDeleteLspProfile,
|
||||
onNewLspProfile,
|
||||
onSaveWorkspaceAgent,
|
||||
onDeleteWorkspaceAgent,
|
||||
onNewWorkspaceAgent,
|
||||
workspaceAgents,
|
||||
onSetTheme,
|
||||
notificationsEnabled,
|
||||
onSetNotificationsEnabled,
|
||||
@@ -137,36 +147,51 @@ export function SettingsPanel({
|
||||
onResetLocalWorkspace,
|
||||
onResolveUserDiscoveredTooling,
|
||||
onGetQuota,
|
||||
workflowTemplates,
|
||||
onCreateWorkflowFromTemplate,
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
|
||||
|
||||
if (editingPattern) {
|
||||
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
||||
if (editingWorkflow) {
|
||||
const api = getElectronApi();
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
<PatternEditor
|
||||
<WorkflowEditor
|
||||
availableModels={availableModels}
|
||||
isBuiltin={isBuiltin}
|
||||
onBack={() => setEditingPattern(null)}
|
||||
onChange={setEditingPattern}
|
||||
onBack={() => setEditingWorkflow(null)}
|
||||
onChange={setEditingWorkflow}
|
||||
onDelete={
|
||||
isBuiltin
|
||||
? undefined
|
||||
: async () => {
|
||||
await onDeletePattern(editingPattern.id);
|
||||
setEditingPattern(null);
|
||||
}
|
||||
async () => {
|
||||
try {
|
||||
await onDeleteWorkflow(editingWorkflow.id);
|
||||
setEditingWorkflow(null);
|
||||
} catch (err) {
|
||||
window.alert(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Cannot delete this workflow. It may be referenced by other workflows.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
onSave={async () => {
|
||||
await onSavePattern(editingPattern);
|
||||
setEditingPattern(null);
|
||||
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
|
||||
setEditingWorkflow(null);
|
||||
}}
|
||||
pattern={editingPattern}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
toolingSettings={toolingSettings}
|
||||
onExportWorkflow={async (format) => {
|
||||
const result = await api.exportWorkflow({ workflowId: editingWorkflow.id, format });
|
||||
return result;
|
||||
}}
|
||||
onImportWorkflow={async (content, format) => {
|
||||
const result = await api.importWorkflow({ content, format, options: { save: false } });
|
||||
return result.workflow;
|
||||
}}
|
||||
workflow={editingWorkflow}
|
||||
workflows={workflows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -222,6 +247,33 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (editingWorkspaceAgent) {
|
||||
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
<WorkspaceAgentEditor
|
||||
agent={editingWorkspaceAgent}
|
||||
availableModels={availableModels}
|
||||
onBack={() => setEditingWorkspaceAgent(null)}
|
||||
onChange={setEditingWorkspaceAgent}
|
||||
onDelete={
|
||||
exists
|
||||
? async () => {
|
||||
await onDeleteWorkspaceAgent(editingWorkspaceAgent.id);
|
||||
setEditingWorkspaceAgent(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSave={async () => {
|
||||
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
|
||||
setEditingWorkspaceAgent(null);
|
||||
}}
|
||||
workflows={workflows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
<div className="drag-region flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-3">
|
||||
@@ -291,11 +343,21 @@ export function SettingsPanel({
|
||||
onGetQuota={onGetQuota}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'patterns' && (
|
||||
<PatternsSection
|
||||
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
|
||||
onNewPattern={() => setEditingPattern(onNewPattern())}
|
||||
patterns={patterns}
|
||||
{activeSection === 'workflows' && (
|
||||
<WorkflowsSection
|
||||
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
||||
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
|
||||
workflows={workflows}
|
||||
workflowTemplates={workflowTemplates}
|
||||
onCreateWorkflowFromTemplate={onCreateWorkflowFromTemplate}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'agents' && (
|
||||
<WorkspaceAgentsSection
|
||||
agents={workspaceAgents}
|
||||
workflows={workflows}
|
||||
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
|
||||
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'mcp-servers' && (
|
||||
@@ -506,50 +568,225 @@ function ConnectionSection({
|
||||
);
|
||||
}
|
||||
|
||||
function PatternsSection({
|
||||
patterns,
|
||||
onEditPattern,
|
||||
onNewPattern,
|
||||
const categoryLabels: Record<WorkflowTemplateCategory, string> = {
|
||||
'orchestration': 'Orchestration',
|
||||
'data-pipeline': 'Data Pipeline',
|
||||
'human-in-loop': 'Human-in-Loop',
|
||||
};
|
||||
|
||||
function WorkflowsSection({
|
||||
workflows,
|
||||
onEditWorkflow,
|
||||
onNewWorkflow,
|
||||
workflowTemplates,
|
||||
onCreateWorkflowFromTemplate,
|
||||
}: {
|
||||
patterns: PatternDefinition[];
|
||||
onEditPattern: (pattern: PatternDefinition) => void;
|
||||
onNewPattern: () => void;
|
||||
workflows: WorkflowDefinition[];
|
||||
onEditWorkflow: (workflow: WorkflowDefinition) => void;
|
||||
onNewWorkflow: () => void;
|
||||
workflowTemplates?: WorkflowTemplateDefinition[];
|
||||
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
||||
}) {
|
||||
const [categoryFilter, setCategoryFilter] = useState<WorkflowTemplateCategory | 'all'>('all');
|
||||
|
||||
const filteredTemplates = (workflowTemplates ?? []).filter(
|
||||
(t) => categoryFilter === 'all' || t.category === categoryFilter,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Design multi-step agent workflows with visual graphs"
|
||||
title="Workflows"
|
||||
>
|
||||
<SectionAction label="New Workflow" onClick={onNewWorkflow} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{workflows.map((wf) => {
|
||||
const agentCount = wf.graph.nodes.filter((n) => n.kind === 'agent').length;
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
||||
key={wf.id}
|
||||
onClick={() => onEditWorkflow(wf)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{wf.name}</span>
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
|
||||
{wf.settings.executionMode}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{wf.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
{agentCount} agent{agentCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{workflows.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
No workflows yet. Create one to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template Gallery */}
|
||||
{workflowTemplates && workflowTemplates.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="mb-3">
|
||||
<h4 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Templates</h4>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
Start from a pre-built workflow template
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-1.5">
|
||||
{(['all', 'orchestration', 'data-pipeline', 'human-in-loop'] as const).map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`rounded-lg px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
|
||||
categoryFilter === cat
|
||||
? 'bg-[var(--color-accent)] text-white'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
type="button"
|
||||
>
|
||||
{cat === 'all' ? 'All' : categoryLabels[cat]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{filteredTemplates.map((template) => {
|
||||
const nodeCount = template.workflow.graph.nodes.length;
|
||||
const agentCount = template.workflow.graph.nodes.filter((n) => n.kind === 'agent').length;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)]/50 p-4 transition-all duration-200 hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-1)]"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h5 className="text-[13px] font-medium text-[var(--color-text-primary)]">{template.name}</h5>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
|
||||
{categoryLabels[template.category]}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
|
||||
template.source === 'builtin'
|
||||
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'
|
||||
}`}>
|
||||
{template.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-2 text-[12px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{nodeCount} node{nodeCount === 1 ? '' : 's'} · {agentCount} agent{agentCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{onCreateWorkflowFromTemplate && (
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-accent)] px-3 py-1 text-[11px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
|
||||
onClick={() => void onCreateWorkflowFromTemplate(template.id)}
|
||||
type="button"
|
||||
>
|
||||
Use Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredTemplates.length === 0 && (
|
||||
<p className="py-6 text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
No templates in this category.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceAgentsSection({
|
||||
agents,
|
||||
workflows,
|
||||
onEditAgent,
|
||||
onNewAgent,
|
||||
}: {
|
||||
agents: WorkspaceAgentDefinition[];
|
||||
workflows: WorkflowDefinition[];
|
||||
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
|
||||
onNewAgent: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Define reusable agent configurations for your sessions"
|
||||
title="Orchestration Patterns"
|
||||
description="Define reusable agents that can be shared across multiple workflows"
|
||||
title="Workspace Agents"
|
||||
>
|
||||
<SectionAction label="New Pattern" onClick={onNewPattern} />
|
||||
<SectionAction label="New Agent" onClick={onNewAgent} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{patterns.map((pattern) => (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
||||
key={pattern.id}
|
||||
onClick={() => onEditPattern(pattern)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{pattern.name}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${modeBadgeClasses(pattern)}`}>
|
||||
{pattern.mode}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{pattern.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
{pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[var(--color-border)] px-6 py-10 text-center">
|
||||
<UserCircle className="mx-auto size-8 text-[var(--color-text-muted)]" />
|
||||
<p className="mt-3 text-[13px] font-medium text-[var(--color-text-secondary)]">
|
||||
No workspace agents yet
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
|
||||
Create agents here and reference them in multiple workflows.
|
||||
Changes to a workspace agent automatically propagate to all linked workflows.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{agents.map((agent) => {
|
||||
const usageCount = findWorkspaceAgentUsages(agent.id, workflows).length;
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
||||
key={agent.id}
|
||||
onClick={() => onEditAgent(agent)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{agent.name}</span>
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{agent.model}
|
||||
</span>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{usageCount > 0 && (
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
{usageCount} workflow{usageCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -991,7 +1228,7 @@ function TroubleshootingSection({
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
|
||||
Restore Aryx to its initial state. This permanently removes all sessions, custom workflows,
|
||||
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
|
||||
is not affected.
|
||||
</p>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
GitBranch,
|
||||
GitFork,
|
||||
ListOrdered,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
|
||||
import { resolveWorkflowAgentNodes, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
|
||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
@@ -59,13 +58,12 @@ interface SidebarProps {
|
||||
|
||||
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
||||
|
||||
const modeVisuals: Record<OrchestrationMode, { icon: LucideIcon; color: string }> = {
|
||||
const modeVisuals: Record<WorkflowOrchestrationMode, { icon: LucideIcon; color: string }> = {
|
||||
single: { icon: MessageSquare, color: 'text-[#245CF9]' },
|
||||
sequential: { icon: ListOrdered, color: 'text-[var(--color-status-warning)]' },
|
||||
concurrent: { icon: GitFork, color: 'text-[var(--color-status-success)]' },
|
||||
handoff: { icon: ArrowLeftRight, color: 'text-[var(--color-accent-sky)]' },
|
||||
'group-chat': { icon: Users, color: 'text-[var(--color-accent-purple)]' },
|
||||
magentic: { icon: Lock, color: 'text-[var(--color-text-muted)]' },
|
||||
};
|
||||
|
||||
/* ── Relative time helper ──────────────────────────────────── */
|
||||
@@ -178,7 +176,7 @@ function ActionMenuItem({
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
pattern,
|
||||
workflow,
|
||||
isActive,
|
||||
isRenaming,
|
||||
onSelect,
|
||||
@@ -187,7 +185,7 @@ function SessionItem({
|
||||
onRenameCancel,
|
||||
}: {
|
||||
session: SessionRecord;
|
||||
pattern?: PatternDefinition;
|
||||
workflow?: WorkflowDefinition;
|
||||
isActive: boolean;
|
||||
isRenaming: boolean;
|
||||
onSelect: () => void;
|
||||
@@ -199,10 +197,10 @@ function SessionItem({
|
||||
const isError = session.status === 'error';
|
||||
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
||||
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
|
||||
const mode = pattern?.mode ?? 'single';
|
||||
const mode = workflow?.settings.orchestrationMode ?? 'single';
|
||||
const visual = modeVisuals[mode];
|
||||
const ModeIcon = visual.icon;
|
||||
const agentCount = pattern?.agents.length ?? 1;
|
||||
const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1;
|
||||
|
||||
const [renameText, setRenameText] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -363,7 +361,7 @@ function SessionItem({
|
||||
function ProjectGroup({
|
||||
project,
|
||||
sessions,
|
||||
patterns,
|
||||
workflows,
|
||||
selectedSessionId,
|
||||
renamingSessionId,
|
||||
onSessionSelect,
|
||||
@@ -377,7 +375,7 @@ function ProjectGroup({
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
sessions: SessionRecord[];
|
||||
patterns: PatternDefinition[];
|
||||
workflows: WorkflowDefinition[];
|
||||
selectedSessionId?: string;
|
||||
renamingSessionId?: string;
|
||||
onSessionSelect: (sessionId: string) => void;
|
||||
@@ -392,11 +390,11 @@ function ProjectGroup({
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isScratchpad = isScratchpadProject(project);
|
||||
|
||||
const patternMap = useMemo(() => {
|
||||
const map = new Map<string, PatternDefinition>();
|
||||
for (const p of patterns) map.set(p.id, p);
|
||||
const workflowMap = useMemo(() => {
|
||||
const map = new Map<string, WorkflowDefinition>();
|
||||
for (const w of workflows) map.set(w.id, w);
|
||||
return map;
|
||||
}, [patterns]);
|
||||
}, [workflows]);
|
||||
|
||||
const visibleSessions = useMemo(() =>
|
||||
sessions
|
||||
@@ -519,7 +517,7 @@ function ProjectGroup({
|
||||
onOpenMenu={(e) => onOpenMenu(session.id, e)}
|
||||
onRenameSubmit={(title) => onRenameSubmit(session.id, title)}
|
||||
onRenameCancel={onRenameCancel}
|
||||
pattern={patternMap.get(session.patternId)}
|
||||
workflow={workflowMap.get(session.workflowId)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
@@ -575,11 +573,13 @@ export function Sidebar({
|
||||
|
||||
const isQueryActive = searchText.trim().length > 0;
|
||||
|
||||
const patternMap = useMemo(() => {
|
||||
const map = new Map<string, PatternDefinition>();
|
||||
for (const p of workspace.patterns) map.set(p.id, p);
|
||||
const workflowMap = useMemo(() => {
|
||||
const map = new Map<string, WorkflowDefinition>();
|
||||
for (const w of workspace.workflows) {
|
||||
map.set(w.id, w);
|
||||
}
|
||||
return map;
|
||||
}, [workspace.patterns]);
|
||||
}, [workspace.workflows]);
|
||||
|
||||
const queryResults = useMemo(() => {
|
||||
if (!isQueryActive) return [];
|
||||
@@ -694,7 +694,7 @@ export function Sidebar({
|
||||
onOpenMenu={(e) => handleOpenMenu(session.id, e)}
|
||||
onRenameSubmit={(title) => handleRenameSubmit(session.id, title)}
|
||||
onRenameCancel={() => setRenamingSessionId(undefined)}
|
||||
pattern={patternMap.get(session.patternId)}
|
||||
workflow={workflowMap.get(session.workflowId)}
|
||||
session={session}
|
||||
/>
|
||||
))
|
||||
@@ -715,7 +715,7 @@ export function Sidebar({
|
||||
onRenameSubmit={handleRenameSubmit}
|
||||
onRenameCancel={() => setRenamingSessionId(undefined)}
|
||||
renamingSessionId={renamingSessionId}
|
||||
patterns={workspace.patterns}
|
||||
workflows={[...workflowMap.values()]}
|
||||
project={scratchpadProject}
|
||||
selectedSessionId={workspace.selectedSessionId}
|
||||
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
|
||||
@@ -764,7 +764,7 @@ export function Sidebar({
|
||||
onRefreshGitContext={onRefreshGitContext}
|
||||
onOpenProjectSettings={onOpenProjectSettings}
|
||||
renamingSessionId={renamingSessionId}
|
||||
patterns={workspace.patterns}
|
||||
workflows={[...workflowMap.values()]}
|
||||
project={project}
|
||||
selectedSessionId={workspace.selectedSessionId}
|
||||
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Download, Info, Plus, Trash2, Upload, X } from 'lucide-react';
|
||||
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
WorkflowNodeKind,
|
||||
WorkflowEdge,
|
||||
AgentNodeConfig,
|
||||
SubWorkflowConfig,
|
||||
WorkflowStateScope,
|
||||
} from '@shared/domain/workflow';
|
||||
import { validateWorkflowDefinition, isBuilderBasedMode, syncBuilderModeEdgeIterations } from '@shared/domain/workflow';
|
||||
import { createId } from '@shared/utils/ids';
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
|
||||
import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
|
||||
import { ExportDropdown, ExportModal, ImportModal } from './workflow/WorkflowExportImportPanel';
|
||||
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
|
||||
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
|
||||
import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
|
||||
|
||||
interface WorkflowEditorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
workflow: WorkflowDefinition;
|
||||
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||
onChange: (workflow: WorkflowDefinition) => void;
|
||||
onDelete?: () => void;
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onExportWorkflow?: (format: 'yaml' | 'mermaid' | 'dot') => Promise<{ content: string }>;
|
||||
onImportWorkflow?: (content: string, format: 'yaml' | 'json') => Promise<WorkflowDefinition>;
|
||||
}
|
||||
|
||||
interface WorkflowBreadcrumb {
|
||||
workflow: WorkflowDefinition;
|
||||
nodeId: string;
|
||||
nodeLabel: string;
|
||||
/** true when drilling into a referenced (not inline) workflow — view-only */
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const baseClasses =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${baseClasses} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={baseClasses}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Default configs for new nodes ─────────────────────────── */
|
||||
|
||||
function defaultConfigForKind(kind: WorkflowNodeKind): WorkflowNodeConfig {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return { kind: 'start' };
|
||||
case 'end':
|
||||
return { kind: 'end' };
|
||||
case 'agent':
|
||||
return {
|
||||
kind: 'agent',
|
||||
id: createId('agent'),
|
||||
name: 'New Agent',
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
};
|
||||
case 'invoke-function':
|
||||
return { kind: 'invoke-function', functionName: '', arguments: {} };
|
||||
case 'sub-workflow':
|
||||
return { kind: 'sub-workflow' };
|
||||
case 'request-port':
|
||||
return { kind: 'request-port', portId: '', requestType: '', responseType: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLabelForKind(kind: WorkflowNodeKind): string {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return 'Start';
|
||||
case 'end':
|
||||
return 'End';
|
||||
case 'agent':
|
||||
return 'New Agent';
|
||||
case 'invoke-function':
|
||||
return 'Function Tool';
|
||||
case 'sub-workflow':
|
||||
return 'Sub-Workflow';
|
||||
case 'request-port':
|
||||
return 'Request Port';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Minimal inline workflow factory ────────────────────────── */
|
||||
|
||||
function createMinimalInlineWorkflow(): WorkflowDefinition {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: createId('inline-wf'),
|
||||
name: 'Inline Workflow',
|
||||
description: '',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: createId('wf-start'), kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{ id: createId('wf-end'), kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Main editor ───────────────────────────────────────────── */
|
||||
|
||||
export function WorkflowEditor({
|
||||
availableModels,
|
||||
workflow,
|
||||
workflows,
|
||||
onChange,
|
||||
onDelete,
|
||||
onSave,
|
||||
onBack,
|
||||
onExportWorkflow,
|
||||
onImportWorkflow,
|
||||
}: WorkflowEditorProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<WorkflowBreadcrumb[]>([]);
|
||||
const [showExportDropdown, setShowExportDropdown] = useState(false);
|
||||
const [exportResult, setExportResult] = useState<{ format: 'yaml' | 'mermaid' | 'dot'; content: string } | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
/* ── Active workflow resolution ──────────────────────────── */
|
||||
|
||||
const activeWorkflow = useMemo(() => {
|
||||
if (breadcrumbs.length === 0) return workflow;
|
||||
return breadcrumbs[breadcrumbs.length - 1].workflow;
|
||||
}, [workflow, breadcrumbs]);
|
||||
|
||||
const isReadOnly = breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1].readOnly;
|
||||
|
||||
const issues = validateWorkflowDefinition(activeWorkflow);
|
||||
|
||||
const disabledPaletteKinds = useMemo(() => {
|
||||
const disabled = new Set<WorkflowNodeKind>();
|
||||
if (activeWorkflow.graph.nodes.some((n) => n.kind === 'start')) disabled.add('start');
|
||||
if (activeWorkflow.graph.nodes.some((n) => n.kind === 'end')) disabled.add('end');
|
||||
return disabled;
|
||||
}, [activeWorkflow.graph.nodes]);
|
||||
|
||||
/* ── Change propagation ─────────────────────────────────── */
|
||||
|
||||
function propagateActiveChange(updatedActive: WorkflowDefinition) {
|
||||
if (breadcrumbs.length === 0) {
|
||||
onChange(updatedActive);
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk back up the breadcrumb stack, embedding each level's workflow
|
||||
// into the parent's sub-workflow node config.
|
||||
let child = updatedActive;
|
||||
const newCrumbs = [...breadcrumbs];
|
||||
newCrumbs[newCrumbs.length - 1] = { ...newCrumbs[newCrumbs.length - 1], workflow: child };
|
||||
|
||||
for (let i = newCrumbs.length - 1; i >= 0; i--) {
|
||||
const crumb = newCrumbs[i];
|
||||
const parent = i === 0 ? workflow : newCrumbs[i - 1].workflow;
|
||||
const updatedParent: WorkflowDefinition = {
|
||||
...parent,
|
||||
graph: {
|
||||
...parent.graph,
|
||||
nodes: parent.graph.nodes.map((n) => {
|
||||
if (n.id !== crumb.nodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
config: { kind: 'sub-workflow' as const, inlineWorkflow: child } satisfies SubWorkflowConfig,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
child = updatedParent;
|
||||
if (i > 0) {
|
||||
newCrumbs[i - 1] = { ...newCrumbs[i - 1], workflow: updatedParent };
|
||||
}
|
||||
}
|
||||
|
||||
setBreadcrumbs(newCrumbs);
|
||||
onChange(child);
|
||||
}
|
||||
|
||||
function emitChange(next: WorkflowDefinition) {
|
||||
propagateActiveChange(next);
|
||||
}
|
||||
|
||||
function emitGraphChange(graph: WorkflowGraph) {
|
||||
propagateActiveChange({ ...activeWorkflow, graph });
|
||||
}
|
||||
|
||||
function handleAddNode(kind: WorkflowNodeKind) {
|
||||
const nodeId = createId(`wf-${kind}`);
|
||||
const newNode: WorkflowNode = {
|
||||
id: nodeId,
|
||||
kind,
|
||||
label: defaultLabelForKind(kind),
|
||||
position: { x: 300, y: 200 },
|
||||
config: defaultConfigForKind(kind),
|
||||
};
|
||||
emitGraphChange({
|
||||
...activeWorkflow.graph,
|
||||
nodes: [...activeWorkflow.graph.nodes, newNode],
|
||||
});
|
||||
setSelectedNodeId(nodeId);
|
||||
setSelectedEdgeId(null);
|
||||
}
|
||||
|
||||
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
|
||||
emitGraphChange({
|
||||
...activeWorkflow.graph,
|
||||
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
|
||||
emitGraphChange({
|
||||
...activeWorkflow.graph,
|
||||
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleNodeRemove(nodeId: string) {
|
||||
const node = activeWorkflow.graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.kind === 'start' || node.kind === 'end') {
|
||||
return;
|
||||
}
|
||||
|
||||
emitGraphChange({
|
||||
nodes: activeWorkflow.graph.nodes.filter((n) => n.id !== nodeId),
|
||||
edges: activeWorkflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
|
||||
function handleEdgeChange(edgeId: string, patch: Partial<WorkflowEdge>) {
|
||||
emitGraphChange({
|
||||
...activeWorkflow.graph,
|
||||
edges: activeWorkflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleEdgeRemove(edgeId: string) {
|
||||
emitGraphChange({
|
||||
...activeWorkflow.graph,
|
||||
edges: activeWorkflow.graph.edges.filter((e) => e.id !== edgeId),
|
||||
});
|
||||
setSelectedEdgeId(null);
|
||||
}
|
||||
|
||||
/* ── Drill-down ─────────────────────────────────────────── */
|
||||
|
||||
const handleDrillIntoSubWorkflow = useCallback(
|
||||
(node: WorkflowNode) => {
|
||||
if (node.config.kind !== 'sub-workflow') return;
|
||||
|
||||
const config = node.config as SubWorkflowConfig;
|
||||
|
||||
if (config.workflowId) {
|
||||
// Reference mode — find the referenced workflow and show it read-only
|
||||
const ref = workflows.find((wf) => wf.id === config.workflowId);
|
||||
if (!ref) return;
|
||||
setBreadcrumbs((prev) => [
|
||||
...prev,
|
||||
{ workflow: ref, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: true },
|
||||
]);
|
||||
} else {
|
||||
// Inline mode — create a minimal workflow if needed, then drill in
|
||||
let inlineWf = config.inlineWorkflow;
|
||||
if (!inlineWf) {
|
||||
inlineWf = createMinimalInlineWorkflow();
|
||||
// Persist the new inline workflow into the node
|
||||
handleNodeConfigChange(node.id, { kind: 'sub-workflow', inlineWorkflow: inlineWf });
|
||||
}
|
||||
setBreadcrumbs((prev) => [
|
||||
...prev,
|
||||
{ workflow: inlineWf, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: false },
|
||||
]);
|
||||
}
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
},
|
||||
[workflows, handleNodeConfigChange],
|
||||
);
|
||||
|
||||
/* ── Breadcrumb sync: when the top-level workflow changes externally,
|
||||
re-resolve the breadcrumb stack from the current workflow to keep
|
||||
inline sub-workflows in sync. ──────────────────────────────────── */
|
||||
|
||||
// Kept simple: if breadcrumbs exist and the innermost is inline,
|
||||
// re-resolve the active workflow from the current top-level workflow
|
||||
// so external saves don't desync. Referenced (read-only) crumbs
|
||||
// already point to a stable workflow object.
|
||||
useMemo(() => {
|
||||
if (breadcrumbs.length === 0) return;
|
||||
let current: WorkflowDefinition = workflow;
|
||||
const synced: WorkflowBreadcrumb[] = [];
|
||||
for (const crumb of breadcrumbs) {
|
||||
if (crumb.readOnly) {
|
||||
synced.push(crumb);
|
||||
continue;
|
||||
}
|
||||
const parentNode = current.graph.nodes.find((n) => n.id === crumb.nodeId);
|
||||
if (
|
||||
!parentNode ||
|
||||
parentNode.config.kind !== 'sub-workflow' ||
|
||||
!(parentNode.config as SubWorkflowConfig).inlineWorkflow
|
||||
) {
|
||||
// Breadcrumb target no longer exists — pop remaining crumbs
|
||||
break;
|
||||
}
|
||||
const resolved = (parentNode.config as SubWorkflowConfig).inlineWorkflow!;
|
||||
synced.push({ ...crumb, workflow: resolved });
|
||||
current = resolved;
|
||||
}
|
||||
if (synced.length !== breadcrumbs.length || synced.some((s, i) => s.workflow !== breadcrumbs[i].workflow)) {
|
||||
setBreadcrumbs(synced);
|
||||
}
|
||||
}, [workflow]); // intentionally excluding breadcrumbs to avoid loops
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{workflow.name || 'Untitled workflow'}
|
||||
</h3>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">Workflow Designer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="no-drag flex items-center gap-2">
|
||||
{onExportWorkflow && (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={() => setShowExportDropdown((prev) => !prev)}
|
||||
type="button"
|
||||
aria-expanded={showExportDropdown}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Export
|
||||
</button>
|
||||
{showExportDropdown && (
|
||||
<ExportDropdown
|
||||
onSelectFormat={async (format) => {
|
||||
const result = await onExportWorkflow(format);
|
||||
setExportResult({ format, content: result.content });
|
||||
}}
|
||||
onClose={() => setShowExportDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onImportWorkflow && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
type="button"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb bar */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex items-center gap-1 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
|
||||
<button
|
||||
className="text-[12px] text-[var(--color-accent)] hover:underline"
|
||||
onClick={() => { setBreadcrumbs([]); setSelectedNodeId(null); setSelectedEdgeId(null); }}
|
||||
type="button"
|
||||
>
|
||||
{workflow.name || 'Untitled'}
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<Fragment key={`${crumb.nodeId}-${index}`}>
|
||||
<ChevronRight className="size-3 text-[var(--color-text-muted)]" />
|
||||
<button
|
||||
className={`text-[12px] ${
|
||||
index === breadcrumbs.length - 1
|
||||
? 'font-medium text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-accent)] hover:underline'
|
||||
}`}
|
||||
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, index + 1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
|
||||
type="button"
|
||||
>
|
||||
{crumb.nodeLabel}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only banner for referenced sub-workflows */}
|
||||
{isReadOnly && (
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
|
||||
<Info className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
This is a referenced workflow. Open it separately to edit.
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto text-[12px] text-[var(--color-accent)] hover:underline"
|
||||
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, -1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
|
||||
type="button"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body — palette + canvas + inspector */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left palette */}
|
||||
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<WorkflowNodePalette disabledKinds={disabledPaletteKinds} onAddNode={handleAddNode} />
|
||||
</div>
|
||||
|
||||
{/* Center column: validation + canvas + settings */}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto">
|
||||
{/* Validation banner */}
|
||||
<div className="px-5 pt-4">
|
||||
{issues.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{issues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Workflow is valid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph canvas — guaranteed minimum height so settings never collapse it */}
|
||||
<div className="min-h-[360px] px-5 pb-2 pt-4" style={{ height: 'clamp(360px, 50vh, 100%)' }}>
|
||||
<WorkflowGraphCanvas
|
||||
availableModels={availableModels}
|
||||
onEdgeSelect={setSelectedEdgeId}
|
||||
onGraphChange={emitGraphChange}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
workflow={activeWorkflow}
|
||||
workflows={workflows}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings below canvas */}
|
||||
{breadcrumbs.length === 0 && (
|
||||
<WorkflowSettingsPanel workflow={workflow} onChange={emitChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right inspector */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<WorkflowGraphInspector
|
||||
availableModels={availableModels}
|
||||
onDrillIntoSubWorkflow={handleDrillIntoSubWorkflow}
|
||||
onEdgeChange={handleEdgeChange}
|
||||
onEdgeRemove={handleEdgeRemove}
|
||||
onNodeChange={handleNodeChange}
|
||||
onNodeConfigChange={handleNodeConfigChange}
|
||||
onNodeRemove={handleNodeRemove}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
validationIssues={issues}
|
||||
workflow={activeWorkflow}
|
||||
workflows={workflows}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<ExportModal
|
||||
format={exportResult.format}
|
||||
content={exportResult.content}
|
||||
onClose={() => setExportResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && onImportWorkflow && (
|
||||
<ImportModal
|
||||
onImport={async (content, format) => {
|
||||
const imported = await onImportWorkflow(content, format);
|
||||
onChange(imported);
|
||||
return imported;
|
||||
}}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Settings panel below canvas ───────────────────────────── */
|
||||
|
||||
function StateScopeInitialValues({
|
||||
initialValues,
|
||||
onChange,
|
||||
}: {
|
||||
initialValues: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const entries = Object.entries(initialValues);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">Initial Values</span>
|
||||
<button
|
||||
className="flex size-5 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
|
||||
onClick={() => {
|
||||
const key = `key${entries.length + 1}`;
|
||||
onChange({ ...initialValues, [key]: '' });
|
||||
}}
|
||||
title="Add initial value"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
{entries.map(([key, value]) => (
|
||||
<div className="flex items-center gap-1.5" key={key}>
|
||||
<input
|
||||
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => {
|
||||
const next = { ...initialValues };
|
||||
const val = next[key];
|
||||
delete next[key];
|
||||
next[e.target.value] = val;
|
||||
onChange(next);
|
||||
}}
|
||||
placeholder="key"
|
||||
value={key}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => {
|
||||
onChange({ ...initialValues, [key]: e.target.value });
|
||||
}}
|
||||
placeholder="value"
|
||||
value={typeof value === 'string' ? value : JSON.stringify(value) ?? ''}
|
||||
/>
|
||||
<button
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => {
|
||||
const next = { ...initialValues };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
}}
|
||||
title="Remove value"
|
||||
type="button"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowSettingsPanel({
|
||||
workflow,
|
||||
onChange,
|
||||
}: {
|
||||
workflow: WorkflowDefinition;
|
||||
onChange: (workflow: WorkflowDefinition) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-[var(--color-border)] px-5 py-4">
|
||||
<h4 className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Settings
|
||||
</h4>
|
||||
|
||||
{/* Orchestration mode — prominent, above general settings */}
|
||||
<div className="mb-5">
|
||||
<OrchestrationModePanel workflow={workflow} onChange={onChange} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => onChange({ ...workflow, name: v })}
|
||||
placeholder="Workflow name"
|
||||
value={workflow.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => onChange({ ...workflow, description: v })}
|
||||
placeholder="What this workflow does"
|
||||
value={workflow.description}
|
||||
/>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Execution Mode</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: { ...workflow.settings, executionMode: e.target.value as 'off-thread' | 'lockstep' },
|
||||
})
|
||||
}
|
||||
value={workflow.settings.executionMode}
|
||||
>
|
||||
<option value="off-thread">Off-thread</option>
|
||||
<option value="lockstep">Lockstep</option>
|
||||
</select>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Off-thread executes in the background without blocking. Lockstep waits for each step to finish before proceeding.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Max Iterations</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50"
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
const maxIterations = Number.isNaN(raw) ? undefined : raw;
|
||||
const updated: WorkflowDefinition = {
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
maxIterations,
|
||||
...(workflow.settings.orchestrationMode === 'group-chat' && maxIterations !== undefined
|
||||
? {
|
||||
modeSettings: {
|
||||
...workflow.settings.modeSettings,
|
||||
groupChat: {
|
||||
...(workflow.settings.modeSettings?.groupChat ?? { selectionStrategy: 'round-robin' as const }),
|
||||
maxRounds: maxIterations,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
onChange(
|
||||
isBuilderBasedMode(workflow.settings.orchestrationMode)
|
||||
? syncBuilderModeEdgeIterations(updated)
|
||||
: updated,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g. 5"
|
||||
type="number"
|
||||
value={workflow.settings.maxIterations ?? ''}
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Safety limit on total step executions to prevent runaway loops.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div className="col-span-2 flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Checkpointing</div>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">Enable state checkpointing between steps</p>
|
||||
</div>
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
checkpointing: { enabled: !workflow.settings.checkpointing.enabled },
|
||||
},
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ToggleSwitch enabled={workflow.settings.checkpointing.enabled} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* State Scopes */}
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
State Scopes
|
||||
</span>
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Named containers for shared state that persists across workflow steps.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
|
||||
onClick={() => {
|
||||
const scopes = [...(workflow.settings.stateScopes ?? []), { name: '', description: '', initialValues: {} }];
|
||||
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||
}}
|
||||
title="Add state scope"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(workflow.settings.stateScopes ?? []).map((scope, idx) => (
|
||||
<div
|
||||
className="space-y-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-3"
|
||||
key={idx}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => {
|
||||
const scopes = [...(workflow.settings.stateScopes ?? [])];
|
||||
scopes[idx] = { ...scopes[idx], name: e.target.value };
|
||||
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||
}}
|
||||
placeholder="Scope name"
|
||||
value={scope.name}
|
||||
/>
|
||||
<button
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => {
|
||||
const scopes = (workflow.settings.stateScopes ?? []).filter((_, i) => i !== idx);
|
||||
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||
}}
|
||||
title="Remove scope"
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => {
|
||||
const scopes = [...(workflow.settings.stateScopes ?? [])];
|
||||
scopes[idx] = { ...scopes[idx], description: e.target.value };
|
||||
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||
}}
|
||||
placeholder="Description (optional)"
|
||||
value={scope.description ?? ''}
|
||||
/>
|
||||
<StateScopeInitialValues
|
||||
initialValues={scope.initialValues ?? {}}
|
||||
onChange={(initialValues) => {
|
||||
const scopes = [...(workflow.settings.stateScopes ?? [])];
|
||||
scopes[idx] = { ...scopes[idx], initialValues };
|
||||
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useClickOutside } from '@renderer/hooks/useClickOutside';
|
||||
import type { ApprovalToolDefinition, LspProfileDefinition, McpServerDefinition, SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
|
||||
import { groupApprovalToolsByProvider, type ApprovalToolGroup } from '@shared/domain/tooling';
|
||||
import { findModel, inferProvider, providerMeta, type ModelDefinition } from '@shared/domain/models';
|
||||
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
|
||||
import { RotateCcw, Server, ShieldCheck } from 'lucide-react';
|
||||
|
||||
/* ── Tier badge ────────────────────────────────────────────── */
|
||||
@@ -556,7 +556,7 @@ export function InlineApprovalPill({
|
||||
|
||||
{open && !disabled && (
|
||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
||||
{/* Header: session override / pattern defaults */}
|
||||
{/* Header: session override / workflow defaults */}
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { ArrowUp, FileText, X } from 'lucide-react';
|
||||
|
||||
import { useClickOutside } from '@renderer/hooks/useClickOutside';
|
||||
import type { ProjectPromptFile, ProjectPromptVariable } from '@shared/domain/projectCustomization';
|
||||
import type { ProjectPromptFile, ProjectPromptInvocation, ProjectPromptVariable } from '@shared/domain/projectCustomization';
|
||||
|
||||
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):[^}]+\}/g;
|
||||
|
||||
@@ -12,14 +12,52 @@ function resolvePromptTemplate(template: string, values: Record<string, string>)
|
||||
});
|
||||
}
|
||||
|
||||
function buildPromptInvocation(prompt: ProjectPromptFile, resolvedTemplate: string): ProjectPromptInvocation {
|
||||
const invocation: ProjectPromptInvocation = {
|
||||
id: prompt.id,
|
||||
name: prompt.name,
|
||||
sourcePath: prompt.sourcePath,
|
||||
resolvedPrompt: resolvedTemplate,
|
||||
};
|
||||
|
||||
if (prompt.description) invocation.description = prompt.description;
|
||||
if (prompt.agent) invocation.agent = prompt.agent;
|
||||
if (prompt.model) invocation.model = prompt.model;
|
||||
if (prompt.tools?.length) invocation.tools = prompt.tools;
|
||||
|
||||
return invocation;
|
||||
}
|
||||
|
||||
/** Returns a human-friendly display label for source paths, shortening ancestor-relative segments. */
|
||||
function formatPromptSourcePath(sourcePath: string): string {
|
||||
const normalized = sourcePath.replace(/\\/g, '/');
|
||||
const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//;
|
||||
if (ancestorPrefix.test(normalized)) {
|
||||
const segments = normalized.split('/').filter((s) => s !== '..');
|
||||
return `↑ ${segments.join('/')}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export interface ArmedPrompt {
|
||||
prompt: ProjectPromptFile;
|
||||
invocation: ProjectPromptInvocation;
|
||||
}
|
||||
|
||||
export function InlinePromptPill({
|
||||
promptFiles,
|
||||
disabled,
|
||||
armedPrompt,
|
||||
onArm,
|
||||
onDisarm,
|
||||
onSubmit,
|
||||
}: {
|
||||
promptFiles: ReadonlyArray<ProjectPromptFile>;
|
||||
disabled: boolean;
|
||||
onSubmit: (resolvedContent: string) => void;
|
||||
armedPrompt?: ArmedPrompt | null;
|
||||
onArm?: (armed: ArmedPrompt) => void;
|
||||
onDisarm?: () => void;
|
||||
onSubmit: (invocation: ProjectPromptInvocation) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<ProjectPromptFile | null>(null);
|
||||
@@ -32,23 +70,32 @@ export function InlinePromptPill({
|
||||
setVariableValues({});
|
||||
}, []);
|
||||
|
||||
const armOrSubmit = useCallback((prompt: ProjectPromptFile, resolvedTemplate: string) => {
|
||||
const invocation = buildPromptInvocation(prompt, resolvedTemplate);
|
||||
if (prompt.argumentHint && onArm) {
|
||||
onArm({ prompt, invocation });
|
||||
handleClose();
|
||||
} else {
|
||||
onSubmit(invocation);
|
||||
handleClose();
|
||||
}
|
||||
}, [onArm, onSubmit, handleClose]);
|
||||
|
||||
const handleSelectPrompt = useCallback((prompt: ProjectPromptFile) => {
|
||||
if (prompt.variables.length === 0) {
|
||||
onSubmit(prompt.template.trim());
|
||||
handleClose();
|
||||
armOrSubmit(prompt, prompt.template.trim());
|
||||
} else {
|
||||
setSelectedPrompt(prompt);
|
||||
setVariableValues({});
|
||||
}
|
||||
}, [onSubmit, handleClose]);
|
||||
}, [armOrSubmit]);
|
||||
|
||||
const handleSubmitWithVariables = useCallback(() => {
|
||||
if (!selectedPrompt) return;
|
||||
const resolved = resolvePromptTemplate(selectedPrompt.template, variableValues).trim();
|
||||
if (!resolved) return;
|
||||
onSubmit(resolved);
|
||||
handleClose();
|
||||
}, [selectedPrompt, variableValues, onSubmit, handleClose]);
|
||||
armOrSubmit(selectedPrompt, resolved);
|
||||
}, [selectedPrompt, variableValues, armOrSubmit]);
|
||||
|
||||
const handleVariableChange = useCallback((name: string, value: string) => {
|
||||
setVariableValues((prev) => ({ ...prev, [name]: value }));
|
||||
@@ -63,18 +110,31 @@ export function InlinePromptPill({
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
Prompts
|
||||
<span className="text-[var(--color-text-muted)]">({promptFiles.length})</span>
|
||||
</button>
|
||||
{armedPrompt ? (
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-status-success)]/10 px-2.5 py-1 text-[11px] font-medium text-[var(--color-status-success)] transition-all duration-200 hover:bg-[var(--color-status-success)]/20"
|
||||
onClick={() => onDisarm?.()}
|
||||
type="button"
|
||||
aria-label={`Disarm prompt: ${armedPrompt.prompt.name}`}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
<span className="max-w-[120px] truncate">{armedPrompt.prompt.name}</span>
|
||||
<X className="size-3 opacity-60" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
Prompts
|
||||
<span className="text-[var(--color-text-muted)]">({promptFiles.length})</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && !disabled && (
|
||||
<div
|
||||
@@ -127,24 +187,49 @@ function PromptList({
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
|
||||
{prompt.name}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
|
||||
{prompt.name}
|
||||
</span>
|
||||
{prompt.agent && (
|
||||
<span className="shrink-0 rounded bg-[var(--color-accent-sky)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
|
||||
{prompt.agent}
|
||||
</span>
|
||||
)}
|
||||
{prompt.model && (
|
||||
<span className="shrink-0 rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
|
||||
{prompt.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prompt.description && (
|
||||
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">
|
||||
{prompt.description}
|
||||
</div>
|
||||
)}
|
||||
{prompt.variables.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{prompt.variables.map((v) => (
|
||||
<span
|
||||
key={v.name}
|
||||
className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
{v.name}
|
||||
</span>
|
||||
))}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{prompt.tools && prompt.tools.length > 0 && (
|
||||
<span className="rounded bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
|
||||
{prompt.tools.length} tool{prompt.tools.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{prompt.argumentHint && (
|
||||
<span className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] italic text-[var(--color-text-muted)]">
|
||||
hint: {prompt.argumentHint}
|
||||
</span>
|
||||
)}
|
||||
{prompt.variables.map((v) => (
|
||||
<span
|
||||
key={v.name}
|
||||
className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
{v.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{prompt.sourcePath.startsWith('..') && (
|
||||
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{formatPromptSourcePath(prompt.sourcePath)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -181,8 +266,15 @@ function PromptVariableForm({
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
|
||||
{prompt.name}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
|
||||
{prompt.name}
|
||||
</span>
|
||||
{prompt.model && (
|
||||
<span className="shrink-0 rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
|
||||
{prompt.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prompt.description && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{prompt.description}</div>
|
||||
@@ -213,7 +305,7 @@ function PromptVariableForm({
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
Send prompt
|
||||
{prompt.argumentHint ? 'Arm prompt' : 'Send prompt'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||
|
||||
const COMPLETION_GRACE_MS = 3000;
|
||||
|
||||
function formatElapsed(startedAt: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
@@ -37,9 +39,10 @@ function ElapsedTimer({ startedAt }: { startedAt: string }) {
|
||||
|
||||
interface SubagentActivityCardProps {
|
||||
subagent: ActiveSubagent;
|
||||
fading?: boolean;
|
||||
}
|
||||
|
||||
function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
||||
function SubagentActivityCard({ subagent, fading }: SubagentActivityCardProps) {
|
||||
const borderClass =
|
||||
subagent.status === 'running'
|
||||
? 'border-[var(--color-accent-sky)]/20'
|
||||
@@ -49,7 +52,7 @@ function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-200 ${borderClass}`}
|
||||
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-300 ${borderClass} ${fading ? 'opacity-0' : 'opacity-100'}`}
|
||||
role="status"
|
||||
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
|
||||
>
|
||||
@@ -73,16 +76,68 @@ interface SubagentActivityListProps {
|
||||
}
|
||||
|
||||
export function SubagentActivityList({ subagents }: SubagentActivityListProps) {
|
||||
// Track recently-completed subagent IDs so we can show them briefly
|
||||
const [recentlyDone, setRecentlyDone] = useState<Set<string>>(new Set());
|
||||
const [fading, setFading] = useState<Set<string>>(new Set());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
for (const sub of subagents) {
|
||||
if (sub.status !== 'running' && !recentlyDone.has(sub.toolCallId) && !timersRef.current.has(sub.toolCallId)) {
|
||||
// Newly completed — track it
|
||||
setRecentlyDone((prev) => new Set(prev).add(sub.toolCallId));
|
||||
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setFading((prev) => new Set(prev).add(sub.toolCallId));
|
||||
}, COMPLETION_GRACE_MS - 300);
|
||||
|
||||
const removeTimer = setTimeout(() => {
|
||||
setRecentlyDone((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sub.toolCallId);
|
||||
return next;
|
||||
});
|
||||
setFading((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sub.toolCallId);
|
||||
return next;
|
||||
});
|
||||
timersRef.current.delete(sub.toolCallId);
|
||||
}, COMPLETION_GRACE_MS);
|
||||
|
||||
timersRef.current.set(sub.toolCallId, removeTimer);
|
||||
// Store fade timer for cleanup
|
||||
timersRef.current.set(`fade-${sub.toolCallId}`, fadeTimer);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subagents]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (subagents.length === 0) return null;
|
||||
|
||||
// Only show running subagents in the chat stream
|
||||
const visible = subagents.filter((s) => s.status === 'running');
|
||||
// Show running subagents + recently-completed ones within the grace period
|
||||
const visible = subagents.filter(
|
||||
(s) => s.status === 'running' || recentlyDone.has(s.toolCallId),
|
||||
);
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
|
||||
{visible.map((subagent) => (
|
||||
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
|
||||
<SubagentActivityCard
|
||||
key={subagent.toolCallId}
|
||||
subagent={subagent}
|
||||
fading={fading.has(subagent.toolCallId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
interface ThinkingProcessProps {
|
||||
messages: ChatMessageRecord[];
|
||||
isActive: boolean;
|
||||
turnStartedAt?: string;
|
||||
}
|
||||
|
||||
export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingProcessProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const wasActiveRef = useRef(isActive);
|
||||
|
||||
// Auto-expand when the turn is active and thinking messages appear.
|
||||
// Auto-collapse once the turn finishes.
|
||||
useEffect(() => {
|
||||
if (isActive && messages.length > 0) {
|
||||
setExpanded(true);
|
||||
} else if (wasActiveRef.current && !isActive) {
|
||||
setExpanded(false);
|
||||
}
|
||||
wasActiveRef.current = isActive;
|
||||
}, [isActive, messages.length]);
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
const elapsed = useMemo(() => {
|
||||
if (!turnStartedAt || messages.length === 0) return undefined;
|
||||
const start = new Date(turnStartedAt).getTime();
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
|
||||
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
||||
if (seconds < 2) return undefined;
|
||||
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
|
||||
}, [turnStartedAt, messages, isActive]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepCount = messages.length;
|
||||
const summaryParts: string[] = [];
|
||||
if (elapsed) summaryParts.push(`${elapsed}`);
|
||||
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
|
||||
|
||||
return (
|
||||
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)]/50"
|
||||
>
|
||||
<Brain className="size-3.5 shrink-0 text-[var(--color-accent-purple)]" />
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[var(--color-text-secondary)]">Thinking</span>
|
||||
<ThinkingPulse />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-secondary)]">
|
||||
Thought for {summaryParts.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0">
|
||||
{expanded
|
||||
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
|
||||
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{messages.map((message) => (
|
||||
<ThinkingStep key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
|
||||
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 text-[12px] leading-relaxed">
|
||||
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]">▸</span>
|
||||
<div className="min-w-0">
|
||||
{message.authorName && (
|
||||
<span className="mr-1.5 font-medium text-[var(--color-text-secondary)]">
|
||||
{message.authorName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[var(--color-text-muted)]">{preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPulse() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
|
||||
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
|
||||
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function truncatePreview(text: string, maxLength: number): string {
|
||||
const firstLine = text.split('\n')[0] ?? '';
|
||||
const cleaned = firstLine.trim();
|
||||
if (cleaned.length <= maxLength) return cleaned;
|
||||
return `${cleaned.slice(0, maxLength)}…`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
formatToolCallSummary,
|
||||
formatToolArgumentValue,
|
||||
getDisplayableArguments,
|
||||
} from '@renderer/lib/toolCallSummary';
|
||||
|
||||
export interface ToolCallDetailPanelProps {
|
||||
toolName?: string;
|
||||
toolArguments?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ToolCallDetailPanel({ toolName, toolArguments }: ToolCallDetailPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const summary = formatToolCallSummary(toolName, toolArguments);
|
||||
const displayArgs = getDisplayableArguments(toolArguments);
|
||||
const hasExpandableContent = displayArgs.length > 0;
|
||||
|
||||
if (!summary && !hasExpandableContent) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-0.5">
|
||||
{/* Inline summary — always visible when summary exists */}
|
||||
<button
|
||||
type="button"
|
||||
className={`group flex max-w-full items-start gap-1 text-left text-[11px] leading-snug ${
|
||||
hasExpandableContent
|
||||
? 'cursor-pointer hover:text-[var(--color-text-secondary)]'
|
||||
: 'cursor-default'
|
||||
}`}
|
||||
onClick={hasExpandableContent ? () => setExpanded((prev) => !prev) : undefined}
|
||||
aria-expanded={hasExpandableContent ? expanded : undefined}
|
||||
aria-label={hasExpandableContent ? `Toggle ${toolName} arguments` : undefined}
|
||||
tabIndex={hasExpandableContent ? 0 : -1}
|
||||
onKeyDown={hasExpandableContent
|
||||
? (e) => { if (e.key === ' ') { e.preventDefault(); setExpanded((prev) => !prev); } }
|
||||
: undefined}
|
||||
>
|
||||
{hasExpandableContent && (
|
||||
<ChevronRight
|
||||
className={`mt-px size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{summary && (
|
||||
<span className="min-w-0 truncate font-mono text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]">
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded argument list */}
|
||||
{expanded && hasExpandableContent && (
|
||||
<div className="mt-1 overflow-hidden rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/80">
|
||||
<div className="max-h-48 overflow-auto">
|
||||
{displayArgs.map(([key, value]) => {
|
||||
const formatted = formatToolArgumentValue(value);
|
||||
const isMultiline = formatted.includes('\n') || formatted.length > 120;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="border-b border-[var(--color-border-subtle)] px-2 py-1 last:border-b-0"
|
||||
>
|
||||
<span className="text-[10px] font-semibold tracking-wide text-[var(--color-accent-purple)]">
|
||||
{key}
|
||||
</span>
|
||||
{isMultiline ? (
|
||||
<pre className="mt-0.5 max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{formatted}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="ml-1.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
|
||||
{formatted}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
ShieldAlert,
|
||||
Wrench,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
|
||||
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
||||
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
|
||||
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
||||
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────── */
|
||||
|
||||
/** A unified activity stream item, merging chat thinking messages
|
||||
* and run timeline events into a single chronological list. */
|
||||
type ActivityStreamItem =
|
||||
| { kind: 'thinking-step'; message: ChatMessageRecord }
|
||||
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
|
||||
|
||||
/** Events to skip in the inline panel (redundant or implicit). */
|
||||
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
|
||||
|
||||
/* ── Props ─────────────────────────────────────────────────── */
|
||||
|
||||
export interface TurnActivityPanelProps {
|
||||
thinkingMessages: ChatMessageRecord[];
|
||||
run?: SessionRunRecord;
|
||||
isActive: boolean;
|
||||
turnStartedAt?: string;
|
||||
sessionId: string;
|
||||
/** Agent names in this turn group — used to scope run events in multi-agent runs. */
|
||||
agentNames?: ReadonlySet<string>;
|
||||
/** True when this panel is the last one sharing a given run (controls git summary / discard). */
|
||||
isLastRunPanel?: boolean;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
|
||||
function truncatePreview(text: string, maxLength: number): string {
|
||||
const firstLine = text.split('\n')[0] ?? '';
|
||||
const cleaned = firstLine.trim();
|
||||
if (cleaned.length <= maxLength) return cleaned;
|
||||
return `${cleaned.slice(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
function buildActivityStream(
|
||||
thinkingMessages: ChatMessageRecord[],
|
||||
events: readonly RunTimelineEventRecord[],
|
||||
): ActivityStreamItem[] {
|
||||
const items: ActivityStreamItem[] = [];
|
||||
|
||||
for (const msg of thinkingMessages) {
|
||||
items.push({ kind: 'thinking-step', message: msg });
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
|
||||
items.push({ kind: 'timeline-event', event });
|
||||
}
|
||||
|
||||
// Sort chronologically by timestamp
|
||||
items.sort((a, b) => {
|
||||
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
|
||||
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
|
||||
return new Date(tsA).getTime() - new Date(tsB).getTime();
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function formatSummaryParts(summary: ActivitySummary): string[] {
|
||||
const parts: string[] = [];
|
||||
if (summary.toolCalls > 0) {
|
||||
parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`);
|
||||
}
|
||||
if (summary.handoffs > 0) {
|
||||
parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`);
|
||||
}
|
||||
if (summary.approvals > 0) {
|
||||
parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`);
|
||||
}
|
||||
if (summary.thinkingSteps > 0) {
|
||||
parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/* ── Event icon ────────────────────────────────────────────── */
|
||||
|
||||
function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
|
||||
const base = 'size-3 shrink-0';
|
||||
|
||||
switch (kind) {
|
||||
case 'tool-call':
|
||||
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||
case 'approval':
|
||||
return (
|
||||
<ShieldAlert
|
||||
className={`${base} ${
|
||||
status === 'error'
|
||||
? 'text-[var(--color-status-error)]'
|
||||
: status === 'running'
|
||||
? 'text-[var(--color-status-warning)]'
|
||||
: 'text-[var(--color-status-success)]'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
case 'handoff':
|
||||
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
|
||||
case 'message':
|
||||
return <MessageSquare className={`${base} text-[var(--color-accent-sky)]`} />;
|
||||
case 'run-completed':
|
||||
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
|
||||
case 'run-cancelled':
|
||||
return <XCircle className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
case 'run-failed':
|
||||
return <AlertTriangle className={`${base} text-[var(--color-status-error)]`} />;
|
||||
default:
|
||||
return <Zap className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Activity event row ────────────────────────────────────── */
|
||||
|
||||
function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) {
|
||||
const label = formatEventLabel(event);
|
||||
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
|
||||
|
||||
return (
|
||||
<div className="turn-activity-row flex gap-2 py-1">
|
||||
<div className="mt-0.5 flex shrink-0 items-start">
|
||||
<ActivityEventIcon kind={event.kind} status={event.status} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Approval kind badge */}
|
||||
{event.kind === 'approval' && event.approvalKind && (
|
||||
<span
|
||||
className={`ml-1.5 inline-flex rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
|
||||
event.status === 'running'
|
||||
? 'bg-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
|
||||
: event.status === 'completed'
|
||||
? 'bg-[var(--color-status-success)]/15 text-[var(--color-status-success)]'
|
||||
: 'bg-[var(--color-status-error)]/15 text-[var(--color-status-error)]'
|
||||
}`}
|
||||
>
|
||||
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Content preview for message events */}
|
||||
{event.kind === 'message' && event.content && (
|
||||
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
|
||||
{truncateContent(event.content, 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Approval detail */}
|
||||
{event.kind === 'approval' && event.approvalDetail && (
|
||||
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
|
||||
{truncateContent(event.approvalDetail, 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{event.error && (
|
||||
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-status-error)]/80">
|
||||
{truncateContent(event.error, 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tool call argument details */}
|
||||
{event.kind === 'tool-call' && (
|
||||
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
|
||||
)}
|
||||
|
||||
{/* File change preview for tool-call events */}
|
||||
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<FileChangePreview fileChanges={event.fileChanges} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Thinking step row ─────────────────────────────────────── */
|
||||
|
||||
function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
|
||||
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
|
||||
|
||||
if (message.pending && !message.content) return null;
|
||||
|
||||
return (
|
||||
<div className="turn-activity-row flex gap-2 py-1">
|
||||
<div className="mt-0.5 flex shrink-0 items-start">
|
||||
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.authorName && (
|
||||
<span className="mr-1.5 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
{message.authorName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">{preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Active pulse dots ─────────────────────────────────────── */
|
||||
|
||||
function ActivityPulse() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
|
||||
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
|
||||
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ────────────────────────────────────────── */
|
||||
|
||||
export function TurnActivityPanel({
|
||||
thinkingMessages,
|
||||
run,
|
||||
isActive,
|
||||
turnStartedAt,
|
||||
sessionId,
|
||||
agentNames,
|
||||
isLastRunPanel,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
}: TurnActivityPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const wasActiveRef = useRef(isActive);
|
||||
|
||||
// Auto-expand when the turn is active (run exists or thinking arrives).
|
||||
// Auto-collapse once the turn finishes.
|
||||
useEffect(() => {
|
||||
if (isActive && (thinkingMessages.length > 0 || run)) {
|
||||
setExpanded(true);
|
||||
} else if (wasActiveRef.current && !isActive) {
|
||||
setExpanded(false);
|
||||
}
|
||||
wasActiveRef.current = isActive;
|
||||
}, [isActive, thinkingMessages.length, run]);
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
// When the run is shared across multiple panels (multi-agent sequential),
|
||||
// scope events to only those belonging to this panel's agents.
|
||||
const scopedEvents = useMemo(
|
||||
() => filterEventsByAgent(run?.events ?? [], agentNames),
|
||||
[run?.events, agentNames],
|
||||
);
|
||||
|
||||
// Derive per-agent timing from the scoped events when agent names are set
|
||||
// (multi-agent run). For single-agent runs, use the run-level start time.
|
||||
const effectiveTurnStartedAt = useMemo(() => {
|
||||
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
|
||||
return turnStartedAt;
|
||||
}
|
||||
// Use the earliest scoped event as the start time for this agent's panel.
|
||||
let earliest = turnStartedAt;
|
||||
for (const e of scopedEvents) {
|
||||
if (!earliest || e.occurredAt < earliest) {
|
||||
earliest = e.occurredAt;
|
||||
break; // events are already in insertion order (chronological)
|
||||
}
|
||||
}
|
||||
return earliest;
|
||||
}, [agentNames, scopedEvents, turnStartedAt]);
|
||||
|
||||
const elapsed = useElapsedTimer(
|
||||
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
|
||||
isActive,
|
||||
);
|
||||
|
||||
const summary = useMemo(
|
||||
() => summarizeActivity(thinkingMessages, scopedEvents),
|
||||
[thinkingMessages, scopedEvents],
|
||||
);
|
||||
|
||||
const activityStream = useMemo(
|
||||
() => buildActivityStream(thinkingMessages, scopedEvents),
|
||||
[thinkingMessages, scopedEvents],
|
||||
);
|
||||
|
||||
// Nothing to show — no thinking messages, no run, and not active
|
||||
if (thinkingMessages.length === 0 && !run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryParts = formatSummaryParts(summary);
|
||||
const runStatus = run?.status;
|
||||
const isCompleted = runStatus === 'completed';
|
||||
const isFailed = runStatus === 'error';
|
||||
const isCancelled = runStatus === 'cancelled';
|
||||
const isTerminated = isCompleted || isFailed || isCancelled;
|
||||
// Only show git summary and discard on the last panel for a given run
|
||||
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
|
||||
|
||||
// Build the summary label
|
||||
let summaryLabel: string;
|
||||
if (isActive) {
|
||||
summaryLabel = 'Working';
|
||||
} else if (isFailed) {
|
||||
summaryLabel = elapsed ? `Failed after ${elapsed}` : 'Failed';
|
||||
} else if (isCancelled) {
|
||||
summaryLabel = elapsed ? `Cancelled after ${elapsed}` : 'Cancelled';
|
||||
} else if (elapsed) {
|
||||
summaryLabel = `Completed in ${elapsed}`;
|
||||
} else {
|
||||
summaryLabel = 'Completed';
|
||||
}
|
||||
|
||||
const statusColorClass = isFailed
|
||||
? 'text-[var(--color-status-error)]'
|
||||
: isCancelled
|
||||
? 'text-[var(--color-text-muted)]'
|
||||
: isActive
|
||||
? 'text-[var(--color-text-secondary)]'
|
||||
: 'text-[var(--color-text-secondary)]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'border-[var(--color-accent)]/30'
|
||||
: isFailed
|
||||
? 'border-[var(--color-status-error)]/20'
|
||||
: 'border-[var(--color-border)]/50'
|
||||
}`}
|
||||
>
|
||||
{/* Summary header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
|
||||
aria-expanded={expanded}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
|
||||
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
|
||||
}`}
|
||||
>
|
||||
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
|
||||
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={statusColorClass}>{summaryLabel}</span>
|
||||
<ActivityPulse />
|
||||
</span>
|
||||
) : (
|
||||
<span className={statusColorClass}>{summaryLabel}</span>
|
||||
)}
|
||||
|
||||
{/* Inline counters */}
|
||||
{summaryParts.length > 0 && (
|
||||
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
{'· '}
|
||||
{summaryParts.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="ml-auto shrink-0">
|
||||
{expanded
|
||||
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
|
||||
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded activity stream */}
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
{activityStream.map((item) => {
|
||||
if (item.kind === 'thinking-step') {
|
||||
return <ThinkingStepRow key={item.message.id} message={item.message} />;
|
||||
}
|
||||
return <ActivityTimelineEventRow key={item.event.id} event={item.event} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Post-run git changes */}
|
||||
{showGitSummary && (
|
||||
<div className="mt-2 border-t border-[var(--color-border)]/30 pt-2">
|
||||
<RunChangeSummaryCard
|
||||
onDiscard={onDiscard}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runId={run.requestId}
|
||||
sessionId={sessionId}
|
||||
summary={run.postRunGitSummary!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { CircleUser, Shuffle, Layers, Radio, Bot } from 'lucide-react';
|
||||
|
||||
import type { GraphNodeData } from '@renderer/lib/patternGraph';
|
||||
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
|
||||
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot, // fallback when no provider is resolved
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
};
|
||||
|
||||
const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; text: string }> = {
|
||||
'user-input': { bg: 'bg-[var(--color-accent)]/10', border: 'border-[var(--color-accent)]/30', text: 'text-[var(--color-accent-sky)]' },
|
||||
'user-output': { bg: 'bg-[var(--color-accent)]/10', border: 'border-[var(--color-accent)]/30', text: 'text-[var(--color-accent-sky)]' },
|
||||
agent: { bg: 'bg-[var(--color-surface-2)]/80', border: 'border-[var(--color-border)]/40', text: 'text-[var(--color-text-primary)]' },
|
||||
distributor: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
collector: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
orchestrator: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
|
||||
};
|
||||
|
||||
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
|
||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||
const isAgent = data.kind === 'agent';
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAgent && data.provider) {
|
||||
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
|
||||
}
|
||||
const FallbackIcon = kindIcons[data.kind] ?? Bot;
|
||||
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md backdrop-blur-sm transition ${
|
||||
colors.bg
|
||||
} ${selected ? 'ring-2 ring-[var(--color-accent)]/50' : ''} ${colors.border}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||
{data.label}
|
||||
</div>
|
||||
{isAgent && data.modelLabel && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
{data.readOnly && (
|
||||
<span className="ml-1 rounded bg-[var(--color-surface-3)]/50 px-1 py-0.5 text-[8px] font-medium text-[var(--color-text-muted)]">
|
||||
SYS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleStyles = {
|
||||
system: '!size-2 !border-[var(--color-border)] !bg-[var(--color-text-secondary)]',
|
||||
agent: '!size-2 !border-[var(--color-accent-sky)] !bg-[var(--color-accent)]',
|
||||
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
|
||||
};
|
||||
|
||||
/* user-input: source only (no incoming handle)
|
||||
user-output: target only (no outgoing handle) */
|
||||
|
||||
export const UserInputNode = memo(function UserInputNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.system} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const UserOutputNode = memo(function UserOutputNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.system} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.system} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.system} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const graphNodeTypes = {
|
||||
userInputNode: UserInputNode,
|
||||
userOutputNode: UserOutputNode,
|
||||
systemNode: SystemNode,
|
||||
agentNode: AgentNode,
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
MarkerType,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type OnConnect,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
|
||||
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
addEdge,
|
||||
autoLayoutGraph,
|
||||
fromCanvasPositions,
|
||||
isConnectionAllowed,
|
||||
isEdgeDeletionAllowed,
|
||||
removeEdge,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
type GraphNodeData,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
|
||||
import { graphNodeTypes } from './GraphNodes';
|
||||
|
||||
interface PatternGraphCanvasProps {
|
||||
pattern: PatternDefinition;
|
||||
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onNodeSelect: (nodeId: string | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
}
|
||||
|
||||
function PatternGraphCanvasInner({
|
||||
pattern,
|
||||
availableModels,
|
||||
onGraphChange,
|
||||
onAgentRemove,
|
||||
onNodeSelect,
|
||||
selectedNodeId,
|
||||
}: PatternGraphCanvasProps) {
|
||||
const { fitView } = useReactFlow();
|
||||
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||
toCanvasNodes(graph, pattern.agents, availableModels),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||
toCanvasEdges(graph, pattern.mode),
|
||||
);
|
||||
|
||||
// Sync canvas when pattern changes externally
|
||||
useEffect(() => {
|
||||
setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
|
||||
setEdges(toCanvasEdges(graph, pattern.mode));
|
||||
}, [graph, pattern.agents, pattern.mode, availableModels, setNodes, setEdges]);
|
||||
|
||||
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
|
||||
(changes) => {
|
||||
// Intercept node removals and route agent deletions through the
|
||||
// authoritative removal path (which also removes from pattern.agents).
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
const graphNode = graph.nodes.find((n) => n.id === removal.id);
|
||||
if (graphNode?.kind === 'agent' && graphNode.agentId) {
|
||||
onAgentRemove(graphNode.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nonRemovals.length > 0) {
|
||||
onNodesChange(nonRemovals);
|
||||
}
|
||||
|
||||
const hasDragStart = nonRemovals.some(
|
||||
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
|
||||
);
|
||||
const hasDragStop = nonRemovals.some(
|
||||
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
|
||||
);
|
||||
|
||||
if (hasDragStart) {
|
||||
draggingRef.current = true;
|
||||
}
|
||||
|
||||
if (hasDragStop && draggingRef.current) {
|
||||
draggingRef.current = false;
|
||||
setNodes((currentNodes) => {
|
||||
const updatedGraph = fromCanvasPositions(pattern, currentNodes);
|
||||
onGraphChange(updatedGraph);
|
||||
return currentNodes;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onNodesChange, pattern, graph, onGraphChange, onAgentRemove, setNodes],
|
||||
);
|
||||
|
||||
const handleEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
// Route edge removals through the authoritative graph.
|
||||
// Non-deletable edges are already protected by the per-edge `deletable`
|
||||
// flag, so React Flow will not emit removal changes for them.
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
if (removals.length > 0 && isEdgeDeletionAllowed(pattern.mode)) {
|
||||
let updatedGraph = graph;
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
updatedGraph = removeEdge(updatedGraph, removal.id);
|
||||
}
|
||||
}
|
||||
onGraphChange(updatedGraph);
|
||||
}
|
||||
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
if (nonRemovals.length > 0) {
|
||||
onEdgesChange(nonRemovals);
|
||||
}
|
||||
},
|
||||
[onEdgesChange, pattern.mode, graph, onGraphChange],
|
||||
);
|
||||
|
||||
const handleConnect: OnConnect = useCallback(
|
||||
(connection) => {
|
||||
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.source && connection.target) {
|
||||
const updatedGraph = addEdge(graph, connection.source, connection.target);
|
||||
onGraphChange(updatedGraph);
|
||||
}
|
||||
},
|
||||
[graph, pattern.mode, onGraphChange],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<GraphNodeData>) => {
|
||||
onNodeSelect(node.id);
|
||||
},
|
||||
[onNodeSelect],
|
||||
);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
onNodeSelect(null);
|
||||
}, [onNodeSelect]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const layouted = autoLayoutGraph(graph);
|
||||
onGraphChange(layouted);
|
||||
// Allow React to render the new positions before fitting
|
||||
requestAnimationFrame(() => fitView({ padding: 0.3 }));
|
||||
}, [graph, onGraphChange, fitView]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
|
||||
<ReactFlow
|
||||
nodes={nodes.map((n) => ({
|
||||
...n,
|
||||
selected: n.id === selectedNodeId,
|
||||
}))}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
nodeTypes={graphNodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
style: { stroke: '#245CF9', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#245CF9' },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#245CF9', strokeWidth: 1.5 }}
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
|
||||
<Panel position="top-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoLayout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
|
||||
title="Auto-layout nodes"
|
||||
>
|
||||
<LayoutGrid className="size-3.5" />
|
||||
Auto layout
|
||||
</button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternGraphCanvas(props: PatternGraphCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<PatternGraphCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Radio, Shuffle, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import type {
|
||||
OrchestrationMode,
|
||||
PatternAgentDefinition,
|
||||
PatternGraph,
|
||||
PatternGraphNodeKind,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
canMoveSequential,
|
||||
findAgentForNode,
|
||||
swapSequentialOrder,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
|
||||
|
||||
interface PatternGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
agents: PatternAgentDefinition[];
|
||||
graph: PatternGraph;
|
||||
mode: OrchestrationMode;
|
||||
selectedNodeId: string | null;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const base =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${base} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={base}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const kindIcons: Record<PatternGraphNodeKind, typeof Bot> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot,
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
};
|
||||
|
||||
const kindLabels: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'User Input',
|
||||
'user-output': 'User Output',
|
||||
distributor: 'Distributor',
|
||||
collector: 'Collector',
|
||||
orchestrator: 'Orchestrator',
|
||||
};
|
||||
|
||||
const kindDescriptions: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'Entry point — receives user messages.',
|
||||
'user-output': 'Exit point — returns final response to the user.',
|
||||
distributor: 'Fans user input to all agents in parallel.',
|
||||
collector: 'Aggregates parallel agent responses.',
|
||||
orchestrator: 'Manages group chat round-robin turns.',
|
||||
};
|
||||
|
||||
function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
|
||||
const Icon = kindIcons[kind];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-accent)]/10">
|
||||
<Icon className="size-4 text-[var(--color-accent-sky)]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">{kindLabels[kind]}</div>
|
||||
<span className="rounded bg-[var(--color-surface-3)]/50 px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
|
||||
System node
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-[var(--color-text-muted)]">{kindDescriptions[kind]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentNodeInspector({
|
||||
agent,
|
||||
availableModels,
|
||||
mode,
|
||||
graph,
|
||||
nodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
onGraphChange,
|
||||
}: {
|
||||
agent: PatternAgentDefinition;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
mode: OrchestrationMode;
|
||||
graph: PatternGraph;
|
||||
nodeId: string;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
}) {
|
||||
const model = findModel(agent.model, availableModels);
|
||||
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
|
||||
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
|
||||
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Bot className="size-4 text-[var(--color-text-secondary)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">{agent.name || 'Unnamed'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showReorder && (
|
||||
<>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-muted)]"
|
||||
disabled={!canUp}
|
||||
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'up'))}
|
||||
title="Move earlier in sequence"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-muted)]"
|
||||
disabled={!canDown}
|
||||
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'down'))}
|
||||
title="Move later in sequence"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-[var(--color-text-muted)] transition hover:text-red-400"
|
||||
onClick={() => onAgentRemove(agent.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => onAgentChange(agent.id, { name: v })}
|
||||
value={agent.name}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => {
|
||||
const m = findModel(value, availableModels);
|
||||
onAgentChange(agent.id, {
|
||||
model: value,
|
||||
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
|
||||
});
|
||||
}}
|
||||
value={agent.model}
|
||||
/>
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => onAgentChange(agent.id, { reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(model)}
|
||||
value={resolveReasoningEffort(model, agent.reasoningEffort)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => onAgentChange(agent.id, { description: v })}
|
||||
placeholder="What this agent does..."
|
||||
value={agent.description}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => onAgentChange(agent.id, { instructions: v })}
|
||||
placeholder="System prompt for this agent..."
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternGraphInspector({
|
||||
availableModels,
|
||||
agents,
|
||||
graph,
|
||||
mode,
|
||||
selectedNodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
onGraphChange,
|
||||
}: PatternGraphInspectorProps) {
|
||||
if (!selectedNodeId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
Select a node on the graph to inspect it
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const node = graph.nodes.find((n) => n.id === selectedNodeId);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.kind !== 'agent') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SystemNodeInspector kind={node.kind} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = findAgentForNode(selectedNodeId, graph, agents);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AgentNodeInspector
|
||||
agent={agent}
|
||||
availableModels={availableModels}
|
||||
mode={mode}
|
||||
graph={graph}
|
||||
nodeId={selectedNodeId}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentRemove={onAgentRemove}
|
||||
onGraphChange={onGraphChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FormField, TextInput, TextareaInput } from '@renderer/components/ui';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||
import { findModel, type ModelDefinition } from '@shared/domain/models';
|
||||
import { resolveReasoningEffort } from '@shared/domain/models';
|
||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent';
|
||||
import { ToolingEditorShell } from './ToolingEditorShell';
|
||||
import { Link2, Workflow } from 'lucide-react';
|
||||
|
||||
function validateWorkspaceAgent(agent: WorkspaceAgentDefinition): string | undefined {
|
||||
if (!agent.name.trim()) return 'Agent name is required.';
|
||||
if (!agent.model.trim()) return 'Model is required.';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function WorkspaceAgentEditor({
|
||||
agent,
|
||||
onChange,
|
||||
onBack,
|
||||
onSave,
|
||||
onDelete,
|
||||
availableModels,
|
||||
workflows,
|
||||
}: {
|
||||
agent: WorkspaceAgentDefinition;
|
||||
onChange: (agent: WorkspaceAgentDefinition) => void;
|
||||
onBack: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
workflows: WorkflowDefinition[];
|
||||
}) {
|
||||
const validationError = validateWorkspaceAgent(agent);
|
||||
const usages = findWorkspaceAgentUsages(agent.id, workflows);
|
||||
|
||||
return (
|
||||
<ToolingEditorShell
|
||||
disableSave={Boolean(validationError)}
|
||||
error={validationError}
|
||||
onBack={onBack}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
subtitle="Reusable agent definition"
|
||||
title={agent.name || 'Untitled Agent'}
|
||||
>
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
General
|
||||
</h4>
|
||||
<FormField label="Name" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange({ ...agent, name: value })}
|
||||
placeholder="e.g. Code Reviewer, Architect, QA Agent"
|
||||
value={agent.name}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
AI Configuration
|
||||
</h4>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(model) => {
|
||||
const m = findModel(model, availableModels);
|
||||
onChange({
|
||||
...agent,
|
||||
model,
|
||||
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
|
||||
});
|
||||
}}
|
||||
value={agent.model}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ReasoningEffortSelect
|
||||
onChange={(value) => onChange({ ...agent, reasoningEffort: value })}
|
||||
supportedEfforts={findModel(agent.model, availableModels)?.supportedReasoningEfforts}
|
||||
value={agent.reasoningEffort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Agent Identity
|
||||
</h4>
|
||||
<FormField label="Description">
|
||||
<TextareaInput
|
||||
onChange={(value) => onChange({ ...agent, description: value })}
|
||||
placeholder="A short description of this agent's role and purpose"
|
||||
rows={2}
|
||||
value={agent.description}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Instructions">
|
||||
<TextareaInput
|
||||
onChange={(value) => onChange({ ...agent, instructions: value })}
|
||||
placeholder="System instructions that define this agent's behavior"
|
||||
rows={8}
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
{usages.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Used By
|
||||
</h4>
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)]">
|
||||
{usages.map((usage) => (
|
||||
<div
|
||||
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
|
||||
key={usage.workflowId}
|
||||
>
|
||||
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[13px] text-[var(--color-text-secondary)]">
|
||||
{usage.workflowName}
|
||||
</span>
|
||||
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Referenced by {usages.length} workflow{usages.length === 1 ? '' : 's'}.
|
||||
Changes to this agent will affect all linked workflows.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</ToolingEditorShell>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import type { ReactNode } from 'react';
|
||||
export function FormField({
|
||||
label,
|
||||
required,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -16,6 +18,9 @@ export function FormField({
|
||||
{required && <span className="ml-1 text-[var(--color-status-warning)]">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
{description && (
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-[var(--color-text-muted)]">{description}</p>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { EdgeCondition, WorkflowConditionRule } from '@shared/domain/workflow';
|
||||
|
||||
type ConditionType = 'none' | 'always' | 'property' | 'message-type' | 'expression';
|
||||
|
||||
interface ConditionEditorProps {
|
||||
condition: EdgeCondition | undefined;
|
||||
onChange: (condition: EdgeCondition | undefined) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const OPERATOR_OPTIONS: { value: WorkflowConditionRule['operator']; label: string }[] = [
|
||||
{ value: 'equals', label: '=' },
|
||||
{ value: 'not-equals', label: '≠' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'gt', label: '>' },
|
||||
{ value: 'lt', label: '<' },
|
||||
{ value: 'regex', label: 'matches regex' },
|
||||
];
|
||||
|
||||
const inputClasses =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
const selectClasses =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
function resolveConditionType(condition: EdgeCondition | undefined): ConditionType {
|
||||
if (!condition) return 'none';
|
||||
return condition.type;
|
||||
}
|
||||
|
||||
function emptyRule(): WorkflowConditionRule {
|
||||
return { propertyPath: '', operator: 'equals', value: '' };
|
||||
}
|
||||
|
||||
function defaultConditionForType(type: ConditionType): EdgeCondition | undefined {
|
||||
switch (type) {
|
||||
case 'none':
|
||||
return undefined;
|
||||
case 'always':
|
||||
return { type: 'always' };
|
||||
case 'property':
|
||||
return { type: 'property', combinator: 'and', rules: [emptyRule()] };
|
||||
case 'message-type':
|
||||
return { type: 'message-type', typeName: '' };
|
||||
case 'expression':
|
||||
return { type: 'expression', expression: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Property rules editor ─────────────────────────────────── */
|
||||
|
||||
function PropertyRuleRow({
|
||||
rule,
|
||||
index,
|
||||
disabled,
|
||||
onRuleChange,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}: {
|
||||
rule: WorkflowConditionRule;
|
||||
index: number;
|
||||
disabled?: boolean;
|
||||
onRuleChange: (index: number, patch: Partial<WorkflowConditionRule>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
canRemove: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
className={inputClasses}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onRuleChange(index, { propertyPath: e.target.value })}
|
||||
placeholder="property.path"
|
||||
style={{ flex: 2 }}
|
||||
value={rule.propertyPath}
|
||||
/>
|
||||
<select
|
||||
className={selectClasses}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onRuleChange(index, { operator: e.target.value as WorkflowConditionRule['operator'] })
|
||||
}
|
||||
style={{ flex: 1.2 }}
|
||||
value={rule.operator}
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className={inputClasses}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onRuleChange(index, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
style={{ flex: 2 }}
|
||||
value={rule.value}
|
||||
/>
|
||||
{canRemove && (
|
||||
<button
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)] disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(index)}
|
||||
title="Remove rule"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyRulesEditor({
|
||||
condition,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
condition: Extract<EdgeCondition, { type: 'property' }>;
|
||||
disabled?: boolean;
|
||||
onChange: (condition: EdgeCondition) => void;
|
||||
}) {
|
||||
const rules = condition.rules;
|
||||
|
||||
const handleRuleChange = useCallback(
|
||||
(index: number, patch: Partial<WorkflowConditionRule>) => {
|
||||
const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r));
|
||||
onChange({ ...condition, rules: updated });
|
||||
},
|
||||
[rules, condition, onChange],
|
||||
);
|
||||
|
||||
const handleRemoveRule = useCallback(
|
||||
(index: number) => {
|
||||
onChange({ ...condition, rules: rules.filter((_, i) => i !== index) });
|
||||
},
|
||||
[rules, condition, onChange],
|
||||
);
|
||||
|
||||
const handleAddRule = useCallback(() => {
|
||||
onChange({ ...condition, rules: [...rules, emptyRule()] });
|
||||
}, [rules, condition, onChange]);
|
||||
|
||||
const handleCombinatorChange = useCallback(
|
||||
(combinator: 'and' | 'or') => {
|
||||
onChange({ ...condition, combinator });
|
||||
},
|
||||
[condition, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rules.length >= 2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">Combine</span>
|
||||
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<button
|
||||
className={`px-2.5 py-1 text-[11px] font-medium transition ${
|
||||
(condition.combinator ?? 'and') === 'and'
|
||||
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={() => handleCombinatorChange('and')}
|
||||
type="button"
|
||||
>
|
||||
AND
|
||||
</button>
|
||||
<button
|
||||
className={`px-2.5 py-1 text-[11px] font-medium transition ${
|
||||
condition.combinator === 'or'
|
||||
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={() => handleCombinatorChange('or')}
|
||||
type="button"
|
||||
>
|
||||
OR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.map((rule, i) => (
|
||||
<PropertyRuleRow
|
||||
canRemove={rules.length > 1}
|
||||
disabled={disabled}
|
||||
index={i}
|
||||
key={i}
|
||||
onRemove={handleRemoveRule}
|
||||
onRuleChange={handleRuleChange}
|
||||
rule={rule}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10 disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={handleAddRule}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main condition editor ─────────────────────────────────── */
|
||||
|
||||
export function ConditionEditor({ condition, onChange, disabled }: ConditionEditorProps) {
|
||||
const currentType = resolveConditionType(condition);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(type: ConditionType) => {
|
||||
onChange(defaultConditionForType(type));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Condition
|
||||
</span>
|
||||
<select
|
||||
className={selectClasses}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleTypeChange(e.target.value as ConditionType)}
|
||||
value={currentType}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="always">Always</option>
|
||||
<option value="property">Property Rule</option>
|
||||
<option value="message-type">Message Type</option>
|
||||
<option value="expression">Expression</option>
|
||||
</select>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Determines when this edge fires. Use property rules or expressions to create conditional branches.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{condition?.type === 'property' && (
|
||||
<PropertyRulesEditor condition={condition} disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
|
||||
{condition?.type === 'message-type' && (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">Type Name</span>
|
||||
<input
|
||||
className={inputClasses}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...condition, typeName: e.target.value })}
|
||||
placeholder="e.g. ApprovalResponse"
|
||||
value={condition.typeName}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{condition?.type === 'expression' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">Expression</span>
|
||||
<input
|
||||
className={`${inputClasses} font-mono`}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...condition, expression: e.target.value })}
|
||||
placeholder='e.g. result.status == "done"'
|
||||
value={condition.expression}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-start gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2.5 py-2">
|
||||
<Info className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Supported: ==, !=, >, <, contains, matches. Combine with && or ||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AlertCircle, FunctionSquare, Plus, Trash2, X } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
InvokeFunctionConfig,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
WorkflowValidationIssue,
|
||||
} from '@shared/domain/workflow';
|
||||
|
||||
interface InvokeFunctionInspectorProps {
|
||||
node: WorkflowNode;
|
||||
validationIssues?: WorkflowValidationIssue[];
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const base =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
<input
|
||||
className={base}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyValue(v: unknown): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return JSON.stringify(v) ?? '';
|
||||
}
|
||||
|
||||
export function InvokeFunctionInspector({
|
||||
node,
|
||||
validationIssues,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
}: InvokeFunctionInspectorProps) {
|
||||
const config = node.config as InvokeFunctionConfig;
|
||||
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
|
||||
const args = config.arguments ?? {};
|
||||
const argEntries = Object.entries(args);
|
||||
|
||||
const [newKey, setNewKey] = useState('');
|
||||
|
||||
const patchConfig = useCallback(
|
||||
(patch: Partial<InvokeFunctionConfig>) => {
|
||||
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||
},
|
||||
[node.id, config, onNodeConfigChange],
|
||||
);
|
||||
|
||||
const handleArgChange = useCallback(
|
||||
(oldKey: string, newArgKey: string, value: string) => {
|
||||
const next = { ...args };
|
||||
if (newArgKey !== oldKey) {
|
||||
delete next[oldKey];
|
||||
}
|
||||
next[newArgKey] = value;
|
||||
patchConfig({ arguments: next });
|
||||
},
|
||||
[args, patchConfig],
|
||||
);
|
||||
|
||||
const handleArgRemove = useCallback(
|
||||
(key: string) => {
|
||||
const next = { ...args };
|
||||
delete next[key];
|
||||
patchConfig({ arguments: next });
|
||||
},
|
||||
[args, patchConfig],
|
||||
);
|
||||
|
||||
const handleArgAdd = useCallback(() => {
|
||||
const key = newKey.trim() || `arg${argEntries.length + 1}`;
|
||||
patchConfig({ arguments: { ...args, [key]: '' } });
|
||||
setNewKey('');
|
||||
}, [newKey, argEntries.length, args, patchConfig]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-violet-500/10">
|
||||
<FunctionSquare className="size-4 text-violet-400" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{node.label || 'Function Tool'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
|
||||
{/* Function name */}
|
||||
<InputField
|
||||
label="Function Name"
|
||||
onChange={(v) => patchConfig({ functionName: v })}
|
||||
placeholder="e.g. GetUserData"
|
||||
value={config.functionName}
|
||||
/>
|
||||
|
||||
{/* Result variable */}
|
||||
<div className="space-y-1.5">
|
||||
<InputField
|
||||
label="Result Variable"
|
||||
onChange={(v) => patchConfig({ resultVariable: v || undefined })}
|
||||
placeholder="e.g. Local.result"
|
||||
value={config.resultVariable ?? ''}
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
State path where the return value is stored for use in subsequent steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Require approval */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Require Approval
|
||||
</span>
|
||||
<input
|
||||
checked={config.requireApproval === true}
|
||||
className="size-4 accent-[var(--color-accent)]"
|
||||
onChange={(e) => patchConfig({ requireApproval: e.target.checked || undefined })}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Pause and require human confirmation before invoking this function.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arguments editor */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Arguments
|
||||
</span>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
|
||||
onClick={handleArgAdd}
|
||||
title="Add argument"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{argEntries.length === 0 && (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">No arguments defined.</p>
|
||||
)}
|
||||
|
||||
{argEntries.map(([key, value]) => (
|
||||
<div className="flex items-center gap-1.5" key={key}>
|
||||
<input
|
||||
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => handleArgChange(key, e.target.value, stringifyValue(value))}
|
||||
placeholder="key"
|
||||
value={key}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => handleArgChange(key, key, e.target.value)}
|
||||
placeholder="value"
|
||||
value={stringifyValue(value)}
|
||||
/>
|
||||
<button
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => handleArgRemove(key)}
|
||||
title="Remove argument"
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Validation issues */}
|
||||
{nodeIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{nodeIssues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
MessageCircle,
|
||||
Repeat,
|
||||
Route,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type {
|
||||
HandoffModeSettings,
|
||||
GroupChatModeSettings,
|
||||
WorkflowDefinition,
|
||||
WorkflowNode,
|
||||
WorkflowOrchestrationMode,
|
||||
} from '@shared/domain/workflow';
|
||||
import {
|
||||
createDefaultModeSettings,
|
||||
inferWorkflowOrchestrationMode,
|
||||
isBuilderBasedMode,
|
||||
isGraphBasedMode,
|
||||
scaffoldGraphForMode,
|
||||
syncBuilderModeEdgeIterations,
|
||||
} from '@shared/domain/workflow';
|
||||
import { FormField, InfoCallout, SelectInput, ToggleSwitch } from '@renderer/components/ui';
|
||||
|
||||
/* ── Mode metadata ─────────────────────────────────────────── */
|
||||
|
||||
interface ModeOption {
|
||||
value: WorkflowOrchestrationMode;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Bot;
|
||||
accentClass: string;
|
||||
}
|
||||
|
||||
const modeOptions: ModeOption[] = [
|
||||
{
|
||||
value: 'single',
|
||||
label: 'Single Agent',
|
||||
description: 'One agent handles the full conversation directly.',
|
||||
icon: User,
|
||||
accentClass: 'text-emerald-400',
|
||||
},
|
||||
{
|
||||
value: 'sequential',
|
||||
label: 'Sequential',
|
||||
description: 'Agents execute in order, each seeing the full conversation history.',
|
||||
icon: ArrowRightLeft,
|
||||
accentClass: 'text-sky-400',
|
||||
},
|
||||
{
|
||||
value: 'concurrent',
|
||||
label: 'Concurrent',
|
||||
description: 'Agents execute in parallel via fan-out, results collected at barrier.',
|
||||
icon: Layers,
|
||||
accentClass: 'text-amber-400',
|
||||
},
|
||||
{
|
||||
value: 'handoff',
|
||||
label: 'Handoff',
|
||||
description: 'Triage agent routes requests to specialists via handoff tool calls.',
|
||||
icon: Route,
|
||||
accentClass: 'text-violet-400',
|
||||
},
|
||||
{
|
||||
value: 'group-chat',
|
||||
label: 'Group Chat',
|
||||
description: 'Agents take turns in a managed conversation loop.',
|
||||
icon: MessageCircle,
|
||||
accentClass: 'text-rose-400',
|
||||
},
|
||||
];
|
||||
|
||||
const modeMap = new Map(modeOptions.map((m) => [m.value, m]));
|
||||
|
||||
/* ── Scaffold confirmation dialog ──────────────────────────── */
|
||||
|
||||
function ScaffoldDialog({
|
||||
fromMode,
|
||||
toMode,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}: {
|
||||
fromMode: string;
|
||||
toMode: WorkflowOrchestrationMode;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
const target = modeMap.get(toMode);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="scaffold-dialog-title"
|
||||
>
|
||||
<div
|
||||
className="mx-4 w-full max-w-md animate-[palette-enter_0.2s_ease-out] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-2)] p-6 shadow-2xl"
|
||||
>
|
||||
<h3
|
||||
id="scaffold-dialog-title"
|
||||
className="mb-2 text-[14px] font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
Restructure graph for {target?.label ?? toMode}?
|
||||
</h3>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
This will update edges to match the <span className="font-medium text-[var(--color-text-primary)]">{target?.label ?? toMode}</span> topology while keeping your existing agents.
|
||||
{fromMode && (
|
||||
<span className="mt-1 block text-[12px] text-[var(--color-text-muted)]">
|
||||
Changing from {fromMode}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecline}
|
||||
className="rounded-lg px-4 py-2 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
Keep current graph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
className="rounded-lg bg-[var(--color-accent)] px-4 py-2 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-hover)]"
|
||||
>
|
||||
Restructure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Mode selector card ────────────────────────────────────── */
|
||||
|
||||
function ModeCard({
|
||||
option,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
option: ModeOption;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-all duration-200 ${
|
||||
selected
|
||||
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)] shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_16px_rgba(36,92,249,0.06)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-1)] hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-2)]'
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg ${
|
||||
selected ? 'bg-[var(--color-accent)]/15' : 'bg-[var(--color-surface-3)]'
|
||||
}`}>
|
||||
<Icon className={`size-3.5 ${selected ? 'text-[var(--color-accent)]' : option.accentClass}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-medium ${
|
||||
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]'
|
||||
}`}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Handoff settings sub-panel ────────────────────────────── */
|
||||
|
||||
function HandoffSettingsPanel({
|
||||
settings,
|
||||
agentNodes,
|
||||
onChange,
|
||||
}: {
|
||||
settings: HandoffModeSettings;
|
||||
agentNodes: WorkflowNode[];
|
||||
onChange: (settings: HandoffModeSettings) => void;
|
||||
}) {
|
||||
const triageOptions = useMemo(() => [
|
||||
{ value: '', label: 'First agent (default)' },
|
||||
...agentNodes.map((node) => ({
|
||||
value: node.id,
|
||||
label: node.label || (node.config.kind === 'agent' ? node.config.name : node.id),
|
||||
})),
|
||||
], [agentNodes]);
|
||||
|
||||
const filteringOptions = [
|
||||
{ value: 'none', label: 'None — All tools available' },
|
||||
{ value: 'handoff-only', label: 'Handoff-only — Restrict to handoff tools' },
|
||||
{ value: 'all', label: 'All — Full tool filtering' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Route className="size-3.5 text-violet-400" />
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Handoff Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField label="Triage Agent" description="The entry-point agent that receives requests and decides which specialist to hand off to.">
|
||||
<SelectInput
|
||||
value={settings.triageAgentNodeId ?? ''}
|
||||
options={triageOptions}
|
||||
onChange={(v) => onChange({ ...settings, triageAgentNodeId: v || undefined })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Tool-Call Filtering" description="Controls which tools each agent can see and invoke during handoff routing.">
|
||||
<SelectInput
|
||||
value={settings.toolCallFiltering}
|
||||
options={filteringOptions}
|
||||
onChange={(v) => onChange({ ...settings, toolCallFiltering: v as HandoffModeSettings['toolCallFiltering'] })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Return to Previous</div>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">Allow specialists to hand back to the previous agent</p>
|
||||
</div>
|
||||
<button type="button" className="cursor-pointer" onClick={() => onChange({ ...settings, returnToPrevious: !settings.returnToPrevious })}>
|
||||
<ToggleSwitch enabled={settings.returnToPrevious} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormField label="Custom Handoff Instructions" description="Additional guidance given to agents when performing handoffs between specialists.">
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
|
||||
rows={3}
|
||||
placeholder="Override default handoff guidance (optional)"
|
||||
value={settings.handoffInstructions ?? ''}
|
||||
onChange={(e) => onChange({ ...settings, handoffInstructions: e.target.value || undefined })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Group-chat settings sub-panel ─────────────────────────── */
|
||||
|
||||
function GroupChatSettingsPanel({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: GroupChatModeSettings;
|
||||
onChange: (settings: GroupChatModeSettings) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="size-3.5 text-rose-400" />
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Group Chat Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField label="Selection Strategy" description="Determines which agent speaks next in each conversation turn.">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
|
||||
<Repeat className="size-3.5 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[13px] text-[var(--color-text-primary)]">Round Robin</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">Only strategy</span>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Max Rounds" description="Maximum number of conversation turns across all agents before the group chat terminates.">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
|
||||
placeholder="5"
|
||||
value={settings.maxRounds}
|
||||
onChange={(e) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
onChange({ ...settings, maxRounds: Number.isNaN(raw) ? 5 : Math.max(1, Math.min(100, raw)) });
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main orchestration mode panel ─────────────────────────── */
|
||||
|
||||
export function OrchestrationModePanel({
|
||||
workflow,
|
||||
onChange,
|
||||
}: {
|
||||
workflow: WorkflowDefinition;
|
||||
onChange: (workflow: WorkflowDefinition) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [scaffoldRequest, setScaffoldRequest] = useState<{
|
||||
newMode: WorkflowOrchestrationMode;
|
||||
fromLabel: string;
|
||||
} | null>(null);
|
||||
|
||||
const currentMode = workflow.settings.orchestrationMode;
|
||||
const inferredMode = useMemo(() => inferWorkflowOrchestrationMode(workflow), [workflow]);
|
||||
const effectiveMode = currentMode ?? inferredMode;
|
||||
const effectiveMeta = modeMap.get(effectiveMode);
|
||||
|
||||
const agentNodes = useMemo(
|
||||
() => workflow.graph.nodes.filter((n) => n.kind === 'agent'),
|
||||
[workflow.graph.nodes],
|
||||
);
|
||||
|
||||
const handoffSettings: HandoffModeSettings = useMemo(
|
||||
() => workflow.settings.modeSettings?.handoff ?? createDefaultModeSettings('handoff')!.handoff!,
|
||||
[workflow.settings.modeSettings?.handoff],
|
||||
);
|
||||
|
||||
const groupChatSettings: GroupChatModeSettings = useMemo(
|
||||
() => workflow.settings.modeSettings?.groupChat ?? createDefaultModeSettings('group-chat')!.groupChat!,
|
||||
[workflow.settings.modeSettings?.groupChat],
|
||||
);
|
||||
|
||||
const applyModeChange = useCallback(
|
||||
(newMode: WorkflowOrchestrationMode, restructure: boolean) => {
|
||||
const modeSettings = createDefaultModeSettings(newMode);
|
||||
const mergedSettings = {
|
||||
...workflow.settings,
|
||||
orchestrationMode: newMode,
|
||||
modeSettings: modeSettings
|
||||
? { ...workflow.settings.modeSettings, ...modeSettings }
|
||||
: workflow.settings.modeSettings,
|
||||
};
|
||||
|
||||
// Sync maxIterations from mode-specific settings for builder-based modes
|
||||
if (newMode === 'group-chat' && modeSettings?.groupChat) {
|
||||
mergedSettings.maxIterations = modeSettings.groupChat.maxRounds;
|
||||
}
|
||||
|
||||
const base: WorkflowDefinition = { ...workflow, settings: mergedSettings };
|
||||
|
||||
if (restructure) {
|
||||
const existingAgents = workflow.graph.nodes.filter((n) => n.kind === 'agent');
|
||||
const newGraph = scaffoldGraphForMode(newMode, {
|
||||
agentNodes: existingAgents.length > 0 ? existingAgents : undefined,
|
||||
settings: mergedSettings,
|
||||
});
|
||||
onChange({ ...base, graph: newGraph });
|
||||
} else {
|
||||
onChange(syncBuilderModeEdgeIterations(base));
|
||||
}
|
||||
},
|
||||
[workflow, onChange],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(newMode: WorkflowOrchestrationMode) => {
|
||||
if (newMode === currentMode) return;
|
||||
|
||||
const hasAgents = agentNodes.length > 0;
|
||||
const fromLabel = currentMode
|
||||
? (modeMap.get(currentMode)?.label ?? currentMode)
|
||||
: `Auto (${modeMap.get(inferredMode)?.label ?? inferredMode})`;
|
||||
|
||||
if (hasAgents) {
|
||||
setScaffoldRequest({ newMode, fromLabel });
|
||||
} else {
|
||||
applyModeChange(newMode, true);
|
||||
}
|
||||
},
|
||||
[currentMode, inferredMode, agentNodes.length, applyModeChange],
|
||||
);
|
||||
|
||||
const handleHandoffChange = useCallback(
|
||||
(handoff: HandoffModeSettings) => {
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
modeSettings: { ...workflow.settings.modeSettings, handoff },
|
||||
},
|
||||
});
|
||||
},
|
||||
[workflow, onChange],
|
||||
);
|
||||
|
||||
const handleGroupChatChange = useCallback(
|
||||
(groupChat: GroupChatModeSettings) => {
|
||||
const updated: WorkflowDefinition = {
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
modeSettings: { ...workflow.settings.modeSettings, groupChat },
|
||||
maxIterations: groupChat.maxRounds,
|
||||
},
|
||||
};
|
||||
onChange(syncBuilderModeEdgeIterations(updated));
|
||||
},
|
||||
[workflow, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Section header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center justify-between"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{!currentMode && (
|
||||
<span className="rounded-md bg-[var(--color-surface-3)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
{effectiveMeta && (
|
||||
<span className={`text-[11px] font-medium ${effectiveMeta.accentClass}`}>
|
||||
{effectiveMeta.label}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`size-3.5 text-[var(--color-text-muted)] transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-4">
|
||||
{/* Mode cards */}
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{modeOptions.map((option) => (
|
||||
<ModeCard
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={effectiveMode === option.value}
|
||||
onClick={() => handleModeSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auto-inference indicator */}
|
||||
{!currentMode && (
|
||||
<InfoCallout>
|
||||
Mode is auto-inferred from graph shape as <span className="font-medium text-[var(--color-text-primary)]">{effectiveMeta?.label}</span>.
|
||||
Select a mode explicitly to lock it.
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Builder-based mode info */}
|
||||
{isBuilderBasedMode(effectiveMode) && (
|
||||
<InfoCallout>
|
||||
In {effectiveMeta?.label} mode, the framework manages agent routing automatically. The graph defines participants.
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Graph-based mode info */}
|
||||
{isGraphBasedMode(effectiveMode) && currentMode && (
|
||||
<InfoCallout>
|
||||
This mode uses the graph topology for execution. Connect agents with the appropriate edge types.
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Mode-specific settings */}
|
||||
{effectiveMode === 'handoff' && (
|
||||
<HandoffSettingsPanel
|
||||
settings={handoffSettings}
|
||||
agentNodes={agentNodes}
|
||||
onChange={handleHandoffChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveMode === 'group-chat' && (
|
||||
<GroupChatSettingsPanel
|
||||
settings={groupChatSettings}
|
||||
onChange={handleGroupChatChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scaffold confirmation dialog */}
|
||||
{scaffoldRequest && (
|
||||
<ScaffoldDialog
|
||||
fromMode={scaffoldRequest.fromLabel}
|
||||
toMode={scaffoldRequest.newMode}
|
||||
onAccept={() => {
|
||||
applyModeChange(scaffoldRequest.newMode, true);
|
||||
setScaffoldRequest(null);
|
||||
}}
|
||||
onDecline={() => {
|
||||
applyModeChange(scaffoldRequest.newMode, false);
|
||||
setScaffoldRequest(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback } from 'react';
|
||||
import { AlertCircle, Radio, Trash2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
RequestPortConfig,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
WorkflowValidationIssue,
|
||||
} from '@shared/domain/workflow';
|
||||
|
||||
interface RequestPortInspectorProps {
|
||||
node: WorkflowNode;
|
||||
validationIssues?: WorkflowValidationIssue[];
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const base =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${base} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={base}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function RequestPortInspector({
|
||||
node,
|
||||
validationIssues,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
}: RequestPortInspectorProps) {
|
||||
const config = node.config as RequestPortConfig;
|
||||
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
|
||||
|
||||
const patchConfig = useCallback(
|
||||
(patch: Partial<RequestPortConfig>) => {
|
||||
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||
},
|
||||
[node.id, config, onNodeConfigChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-teal-500/10">
|
||||
<Radio className="size-4 text-teal-400" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{node.label || 'Request Port'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
|
||||
{/* Port ID */}
|
||||
<div className="space-y-1.5">
|
||||
<InputField
|
||||
label="Port ID"
|
||||
onChange={(v) => patchConfig({ portId: v })}
|
||||
placeholder="Unique port identifier"
|
||||
value={config.portId}
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Unique identifier used to target this port from parent workflows or external callers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Request Type */}
|
||||
<div className="space-y-1.5">
|
||||
<InputField
|
||||
label="Request Type"
|
||||
onChange={(v) => patchConfig({ requestType: v })}
|
||||
placeholder="e.g. string, boolean, number, json"
|
||||
value={config.requestType}
|
||||
/>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Supported types: string, boolean, number, json
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Response Type */}
|
||||
<div className="space-y-1.5">
|
||||
<InputField
|
||||
label="Response Type"
|
||||
onChange={(v) => patchConfig({ responseType: v })}
|
||||
placeholder="e.g. string, boolean, number, json"
|
||||
value={config.responseType}
|
||||
/>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Supported types: string, boolean, number, json
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<InputField
|
||||
label="Prompt"
|
||||
multiline
|
||||
onChange={(v) => patchConfig({ prompt: v || undefined })}
|
||||
placeholder="Optional question shown to the user"
|
||||
value={config.prompt ?? ''}
|
||||
/>
|
||||
|
||||
{/* Info callout */}
|
||||
<div className="rounded-lg border border-teal-500/20 bg-teal-500/5 px-3 py-2 text-[12px] text-teal-400">
|
||||
This node pauses workflow execution and requests input from the user. The response is
|
||||
coerced to the specified response type before continuing.
|
||||
</div>
|
||||
|
||||
{/* Validation issues */}
|
||||
{nodeIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{nodeIssues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ExternalLink, GitBranch, Link, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
SubWorkflowConfig,
|
||||
WorkflowDefinition,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
} from '@shared/domain/workflow';
|
||||
|
||||
interface SubWorkflowInspectorProps {
|
||||
node: WorkflowNode;
|
||||
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||
currentWorkflowId: string;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
|
||||
}
|
||||
|
||||
type SourceMode = 'reference' | 'inline';
|
||||
|
||||
function resolveSourceMode(config: SubWorkflowConfig): SourceMode {
|
||||
if (config.workflowId) return 'reference';
|
||||
if (config.inlineWorkflow) return 'inline';
|
||||
return 'reference';
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubWorkflowInspector({
|
||||
node,
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
onDrillIntoSubWorkflow,
|
||||
}: SubWorkflowInspectorProps) {
|
||||
const config = node.config as SubWorkflowConfig;
|
||||
const sourceMode = resolveSourceMode(config);
|
||||
|
||||
const availableWorkflows = workflows.filter((wf) => wf.id !== currentWorkflowId);
|
||||
const selectedWorkflow = config.workflowId
|
||||
? workflows.find((wf) => wf.id === config.workflowId)
|
||||
: undefined;
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: SourceMode) => {
|
||||
if (mode === 'reference') {
|
||||
onNodeConfigChange(node.id, { kind: 'sub-workflow', workflowId: config.workflowId });
|
||||
} else {
|
||||
onNodeConfigChange(node.id, {
|
||||
kind: 'sub-workflow',
|
||||
inlineWorkflow: config.inlineWorkflow,
|
||||
});
|
||||
}
|
||||
},
|
||||
[node.id, config.workflowId, config.inlineWorkflow, onNodeConfigChange],
|
||||
);
|
||||
|
||||
const handleWorkflowSelect = useCallback(
|
||||
(workflowId: string) => {
|
||||
onNodeConfigChange(node.id, {
|
||||
kind: 'sub-workflow',
|
||||
workflowId: workflowId || undefined,
|
||||
});
|
||||
},
|
||||
[node.id, onNodeConfigChange],
|
||||
);
|
||||
|
||||
const handleDrillIn = useCallback(() => {
|
||||
onDrillIntoSubWorkflow(node);
|
||||
}, [node, onDrillIntoSubWorkflow]);
|
||||
|
||||
const inlineNodeCount = config.inlineWorkflow?.graph?.nodes?.length ?? 0;
|
||||
const inlineEdgeCount = config.inlineWorkflow?.graph?.edges?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<GitBranch className="size-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{node.label || 'Sub-Workflow'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
|
||||
{/* Source mode selector */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Source</span>
|
||||
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-0.5">
|
||||
<button
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||
sourceMode === 'reference'
|
||||
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => handleModeChange('reference')}
|
||||
type="button"
|
||||
>
|
||||
Reference
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||
sourceMode === 'inline'
|
||||
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => handleModeChange('inline')}
|
||||
type="button"
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference mode */}
|
||||
{sourceMode === 'reference' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Workflow
|
||||
</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => handleWorkflowSelect(e.target.value)}
|
||||
value={config.workflowId ?? ''}
|
||||
>
|
||||
<option value="">Select a workflow…</option>
|
||||
{availableWorkflows.map((wf) => (
|
||||
<option key={wf.id} value={wf.id}>
|
||||
{wf.name || 'Untitled'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedWorkflow?.description && (
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{selectedWorkflow.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!config.workflowId || !selectedWorkflow}
|
||||
onClick={handleDrillIn}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Open Sub-Workflow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline mode */}
|
||||
{sourceMode === 'inline' && (
|
||||
<div className="space-y-3">
|
||||
{config.inlineWorkflow ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-[12px] text-amber-400">
|
||||
<Link className="size-3 shrink-0" />
|
||||
<span>
|
||||
{inlineNodeCount} node{inlineNodeCount !== 1 ? 's' : ''},{' '}
|
||||
{inlineEdgeCount} edge{inlineEdgeCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
No inline workflow configured yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10"
|
||||
onClick={handleDrillIn}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit Inline Workflow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy, Download, Upload, X } from 'lucide-react';
|
||||
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
|
||||
type ExportFormat = 'yaml' | 'mermaid' | 'dot';
|
||||
type ImportFormat = 'yaml' | 'json';
|
||||
|
||||
/* ── Export Modal ──────────────────────────────────────────── */
|
||||
|
||||
interface ExportModalProps {
|
||||
format: ExportFormat;
|
||||
content: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportModal({ format, content, onClose }: ExportModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
void navigator.clipboard.writeText(content).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="export-modal-title"
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)] p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 id="export-modal-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
Export — {format.toUpperCase()}
|
||||
</h3>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre className="max-h-80 overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4 font-mono text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{content}
|
||||
</pre>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Import Modal ──────────────────────────────────────────── */
|
||||
|
||||
interface ImportModalProps {
|
||||
onImport: (content: string, format: ImportFormat) => Promise<WorkflowDefinition>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportModal({ onImport, onClose }: ImportModalProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [format, setFormat] = useState<ImportFormat>('yaml');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
async function handleImport() {
|
||||
if (!content.trim()) {
|
||||
setError('Please paste workflow content');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setImporting(true);
|
||||
try {
|
||||
await onImport(content, format);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="import-modal-title"
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)] p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 id="import-modal-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
Import Workflow
|
||||
</h3>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
{(['yaml', 'json'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||
format === f
|
||||
? 'bg-[var(--color-accent)] text-white'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
onClick={() => setFormat(f)}
|
||||
type="button"
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="min-h-48 w-full resize-y rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4 font-mono text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`Paste your ${format.toUpperCase()} workflow definition here...`}
|
||||
value={content}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)] disabled:opacity-50"
|
||||
disabled={importing || !content.trim()}
|
||||
onClick={handleImport}
|
||||
type="button"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{importing ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Export Dropdown ───────────────────────────────────────── */
|
||||
|
||||
interface ExportDropdownProps {
|
||||
onSelectFormat: (format: ExportFormat) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportDropdown({ onSelectFormat, onClose }: ExportDropdownProps) {
|
||||
const formats: { value: ExportFormat; label: string }[] = [
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'mermaid', label: 'Mermaid' },
|
||||
{ value: 'dot', label: 'DOT (Graphviz)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 w-40 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] py-1 shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => { onSelectFormat(f.value); onClose(); }}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
type="button"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type OnConnect,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { LayoutGrid, Maximize2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
|
||||
import type { WorkflowDefinition, WorkflowGraph, WorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import { inferWorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import {
|
||||
addWorkflowEdge,
|
||||
autoLayoutWorkflowGraph,
|
||||
fromCanvasPositions,
|
||||
isWorkflowConnectionAllowed,
|
||||
removeWorkflowEdge,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
type WorkflowGraphNodeData,
|
||||
} from '@renderer/lib/workflowGraph';
|
||||
|
||||
import { workflowNodeTypes } from './WorkflowGraphNodes';
|
||||
import { workflowEdgeTypes } from './WorkflowGraphEdges';
|
||||
|
||||
interface WorkflowGraphCanvasProps {
|
||||
workflow: WorkflowDefinition;
|
||||
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||
workflows?: ReadonlyArray<WorkflowDefinition>;
|
||||
onGraphChange: (graph: WorkflowGraph) => void;
|
||||
onNodeSelect: (nodeId: string | null) => void;
|
||||
onEdgeSelect: (edgeId: string | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
}
|
||||
|
||||
function WorkflowGraphCanvasInner({
|
||||
workflow,
|
||||
availableModels,
|
||||
workflows,
|
||||
onGraphChange,
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
selectedNodeId,
|
||||
}: WorkflowGraphCanvasProps) {
|
||||
const reactFlow = useReactFlow();
|
||||
const { fitView } = reactFlow;
|
||||
const graph = workflow.graph;
|
||||
const draggingRef = useRef(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const modeBadge = useMemo(() => {
|
||||
const mode = inferWorkflowOrchestrationMode(workflow);
|
||||
const explicit = !!workflow.settings.orchestrationMode;
|
||||
const meta: Record<WorkflowOrchestrationMode, { label: string; icon: string; color: string }> = {
|
||||
single: { label: 'Single', icon: '●', color: 'text-emerald-400' },
|
||||
sequential: { label: 'Sequential', icon: '→', color: 'text-sky-400' },
|
||||
concurrent: { label: 'Concurrent', icon: '⇉', color: 'text-amber-400' },
|
||||
handoff: { label: 'Handoff', icon: '⇢', color: 'text-violet-400' },
|
||||
'group-chat': { label: 'Group Chat', icon: '⟳', color: 'text-rose-400' },
|
||||
};
|
||||
return { mode, explicit, ...meta[mode] };
|
||||
}, [workflow]);
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
||||
toCanvasNodes(graph, availableModels, workflows),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
|
||||
toCanvasEdges(graph),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(toCanvasNodes(graph, availableModels, workflows));
|
||||
setEdges(toCanvasEdges(graph));
|
||||
}, [graph, availableModels, workflows, setNodes, setEdges]);
|
||||
|
||||
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
|
||||
(changes) => {
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
|
||||
if (removals.length > 0) {
|
||||
let updatedGraph = graph;
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
const node = graph.nodes.find((n) => n.id === removal.id);
|
||||
if (node && node.kind !== 'start' && node.kind !== 'end') {
|
||||
updatedGraph = {
|
||||
...updatedGraph,
|
||||
nodes: updatedGraph.nodes.filter((n) => n.id !== removal.id),
|
||||
edges: updatedGraph.edges.filter((e) => e.source !== removal.id && e.target !== removal.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updatedGraph !== graph) {
|
||||
onGraphChange(updatedGraph);
|
||||
onNodeSelect(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonRemovals.length > 0) {
|
||||
onNodesChangeBase(nonRemovals);
|
||||
}
|
||||
|
||||
const hasDragStart = nonRemovals.some(
|
||||
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
|
||||
);
|
||||
const hasDragStop = nonRemovals.some(
|
||||
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
|
||||
);
|
||||
|
||||
if (hasDragStart) {
|
||||
draggingRef.current = true;
|
||||
}
|
||||
|
||||
if (hasDragStop && draggingRef.current) {
|
||||
draggingRef.current = false;
|
||||
setNodes((currentNodes) => {
|
||||
const updatedGraph = fromCanvasPositions(workflow, currentNodes);
|
||||
onGraphChange(updatedGraph);
|
||||
return currentNodes;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onNodesChangeBase, workflow, graph, onGraphChange, onNodeSelect, setNodes],
|
||||
);
|
||||
|
||||
const handleEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
if (removals.length > 0) {
|
||||
let updatedGraph = graph;
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
updatedGraph = removeWorkflowEdge(updatedGraph, removal.id);
|
||||
}
|
||||
}
|
||||
onGraphChange(updatedGraph);
|
||||
onEdgeSelect(null);
|
||||
}
|
||||
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
if (nonRemovals.length > 0) {
|
||||
onEdgesChangeBase(nonRemovals);
|
||||
}
|
||||
},
|
||||
[onEdgesChangeBase, graph, onGraphChange, onEdgeSelect],
|
||||
);
|
||||
|
||||
const handleConnect: OnConnect = useCallback(
|
||||
(connection) => {
|
||||
if (!isWorkflowConnectionAllowed(connection, graph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.source && connection.target) {
|
||||
const updatedGraph = addWorkflowEdge(graph, connection.source, connection.target);
|
||||
onGraphChange(updatedGraph);
|
||||
}
|
||||
},
|
||||
[graph, onGraphChange],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<WorkflowGraphNodeData>) => {
|
||||
onNodeSelect(node.id);
|
||||
onEdgeSelect(null);
|
||||
},
|
||||
[onNodeSelect, onEdgeSelect],
|
||||
);
|
||||
|
||||
const handleEdgeClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
onEdgeSelect(edge.id);
|
||||
onNodeSelect(null);
|
||||
},
|
||||
[onEdgeSelect, onNodeSelect],
|
||||
);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
onNodeSelect(null);
|
||||
onEdgeSelect(null);
|
||||
}, [onNodeSelect, onEdgeSelect]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const layouted = autoLayoutWorkflowGraph(graph);
|
||||
onGraphChange(layouted);
|
||||
requestAnimationFrame(() => fitView({ padding: 0.3 }));
|
||||
}, [graph, onGraphChange, fitView]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
reactFlow.zoomIn();
|
||||
}, [reactFlow]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
reactFlow.zoomOut();
|
||||
}, [reactFlow]);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
fitView({ padding: 0.3 });
|
||||
}, [fitView]);
|
||||
|
||||
// Ctrl+A to select all nodes
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
|
||||
e.preventDefault();
|
||||
setNodes((currentNodes) =>
|
||||
currentNodes.map((n) => ({ ...n, selected: true })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [setNodes]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
|
||||
<ReactFlow
|
||||
nodes={nodes.map((n) => ({
|
||||
...n,
|
||||
selected: n.id === selectedNodeId,
|
||||
}))}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
onMoveEnd={() => setZoomLevel(reactFlow.getZoom())}
|
||||
nodeTypes={workflowNodeTypes}
|
||||
edgeTypes={workflowEdgeTypes}
|
||||
selectionOnDrag
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
style: { stroke: '#6366f1', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
|
||||
<Panel position="top-left">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 shadow-sm backdrop-blur">
|
||||
<span className={`text-[12px] font-semibold leading-none ${modeBadge.color}`}>
|
||||
{modeBadge.icon}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{modeBadge.label}
|
||||
</span>
|
||||
{!modeBadge.explicit && (
|
||||
<span className="rounded bg-[var(--color-surface-3)] px-1.5 py-px text-[10px] text-[var(--color-text-muted)]">
|
||||
auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<MiniMap
|
||||
className="!rounded-lg !border !border-[var(--color-border)] !bg-[var(--color-surface-1)]"
|
||||
maskColor="rgba(0,0,0,0.6)"
|
||||
nodeColor="#3f3f46"
|
||||
/>
|
||||
<Panel position="top-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoLayout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
|
||||
title="Auto-layout nodes"
|
||||
>
|
||||
<LayoutGrid className="size-3.5" />
|
||||
Auto layout
|
||||
</button>
|
||||
</Panel>
|
||||
<Panel position="bottom-right">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 p-1 shadow-sm backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomOut}
|
||||
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-3.5" />
|
||||
</button>
|
||||
<span className="min-w-[3rem] text-center text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomIn}
|
||||
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-3.5" />
|
||||
</button>
|
||||
<div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFitView}
|
||||
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
title="Fit view"
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowGraphCanvas(props: WorkflowGraphCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowGraphCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BaseEdge, type EdgeProps } from '@xyflow/react';
|
||||
|
||||
function SelfLoopEdge({ id, sourceX, sourceY, style, markerEnd }: EdgeProps) {
|
||||
const loopHeight = 60;
|
||||
const loopWidth = 40;
|
||||
|
||||
const path = `M ${sourceX} ${sourceY} C ${sourceX + loopWidth} ${sourceY - loopHeight}, ${sourceX - loopWidth} ${sourceY - loopHeight}, ${sourceX} ${sourceY}`;
|
||||
|
||||
return <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />;
|
||||
}
|
||||
|
||||
export const workflowEdgeTypes = {
|
||||
selfLoop: SelfLoopEdge,
|
||||
} as const;
|
||||
@@ -0,0 +1,526 @@
|
||||
import { useCallback } from 'react';
|
||||
import { AlertCircle, Bot, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import type {
|
||||
EdgeCondition,
|
||||
WorkflowDefinition,
|
||||
WorkflowEdge,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
WorkflowOrchestrationMode,
|
||||
WorkflowValidationIssue,
|
||||
AgentNodeConfig,
|
||||
} from '@shared/domain/workflow';
|
||||
import { isBuilderBasedMode } from '@shared/domain/workflow';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||
import { InvokeFunctionInspector } from '@renderer/components/workflow/InvokeFunctionInspector';
|
||||
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
||||
import { RequestPortInspector } from '@renderer/components/workflow/RequestPortInspector';
|
||||
import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector';
|
||||
|
||||
interface WorkflowGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
workflow: WorkflowDefinition;
|
||||
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
validationIssues?: WorkflowValidationIssue[];
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||
onEdgeRemove: (edgeId: string) => void;
|
||||
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const base =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${base} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={base}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Agent node inspector ──────────────────────────────────── */
|
||||
|
||||
function AgentNodeInspector({
|
||||
node,
|
||||
availableModels,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
}: {
|
||||
node: WorkflowNode;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
}) {
|
||||
const config = node.config as AgentNodeConfig;
|
||||
const model = findModel(config.model, availableModels);
|
||||
|
||||
function patchConfig(patch: Partial<AgentNodeConfig>) {
|
||||
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Bot className="size-4 text-[var(--color-text-primary)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{config.name || 'Unnamed Agent'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
<InputField
|
||||
label="Agent Name"
|
||||
onChange={(v) => patchConfig({ name: v })}
|
||||
placeholder="Agent name"
|
||||
value={config.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => patchConfig({ description: v })}
|
||||
placeholder="What this agent does"
|
||||
value={config.description}
|
||||
/>
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => patchConfig({ instructions: v })}
|
||||
placeholder="System instructions for this agent"
|
||||
value={config.instructions}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => {
|
||||
const m = findModel(value, availableModels);
|
||||
patchConfig({
|
||||
model: value,
|
||||
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
|
||||
});
|
||||
}}
|
||||
value={config.model}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => patchConfig({ reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(model)}
|
||||
value={config.reasoningEffort}
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── System node inspector ─────────────────────────────────── */
|
||||
|
||||
function SystemNodeInspector({ node }: { node: WorkflowNode }) {
|
||||
const kindLabels: Record<string, string> = {
|
||||
start: 'Start Node',
|
||||
end: 'End Node',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Info className="size-4 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{kindLabels[node.kind] ?? node.kind}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
System node — cannot be edited or removed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Placeholder inspector for future node kinds ───────────── */
|
||||
|
||||
const placeholderIcons: Record<string, typeof FunctionSquare> = {
|
||||
'sub-workflow': GitBranch,
|
||||
'request-port': Radio,
|
||||
};
|
||||
|
||||
function PlaceholderNodeInspector({
|
||||
node,
|
||||
onNodeChange,
|
||||
onNodeRemove,
|
||||
}: {
|
||||
node: WorkflowNode;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
}) {
|
||||
const Icon = placeholderIcons[node.kind] ?? Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Icon className="size-4 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{node.label || node.kind}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
<div className="rounded-lg border border-[var(--color-status-warning)]/20 bg-[var(--color-status-warning)]/5 px-3 py-2 text-[12px] text-[var(--color-status-warning)]">
|
||||
Full configuration for this node type is not yet available.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Edge inspector ────────────────────────────────────────── */
|
||||
|
||||
function EdgeInspector({
|
||||
edge,
|
||||
orchestrationMode,
|
||||
validationIssues,
|
||||
onEdgeChange,
|
||||
onEdgeRemove,
|
||||
}: {
|
||||
edge: WorkflowEdge;
|
||||
orchestrationMode?: WorkflowOrchestrationMode;
|
||||
validationIssues?: WorkflowValidationIssue[];
|
||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||
onEdgeRemove: (edgeId: string) => void;
|
||||
}) {
|
||||
const isFanIn = edge.kind === 'fan-in';
|
||||
const builderMode = isBuilderBasedMode(orchestrationMode);
|
||||
const edgeIssues = validationIssues?.filter((i) => i.edgeId === edge.id) ?? [];
|
||||
|
||||
const handleConditionChange = useCallback(
|
||||
(condition: EdgeCondition | undefined) => {
|
||||
onEdgeChange(edge.id, { condition });
|
||||
},
|
||||
[edge.id, onEdgeChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">Edge</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onEdgeRemove(edge.id)}
|
||||
title="Remove edge"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Kind</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => onEdgeChange(edge.id, { kind: e.target.value as WorkflowEdge['kind'] })}
|
||||
value={edge.kind}
|
||||
>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="fan-out">Fan-out</option>
|
||||
<option value="fan-in">Fan-in</option>
|
||||
</select>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
Direct connects nodes sequentially. Fan-out splits into parallel branches. Fan-in waits for all branches to complete.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* Loop controls */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Loop Edge
|
||||
</span>
|
||||
<input
|
||||
checked={edge.isLoop === true}
|
||||
className="size-4 accent-[var(--color-accent)]"
|
||||
disabled={builderMode}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onEdgeChange(edge.id, { isLoop: true, maxIterations: edge.maxIterations ?? 10 });
|
||||
} else {
|
||||
onEdgeChange(edge.id, { isLoop: undefined, maxIterations: undefined });
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{builderMode
|
||||
? 'Loop edges are managed by the orchestration mode settings.'
|
||||
: 'Creates an iterative cycle, re-executing the path between these nodes up to a set limit.'}
|
||||
</p>
|
||||
</div>
|
||||
{edge.isLoop && (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">Max Iterations</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:opacity-60"
|
||||
disabled={builderMode}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
onEdgeChange(edge.id, {
|
||||
maxIterations: Number.isNaN(raw) ? undefined : Math.max(1, raw),
|
||||
});
|
||||
}}
|
||||
type="number"
|
||||
value={edge.maxIterations ?? 10}
|
||||
/>
|
||||
{builderMode && (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Derived from the {orchestrationMode === 'group-chat' ? 'Max Rounds' : 'Max Iterations'} setting.
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition editor */}
|
||||
<div className="space-y-1.5">
|
||||
<ConditionEditor
|
||||
condition={edge.condition}
|
||||
disabled={isFanIn}
|
||||
onChange={handleConditionChange}
|
||||
/>
|
||||
{isFanIn && (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Fan-in edges cannot have conditions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onEdgeChange(edge.id, { label: v || undefined })}
|
||||
placeholder="Optional edge label"
|
||||
value={edge.label ?? ''}
|
||||
/>
|
||||
|
||||
{/* Validation issues for this edge */}
|
||||
{edgeIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{edgeIssues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main inspector component ──────────────────────────────── */
|
||||
|
||||
export function WorkflowGraphInspector({
|
||||
availableModels,
|
||||
workflow,
|
||||
workflows,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
validationIssues,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
onEdgeChange,
|
||||
onEdgeRemove,
|
||||
onDrillIntoSubWorkflow,
|
||||
}: WorkflowGraphInspectorProps) {
|
||||
const selectedNode = selectedNodeId
|
||||
? workflow.graph.nodes.find((n) => n.id === selectedNodeId)
|
||||
: undefined;
|
||||
const selectedEdge = selectedEdgeId
|
||||
? workflow.graph.edges.find((e) => e.id === selectedEdgeId)
|
||||
: undefined;
|
||||
|
||||
if (selectedEdge) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<EdgeInspector
|
||||
edge={selectedEdge}
|
||||
orchestrationMode={workflow.settings.orchestrationMode}
|
||||
onEdgeChange={onEdgeChange}
|
||||
onEdgeRemove={onEdgeRemove}
|
||||
validationIssues={validationIssues}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedNode) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
Select a node or edge to inspect
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'start' || selectedNode.kind === 'end') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SystemNodeInspector node={selectedNode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'agent') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AgentNodeInspector
|
||||
availableModels={availableModels}
|
||||
node={selectedNode}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeConfigChange={onNodeConfigChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'sub-workflow') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SubWorkflowInspector
|
||||
currentWorkflowId={workflow.id}
|
||||
node={selectedNode}
|
||||
onDrillIntoSubWorkflow={onDrillIntoSubWorkflow}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeConfigChange={onNodeConfigChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
workflows={workflows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'invoke-function') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<InvokeFunctionInspector
|
||||
node={selectedNode}
|
||||
validationIssues={validationIssues}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeConfigChange={onNodeConfigChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'request-port') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RequestPortInspector
|
||||
node={selectedNode}
|
||||
validationIssues={validationIssues}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeConfigChange={onNodeConfigChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for any future unknown node kinds
|
||||
return (
|
||||
<div className="p-4">
|
||||
<PlaceholderNodeInspector
|
||||
node={selectedNode}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Bot, Circle, FunctionSquare, GitBranch, Link2, Radio, Play, Square } from 'lucide-react';
|
||||
|
||||
import type { WorkflowGraphNodeData } from '@renderer/lib/workflowGraph';
|
||||
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
|
||||
/* ── Styling constants ─────────────────────────────────────── */
|
||||
|
||||
const kindColors: Record<WorkflowNodeKind, { bg: string; border: string; text: string }> = {
|
||||
start: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
|
||||
end: { bg: 'bg-rose-500/10', border: 'border-rose-500/30', text: 'text-rose-400' },
|
||||
agent: { bg: 'bg-[var(--color-surface-2)]/80', border: 'border-[var(--color-border)]/40', text: 'text-[var(--color-text-primary)]' },
|
||||
'invoke-function': { bg: 'bg-violet-500/10', border: 'border-violet-500/30', text: 'text-violet-400' },
|
||||
'sub-workflow': { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
'request-port': { bg: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-400' },
|
||||
};
|
||||
|
||||
const kindIcons: Record<WorkflowNodeKind, typeof Bot> = {
|
||||
start: Play,
|
||||
end: Square,
|
||||
agent: Bot,
|
||||
'invoke-function': FunctionSquare,
|
||||
'sub-workflow': GitBranch,
|
||||
'request-port': Radio,
|
||||
};
|
||||
|
||||
const handleStyles = {
|
||||
flow: '!size-2 !border-[var(--color-border)] !bg-[var(--color-text-secondary)]',
|
||||
agent: '!size-2 !border-[var(--color-accent-sky)] !bg-[var(--color-accent)]',
|
||||
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
|
||||
};
|
||||
|
||||
/* ── Shared node content ───────────────────────────────────── */
|
||||
|
||||
function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) {
|
||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||
const isAgent = data.kind === 'agent';
|
||||
const isSubWorkflow = data.kind === 'sub-workflow';
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAgent && data.provider) {
|
||||
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
|
||||
}
|
||||
const FallbackIcon = kindIcons[data.kind] ?? Circle;
|
||||
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md backdrop-blur-sm transition ${
|
||||
colors.bg
|
||||
} ${selected ? 'ring-2 ring-[var(--color-accent)]/50' : ''} ${colors.border}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||
{data.label}
|
||||
</div>
|
||||
{isAgent && data.modelLabel && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
|
||||
)}
|
||||
{isSubWorkflow && data.subWorkflowLabel && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{data.subWorkflowLabel === 'Inline' ? (
|
||||
<span className="rounded bg-amber-500/15 px-1 py-0.5 text-amber-400">Inline</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Link2 className="inline size-2.5" />
|
||||
{data.subWorkflowLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Node type components (all memoized) ───────────────────── */
|
||||
|
||||
export const StartNode = memo(function StartNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const EndNode = memo(function EndNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const InvokeFunctionNode = memo(function InvokeFunctionNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const SubWorkflowNode = memo(function SubWorkflowNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const RequestPortNode = memo(function RequestPortNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Node type map for ReactFlow ───────────────────────────── */
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
startNode: StartNode,
|
||||
endNode: EndNode,
|
||||
agentNode: AgentNode,
|
||||
invokeFunctionNode: InvokeFunctionNode,
|
||||
subWorkflowNode: SubWorkflowNode,
|
||||
requestPortNode: RequestPortNode,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Bot, FunctionSquare, GitBranch, Play, Radio, Square } from 'lucide-react';
|
||||
|
||||
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
||||
|
||||
interface WorkflowNodePaletteProps {
|
||||
onAddNode: (kind: WorkflowNodeKind) => void;
|
||||
disabledKinds?: ReadonlySet<WorkflowNodeKind>;
|
||||
}
|
||||
|
||||
interface PaletteItem {
|
||||
kind: WorkflowNodeKind;
|
||||
label: string;
|
||||
icon: typeof Bot;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PaletteGroup {
|
||||
label: string;
|
||||
items: PaletteItem[];
|
||||
}
|
||||
|
||||
const paletteGroups: PaletteGroup[] = [
|
||||
{
|
||||
label: 'Flow Control',
|
||||
items: [
|
||||
{ kind: 'start', label: 'Start', icon: Play, color: 'text-emerald-400' },
|
||||
{ kind: 'end', label: 'End', icon: Square, color: 'text-rose-400' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Agents',
|
||||
items: [
|
||||
{ kind: 'agent', label: 'Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Processing',
|
||||
items: [
|
||||
{ kind: 'invoke-function', label: 'Function Tool', icon: FunctionSquare, color: 'text-violet-400' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Integration',
|
||||
items: [
|
||||
{ kind: 'sub-workflow', label: 'Sub-Workflow', icon: GitBranch, color: 'text-amber-400' },
|
||||
{ kind: 'request-port', label: 'Port', icon: Radio, color: 'text-teal-400' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WorkflowNodePalette({ onAddNode, disabledKinds }: WorkflowNodePaletteProps) {
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Nodes
|
||||
</h4>
|
||||
{paletteGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const disabled = disabledKinds?.has(item.kind) ?? false;
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] transition-all duration-200 ${
|
||||
disabled
|
||||
? 'cursor-not-allowed text-[var(--color-text-muted)] opacity-40'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={item.kind}
|
||||
onClick={() => onAddNode(item.kind)}
|
||||
title={disabled ? `${item.label} node already exists` : undefined}
|
||||
type="button"
|
||||
>
|
||||
<Icon className={`size-3.5 ${item.color}`} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Bot,
|
||||
Layers,
|
||||
MessageCircle,
|
||||
Route,
|
||||
Search,
|
||||
Star,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { WorkflowDefinition, WorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import { inferWorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import { useClickOutside } from '@renderer/hooks/useClickOutside';
|
||||
|
||||
/* ── Mode visual metadata ──────────────────────────────────── */
|
||||
|
||||
const modeMeta: Record<WorkflowOrchestrationMode, {
|
||||
label: string;
|
||||
icon: typeof Bot;
|
||||
accent: string;
|
||||
bg: string;
|
||||
}> = {
|
||||
single: { label: 'Single', icon: User, accent: 'text-emerald-400', bg: 'bg-emerald-400/10' },
|
||||
sequential: { label: 'Sequential', icon: ArrowRightLeft, accent: 'text-sky-400', bg: 'bg-sky-400/10' },
|
||||
concurrent: { label: 'Concurrent', icon: Layers, accent: 'text-amber-400', bg: 'bg-amber-400/10' },
|
||||
handoff: { label: 'Handoff', icon: Route, accent: 'text-violet-400', bg: 'bg-violet-400/10' },
|
||||
'group-chat': { label: 'Group Chat', icon: MessageCircle, accent: 'text-rose-400', bg: 'bg-rose-400/10' },
|
||||
};
|
||||
|
||||
const modeOrder: WorkflowOrchestrationMode[] = ['single', 'sequential', 'concurrent', 'handoff', 'group-chat'];
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────── */
|
||||
|
||||
interface WorkflowPickerProps {
|
||||
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||
onSelect: (workflowId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface AnnotatedWorkflow {
|
||||
workflow: WorkflowDefinition;
|
||||
mode: WorkflowOrchestrationMode;
|
||||
agentCount: number;
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
|
||||
function annotateWorkflow(workflow: WorkflowDefinition): AnnotatedWorkflow {
|
||||
const mode = inferWorkflowOrchestrationMode(workflow);
|
||||
const agentCount = workflow.graph.nodes.filter((n) => n.kind === 'agent').length;
|
||||
return { workflow, mode, agentCount };
|
||||
}
|
||||
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
if (t.includes(q)) return true;
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
||||
if (t[ti] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length;
|
||||
}
|
||||
|
||||
/* ── Component ─────────────────────────────────────────────── */
|
||||
|
||||
export function WorkflowPicker({ workflows, onSelect, onClose }: WorkflowPickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useClickOutside<HTMLDivElement>(onClose, true);
|
||||
|
||||
// Annotate and group workflows
|
||||
const annotated = useMemo(
|
||||
() => workflows.map(annotateWorkflow),
|
||||
[workflows],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return annotated;
|
||||
return annotated.filter((a) =>
|
||||
fuzzyMatch(query, a.workflow.name)
|
||||
|| fuzzyMatch(query, a.workflow.description)
|
||||
|| fuzzyMatch(query, modeMeta[a.mode].label),
|
||||
);
|
||||
}, [annotated, query]);
|
||||
|
||||
// Group by mode, preserving order
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<WorkflowOrchestrationMode, AnnotatedWorkflow[]>();
|
||||
for (const item of filtered) {
|
||||
const existing = map.get(item.mode);
|
||||
if (existing) {
|
||||
existing.push(item);
|
||||
} else {
|
||||
map.set(item.mode, [item]);
|
||||
}
|
||||
}
|
||||
return modeOrder
|
||||
.filter((mode) => map.has(mode))
|
||||
.map((mode) => ({ mode, items: map.get(mode)! }));
|
||||
}, [filtered]);
|
||||
|
||||
// Flat list of items for keyboard navigation
|
||||
const flatItems = useMemo(
|
||||
() => groups.flatMap((g) => g.items),
|
||||
[groups],
|
||||
);
|
||||
|
||||
// Clamp active index on filter change
|
||||
useEffect(() => {
|
||||
setActiveIndex((prev) => Math.min(prev, Math.max(0, flatItems.length - 1)));
|
||||
}, [flatItems.length]);
|
||||
|
||||
// Auto-focus input
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
const active = listRef.current?.querySelector('[data-active="true"]');
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, flatItems.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (flatItems[activeIndex]) {
|
||||
onSelect(flatItems[activeIndex].workflow.id);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[flatItems, activeIndex, onSelect, onClose],
|
||||
);
|
||||
|
||||
let flatIdx = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] backdrop-blur-[2px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select a workflow"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-[420px] animate-[palette-enter_0.18s_ease-out] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_24px_80px_rgba(0,0,0,0.45),0_0_0_1px_rgba(36,92,249,0.08)]"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-4 py-3">
|
||||
<Search className="size-4 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-[14px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||
placeholder="Pick a workflow…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
aria-label="Search workflows"
|
||||
/>
|
||||
<kbd className="hidden rounded border border-[var(--color-border)] bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)] sm:inline">
|
||||
esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Workflow list */}
|
||||
<div ref={listRef} className="max-h-[52vh] overflow-y-auto overscroll-contain py-1.5" role="listbox">
|
||||
{groups.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-text-muted)]">
|
||||
No workflows match your search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => {
|
||||
const meta = modeMeta[group.mode];
|
||||
const GroupIcon = meta.icon;
|
||||
|
||||
return (
|
||||
<div key={group.mode}>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 px-4 pb-1 pt-2.5">
|
||||
<GroupIcon className={`size-3 ${meta.accent}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--color-text-muted)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{group.items.map((item) => {
|
||||
const isActive = flatIdx === activeIndex;
|
||||
const currentIdx = flatIdx;
|
||||
flatIdx++;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.workflow.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
|
||||
isActive
|
||||
? 'bg-[var(--color-accent-muted)]'
|
||||
: 'hover:bg-[var(--color-surface-2)]'
|
||||
}`}
|
||||
onClick={() => onSelect(item.workflow.id)}
|
||||
onMouseEnter={() => setActiveIndex(currentIdx)}
|
||||
>
|
||||
{/* Mode badge */}
|
||||
<div className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${meta.bg}`}>
|
||||
<GroupIcon className={`size-3.5 ${meta.accent}`} />
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`truncate text-[13px] font-medium ${
|
||||
isActive ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
|
||||
}`}>
|
||||
{item.workflow.name}
|
||||
</span>
|
||||
{item.workflow.isFavorite && (
|
||||
<Star className="size-3 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
{item.workflow.description && (
|
||||
<p className="truncate text-[11px] text-[var(--color-text-muted)]">
|
||||
{item.workflow.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent count pill */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-md bg-[var(--color-surface-3)] px-1.5 py-0.5">
|
||||
<Bot className="size-2.5 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{item.agentCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="flex items-center gap-3 border-t border-[var(--color-border)] px-4 py-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-3)] px-1 py-px text-[10px]">↑↓</kbd>
|
||||
{' '}navigate
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-3)] px-1 py-px text-[10px]">↵</kbd>
|
||||
{' '}select
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">
|
||||
{flatItems.length} workflow{flatItems.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function formatElapsed(startMs: number): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return `${minutes}m ${remainder}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a live-ticking elapsed-time string while `active` is true.
|
||||
* Freezes the display when `active` becomes false.
|
||||
*/
|
||||
export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined {
|
||||
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
|
||||
|
||||
const [elapsed, setElapsed] = useState<string | undefined>(() => {
|
||||
if (!startMs) return undefined;
|
||||
return formatElapsed(startMs);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!startMs) {
|
||||
setElapsed(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always sync to current value immediately
|
||||
setElapsed(formatElapsed(startMs));
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startMs, active]);
|
||||
|
||||
return elapsed;
|
||||
}
|
||||
@@ -100,10 +100,15 @@ export const chatMarkdownComponents: Components = {
|
||||
},
|
||||
|
||||
a({ href, children }) {
|
||||
const url = String(href ?? '');
|
||||
const isSafe = /^https?:|^mailto:|^#/i.test(url);
|
||||
if (!isSafe) {
|
||||
return <span className="text-indigo-400">{children as ReactNode}</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
href={String(href)}
|
||||
href={url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
|
||||
|
||||
import type {
|
||||
OrchestrationMode,
|
||||
PatternAgentDefinition,
|
||||
PatternDefinition,
|
||||
PatternGraph,
|
||||
PatternGraphEdge,
|
||||
PatternGraphNode,
|
||||
PatternGraphNodeKind,
|
||||
} from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { ModelProvider } from '@shared/domain/models';
|
||||
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
|
||||
|
||||
/* ── Canvas node data ──────────────────────────────────────── */
|
||||
|
||||
export interface GraphNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
kind: PatternGraphNodeKind;
|
||||
agentId?: string;
|
||||
order?: number;
|
||||
readOnly: boolean;
|
||||
/** AI provider inferred from the agent's model (agent nodes only). */
|
||||
provider?: ModelProvider;
|
||||
/** Short display name for the agent's model (agent nodes only). */
|
||||
modelLabel?: string;
|
||||
}
|
||||
|
||||
/* ── View-model projection ─────────────────────────────────── */
|
||||
|
||||
const SYSTEM_NODE_LABELS: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'User Input',
|
||||
'user-output': 'User Output',
|
||||
distributor: 'Distributor',
|
||||
collector: 'Collector',
|
||||
orchestrator: 'Orchestrator',
|
||||
};
|
||||
|
||||
function isSystemNode(kind: PatternGraphNodeKind): boolean {
|
||||
return kind !== 'agent';
|
||||
}
|
||||
|
||||
function resolveNodeLabel(node: PatternGraphNode, agents: PatternAgentDefinition[]): string {
|
||||
if (node.kind === 'agent' && node.agentId) {
|
||||
const agent = agents.find((a) => a.id === node.agentId);
|
||||
return agent?.name || node.agentId;
|
||||
}
|
||||
|
||||
return SYSTEM_NODE_LABELS[node.kind] ?? node.kind;
|
||||
}
|
||||
|
||||
function resolveNodeType(kind: PatternGraphNodeKind): string {
|
||||
switch (kind) {
|
||||
case 'user-input':
|
||||
return 'userInputNode';
|
||||
case 'user-output':
|
||||
return 'userOutputNode';
|
||||
case 'agent':
|
||||
return 'agentNode';
|
||||
default:
|
||||
return 'systemNode';
|
||||
}
|
||||
}
|
||||
|
||||
export function toCanvasNodes(
|
||||
graph: PatternGraph,
|
||||
agents: PatternAgentDefinition[],
|
||||
models?: ReadonlyArray<ModelDefinition>,
|
||||
): Node<GraphNodeData>[] {
|
||||
return graph.nodes.map((node) => {
|
||||
let provider: ModelProvider | undefined;
|
||||
let modelLabel: string | undefined;
|
||||
|
||||
if (node.kind === 'agent' && node.agentId) {
|
||||
const agent = agents.find((a) => a.id === node.agentId);
|
||||
if (agent?.model) {
|
||||
provider = inferProvider(agent.model);
|
||||
const modelDef = models ? findModel(agent.model, models) : undefined;
|
||||
modelLabel = modelDef?.name ?? agent.model;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: resolveNodeType(node.kind),
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
data: {
|
||||
label: resolveNodeLabel(node, agents),
|
||||
kind: node.kind,
|
||||
agentId: node.agentId,
|
||||
order: node.order,
|
||||
readOnly: isSystemNode(node.kind),
|
||||
provider,
|
||||
modelLabel,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: node.kind === 'agent',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Determines whether an edge can be deleted by the user.
|
||||
*
|
||||
* Deletable edges:
|
||||
* - handoff: agent ↔ agent edges (handoff routes)
|
||||
* - group-chat: orchestrator ↔ agent edges (participation links)
|
||||
*
|
||||
* Structural edges (user-input/output, distributor/collector) are never deletable.
|
||||
* Sequential, concurrent, and single modes have fully structural topologies.
|
||||
*/
|
||||
function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph: PatternGraph): boolean {
|
||||
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === edge.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === 'handoff') {
|
||||
return sourceNode.kind === 'agent' && targetNode.kind === 'agent';
|
||||
}
|
||||
|
||||
if (mode === 'group-chat') {
|
||||
const kinds = new Set([sourceNode.kind, targetNode.kind]);
|
||||
return kinds.has('orchestrator') && kinds.has('agent');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const EDGE_COLORS = {
|
||||
structural: { stroke: '#3f3f46', markerColor: '#3f3f46' }, // zinc-700 — muted
|
||||
agentToAgent: { stroke: '#6366f1', markerColor: '#6366f1' }, // indigo-500 — distinct
|
||||
};
|
||||
|
||||
function resolveEdgeColor(edge: PatternGraphEdge, graph: PatternGraph): typeof EDGE_COLORS.structural {
|
||||
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === edge.target);
|
||||
|
||||
if (sourceNode?.kind === 'agent' && targetNode?.kind === 'agent') {
|
||||
return EDGE_COLORS.agentToAgent;
|
||||
}
|
||||
|
||||
return EDGE_COLORS.structural;
|
||||
}
|
||||
|
||||
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
||||
return graph.edges.map((edge) => {
|
||||
const color = resolveEdgeColor(edge, graph);
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
animated: mode === 'handoff',
|
||||
deletable: isEdgeDeletable(edge, mode, graph),
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
|
||||
style: { stroke: color.stroke, strokeWidth: 1.5 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fromCanvasPositions(
|
||||
pattern: PatternDefinition,
|
||||
canvasNodes: Node<GraphNodeData>[],
|
||||
): PatternGraph {
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
|
||||
|
||||
return {
|
||||
nodes: graph.nodes.map((node) => {
|
||||
const pos = positionMap.get(node.id);
|
||||
return pos ? { ...node, position: { x: Math.round(pos.x), y: Math.round(pos.y) } } : node;
|
||||
}),
|
||||
edges: graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Connection rules ──────────────────────────────────────── */
|
||||
|
||||
export function isConnectionAllowed(
|
||||
connection: Connection,
|
||||
mode: OrchestrationMode,
|
||||
graph: PatternGraph,
|
||||
): boolean {
|
||||
if (!connection.source || !connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (connection.source === connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === connection.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'single':
|
||||
case 'sequential':
|
||||
case 'magentic':
|
||||
return false;
|
||||
case 'concurrent':
|
||||
return false;
|
||||
case 'handoff':
|
||||
return sourceNode.kind === 'agent' && targetNode.kind === 'agent';
|
||||
case 'group-chat': {
|
||||
const kinds = new Set([sourceNode.kind, targetNode.kind]);
|
||||
return kinds.has('orchestrator') && kinds.has('agent');
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any edges can be deleted by the user in this mode. */
|
||||
export function isEdgeDeletionAllowed(mode: OrchestrationMode): boolean {
|
||||
return mode === 'handoff' || mode === 'group-chat';
|
||||
}
|
||||
|
||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||
|
||||
function edgeId(source: string, target: string): string {
|
||||
return `edge-${source}-to-${target}`;
|
||||
}
|
||||
|
||||
export function addEdge(graph: PatternGraph, source: string, target: string): PatternGraph {
|
||||
const newEdge: PatternGraphEdge = {
|
||||
id: edgeId(source, target),
|
||||
source,
|
||||
target,
|
||||
};
|
||||
|
||||
if (graph.edges.some((e) => e.source === source && e.target === target)) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
return { ...graph, edges: [...graph.edges, newEdge] };
|
||||
}
|
||||
|
||||
export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGraph {
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Sequential reorder ────────────────────────────────────── */
|
||||
|
||||
export function swapSequentialOrder(
|
||||
graph: PatternGraph,
|
||||
agentNodeId: string,
|
||||
direction: 'up' | 'down',
|
||||
): PatternGraph {
|
||||
const agentNodes = graph.nodes
|
||||
.filter((n) => n.kind === 'agent')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const currentIndex = agentNodes.findIndex((n) => n.id === agentNodeId);
|
||||
if (currentIndex < 0) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= agentNodes.length) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
const currentNode = agentNodes[currentIndex]!;
|
||||
const swapNode = agentNodes[swapIndex]!;
|
||||
|
||||
// Swap order values
|
||||
const updatedNodes = graph.nodes.map((n) => {
|
||||
if (n.id === currentNode.id) {
|
||||
return { ...n, order: swapNode.order, position: { ...swapNode.position } };
|
||||
}
|
||||
if (n.id === swapNode.id) {
|
||||
return { ...n, order: currentNode.order, position: { ...currentNode.position } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
// Rebuild linear edges: input → agent₁ → agent₂ → ... → output
|
||||
const inputNode = graph.nodes.find((n) => n.kind === 'user-input');
|
||||
const outputNode = graph.nodes.find((n) => n.kind === 'user-output');
|
||||
const sortedAgents = updatedNodes
|
||||
.filter((n) => n.kind === 'agent')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const path = [
|
||||
...(inputNode ? [inputNode.id] : []),
|
||||
...sortedAgents.map((n) => n.id),
|
||||
...(outputNode ? [outputNode.id] : []),
|
||||
];
|
||||
|
||||
const newEdges: PatternGraphEdge[] = [];
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
newEdges.push({ id: edgeId(path[i]!, path[i + 1]!), source: path[i]!, target: path[i + 1]! });
|
||||
}
|
||||
|
||||
return { nodes: updatedNodes, edges: newEdges };
|
||||
}
|
||||
|
||||
/** Check if an agent node can be moved up or down in sequential order. */
|
||||
export function canMoveSequential(
|
||||
graph: PatternGraph,
|
||||
agentNodeId: string,
|
||||
direction: 'up' | 'down',
|
||||
): boolean {
|
||||
const agentNodes = graph.nodes
|
||||
.filter((n) => n.kind === 'agent')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const index = agentNodes.findIndex((n) => n.id === agentNodeId);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return direction === 'up' ? index > 0 : index < agentNodes.length - 1;
|
||||
}
|
||||
|
||||
/* ── Agent node for selected inspector ─────────────────────── */
|
||||
|
||||
export function findAgentForNode(
|
||||
nodeId: string,
|
||||
graph: PatternGraph,
|
||||
agents: PatternAgentDefinition[],
|
||||
): PatternAgentDefinition | undefined {
|
||||
const node = graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node?.agentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return agents.find((a) => a.id === node.agentId);
|
||||
}
|
||||
|
||||
/* ── Auto-layout via dagre ─────────────────────────────────── */
|
||||
|
||||
import dagre from '@dagrejs/dagre';
|
||||
|
||||
const NODE_WIDTH = 170;
|
||||
const NODE_HEIGHT = 52;
|
||||
|
||||
/**
|
||||
* Re-compute all node positions using dagre's layered layout algorithm.
|
||||
* Direction is always left-to-right to match the graph flow.
|
||||
* Returns a new graph with updated positions; edges are unchanged.
|
||||
*/
|
||||
export function autoLayoutGraph(graph: PatternGraph): PatternGraph {
|
||||
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 60,
|
||||
ranksep: 120,
|
||||
marginx: 20,
|
||||
marginy: 20,
|
||||
});
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const layoutedNodes = graph.nodes.map((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: Math.round(dagreNode.x - NODE_WIDTH / 2),
|
||||
y: Math.round(dagreNode.y - NODE_HEIGHT / 2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: graph.edges };
|
||||
}
|
||||
@@ -168,3 +168,68 @@ export function truncateContent(content: string | undefined, maxLength = 80): st
|
||||
export function findLatestRun(runs: readonly SessionRunRecord[]): SessionRunRecord | undefined {
|
||||
return runs.length > 0 ? runs[0] : undefined;
|
||||
}
|
||||
|
||||
/* ── Agent-scoped event filtering ──────────────────────────── */
|
||||
|
||||
/** Run-level events that are not scoped to any specific agent. */
|
||||
const RUN_LEVEL_EVENT_KINDS = new Set<RunTimelineEventKind>([
|
||||
'run-started',
|
||||
'run-completed',
|
||||
'run-cancelled',
|
||||
'run-failed',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Filter run events to only those belonging to the given agents.
|
||||
* When `agentNames` is undefined or empty (single-agent run), all events pass through.
|
||||
* Run-level events (start/complete/fail) are excluded from agent-scoped panels
|
||||
* because they represent the entire run, not a specific agent's work.
|
||||
*/
|
||||
export function filterEventsByAgent(
|
||||
events: readonly RunTimelineEventRecord[],
|
||||
agentNames: ReadonlySet<string> | undefined,
|
||||
): RunTimelineEventRecord[] {
|
||||
if (!agentNames || agentNames.size === 0) return events.slice();
|
||||
|
||||
return events.filter((e) => {
|
||||
if (RUN_LEVEL_EVENT_KINDS.has(e.kind)) return false;
|
||||
if (e.kind === 'handoff') {
|
||||
return (e.sourceAgentName != null && agentNames.has(e.sourceAgentName))
|
||||
|| (e.targetAgentName != null && agentNames.has(e.targetAgentName));
|
||||
}
|
||||
if (e.agentName) return agentNames.has(e.agentName);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Counts of different activity types within a set of run timeline events. */
|
||||
export interface ActivitySummary {
|
||||
thinkingSteps: number;
|
||||
toolCalls: number;
|
||||
handoffs: number;
|
||||
approvals: number;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize activity from thinking messages and (already-filtered) run events.
|
||||
*/
|
||||
export function summarizeActivity(
|
||||
thinkingMessages: ReadonlyArray<{ content: string }>,
|
||||
events: readonly RunTimelineEventRecord[],
|
||||
): ActivitySummary {
|
||||
const thinkingSteps = thinkingMessages.filter((m) => m.content).length;
|
||||
let toolCalls = 0;
|
||||
let handoffs = 0;
|
||||
let approvals = 0;
|
||||
let hasError = false;
|
||||
|
||||
for (const e of events) {
|
||||
if (e.kind === 'tool-call') toolCalls++;
|
||||
else if (e.kind === 'handoff') handoffs++;
|
||||
else if (e.kind === 'approval') approvals++;
|
||||
else if (e.kind === 'run-failed') hasError = true;
|
||||
}
|
||||
|
||||
return { thinkingSteps, toolCalls, handoffs, approvals, hasError };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { AgentNodeConfig } from '@shared/domain/workflow';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
|
||||
|
||||
@@ -93,7 +93,7 @@ export function pruneSessionActivities(
|
||||
|
||||
export function buildAgentActivityRows(
|
||||
current: SessionActivityState | undefined,
|
||||
agents: PatternDefinition['agents'],
|
||||
agents: ReadonlyArray<{ id: string; name: string }>,
|
||||
): AgentActivityRow[] {
|
||||
return agents.map((agent) => {
|
||||
const activity = current?.[agent.id] ?? current?.[agent.name];
|
||||
@@ -166,12 +166,16 @@ function clearActiveSessionActivity(
|
||||
return current;
|
||||
}
|
||||
|
||||
// Transition active entries to 'completed' instead of removing them.
|
||||
// The UI layer uses a grace timer to eventually clear these.
|
||||
let changed = false;
|
||||
const nextSessionActivity = Object.fromEntries(
|
||||
Object.entries(sessionActivity).filter(([, activity]) => {
|
||||
const keep = !isAgentActivityActive(activity);
|
||||
changed ||= !keep;
|
||||
return keep;
|
||||
Object.entries(sessionActivity).map(([key, activity]) => {
|
||||
if (isAgentActivityActive(activity)) {
|
||||
changed = true;
|
||||
return [key, { ...activity, activityType: 'completed' as const }];
|
||||
}
|
||||
return [key, activity];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -179,10 +183,6 @@ function clearActiveSessionActivity(
|
||||
return current;
|
||||
}
|
||||
|
||||
if (Object.keys(nextSessionActivity).length === 0) {
|
||||
return removeSessionActivity(current, sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[sessionId]: nextSessionActivity,
|
||||
@@ -466,6 +466,60 @@ export function pruneSessionRequestUsage(
|
||||
|
||||
/* ── Formatting helpers ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Remove all completed activity entries for a session.
|
||||
* Called after the UI grace period to finish clearing stale labels.
|
||||
*/
|
||||
export function purgeCompletedActivity(
|
||||
current: SessionActivityMap,
|
||||
sessionId: string,
|
||||
): SessionActivityMap {
|
||||
const sessionActivity = current[sessionId];
|
||||
if (!sessionActivity) return current;
|
||||
|
||||
const nextSessionActivity = Object.fromEntries(
|
||||
Object.entries(sessionActivity).filter(([, a]) => !isAgentActivityCompleted(a)),
|
||||
);
|
||||
|
||||
if (Object.keys(nextSessionActivity).length === Object.keys(sessionActivity).length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (Object.keys(nextSessionActivity).length === 0) {
|
||||
return removeSessionActivity(current, sessionId);
|
||||
}
|
||||
|
||||
return { ...current, [sessionId]: nextSessionActivity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single summary label for the most relevant activity in a session.
|
||||
* Prefers active states (thinking → tool-calling → handoff) over completed.
|
||||
*/
|
||||
export function summarizeSessionActivity(
|
||||
sessionActivity: SessionActivityState | undefined,
|
||||
): string | undefined {
|
||||
if (!sessionActivity) return undefined;
|
||||
|
||||
const entries = Object.values(sessionActivity);
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
// Prefer the first active entry by priority
|
||||
const active = entries.find((a) => a.activityType === 'thinking')
|
||||
?? entries.find((a) => a.activityType === 'tool-calling')
|
||||
?? entries.find((a) => a.activityType === 'handoff');
|
||||
|
||||
if (active) {
|
||||
return formatAgentActivityLabel(active);
|
||||
}
|
||||
|
||||
// If everything is completed, show that
|
||||
const completed = entries.find((a) => a.activityType === 'completed');
|
||||
if (completed) return 'Completed';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const MAX_SUMMARY_LENGTH = 80;
|
||||
|
||||
function truncateSummary(value: string): string {
|
||||
const firstLine = value.split('\n')[0] ?? '';
|
||||
const cleaned = firstLine.trim();
|
||||
if (cleaned.length <= MAX_SUMMARY_LENGTH) return cleaned;
|
||||
return `${cleaned.slice(0, MAX_SUMMARY_LENGTH)}…`;
|
||||
}
|
||||
|
||||
function stringArg(args: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = args[key];
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function summarizePath(args: Record<string, unknown>): string | undefined {
|
||||
const path = stringArg(args, 'path');
|
||||
if (!path) return undefined;
|
||||
const range = args['view_range'] ?? args['viewRange'];
|
||||
if (Array.isArray(range) && range.length === 2) {
|
||||
return truncateSummary(`${path}:${range[0]}-${range[1]}`);
|
||||
}
|
||||
return truncateSummary(path);
|
||||
}
|
||||
|
||||
function summarizeGitHub(toolName: string, args: Record<string, unknown>): string | undefined {
|
||||
const owner = stringArg(args, 'owner');
|
||||
const repo = stringArg(args, 'repo');
|
||||
const query = stringArg(args, 'query');
|
||||
|
||||
if (query) return truncateSummary(query);
|
||||
if (owner && repo) return truncateSummary(`${owner}/${repo}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type SummaryExtractor = (args: Record<string, unknown>, toolName: string) => string | undefined;
|
||||
|
||||
const toolSummarizers: Record<string, SummaryExtractor> = {
|
||||
powershell: (args) => stringArg(args, 'command') ? truncateSummary(stringArg(args, 'command')!) : undefined,
|
||||
view: (args) => summarizePath(args),
|
||||
edit: (args) => summarizePath(args),
|
||||
create: (args) => summarizePath(args),
|
||||
grep: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
|
||||
glob: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
|
||||
lsp: (args) => {
|
||||
const op = stringArg(args, 'operation');
|
||||
const file = stringArg(args, 'file');
|
||||
if (op && file) return truncateSummary(`${op} ${file}`);
|
||||
return op ? truncateSummary(op) : undefined;
|
||||
},
|
||||
web_fetch: (args) => stringArg(args, 'url') ? truncateSummary(stringArg(args, 'url')!) : undefined,
|
||||
sql: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
|
||||
task: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
|
||||
ask_user: (args) => stringArg(args, 'question') ? truncateSummary(stringArg(args, 'question')!) : undefined,
|
||||
skill: (args) => stringArg(args, 'skill') ? truncateSummary(stringArg(args, 'skill')!) : undefined,
|
||||
report_intent: (args) => stringArg(args, 'intent') ? truncateSummary(stringArg(args, 'intent')!) : undefined,
|
||||
};
|
||||
|
||||
function fallbackSummary(args: Record<string, unknown>): string | undefined {
|
||||
for (const value of Object.values(args)) {
|
||||
if (typeof value === 'string' && value.trim().length > 0 && value !== '[truncated]') {
|
||||
return truncateSummary(value);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatToolCallSummary(
|
||||
toolName: string | undefined,
|
||||
toolArguments: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (!toolName || !toolArguments || Object.keys(toolArguments).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check for GitHub tools (github-*)
|
||||
if (toolName.startsWith('github-')) {
|
||||
return summarizeGitHub(toolName, toolArguments);
|
||||
}
|
||||
|
||||
const summarizer = toolSummarizers[toolName];
|
||||
if (summarizer) {
|
||||
return summarizer(toolArguments, toolName);
|
||||
}
|
||||
|
||||
return fallbackSummary(toolArguments);
|
||||
}
|
||||
|
||||
export function formatToolArgumentValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keys that are redundant with the label itself or too noisy to display inline. */
|
||||
const HIDDEN_ARGUMENT_KEYS = new Set([
|
||||
'description', // often duplicates the summary
|
||||
]);
|
||||
|
||||
export function getDisplayableArguments(
|
||||
toolArguments: Record<string, unknown> | undefined,
|
||||
): Array<[string, unknown]> {
|
||||
if (!toolArguments) return [];
|
||||
|
||||
return Object.entries(toolArguments).filter(
|
||||
([key, value]) =>
|
||||
!HIDDEN_ARGUMENT_KEYS.has(key)
|
||||
&& value !== null
|
||||
&& value !== undefined
|
||||
&& value !== '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
|
||||
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowEdge as WfEdge,
|
||||
WorkflowEdgeKind,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowNodeKind,
|
||||
SubWorkflowConfig,
|
||||
} from '@shared/domain/workflow';
|
||||
import type { ModelProvider } from '@shared/domain/models';
|
||||
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
|
||||
|
||||
/* ── Canvas node data ──────────────────────────────────────── */
|
||||
|
||||
export interface WorkflowGraphNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
kind: WorkflowNodeKind;
|
||||
/** AI provider inferred from the agent's model (agent nodes only). */
|
||||
provider?: ModelProvider;
|
||||
/** Short display name for the agent's model (agent nodes only). */
|
||||
modelLabel?: string;
|
||||
/** Sub-workflow label: referenced workflow name or "Inline" (sub-workflow nodes only). */
|
||||
subWorkflowLabel?: string;
|
||||
}
|
||||
|
||||
/* ── View-model projection ─────────────────────────────────── */
|
||||
|
||||
function resolveNodeType(kind: WorkflowNodeKind): string {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return 'startNode';
|
||||
case 'end':
|
||||
return 'endNode';
|
||||
case 'agent':
|
||||
return 'agentNode';
|
||||
case 'invoke-function':
|
||||
return 'invokeFunctionNode';
|
||||
case 'sub-workflow':
|
||||
return 'subWorkflowNode';
|
||||
case 'request-port':
|
||||
return 'requestPortNode';
|
||||
}
|
||||
}
|
||||
|
||||
export function toCanvasNodes(
|
||||
graph: WorkflowGraph,
|
||||
models?: ReadonlyArray<ModelDefinition>,
|
||||
workflows?: ReadonlyArray<WorkflowDefinition>,
|
||||
): Node<WorkflowGraphNodeData>[] {
|
||||
return graph.nodes.map((node) => {
|
||||
let provider: ModelProvider | undefined;
|
||||
let modelLabel: string | undefined;
|
||||
let subWorkflowLabel: string | undefined;
|
||||
|
||||
if (node.kind === 'agent' && node.config.kind === 'agent') {
|
||||
const modelId = node.config.model;
|
||||
if (modelId) {
|
||||
provider = inferProvider(modelId);
|
||||
const modelDef = models ? findModel(modelId, models) : undefined;
|
||||
modelLabel = modelDef?.name ?? modelId;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.kind === 'sub-workflow' && node.config.kind === 'sub-workflow') {
|
||||
const swConfig = node.config as SubWorkflowConfig;
|
||||
if (swConfig.workflowId && workflows) {
|
||||
const ref = workflows.find((wf) => wf.id === swConfig.workflowId);
|
||||
subWorkflowLabel = ref?.name ?? swConfig.workflowId;
|
||||
} else if (swConfig.inlineWorkflow) {
|
||||
subWorkflowLabel = 'Inline';
|
||||
}
|
||||
}
|
||||
|
||||
const isSystemNode = node.kind === 'start' || node.kind === 'end';
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: resolveNodeType(node.kind),
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
data: {
|
||||
label: node.label,
|
||||
kind: node.kind,
|
||||
provider,
|
||||
modelLabel,
|
||||
subWorkflowLabel,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: !isSystemNode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Edge styling ──────────────────────────────────────────── */
|
||||
|
||||
const EDGE_COLORS: Record<WorkflowEdgeKind, { stroke: string; markerColor: string }> = {
|
||||
direct: { stroke: '#6366f1', markerColor: '#6366f1' },
|
||||
'fan-out': { stroke: '#f59e0b', markerColor: '#f59e0b' },
|
||||
'fan-in': { stroke: '#10b981', markerColor: '#10b981' },
|
||||
};
|
||||
|
||||
const EDGE_DASH: Record<WorkflowEdgeKind, string | undefined> = {
|
||||
direct: undefined,
|
||||
'fan-out': '6 3',
|
||||
'fan-in': '2 2',
|
||||
};
|
||||
|
||||
const LOOP_EDGE_COLOR = { stroke: '#c084fc', markerColor: '#c084fc' };
|
||||
const LOOP_EDGE_DASH = '8 4';
|
||||
|
||||
function buildEdgeLabel(edge: WfEdge): string | undefined {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (edge.condition && edge.condition.type !== 'always') {
|
||||
parts.push('⚡');
|
||||
}
|
||||
|
||||
if (edge.label) {
|
||||
parts.push(edge.label);
|
||||
}
|
||||
|
||||
if (edge.isLoop && edge.maxIterations) {
|
||||
parts.push(`🔁 ×${edge.maxIterations}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : undefined;
|
||||
}
|
||||
|
||||
export function toCanvasEdges(graph: WorkflowGraph): Edge[] {
|
||||
return graph.edges.map((edge) => {
|
||||
const isLoop = edge.isLoop === true;
|
||||
const isSelfLoop = edge.source === edge.target;
|
||||
const color = isLoop ? LOOP_EDGE_COLOR : (EDGE_COLORS[edge.kind] ?? EDGE_COLORS.direct);
|
||||
const dashArray = isLoop ? LOOP_EDGE_DASH : EDGE_DASH[edge.kind];
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: isSelfLoop ? 'selfLoop' : 'default',
|
||||
animated: isLoop || edge.kind === 'fan-out',
|
||||
deletable: true,
|
||||
label: buildEdgeLabel(edge),
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
|
||||
style: {
|
||||
stroke: color.stroke,
|
||||
strokeWidth: 1.5,
|
||||
...(dashArray ? { strokeDasharray: dashArray } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Position sync ─────────────────────────────────────────── */
|
||||
|
||||
export function fromCanvasPositions(
|
||||
workflow: WorkflowDefinition,
|
||||
canvasNodes: Node[],
|
||||
): WorkflowGraph {
|
||||
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
|
||||
|
||||
return {
|
||||
nodes: workflow.graph.nodes.map((node) => {
|
||||
const pos = positionMap.get(node.id);
|
||||
return pos ? { ...node, position: { x: Math.round(pos.x), y: Math.round(pos.y) } } : node;
|
||||
}),
|
||||
edges: workflow.graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Connection rules ──────────────────────────────────────── */
|
||||
|
||||
export function isWorkflowConnectionAllowed(
|
||||
connection: Connection,
|
||||
graph: WorkflowGraph,
|
||||
): boolean {
|
||||
if (!connection.source || !connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === connection.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start nodes cannot receive incoming edges
|
||||
if (targetNode.kind === 'start') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// End nodes cannot have outgoing edges
|
||||
if (sourceNode.kind === 'end') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No duplicate edges
|
||||
if (graph.edges.some((e) => e.source === connection.source && e.target === connection.target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||
|
||||
function edgeId(source: string, target: string): string {
|
||||
return `edge-${source}-to-${target}`;
|
||||
}
|
||||
|
||||
export function addWorkflowEdge(graph: WorkflowGraph, source: string, target: string): WorkflowGraph {
|
||||
const newEdge: WfEdge = {
|
||||
id: edgeId(source, target),
|
||||
source,
|
||||
target,
|
||||
kind: 'direct',
|
||||
};
|
||||
|
||||
if (graph.edges.some((e) => e.source === source && e.target === target)) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
return { ...graph, edges: [...graph.edges, newEdge] };
|
||||
}
|
||||
|
||||
export function removeWorkflowEdge(graph: WorkflowGraph, removeEdgeId: string): WorkflowGraph {
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Auto-layout via dagre ─────────────────────────────────── */
|
||||
|
||||
const NODE_WIDTH = 170;
|
||||
const NODE_HEIGHT = 52;
|
||||
|
||||
export function autoLayoutWorkflowGraph(graph: WorkflowGraph): WorkflowGraph {
|
||||
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 60,
|
||||
ranksep: 120,
|
||||
marginx: 20,
|
||||
marginy: 20,
|
||||
});
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const layoutedNodes = graph.nodes.map((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: Math.round(dagreNode.x - NODE_WIDTH / 2),
|
||||
y: Math.round(dagreNode.y - NODE_HEIGHT / 2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: graph.edges };
|
||||
}
|
||||
+17
-5
@@ -639,17 +639,28 @@ body {
|
||||
animation: update-banner-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── Thinking process section ────────────────────────────────── */
|
||||
/* ── Turn activity panel ─────────────────────────────────────── */
|
||||
|
||||
@keyframes thinking-process-in {
|
||||
@keyframes turn-activity-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-process-enter {
|
||||
animation: thinking-process-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
.turn-activity-enter {
|
||||
animation: turn-activity-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes turn-activity-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
.turn-activity-row {
|
||||
animation: turn-activity-row-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Respect reduced motion ──────────────────────────────────── */
|
||||
@@ -665,7 +676,8 @@ body {
|
||||
.session-item-enter,
|
||||
.banner-slide-enter,
|
||||
.update-banner-enter,
|
||||
.thinking-process-enter {
|
||||
.turn-activity-enter,
|
||||
.turn-activity-row {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,14 @@ export const ipcChannels = {
|
||||
rescanProjectCustomization: 'project:rescan-customization',
|
||||
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
|
||||
setProjectAgentProfileEnabled: 'project:set-agent-profile-enabled',
|
||||
savePattern: 'patterns:save',
|
||||
deletePattern: 'patterns:delete',
|
||||
setPatternFavorite: 'patterns:set-favorite',
|
||||
saveWorkflow: 'workflows:save',
|
||||
saveWorkflowTemplate: 'workflows:save-template',
|
||||
deleteWorkflow: 'workflows:delete',
|
||||
listWorkflowReferences: 'workflows:list-references',
|
||||
createWorkflowFromTemplate: 'workflows:create-from-template',
|
||||
exportWorkflow: 'workflows:export',
|
||||
importWorkflow: 'workflows:import',
|
||||
createWorkflowSession: 'workflows:create-session',
|
||||
setTheme: 'settings:set-theme',
|
||||
setTerminalHeight: 'settings:set-terminal-height',
|
||||
setNotificationsEnabled: 'settings:set-notifications-enabled',
|
||||
@@ -26,6 +31,8 @@ export const ipcChannels = {
|
||||
deleteMcpServer: 'tooling:mcp:delete',
|
||||
saveLspProfile: 'tooling:lsp:save',
|
||||
deleteLspProfile: 'tooling:lsp:delete',
|
||||
saveWorkspaceAgent: 'workspace-agents:save',
|
||||
deleteWorkspaceAgent: 'workspace-agents:delete',
|
||||
describeTerminal: 'terminal:describe',
|
||||
createTerminal: 'terminal:create',
|
||||
restartTerminal: 'terminal:restart',
|
||||
@@ -66,7 +73,6 @@ export const ipcChannels = {
|
||||
switchProjectGitBranch: 'git:switch-branch',
|
||||
deleteProjectGitBranch: 'git:delete-branch',
|
||||
selectProject: 'selection:project',
|
||||
selectPattern: 'selection:pattern',
|
||||
selectSession: 'selection:session',
|
||||
openAppDataFolder: 'troubleshooting:open-app-data-folder',
|
||||
resetLocalWorkspace: 'troubleshooting:reset-local-workspace',
|
||||
|
||||
+63
-14
@@ -1,6 +1,8 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { WorkflowExportFormat, WorkflowExportResult } from '@shared/domain/workflowSerialization';
|
||||
import type { WorkflowTemplateCategory } from '@shared/domain/workflowTemplate';
|
||||
import type { ReasoningEffort, WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
|
||||
import type {
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitCommitMessageSuggestion,
|
||||
@@ -20,14 +22,55 @@ import type {
|
||||
} from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
projectId: string;
|
||||
patternId: string;
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
export interface SavePatternInput {
|
||||
pattern: PatternDefinition;
|
||||
export type CreateWorkflowSessionInput = CreateSessionInput;
|
||||
|
||||
export interface SaveWorkflowInput {
|
||||
workflow: WorkflowDefinition;
|
||||
}
|
||||
|
||||
export interface SaveWorkflowTemplateInput {
|
||||
workflowId: string;
|
||||
options?: {
|
||||
templateId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: WorkflowTemplateCategory;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateWorkflowFromTemplateInput {
|
||||
templateId: string;
|
||||
options?: {
|
||||
workflowId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportWorkflowInput {
|
||||
workflowId: string;
|
||||
format: WorkflowExportFormat;
|
||||
}
|
||||
|
||||
export interface ImportWorkflowInput {
|
||||
content: string;
|
||||
format: 'yaml' | 'json';
|
||||
options?: {
|
||||
save?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportWorkflowResult {
|
||||
workflow: WorkflowDefinition;
|
||||
workspace?: WorkspaceState;
|
||||
}
|
||||
|
||||
export interface SendSessionMessageInput {
|
||||
@@ -35,6 +78,7 @@ export interface SendSessionMessageInput {
|
||||
content: string;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
messageMode?: MessageMode;
|
||||
promptInvocation?: ProjectPromptInvocation;
|
||||
}
|
||||
|
||||
export interface CancelSessionTurnInput {
|
||||
@@ -103,11 +147,6 @@ export interface SetSessionArchivedInput {
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
export interface SetPatternFavoriteInput {
|
||||
patternId: string;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface SaveMcpServerInput {
|
||||
server: McpServerDefinition;
|
||||
}
|
||||
@@ -116,6 +155,10 @@ export interface SaveLspProfileInput {
|
||||
profile: LspProfileDefinition;
|
||||
}
|
||||
|
||||
export interface SaveWorkspaceAgentInput {
|
||||
agent: WorkspaceAgentDefinition;
|
||||
}
|
||||
|
||||
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
|
||||
export interface RescanProjectConfigsInput {
|
||||
@@ -265,15 +308,23 @@ export interface ElectronApi {
|
||||
rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise<WorkspaceState>;
|
||||
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
|
||||
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
|
||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||
saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise<WorkspaceState>;
|
||||
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||
listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]>;
|
||||
createWorkflowFromTemplate(input: CreateWorkflowFromTemplateInput): Promise<WorkspaceState>;
|
||||
exportWorkflow(input: ExportWorkflowInput): Promise<WorkflowExportResult>;
|
||||
importWorkflow(input: ImportWorkflowInput): Promise<ImportWorkflowResult>;
|
||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
|
||||
saveWorkspaceAgent(input: SaveWorkspaceAgentInput): Promise<WorkspaceState>;
|
||||
deleteWorkspaceAgent(agentId: string): Promise<WorkspaceState>;
|
||||
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||
createWorkflowSession(input: CreateWorkflowSessionInput): Promise<WorkspaceState>;
|
||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||
setSessionMessagePinned(input: SetSessionMessagePinnedInput): Promise<WorkspaceState>;
|
||||
@@ -294,9 +345,7 @@ export interface ElectronApi {
|
||||
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
|
||||
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
||||
selectProject(projectId?: string): Promise<WorkspaceState>;
|
||||
selectPattern(patternId?: string): Promise<WorkspaceState>;
|
||||
selectSession(sessionId?: string): Promise<WorkspaceState>;
|
||||
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
|
||||
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
|
||||
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
|
||||
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
|
||||
@@ -336,5 +385,5 @@ export interface ElectronApi {
|
||||
|
||||
export interface RendererSelectionState {
|
||||
selectedProject?: ProjectRecord;
|
||||
selectedPattern?: PatternDefinition;
|
||||
selectedWorkflow?: WorkflowDefinition;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||
import type {
|
||||
ReasoningEffort,
|
||||
WorkflowDefinition,
|
||||
WorkflowOrchestrationMode,
|
||||
WorkflowValidationIssue,
|
||||
} from '@shared/domain/workflow';
|
||||
|
||||
export type {
|
||||
GroupChatModeSettings,
|
||||
GroupChatSelectionStrategy,
|
||||
HandoffModeSettings,
|
||||
HandoffToolCallFiltering,
|
||||
OrchestrationModeSettings,
|
||||
} from '@shared/domain/workflow';
|
||||
|
||||
export interface SidecarModeCapability {
|
||||
available: boolean;
|
||||
@@ -52,7 +66,7 @@ export interface SidecarModelCapability {
|
||||
|
||||
export interface SidecarCapabilities {
|
||||
runtime: 'dotnet-maf';
|
||||
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
|
||||
modes: Record<WorkflowOrchestrationMode | 'magentic', SidecarModeCapability>;
|
||||
models: SidecarModelCapability[];
|
||||
runtimeTools: RuntimeToolDefinition[];
|
||||
connection: SidecarConnectionDiagnostics;
|
||||
@@ -63,10 +77,11 @@ export interface DescribeCapabilitiesCommand {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ValidatePatternCommand {
|
||||
type: 'validate-pattern';
|
||||
export interface ValidateWorkflowCommand {
|
||||
type: 'validate-workflow';
|
||||
requestId: string;
|
||||
pattern: PatternDefinition;
|
||||
workflow: WorkflowDefinition;
|
||||
workflowLibrary?: WorkflowDefinition[];
|
||||
}
|
||||
|
||||
export type InteractionMode = 'interactive' | 'plan';
|
||||
@@ -87,9 +102,11 @@ export interface RunTurnCommand {
|
||||
mode?: InteractionMode;
|
||||
messageMode?: MessageMode;
|
||||
projectInstructions?: string;
|
||||
pattern: PatternDefinition;
|
||||
workflow: WorkflowDefinition;
|
||||
workflowLibrary?: WorkflowDefinition[];
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
promptInvocation?: ProjectPromptInvocation;
|
||||
tooling?: RunTurnToolingConfig;
|
||||
resumeFromCheckpoint?: WorkflowCheckpointResume;
|
||||
}
|
||||
@@ -144,7 +161,7 @@ export interface CopilotSessionListFilter {
|
||||
|
||||
export type SidecarCommand =
|
||||
| DescribeCapabilitiesCommand
|
||||
| ValidatePatternCommand
|
||||
| ValidateWorkflowCommand
|
||||
| RunTurnCommand
|
||||
| CancelTurnCommand
|
||||
| ResolveApprovalCommand
|
||||
@@ -222,10 +239,10 @@ export interface CapabilitiesEvent {
|
||||
capabilities: SidecarCapabilities;
|
||||
}
|
||||
|
||||
export interface PatternValidationEvent {
|
||||
type: 'pattern-validation';
|
||||
export interface WorkflowValidationEvent {
|
||||
type: 'workflow-validation';
|
||||
requestId: string;
|
||||
issues: PatternValidationIssue[];
|
||||
issues: WorkflowValidationIssue[];
|
||||
}
|
||||
|
||||
export interface TurnDeltaEvent {
|
||||
@@ -273,6 +290,7 @@ export interface AgentActivityEvent {
|
||||
sourceAgentName?: string;
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
toolArguments?: Record<string, unknown>;
|
||||
fileChanges?: ToolCallFileChangePreview[];
|
||||
}
|
||||
|
||||
@@ -589,7 +607,7 @@ export interface CommandCompleteEvent {
|
||||
|
||||
export type SidecarEvent =
|
||||
| CapabilitiesEvent
|
||||
| PatternValidationEvent
|
||||
| WorkflowValidationEvent
|
||||
| TurnDeltaEvent
|
||||
| TurnCompleteEvent
|
||||
| MessageReclassifiedEvent
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user