mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 04:58:44 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eac1da70c | ||
|
|
b8553c6593 | ||
|
|
26847cf00d | ||
|
|
fce0430091 | ||
|
|
fa355197cb | ||
|
|
5f263002f0 | ||
|
|
1376de4148 | ||
|
|
5ce6bb4b0a | ||
|
|
47d3fb0a29 | ||
|
|
4da31cbdd6 | ||
|
|
f15323e37e | ||
|
|
f8fe9d866e | ||
|
|
6c24754749 | ||
|
|
2a7c627008 | ||
|
|
3a3e1d5eab | ||
|
|
9a9332369b | ||
|
|
67fb950e61 | ||
|
|
f7d376fc41 | ||
|
|
59ea028a24 | ||
|
|
c7910e6e4b | ||
|
|
418946b854 | ||
|
|
10fcaf2b92 | ||
|
|
34fb5420a4 | ||
|
|
05b3771544 | ||
|
|
9243ec10a2 | ||
|
|
1f49436d8a | ||
|
|
61b5788e78 | ||
|
|
875c6a3706 | ||
|
|
b4aaf62179 | ||
|
|
4ffd6ad8d4 | ||
|
|
c22bd32f97 | ||
|
|
dad9769fa1 | ||
|
|
56e2f8669c | ||
|
|
d6008da0af | ||
|
|
575aca360a | ||
|
|
649eeddff3 | ||
|
|
521be0de3c | ||
|
|
fdfa6f3046 | ||
|
|
0e9d77c745 | ||
|
|
bed6427dfc | ||
|
|
6068ee42e1 | ||
|
|
41d19e007d | ||
|
|
cc626a3152 | ||
|
|
8d08a1df1a | ||
|
|
08886f9078 | ||
|
|
1fdb1c27d5 | ||
|
|
4e34d2abfc | ||
|
|
7b9c4d140c | ||
|
|
fde82bef4d | ||
|
|
6ef44c6689 | ||
|
|
872476b2e3 | ||
|
|
b02a90a15f | ||
|
|
d3fb0a64c5 | ||
|
|
efcee8a621 | ||
|
|
543cf677ab | ||
|
|
952e69da9f | ||
|
|
fb5970cfa7 | ||
|
|
6714b93bed | ||
|
|
b038019954 | ||
|
|
ec92b61663 | ||
|
|
524380e2b5 | ||
|
|
5324a5121d | ||
|
|
e3660254df | ||
|
|
e36b00ff1d | ||
|
|
c70a5c6612 | ||
|
|
fa8f6ef4b3 | ||
|
|
b9e73831e8 | ||
|
|
0e2f9b8ae5 | ||
|
|
e46193ae53 | ||
|
|
008d8c1bd0 | ||
|
|
e85906669f | ||
|
|
2c165e453f | ||
|
|
931ec27f42 | ||
|
|
3e71de98e8 | ||
|
|
6d698ca233 | ||
|
|
574455729b | ||
|
|
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'
|
||||
|
||||
+59
-32
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ flowchart LR
|
||||
Git[Local git repositories]
|
||||
Sidecar[.NET sidecar]
|
||||
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
|
||||
Telemetry[OTLP collector<br/>Aspire Dashboard]
|
||||
OS[Native windowing<br/>and desktop integration]
|
||||
|
||||
User --> Renderer
|
||||
@@ -46,6 +47,7 @@ flowchart LR
|
||||
Main <--> Git
|
||||
Main <--> Sidecar
|
||||
Sidecar <--> Copilot
|
||||
Sidecar --> Telemetry
|
||||
Main <--> OS
|
||||
```
|
||||
|
||||
@@ -55,9 +57,9 @@ 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 |
|
||||
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
||||
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
||||
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, optional OTLP trace export, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio, OTLP to external collectors |
|
||||
| External systems | Git data, Copilot account/model access, telemetry collectors, 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.
|
||||
|
||||
@@ -68,7 +70,7 @@ Aryx runs as a multi-process desktop application:
|
||||
1. The **renderer** displays the workspace and captures user intent.
|
||||
2. The **preload bridge** exposes a small, typed API into the browser context.
|
||||
3. The **main process** validates and mutates application state, persists it, and manages native integrations.
|
||||
4. The **sidecar** executes Copilot-backed turns and streams structured execution events back.
|
||||
4. The **sidecar** executes Copilot-backed turns, can export OpenTelemetry traces to an external collector, and streams structured execution events back.
|
||||
|
||||
The sidecar is intentionally separate from the Electron main process so that AI runtime concerns stay isolated from UI and persistence concerns.
|
||||
|
||||
@@ -108,7 +110,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 +127,42 @@ 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 provider module 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.
|
||||
The sidecar now keeps that Copilot-specific behavior behind provider seams. Core execution uses shared `IAgentProvider`, `IProviderTurnSupport`, `ProviderSessionEvent`, `ProviderAgentBundle`, `TurnExecutionState`, and `AgentWorkflowTurnRunner` abstractions, while `Services/Providers/Copilot/` owns SDK-specific bundle creation, transcript projection, approvals, user input, MCP OAuth, exit-plan-mode handling, CLI/session management, and event adaptation. Provider adapters implement `IProviderEventAdapter.TryAdapt()` to translate provider-native session events into the normalized `ProviderSessionEvent` records that `TurnExecutionState` consumes. The Copilot adapter is the first concrete implementation; adding a second provider means writing a new adapter without changing the turn-state machine or the main-process contract.
|
||||
|
||||
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.
|
||||
Streamed text assembly is a single-owner responsibility. The sidecar's `StreamingTranscriptBuffer` resolves content deltas and snapshot-mode content from provider events, producing authoritative `content` fields on every message-delta event. The main process forwards that resolved content to the renderer, which trusts it directly without re-running merge logic. This eliminates the duplicate text-assembly that previously caused glitches when the sidecar, main process, and renderer each independently merged streaming text.
|
||||
|
||||
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.
|
||||
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
|
||||
|
||||
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.
|
||||
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 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 through shared orchestration helpers rather than Copilot-specific workflow code.
|
||||
|
||||
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 +183,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 +197,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,19 +215,21 @@ 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
|
||||
|
||||
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior.
|
||||
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior. On the sidecar side, raw provider events are first normalized into sidecar-owned provider event records (via `IProviderEventAdapter`) before they become streamed run activity, so future providers can plug into the same transport without reshaping the main-process contract. Each event carries capability metadata so the main process and renderer can degrade gracefully when a provider does not support intent, reasoning, or fine-grained tool progress.
|
||||
|
||||
The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics:
|
||||
|
||||
- **Workflow activity events**: agent activity records preserve agent, tool, and optional sub-workflow context (`subworkflowNodeId`, `subworkflowName`) so the UI can distinguish root-level activity from nested execution without rebuilding workflow ancestry in Electron
|
||||
- **Sub-workflow lifecycle events**: `subworkflow-started` and `subworkflow-completed` are emitted when nested workflow executors begin and finish, so the Activity panel can surface sub-workflow groups as first-class runtime activity
|
||||
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined
|
||||
- **Skill invocation events**: emitted when an agent-side skill is triggered
|
||||
- **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming
|
||||
- **Assistant intent and reasoning-delta events**: optional Copilot SDK metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces
|
||||
- **Assistant intent and reasoning-delta events**: optional provider metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces; normalized through the provider adapter layer so the UI consumes them uniformly regardless of provider origin
|
||||
- **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity
|
||||
- **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream
|
||||
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
|
||||
@@ -235,7 +246,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 +308,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,16 +317,16 @@ 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
|
||||
|
||||
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
|
||||
- agent activity is surfaced with optional sub-workflow context
|
||||
- turn-scoped lifecycle events (sub-agent, sub-workflow, hook, skill, compaction, usage) are streamed
|
||||
- runs are surfaced inline as collapsible turn activity panels
|
||||
- failures are represented explicitly
|
||||
|
||||
This improves trust and debuggability, especially for multi-agent workflows.
|
||||
@@ -343,7 +354,8 @@ That gives the system:
|
||||
|
||||
The main process owns desktop concerns such as:
|
||||
|
||||
- native window creation
|
||||
- native window creation (main window and quick prompt overlay)
|
||||
- global hotkey registration
|
||||
- title bar behavior
|
||||
- background process management
|
||||
- filesystem access
|
||||
@@ -351,13 +363,28 @@ The main process owns desktop concerns such as:
|
||||
|
||||
This keeps those concerns out of the renderer while still letting the UI feel native.
|
||||
|
||||
### Multi-window setup and Quick Prompt
|
||||
|
||||
Aryx runs two `BrowserWindow` instances:
|
||||
|
||||
- the **main window** — the full workspace UI
|
||||
- the **quick prompt window** — a frameless, transparent, always-on-top overlay for one-off AI questions
|
||||
|
||||
The quick prompt window loads a separate, lightweight renderer entry (`quickprompt.html` / `quickprompt.tsx`) with its own preload script (`preload/quickprompt.ts`). This keeps its bundle small and avoids loading the full workspace renderer. It communicates with the main process through dedicated IPC channels prefixed with `quick-prompt:`.
|
||||
|
||||
A **global hotkey service** (`src/main/services/globalHotkey.ts`) registers a system-wide keyboard shortcut (default `Super+Shift+A`, configurable in settings) using Electron's `globalShortcut` API. Pressing the hotkey toggles the quick prompt window. The service re-registers the shortcut when the configured hotkey changes and unregisters on app quit.
|
||||
|
||||
Quick prompt sessions are real `SessionRecord` instances created on the scratchpad project. The main process routes matching session events from the sidecar to the quick prompt window's `webContents`. After a response completes, the user can discard the session, close the window (preserving the session for later access in the main UI), or continue the conversation in the main window.
|
||||
|
||||
The `window-all-closed` handler excludes the quick prompt window so the app does not stay alive solely because the hidden popup exists.
|
||||
|
||||
## Build and release architecture
|
||||
|
||||
Aryx ships as an Electron application bundled together with a self-contained .NET sidecar.
|
||||
|
||||
The build pipeline is organized around three layers:
|
||||
|
||||
- building the Electron renderer and main process assets
|
||||
- building the Electron renderer entries (main workspace and quick prompt) and main process assets
|
||||
- publishing the sidecar for the target runtime
|
||||
- packaging platform artifacts with electron-builder
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -45,6 +45,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
|
||||
| Message actions | Copy, pin, edit-and-resend, and regenerate individual messages |
|
||||
| Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) |
|
||||
| System tray | Minimize to tray, quick-launch scratchpads, and see running session count |
|
||||
| Quick Prompt | System-wide hotkey (`Win+Shift+A` / `Cmd+Shift+A`) summons a floating popup for instant AI questions from any app |
|
||||
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
|
||||
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
|
||||
|
||||
@@ -53,11 +54,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
|
||||
@@ -89,12 +92,21 @@ bun run test # typecheck + unit tests
|
||||
bun run sidecar:test # backend tests
|
||||
bun run build # full build (electron + sidecar)
|
||||
|
||||
bun run aspire # start the standalone Aspire Dashboard in Docker
|
||||
bun run dev:otel # start Aspire + Aryx dev with OTLP export enabled
|
||||
bun run package # package for current platform → release/
|
||||
bun run installer # create installable artifact
|
||||
bun run publish-release # publish to GitHub Releases
|
||||
bun run release # bump patch version, commit, tag, and push
|
||||
bun run release minor # bump minor version instead (use major for breaking releases)
|
||||
```
|
||||
|
||||
For local observability, `bun run dev:otel` starts the Aspire Dashboard UI at `http://localhost:18888`
|
||||
and configures the sidecar to export OpenTelemetry traces to `http://localhost:4317` over OTLP/gRPC.
|
||||
This dev workflow requires Docker.
|
||||
|
||||
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
|
||||
The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags.
|
||||
|
||||
## Trademarks
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export default defineConfig({
|
||||
preload: {
|
||||
build: {
|
||||
outDir: 'dist-electron/preload',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
quickprompt: resolve(__dirname, 'src/preload/quickprompt.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
@@ -32,6 +38,12 @@ export default defineConfig({
|
||||
root: 'src/renderer',
|
||||
build: {
|
||||
outDir: 'dist/renderer',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
quickprompt: resolve(__dirname, 'src/renderer/quickprompt.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
|
||||
+4
-1
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "aryx",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.30",
|
||||
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:otel": "bun run scripts/dev-with-otel.ts",
|
||||
"aspire": "bun run scripts/launch-aspire-dashboard.ts",
|
||||
"build:electron": "electron-vite build",
|
||||
"build": "bun run build:electron && bun run sidecar:build",
|
||||
"package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never",
|
||||
"installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never",
|
||||
"publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always",
|
||||
"release": "bun run scripts/release.ts",
|
||||
"preview": "electron-vite preview",
|
||||
"lsp:typescript": "typescript-language-server --stdio",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
export const aspireDashboardContainerName = 'aryx-aspire-dashboard';
|
||||
export const aspireDashboardImage = 'mcr.microsoft.com/dotnet/aspire-dashboard:latest';
|
||||
export const aspireDashboardUrls = {
|
||||
dashboard: 'http://localhost:18888',
|
||||
otlpGrpc: 'http://localhost:4317',
|
||||
otlpHttp: 'http://localhost:4318',
|
||||
} as const;
|
||||
|
||||
type ContainerState = 'missing' | 'running' | 'stopped';
|
||||
|
||||
export interface AspireDashboardHandle {
|
||||
readonly dashboardUrl: string;
|
||||
readonly otlpGrpcEndpoint: string;
|
||||
readonly otlpHttpEndpoint: string;
|
||||
readonly startedByScript: boolean;
|
||||
}
|
||||
|
||||
export function createAspireDashboardRunArgs(
|
||||
containerName = aspireDashboardContainerName,
|
||||
image = aspireDashboardImage,
|
||||
): string[] {
|
||||
return [
|
||||
'run',
|
||||
'--rm',
|
||||
'-d',
|
||||
'--name',
|
||||
containerName,
|
||||
'-p',
|
||||
'18888:18888',
|
||||
'-p',
|
||||
'4317:18889',
|
||||
'-p',
|
||||
'4318:18890',
|
||||
'-e',
|
||||
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
|
||||
image,
|
||||
];
|
||||
}
|
||||
|
||||
export function createOpenTelemetryEnvironment(
|
||||
baseEnvironment: NodeJS.ProcessEnv,
|
||||
endpoint: string = aspireDashboardUrls.otlpGrpc,
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...baseEnvironment,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
|
||||
};
|
||||
}
|
||||
|
||||
export async function startAspireDashboard(): Promise<AspireDashboardHandle> {
|
||||
await ensureDockerAvailable();
|
||||
|
||||
let startedByScript = false;
|
||||
const containerState = await getContainerState(aspireDashboardContainerName);
|
||||
if (containerState !== 'running') {
|
||||
if (containerState === 'stopped') {
|
||||
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
|
||||
}
|
||||
|
||||
await runCommand('docker', createAspireDashboardRunArgs(), 'pipe');
|
||||
startedByScript = true;
|
||||
}
|
||||
|
||||
await waitForTcpPort(18888);
|
||||
await waitForTcpPort(4317);
|
||||
|
||||
return {
|
||||
dashboardUrl: aspireDashboardUrls.dashboard,
|
||||
otlpGrpcEndpoint: aspireDashboardUrls.otlpGrpc,
|
||||
otlpHttpEndpoint: aspireDashboardUrls.otlpHttp,
|
||||
startedByScript,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopAspireDashboard(): Promise<void> {
|
||||
const containerState = await getContainerState(aspireDashboardContainerName);
|
||||
if (containerState === 'missing') {
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
|
||||
}
|
||||
|
||||
async function ensureDockerAvailable(): Promise<void> {
|
||||
const cliVersion = await runCommandCapture('docker', ['--version']);
|
||||
if (cliVersion.exitCode !== 0) {
|
||||
throw new Error(
|
||||
'Docker is required to run the standalone Aspire Dashboard. Install Docker Desktop and ensure `docker` is available on PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
if (await isDockerDaemonAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await hasDockerDesktopCli()) {
|
||||
console.log('Docker Desktop is not running. Starting it now...');
|
||||
await runCommand('docker', ['desktop', 'start', '--detach'], 'pipe');
|
||||
await waitForDockerDaemon();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Docker is installed but the daemon is not running. Start Docker Desktop and rerun the Aspire command.',
|
||||
);
|
||||
}
|
||||
|
||||
async function getContainerState(containerName: string): Promise<ContainerState> {
|
||||
const result = await runCommandCapture('docker', [
|
||||
'container',
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{.State.Status}}',
|
||||
containerName,
|
||||
]);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return result.stdout.trim() === 'running' ? 'running' : 'stopped';
|
||||
}
|
||||
|
||||
async function waitForTcpPort(port: number, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await canConnect(port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for localhost:${port} to accept connections.`);
|
||||
}
|
||||
|
||||
async function isDockerDaemonAvailable(): Promise<boolean> {
|
||||
const result = await runCommandCapture('docker', ['info', '--format', '{{.ServerVersion}}']);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function hasDockerDesktopCli(): Promise<boolean> {
|
||||
const result = await runCommandCapture('docker', ['desktop', 'version']);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function waitForDockerDaemon(timeoutMs = 120_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isDockerDaemonAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await delay(1_000);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for Docker Desktop to start and accept API connections.');
|
||||
}
|
||||
|
||||
async function canConnect(port: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finalize = (connected: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(connected);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finalize(true));
|
||||
socket.once('error', () => finalize(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: string[], stdio: 'inherit' | 'pipe'): Promise<void> {
|
||||
const result = await spawnProcess(command, args, stdio);
|
||||
if (result.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.stderr.trim().length > 0) {
|
||||
throw new Error(result.stderr.trim());
|
||||
}
|
||||
|
||||
throw new Error(`${command} exited with code ${result.exitCode}.`);
|
||||
}
|
||||
|
||||
async function runCommandCapture(command: string, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return spawnProcess(command, args, 'pipe');
|
||||
}
|
||||
|
||||
async function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
stdio: 'inherit' | 'pipe',
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (stdio === 'pipe') {
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${command} exited because of signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
createOpenTelemetryEnvironment,
|
||||
startAspireDashboard,
|
||||
stopAspireDashboard,
|
||||
} from './aspireDashboard';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const signalExitCodes: Partial<Record<NodeJS.Signals, number>> = {
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
};
|
||||
|
||||
const dashboard = await startAspireDashboard();
|
||||
|
||||
console.log(`Aspire Dashboard: ${dashboard.dashboardUrl}`);
|
||||
console.log(`Aryx sidecar OTLP endpoint: ${dashboard.otlpGrpcEndpoint}`);
|
||||
|
||||
const child = spawn(process.execPath, ['run', 'dev'], {
|
||||
cwd: repositoryRoot,
|
||||
env: createOpenTelemetryEnvironment(process.env, dashboard.otlpGrpcEndpoint),
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let exitResult: { code: number | null; signal: NodeJS.Signals | null };
|
||||
|
||||
try {
|
||||
exitResult = await new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
if (dashboard.startedByScript) {
|
||||
await stopAspireDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
if (exitResult.signal) {
|
||||
process.exitCode = signalExitCodes[exitResult.signal] ?? 1;
|
||||
} else {
|
||||
process.exitCode = exitResult.code ?? 1;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { startAspireDashboard } from './aspireDashboard';
|
||||
|
||||
const dashboard = await startAspireDashboard();
|
||||
|
||||
console.log(
|
||||
`${dashboard.startedByScript ? 'Started' : 'Reusing'} Aspire Dashboard at ${dashboard.dashboardUrl}`,
|
||||
);
|
||||
console.log(`OTLP/gRPC receiver: ${dashboard.otlpGrpcEndpoint}`);
|
||||
console.log(`OTLP/HTTP receiver: ${dashboard.otlpHttpEndpoint}`);
|
||||
@@ -0,0 +1,313 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const packageJsonPath = resolve(repositoryRoot, 'package.json');
|
||||
|
||||
export type ReleaseBump = 'patch' | 'minor' | 'major';
|
||||
|
||||
interface PackageJsonShape {
|
||||
version?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GitCommandResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type GitRunner = (args: string[]) => Promise<GitCommandResult>;
|
||||
|
||||
export interface ReleaseDependencies {
|
||||
readText(path: string): Promise<string>;
|
||||
writeText(path: string, content: string): Promise<void>;
|
||||
runGit: GitRunner;
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
export interface ReleaseWorkflowOptions {
|
||||
repositoryRoot: string;
|
||||
packageJsonPath: string;
|
||||
bumpArg?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseResult {
|
||||
bump: ReleaseBump;
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
tagName: string;
|
||||
localBranch: string;
|
||||
remote: string;
|
||||
upstreamBranch: string;
|
||||
}
|
||||
|
||||
interface UpstreamTarget {
|
||||
remote: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
function createGitRunner(cwd: string): GitRunner {
|
||||
return async (args) =>
|
||||
new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', rejectPromise);
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectPromise(new Error(`git ${args.join(' ')} exited because of signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePromise({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createReleaseDependencies(cwd: string): ReleaseDependencies {
|
||||
return {
|
||||
readText: (path) => readFile(path, 'utf8'),
|
||||
writeText: (path, content) => writeFile(path, content, 'utf8'),
|
||||
runGit: createGitRunner(cwd),
|
||||
log: console.log,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePackageJson(packageJsonText: string): PackageJsonShape {
|
||||
try {
|
||||
return JSON.parse(packageJsonText) as PackageJsonShape;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Could not parse package.json: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseReleaseBump(value?: string): ReleaseBump {
|
||||
if (value === undefined) {
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
if (value === 'patch' || value === 'minor' || value === 'major') {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported release bump "${value}". Use patch, minor, or major.`);
|
||||
}
|
||||
|
||||
export function readPackageVersion(packageJsonText: string): string {
|
||||
const packageJson = parsePackageJson(packageJsonText);
|
||||
|
||||
if (typeof packageJson.version !== 'string') {
|
||||
throw new Error('package.json is missing a string version field.');
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function parseSemver(version: string): [number, number, number] {
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported version "${version}". Expected a simple x.y.z semantic version.`);
|
||||
}
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
export function incrementVersion(version: string, bump: ReleaseBump): string {
|
||||
const [major, minor, patch] = parseSemver(version);
|
||||
|
||||
switch (bump) {
|
||||
case 'major':
|
||||
return `${major + 1}.0.0`;
|
||||
case 'minor':
|
||||
return `${major}.${minor + 1}.0`;
|
||||
case 'patch':
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
function detectNewline(packageJsonText: string): '\r\n' | '\n' {
|
||||
return packageJsonText.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
export function updatePackageJsonVersion(packageJsonText: string, nextVersion: string): string {
|
||||
const newline = detectNewline(packageJsonText);
|
||||
const packageJson = parsePackageJson(packageJsonText);
|
||||
|
||||
if (typeof packageJson.version !== 'string') {
|
||||
throw new Error('package.json is missing a string version field.');
|
||||
}
|
||||
|
||||
packageJson.version = nextVersion;
|
||||
|
||||
return `${JSON.stringify(packageJson, null, 2).replace(/\n/g, newline)}${newline}`;
|
||||
}
|
||||
|
||||
function formatGitFailure(args: string[], result: GitCommandResult): string {
|
||||
const detail = result.stderr.trim() || result.stdout.trim();
|
||||
|
||||
if (detail.length > 0) {
|
||||
return `git ${args.join(' ')} failed: ${detail}`;
|
||||
}
|
||||
|
||||
return `git ${args.join(' ')} failed with exit code ${result.exitCode}.`;
|
||||
}
|
||||
|
||||
async function runGitOrThrow(runGit: GitRunner, args: string[], context: string): Promise<GitCommandResult> {
|
||||
const result = await runGit(args);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${context} ${formatGitFailure(args, result)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function ensureCleanWorktree(runGit: GitRunner): Promise<void> {
|
||||
const result = await runGitOrThrow(runGit, ['status', '--porcelain'], 'Could not inspect the current git worktree.');
|
||||
|
||||
if (result.stdout.trim().length > 0) {
|
||||
throw new Error('Release requires a clean git worktree. Commit, stash, or discard changes first.');
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLocalBranch(runGit: GitRunner): Promise<string> {
|
||||
const result = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error('Release requires a checked-out branch. Detached HEAD is not supported.');
|
||||
}
|
||||
|
||||
const branch = result.stdout.trim();
|
||||
|
||||
if (branch.length === 0) {
|
||||
throw new Error('Release could not determine the current branch name.');
|
||||
}
|
||||
|
||||
return branch;
|
||||
}
|
||||
|
||||
function normalizeUpstreamBranch(mergeRef: string): string {
|
||||
const prefix = 'refs/heads/';
|
||||
|
||||
if (!mergeRef.startsWith(prefix)) {
|
||||
throw new Error(`Release expected an upstream branch ref, received "${mergeRef}".`);
|
||||
}
|
||||
|
||||
return mergeRef.slice(prefix.length);
|
||||
}
|
||||
|
||||
async function resolveUpstreamTarget(runGit: GitRunner, localBranch: string): Promise<UpstreamTarget> {
|
||||
const remoteResult = await runGit(['config', '--get', `branch.${localBranch}.remote`]);
|
||||
const mergeResult = await runGit(['config', '--get', `branch.${localBranch}.merge`]);
|
||||
|
||||
if (remoteResult.exitCode !== 0 || mergeResult.exitCode !== 0) {
|
||||
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
|
||||
}
|
||||
|
||||
const remote = remoteResult.stdout.trim();
|
||||
const mergeRef = mergeResult.stdout.trim();
|
||||
|
||||
if (remote.length === 0 || mergeRef.length === 0) {
|
||||
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
|
||||
}
|
||||
|
||||
return {
|
||||
remote,
|
||||
branch: normalizeUpstreamBranch(mergeRef),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureTagDoesNotExist(runGit: GitRunner, tagName: string): Promise<void> {
|
||||
const result = await runGitOrThrow(runGit, ['tag', '--list', tagName], 'Could not inspect existing git tags.');
|
||||
|
||||
if (result.stdout.trim().length > 0) {
|
||||
throw new Error(`Tag ${tagName} already exists. Choose a different release version.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runReleaseWorkflow(
|
||||
options: ReleaseWorkflowOptions,
|
||||
dependencies: ReleaseDependencies,
|
||||
): Promise<ReleaseResult> {
|
||||
const bump = parseReleaseBump(options.bumpArg);
|
||||
const packageJsonText = await dependencies.readText(options.packageJsonPath);
|
||||
const currentVersion = readPackageVersion(packageJsonText);
|
||||
const nextVersion = incrementVersion(currentVersion, bump);
|
||||
const tagName = `v${nextVersion}`;
|
||||
const packageJsonFile = relative(options.repositoryRoot, options.packageJsonPath);
|
||||
|
||||
await ensureCleanWorktree(dependencies.runGit);
|
||||
|
||||
const localBranch = await resolveLocalBranch(dependencies.runGit);
|
||||
const upstreamTarget = await resolveUpstreamTarget(dependencies.runGit, localBranch);
|
||||
|
||||
await ensureTagDoesNotExist(dependencies.runGit, tagName);
|
||||
|
||||
const updatedPackageJson = updatePackageJsonVersion(packageJsonText, nextVersion);
|
||||
const commitMessage = `chore: release ${tagName}`;
|
||||
const tagMessage = `Release ${tagName}`;
|
||||
|
||||
await dependencies.writeText(options.packageJsonPath, updatedPackageJson);
|
||||
await runGitOrThrow(dependencies.runGit, ['add', packageJsonFile], `Could not stage ${packageJsonFile}.`);
|
||||
await runGitOrThrow(dependencies.runGit, ['commit', '-m', commitMessage], 'Could not create the release commit.');
|
||||
await runGitOrThrow(dependencies.runGit, ['tag', '-a', tagName, '-m', tagMessage], 'Could not create the annotated release tag.');
|
||||
await runGitOrThrow(
|
||||
dependencies.runGit,
|
||||
['push', '--follow-tags', upstreamTarget.remote, `HEAD:${upstreamTarget.branch}`],
|
||||
'Could not push the release commit and tag.',
|
||||
);
|
||||
|
||||
dependencies.log(`Released ${tagName} from ${localBranch} to ${upstreamTarget.remote}/${upstreamTarget.branch}.`);
|
||||
|
||||
return {
|
||||
bump,
|
||||
currentVersion,
|
||||
nextVersion,
|
||||
tagName,
|
||||
localBranch,
|
||||
remote: upstreamTarget.remote,
|
||||
upstreamBranch: upstreamTarget.branch,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(argv: string[]): Promise<void> {
|
||||
await runReleaseWorkflow(
|
||||
{
|
||||
repositoryRoot,
|
||||
packageJsonPath,
|
||||
bumpArg: argv[0],
|
||||
},
|
||||
createReleaseDependencies(repositoryRoot),
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main(process.argv.slice(2)).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -2,17 +2,19 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260311.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.1.0-preview.260410.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.1.0" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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
|
||||
@@ -346,10 +452,13 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
||||
public string ActivityType { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? SubworkflowNodeId { get; init; }
|
||||
public string? SubworkflowName { get; init; }
|
||||
public string? SourceAgentId { get; init; }
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -569,6 +678,7 @@ public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
public string? AgentName { get; init; }
|
||||
public string? ToolName { get; init; }
|
||||
public string? PermissionKind { get; init; }
|
||||
public string? ApprovalToolKey { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public PermissionDetailDto? PermissionDetail { get; init; }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal abstract record ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantMessageDeltaEvent(
|
||||
string MessageId,
|
||||
string? DeltaContent) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantMessageEvent(
|
||||
string MessageId,
|
||||
string? Content,
|
||||
bool HasToolRequests) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionStartEvent(
|
||||
string ToolCallId,
|
||||
string ToolName,
|
||||
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionProgressEvent(
|
||||
string ToolCallId,
|
||||
string? ProgressMessage) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionPartialResultEvent(
|
||||
string ToolCallId,
|
||||
string? PartialOutput) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionCompleteEvent(
|
||||
string ToolCallId,
|
||||
bool Success,
|
||||
string? ResultContent,
|
||||
string? DetailedResultContent,
|
||||
string? Error) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantReasoningDeltaEvent(
|
||||
string? ReasoningId,
|
||||
string? DeltaContent) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantReasoningEvent(
|
||||
string? ReasoningId,
|
||||
string? Content) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantTurnStartEvent(string? TurnId) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantTurnEndEvent(string? TurnId) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentStartedEvent(
|
||||
string? ToolCallId,
|
||||
string? AgentName,
|
||||
string? AgentDisplayName,
|
||||
string? AgentDescription) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentCompletedEvent(
|
||||
string? ToolCallId,
|
||||
string? AgentName,
|
||||
string? AgentDisplayName) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentFailedEvent(
|
||||
string? ToolCallId,
|
||||
string? AgentName,
|
||||
string? AgentDisplayName,
|
||||
string? Error) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentSelectedEvent(
|
||||
string? AgentName,
|
||||
string? AgentDisplayName,
|
||||
IReadOnlyList<string>? Tools) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentDeselectedEvent() : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSkillInvokedEvent(
|
||||
string SkillName,
|
||||
string Path,
|
||||
string Content,
|
||||
IReadOnlyList<string>? AllowedTools,
|
||||
string? PluginName,
|
||||
string? PluginVersion) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderHookStartEvent(
|
||||
string HookInvocationId,
|
||||
string HookType,
|
||||
object? Input) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderHookEndEvent(
|
||||
string HookInvocationId,
|
||||
string HookType,
|
||||
bool? Success,
|
||||
object? Output,
|
||||
string? Error) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantUsageEvent(
|
||||
string Model,
|
||||
double? InputTokens,
|
||||
double? OutputTokens,
|
||||
double? CacheReadTokens,
|
||||
double? CacheWriteTokens,
|
||||
double? Cost,
|
||||
double? Duration,
|
||||
double? TotalNanoAiu,
|
||||
Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSessionUsageEvent(
|
||||
double TokenLimit,
|
||||
double CurrentTokens,
|
||||
double MessagesLength,
|
||||
double? SystemTokens,
|
||||
double? ConversationTokens,
|
||||
double? ToolDefinitionsTokens,
|
||||
bool? IsInitial) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSessionCompactionStartEvent(
|
||||
double? SystemTokens,
|
||||
double? ConversationTokens,
|
||||
double? ToolDefinitionsTokens) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSessionCompactionCompleteEvent(
|
||||
bool? Success,
|
||||
string? Error,
|
||||
double? SystemTokens,
|
||||
double? ConversationTokens,
|
||||
double? ToolDefinitionsTokens,
|
||||
double? PreCompactionTokens,
|
||||
double? PostCompactionTokens,
|
||||
double? PreCompactionMessagesLength,
|
||||
double? MessagesRemoved,
|
||||
double? TokensRemoved,
|
||||
string? SummaryContent,
|
||||
double? CheckpointNumber,
|
||||
string? CheckpointPath) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderPendingMessagesModifiedEvent() : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderMcpOauthRequiredEvent() : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderExitPlanModeRequestedEvent() : ProviderSessionEvent;
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal sealed record ProviderTurnStreamCapabilities
|
||||
{
|
||||
public static ProviderTurnStreamCapabilities None { get; } = new();
|
||||
|
||||
public bool SupportsIntent { get; init; }
|
||||
|
||||
public bool SupportsReasoningDelta { get; init; }
|
||||
|
||||
public bool SupportsReasoningBlock { get; init; }
|
||||
|
||||
public bool SupportsToolExecutionProgress { get; init; }
|
||||
|
||||
public bool SupportsToolExecutionPartialResult { get; init; }
|
||||
|
||||
public bool SupportsToolExecutionCompletion { get; init; }
|
||||
|
||||
public bool SupportsSubagentLifecycle { get; init; }
|
||||
|
||||
public bool SupportsHookLifecycle { get; init; }
|
||||
|
||||
public bool SupportsSessionCompaction { get; init; }
|
||||
|
||||
public bool SupportsPendingMessagesMutation { get; init; }
|
||||
|
||||
public bool SupportsSessionTurnBoundaries { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal enum ProviderToolExecutionStatus
|
||||
{
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
internal sealed record ProviderToolExecutionSnapshot
|
||||
{
|
||||
public string ToolCallId { get; init; } = string.Empty;
|
||||
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
|
||||
|
||||
public ProviderToolExecutionStatus Status { get; init; }
|
||||
|
||||
public string? LatestProgressMessage { get; init; }
|
||||
|
||||
public string PartialOutput { get; init; } = string.Empty;
|
||||
|
||||
public string? ResultContent { get; init; }
|
||||
|
||||
public string? DetailedResultContent { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ProviderReasoningSnapshot
|
||||
{
|
||||
public string ReasoningId { get; init; } = string.Empty;
|
||||
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
public bool IsComplete { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Aryx.AgentHost.Services;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
if (!args.Contains("--stdio", StringComparer.Ordinal))
|
||||
{
|
||||
@@ -6,5 +7,7 @@ if (!args.Contains("--stdio", StringComparer.Ordinal))
|
||||
return;
|
||||
}
|
||||
|
||||
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProviderFromEnvironment();
|
||||
|
||||
SidecarProtocolHost host = new();
|
||||
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class AgentHostOptionsFactory
|
||||
{
|
||||
public static AIAgentHostOptions CreateDefault()
|
||||
{
|
||||
return new AIAgentHostOptions
|
||||
{
|
||||
EmitAgentUpdateEvents = null,
|
||||
EmitAgentResponseEvents = false,
|
||||
InterceptUserInputRequests = false,
|
||||
InterceptUnterminatedFunctionCalls = false,
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,111 @@ using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
|
||||
internal readonly record struct SubworkflowContext(string SubworkflowNodeId, string SubworkflowName);
|
||||
|
||||
internal readonly record struct AgentIdentity(
|
||||
string AgentId,
|
||||
string AgentName,
|
||||
SubworkflowContext? Subworkflow = null);
|
||||
|
||||
internal static class AgentIdentityResolver
|
||||
{
|
||||
private const string GenericAssistantIdentifier = "assistant";
|
||||
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
return TryResolveKnownAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
|
||||
agentIdentifier,
|
||||
agentSubworkflowIndex: null,
|
||||
out agent);
|
||||
}
|
||||
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
return TryResolveKnownAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
|
||||
agentIdentifier,
|
||||
agentSubworkflowIndex: null,
|
||||
out agent);
|
||||
}
|
||||
|
||||
internal static bool TryResolveKnownAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? agentIdentifier,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier);
|
||||
if (match is null)
|
||||
WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier);
|
||||
if (shallowMatch is not null)
|
||||
{
|
||||
agent = ToAgentIdentity(shallowMatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier);
|
||||
if (deepMatch is not null)
|
||||
{
|
||||
IReadOnlyDictionary<string, SubworkflowContext> subworkflowIndex = agentSubworkflowIndex
|
||||
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
|
||||
agent = ToAgentIdentity(deepMatch, subworkflowIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier);
|
||||
if (aliasMatch is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
agent = ToAgentIdentity(match);
|
||||
if (workflow.GetAgentNodes().Contains(aliasMatch))
|
||||
{
|
||||
agent = ToAgentIdentity(aliasMatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, SubworkflowContext> aliasSubworkflowIndex = agentSubworkflowIndex
|
||||
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
|
||||
agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryResolveObservedAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent))
|
||||
return TryResolveObservedAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
|
||||
agentIdentifier,
|
||||
fallbackAgent,
|
||||
agentSubworkflowIndex: null,
|
||||
out agent);
|
||||
}
|
||||
|
||||
internal static bool TryResolveObservedAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -49,30 +123,63 @@ 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);
|
||||
return ResolveAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
|
||||
agentId,
|
||||
agentName,
|
||||
agentSubworkflowIndex: null);
|
||||
}
|
||||
|
||||
return match is not null
|
||||
? ToAgentIdentity(match)
|
||||
: CreateFallbackIdentity(agentId, agentName);
|
||||
public static AgentIdentity ResolveAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
|
||||
string? agentId,
|
||||
string? agentName)
|
||||
{
|
||||
return ResolveAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
|
||||
agentId,
|
||||
agentName,
|
||||
agentSubworkflowIndex: null);
|
||||
}
|
||||
|
||||
internal static AgentIdentity ResolveAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? agentId,
|
||||
string? agentName,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById))
|
||||
{
|
||||
return resolvedById;
|
||||
}
|
||||
|
||||
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName))
|
||||
{
|
||||
return resolvedByName;
|
||||
}
|
||||
|
||||
return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -90,6 +197,53 @@ internal static class AgentIdentityResolver
|
||||
return GenericAssistantIdentifier;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
return BuildAgentSubworkflowIndex(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary));
|
||||
}
|
||||
|
||||
internal static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(workflowLibrary);
|
||||
|
||||
Dictionary<string, SubworkflowContext> index = new(StringComparer.Ordinal);
|
||||
CollectAgentSubworkflowContexts(
|
||||
workflow,
|
||||
workflowLibrary,
|
||||
currentSubworkflow: null,
|
||||
index,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
return index;
|
||||
}
|
||||
|
||||
internal static bool TryResolveSubworkflowContext(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? subworkflowNodeId,
|
||||
out SubworkflowContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(workflowLibrary);
|
||||
|
||||
context = default;
|
||||
WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary);
|
||||
if (node is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
context = CreateSubworkflowContext(node, workflowLibrary);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool IsGenericAssistantIdentifier(string? candidate)
|
||||
{
|
||||
return string.Equals(
|
||||
@@ -98,55 +252,145 @@ internal static class AgentIdentityResolver
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias(
|
||||
PatternDefinitionDto pattern,
|
||||
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
params string?[] agentIdentifiers)
|
||||
{
|
||||
return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
||||
? pattern.Agents[0]
|
||||
: null;
|
||||
if (!agentIdentifiers.Any(IsGenericAssistantIdentifier))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> topLevelAgents = workflow.GetAgentNodes();
|
||||
if (topLevelAgents.Count == 1)
|
||||
{
|
||||
return topLevelAgents[0];
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> allAgents = workflow.GetAllAgentNodes(workflowLibrary);
|
||||
return allAgents.Count == 1 ? allAgents[0] : null;
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
|
||||
private static WorkflowNodeDto? FindKnownAgent(
|
||||
IEnumerable<WorkflowNodeDto> agents,
|
||||
string? candidate)
|
||||
{
|
||||
return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
}
|
||||
|
||||
private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent)
|
||||
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
|
||||
=> new(agent.GetAgentId(), agent.GetAgentName());
|
||||
|
||||
private static AgentIdentity ToAgentIdentity(
|
||||
WorkflowNodeDto agent,
|
||||
IReadOnlyDictionary<string, SubworkflowContext> agentSubworkflowIndex)
|
||||
{
|
||||
return new AgentIdentity(
|
||||
agent.Id,
|
||||
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name);
|
||||
string agentId = agent.GetAgentId();
|
||||
return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow)
|
||||
? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow)
|
||||
: new AgentIdentity(agentId, agent.GetAgentName());
|
||||
}
|
||||
|
||||
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
|
||||
private static AgentIdentity CreateFallbackIdentity(
|
||||
string? agentId,
|
||||
string? agentName,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
|
||||
{
|
||||
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
|
||||
? agentId
|
||||
: agentName ?? "agent";
|
||||
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName)
|
||||
? agentName
|
||||
: resolvedAgentId;
|
||||
string resolvedAgentId = NormalizeOptionalString(agentId)
|
||||
?? NormalizeOptionalString(agentName)
|
||||
?? "agent";
|
||||
string resolvedAgentName = NormalizeOptionalString(agentName)
|
||||
?? resolvedAgentId;
|
||||
|
||||
if (agentSubworkflowIndex is not null
|
||||
&& agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow))
|
||||
{
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow);
|
||||
}
|
||||
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
|
||||
}
|
||||
|
||||
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
||||
private static void CollectAgentSubworkflowContexts(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
SubworkflowContext? currentSubworkflow,
|
||||
Dictionary<string, SubworkflowContext> index,
|
||||
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())
|
||||
{
|
||||
if (currentSubworkflow.HasValue)
|
||||
{
|
||||
index[node.GetAgentId()] = currentSubworkflow.Value;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!node.IsSubWorkflowNode())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
|
||||
if (subWorkflow is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CollectAgentSubworkflowContexts(
|
||||
subWorkflow,
|
||||
workflowLibrary,
|
||||
CreateSubworkflowContext(node, workflowLibrary),
|
||||
index,
|
||||
visitedWorkflowIds,
|
||||
visitedAnonymousWorkflows);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubworkflowContext CreateSubworkflowContext(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -165,6 +409,9 @@ internal static class AgentIdentityResolver
|
||||
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string NormalizeComparisonKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+328
-103
@@ -1,25 +1,30 @@
|
||||
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;
|
||||
|
||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private const string HandoffFunctionPrefix = "handoff_to_";
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||
private readonly WorkflowValidator _workflowValidator;
|
||||
private readonly WorkflowRunner _workflowRunner = new();
|
||||
private readonly IProviderTurnSupport _providerTurnSupport;
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
internal AgentWorkflowTurnRunner(
|
||||
IProviderTurnSupport providerTurnSupport,
|
||||
WorkflowValidator? workflowValidator = null)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_providerTurnSupport = providerTurnSupport ?? throw new ArgumentNullException(nameof(providerTurnSupport));
|
||||
_workflowValidator = workflowValidator ?? new WorkflowValidator();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
@@ -32,56 +37,34 @@ 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);
|
||||
TurnExecutionState state = new(command);
|
||||
using CancellationTokenSource runCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
IProviderTranscriptProjector? transcriptProjector = null;
|
||||
|
||||
try
|
||||
{
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
await using ProviderAgentBundle bundle = await _providerTurnSupport.CreateAgentBundleAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
activity => EmitActivityAsync(command, state, activity, onEvent),
|
||||
state,
|
||||
onEvent,
|
||||
onApproval,
|
||||
runCancellation.Token),
|
||||
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
onUserInput,
|
||||
runCancellation.Token),
|
||||
(agent, sessionEvent) =>
|
||||
{
|
||||
state.ObserveSessionEvent(agent, sessionEvent);
|
||||
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
|
||||
{
|
||||
state.EnqueuePendingMcpOauthRequest(
|
||||
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
|
||||
}
|
||||
|
||||
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
|
||||
{
|
||||
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
|
||||
runCancellation.Cancel();
|
||||
}
|
||||
},
|
||||
runCancellation.Token);
|
||||
runCancellation,
|
||||
runCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
transcriptProjector = bundle.TranscriptProjector;
|
||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(transcriptProjector.ToChatMessage).ToList();
|
||||
transcriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
using FileSystemJsonCheckpointStore? checkpointStore = CreateCheckpointStore(command);
|
||||
CheckpointManager? checkpointManager = checkpointStore is not null
|
||||
@@ -97,9 +80,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
|
||||
if (evt is RequestInfoEvent requestInfo
|
||||
&& await TryHandleRequestPortRequestAsync(
|
||||
command,
|
||||
requestInfo,
|
||||
run,
|
||||
onUserInput,
|
||||
runCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(
|
||||
command,
|
||||
evt,
|
||||
inputMessages,
|
||||
state,
|
||||
transcriptProjector,
|
||||
onDelta,
|
||||
onEvent)
|
||||
.ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
@@ -107,30 +108,47 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
return state.FinalizeCompletedMessages(transcriptProjector);
|
||||
}
|
||||
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
|
||||
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
|
||||
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
|
||||
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
return state.FinalizeCompletedMessages(
|
||||
transcriptProjector ?? throw new InvalidOperationException("Provider transcript projector was not initialized."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_approvalCoordinator.ClearRequestApprovals(command.RequestId);
|
||||
_providerTurnSupport.ClearRequestState(command.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
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" => WorkflowOrchestrationFactory.CreateHandoffWorkflow(command.Workflow, agents),
|
||||
"group-chat" => WorkflowOrchestrationFactory.CreateGroupChatWorkflow(command.Workflow, agents),
|
||||
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
|
||||
};
|
||||
}
|
||||
|
||||
internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
||||
{
|
||||
if (!ShouldEnableWorkflowCheckpointing(command))
|
||||
@@ -145,7 +163,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,35 +179,48 @@ 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(
|
||||
CopilotTurnExecutionState state,
|
||||
CopilotAgentBundle bundle)
|
||||
TurnExecutionState state,
|
||||
ProviderAgentBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
@@ -198,17 +229,24 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
|
||||
private static async Task EmitPendingEventsAsync(
|
||||
CopilotTurnExecutionState state,
|
||||
TurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
||||
{
|
||||
if (pendingEvent is TurnDeltaEventDto delta)
|
||||
{
|
||||
await onDelta(delta).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
await onEvent(pendingEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EmitPendingMcpOauthRequestsAsync(
|
||||
CopilotTurnExecutionState state,
|
||||
TurnExecutionState state,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
|
||||
{
|
||||
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
|
||||
@@ -221,34 +259,66 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
|
||||
return _providerTurnSupport.ResolveApprovalAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
|
||||
return _providerTurnSupport.ResolveUserInputAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<bool> HandleWorkflowEventAsync(
|
||||
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 _providerTurnSupport.RequestRequestPortUserInputAsync(
|
||||
command,
|
||||
userInputRequest,
|
||||
onUserInput,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
object coercedResponse = CoerceRequestPortResponse(metadata!.ResponseType, response.Answer);
|
||||
await run.SendResponseAsync(requestInfo.Request.CreateResponse(coercedResponse)).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static async Task<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
TurnExecutionState state,
|
||||
IProviderTranscriptProjector transcriptProjector,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked)
|
||||
{
|
||||
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
command.Pattern,
|
||||
invoked.ExecutorId,
|
||||
out AgentIdentity invokedAgent))
|
||||
if (state.TryResolveKnownAgentIdentity(invoked.ExecutorId, out AgentIdentity invokedAgent))
|
||||
{
|
||||
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else if (state.TryCreateSubworkflowLifecycleActivity(
|
||||
"subworkflow-started",
|
||||
invoked.ExecutorId,
|
||||
out AgentActivityEventDto subworkflowStarted))
|
||||
{
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Sub-workflow executor invoked: {invoked.ExecutorId} -> {subworkflowStarted.SubworkflowName ?? subworkflowStarted.SubworkflowNodeId ?? "<unknown>"}.");
|
||||
await EmitActivityAsync(command, state, subworkflowStarted, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}.");
|
||||
@@ -263,7 +333,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
command,
|
||||
requestInfo,
|
||||
state.ActiveAgent,
|
||||
state.ToolNamesByCallId);
|
||||
state.ToolCalls);
|
||||
|
||||
if (activity is null)
|
||||
{
|
||||
@@ -298,15 +368,22 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
if (evt is ExecutorCompletedEvent completed)
|
||||
{
|
||||
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
completed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity completedAgent))
|
||||
if (state.TryResolveObservedAgentIdentity(completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent))
|
||||
{
|
||||
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
|
||||
state.QueueCompletedActivity(completedAgent);
|
||||
state.ClearActiveAgentIfMatching(completedAgent);
|
||||
}
|
||||
else if (state.TryCreateSubworkflowLifecycleActivity(
|
||||
"subworkflow-completed",
|
||||
completed.ExecutorId,
|
||||
out AgentActivityEventDto subworkflowCompleted))
|
||||
{
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Sub-workflow executor completed: {completed.ExecutorId} -> {subworkflowCompleted.SubworkflowName ?? subworkflowCompleted.SubworkflowNodeId ?? "<unknown>"}.");
|
||||
await EmitActivityAsync(command, state, subworkflowCompleted, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
|
||||
@@ -318,16 +395,84 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
|
||||
state.UpdateCompletedMessages(allMessages, inputMessages);
|
||||
state.UpdateCompletedMessages(allMessages, inputMessages, transcriptProjector);
|
||||
}
|
||||
|
||||
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,
|
||||
CopilotTurnExecutionState state,
|
||||
TurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
@@ -344,11 +489,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
updateAgent = observedMessageAgent;
|
||||
authorName = observedMessageAgent.AgentName;
|
||||
}
|
||||
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
update.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedUpdateAgent))
|
||||
else if (state.TryResolveObservedAgentIdentity(
|
||||
update.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedUpdateAgent))
|
||||
{
|
||||
updateAgent = resolvedUpdateAgent;
|
||||
authorName = resolvedUpdateAgent.AgentName;
|
||||
@@ -386,10 +530,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
|
||||
string messageId = state.CreateMessageId(update.Update.MessageId);
|
||||
(string _, string currentAuthorName, string currentContent) = state.AppendDelta(
|
||||
if (!state.TryAppendDelta(
|
||||
messageId,
|
||||
authorName,
|
||||
update.Update.Text);
|
||||
update.Update.Text,
|
||||
out TranscriptSegment currentSegment))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onDelta(new TurnDeltaEventDto
|
||||
{
|
||||
@@ -397,15 +545,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
MessageId = messageId,
|
||||
AuthorName = currentAuthorName,
|
||||
AuthorName = currentSegment.AuthorName,
|
||||
ContentDelta = update.Update.Text,
|
||||
Content = currentContent,
|
||||
Content = currentSegment.Content,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EmitActivityAsync(
|
||||
internal static async Task EmitActivityAsync(
|
||||
RunTurnCommandDto command,
|
||||
CopilotTurnExecutionState state,
|
||||
TurnExecutionState state,
|
||||
AgentActivityEventDto activity,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
@@ -419,11 +567,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName);
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
|
||||
$"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity(activity.AgentId, activity.AgentName),
|
||||
promotedAgent,
|
||||
onEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -458,7 +607,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
private static bool TryCreateWorkflowDiagnosticEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
CopilotTurnExecutionState state,
|
||||
TurnExecutionState state,
|
||||
out WorkflowDiagnosticEventDto diagnostic)
|
||||
{
|
||||
diagnostic = default!;
|
||||
@@ -467,8 +616,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
case ExecutorFailedEvent executorFailed:
|
||||
{
|
||||
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
AgentIdentity? agent = state.TryResolveObservedAgentIdentity(
|
||||
executorFailed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedAgent)
|
||||
@@ -531,6 +679,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 +776,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;
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
using System.Threading;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
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 readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
internal CopilotAgentBundle(IReadOnlyList<AIAgent> agents, bool hasConfiguredHooks)
|
||||
{
|
||||
Agents = agents;
|
||||
HasConfiguredHooks = hasConfiguredHooks;
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public bool HasConfiguredHooks { get; }
|
||||
|
||||
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,
|
||||
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;
|
||||
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||
? null
|
||||
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (toolingBundle is not null)
|
||||
{
|
||||
disposables.Add(toolingBundle);
|
||||
}
|
||||
|
||||
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
|
||||
{
|
||||
CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
|
||||
AryxCopilotAgent agent = new(
|
||||
client,
|
||||
sessionConfig,
|
||||
ownsClient: true,
|
||||
id: definition.Id,
|
||||
name: definition.Name,
|
||||
description: definition.Description);
|
||||
|
||||
agents.Add(agent);
|
||||
disposables.Add(agent);
|
||||
}
|
||||
|
||||
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
internal static SessionConfig CreateSessionConfig(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto definition,
|
||||
int agentIndex,
|
||||
PermissionRequestHandler? onPermissionRequest = null,
|
||||
UserInputHandler? onUserInputRequest = null,
|
||||
SessionEventHandler? onSessionEvent = null,
|
||||
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,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(
|
||||
command.Pattern,
|
||||
definition,
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode,
|
||||
command.ProjectInstructions),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
OnUserInputRequest = onUserInputRequest,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
internal static void ApplySessionTooling(
|
||||
SessionConfig sessionConfig,
|
||||
Dictionary<string, object>? mcpServers,
|
||||
IReadOnlyList<AIFunction>? tools)
|
||||
{
|
||||
if (mcpServers is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.McpServers = mcpServers;
|
||||
}
|
||||
|
||||
if (tools is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.Tools = tools.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal static List<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
if (customAgents is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return customAgents.Select(customAgent => new CustomAgentConfig
|
||||
{
|
||||
Name = customAgent.Name,
|
||||
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
|
||||
Description = NormalizeOptionalString(customAgent.Description),
|
||||
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
|
||||
Prompt = customAgent.Prompt,
|
||||
McpServers = customAgent.McpServers.Count == 0
|
||||
? null
|
||||
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
|
||||
Infer = customAgent.Infer,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InfiniteSessionConfig
|
||||
{
|
||||
Enabled = config.Enabled,
|
||||
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
|
||||
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
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
|
||||
{
|
||||
"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}'."),
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
{
|
||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
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];
|
||||
|
||||
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(entryAgent);
|
||||
|
||||
foreach (PatternHandoffRoute route in topology.Routes)
|
||||
{
|
||||
if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent)
|
||||
|| !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent)
|
||||
|| !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return agentMap;
|
||||
}
|
||||
}
|
||||
@@ -1,650 +1,24 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotTurnExecutionState
|
||||
internal sealed class CopilotTurnExecutionState : TurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||
private int _fallbackMessageIndex;
|
||||
private string? _lastObservedMessageId;
|
||||
|
||||
public CopilotTurnExecutionState(RunTurnCommandDto command)
|
||||
: base(command)
|
||||
{
|
||||
_command = command;
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public AgentIdentity? ActiveAgent { get; private set; }
|
||||
|
||||
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||
|
||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||
|
||||
public bool SuppressHookLifecycleEvents { get; set; }
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(thinkingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (evt is AgentActivityEventDto activity
|
||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
|
||||
}
|
||||
}
|
||||
|
||||
public void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
|
||||
{
|
||||
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
_command.Pattern,
|
||||
agentDefinition.Id,
|
||||
agentDefinition.Name);
|
||||
|
||||
switch (sessionEvent)
|
||||
{
|
||||
case AssistantMessageDeltaEvent messageDelta when !string.IsNullOrWhiteSpace(messageDelta.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, messageDelta.Data!.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
if (assistantMessage.Data?.ToolRequests is { Length: > 0 })
|
||||
{
|
||||
QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId);
|
||||
}
|
||||
break;
|
||||
case ToolExecutionStartEvent toolExecutionStart
|
||||
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
||||
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
||||
string toolCallId = toolExecutionStart.Data.ToolCallId.Trim();
|
||||
string toolName = toolExecutionStart.Data.ToolName.Trim();
|
||||
ToolNamesByCallId[toolCallId] = toolName;
|
||||
ActiveAgent = agent;
|
||||
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(agent, toolName, toolCallId);
|
||||
if (toolActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(toolActivity);
|
||||
}
|
||||
|
||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||
break;
|
||||
case AssistantIntentEvent intentEvent:
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Data);
|
||||
if (assistantIntent is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(assistantIntent);
|
||||
}
|
||||
break;
|
||||
case AssistantReasoningDeltaEvent reasoningDelta:
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(agent, reasoningDelta.Data);
|
||||
if (reasoningDeltaEvent is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
||||
}
|
||||
break;
|
||||
case SubagentStartedEvent started:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentEvent(agent, "started", started.Data));
|
||||
break;
|
||||
case SubagentCompletedEvent completed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed.Data));
|
||||
break;
|
||||
case SubagentFailedEvent failed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed.Data));
|
||||
break;
|
||||
case SubagentSelectedEvent selected:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected.Data));
|
||||
break;
|
||||
case SubagentDeselectedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
||||
break;
|
||||
case SkillInvokedEvent skillInvoked:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked.Data));
|
||||
break;
|
||||
case HookStartEvent hookStart:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
|
||||
}
|
||||
break;
|
||||
case HookEndEvent hookEnd:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
|
||||
}
|
||||
break;
|
||||
case AssistantUsageEvent assistantUsage:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage.Data));
|
||||
break;
|
||||
case SessionUsageInfoEvent usageInfo:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
|
||||
break;
|
||||
case SessionCompactionStartEvent compactionStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart.Data));
|
||||
break;
|
||||
case SessionCompactionCompleteEvent compactionComplete:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete.Data));
|
||||
break;
|
||||
case PendingMessagesModifiedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
||||
break;
|
||||
case McpOauthRequiredEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
case ExitPlanModeRequestedEvent:
|
||||
HasPendingExitPlanModeRequest = true;
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||
{
|
||||
List<SidecarEventDto> pending = [];
|
||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||
{
|
||||
pending.Add(pendingEvent);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
_pendingMcpOauthRequests.Enqueue(request);
|
||||
}
|
||||
|
||||
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
|
||||
{
|
||||
List<McpOauthRequiredEventDto> pending = [];
|
||||
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
|
||||
{
|
||||
pending.Add(request);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
return !string.IsNullOrWhiteSpace(messageId)
|
||||
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
||||
}
|
||||
|
||||
public string CreateMessageId(string? messageId)
|
||||
{
|
||||
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
||||
}
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
|
||||
}
|
||||
|
||||
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
|
||||
{
|
||||
if (ActiveAgent.HasValue
|
||||
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
||||
{
|
||||
ActiveAgent = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
_observedAgentsByMessageId[messageId] = agent;
|
||||
_lastObservedMessageId = messageId;
|
||||
}
|
||||
|
||||
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(messageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedMessageId = messageId.Trim();
|
||||
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
|
||||
if (!_startedAgents.Add(agent.AgentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateToolCallingActivity(
|
||||
AgentIdentity agent,
|
||||
string toolName,
|
||||
string toolCallId)
|
||||
{
|
||||
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "tool-calling",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||
{
|
||||
return new MessageReclassifiedEventDto
|
||||
{
|
||||
Type = "message-reclassified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
MessageId = messageId,
|
||||
NewKind = "thinking",
|
||||
};
|
||||
}
|
||||
|
||||
public void UpdateCompletedMessages(
|
||||
IReadOnlyList<ChatMessage> allMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages)
|
||||
{
|
||||
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
|
||||
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
newMessages,
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
base.UpdateCompletedMessages(allMessages, inputMessages, CopilotTranscriptProjector.Instance);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
|
||||
{
|
||||
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
|
||||
{
|
||||
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
[],
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
foreach (ChatMessageDto message in CompletedMessages)
|
||||
{
|
||||
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||
{
|
||||
message.MessageKind = "thinking";
|
||||
}
|
||||
}
|
||||
|
||||
return CompletedMessages;
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentEvent(
|
||||
AgentIdentity agent,
|
||||
string eventKind,
|
||||
SubagentStartedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = eventKind,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
CustomAgentDescription = data?.AgentDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentCompletedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentCompletedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentFailedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentFailedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "failed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
Error = data?.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentSelectedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentSelectedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "selected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
Tools = data?.Tools,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "deselected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantIntentEventDto? CreateAssistantIntentEvent(
|
||||
AgentIdentity agent,
|
||||
AssistantIntentData? data)
|
||||
{
|
||||
string? intent = data?.Intent?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(intent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssistantIntentEventDto
|
||||
{
|
||||
Type = "assistant-intent",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Intent = intent,
|
||||
};
|
||||
}
|
||||
|
||||
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
|
||||
AgentIdentity agent,
|
||||
AssistantReasoningDeltaData? data)
|
||||
{
|
||||
if (data is null
|
||||
|| string.IsNullOrWhiteSpace(data.ReasoningId)
|
||||
|| string.IsNullOrEmpty(data.DeltaContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReasoningDeltaEventDto
|
||||
{
|
||||
Type = "reasoning-delta",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ReasoningId = data.ReasoningId,
|
||||
ContentDelta = data.DeltaContent,
|
||||
};
|
||||
}
|
||||
|
||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||
AgentIdentity agent,
|
||||
SkillInvokedData? data)
|
||||
{
|
||||
return new SkillInvokedEventDto
|
||||
{
|
||||
Type = "skill-invoked",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SkillName = data?.Name ?? string.Empty,
|
||||
Path = data?.Path ?? string.Empty,
|
||||
Content = data?.Content ?? string.Empty,
|
||||
AllowedTools = data?.AllowedTools,
|
||||
PluginName = data?.PluginName,
|
||||
PluginVersion = data?.PluginVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
HookStartData? data)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = data?.HookInvocationId ?? string.Empty,
|
||||
HookType = data?.HookType ?? string.Empty,
|
||||
Phase = phase,
|
||||
Input = data?.Input,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
HookEndData? data)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = data?.HookInvocationId ?? string.Empty,
|
||||
HookType = data?.HookType ?? string.Empty,
|
||||
Phase = phase,
|
||||
Success = data?.Success,
|
||||
Output = data?.Output,
|
||||
Error = data?.Error?.Message,
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantUsageEventDto CreateAssistantUsageEvent(
|
||||
AgentIdentity agent,
|
||||
AssistantUsageData? data)
|
||||
{
|
||||
return new AssistantUsageEventDto
|
||||
{
|
||||
Type = "assistant-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Model = data?.Model ?? string.Empty,
|
||||
InputTokens = data?.InputTokens,
|
||||
OutputTokens = data?.OutputTokens,
|
||||
CacheReadTokens = data?.CacheReadTokens,
|
||||
CacheWriteTokens = data?.CacheWriteTokens,
|
||||
Cost = data?.Cost,
|
||||
Duration = data?.Duration,
|
||||
TotalNanoAiu = data?.CopilotUsage?.TotalNanoAiu,
|
||||
QuotaSnapshots = QuotaSnapshotMapper.MapOrNull(data?.QuotaSnapshots),
|
||||
};
|
||||
}
|
||||
|
||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
|
||||
{
|
||||
return new SessionUsageEventDto
|
||||
{
|
||||
Type = "session-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
TokenLimit = data?.TokenLimit ?? 0,
|
||||
CurrentTokens = data?.CurrentTokens ?? 0,
|
||||
MessagesLength = data?.MessagesLength ?? 0,
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
IsInitial = data?.IsInitial,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionStartEvent(
|
||||
AgentIdentity agent,
|
||||
SessionCompactionStartData? data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "start",
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
||||
AgentIdentity agent,
|
||||
SessionCompactionCompleteData? data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "complete",
|
||||
Success = data?.Success,
|
||||
Error = data?.Error,
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
PreCompactionTokens = data?.PreCompactionTokens,
|
||||
PostCompactionTokens = data?.PostCompactionTokens,
|
||||
PreCompactionMessagesLength = data?.PreCompactionMessagesLength,
|
||||
MessagesRemoved = data?.MessagesRemoved,
|
||||
TokensRemoved = data?.TokensRemoved,
|
||||
SummaryContent = data?.SummaryContent,
|
||||
CheckpointNumber = data?.CheckpointNumber,
|
||||
CheckpointPath = data?.CheckpointPath,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new PendingMessagesModifiedEventDto
|
||||
{
|
||||
Type = "pending-messages-modified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
return base.FinalizeCompletedMessages(CopilotTranscriptProjector.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IAgentProvider
|
||||
{
|
||||
ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator);
|
||||
|
||||
Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken);
|
||||
|
||||
IProviderSessionManager CreateSessionManager();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IProviderEventAdapter
|
||||
{
|
||||
ProviderTurnStreamCapabilities Capabilities { get; }
|
||||
|
||||
ProviderSessionEvent? TryAdapt(object rawEvent);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IProviderTranscriptProjector
|
||||
{
|
||||
ChatMessage ToChatMessage(ChatMessageDto message);
|
||||
|
||||
void AttachMessageMode(IList<ChatMessage> messages, string? messageMode);
|
||||
|
||||
List<ChatMessage> SelectNewOutputMessages(
|
||||
IReadOnlyList<ChatMessage> outputMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages);
|
||||
|
||||
List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
IReadOnlyList<TranscriptSegment> segments,
|
||||
AgentIdentity? fallbackAgent = null);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IProviderTurnSupport
|
||||
{
|
||||
Task<ProviderAgentBundle> CreateAgentBundleAsync(
|
||||
RunTurnCommandDto command,
|
||||
TurnExecutionState state,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationTokenSource runCancellation,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UserInputResponse> RequestRequestPortUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
UserInputRequest request,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId);
|
||||
|
||||
void ClearRequestState(string requestId);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Collections;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class OpenTelemetrySetup
|
||||
{
|
||||
private const string ServiceName = "Aryx.AgentHost";
|
||||
private const string EndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT";
|
||||
private const string ProtocolEnvironmentVariableName = "OTEL_EXPORTER_OTLP_PROTOCOL";
|
||||
|
||||
private static readonly string[] ActivitySourceNames =
|
||||
[
|
||||
"Experimental.Microsoft.Agents.AI",
|
||||
"Microsoft.Agents.AI.Workflows",
|
||||
];
|
||||
|
||||
public static TracerProvider? CreateTracerProviderFromEnvironment(TextWriter? diagnosticsWriter = null)
|
||||
{
|
||||
return CreateTracerProvider(ReadEnvironmentVariables(), diagnosticsWriter ?? Console.Error);
|
||||
}
|
||||
|
||||
internal static TracerProvider? CreateTracerProvider(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
|
||||
TextWriter diagnosticsWriter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environmentVariables);
|
||||
ArgumentNullException.ThrowIfNull(diagnosticsWriter);
|
||||
|
||||
OpenTelemetryTracingConfiguration? configuration = ResolveTracingConfiguration(environmentVariables);
|
||||
if (configuration is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
diagnosticsWriter.WriteLine(
|
||||
$"Aryx.AgentHost OpenTelemetry tracing enabled ({configuration.ProtocolLabel}) -> {configuration.Endpoint}.");
|
||||
|
||||
return Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(
|
||||
ResourceBuilder.CreateDefault()
|
||||
.AddService(ServiceName, serviceVersion: ResolveServiceVersion()))
|
||||
.AddSource(configuration.ActivitySourceNames.ToArray())
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = configuration.Endpoint;
|
||||
options.Protocol = configuration.Protocol;
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
internal static OpenTelemetryTracingConfiguration? ResolveTracingConfiguration(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environmentVariables);
|
||||
|
||||
string? endpointValue = ReadSetting(environmentVariables, EndpointEnvironmentVariableName);
|
||||
if (string.IsNullOrWhiteSpace(endpointValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out Uri? endpoint)
|
||||
|| (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{EndpointEnvironmentVariableName} must be an absolute http or https URL. Received '{endpointValue}'.");
|
||||
}
|
||||
|
||||
OtlpExportProtocol protocol = ResolveProtocol(ReadSetting(environmentVariables, ProtocolEnvironmentVariableName));
|
||||
return new OpenTelemetryTracingConfiguration(endpoint, protocol, ActivitySourceNames);
|
||||
}
|
||||
|
||||
private static string? ResolveServiceVersion()
|
||||
{
|
||||
return typeof(OpenTelemetrySetup).Assembly.GetName().Version?.ToString();
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string?>> ReadEnvironmentVariables()
|
||||
{
|
||||
return Environment.GetEnvironmentVariables()
|
||||
.Cast<DictionaryEntry>()
|
||||
.Select(entry => new KeyValuePair<string, string?>(
|
||||
entry.Key?.ToString() ?? string.Empty,
|
||||
entry.Value?.ToString()));
|
||||
}
|
||||
|
||||
private static string? ReadSetting(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
|
||||
string name)
|
||||
{
|
||||
foreach (KeyValuePair<string, string?> entry in environmentVariables)
|
||||
{
|
||||
if (!string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(entry.Value)
|
||||
? null
|
||||
: entry.Value.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static OtlpExportProtocol ResolveProtocol(string? protocolValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(protocolValue))
|
||||
{
|
||||
return OtlpExportProtocol.Grpc;
|
||||
}
|
||||
|
||||
return protocolValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"grpc" => OtlpExportProtocol.Grpc,
|
||||
"http/protobuf" => OtlpExportProtocol.HttpProtobuf,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"{ProtocolEnvironmentVariableName} must be 'grpc' or 'http/protobuf'. Received '{protocolValue}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenTelemetryTracingConfiguration(
|
||||
Uri Endpoint,
|
||||
OtlpExportProtocol Protocol,
|
||||
IReadOnlyList<string> ActivitySourceNames)
|
||||
{
|
||||
public string ProtocolLabel => Protocol switch
|
||||
{
|
||||
OtlpExportProtocol.HttpProtobuf => "http/protobuf",
|
||||
_ => "grpc",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal abstract class ProviderAgentBundle : IAsyncDisposable
|
||||
{
|
||||
public abstract IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public abstract bool HasConfiguredHooks { get; }
|
||||
|
||||
public abstract IProviderTranscriptProjector TranscriptProjector { get; }
|
||||
|
||||
public abstract ValueTask DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotAgentBundle : ProviderAgentBundle
|
||||
{
|
||||
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)
|
||||
{
|
||||
Agents = agents;
|
||||
HasConfiguredHooks = hasConfiguredHooks;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public override bool HasConfiguredHooks { get; }
|
||||
|
||||
public override IProviderTranscriptProjector TranscriptProjector { get; } = CopilotTranscriptProjector.Instance;
|
||||
|
||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
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 = [];
|
||||
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
|
||||
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||
? null
|
||||
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (toolingBundle is not null)
|
||||
{
|
||||
disposables.Add(toolingBundle);
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = command.Workflow.GetAllAgentNodes(command.WorkflowLibrary);
|
||||
|
||||
if (agentNodes.Count > 0)
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
|
||||
|
||||
AryxCopilotAgent agent = new(
|
||||
sharedClient,
|
||||
sessionConfig,
|
||||
ownsClient: false,
|
||||
id: definition.GetAgentId(),
|
||||
name: definition.GetAgentName(),
|
||||
description: NormalizeOptionalString(definition.Config.Description));
|
||||
|
||||
AIAgent instrumentedAgent = new AIAgentBuilder(agent).UseOpenTelemetry().Build();
|
||||
agents.Add(instrumentedAgent);
|
||||
disposables.Add(agent);
|
||||
if (instrumentedAgent is IDisposable instrumentedDisposable)
|
||||
{
|
||||
disposables.Add(new SyncDisposableAdapter(instrumentedDisposable));
|
||||
}
|
||||
}
|
||||
|
||||
// The bundle owns the shared client — disposed after all agents.
|
||||
disposables.Add(sharedClient);
|
||||
}
|
||||
|
||||
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
internal static SessionConfig CreateSessionConfig(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto definition,
|
||||
int agentIndex,
|
||||
PermissionRequestHandler? onPermissionRequest = null,
|
||||
UserInputHandler? onUserInputRequest = null,
|
||||
SessionEventHandler? onSessionEvent = null,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = definition.Config.Model,
|
||||
ReasoningEffort = definition.Config.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(
|
||||
command.Workflow,
|
||||
definition,
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode,
|
||||
command.ProjectInstructions,
|
||||
command.PromptInvocation),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
OnUserInputRequest = onUserInputRequest,
|
||||
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
||||
OnEvent = onSessionEvent,
|
||||
Streaming = true,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
internal static void ApplySessionTooling(
|
||||
SessionConfig sessionConfig,
|
||||
Dictionary<string, object>? mcpServers,
|
||||
IReadOnlyList<AIFunction>? tools)
|
||||
{
|
||||
if (mcpServers is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.McpServers = mcpServers;
|
||||
}
|
||||
|
||||
if (tools is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.Tools = tools.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (customAgents is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return customAgents.Select(customAgent => new CustomAgentConfig
|
||||
{
|
||||
Name = customAgent.Name,
|
||||
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
|
||||
Description = NormalizeOptionalString(customAgent.Description),
|
||||
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
|
||||
Prompt = customAgent.Prompt,
|
||||
McpServers = customAgent.McpServers.Count == 0
|
||||
? null
|
||||
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
|
||||
Infer = customAgent.Infer,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InfiniteSessionConfig
|
||||
{
|
||||
Enabled = config.Enabled,
|
||||
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
|
||||
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||
{
|
||||
return AgentHostOptionsFactory.CreateDefault();
|
||||
}
|
||||
|
||||
internal static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
|
||||
AIAgent entryAgent,
|
||||
HandoffModeSettingsDto? settings = null)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateHandoffWorkflowBuilder(entryAgent, settings);
|
||||
}
|
||||
|
||||
internal static Workflow CreateHandoffWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateHandoffWorkflow(workflowDefinition, agents);
|
||||
}
|
||||
|
||||
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateGroupChatWorkflowBuilder(workflowDefinition, agents);
|
||||
}
|
||||
|
||||
internal static Workflow CreateGroupChatWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateGroupChatWorkflow(workflowDefinition, agents);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
{
|
||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
return values is { Count: > 0 }
|
||||
? [.. values]
|
||||
: null;
|
||||
}
|
||||
|
||||
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 (string requiredTool in RequiredPromptTools)
|
||||
{
|
||||
if (!availableTools.Contains(requiredTool, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
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 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapts a synchronous <see cref="IDisposable"/> to <see cref="IAsyncDisposable"/>
|
||||
/// so it can be tracked in the async disposal pipeline.
|
||||
/// </summary>
|
||||
private sealed class SyncDisposableAdapter(IDisposable inner) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
inner.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotAgentProvider : IAgentProvider
|
||||
{
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AskUserToolName,
|
||||
"report_intent",
|
||||
"task_complete",
|
||||
};
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"not signed in",
|
||||
"not logged in",
|
||||
"reauth",
|
||||
"credential",
|
||||
];
|
||||
|
||||
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowValidator);
|
||||
return new AgentWorkflowTurnRunner(new CopilotTurnRunnerSupport(), workflowValidator);
|
||||
}
|
||||
|
||||
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return BuildCapabilitiesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public IProviderSessionManager CreateSessionManager()
|
||||
{
|
||||
return new CopilotSessionManager();
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
|
||||
return CreateCapabilities([], [], connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
GetAuthStatusResponse? authStatus =
|
||||
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
|
||||
authStatus,
|
||||
cliContext.Environment,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
|
||||
}
|
||||
}
|
||||
|
||||
private static SidecarCapabilitiesDto CreateCapabilities(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
|
||||
SidecarConnectionDiagnosticsDto connection)
|
||||
{
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
RuntimeTools = runtimeTools,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
|
||||
{
|
||||
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
["sequential"] = new() { Available = true },
|
||||
["concurrent"] = new() { Available = true },
|
||||
["handoff"] = new() { Available = true },
|
||||
["group-chat"] = new() { Available = true },
|
||||
["magentic"] = new()
|
||||
{
|
||||
Available = false,
|
||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return models
|
||||
.Select(model => new SidecarModelCapabilityDto
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name,
|
||||
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
|
||||
.Where(IsReasoningEffort)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
|
||||
? model.DefaultReasoningEffort
|
||||
: null,
|
||||
})
|
||||
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
|
||||
return MapRuntimeTools(result.Tools);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> tools)
|
||||
{
|
||||
return tools
|
||||
.Where(ShouldIncludeRuntimeTool)
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
|
||||
.Select(tool => new SidecarRuntimeToolDto
|
||||
{
|
||||
Id = tool.Name.Trim(),
|
||||
Label = tool.Name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
|
||||
})
|
||||
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeRuntimeTool(Tool tool)
|
||||
{
|
||||
string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim();
|
||||
return toolName is not null
|
||||
&& !ExcludedRuntimeToolNames.Contains(toolName);
|
||||
}
|
||||
|
||||
private static bool IsReasoningEffort(string? value)
|
||||
{
|
||||
return value is "low" or "medium" or "high" or "xhigh";
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
|
||||
{
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "copilot-cli-missing",
|
||||
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
|
||||
Detail = exception.Message,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
0 => "Connected to GitHub Copilot, but no models were reported.",
|
||||
1 => "Connected to GitHub Copilot. 1 model is available.",
|
||||
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
|
||||
};
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
? "GitHub Copilot requires authentication before Aryx can load models."
|
||||
: "GitHub Copilot was found, but Aryx could not load its model list.";
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyConnectionStatus(Exception exception)
|
||||
{
|
||||
string message = exception.Message;
|
||||
if (AuthenticationErrorIndicators.Any(indicator =>
|
||||
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "copilot-auth-required";
|
||||
}
|
||||
|
||||
return "copilot-error";
|
||||
}
|
||||
|
||||
private sealed record CapabilityProbeResult(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> Models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
|
||||
SidecarConnectionDiagnosticsDto Connection);
|
||||
}
|
||||
+192
-78
@@ -21,22 +21,33 @@ internal sealed class CopilotApprovalCoordinator
|
||||
private const string HookPermissionKind = "hook";
|
||||
private const string ToolCallingActivityType = "tool-calling";
|
||||
|
||||
private static readonly Dictionary<string, string> HookToolCategories = new(StringComparer.OrdinalIgnoreCase)
|
||||
private static readonly Dictionary<string, HookToolApprovalMapping> HookToolApprovals = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["view"] = ReadPermissionKind,
|
||||
["glob"] = ReadPermissionKind,
|
||||
["grep"] = ReadPermissionKind,
|
||||
["lsp"] = ReadPermissionKind,
|
||||
["edit"] = WritePermissionKind,
|
||||
["create"] = WritePermissionKind,
|
||||
["powershell"] = ShellPermissionKind,
|
||||
["read_powershell"] = ShellPermissionKind,
|
||||
["write_powershell"] = ShellPermissionKind,
|
||||
["stop_powershell"] = ShellPermissionKind,
|
||||
["list_powershell"] = ShellPermissionKind,
|
||||
["web_fetch"] = UrlPermissionKind,
|
||||
["web_search"] = UrlPermissionKind,
|
||||
["store_memory"] = MemoryPermissionKind,
|
||||
["view"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["show_file"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["read_file"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["glob"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["grep"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["rg"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["lsp"] = new(ReadPermissionKind, ReadPermissionKind),
|
||||
["edit"] = new(WritePermissionKind, WritePermissionKind),
|
||||
["create"] = new(WritePermissionKind, WritePermissionKind),
|
||||
["write_file"] = new(WritePermissionKind, WritePermissionKind),
|
||||
["apply_patch"] = new(WritePermissionKind, WritePermissionKind),
|
||||
["bash"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["read_bash"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["write_bash"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["stop_bash"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["list_bash"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["powershell"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["read_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["write_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["stop_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["list_powershell"] = new(ShellPermissionKind, ShellPermissionKind),
|
||||
["web_fetch"] = new(UrlPermissionKind, WebFetchToolName),
|
||||
["web_search"] = new(UrlPermissionKind, WebFetchToolName),
|
||||
["store_memory"] = new(MemoryPermissionKind, StoreMemoryToolName),
|
||||
["remember_fact"] = new(MemoryPermissionKind, StoreMemoryToolName),
|
||||
};
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
@@ -67,10 +78,10 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
ToolCallRegistry toolCalls,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -79,7 +90,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
toolNamesByCallId,
|
||||
toolCalls,
|
||||
onActivity: null,
|
||||
onApproval,
|
||||
cancellationToken)
|
||||
@@ -88,27 +99,30 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
ToolCallRegistry toolCalls,
|
||||
Func<AgentActivityEventDto, Task>? onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
|
||||
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
|
||||
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
|
||||
ResolvedApprovalContext approval = ResolveApprovalContext(request, toolCalls, command.Tooling?.McpServers);
|
||||
string? approvalCacheKey = ResolveApprovalCacheKey(approval.ToolName, approval.ApprovalToolKey);
|
||||
|
||||
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
|
||||
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, approval.ToolName);
|
||||
if (fileChangeActivity is not null && onActivity is not null)
|
||||
{
|
||||
await onActivity(fileChangeActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|
||||
|| !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey))
|
||||
|| !RequiresToolCallApproval(
|
||||
command.Workflow.Settings.ApprovalPolicy,
|
||||
agent.GetAgentId(),
|
||||
approval.ToolName,
|
||||
approval.ApprovalToolKey,
|
||||
approval.McpServerApprovalKey))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
@@ -127,7 +141,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
request,
|
||||
invocation,
|
||||
pending.ApprovalId,
|
||||
toolName))
|
||||
approval.ToolName))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
@@ -149,26 +163,28 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
string approvalId,
|
||||
string? toolName)
|
||||
{
|
||||
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
|
||||
ResolvedApprovalContext approval = ResolveApprovalContext(request, toolName, 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? normalizedToolName = approval.ToolName;
|
||||
string? displayToolName = normalizedToolName ?? NormalizeOptionalString(approval.ApprovalToolKey);
|
||||
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
||||
? NormalizeOptionalString(urlRequest.Url)
|
||||
: null;
|
||||
string title = normalizedToolName is null
|
||||
? $"Approve {permissionKind}"
|
||||
: $"Approve {normalizedToolName}";
|
||||
string detail = normalizedToolName is null
|
||||
? $"{agentName} requested {permissionKind} permission"
|
||||
: $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\"";
|
||||
string title = displayToolName is null
|
||||
? $"Approve {approval.PermissionKind}"
|
||||
: $"Approve {displayToolName}";
|
||||
string detail = displayToolName is null
|
||||
? $"{agentName} requested {approval.PermissionKind} permission"
|
||||
: $"{agentName} requested {approval.PermissionKind} permission for tool \"{displayToolName}\"";
|
||||
|
||||
if (requestedUrl is not null)
|
||||
{
|
||||
@@ -191,10 +207,11 @@ 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,
|
||||
PermissionKind = approval.PermissionKind,
|
||||
ApprovalToolKey = NormalizeOptionalString(approval.ApprovalToolKey),
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
|
||||
@@ -203,7 +220,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
string? toolName)
|
||||
{
|
||||
@@ -218,14 +235,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),
|
||||
@@ -312,7 +330,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
ApprovalPolicyDto? approvalPolicy,
|
||||
string agentId,
|
||||
string? toolName,
|
||||
string? autoApprovedToolName = null,
|
||||
string? approvalToolKey = null,
|
||||
string? mcpServerApprovalKey = null)
|
||||
{
|
||||
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
|
||||
@@ -331,21 +349,21 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return true;
|
||||
}
|
||||
|
||||
return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, autoApprovedToolName)
|
||||
return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, approvalToolKey)
|
||||
&& !MatchesAutoApprovedToolName(autoApprovedToolNames, mcpServerApprovalKey);
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId,
|
||||
ToolCallRegistry? toolCalls,
|
||||
out string? toolName)
|
||||
{
|
||||
toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
toolName = ResolveApprovalToolName(request, toolCalls);
|
||||
return toolName is not null;
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
|
||||
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
|
||||
=> TryGetApprovalToolName(request, toolCalls: null, out toolName);
|
||||
|
||||
internal void ClearRequestApprovals(string requestId)
|
||||
{
|
||||
@@ -402,16 +420,74 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
private static string? ResolveApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId)
|
||||
ToolCallRegistry? toolCalls)
|
||||
{
|
||||
return GetDirectToolName(request)
|
||||
?? ResolveToolNameFromLookup(request, toolNamesByCallId)
|
||||
?? ResolveToolNameFromLookup(request, toolCalls)
|
||||
?? GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private static string? ResolveAutoApprovedToolName(PermissionRequest request)
|
||||
private static ResolvedApprovalContext ResolveApprovalContext(
|
||||
PermissionRequest request,
|
||||
ToolCallRegistry? toolCalls,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
return GetFallbackToolName(request);
|
||||
return ResolveApprovalContext(
|
||||
request,
|
||||
ResolveApprovalToolName(request, toolCalls),
|
||||
configuredMcpServers);
|
||||
}
|
||||
|
||||
private static ResolvedApprovalContext ResolveApprovalContext(
|
||||
PermissionRequest request,
|
||||
string? toolName,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestShell => new(
|
||||
normalizedToolName,
|
||||
ShellPermissionKind,
|
||||
ShellPermissionKind,
|
||||
null),
|
||||
PermissionRequestWrite => new(
|
||||
normalizedToolName,
|
||||
WritePermissionKind,
|
||||
WritePermissionKind,
|
||||
null),
|
||||
PermissionRequestRead => new(
|
||||
normalizedToolName,
|
||||
ReadPermissionKind,
|
||||
ReadPermissionKind,
|
||||
null),
|
||||
PermissionRequestUrl => new(
|
||||
normalizedToolName,
|
||||
UrlPermissionKind,
|
||||
WebFetchToolName,
|
||||
null),
|
||||
PermissionRequestMemory => new(
|
||||
normalizedToolName,
|
||||
MemoryPermissionKind,
|
||||
StoreMemoryToolName,
|
||||
null),
|
||||
PermissionRequestMcp => new(
|
||||
normalizedToolName,
|
||||
McpPermissionKind,
|
||||
normalizedToolName,
|
||||
ResolveMcpServerApprovalKey(request, configuredMcpServers)),
|
||||
PermissionRequestCustomTool => new(
|
||||
normalizedToolName,
|
||||
CustomToolPermissionKind,
|
||||
normalizedToolName,
|
||||
null),
|
||||
PermissionRequestHook => ResolveHookApprovalContext(normalizedToolName, configuredMcpServers),
|
||||
_ => new(
|
||||
normalizedToolName,
|
||||
NormalizeOptionalString(request.Kind) ?? "tool access",
|
||||
normalizedToolName,
|
||||
ResolveMcpServerApprovalKey(request, configuredMcpServers)),
|
||||
};
|
||||
}
|
||||
|
||||
private const string McpServerApprovalPrefix = "mcp_server:";
|
||||
@@ -478,16 +554,16 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
private static string? ResolveToolNameFromLookup(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId)
|
||||
ToolCallRegistry? toolCalls)
|
||||
{
|
||||
if (toolNamesByCallId is null)
|
||||
if (toolCalls is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? toolCallId = GetToolCallId(request);
|
||||
if (toolCallId is null
|
||||
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName))
|
||||
|| !toolCalls.TryGetToolName(toolCallId, out string? resolvedToolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -520,44 +596,56 @@ internal sealed class CopilotApprovalCoordinator
|
||||
PermissionRequestWrite => WritePermissionKind,
|
||||
PermissionRequestRead => ReadPermissionKind,
|
||||
PermissionRequestMemory => StoreMemoryToolName,
|
||||
PermissionRequestHook hook => ResolveHookToolCategory(hook.ToolName),
|
||||
PermissionRequestHook hook => ResolveHookApprovalToolKey(hook.ToolName),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? ResolveHookToolCategory(string? toolName)
|
||||
{
|
||||
string? normalized = NormalizeOptionalString(toolName);
|
||||
if (normalized is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
|
||||
return TryResolveHookToolApproval(toolName, out HookToolApprovalMapping? mapping) && mapping is not null
|
||||
? mapping.PermissionKind
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ResolvePermissionKind(
|
||||
PermissionRequest request,
|
||||
internal static string? ResolveHookApprovalToolKey(string? toolName)
|
||||
{
|
||||
return TryResolveHookToolApproval(toolName, out HookToolApprovalMapping? mapping) && mapping is not null
|
||||
? mapping.ApprovalToolKey
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static ResolvedApprovalContext ResolveHookApprovalContext(
|
||||
string? toolName,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||
? "tool access"
|
||||
: request.Kind.Trim();
|
||||
|
||||
if (request is not PermissionRequestHook hook)
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
if (normalizedToolName is null)
|
||||
{
|
||||
return permissionKind;
|
||||
return new ResolvedApprovalContext(null, HookPermissionKind, null, null);
|
||||
}
|
||||
|
||||
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
|
||||
if (resolvedCategory is not null)
|
||||
if (TryResolveHookToolApproval(normalizedToolName, out HookToolApprovalMapping? mapping) && mapping is not null)
|
||||
{
|
||||
return resolvedCategory;
|
||||
return new ResolvedApprovalContext(
|
||||
normalizedToolName,
|
||||
mapping.PermissionKind,
|
||||
mapping.ApprovalToolKey,
|
||||
null);
|
||||
}
|
||||
|
||||
return ResolveHookMcpServerName(hook.ToolName, configuredMcpServers) is not null
|
||||
? McpPermissionKind
|
||||
: permissionKind;
|
||||
string? mcpServerApprovalKey = ResolveHookMcpServerApprovalKey(normalizedToolName, configuredMcpServers);
|
||||
return mcpServerApprovalKey is not null
|
||||
? new ResolvedApprovalContext(
|
||||
normalizedToolName,
|
||||
McpPermissionKind,
|
||||
normalizedToolName,
|
||||
mcpServerApprovalKey)
|
||||
: new ResolvedApprovalContext(
|
||||
normalizedToolName,
|
||||
HookPermissionKind,
|
||||
normalizedToolName,
|
||||
null);
|
||||
}
|
||||
|
||||
private static PermissionDetailDto BuildHookPermissionDetail(
|
||||
@@ -617,13 +705,29 @@ internal sealed class CopilotApprovalCoordinator
|
||||
: strippedToolName;
|
||||
}
|
||||
|
||||
private static bool TryResolveHookToolApproval(
|
||||
string? toolName,
|
||||
out HookToolApprovalMapping? mapping)
|
||||
{
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
if (normalizedToolName is not null
|
||||
&& HookToolApprovals.TryGetValue(normalizedToolName, out HookToolApprovalMapping? resolvedMapping))
|
||||
{
|
||||
mapping = resolvedMapping;
|
||||
return true;
|
||||
}
|
||||
|
||||
mapping = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedTool(
|
||||
IReadOnlyList<string> autoApprovedToolNames,
|
||||
string? toolName,
|
||||
string? autoApprovedToolName)
|
||||
string? approvalToolKey)
|
||||
{
|
||||
return MatchesAutoApprovedToolName(autoApprovedToolNames, toolName)
|
||||
|| MatchesAutoApprovedToolName(autoApprovedToolNames, autoApprovedToolName);
|
||||
|| MatchesAutoApprovedToolName(autoApprovedToolNames, approvalToolKey);
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedToolName(
|
||||
@@ -718,6 +822,16 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
internal sealed record ResolvedApprovalContext(
|
||||
string? ToolName,
|
||||
string PermissionKind,
|
||||
string? ApprovalToolKey,
|
||||
string? McpServerApprovalKey);
|
||||
|
||||
private sealed record HookToolApprovalMapping(
|
||||
string PermissionKind,
|
||||
string ApprovalToolKey);
|
||||
|
||||
private sealed record PendingApprovalRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
@@ -0,0 +1,205 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotEventAdapter : IProviderEventAdapter
|
||||
{
|
||||
public ProviderTurnStreamCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsIntent = true,
|
||||
SupportsReasoningDelta = true,
|
||||
SupportsReasoningBlock = true,
|
||||
SupportsToolExecutionProgress = true,
|
||||
SupportsToolExecutionPartialResult = true,
|
||||
SupportsToolExecutionCompletion = true,
|
||||
SupportsSubagentLifecycle = true,
|
||||
SupportsHookLifecycle = true,
|
||||
SupportsSessionCompaction = true,
|
||||
SupportsPendingMessagesMutation = true,
|
||||
SupportsSessionTurnBoundaries = true,
|
||||
};
|
||||
|
||||
public ProviderSessionEvent? TryAdapt(object rawEvent)
|
||||
{
|
||||
return rawEvent switch
|
||||
{
|
||||
AssistantMessageDeltaEvent messageDelta
|
||||
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
|
||||
new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent),
|
||||
|
||||
AssistantMessageEvent assistantMessage
|
||||
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
|
||||
new ProviderAssistantMessageEvent(
|
||||
messageId,
|
||||
assistantMessage.Data?.Content,
|
||||
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
|
||||
|
||||
ToolExecutionStartEvent toolExecutionStart
|
||||
when NormalizeRequiredString(toolExecutionStart.Data?.ToolCallId) is { } toolCallId
|
||||
&& NormalizeRequiredString(toolExecutionStart.Data?.ToolName) is { } toolName =>
|
||||
new ProviderToolExecutionStartEvent(
|
||||
toolCallId,
|
||||
toolName,
|
||||
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)),
|
||||
|
||||
ToolExecutionProgressEvent toolExecutionProgress
|
||||
when NormalizeRequiredString(toolExecutionProgress.Data?.ToolCallId) is { } toolCallId =>
|
||||
new ProviderToolExecutionProgressEvent(
|
||||
toolCallId,
|
||||
NormalizeOptionalString(toolExecutionProgress.Data?.ProgressMessage)),
|
||||
|
||||
ToolExecutionPartialResultEvent toolExecutionPartialResult
|
||||
when NormalizeRequiredString(toolExecutionPartialResult.Data?.ToolCallId) is { } toolCallId =>
|
||||
new ProviderToolExecutionPartialResultEvent(
|
||||
toolCallId,
|
||||
toolExecutionPartialResult.Data?.PartialOutput),
|
||||
|
||||
ToolExecutionCompleteEvent toolExecutionComplete
|
||||
when NormalizeRequiredString(toolExecutionComplete.Data?.ToolCallId) is { } toolCallId =>
|
||||
new ProviderToolExecutionCompleteEvent(
|
||||
toolCallId,
|
||||
toolExecutionComplete.Data?.Success ?? false,
|
||||
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.Content),
|
||||
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.DetailedContent),
|
||||
NormalizeOptionalString(toolExecutionComplete.Data?.Error?.Message)),
|
||||
|
||||
AssistantIntentEvent intentEvent =>
|
||||
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
|
||||
|
||||
AssistantReasoningDeltaEvent reasoningDelta =>
|
||||
new ProviderAssistantReasoningDeltaEvent(
|
||||
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
|
||||
reasoningDelta.Data?.DeltaContent),
|
||||
|
||||
AssistantReasoningEvent reasoning =>
|
||||
new ProviderAssistantReasoningEvent(
|
||||
NormalizeOptionalString(reasoning.Data?.ReasoningId),
|
||||
reasoning.Data?.Content),
|
||||
|
||||
AssistantTurnStartEvent turnStart =>
|
||||
new ProviderAssistantTurnStartEvent(NormalizeOptionalString(turnStart.Data?.TurnId)),
|
||||
|
||||
AssistantTurnEndEvent turnEnd =>
|
||||
new ProviderAssistantTurnEndEvent(NormalizeOptionalString(turnEnd.Data?.TurnId)),
|
||||
|
||||
SubagentStartedEvent started =>
|
||||
new ProviderSubagentStartedEvent(
|
||||
started.Data?.ToolCallId,
|
||||
started.Data?.AgentName,
|
||||
started.Data?.AgentDisplayName,
|
||||
started.Data?.AgentDescription),
|
||||
|
||||
SubagentCompletedEvent completed =>
|
||||
new ProviderSubagentCompletedEvent(
|
||||
completed.Data?.ToolCallId,
|
||||
completed.Data?.AgentName,
|
||||
completed.Data?.AgentDisplayName),
|
||||
|
||||
SubagentFailedEvent failed =>
|
||||
new ProviderSubagentFailedEvent(
|
||||
failed.Data?.ToolCallId,
|
||||
failed.Data?.AgentName,
|
||||
failed.Data?.AgentDisplayName,
|
||||
failed.Data?.Error),
|
||||
|
||||
SubagentSelectedEvent selected =>
|
||||
new ProviderSubagentSelectedEvent(
|
||||
selected.Data?.AgentName,
|
||||
selected.Data?.AgentDisplayName,
|
||||
selected.Data?.Tools),
|
||||
|
||||
SubagentDeselectedEvent =>
|
||||
new ProviderSubagentDeselectedEvent(),
|
||||
|
||||
SkillInvokedEvent skillInvoked =>
|
||||
new ProviderSkillInvokedEvent(
|
||||
skillInvoked.Data?.Name ?? string.Empty,
|
||||
skillInvoked.Data?.Path ?? string.Empty,
|
||||
skillInvoked.Data?.Content ?? string.Empty,
|
||||
skillInvoked.Data?.AllowedTools,
|
||||
skillInvoked.Data?.PluginName,
|
||||
skillInvoked.Data?.PluginVersion),
|
||||
|
||||
HookStartEvent hookStart =>
|
||||
new ProviderHookStartEvent(
|
||||
hookStart.Data?.HookInvocationId ?? string.Empty,
|
||||
hookStart.Data?.HookType ?? string.Empty,
|
||||
hookStart.Data?.Input),
|
||||
|
||||
HookEndEvent hookEnd =>
|
||||
new ProviderHookEndEvent(
|
||||
hookEnd.Data?.HookInvocationId ?? string.Empty,
|
||||
hookEnd.Data?.HookType ?? string.Empty,
|
||||
hookEnd.Data?.Success,
|
||||
hookEnd.Data?.Output,
|
||||
hookEnd.Data?.Error?.Message),
|
||||
|
||||
AssistantUsageEvent assistantUsage =>
|
||||
new ProviderAssistantUsageEvent(
|
||||
assistantUsage.Data?.Model ?? string.Empty,
|
||||
assistantUsage.Data?.InputTokens,
|
||||
assistantUsage.Data?.OutputTokens,
|
||||
assistantUsage.Data?.CacheReadTokens,
|
||||
assistantUsage.Data?.CacheWriteTokens,
|
||||
assistantUsage.Data?.Cost,
|
||||
assistantUsage.Data?.Duration,
|
||||
assistantUsage.Data?.CopilotUsage?.TotalNanoAiu,
|
||||
QuotaSnapshotMapper.MapOrNull(assistantUsage.Data?.QuotaSnapshots)),
|
||||
|
||||
SessionUsageInfoEvent usageInfo =>
|
||||
new ProviderSessionUsageEvent(
|
||||
usageInfo.Data?.TokenLimit ?? 0,
|
||||
usageInfo.Data?.CurrentTokens ?? 0,
|
||||
usageInfo.Data?.MessagesLength ?? 0,
|
||||
usageInfo.Data?.SystemTokens,
|
||||
usageInfo.Data?.ConversationTokens,
|
||||
usageInfo.Data?.ToolDefinitionsTokens,
|
||||
usageInfo.Data?.IsInitial),
|
||||
|
||||
SessionCompactionStartEvent compactionStart =>
|
||||
new ProviderSessionCompactionStartEvent(
|
||||
compactionStart.Data?.SystemTokens,
|
||||
compactionStart.Data?.ConversationTokens,
|
||||
compactionStart.Data?.ToolDefinitionsTokens),
|
||||
|
||||
SessionCompactionCompleteEvent compactionComplete =>
|
||||
new ProviderSessionCompactionCompleteEvent(
|
||||
compactionComplete.Data?.Success,
|
||||
compactionComplete.Data?.Error,
|
||||
compactionComplete.Data?.SystemTokens,
|
||||
compactionComplete.Data?.ConversationTokens,
|
||||
compactionComplete.Data?.ToolDefinitionsTokens,
|
||||
compactionComplete.Data?.PreCompactionTokens,
|
||||
compactionComplete.Data?.PostCompactionTokens,
|
||||
compactionComplete.Data?.PreCompactionMessagesLength,
|
||||
compactionComplete.Data?.MessagesRemoved,
|
||||
compactionComplete.Data?.TokensRemoved,
|
||||
compactionComplete.Data?.SummaryContent,
|
||||
compactionComplete.Data?.CheckpointNumber,
|
||||
compactionComplete.Data?.CheckpointPath),
|
||||
|
||||
PendingMessagesModifiedEvent =>
|
||||
new ProviderPendingMessagesModifiedEvent(),
|
||||
|
||||
McpOauthRequiredEvent =>
|
||||
new ProviderMcpOauthRequiredEvent(),
|
||||
|
||||
ExitPlanModeRequestedEvent =>
|
||||
new ProviderExitPlanModeRequestedEvent(),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeRequiredString(string? value)
|
||||
{
|
||||
string? normalized = NormalizeOptionalString(value);
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
{
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
+9
-10
@@ -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);
|
||||
@@ -248,17 +248,16 @@ internal static class CopilotSessionHooks
|
||||
};
|
||||
}
|
||||
|
||||
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||
CopilotApprovalCoordinator.ResolvedApprovalContext approval = CopilotApprovalCoordinator.ResolveHookApprovalContext(
|
||||
toolName,
|
||||
command.Tooling?.McpServers);
|
||||
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Pattern.ApprovalPolicy,
|
||||
agentDefinition.Id,
|
||||
toolName,
|
||||
autoApprovedToolName,
|
||||
mcpServerApprovalKey);
|
||||
command.Workflow.Settings.ApprovalPolicy,
|
||||
agentDefinition.GetAgentId(),
|
||||
approval.ToolName,
|
||||
approval.ApprovalToolKey,
|
||||
approval.McpServerApprovalKey);
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
@@ -0,0 +1,35 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotTranscriptProjector : IProviderTranscriptProjector
|
||||
{
|
||||
public static CopilotTranscriptProjector Instance { get; } = new();
|
||||
|
||||
private CopilotTranscriptProjector()
|
||||
{
|
||||
}
|
||||
|
||||
public ChatMessage ToChatMessage(ChatMessageDto message)
|
||||
=> WorkflowTranscriptProjector.ToChatMessage(message);
|
||||
|
||||
public void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
|
||||
=> WorkflowTranscriptProjector.AttachMessageMode(messages, messageMode);
|
||||
|
||||
public List<ChatMessage> SelectNewOutputMessages(
|
||||
IReadOnlyList<ChatMessage> outputMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages)
|
||||
=> WorkflowTranscriptProjector.SelectNewOutputMessages(outputMessages, inputMessages);
|
||||
|
||||
public List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
IReadOnlyList<TranscriptSegment> segments,
|
||||
AgentIdentity? fallbackAgent = null)
|
||||
=> WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
command,
|
||||
newMessages,
|
||||
segments,
|
||||
fallbackAgent);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
|
||||
{
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||
private readonly IProviderEventAdapter _providerEventAdapter = new CopilotEventAdapter();
|
||||
|
||||
public async Task<ProviderAgentBundle> CreateAgentBundleAsync(
|
||||
RunTurnCommandDto command,
|
||||
TurnExecutionState state,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationTokenSource runCancellation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
state.SetStreamCapabilities(_providerEventAdapter.Capabilities);
|
||||
|
||||
return await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolCalls,
|
||||
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
|
||||
onApproval,
|
||||
runCancellation.Token),
|
||||
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
onUserInput,
|
||||
runCancellation.Token),
|
||||
(agent, sessionEvent) => ObserveSessionEvent(command, state, runCancellation, agent, sessionEvent),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UserInputResponse> RequestRequestPortUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
UserInputRequest request,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
request,
|
||||
onUserInput,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId)
|
||||
{
|
||||
return _exitPlanModeCoordinator.ConsumePendingRequest(requestId);
|
||||
}
|
||||
|
||||
public void ClearRequestState(string requestId)
|
||||
{
|
||||
_approvalCoordinator.ClearRequestApprovals(requestId);
|
||||
}
|
||||
|
||||
private void ObserveSessionEvent(
|
||||
RunTurnCommandDto command,
|
||||
TurnExecutionState state,
|
||||
CancellationTokenSource runCancellation,
|
||||
WorkflowNodeDto agent,
|
||||
SessionEvent sessionEvent)
|
||||
{
|
||||
if (_providerEventAdapter.TryAdapt(sessionEvent) is { } providerEvent)
|
||||
{
|
||||
state.ObserveSessionEvent(agent, providerEvent);
|
||||
}
|
||||
|
||||
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
|
||||
{
|
||||
state.EnqueuePendingMcpOauthRequest(
|
||||
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
|
||||
}
|
||||
|
||||
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
|
||||
{
|
||||
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
|
||||
runCancellation.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
-29
@@ -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(
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.IO;
|
||||
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;
|
||||
|
||||
public sealed class CopilotWorkflowRunner : AgentWorkflowTurnRunner
|
||||
{
|
||||
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
|
||||
: base(new CopilotTurnRunnerSupport(), workflowValidator)
|
||||
{
|
||||
}
|
||||
|
||||
internal new static Workflow BuildWorkflowForCommand(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<AIAgent> agents,
|
||||
WorkflowRunner? workflowRunner = null)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.BuildWorkflowForCommand(command, agents, workflowRunner);
|
||||
}
|
||||
|
||||
internal new static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.CreateCheckpointStore(command);
|
||||
}
|
||||
|
||||
internal new static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.ShouldEnableWorkflowCheckpointing(command);
|
||||
}
|
||||
|
||||
internal new static string GetCheckpointStorePath(RunTurnCommandDto command)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.GetCheckpointStorePath(command);
|
||||
}
|
||||
|
||||
internal new static InProcessExecutionEnvironment CreateExecutionEnvironment(
|
||||
RunTurnCommandDto command,
|
||||
CheckpointManager? checkpointManager)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.CreateExecutionEnvironment(command, checkpointManager);
|
||||
}
|
||||
|
||||
internal static void ConfigureHookLifecycleEventSuppression(
|
||||
CopilotTurnExecutionState state,
|
||||
CopilotAgentBundle bundle)
|
||||
{
|
||||
AgentWorkflowTurnRunner.ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||
}
|
||||
|
||||
internal new static UserInputRequest CreateRequestPortUserInputRequest(
|
||||
AgentWorkflowTurnRunner.WorkflowRequestPortMetadata metadata,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.CreateRequestPortUserInputRequest(metadata, requestInfo);
|
||||
}
|
||||
|
||||
internal new static object CoerceRequestPortResponse(string responseType, string? answer)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.CoerceRequestPortResponse(responseType, answer);
|
||||
}
|
||||
|
||||
private static Task<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
return AgentWorkflowTurnRunner.HandleWorkflowEventAsync(
|
||||
command,
|
||||
evt,
|
||||
inputMessages,
|
||||
state,
|
||||
CopilotTranscriptProjector.Instance,
|
||||
onDelta,
|
||||
onEvent);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -2,7 +2,7 @@ using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public interface ICopilotSessionManager
|
||||
public interface IProviderSessionManager
|
||||
{
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
@@ -17,3 +17,5 @@ public interface ICopilotSessionManager
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ICopilotSessionManager : IProviderSessionManager;
|
||||
|
||||
+35
-92
@@ -1,16 +1,9 @@
|
||||
using System.Text;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct TranscriptSegment(string MessageId, string AuthorName, string Content)
|
||||
{
|
||||
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
|
||||
=> new(segment.MessageId, segment.AuthorName, segment.Content);
|
||||
}
|
||||
|
||||
internal static class WorkflowTranscriptProjector
|
||||
{
|
||||
public static ChatMessage ToChatMessage(ChatMessageDto message)
|
||||
@@ -73,7 +66,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 +77,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 +126,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),
|
||||
@@ -146,10 +139,16 @@ internal static class WorkflowTranscriptProjector
|
||||
ChatMessage message,
|
||||
TranscriptSegment? matchedSegment)
|
||||
{
|
||||
if (matchedSegment is { IsFinalized: true } finalizedSegment
|
||||
&& !string.IsNullOrWhiteSpace(finalizedSegment.Content))
|
||||
{
|
||||
return finalizedSegment.Content;
|
||||
}
|
||||
|
||||
return FirstNonBlank(
|
||||
message.Text,
|
||||
matchedSegment?.Content,
|
||||
TryGetAssistantMessageContent(message))
|
||||
TryGetAssistantMessageContent(message),
|
||||
matchedSegment?.Content)
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
@@ -180,17 +179,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 +204,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 +218,7 @@ internal static class WorkflowTranscriptProjector
|
||||
ChatMessage message,
|
||||
IReadOnlyList<TranscriptSegment> remainingSegments,
|
||||
int remainingMessageCount,
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
AgentIdentity? fallbackAgent)
|
||||
{
|
||||
if (remainingSegments.Count == 0)
|
||||
@@ -227,11 +226,21 @@ internal static class WorkflowTranscriptProjector
|
||||
return null;
|
||||
}
|
||||
|
||||
string? messageId = FirstNonBlank(message.MessageId);
|
||||
if (messageId is not null
|
||||
&& TryFindSegment(
|
||||
remainingSegments,
|
||||
segment => string.Equals(segment.MessageId, messageId, StringComparison.Ordinal),
|
||||
out TranscriptSegment messageIdMatchedSegment))
|
||||
{
|
||||
return messageIdMatchedSegment;
|
||||
}
|
||||
|
||||
string? messageText = string.IsNullOrWhiteSpace(message.Text) ? null : message.Text;
|
||||
if (messageText is not null)
|
||||
{
|
||||
string resolvedAuthorName = ResolveProjectedAuthorName(
|
||||
pattern,
|
||||
workflow,
|
||||
message.AuthorName,
|
||||
fallbackIdentifier: null,
|
||||
fallbackAgent);
|
||||
@@ -240,7 +249,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 +273,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 +391,7 @@ internal static class WorkflowTranscriptProjector
|
||||
}
|
||||
|
||||
private static string ResolveProjectedAuthorName(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? primaryIdentifier,
|
||||
string? fallbackIdentifier,
|
||||
AgentIdentity? fallbackAgent)
|
||||
@@ -399,16 +408,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);
|
||||
}
|
||||
@@ -444,70 +454,3 @@ internal static class WorkflowTranscriptProjector
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StreamingTranscriptBuffer
|
||||
{
|
||||
private readonly List<BufferedTranscriptSegment> _segments = [];
|
||||
|
||||
public int Count => _segments.Count;
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName);
|
||||
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
|
||||
segment.SetAuthorName(authorName);
|
||||
return segment.ToSnapshot();
|
||||
}
|
||||
|
||||
public IReadOnlyList<TranscriptSegment> Snapshot()
|
||||
{
|
||||
return _segments.Select(segment => segment.ToSnapshot()).ToList();
|
||||
}
|
||||
|
||||
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
|
||||
{
|
||||
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
BufferedTranscriptSegment created = new(messageId, authorName);
|
||||
_segments.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private sealed class BufferedTranscriptSegment
|
||||
{
|
||||
public BufferedTranscriptSegment(string messageId, string authorName)
|
||||
{
|
||||
MessageId = messageId;
|
||||
AuthorName = authorName;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string AuthorName { get; private set; }
|
||||
|
||||
public StringBuilder Content { get; } = new();
|
||||
|
||||
public void SetContent(string value)
|
||||
{
|
||||
Content.Clear();
|
||||
Content.Append(value);
|
||||
}
|
||||
|
||||
public void SetAuthorName(string value)
|
||||
{
|
||||
AuthorName = value;
|
||||
}
|
||||
|
||||
public TranscriptSegment ToSnapshot()
|
||||
{
|
||||
return new TranscriptSegment(MessageId, AuthorName, Content.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
@@ -10,7 +8,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";
|
||||
@@ -19,31 +17,11 @@ public sealed class SidecarProtocolHost
|
||||
private const string DeleteSessionCommandType = "delete-session";
|
||||
private const string DisconnectSessionCommandType = "disconnect-session";
|
||||
private const string GetQuotaCommandType = "get-quota";
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AskUserToolName,
|
||||
"report_intent",
|
||||
"task_complete",
|
||||
};
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"not signed in",
|
||||
"not logged in",
|
||||
"reauth",
|
||||
"credential",
|
||||
];
|
||||
|
||||
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 IProviderSessionManager _sessionManager;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
@@ -53,27 +31,48 @@ 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)
|
||||
IProviderSessionManager? sessionManager = null)
|
||||
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
||||
}
|
||||
|
||||
public SidecarProtocolHost(
|
||||
WorkflowValidator workflowValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
IProviderSessionManager? sessionManager = null)
|
||||
: this(workflowValidator, new CopilotAgentProvider(), workflowRunner, capabilitiesProvider, sessionManager)
|
||||
{
|
||||
}
|
||||
|
||||
internal SidecarProtocolHost(
|
||||
WorkflowValidator workflowValidator,
|
||||
IAgentProvider agentProvider,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
IProviderSessionManager? sessionManager = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowValidator);
|
||||
ArgumentNullException.ThrowIfNull(agentProvider);
|
||||
|
||||
_workflowValidator = workflowValidator;
|
||||
_workflowRunner = workflowRunner ?? agentProvider.CreateWorkflowRunner(_workflowValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? agentProvider.GetCapabilitiesAsync;
|
||||
_sessionManager = sessionManager ?? agentProvider.CreateSessionManager();
|
||||
_jsonOptions = JsonSerialization.CreateWebOptions();
|
||||
_jsonOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
_jsonOptions.PropertyNameCaseInsensitive = true;
|
||||
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
|
||||
{
|
||||
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
||||
[ValidatePatternCommandType] = HandleValidatePatternAsync,
|
||||
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
|
||||
[RunTurnCommandType] = HandleRunTurnAsync,
|
||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||
@@ -168,15 +167,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);
|
||||
}
|
||||
|
||||
@@ -451,252 +450,9 @@ public sealed class SidecarProtocolHost
|
||||
return cancelledRequestIds;
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
|
||||
return CreateCapabilities([], [], connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
GetAuthStatusResponse? authStatus =
|
||||
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
|
||||
authStatus,
|
||||
cliContext.Environment,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
|
||||
}
|
||||
}
|
||||
|
||||
private static SidecarCapabilitiesDto CreateCapabilities(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
|
||||
SidecarConnectionDiagnosticsDto connection)
|
||||
{
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
RuntimeTools = runtimeTools,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
|
||||
{
|
||||
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
["sequential"] = new() { Available = true },
|
||||
["concurrent"] = new() { Available = true },
|
||||
["handoff"] = new() { Available = true },
|
||||
["group-chat"] = new() { Available = true },
|
||||
["magentic"] = new()
|
||||
{
|
||||
Available = false,
|
||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return models
|
||||
.Select(model => new SidecarModelCapabilityDto
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name,
|
||||
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
|
||||
.Where(IsReasoningEffort)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
|
||||
? model.DefaultReasoningEffort
|
||||
: null,
|
||||
})
|
||||
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
|
||||
return MapRuntimeTools(result.Tools);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> tools)
|
||||
{
|
||||
return tools
|
||||
.Where(ShouldIncludeRuntimeTool)
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
|
||||
.Select(tool => new SidecarRuntimeToolDto
|
||||
{
|
||||
Id = tool.Name.Trim(),
|
||||
Label = tool.Name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
|
||||
})
|
||||
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeRuntimeTool(Tool tool)
|
||||
{
|
||||
string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim();
|
||||
return toolName is not null
|
||||
&& !ExcludedRuntimeToolNames.Contains(toolName);
|
||||
}
|
||||
|
||||
private static bool IsReasoningEffort(string? value)
|
||||
{
|
||||
return value is "low" or "medium" or "high" or "xhigh";
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
|
||||
{
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "copilot-cli-missing",
|
||||
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
|
||||
Detail = exception.Message,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
0 => "Connected to GitHub Copilot, but no models were reported.",
|
||||
1 => "Connected to GitHub Copilot. 1 model is available.",
|
||||
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
|
||||
};
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
? "GitHub Copilot requires authentication before Aryx can load models."
|
||||
: "GitHub Copilot was found, but Aryx could not load its model list.";
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyConnectionStatus(Exception exception)
|
||||
{
|
||||
string message = exception.Message;
|
||||
if (AuthenticationErrorIndicators.Any(indicator =>
|
||||
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "copilot-auth-required";
|
||||
}
|
||||
|
||||
return "copilot-error";
|
||||
}
|
||||
|
||||
private sealed record CommandContext(
|
||||
string RawCommand,
|
||||
SidecarCommandEnvelope Envelope,
|
||||
TextWriter Output,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed record CapabilityProbeResult(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> Models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
|
||||
SidecarConnectionDiagnosticsDto Connection);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static partial class StreamingTextMerger
|
||||
@@ -7,7 +5,6 @@ internal static partial class StreamingTextMerger
|
||||
private const double SnapshotReplacementMinLengthRatio = 0.6;
|
||||
private const int SnapshotReplacementMinTokenCount = 3;
|
||||
private const double SnapshotReplacementSharedTokenRatio = 0.5;
|
||||
private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`";
|
||||
|
||||
public static string Merge(string current, string incoming)
|
||||
{
|
||||
@@ -32,51 +29,7 @@ internal static partial class StreamingTextMerger
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return current + ResolveBoundarySeparator(current, incoming) + incoming;
|
||||
}
|
||||
|
||||
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
|
||||
{
|
||||
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||
{
|
||||
merged = incoming;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||
{
|
||||
merged = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
|
||||
{
|
||||
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
|
||||
if (overlapLength == 0)
|
||||
{
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
merged = current + incoming[overlapLength..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ResolveBoundarySeparator(string current, string incoming)
|
||||
{
|
||||
if (ShouldInsertNewlineBoundary(current, incoming))
|
||||
{
|
||||
return "\n";
|
||||
}
|
||||
|
||||
return ShouldInsertSpaceBoundary(current, incoming)
|
||||
? " "
|
||||
: string.Empty;
|
||||
return current + incoming;
|
||||
}
|
||||
|
||||
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
|
||||
@@ -125,54 +78,6 @@ internal static partial class StreamingTextMerger
|
||||
&& incomingTokens.Count >= SnapshotReplacementMinTokenCount;
|
||||
}
|
||||
|
||||
private static bool ShouldInsertNewlineBoundary(string current, string incoming)
|
||||
{
|
||||
return !current.EndsWith('\n')
|
||||
&& MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
|
||||
}
|
||||
|
||||
private static bool ShouldInsertSpaceBoundary(string current, string incoming)
|
||||
{
|
||||
char lastCharacter = current[^1];
|
||||
char firstCharacter = incoming[0];
|
||||
if (HasExistingBoundary(lastCharacter, firstCharacter)
|
||||
|| CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ClosingPunctuationRegex().IsMatch(incoming))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|
||||
|| LooksLikeWordBoundary(current, incoming);
|
||||
}
|
||||
|
||||
private static bool HasExistingBoundary(char lastCharacter, char firstCharacter)
|
||||
{
|
||||
return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter);
|
||||
}
|
||||
|
||||
private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming)
|
||||
{
|
||||
return MarkdownInlinePrefixRegex().IsMatch(incoming)
|
||||
|| char.IsUpper(firstCharacter)
|
||||
|| char.IsDigit(firstCharacter);
|
||||
}
|
||||
|
||||
private static bool LooksLikeWordBoundary(string current, string incoming)
|
||||
{
|
||||
string[] currentTokens = Tokenize(current).ToArray();
|
||||
string[] incomingTokens = Tokenize(incoming).ToArray();
|
||||
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
return currentTokens.Length >= 2
|
||||
&& incomingTokens.Length >= 2
|
||||
&& firstIncomingToken.Length >= 2;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string value)
|
||||
{
|
||||
return TokenRegex()
|
||||
@@ -181,15 +86,38 @@ internal static partial class StreamingTextMerger
|
||||
.Where(token => token.Length > 0);
|
||||
}
|
||||
|
||||
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TokenRegex();
|
||||
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
|
||||
{
|
||||
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||
{
|
||||
merged = incoming;
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[.,!?;:%)\]}]")]
|
||||
private static partial Regex ClosingPunctuationRegex();
|
||||
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||
{
|
||||
merged = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[*_`~\[]")]
|
||||
private static partial Regex MarkdownInlinePrefixRegex();
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)]
|
||||
private static partial Regex MarkdownBlockPrefixRegex();
|
||||
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
|
||||
{
|
||||
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
|
||||
if (overlapLength == 0)
|
||||
{
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
merged = current + incoming[overlapLength..];
|
||||
return true;
|
||||
}
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex("[a-z0-9]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
|
||||
private static partial System.Text.RegularExpressions.Regex TokenRegex();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct TranscriptSegment(
|
||||
string MessageId,
|
||||
string AuthorName,
|
||||
string Content,
|
||||
bool IsFinalized = false)
|
||||
{
|
||||
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
|
||||
=> new(segment.MessageId, segment.AuthorName, segment.Content);
|
||||
|
||||
public void Deconstruct(out string messageId, out string authorName, out string content)
|
||||
{
|
||||
messageId = MessageId;
|
||||
authorName = AuthorName;
|
||||
content = Content;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StreamingTranscriptBuffer
|
||||
{
|
||||
private readonly List<BufferedTranscriptSegment> _segments = [];
|
||||
|
||||
public int Count => _segments.Count;
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
_ = TryAppendDelta(messageId, authorName, delta, out TranscriptSegment segment);
|
||||
return segment;
|
||||
}
|
||||
|
||||
public bool TryAppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
BufferedTranscriptSegment bufferedSegment = GetOrCreateSegment(messageId, authorName);
|
||||
bool contentChanged = bufferedSegment.TryAppendDelta(authorName, delta);
|
||||
segment = bufferedSegment.ToSnapshot();
|
||||
return contentChanged;
|
||||
}
|
||||
|
||||
public bool TryApplySnapshot(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string content,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
BufferedTranscriptSegment bufferedSegment = GetOrCreateSegment(messageId, authorName);
|
||||
bool visibleContentChanged = bufferedSegment.TryApplySnapshot(authorName, content);
|
||||
segment = bufferedSegment.ToSnapshot();
|
||||
return visibleContentChanged;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TranscriptSegment> Snapshot()
|
||||
{
|
||||
return _segments.Select(segment => segment.ToSnapshot()).ToList();
|
||||
}
|
||||
|
||||
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
|
||||
{
|
||||
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
BufferedTranscriptSegment created = new(messageId, authorName);
|
||||
_segments.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private sealed class BufferedTranscriptSegment
|
||||
{
|
||||
public BufferedTranscriptSegment(string messageId, string authorName)
|
||||
{
|
||||
MessageId = messageId;
|
||||
AuthorName = authorName;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string AuthorName { get; private set; }
|
||||
|
||||
public bool IsFinalized { get; private set; }
|
||||
|
||||
public StringBuilder Content { get; } = new();
|
||||
|
||||
public bool TryAppendDelta(string authorName, string delta)
|
||||
{
|
||||
SetAuthorName(authorName);
|
||||
if (IsFinalized || string.IsNullOrEmpty(delta))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string currentContent = Content.ToString();
|
||||
string mergedContent = StreamingTextMerger.Merge(currentContent, delta);
|
||||
if (string.Equals(currentContent, mergedContent, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SetContent(mergedContent);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplySnapshot(string authorName, string content)
|
||||
{
|
||||
SetAuthorName(authorName);
|
||||
string normalizedContent = content ?? string.Empty;
|
||||
string currentContent = Content.ToString();
|
||||
bool visibleContentChanged = !string.Equals(currentContent, normalizedContent, StringComparison.Ordinal);
|
||||
|
||||
SetContent(normalizedContent);
|
||||
IsFinalized = true;
|
||||
return visibleContentChanged;
|
||||
}
|
||||
|
||||
private void SetContent(string value)
|
||||
{
|
||||
Content.Clear();
|
||||
Content.Append(value);
|
||||
}
|
||||
|
||||
private void SetAuthorName(string value)
|
||||
{
|
||||
AuthorName = value;
|
||||
}
|
||||
|
||||
public TranscriptSegment ToSnapshot()
|
||||
{
|
||||
return new TranscriptSegment(MessageId, AuthorName, Content.ToString(), IsFinalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class ToolCallRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryGetExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
|
||||
{
|
||||
snapshot = null;
|
||||
return !string.IsNullOrWhiteSpace(toolCallId)
|
||||
&& _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot);
|
||||
}
|
||||
|
||||
public bool TryGetToolName(string? toolCallId, [NotNullWhen(true)] out string? toolName)
|
||||
{
|
||||
toolName = null;
|
||||
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
|
||||
&& !string.IsNullOrWhiteSpace(snapshot.ToolName)
|
||||
&& (toolName = snapshot.ToolName) is not null;
|
||||
}
|
||||
|
||||
public bool HasTrackedArguments(string? toolCallId)
|
||||
{
|
||||
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
|
||||
&& snapshot.ToolArguments is { Count: > 0 };
|
||||
}
|
||||
|
||||
public void RecordToolStart(
|
||||
string toolCallId,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolCallId,
|
||||
static (id, state) => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
ToolName = state.ToolName,
|
||||
ToolArguments = state.ToolArguments,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
},
|
||||
static (_, existing, state) => existing with
|
||||
{
|
||||
ToolName = state.ToolName,
|
||||
ToolArguments = state.ToolArguments,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
},
|
||||
(ToolName: toolName, ToolArguments: toolArguments));
|
||||
}
|
||||
|
||||
public bool TryRecordToolRequest(
|
||||
string? toolCallId,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
bool hasToolArguments = toolArguments is { Count: > 0 };
|
||||
string? normalizedToolCallId = NormalizeOptionalString(toolCallId);
|
||||
if (normalizedToolCallId is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_toolExecutionsByCallId.TryGetValue(normalizedToolCallId, out ProviderToolExecutionSnapshot? existing))
|
||||
{
|
||||
bool trackedHasArguments = existing.ToolArguments is { Count: > 0 };
|
||||
if (trackedHasArguments || !hasToolArguments)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
normalizedToolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
ToolName = toolName,
|
||||
ToolArguments = toolArguments,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
ToolName = toolName,
|
||||
ToolArguments = toolArguments,
|
||||
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
|
||||
? existing.Status
|
||||
: ProviderToolExecutionStatus.Running,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RecordProgress(string toolCallId, string? progressMessage)
|
||||
{
|
||||
string? normalizedProgress = NormalizeOptionalString(progressMessage);
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
LatestProgressMessage = normalizedProgress,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
|
||||
? existing.Status
|
||||
: ProviderToolExecutionStatus.Running,
|
||||
LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage,
|
||||
});
|
||||
}
|
||||
|
||||
public void RecordPartialResult(string toolCallId, string? partialOutput)
|
||||
{
|
||||
if (string.IsNullOrEmpty(partialOutput))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
PartialOutput = partialOutput,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
|
||||
? existing.Status
|
||||
: ProviderToolExecutionStatus.Running,
|
||||
PartialOutput = string.Concat(existing.PartialOutput, partialOutput),
|
||||
});
|
||||
}
|
||||
|
||||
public void RecordCompletion(ProviderToolExecutionCompleteEvent toolExecution)
|
||||
{
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolExecution.ToolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
|
||||
ResultContent = toolExecution.ResultContent,
|
||||
DetailedResultContent = toolExecution.DetailedResultContent,
|
||||
Error = toolExecution.Error,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
|
||||
ResultContent = toolExecution.ResultContent ?? existing.ResultContent,
|
||||
DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent,
|
||||
Error = toolExecution.Error ?? existing.Error,
|
||||
});
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal class TurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> _workflowLibrary;
|
||||
private readonly IReadOnlyDictionary<string, SubworkflowContext> _agentSubworkflowIndex;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
|
||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||
private int _fallbackMessageIndex;
|
||||
private string? _lastObservedMessageId;
|
||||
|
||||
public TurnExecutionState(RunTurnCommandDto command)
|
||||
{
|
||||
_command = command;
|
||||
_workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary);
|
||||
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
|
||||
}
|
||||
|
||||
public ToolCallRegistry ToolCalls { get; } = new();
|
||||
|
||||
public AgentIdentity? ActiveAgent { get; private set; }
|
||||
|
||||
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||
|
||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||
|
||||
public ProviderTurnStreamCapabilities StreamCapabilities { get; private set; } = ProviderTurnStreamCapabilities.None;
|
||||
|
||||
public string? CurrentProviderTurnId { get; private set; }
|
||||
|
||||
public string? LatestCompletedProviderTurnId { get; private set; }
|
||||
|
||||
public bool SuppressHookLifecycleEvents { get; set; }
|
||||
|
||||
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
|
||||
{
|
||||
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
|
||||
}
|
||||
|
||||
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
|
||||
{
|
||||
return AgentIdentityResolver.ResolveAgentIdentity(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
agentId,
|
||||
agentName,
|
||||
_agentSubworkflowIndex);
|
||||
}
|
||||
|
||||
public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent)
|
||||
{
|
||||
return AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
agentIdentifier,
|
||||
_agentSubworkflowIndex,
|
||||
out agent);
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentIdentity(
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
return AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
agentIdentifier,
|
||||
fallbackAgent,
|
||||
_agentSubworkflowIndex,
|
||||
out agent);
|
||||
}
|
||||
|
||||
public bool TryCreateSubworkflowLifecycleActivity(
|
||||
string activityType,
|
||||
string? executorId,
|
||||
out AgentActivityEventDto activity)
|
||||
{
|
||||
activity = default!;
|
||||
if (!AgentIdentityResolver.TryResolveSubworkflowContext(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
executorId,
|
||||
out SubworkflowContext subworkflow))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
activity = CreateSubworkflowActivity(activityType, subworkflow);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(thinkingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
public void QueueCompletedActivity(AgentIdentity agent)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
|
||||
}
|
||||
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (evt is AgentActivityEventDto activity
|
||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName);
|
||||
}
|
||||
}
|
||||
|
||||
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
|
||||
{
|
||||
AgentIdentity agent = ResolveAgentIdentity(
|
||||
agentDefinition.GetAgentId(),
|
||||
agentDefinition.GetAgentName());
|
||||
|
||||
switch (sessionEvent)
|
||||
{
|
||||
case ProviderAssistantMessageDeltaEvent messageDelta:
|
||||
RecordObservedAgentForMessage(agent, messageDelta.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case ProviderAssistantMessageEvent assistantMessage:
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
if (!string.IsNullOrWhiteSpace(assistantMessage.Content)
|
||||
&& TryFinalizeTranscriptMessage(
|
||||
assistantMessage.MessageId,
|
||||
agent.AgentName,
|
||||
assistantMessage.Content,
|
||||
out TranscriptSegment finalizedSegment))
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateTurnDeltaEvent(
|
||||
finalizedSegment.MessageId,
|
||||
finalizedSegment.AuthorName,
|
||||
string.Empty,
|
||||
finalizedSegment.Content));
|
||||
}
|
||||
|
||||
if (assistantMessage.HasToolRequests)
|
||||
{
|
||||
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
|
||||
}
|
||||
break;
|
||||
case ProviderToolExecutionStartEvent toolExecutionStart:
|
||||
string toolCallId = toolExecutionStart.ToolCallId;
|
||||
string toolName = toolExecutionStart.ToolName;
|
||||
bool shouldQueueToolActivity = TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
|
||||
ActiveAgent = agent;
|
||||
if (shouldQueueToolActivity)
|
||||
{
|
||||
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
|
||||
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
|
||||
if (toolActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(toolActivity);
|
||||
}
|
||||
}
|
||||
|
||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||
break;
|
||||
case ProviderToolExecutionProgressEvent toolExecutionProgress:
|
||||
ActiveAgent = agent;
|
||||
TrackToolExecutionProgress(toolExecutionProgress.ToolCallId, toolExecutionProgress.ProgressMessage);
|
||||
break;
|
||||
case ProviderToolExecutionPartialResultEvent toolExecutionPartialResult:
|
||||
ActiveAgent = agent;
|
||||
TrackToolExecutionPartialResult(toolExecutionPartialResult.ToolCallId, toolExecutionPartialResult.PartialOutput);
|
||||
break;
|
||||
case ProviderToolExecutionCompleteEvent toolExecutionComplete:
|
||||
ActiveAgent = agent;
|
||||
TrackToolExecutionComplete(toolExecutionComplete);
|
||||
break;
|
||||
case ProviderAssistantIntentEvent intentEvent:
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
|
||||
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
|
||||
if (assistantIntent is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(assistantIntent);
|
||||
}
|
||||
break;
|
||||
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
|
||||
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
|
||||
agent,
|
||||
reasoningDelta.ReasoningId,
|
||||
reasoningDelta.DeltaContent);
|
||||
if (reasoningDeltaEvent is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
||||
}
|
||||
break;
|
||||
case ProviderAssistantReasoningEvent reasoning:
|
||||
ActiveAgent = agent;
|
||||
TrackReasoningContent(reasoning.ReasoningId, reasoning.Content, isComplete: true);
|
||||
break;
|
||||
case ProviderAssistantTurnStartEvent turnStart:
|
||||
ActiveAgent = agent;
|
||||
CurrentProviderTurnId = turnStart.TurnId;
|
||||
break;
|
||||
case ProviderAssistantTurnEndEvent turnEnd:
|
||||
ActiveAgent = agent;
|
||||
LatestCompletedProviderTurnId = turnEnd.TurnId;
|
||||
if (string.Equals(CurrentProviderTurnId, turnEnd.TurnId, StringComparison.Ordinal))
|
||||
{
|
||||
CurrentProviderTurnId = null;
|
||||
}
|
||||
break;
|
||||
case ProviderSubagentStartedEvent started:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
|
||||
break;
|
||||
case ProviderSubagentCompletedEvent completed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed));
|
||||
break;
|
||||
case ProviderSubagentFailedEvent failed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed));
|
||||
break;
|
||||
case ProviderSubagentSelectedEvent selected:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected));
|
||||
break;
|
||||
case ProviderSubagentDeselectedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
||||
break;
|
||||
case ProviderSkillInvokedEvent skillInvoked:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked));
|
||||
break;
|
||||
case ProviderHookStartEvent hookStart:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
||||
agent,
|
||||
"start",
|
||||
hookStart.HookInvocationId,
|
||||
hookStart.HookType,
|
||||
input: hookStart.Input));
|
||||
}
|
||||
break;
|
||||
case ProviderHookEndEvent hookEnd:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
||||
agent,
|
||||
"end",
|
||||
hookEnd.HookInvocationId,
|
||||
hookEnd.HookType,
|
||||
success: hookEnd.Success,
|
||||
output: hookEnd.Output,
|
||||
error: hookEnd.Error));
|
||||
}
|
||||
break;
|
||||
case ProviderAssistantUsageEvent assistantUsage:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage));
|
||||
break;
|
||||
case ProviderSessionUsageEvent usageInfo:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo));
|
||||
break;
|
||||
case ProviderSessionCompactionStartEvent compactionStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart));
|
||||
break;
|
||||
case ProviderSessionCompactionCompleteEvent compactionComplete:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete));
|
||||
break;
|
||||
case ProviderPendingMessagesModifiedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
||||
break;
|
||||
case ProviderMcpOauthRequiredEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
case ProviderExitPlanModeRequestedEvent:
|
||||
HasPendingExitPlanModeRequest = true;
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||
{
|
||||
List<SidecarEventDto> pending = [];
|
||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||
{
|
||||
pending.Add(pendingEvent);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
_pendingMcpOauthRequests.Enqueue(request);
|
||||
}
|
||||
|
||||
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
|
||||
{
|
||||
List<McpOauthRequiredEventDto> pending = [];
|
||||
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
|
||||
{
|
||||
pending.Add(request);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
|
||||
{
|
||||
return ToolCalls.TryGetExecution(toolCallId, out snapshot);
|
||||
}
|
||||
|
||||
public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot)
|
||||
{
|
||||
snapshot = null;
|
||||
return !string.IsNullOrWhiteSpace(reasoningId)
|
||||
&& _reasoningById.TryGetValue(reasoningId, out snapshot);
|
||||
}
|
||||
|
||||
public bool TryGetLatestIntent(string? agentId, [NotNullWhen(true)] out string? intent)
|
||||
{
|
||||
intent = null;
|
||||
return !string.IsNullOrWhiteSpace(agentId)
|
||||
&& _latestIntentByAgentId.TryGetValue(agentId, out intent);
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
return !string.IsNullOrWhiteSpace(messageId)
|
||||
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
||||
}
|
||||
|
||||
public string CreateMessageId(string? messageId)
|
||||
{
|
||||
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
||||
}
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
|
||||
}
|
||||
|
||||
public bool TryAppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
return _transcriptBuffer.TryAppendDelta(messageId, authorName, delta, out segment);
|
||||
}
|
||||
|
||||
public bool TryFinalizeTranscriptMessage(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string content,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
return _transcriptBuffer.TryApplySnapshot(messageId, authorName, content, out segment);
|
||||
}
|
||||
|
||||
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
|
||||
{
|
||||
if (ActiveAgent.HasValue
|
||||
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
||||
{
|
||||
ActiveAgent = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
_observedAgentsByMessageId[messageId] = agent;
|
||||
_lastObservedMessageId = messageId;
|
||||
}
|
||||
|
||||
private bool TrackToolCall(
|
||||
string toolCallId,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
return ToolCalls.TryRecordToolRequest(toolCallId, toolName, toolArguments);
|
||||
}
|
||||
|
||||
private void TrackToolExecutionProgress(string toolCallId, string? progressMessage)
|
||||
{
|
||||
ToolCalls.RecordProgress(toolCallId, progressMessage);
|
||||
}
|
||||
|
||||
private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput)
|
||||
{
|
||||
ToolCalls.RecordPartialResult(toolCallId, partialOutput);
|
||||
}
|
||||
|
||||
private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution)
|
||||
{
|
||||
ToolCalls.RecordCompletion(toolExecution);
|
||||
}
|
||||
|
||||
private void TrackLatestIntent(string agentId, string? intent)
|
||||
{
|
||||
string? normalizedIntent = NormalizeOptionalString(intent);
|
||||
if (normalizedIntent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_latestIntentByAgentId[agentId] = normalizedIntent;
|
||||
}
|
||||
|
||||
private void TrackReasoningContent(string? reasoningId, string? content, bool isComplete)
|
||||
{
|
||||
string? normalizedReasoningId = NormalizeOptionalString(reasoningId);
|
||||
if (normalizedReasoningId is null || content is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_reasoningById.AddOrUpdate(
|
||||
normalizedReasoningId,
|
||||
id => new ProviderReasoningSnapshot
|
||||
{
|
||||
ReasoningId = id,
|
||||
Content = content,
|
||||
IsComplete = isComplete,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Content = isComplete ? content : string.Concat(existing.Content, content),
|
||||
IsComplete = isComplete || existing.IsComplete,
|
||||
});
|
||||
}
|
||||
|
||||
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(messageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedMessageId = messageId.Trim();
|
||||
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
|
||||
if (!_startedAgents.Add(agent.AgentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = agent.Subworkflow?.SubworkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateToolCallingActivity(
|
||||
AgentIdentity agent,
|
||||
string toolName,
|
||||
string toolCallId,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments = null)
|
||||
{
|
||||
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "tool-calling",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = agent.Subworkflow?.SubworkflowName,
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolCallId,
|
||||
ToolArguments = toolArguments,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = agent.Subworkflow?.SubworkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto CreateSubworkflowActivity(
|
||||
string activityType,
|
||||
SubworkflowContext subworkflow)
|
||||
{
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = activityType,
|
||||
SubworkflowNodeId = subworkflow.SubworkflowNodeId,
|
||||
SubworkflowName = subworkflow.SubworkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||
{
|
||||
return new MessageReclassifiedEventDto
|
||||
{
|
||||
Type = "message-reclassified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
MessageId = messageId,
|
||||
NewKind = "thinking",
|
||||
};
|
||||
}
|
||||
|
||||
public void UpdateCompletedMessages(
|
||||
IReadOnlyList<ChatMessage> allMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
IProviderTranscriptProjector transcriptProjector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transcriptProjector);
|
||||
|
||||
List<ChatMessage> newMessages = transcriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
|
||||
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
newMessages,
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages(IProviderTranscriptProjector transcriptProjector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transcriptProjector);
|
||||
|
||||
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
|
||||
{
|
||||
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
[],
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
foreach (ChatMessageDto message in CompletedMessages)
|
||||
{
|
||||
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||
{
|
||||
message.MessageKind = "thinking";
|
||||
}
|
||||
}
|
||||
|
||||
return CompletedMessages;
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentStartedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentStartedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "started",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data.ToolCallId,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
CustomAgentDescription = data.AgentDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentCompletedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentCompletedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data.ToolCallId,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentFailedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentFailedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "failed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data.ToolCallId,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
Error = data.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentSelectedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentSelectedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "selected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
Tools = data.Tools,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "deselected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantIntentEventDto? CreateAssistantIntentEvent(
|
||||
AgentIdentity agent,
|
||||
string? intent)
|
||||
{
|
||||
string? normalizedIntent = intent?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedIntent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssistantIntentEventDto
|
||||
{
|
||||
Type = "assistant-intent",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Intent = normalizedIntent,
|
||||
};
|
||||
}
|
||||
|
||||
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
|
||||
AgentIdentity agent,
|
||||
string? reasoningId,
|
||||
string? deltaContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reasoningId)
|
||||
|| string.IsNullOrEmpty(deltaContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReasoningDeltaEventDto
|
||||
{
|
||||
Type = "reasoning-delta",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ReasoningId = reasoningId,
|
||||
ContentDelta = deltaContent,
|
||||
};
|
||||
}
|
||||
|
||||
private TurnDeltaEventDto CreateTurnDeltaEvent(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string contentDelta,
|
||||
string? content)
|
||||
{
|
||||
return new TurnDeltaEventDto
|
||||
{
|
||||
Type = "turn-delta",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
MessageId = messageId,
|
||||
AuthorName = authorName,
|
||||
ContentDelta = contentDelta,
|
||||
Content = content,
|
||||
};
|
||||
}
|
||||
|
||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSkillInvokedEvent data)
|
||||
{
|
||||
return new SkillInvokedEventDto
|
||||
{
|
||||
Type = "skill-invoked",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SkillName = data.SkillName,
|
||||
Path = data.Path,
|
||||
Content = data.Content,
|
||||
AllowedTools = data.AllowedTools,
|
||||
PluginName = data.PluginName,
|
||||
PluginVersion = data.PluginVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
string hookInvocationId,
|
||||
string hookType,
|
||||
object? input = null,
|
||||
bool? success = null,
|
||||
object? output = null,
|
||||
string? error = null)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = hookInvocationId,
|
||||
HookType = hookType,
|
||||
Phase = phase,
|
||||
Input = input,
|
||||
Success = success,
|
||||
Output = output,
|
||||
Error = error,
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantUsageEventDto CreateAssistantUsageEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderAssistantUsageEvent data)
|
||||
{
|
||||
return new AssistantUsageEventDto
|
||||
{
|
||||
Type = "assistant-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Model = data.Model,
|
||||
InputTokens = data.InputTokens,
|
||||
OutputTokens = data.OutputTokens,
|
||||
CacheReadTokens = data.CacheReadTokens,
|
||||
CacheWriteTokens = data.CacheWriteTokens,
|
||||
Cost = data.Cost,
|
||||
Duration = data.Duration,
|
||||
TotalNanoAiu = data.TotalNanoAiu,
|
||||
QuotaSnapshots = data.QuotaSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, ProviderSessionUsageEvent data)
|
||||
{
|
||||
return new SessionUsageEventDto
|
||||
{
|
||||
Type = "session-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
TokenLimit = data.TokenLimit,
|
||||
CurrentTokens = data.CurrentTokens,
|
||||
MessagesLength = data.MessagesLength,
|
||||
SystemTokens = data.SystemTokens,
|
||||
ConversationTokens = data.ConversationTokens,
|
||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||
IsInitial = data.IsInitial,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionStartEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSessionCompactionStartEvent data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "start",
|
||||
SystemTokens = data.SystemTokens,
|
||||
ConversationTokens = data.ConversationTokens,
|
||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSessionCompactionCompleteEvent data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "complete",
|
||||
Success = data.Success,
|
||||
Error = data.Error,
|
||||
SystemTokens = data.SystemTokens,
|
||||
ConversationTokens = data.ConversationTokens,
|
||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||
PreCompactionTokens = data.PreCompactionTokens,
|
||||
PostCompactionTokens = data.PostCompactionTokens,
|
||||
PreCompactionMessagesLength = data.PreCompactionMessagesLength,
|
||||
MessagesRemoved = data.MessagesRemoved,
|
||||
TokensRemoved = data.TokensRemoved,
|
||||
SummaryContent = data.SummaryContent,
|
||||
CheckpointNumber = data.CheckpointNumber,
|
||||
CheckpointPath = data.CheckpointPath,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new PendingMessagesModifiedEventDto
|
||||
{
|
||||
Type = "pending-messages-modified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -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,287 @@
|
||||
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 bool IsSubWorkflowNode(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return string.Equals(node.Kind, "sub-workflow", 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)
|
||||
{
|
||||
return node.TryResolveSubWorkflowDefinition(workflowLibrary)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
|
||||
}
|
||||
|
||||
public static WorkflowDefinitionDto? TryResolveSubWorkflowDefinition(
|
||||
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;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mode);
|
||||
|
||||
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static WorkflowNodeDto? FindSubWorkflowNode(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
string? nodeId,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
string? normalizedNodeId = NormalizeOptionalString(nodeId);
|
||||
if (normalizedNodeId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindSubWorkflowNode(
|
||||
workflow,
|
||||
normalizedNodeId,
|
||||
CreateWorkflowLibraryMap(workflowLibrary),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
}
|
||||
|
||||
internal static WorkflowNodeDto? FindSubWorkflowNode(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
string? nodeId,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
string? normalizedNodeId = NormalizeOptionalString(nodeId);
|
||||
if (normalizedNodeId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindSubWorkflowNode(
|
||||
workflow,
|
||||
normalizedNodeId,
|
||||
workflowLibrary ?? EmptyWorkflowLibrary,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
}
|
||||
|
||||
internal static string GetSubworkflowDisplayName(
|
||||
this WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
|
||||
WorkflowDefinitionDto? resolvedWorkflow = null;
|
||||
if (node.Config.InlineWorkflow is not null)
|
||||
{
|
||||
resolvedWorkflow = node.Config.InlineWorkflow;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
|
||||
&& workflowLibrary is not null
|
||||
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
|
||||
{
|
||||
resolvedWorkflow = workflow;
|
||||
}
|
||||
|
||||
return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow";
|
||||
}
|
||||
|
||||
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.TryResolveSubWorkflowDefinition(workflowLibrary);
|
||||
if (subWorkflow is not null)
|
||||
{
|
||||
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto? FindSubWorkflowNode(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
string nodeId,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
ISet<string> visitedWorkflowIds,
|
||||
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
|
||||
{
|
||||
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
|
||||
if (workflowId is not null)
|
||||
{
|
||||
if (!visitedWorkflowIds.Add(workflowId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
if (!node.IsSubWorkflowNode())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return node;
|
||||
}
|
||||
|
||||
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
|
||||
if (subWorkflow is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkflowNodeDto? match = FindSubWorkflowNode(
|
||||
subWorkflow,
|
||||
nodeId,
|
||||
workflowLibrary,
|
||||
visitedWorkflowIds,
|
||||
visitedAnonymousWorkflows);
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class WorkflowOrchestrationFactory
|
||||
{
|
||||
public static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
|
||||
AIAgent entryAgent,
|
||||
HandoffModeSettingsDto? settings = null)
|
||||
{
|
||||
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
|
||||
HandoffWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
|
||||
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
|
||||
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
|
||||
if (effectiveSettings.ReturnToPrevious)
|
||||
{
|
||||
builder = builder.EnableReturnToPrevious();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public 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;
|
||||
HandoffWorkflowBuilder 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();
|
||||
}
|
||||
|
||||
public 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;
|
||||
}
|
||||
|
||||
public static Workflow CreateGroupChatWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 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? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -12,21 +11,23 @@ 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(
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo,
|
||||
AgentIdentity? activeAgent,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
ToolCallRegistry toolCalls)
|
||||
{
|
||||
RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo);
|
||||
RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
|
||||
return interpretation switch
|
||||
{
|
||||
HandoffRequestInterpretation handoff =>
|
||||
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
|
||||
ToolRequestInterpretation tool when activeAgent.HasValue =>
|
||||
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId),
|
||||
CreateToolCallingActivity(command, activeAgent.Value, tool, toolCalls),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -35,8 +36,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, requestInfo) is UnknownRequestInterpretation;
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateHandoffActivity(
|
||||
@@ -52,6 +53,8 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
ActivityType = HandoffActivityType,
|
||||
AgentId = handoffAgent.AgentId,
|
||||
AgentName = handoffAgent.AgentName,
|
||||
SubworkflowNodeId = handoffAgent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = handoffAgent.Subworkflow?.SubworkflowName,
|
||||
SourceAgentId = activeAgent?.AgentId,
|
||||
SourceAgentName = activeAgent?.AgentName,
|
||||
};
|
||||
@@ -61,15 +64,13 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
RunTurnCommandDto command,
|
||||
AgentIdentity activeAgent,
|
||||
ToolRequestInterpretation tool,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
ToolCallRegistry toolCalls)
|
||||
{
|
||||
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
|
||||
if (!toolCalls.TryRecordToolRequest(tool.ToolCallId, tool.ToolName, tool.ToolArguments))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
@@ -78,38 +79,30 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
ActivityType = ToolCallingActivityType,
|
||||
AgentId = activeAgent.AgentId,
|
||||
AgentName = activeAgent.AgentName,
|
||||
SubworkflowNodeId = activeAgent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = activeAgent.Subworkflow?.SubworkflowName,
|
||||
ToolName = tool.ToolName,
|
||||
ToolCallId = tool.ToolCallId,
|
||||
ToolArguments = tool.ToolArguments,
|
||||
};
|
||||
}
|
||||
|
||||
private static void TrackToolCallId(
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId,
|
||||
string? toolCallId,
|
||||
string toolName)
|
||||
{
|
||||
if (toolCallId is not null)
|
||||
{
|
||||
toolNamesByCallId[toolCallId] = toolName;
|
||||
}
|
||||
}
|
||||
|
||||
private static RequestInterpretation InterpretRequest(
|
||||
PatternDefinitionDto pattern,
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
if (TryGetHandoffTarget(pattern, requestInfo, out AgentIdentity handoffAgent))
|
||||
if (TryGetHandoffTarget(command, 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,
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
@@ -128,7 +121,8 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
}
|
||||
|
||||
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
pattern,
|
||||
command.Workflow,
|
||||
command.WorkflowLibrary,
|
||||
target.Id,
|
||||
target.Name);
|
||||
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||
@@ -137,40 +131,46 @@ 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))
|
||||
{
|
||||
toolName = NormalizeOptionalString(mcpToolCall.ToolName)
|
||||
toolName = NormalizeOptionalString(mcpToolCall.Name)
|
||||
?? NormalizeOptionalString(mcpToolCall.ServerName)
|
||||
?? string.Empty;
|
||||
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
|
||||
toolArguments = NormalizeToolArguments(mcpToolCall.Arguments);
|
||||
return toolName.Length > 0;
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
{
|
||||
toolName = CodeInterpreterToolName;
|
||||
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
|
||||
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -185,14 +186,216 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
{
|
||||
toolName = ImageGenerationToolName;
|
||||
toolCallId = null;
|
||||
toolArguments = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
toolName = string.Empty;
|
||||
toolCallId = null;
|
||||
toolArguments = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, object?>? NormalizeRawToolArguments(object? rawArguments)
|
||||
{
|
||||
return rawArguments switch
|
||||
{
|
||||
null => null,
|
||||
JsonElement { ValueKind: JsonValueKind.Object } element => NormalizeToolArgumentObject(element),
|
||||
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
|
||||
_ => NormalizeRawToolArgumentsViaJson(rawArguments),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?>? NormalizeRawToolArgumentsViaJson(object value)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object
|
||||
? NormalizeToolArgumentObject(document.RootElement)
|
||||
: null;
|
||||
}
|
||||
|
||||
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 +411,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).WithOpenTelemetry().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(AgentHostOptionsFactory.CreateDefault()));
|
||||
}
|
||||
|
||||
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, [])
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,147 @@ public sealed class AgentIdentityResolverTests
|
||||
Assert.Equal("UX Specialist", agent.AgentName);
|
||||
}
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(
|
||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||
string mode = "concurrent")
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
|
||||
"nested-review-workflow",
|
||||
[
|
||||
CreateAgent("agent-reviewer", "Reviewer"),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"parent-workflow",
|
||||
[
|
||||
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
workflow,
|
||||
[nestedWorkflow],
|
||||
"Reviewer_agent_reviewer",
|
||||
out AgentIdentity agent);
|
||||
|
||||
Assert.True(resolved);
|
||||
Assert.Equal("agent-reviewer", agent.AgentId);
|
||||
Assert.Equal("Reviewer", agent.AgentName);
|
||||
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
|
||||
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
|
||||
{
|
||||
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
|
||||
"inner-workflow",
|
||||
[
|
||||
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
|
||||
"outer-workflow",
|
||||
[
|
||||
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"parent-workflow",
|
||||
[
|
||||
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
|
||||
IReadOnlyDictionary<string, SubworkflowContext> index =
|
||||
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
|
||||
|
||||
Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow));
|
||||
Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId);
|
||||
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAgentSubworkflowIndex_SkipsUnresolvableSubWorkflowReferences()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"parent-workflow",
|
||||
[
|
||||
CreateAgent("agent-top-level", "Top Level"),
|
||||
CreateSubworkflow("subworkflow-missing", "Missing Pipeline", workflowId: "nonexistent-workflow"),
|
||||
],
|
||||
orchestrationMode: "concurrent");
|
||||
|
||||
IReadOnlyDictionary<string, SubworkflowContext> index =
|
||||
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
|
||||
|
||||
Assert.Empty(index);
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
IReadOnlyList<WorkflowNodeDto> nodes,
|
||||
string orchestrationMode = "concurrent")
|
||||
{
|
||||
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
string id,
|
||||
IReadOnlyList<WorkflowNodeDto> nodes,
|
||||
string orchestrationMode = "concurrent")
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"{mode}-pattern",
|
||||
Name = "Pattern",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents = agents,
|
||||
Id = id,
|
||||
Name = "Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
.. nodes,
|
||||
],
|
||||
},
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateSubworkflow(
|
||||
string id,
|
||||
string label,
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Kind = "sub-workflow",
|
||||
Label = label,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
@@ -117,12 +163,12 @@ public sealed class CopilotAgentBundleTests
|
||||
{
|
||||
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
|
||||
|
||||
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
|
||||
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
|
||||
|
||||
FieldInfo field = typeof(HandoffsWorkflowBuilder).GetField(
|
||||
FieldInfo field = GetInstanceField(
|
||||
typeof(HandoffWorkflowBuilder),
|
||||
"_toolCallFilteringBehavior",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
|
||||
"Expected HandoffWorkflowBuilder to expose a filtering field.");
|
||||
|
||||
HandoffToolCallFilteringBehavior behavior = Assert.IsType<HandoffToolCallFilteringBehavior>(field.GetValue(builder));
|
||||
|
||||
@@ -130,34 +176,113 @@ 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);
|
||||
HandoffWorkflowBuilder 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 = GetInstanceField(
|
||||
typeof(HandoffWorkflowBuilder),
|
||||
"_toolCallFilteringBehavior",
|
||||
"Expected HandoffWorkflowBuilder to expose a filtering field.");
|
||||
FieldInfo returnToPreviousField = GetInstanceField(
|
||||
typeof(HandoffWorkflowBuilder),
|
||||
"_returnToPrevious",
|
||||
"Expected HandoffWorkflowBuilder to expose a return-to-previous field.");
|
||||
|
||||
Assert.Equal(agentCount, bindings.Length);
|
||||
Assert.Equal(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder));
|
||||
Assert.Equal(true, returnToPreviousField.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 +484,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 +508,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 +583,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 +596,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 +620,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,11 +631,25 @@ public sealed class CopilotAgentBundleTests
|
||||
target,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "echo",
|
||||
Name = name,
|
||||
Description = "Echo test tool",
|
||||
});
|
||||
}
|
||||
|
||||
private static FieldInfo GetInstanceField(Type type, string name, string errorMessage)
|
||||
{
|
||||
for (Type? current = type; current is not null; current = current.BaseType)
|
||||
{
|
||||
FieldInfo? field = current.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
if (field is not null)
|
||||
{
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
private static AIFunctionDeclaration CreateHandoffDeclaration()
|
||||
{
|
||||
return AIFunctionFactory.CreateDeclaration(
|
||||
@@ -513,25 +663,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 +853,4 @@ public sealed class CopilotAgentBundleTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotEventAdapterTests
|
||||
{
|
||||
private static readonly CopilotEventAdapter Adapter = new();
|
||||
|
||||
[Fact]
|
||||
public void Capabilities_AdvertiseRichTurnStreamSupport()
|
||||
{
|
||||
ProviderTurnStreamCapabilities capabilities = Adapter.Capabilities;
|
||||
|
||||
Assert.True(capabilities.SupportsIntent);
|
||||
Assert.True(capabilities.SupportsReasoningDelta);
|
||||
Assert.True(capabilities.SupportsReasoningBlock);
|
||||
Assert.True(capabilities.SupportsToolExecutionProgress);
|
||||
Assert.True(capabilities.SupportsToolExecutionPartialResult);
|
||||
Assert.True(capabilities.SupportsToolExecutionCompletion);
|
||||
Assert.True(capabilities.SupportsSubagentLifecycle);
|
||||
Assert.True(capabilities.SupportsHookLifecycle);
|
||||
Assert.True(capabilities.SupportsSessionCompaction);
|
||||
Assert.True(capabilities.SupportsPendingMessagesMutation);
|
||||
Assert.True(capabilities.SupportsSessionTurnBoundaries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdapt_ToolExecutionComplete_MapsNormalizedResult()
|
||||
{
|
||||
ProviderToolExecutionCompleteEvent evt = Assert.IsType<ProviderToolExecutionCompleteEvent>(
|
||||
Adapter.TryAdapt(SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "tool.execution_complete",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"success": true,
|
||||
"result": {
|
||||
"content": "summary",
|
||||
"detailedContent": "summary\nfull"
|
||||
}
|
||||
},
|
||||
"id": "11111111-2222-3333-4444-555555555555",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
""")));
|
||||
|
||||
Assert.Equal("tool-call-1", evt.ToolCallId);
|
||||
Assert.True(evt.Success);
|
||||
Assert.Equal("summary", evt.ResultContent);
|
||||
Assert.Equal("summary\nfull", evt.DetailedResultContent);
|
||||
Assert.Null(evt.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdapt_AssistantReasoning_MapsCompletedReasoningBlock()
|
||||
{
|
||||
ProviderAssistantReasoningEvent evt = Assert.IsType<ProviderAssistantReasoningEvent>(
|
||||
Adapter.TryAdapt(SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-1",
|
||||
"content": "Planning the next step."
|
||||
},
|
||||
"id": "66666666-7777-8888-9999-aaaaaaaaaaaa",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
""")));
|
||||
|
||||
Assert.Equal("reasoning-1", evt.ReasoningId);
|
||||
Assert.Equal("Planning the next step.", evt.Content);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -154,12 +154,16 @@ public sealed class CopilotSessionHooksTests
|
||||
[Theory]
|
||||
[InlineData("view", "read")]
|
||||
[InlineData("grep", "read")]
|
||||
[InlineData("rg", "read")]
|
||||
[InlineData("edit", "write")]
|
||||
[InlineData("apply_patch", "write")]
|
||||
[InlineData("powershell", "shell")]
|
||||
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
|
||||
[InlineData("web_search", "web_fetch")]
|
||||
[InlineData("store_memory", "store_memory")]
|
||||
public async Task Create_PreToolUseAutoAllowsWhenApprovalToolKeyIsApproved(string toolName, string approvalToolKey)
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(approvalToolKey);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
@@ -177,7 +181,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 +197,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 +223,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 +297,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 +316,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 +338,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 +349,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 +378,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 +465,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(
|
||||
"""
|
||||
{
|
||||
@@ -59,6 +59,40 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Equal("agent-1", observedAgent.AgentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
|
||||
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
nestedAgent,
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.message_delta",
|
||||
"data": {
|
||||
"messageId": "msg-nested-1",
|
||||
"deltaContent": "Reviewing"
|
||||
},
|
||||
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-reviewer", activity.AgentId);
|
||||
Assert.Equal("Reviewer", activity.AgentName);
|
||||
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
|
||||
Assert.Equal("Review Lane", activity.SubworkflowName);
|
||||
Assert.True(state.ActiveAgent.HasValue);
|
||||
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
|
||||
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
|
||||
{
|
||||
@@ -66,16 +100,143 @@ 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"}"""));
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"/src/main.ts","view_range":[10,20]}},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("tool-calling", toolActivity.ActivityType);
|
||||
Assert.Equal("view", toolActivity.ToolName);
|
||||
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.NotNull(toolActivity.ToolArguments);
|
||||
Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]);
|
||||
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_WithoutArguments_SetsToolArgumentsToNull()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
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"}"""));
|
||||
|
||||
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Null(toolActivity.ToolArguments);
|
||||
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionProgress_TracksLatestProgressMessage()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
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"}"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_progress","data":{"toolCallId":"tool-call-1","progressMessage":"Scanning repository"},"id":"43333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
|
||||
|
||||
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
|
||||
Assert.NotNull(toolExecution);
|
||||
Assert.Equal(ProviderToolExecutionStatus.Running, toolExecution.Status);
|
||||
Assert.Equal("view", toolExecution.ToolName);
|
||||
Assert.Equal("Scanning repository", toolExecution.LatestProgressMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionPartialResult_AppendsPartialOutput()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"bash"},"id":"53333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"first line\n"},"id":"63333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"second line"},"id":"73333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:02Z"}"""));
|
||||
|
||||
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
|
||||
Assert.NotNull(toolExecution);
|
||||
Assert.Equal("first line\nsecond line", toolExecution.PartialOutput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionComplete_TracksFinalToolState()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"README.md"}},"id":"83333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "tool.execution_complete",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"success": true,
|
||||
"result": {
|
||||
"content": "README excerpt",
|
||||
"detailedContent": "README excerpt\nwith more detail"
|
||||
}
|
||||
},
|
||||
"id": "93333333-3333-3333-3333-333333333333",
|
||||
"timestamp": "2026-03-27T00:00:01Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
|
||||
Assert.NotNull(toolExecution);
|
||||
Assert.Equal(ProviderToolExecutionStatus.Completed, toolExecution.Status);
|
||||
Assert.Equal("view", toolExecution.ToolName);
|
||||
Assert.NotNull(toolExecution.ToolArguments);
|
||||
Assert.Equal("README excerpt", toolExecution.ResultContent);
|
||||
Assert.Equal("README excerpt\nwith more detail", toolExecution.DetailedResultContent);
|
||||
Assert.Null(toolExecution.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionCompleteFailure_TracksErrorState()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_complete","data":{"toolCallId":"tool-call-err","success":false,"error":{"message":"permission denied"}},"id":"a3333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
|
||||
|
||||
Assert.True(state.TryGetToolExecution("tool-call-err", out ProviderToolExecutionSnapshot? toolExecution));
|
||||
Assert.NotNull(toolExecution);
|
||||
Assert.Equal(ProviderToolExecutionStatus.Failed, toolExecution.Status);
|
||||
Assert.Equal("permission denied", toolExecution.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,13 +246,30 @@ 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"}"""));
|
||||
|
||||
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
|
||||
Assert.Equal("handoff_to_specialist", toolName);
|
||||
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
||||
}
|
||||
|
||||
[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]
|
||||
@@ -101,7 +279,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -142,7 +320,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -158,7 +336,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -172,7 +350,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
"""));
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -194,10 +372,12 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
|
||||
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
||||
Assert.Equal("msg-3", reclassified.MessageId);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
|
||||
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? firstToolName));
|
||||
Assert.Equal("rg", firstToolName);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName));
|
||||
Assert.True(state.ToolCalls.TryGetToolName("tool-call-2", out string? secondToolName));
|
||||
Assert.Equal("view", secondToolName);
|
||||
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
||||
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -207,7 +387,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -231,7 +411,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -267,7 +447,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -289,6 +469,9 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Equal("session-1", intent.SessionId);
|
||||
Assert.Equal("agent-1", intent.AgentId);
|
||||
Assert.Equal("Searching incident playbooks", intent.Intent);
|
||||
Assert.True(state.TryGetLatestIntent("agent-1", out string? latestIntent));
|
||||
Assert.NotNull(latestIntent);
|
||||
Assert.Equal("Searching incident playbooks", latestIntent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -298,7 +481,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -322,6 +505,93 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Equal("agent-1", reasoning.AgentId);
|
||||
Assert.Equal("reasoning-2", reasoning.ReasoningId);
|
||||
Assert.Equal("Searching logs.", reasoning.ContentDelta);
|
||||
Assert.True(state.TryGetReasoning("reasoning-2", out ProviderReasoningSnapshot? reasoningState));
|
||||
Assert.NotNull(reasoningState);
|
||||
Assert.Equal("Searching logs.", reasoningState.Content);
|
||||
Assert.False(reasoningState.IsComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantReasoning_TracksCompletedReasoningBlock()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning_delta",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-3",
|
||||
"deltaContent": "Planning."
|
||||
},
|
||||
"id": "cd269258-5e5d-46b6-bf3f-bd8cba793b1a",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-3",
|
||||
"content": "Planning. Checking logs."
|
||||
},
|
||||
"id": "dd269258-5e5d-46b6-bf3f-bd8cba793b1a",
|
||||
"timestamp": "2026-03-27T00:00:01Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.True(state.TryGetReasoning("reasoning-3", out ProviderReasoningSnapshot? reasoning));
|
||||
Assert.NotNull(reasoning);
|
||||
Assert.Equal("Planning. Checking logs.", reasoning.Content);
|
||||
Assert.True(reasoning.IsComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantTurnBoundaries_TrackProviderTurnIds()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.turn_start",
|
||||
"data": {
|
||||
"turnId": "turn-sdk-1"
|
||||
},
|
||||
"id": "ed269258-5e5d-46b6-bf3f-bd8cba793b1a",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.Equal("turn-sdk-1", state.CurrentProviderTurnId);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.turn_end",
|
||||
"data": {
|
||||
"turnId": "turn-sdk-1"
|
||||
},
|
||||
"id": "fd269258-5e5d-46b6-bf3f-bd8cba793b1a",
|
||||
"timestamp": "2026-03-27T00:00:01Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.Null(state.CurrentProviderTurnId);
|
||||
Assert.Equal("turn-sdk-1", state.LatestCompletedProviderTurnId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -356,7 +626,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -386,7 +656,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -417,7 +687,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 +702,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 +720,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 +733,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -526,7 +796,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -561,7 +831,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
@@ -577,6 +847,53 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Equal("agent-1", evt.AgentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantMessage_FinalizesTranscriptAndSuppressesLateDeltas()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
Assert.True(state.TryAppendDelta("msg-final", "Primary", "Draft response", out TranscriptSegment streamed));
|
||||
Assert.False(streamed.IsFinalized);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.message",
|
||||
"data": {
|
||||
"messageId": "msg-final",
|
||||
"content": "Provider final response"
|
||||
},
|
||||
"id": "12121212-1212-1212-1212-121212121212",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
TurnDeltaEventDto correction = Assert.Single(state.DrainPendingEvents().OfType<TurnDeltaEventDto>());
|
||||
Assert.Equal("msg-final", correction.MessageId);
|
||||
Assert.Equal("Primary", correction.AuthorName);
|
||||
Assert.Equal(string.Empty, correction.ContentDelta);
|
||||
Assert.Equal("Provider final response", correction.Content);
|
||||
|
||||
Assert.False(state.TryAppendDelta("msg-final", "Primary", " late delta", out TranscriptSegment unchanged));
|
||||
Assert.True(unchanged.IsFinalized);
|
||||
Assert.Equal("Provider final response", unchanged.Content);
|
||||
|
||||
ChatMessage output = new(ChatRole.Assistant, "Workflow wording")
|
||||
{
|
||||
MessageId = "msg-final",
|
||||
AuthorName = "assistant",
|
||||
};
|
||||
|
||||
state.UpdateCompletedMessages([output], []);
|
||||
ChatMessageDto completed = Assert.Single(state.FinalizeCompletedMessages());
|
||||
Assert.Equal("msg-final", completed.Id);
|
||||
Assert.Equal("Primary", completed.AuthorName);
|
||||
Assert.Equal("Provider final response", completed.Content);
|
||||
}
|
||||
|
||||
private static SessionEvent CreateHookStartEvent()
|
||||
{
|
||||
return SessionEvent.FromJson(
|
||||
@@ -624,7 +941,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 +989,110 @@ 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 =
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithReferencedSubworkflow()
|
||||
{
|
||||
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
|
||||
"nested-review-workflow",
|
||||
[
|
||||
CreateAgent("agent-reviewer", "Reviewer"),
|
||||
]);
|
||||
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
WorkflowLibrary = [nestedWorkflow],
|
||||
Workflow = CreateWorkflow(
|
||||
"workflow-parent",
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(string id, IReadOnlyList<WorkflowNodeDto> nodes)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = "Execution State Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = [.. nodes],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateSubworkflow(
|
||||
string id,
|
||||
string label,
|
||||
string? workflowId = null)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Kind = "sub-workflow",
|
||||
Label = label,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using Aryx.AgentHost.Services;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class OpenTelemetrySetupTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveTracingConfiguration_ReturnsNullWhenEndpointMissing()
|
||||
{
|
||||
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration([]);
|
||||
|
||||
Assert.Null(configuration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTracingConfiguration_UsesGrpcByDefault()
|
||||
{
|
||||
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
|
||||
[
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
|
||||
]);
|
||||
|
||||
Assert.NotNull(configuration);
|
||||
Assert.Equal(new Uri("http://localhost:4317"), configuration.Endpoint);
|
||||
Assert.Equal(OtlpExportProtocol.Grpc, configuration.Protocol);
|
||||
Assert.Collection(
|
||||
configuration.ActivitySourceNames,
|
||||
source => Assert.Equal("Experimental.Microsoft.Agents.AI", source),
|
||||
source => Assert.Equal("Microsoft.Agents.AI.Workflows", source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTracingConfiguration_UsesHttpProtobufWhenRequested()
|
||||
{
|
||||
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
|
||||
[
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
|
||||
]);
|
||||
|
||||
Assert.NotNull(configuration);
|
||||
Assert.Equal(new Uri("http://localhost:4318"), configuration.Endpoint);
|
||||
Assert.Equal(OtlpExportProtocol.HttpProtobuf, configuration.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTracingConfiguration_ThrowsWhenEndpointInvalid()
|
||||
{
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
|
||||
OpenTelemetrySetup.ResolveTracingConfiguration(
|
||||
[
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"),
|
||||
]));
|
||||
|
||||
Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTracingConfiguration_ThrowsWhenProtocolUnsupported()
|
||||
{
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
|
||||
OpenTelemetrySetup.ResolveTracingConfiguration(
|
||||
[
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"),
|
||||
]));
|
||||
|
||||
Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTracerProvider_CreatesProviderAndWritesDiagnosticsWhenConfigured()
|
||||
{
|
||||
StringWriter diagnosticsWriter = new();
|
||||
|
||||
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProvider(
|
||||
[
|
||||
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
|
||||
],
|
||||
diagnosticsWriter);
|
||||
|
||||
Assert.NotNull(tracerProvider);
|
||||
Assert.Contains("Aryx.AgentHost OpenTelemetry tracing enabled", diagnosticsWriter.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
internal static class SessionEventTestExtensions
|
||||
{
|
||||
private static readonly IProviderEventAdapter ProviderEventAdapter = new CopilotEventAdapter();
|
||||
|
||||
public static void ObserveSessionEvent(
|
||||
this CopilotTurnExecutionState state,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
SessionEvent sessionEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
ArgumentNullException.ThrowIfNull(sessionEvent);
|
||||
|
||||
ProviderSessionEvent providerEvent = Assert.IsAssignableFrom<ProviderSessionEvent>(
|
||||
ProviderEventAdapter.TryAdapt(sessionEvent));
|
||||
|
||||
state.ObserveSessionEvent(agentDefinition, providerEvent);
|
||||
}
|
||||
}
|
||||
@@ -63,23 +63,147 @@ public sealed class SidecarProtocolHostTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion()
|
||||
public async Task InternalConstructor_UsesAgentProviderDefaults()
|
||||
{
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidatePatternCommandDto
|
||||
FakeWorkflowRunner workflowRunner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
Type = "validate-pattern",
|
||||
RequestId = "validate-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
await onActivity(new AgentActivityEventDto
|
||||
{
|
||||
Id = "single-pattern",
|
||||
Type = "agent-activity",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = "agent-provider",
|
||||
AgentName = "Provider Agent",
|
||||
});
|
||||
|
||||
return
|
||||
[
|
||||
new ChatMessageDto
|
||||
{
|
||||
Id = "assistant-provider",
|
||||
Role = "assistant",
|
||||
AuthorName = "Provider Agent",
|
||||
Content = "Hello from the provider.",
|
||||
CreatedAt = "2026-01-01T00:00:00.0000000Z",
|
||||
},
|
||||
];
|
||||
});
|
||||
FakeSessionManager sessionManager = new()
|
||||
{
|
||||
Sessions =
|
||||
[
|
||||
new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = "aryx::provider-session::agent-provider",
|
||||
ManagedByAryx = true,
|
||||
SessionId = "provider-session",
|
||||
AgentId = "agent-provider",
|
||||
},
|
||||
],
|
||||
};
|
||||
SidecarCapabilitiesDto capabilities = new()
|
||||
{
|
||||
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
},
|
||||
Models =
|
||||
[
|
||||
new SidecarModelCapabilityDto
|
||||
{
|
||||
Id = "provider-model",
|
||||
Name = "Provider Model",
|
||||
},
|
||||
],
|
||||
RuntimeTools = [],
|
||||
Connection = new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = "Provider is ready.",
|
||||
CheckedAt = "2026-01-01T00:00:00.0000000Z",
|
||||
},
|
||||
};
|
||||
SidecarProtocolHost host = new(
|
||||
new WorkflowValidator(),
|
||||
new FakeAgentProvider(workflowRunner, sessionManager, capabilities));
|
||||
|
||||
IReadOnlyList<JsonElement> capabilityEvents = await RunHostAsync(
|
||||
new DescribeCapabilitiesCommandDto
|
||||
{
|
||||
Type = "describe-capabilities",
|
||||
RequestId = "provider-capabilities",
|
||||
},
|
||||
host);
|
||||
IReadOnlyList<JsonElement> sessionEvents = await RunHostAsync(
|
||||
new ListSessionsCommandDto
|
||||
{
|
||||
Type = "list-sessions",
|
||||
RequestId = "provider-sessions",
|
||||
},
|
||||
host);
|
||||
IReadOnlyList<JsonElement> turnEvents = await RunHostAsync(
|
||||
CreateRunTurnCommand(requestId: "provider-turn"),
|
||||
host);
|
||||
|
||||
JsonElement capabilityEvent = AssertSingleEvent(capabilityEvents, "capabilities", "provider-capabilities");
|
||||
JsonElement model = Assert.Single(capabilityEvent.GetProperty("capabilities").GetProperty("models").EnumerateArray());
|
||||
Assert.Equal("provider-model", model.GetProperty("id").GetString());
|
||||
|
||||
JsonElement listedEvent = AssertSingleEvent(sessionEvents, "sessions-listed", "provider-sessions");
|
||||
JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray());
|
||||
Assert.Equal("provider-session", session.GetProperty("sessionId").GetString());
|
||||
|
||||
JsonElement turnComplete = AssertSingleEvent(turnEvents, "turn-complete", "provider-turn");
|
||||
JsonElement message = Assert.Single(turnComplete.GetProperty("messages").EnumerateArray());
|
||||
Assert.Equal("Hello from the provider.", message.GetProperty("content").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
|
||||
{
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidateWorkflowCommandDto
|
||||
{
|
||||
Type = "validate-workflow",
|
||||
RequestId = "validate-workflow-1",
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
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 +211,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 +233,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 +281,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 +327,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 +348,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 +381,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 +389,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 +399,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
|
||||
@@ -357,6 +412,7 @@ public sealed class SidecarProtocolHostTests
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
PermissionKind = "tool access",
|
||||
ApprovalToolKey = "shell",
|
||||
Title = "Approve tool access",
|
||||
PermissionDetail = new PermissionDetailDto
|
||||
{
|
||||
@@ -371,24 +427,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(
|
||||
@@ -399,6 +438,7 @@ public sealed class SidecarProtocolHostTests
|
||||
Assert.Equal("turn-approval", approvalEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString());
|
||||
Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString());
|
||||
Assert.Equal("shell", approvalEvent.GetProperty("approvalToolKey").GetString());
|
||||
Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString());
|
||||
JsonElement permissionDetail = approvalEvent.GetProperty("permissionDetail");
|
||||
Assert.Equal("shell", permissionDetail.GetProperty("kind").GetString());
|
||||
@@ -422,7 +462,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 +482,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 +518,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 +576,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 +597,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 +633,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 +681,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 +703,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 +736,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) =>
|
||||
@@ -762,7 +767,7 @@ public sealed class SidecarProtocolHostTests
|
||||
[Fact]
|
||||
public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName()
|
||||
{
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = CopilotAgentProvider.MapRuntimeTools(
|
||||
[
|
||||
new Tool
|
||||
{
|
||||
@@ -815,7 +820,7 @@ public sealed class SidecarProtocolHostTests
|
||||
[Fact]
|
||||
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
|
||||
{
|
||||
string status = SidecarProtocolHost.ClassifyConnectionStatus(
|
||||
string status = CopilotAgentProvider.ClassifyConnectionStatus(
|
||||
new InvalidOperationException("Please run copilot auth login to continue."));
|
||||
|
||||
Assert.Equal("copilot-auth-required", status);
|
||||
@@ -825,7 +830,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto diagnostics =
|
||||
SidecarProtocolHost.CreateReadyConnectionDiagnostics(
|
||||
CopilotAgentProvider.CreateReadyConnectionDiagnostics(
|
||||
@"C:\tools\copilot\copilot.exe",
|
||||
2,
|
||||
new SidecarCopilotCliVersionDiagnosticsDto
|
||||
@@ -855,7 +860,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
sessionManager: new FakeSessionManager
|
||||
{
|
||||
Sessions =
|
||||
@@ -902,7 +907,7 @@ public sealed class SidecarProtocolHostTests
|
||||
],
|
||||
};
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new WorkflowValidator(),
|
||||
sessionManager: sessionManager);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
@@ -925,7 +930,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 +972,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 +1033,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 +1111,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 +1147,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 +1163,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<
|
||||
@@ -1218,6 +1244,38 @@ public sealed class SidecarProtocolHostTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeAgentProvider : IAgentProvider
|
||||
{
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
private readonly IProviderSessionManager _sessionManager;
|
||||
private readonly SidecarCapabilitiesDto _capabilities;
|
||||
|
||||
public FakeAgentProvider(
|
||||
ITurnWorkflowRunner workflowRunner,
|
||||
IProviderSessionManager sessionManager,
|
||||
SidecarCapabilitiesDto capabilities)
|
||||
{
|
||||
_workflowRunner = workflowRunner;
|
||||
_sessionManager = sessionManager;
|
||||
_capabilities = capabilities;
|
||||
}
|
||||
|
||||
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
|
||||
{
|
||||
return _workflowRunner;
|
||||
}
|
||||
|
||||
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_capabilities);
|
||||
}
|
||||
|
||||
public IProviderSessionManager CreateSessionManager()
|
||||
{
|
||||
return _sessionManager;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
@@ -1255,3 +1313,4 @@ public sealed class SidecarProtocolHostTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,22 +37,36 @@ public sealed class StreamingTextMergerTests
|
||||
Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_InsertsWhitespaceWhenSnapshotLikeUpdatesWouldOtherwiseGlueWordsTogether()
|
||||
[Theory]
|
||||
[InlineData("requires all wr", "itable fields", "requires all writable fields")]
|
||||
[InlineData("becomes frag", "ile for clients", "becomes fragile for clients")]
|
||||
[InlineData("Endpoint (domain) uniqu", "eness across tenants", "Endpoint (domain) uniqueness across tenants")]
|
||||
[InlineData("The doc says \"wildc", "ards are allowed\"", "The doc says \"wildcards are allowed\"")]
|
||||
[InlineData("What wildcard syntax supported (*.cont", "oso.com? contoso.* ?)", "What wildcard syntax supported (*.contoso.com? contoso.* ?)")]
|
||||
[InlineData("How does Pur", "view match traffic", "How does Purview match traffic")]
|
||||
[InlineData("more M", "DA properties", "more MDA properties")]
|
||||
[InlineData("does UA", "G normalize them?", "does UAG normalize them?")]
|
||||
public void Merge_DoesNotInjectSpacesIntoSplitWords(string current, string incoming, string expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
"How about The **Ashen Crown** feels",
|
||||
StreamingTextMerger.Merge("How about", "The **Ashen Crown** feels"));
|
||||
Assert.Equal(
|
||||
"The **Ashen Crown** feels classic and timeless.",
|
||||
StreamingTextMerger.Merge("The **Ashen Crown** feels", "classic and timeless."));
|
||||
Assert.Equal(expected, StreamingTextMerger.Merge(current, incoming));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_InsertsNewlineBeforeStreamedMarkdownBlockMarkers()
|
||||
public void Merge_PreservesWhitespaceAlreadyPresentInDelta()
|
||||
{
|
||||
Assert.Equal(
|
||||
"How about The **Ashen Crown** feels",
|
||||
StreamingTextMerger.Merge("How about", " The **Ashen Crown** feels"));
|
||||
Assert.Equal(
|
||||
"The **Ashen Crown** feels classic and timeless.",
|
||||
StreamingTextMerger.Merge("The **Ashen Crown** feels", " classic and timeless."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_PreservesNewlineAlreadyPresentInDelta()
|
||||
{
|
||||
Assert.Equal(
|
||||
"If you want, I can also give you\n- darker titles",
|
||||
StreamingTextMerger.Merge("If you want, I can also give you", "- darker titles"));
|
||||
StreamingTextMerger.Merge("If you want, I can also give you", "\n- darker titles"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -13,103 +14,217 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
var tracking = CreateToolTracking();
|
||||
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(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
Assert.Equal("view", activity.ToolName);
|
||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||
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.True(tracking.TryGetToolName("call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
Assert.True(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_MapsMcpToolCalls()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
var tracking = CreateToolTracking();
|
||||
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(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("git.status", activity.ToolName);
|
||||
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
|
||||
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
|
||||
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
|
||||
Assert.Equal("git.status", toolName);
|
||||
Assert.True(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1"));
|
||||
var tracking = CreateToolTracking();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("code interpreter", activity.ToolName);
|
||||
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal(
|
||||
["print('hello')"],
|
||||
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
|
||||
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
|
||||
Assert.Equal("code interpreter", toolName);
|
||||
Assert.True(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
var tracking = CreateToolTracking();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall());
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("image generation", activity.ToolName);
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
Assert.Null(activity.ToolArguments);
|
||||
Assert.False(tracking.TryGetToolName("call-1", out _));
|
||||
Assert.False(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
|
||||
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
|
||||
{
|
||||
["call-1"] = "view",
|
||||
};
|
||||
var tracking = CreateToolTracking();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||
{
|
||||
["empty"] = " ",
|
||||
["missing"] = null,
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Null(activity.ToolArguments);
|
||||
Assert.False(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
|
||||
{
|
||||
var tracking = CreateToolTracking();
|
||||
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"),
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
|
||||
Assert.True(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments()
|
||||
{
|
||||
var tracking = CreateToolTracking();
|
||||
tracking.RecordToolStart(
|
||||
"call-1",
|
||||
"view",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = @"C:\workspace\seed.txt",
|
||||
});
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = @"C:\workspace\file.txt",
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
tracking);
|
||||
|
||||
Assert.Null(activity);
|
||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
Assert.True(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments()
|
||||
{
|
||||
var tracking = CreateToolTracking();
|
||||
tracking.RecordToolStart("call-1", "view", toolArguments: null);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = @"C:\workspace\file.txt",
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("call-1", activity.ToolCallId);
|
||||
Assert.NotNull(activity.ToolArguments);
|
||||
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
|
||||
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
Assert.True(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
var tracking = CreateToolTracking();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
|
||||
@@ -117,7 +232,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
CreateHandoffCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-handoff-triage", "Triage"),
|
||||
toolNamesByCallId);
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("handoff", activity.ActivityType);
|
||||
@@ -126,7 +241,54 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
|
||||
Assert.Equal("Triage", activity.SourceAgentName);
|
||||
Assert.Null(activity.ToolName);
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
Assert.False(tracking.TryGetToolName("call-1", out _));
|
||||
Assert.False(tracking.HasTrackedArguments("call-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent()
|
||||
{
|
||||
var tracking = CreateToolTracking();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = @"C:\workspace\file.txt",
|
||||
}));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity(
|
||||
"agent-reviewer",
|
||||
"Reviewer",
|
||||
new SubworkflowContext("subworkflow-review", "Review Lane")),
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
|
||||
Assert.Equal("Review Lane", activity.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ResolvesReferencedSubworkflowContextForHandoffTargets()
|
||||
{
|
||||
var tracking = CreateToolTracking();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateHandoffCommandWithReferencedSubworkflow(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-handoff-triage", "Triage"),
|
||||
tracking);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("handoff", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
Assert.Equal("UX Specialist", activity.AgentName);
|
||||
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
|
||||
Assert.Equal("Review Lane", activity.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -185,55 +347,136 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.False(requiresBoundary);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateSingleAgentCommand()
|
||||
[Fact]
|
||||
public void NormalizeRawToolArguments_JsonElement_ExtractsArguments()
|
||||
{
|
||||
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"),
|
||||
],
|
||||
},
|
||||
};
|
||||
using JsonDocument doc = JsonDocument.Parse("""{"path":"/src/main.ts","view_range":[10,20]}""");
|
||||
JsonElement element = doc.RootElement.Clone();
|
||||
|
||||
IReadOnlyDictionary<string, object?>? result = WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(element);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/src/main.ts", result["path"]);
|
||||
IReadOnlyList<object?> viewRange = Assert.IsAssignableFrom<IReadOnlyList<object?>>(result["view_range"]);
|
||||
Assert.Equal(2, viewRange.Count);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateHandoffCommand()
|
||||
[Fact]
|
||||
public void NormalizeRawToolArguments_Null_ReturnsNull()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
Assert.Null(WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRawToolArguments_EmptyObject_ReturnsNull()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("{}");
|
||||
JsonElement element = doc.RootElement.Clone();
|
||||
|
||||
Assert.Null(WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(element));
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateSingleAgentCommand()
|
||||
=> 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 CreateHandoffCommandWithReferencedSubworkflow()
|
||||
{
|
||||
WorkflowDefinitionDto nestedWorkflow = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
Id = "nested-review-workflow",
|
||||
Name = "Nested Review Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
Nodes =
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = "single",
|
||||
},
|
||||
};
|
||||
|
||||
return CreateCommand(
|
||||
"handoff",
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
|
||||
],
|
||||
workflowLibrary: [nestedWorkflow]);
|
||||
}
|
||||
|
||||
private static ToolCallRegistry CreateToolTracking() => new();
|
||||
|
||||
private static RunTurnCommandDto CreateCommand(
|
||||
string orchestrationMode,
|
||||
IReadOnlyList<WorkflowNodeDto> nodes,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
WorkflowLibrary = workflowLibrary ?? [],
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"{orchestrationMode}-workflow",
|
||||
Name = "Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes = [.. nodes],
|
||||
},
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateSubworkflow(
|
||||
string id,
|
||||
string label,
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Kind = "sub-workflow",
|
||||
Label = label,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,22 +487,49 @@ 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);
|
||||
object instance = Activator.CreateInstance(type, 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()
|
||||
@@ -267,7 +537,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
return Activator.CreateInstance(type)!;
|
||||
return Activator.CreateInstance(type, "image-call-1")!;
|
||||
}
|
||||
|
||||
private static object CreateHandoffTarget(string id, string name)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+758
-1340
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> {
|
||||
|
||||
+90
-23
@@ -1,19 +1,53 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow as BrowserWindowType } from 'electron';
|
||||
|
||||
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { registerIpcHandlers, registerQuickPromptIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||
import { GlobalHotkeyService } from '@main/services/globalHotkey';
|
||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||
import {
|
||||
createQuickPromptWindow,
|
||||
toggleQuickPromptWindow,
|
||||
} from '@main/windows/createQuickPromptWindow';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
|
||||
import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
|
||||
|
||||
const { app, BrowserWindow } = electron;
|
||||
|
||||
// Enforce single instance — quit immediately if another instance already holds the lock.
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindowType | undefined;
|
||||
let quickPromptWindow: BrowserWindowType | undefined;
|
||||
let appService: AryxAppService | undefined;
|
||||
let systemTray: SystemTray | undefined;
|
||||
let autoUpdateService: AutoUpdateService | undefined;
|
||||
let globalHotkeyService: GlobalHotkeyService | undefined;
|
||||
|
||||
let quickPromptInitialized = false;
|
||||
|
||||
/** Lazily creates the quick prompt window on first use. */
|
||||
function ensureQuickPromptWindow(): BrowserWindowType | undefined {
|
||||
if (quickPromptWindow && !quickPromptWindow.isDestroyed()) return quickPromptWindow;
|
||||
if (quickPromptInitialized) return undefined;
|
||||
if (!mainWindow || !appService) return undefined;
|
||||
|
||||
try {
|
||||
quickPromptWindow = createQuickPromptWindow();
|
||||
registerQuickPromptIpcHandlers(mainWindow, appService, quickPromptWindow);
|
||||
quickPromptInitialized = true;
|
||||
} catch (err) {
|
||||
console.error('[aryx] Failed to create quick prompt window:', err);
|
||||
quickPromptInitialized = true;
|
||||
}
|
||||
|
||||
return quickPromptWindow;
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
appService = new AryxAppService();
|
||||
@@ -21,23 +55,52 @@ async function bootstrap(): Promise<void> {
|
||||
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
|
||||
registerIpcHandlers(mainWindow, appService, autoUpdateService);
|
||||
|
||||
// Apply persisted theme to the title bar overlay
|
||||
const workspace = await appService.loadWorkspace();
|
||||
applyTitleBarTheme(mainWindow, workspace.settings.theme);
|
||||
// Start workspace loading in parallel — don't block window from showing.
|
||||
// The renderer fetches the workspace via its own IPC call after mount.
|
||||
const workspaceReady = appService.loadWorkspace();
|
||||
|
||||
// Set up system tray
|
||||
systemTray = new SystemTray({
|
||||
onShowWindow: showAndFocusWindow,
|
||||
onCreateScratchpad: () => {
|
||||
showAndFocusWindow();
|
||||
mainWindow?.webContents.send('tray:create-scratchpad');
|
||||
},
|
||||
onQuit: () => app.quit(),
|
||||
});
|
||||
systemTray.create();
|
||||
systemTray.updateRunningCount(workspace);
|
||||
// Apply theme, set up tray, and register global hotkey once workspace is available
|
||||
workspaceReady
|
||||
.then((workspace) => {
|
||||
if (!mainWindow) return;
|
||||
applyTitleBarTheme(mainWindow, workspace.settings.theme);
|
||||
|
||||
systemTray = new SystemTray({
|
||||
onShowWindow: () => showAndFocusWindow(mainWindow!),
|
||||
onCreateScratchpad: () => {
|
||||
showAndFocusWindow(mainWindow!);
|
||||
mainWindow?.webContents.send('tray:create-scratchpad');
|
||||
},
|
||||
onQuit: () => app.quit(),
|
||||
});
|
||||
systemTray.create();
|
||||
systemTray.updateRunningCount(workspace);
|
||||
|
||||
appService!.on('workspace-updated', (updatedWorkspace) => {
|
||||
systemTray?.updateRunningCount(updatedWorkspace);
|
||||
});
|
||||
|
||||
// Register global hotkey — the quick prompt window is created lazily on
|
||||
// first press so it cannot interfere with main window startup.
|
||||
globalHotkeyService = new GlobalHotkeyService();
|
||||
const hotkeySettings = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
|
||||
globalHotkeyService.register(hotkeySettings, () => {
|
||||
const win = ensureQuickPromptWindow();
|
||||
if (win) void toggleQuickPromptWindow(win, appService!.getCurrentTheme());
|
||||
});
|
||||
|
||||
// Re-register hotkey when settings change
|
||||
appService!.on('workspace-updated', (updatedWorkspace) => {
|
||||
const updatedSettings = updatedWorkspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
|
||||
globalHotkeyService?.update(updatedSettings);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[aryx bootstrap] workspace load failed', error);
|
||||
});
|
||||
|
||||
// Intercept close to hide to tray when the setting is enabled
|
||||
setupCloseToTray(mainWindow, () => {
|
||||
@@ -45,11 +108,6 @@ async function bootstrap(): Promise<void> {
|
||||
return currentWorkspace?.settings.minimizeToTray === true;
|
||||
});
|
||||
|
||||
// Keep tray status in sync when workspace changes
|
||||
appService.on('workspace-updated', (updatedWorkspace) => {
|
||||
systemTray?.updateRunningCount(updatedWorkspace);
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
@@ -57,6 +115,12 @@ async function bootstrap(): Promise<void> {
|
||||
autoUpdateService.start();
|
||||
}
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
showAndFocusWindow(mainWindow);
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(bootstrap);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -64,7 +128,9 @@ app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin') return;
|
||||
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
const allHidden = windows.length > 0 && windows.every((w) => !w.isVisible());
|
||||
// Ignore the quick prompt window (it's always hidden, never truly closed)
|
||||
const visibleWindows = windows.filter((w) => w !== quickPromptWindow);
|
||||
const allHidden = visibleWindows.length > 0 && visibleWindows.every((w) => !w.isVisible());
|
||||
if (allHidden) return;
|
||||
|
||||
app.quit();
|
||||
@@ -73,12 +139,13 @@ app.on('window-all-closed', () => {
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await bootstrap();
|
||||
} else {
|
||||
showAndFocusWindow();
|
||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
showAndFocusWindow(mainWindow);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
globalHotkeyService?.dispose();
|
||||
autoUpdateService?.dispose();
|
||||
autoUpdateService = undefined;
|
||||
systemTray?.dispose();
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { BrowserWindow } from 'electron';
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
BranchSessionInput,
|
||||
BatchDeleteSessionsInput,
|
||||
BatchSetSessionsArchivedInput,
|
||||
CancelSessionTurnInput,
|
||||
CommitProjectGitChangesInput,
|
||||
CreateSessionInput,
|
||||
CreateWorkflowFromTemplateInput,
|
||||
CreateWorkflowSessionInput,
|
||||
CreateProjectGitBranchInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
@@ -13,13 +18,15 @@ import type {
|
||||
DeleteSessionInput,
|
||||
DiscardSessionRunGitChangesInput,
|
||||
EditAndResendSessionMessageInput,
|
||||
CommitProjectGitChangesInput,
|
||||
ExportWorkflowInput,
|
||||
ImportWorkflowInput,
|
||||
ProjectGitDetailsInput,
|
||||
ProjectGitFilePreviewInput,
|
||||
ProjectGitFileSelectionInput,
|
||||
ProjectGitInput,
|
||||
PullProjectGitInput,
|
||||
RegenerateSessionMessageInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
StartSessionMcpAuthInput,
|
||||
SuggestProjectGitCommitMessageInput,
|
||||
SwitchProjectGitBranchInput,
|
||||
@@ -30,12 +37,12 @@ import type {
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveSessionApprovalInput,
|
||||
ResolveSessionUserInputInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
SaveWorkflowInput,
|
||||
SaveWorkflowTemplateInput,
|
||||
SaveWorkspaceAgentInput,
|
||||
SendSessionMessageInput,
|
||||
SetPatternFavoriteInput,
|
||||
SetProjectAgentProfileEnabledInput,
|
||||
SetSessionArchivedInput,
|
||||
SetSessionInteractionModeInput,
|
||||
@@ -46,14 +53,18 @@ import type {
|
||||
UpdateSessionModelConfigInput,
|
||||
UpdateSessionApprovalSettingsInput,
|
||||
UpdateSessionToolingInput,
|
||||
QuickPromptSendInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
import type { AppearanceTheme, OpenTelemetrySettings, QuickPromptSettings } from '@shared/domain/tooling';
|
||||
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { AutoUpdateService } from '@main/services/autoUpdater';
|
||||
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import { hideQuickPromptWindow } from '@main/windows/createQuickPromptWindow';
|
||||
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
@@ -106,10 +117,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);
|
||||
@@ -132,6 +155,10 @@ export function registerIpcHandlers(
|
||||
ipcChannels.setGitAutoRefreshEnabled,
|
||||
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setOpenTelemetry,
|
||||
(_event, settings: OpenTelemetrySettings) => service.setOpenTelemetry(settings),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
|
||||
ipcMain.handle(ipcChannels.installUpdate, () => {
|
||||
autoUpdateService.installUpdate();
|
||||
@@ -148,6 +175,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 +204,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),
|
||||
@@ -194,6 +230,12 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
|
||||
service.deleteSession(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.batchSetSessionsArchived, (_event, input: BatchSetSessionsArchivedInput) =>
|
||||
service.batchSetSessionsArchived(input.sessionIds, input.isArchived),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.batchDeleteSessions, (_event, input: BatchDeleteSessionsInput) =>
|
||||
service.batchDeleteSessions(input.sessionIds),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.regenerateSessionMessage, (_event, input: RegenerateSessionMessageInput) =>
|
||||
service.regenerateSessionMessage(input.sessionId, input.messageId),
|
||||
);
|
||||
@@ -201,7 +243,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 +326,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());
|
||||
@@ -317,4 +364,101 @@ export function registerIpcHandlers(
|
||||
service.on('terminal-exit', (info) => {
|
||||
window.webContents.send(ipcChannels.terminalExit, info);
|
||||
});
|
||||
|
||||
// --- Quick Prompt IPC (window-independent) ---
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptGetCapabilities, async () => {
|
||||
const capabilities = await service.describeSidecarCapabilities();
|
||||
const settings = service.getQuickPromptSettings();
|
||||
const models = buildAvailableModelCatalog(capabilities.models);
|
||||
return {
|
||||
models,
|
||||
defaultModel: settings.defaultModel,
|
||||
defaultReasoningEffort: settings.defaultReasoningEffort,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.quickPromptSetSettings,
|
||||
(_event, settings: Partial<QuickPromptSettings>) => service.setQuickPromptSettings(settings),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.quickPromptGetSettings,
|
||||
() => service.getQuickPromptSettings(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers that depend on the quick prompt BrowserWindow.
|
||||
* Called separately after the quick prompt window is created (deferred from bootstrap).
|
||||
*/
|
||||
export function registerQuickPromptIpcHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
service: AryxAppService,
|
||||
quickPromptWindow: BrowserWindow,
|
||||
): void {
|
||||
let quickPromptSessionId: string | undefined;
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptSend, async (_event, input: QuickPromptSendInput) => {
|
||||
const workspace = await service.loadWorkspace();
|
||||
const workflowId = workspace.selectedWorkflowId ?? workspace.workflows[0]?.id;
|
||||
if (!workflowId) throw new Error('No workflow available');
|
||||
|
||||
const created = await service.createSession(SCRATCHPAD_PROJECT_ID, workflowId);
|
||||
const session = created.sessions[0];
|
||||
if (!session) throw new Error('Failed to create quick prompt session');
|
||||
|
||||
quickPromptSessionId = session.id;
|
||||
|
||||
// Apply model override if provided
|
||||
if (input.model) {
|
||||
await service.updateSessionModelConfig(session.id, input.model, input.reasoningEffort);
|
||||
}
|
||||
|
||||
// Send the message (fire-and-forget — results arrive via session events)
|
||||
void service.sendSessionMessage(session.id, input.content);
|
||||
|
||||
return { sessionId: session.id };
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptCancelTurn, async () => {
|
||||
if (quickPromptSessionId) {
|
||||
await service.cancelSessionTurn(quickPromptSessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptDiscard, async () => {
|
||||
if (quickPromptSessionId) {
|
||||
await service.deleteSession(quickPromptSessionId);
|
||||
quickPromptSessionId = undefined;
|
||||
}
|
||||
hideQuickPromptWindow(quickPromptWindow);
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptClose, async () => {
|
||||
quickPromptSessionId = undefined;
|
||||
hideQuickPromptWindow(quickPromptWindow);
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.quickPromptContinueInAryx, async () => {
|
||||
if (quickPromptSessionId) {
|
||||
await service.selectSession(quickPromptSessionId);
|
||||
quickPromptSessionId = undefined;
|
||||
}
|
||||
hideQuickPromptWindow(quickPromptWindow);
|
||||
// Show and focus the main window
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Route session events to the quick prompt window
|
||||
service.on('session-event', (event) => {
|
||||
if (event.sessionId === quickPromptSessionId && !quickPromptWindow.isDestroyed()) {
|
||||
quickPromptWindow.webContents.send(ipcChannels.quickPromptSessionEvent, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
ApprovalRequestedEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
UserInputRequestedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import {
|
||||
dequeuePendingApprovalState,
|
||||
enqueuePendingApprovalState,
|
||||
getPendingApprovalToolKey,
|
||||
listPendingApprovals,
|
||||
resolvePendingApproval,
|
||||
type ApprovalDecision,
|
||||
type PendingApprovalRecord,
|
||||
} from '@shared/domain/approval';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
type PendingApprovalHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type PendingUserInputHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ApprovalCoordinatorDeps = {
|
||||
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
updateSessionRun: (
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
updater: (run: SessionRunRecord) => SessionRunRecord,
|
||||
) => SessionRunRecord | undefined;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
emitSessionEvent: (event: SessionEventRecord) => void;
|
||||
failSessionRunRecord: (run: SessionRunRecord, failedAt: string, error: string) => SessionRunRecord;
|
||||
upsertRunApprovalEvent: (
|
||||
run: SessionRunRecord,
|
||||
approval: PendingApprovalRecord,
|
||||
) => SessionRunRecord;
|
||||
};
|
||||
|
||||
export class ApprovalCoordinator {
|
||||
readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
|
||||
readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
|
||||
private readonly requireSession: ApprovalCoordinatorDeps['requireSession'];
|
||||
|
||||
private readonly persistWorkspace: ApprovalCoordinatorDeps['persistWorkspace'];
|
||||
|
||||
private readonly updateSessionRun: ApprovalCoordinatorDeps['updateSessionRun'];
|
||||
|
||||
private readonly emitRunUpdated: ApprovalCoordinatorDeps['emitRunUpdated'];
|
||||
|
||||
private readonly emitSessionEvent: ApprovalCoordinatorDeps['emitSessionEvent'];
|
||||
|
||||
private readonly failSessionRunRecord: ApprovalCoordinatorDeps['failSessionRunRecord'];
|
||||
|
||||
private readonly upsertRunApprovalEvent: ApprovalCoordinatorDeps['upsertRunApprovalEvent'];
|
||||
|
||||
constructor(deps: ApprovalCoordinatorDeps) {
|
||||
this.requireSession = deps.requireSession;
|
||||
this.persistWorkspace = deps.persistWorkspace;
|
||||
this.updateSessionRun = deps.updateSessionRun;
|
||||
this.emitRunUpdated = deps.emitRunUpdated;
|
||||
this.emitSessionEvent = deps.emitSessionEvent;
|
||||
this.failSessionRunRecord = deps.failSessionRunRecord;
|
||||
this.upsertRunApprovalEvent = deps.upsertRunApprovalEvent;
|
||||
}
|
||||
|
||||
async resolveSessionApproval(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
alwaysApprove?: boolean,
|
||||
): Promise<WorkspaceState> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const approval = session.pendingApproval;
|
||||
if (!approval || approval.id !== approvalId) {
|
||||
const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId);
|
||||
if (queuedApproval) {
|
||||
throw new Error(
|
||||
approval
|
||||
? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.`
|
||||
: `Approval "${approvalId}" is queued but not active for session "${sessionId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`);
|
||||
}
|
||||
|
||||
const handle = this.pendingApprovalHandles.get(approvalId);
|
||||
if (!handle || handle.sessionId !== sessionId) {
|
||||
throw new Error(`Approval "${approvalId}" is no longer active. Restart the run and try again.`);
|
||||
}
|
||||
|
||||
const resolvedAt = nowIso();
|
||||
const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt);
|
||||
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
|
||||
session.updatedAt = resolvedAt;
|
||||
|
||||
const approvalKey = getPendingApprovalToolKey(approval);
|
||||
if (decision === 'approved' && alwaysApprove && approvalKey) {
|
||||
const existing = session.approvalSettings?.autoApprovedToolNames ?? [];
|
||||
if (!existing.includes(approvalKey)) {
|
||||
session.approvalSettings = { autoApprovedToolNames: [...existing, approvalKey] };
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
||||
this.upsertRunApprovalEvent(run, resolvedApproval));
|
||||
|
||||
const cascadeHandles: PendingApprovalHandle[] = [];
|
||||
if (decision === 'approved' && approvalKey && approval.kind === 'tool-call') {
|
||||
for (const queued of listPendingApprovals(session)) {
|
||||
if (queued.id === approvalId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queuedKey = getPendingApprovalToolKey(queued);
|
||||
if (queuedKey !== approvalKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queuedHandle = this.pendingApprovalHandles.get(queued.id);
|
||||
if (!queuedHandle || queuedHandle.sessionId !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cascadeResolved = resolvePendingApproval(queued, 'approved', resolvedAt);
|
||||
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, queued.id));
|
||||
this.updateSessionRun(session, queuedHandle.requestId, (run) =>
|
||||
this.upsertRunApprovalEvent(run, cascadeResolved));
|
||||
this.pendingApprovalHandles.delete(queued.id);
|
||||
cascadeHandles.push(queuedHandle);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.persistWorkspace(workspace);
|
||||
if (updatedRun) {
|
||||
this.emitRunUpdated(sessionId, resolvedAt, updatedRun);
|
||||
}
|
||||
|
||||
this.pendingApprovalHandles.delete(approvalId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(handle.resolve(decision, alwaysApprove));
|
||||
for (const cascaded of cascadeHandles) {
|
||||
await Promise.resolve(cascaded.resolve('approved', alwaysApprove));
|
||||
}
|
||||
} catch (error) {
|
||||
const failedAt = nowIso();
|
||||
this.rejectPendingApprovals(
|
||||
session,
|
||||
failedAt,
|
||||
'Queued approval was cancelled because the run failed before it could resume.',
|
||||
);
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = failedAt;
|
||||
|
||||
const failedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
||||
this.failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
occurredAt: failedAt,
|
||||
error: session.lastError,
|
||||
});
|
||||
if (failedRun) {
|
||||
this.emitRunUpdated(sessionId, failedAt, failedRun);
|
||||
}
|
||||
|
||||
await this.persistWorkspace(workspace);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async resolveSessionUserInput(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
userInputId: string,
|
||||
answer: string,
|
||||
wasFreeform: boolean,
|
||||
): Promise<WorkspaceState> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const pending = session.pendingUserInput;
|
||||
if (!pending || pending.id !== userInputId) {
|
||||
throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`);
|
||||
}
|
||||
|
||||
const handle = this.pendingUserInputHandles.get(userInputId);
|
||||
if (!handle || handle.sessionId !== sessionId) {
|
||||
throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`);
|
||||
}
|
||||
|
||||
const answeredAt = nowIso();
|
||||
session.pendingUserInput = {
|
||||
...pending,
|
||||
status: 'answered',
|
||||
answer,
|
||||
answeredAt,
|
||||
};
|
||||
session.updatedAt = answeredAt;
|
||||
|
||||
const result = await this.persistWorkspace(workspace);
|
||||
this.pendingUserInputHandles.delete(userInputId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(handle.resolve(answer, wasFreeform));
|
||||
session.pendingUserInput = undefined;
|
||||
await this.persistWorkspace(workspace);
|
||||
} catch (error) {
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
occurredAt: session.updatedAt,
|
||||
error: session.lastError,
|
||||
});
|
||||
|
||||
await this.persistWorkspace(workspace);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async handleApprovalRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
approval: ApprovalRequestedEvent | PendingApprovalRecord,
|
||||
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const pendingApproval =
|
||||
'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval;
|
||||
|
||||
this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval));
|
||||
session.updatedAt = pendingApproval.requestedAt;
|
||||
|
||||
const updatedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
this.upsertRunApprovalEvent(run, pendingApproval));
|
||||
|
||||
this.pendingApprovalHandles.set(pendingApproval.id, {
|
||||
sessionId,
|
||||
requestId,
|
||||
resolve,
|
||||
});
|
||||
|
||||
await this.persistWorkspace(workspace);
|
||||
if (updatedRun) {
|
||||
this.emitRunUpdated(sessionId, pendingApproval.requestedAt, updatedRun);
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserInputRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
event: UserInputRequestedEvent,
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingUserInput = {
|
||||
id: event.userInputId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
question: event.question,
|
||||
choices: event.choices,
|
||||
allowFreeform: event.allowFreeform ?? true,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
this.pendingUserInputHandles.set(event.userInputId, {
|
||||
sessionId,
|
||||
requestId,
|
||||
resolve,
|
||||
});
|
||||
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
|
||||
async handleExitPlanModeRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: ExitPlanModeRequestedEvent,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingPlanReview = {
|
||||
id: event.exitPlanId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
summary: event.summary,
|
||||
planContent: event.planContent,
|
||||
actions: event.actions,
|
||||
recommendedAction: event.recommendedAction,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
|
||||
async handleMcpOAuthRequired(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: McpOauthRequiredEvent,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingMcpAuth = {
|
||||
id: event.oauthRequestId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
serverName: event.serverName,
|
||||
serverUrl: event.serverUrl,
|
||||
staticClientConfig: event.staticClientConfig
|
||||
? { clientId: event.staticClientConfig.clientId, publicClient: event.staticClientConfig.publicClient }
|
||||
: undefined,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
|
||||
createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
kind: event.approvalKind,
|
||||
status: 'pending',
|
||||
requestedAt: nowIso(),
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
toolName: event.toolName,
|
||||
permissionKind: event.permissionKind,
|
||||
approvalToolKey: event.approvalToolKey,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
permissionDetail: event.permissionDetail,
|
||||
};
|
||||
}
|
||||
|
||||
setSessionPendingApprovalState(
|
||||
session: SessionRecord,
|
||||
state: {
|
||||
pendingApproval?: PendingApprovalRecord;
|
||||
pendingApprovalQueue?: PendingApprovalRecord[];
|
||||
},
|
||||
): void {
|
||||
session.pendingApproval = state.pendingApproval;
|
||||
session.pendingApprovalQueue = state.pendingApprovalQueue;
|
||||
}
|
||||
|
||||
rejectPendingApprovals(
|
||||
session: SessionRecord,
|
||||
failedAt: string,
|
||||
error: string,
|
||||
): string[] {
|
||||
const requestIds = new Set<string>();
|
||||
|
||||
for (const pendingApproval of listPendingApprovals(session)) {
|
||||
const requestId = this.findApprovalRequestId(session, pendingApproval.id);
|
||||
const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error);
|
||||
|
||||
if (requestId) {
|
||||
requestIds.add(requestId);
|
||||
this.updateSessionRun(session, requestId, (run) =>
|
||||
this.upsertRunApprovalEvent(run, rejectedApproval));
|
||||
}
|
||||
|
||||
this.pendingApprovalHandles.delete(pendingApproval.id);
|
||||
}
|
||||
|
||||
this.setSessionPendingApprovalState(session, {});
|
||||
return [...requestIds];
|
||||
}
|
||||
|
||||
findApprovalRequestId(session: SessionRecord, approvalId: string): string | undefined {
|
||||
const matchingRun = session.runs.find((run) =>
|
||||
run.events.some((event) => event.kind === 'approval' && event.approvalId === approvalId));
|
||||
if (matchingRun) {
|
||||
return matchingRun.requestId;
|
||||
}
|
||||
|
||||
return session.runs.find((run) => run.status === 'running')?.requestId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
ApprovalRequestedEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
MessageReclassifiedEvent,
|
||||
RunTurnCommand,
|
||||
UserInputRequestedEvent,
|
||||
TurnDeltaEvent,
|
||||
WorkflowCheckpointResume,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
|
||||
export type PendingApprovalHandleLike = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export type PendingUserInputHandleLike = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export type WorkflowCheckpointRecoveryState = {
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
stepNumber: number;
|
||||
sessionMessages: ChatMessageRecord[];
|
||||
runEvents: import('@shared/domain/runTimeline').RunTimelineEventRecord[];
|
||||
};
|
||||
|
||||
type CheckpointRecoveryManagerDeps = {
|
||||
persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
updateSessionRun: (
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
updater: (run: SessionRunRecord) => SessionRunRecord,
|
||||
) => SessionRunRecord | undefined;
|
||||
setSessionPendingApprovalState: (
|
||||
session: SessionRecord,
|
||||
state: {
|
||||
pendingApproval?: import('@shared/domain/approval').PendingApprovalRecord;
|
||||
pendingApprovalQueue?: import('@shared/domain/approval').PendingApprovalRecord[];
|
||||
},
|
||||
) => void;
|
||||
pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
|
||||
pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
|
||||
};
|
||||
|
||||
export class CheckpointRecoveryManager {
|
||||
readonly recoveries = new Map<string, WorkflowCheckpointRecoveryState>();
|
||||
|
||||
private readonly persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
|
||||
private readonly emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
private readonly updateSessionRun: CheckpointRecoveryManagerDeps['updateSessionRun'];
|
||||
private readonly setSessionPendingApprovalState: CheckpointRecoveryManagerDeps['setSessionPendingApprovalState'];
|
||||
private readonly pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
|
||||
private readonly pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
|
||||
|
||||
constructor(deps: CheckpointRecoveryManagerDeps) {
|
||||
this.persistWorkspace = deps.persistWorkspace;
|
||||
this.emitRunUpdated = deps.emitRunUpdated;
|
||||
this.updateSessionRun = deps.updateSessionRun;
|
||||
this.setSessionPendingApprovalState = deps.setSessionPendingApprovalState;
|
||||
this.pendingApprovalHandles = deps.pendingApprovalHandles;
|
||||
this.pendingUserInputHandles = deps.pendingUserInputHandles;
|
||||
}
|
||||
|
||||
async runSidecarTurnWithCheckpointRecovery(
|
||||
workspace: import('@shared/domain/workspace').WorkspaceState,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
invokeTurn: (resumeFromCheckpoint?: WorkflowCheckpointResume) => Promise<ChatMessageRecord[]>,
|
||||
isUnexpectedSidecarTerminationError: (error: unknown) => boolean,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
try {
|
||||
return await invokeTurn();
|
||||
} catch (error) {
|
||||
const recovery = this.recoveries.get(requestId);
|
||||
if (!isUnexpectedSidecarTerminationError(error) || !recovery) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery);
|
||||
await this.persistWorkspace(workspace);
|
||||
if (restoredRun) {
|
||||
this.emitRunUpdated(session.id, session.updatedAt, restoredRun);
|
||||
}
|
||||
|
||||
return invokeTurn({
|
||||
workflowSessionId: recovery.workflowSessionId,
|
||||
checkpointId: recovery.checkpointId,
|
||||
storePath: recovery.storePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
recordWorkflowCheckpointRecovery(
|
||||
session: SessionRecord,
|
||||
run: SessionRunRecord,
|
||||
event: WorkflowCheckpointSavedEvent,
|
||||
): void {
|
||||
this.recoveries.set(event.requestId, {
|
||||
workflowSessionId: event.workflowSessionId,
|
||||
checkpointId: event.checkpointId,
|
||||
storePath: event.storePath,
|
||||
stepNumber: event.stepNumber,
|
||||
sessionMessages: structuredClone(session.messages),
|
||||
runEvents: structuredClone(run.events),
|
||||
});
|
||||
}
|
||||
|
||||
restoreWorkflowCheckpointRecovery(
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
recovery: WorkflowCheckpointRecoveryState,
|
||||
): SessionRunRecord | undefined {
|
||||
session.messages = structuredClone(recovery.sessionMessages);
|
||||
session.status = 'running';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
this.clearPendingRunState(session, requestId);
|
||||
|
||||
return this.updateSessionRun(session, requestId, (run) => ({
|
||||
...run,
|
||||
events: structuredClone(recovery.runEvents),
|
||||
}));
|
||||
}
|
||||
|
||||
clearPendingRunState(session: SessionRecord, requestId: string): void {
|
||||
this.setSessionPendingApprovalState(session, {});
|
||||
session.pendingUserInput = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
session.pendingMcpAuth = undefined;
|
||||
|
||||
for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) {
|
||||
if (handle.sessionId === session.id && handle.requestId === requestId) {
|
||||
this.pendingApprovalHandles.delete(approvalId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) {
|
||||
if (handle.sessionId === session.id && handle.requestId === requestId) {
|
||||
this.pendingUserInputHandles.delete(userInputId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupWorkflowCheckpointRecovery(requestId: string): Promise<void> {
|
||||
const recovery = this.recoveries.get(requestId);
|
||||
this.recoveries.delete(requestId);
|
||||
if (!recovery) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(recovery.storePath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,195 @@
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import {
|
||||
applyDiscoveredMcpServerStatus,
|
||||
normalizeDiscoveredToolingState,
|
||||
type DiscoveredMcpServer,
|
||||
type DiscoveredToolingState,
|
||||
type DiscoveredToolingStatus,
|
||||
} from '@shared/domain/discoveredTooling';
|
||||
import {
|
||||
normalizeProjectCustomizationState,
|
||||
type ProjectCustomizationState,
|
||||
} from '@shared/domain/projectCustomization';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
import { ConfigScannerRegistry } from '@main/services/configScanner';
|
||||
import { ProjectCustomizationScanner } from '@main/services/customizationScanner';
|
||||
import { ProjectCustomizationWatcher } from '@main/services/projectCustomizationWatcher';
|
||||
|
||||
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
|
||||
type DiscoveredToolingSyncServiceDeps = {
|
||||
configScanner: ConfigScannerRegistry;
|
||||
customizationScanner: ProjectCustomizationScanner;
|
||||
projectCustomizationWatcher: ProjectCustomizationWatcher;
|
||||
loadWorkspace: () => Promise<WorkspaceState>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
};
|
||||
|
||||
export class DiscoveredToolingSyncService {
|
||||
private customizationWatcherUpdateQueue = Promise.resolve();
|
||||
|
||||
private readonly configScanner: ConfigScannerRegistry;
|
||||
private readonly customizationScanner: ProjectCustomizationScanner;
|
||||
private readonly projectCustomizationWatcher: ProjectCustomizationWatcher;
|
||||
private readonly loadWorkspace: () => Promise<WorkspaceState>;
|
||||
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
|
||||
constructor(deps: DiscoveredToolingSyncServiceDeps) {
|
||||
this.configScanner = deps.configScanner;
|
||||
this.customizationScanner = deps.customizationScanner;
|
||||
this.projectCustomizationWatcher = deps.projectCustomizationWatcher;
|
||||
this.loadWorkspace = deps.loadWorkspace;
|
||||
this.persistWorkspace = deps.persistWorkspace;
|
||||
}
|
||||
|
||||
async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
|
||||
const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling);
|
||||
if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
workspace.settings.discoveredUserTooling = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
|
||||
await this.projectCustomizationWatcher.syncProjects(
|
||||
workspace.projects
|
||||
.filter((project) => !isScratchpadProject(project))
|
||||
.map((project) => ({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async handleProjectCustomizationWatcherChange(projectId: string): Promise<void> {
|
||||
await this.enqueueCustomizationWatcherUpdate(async () => {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = workspace.projects.find((candidate) => candidate.id === projectId);
|
||||
await this.syncProjectCustomizationWatchers(workspace);
|
||||
|
||||
if (!project || isScratchpadProject(project)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didSyncProjectCustomization = await this.syncProjectCustomization(project);
|
||||
await this.syncProjectCustomizationWatchers(workspace);
|
||||
if (didSyncProjectCustomization) {
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.customization = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
|
||||
if (this.equalProjectCustomizationState(project.customization, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.customization = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
async syncProjectDiscoveredTooling(
|
||||
workspace: WorkspaceState,
|
||||
project: ProjectRecord,
|
||||
): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.discoveredTooling = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextState = await this.configScanner.scanProject(
|
||||
project.id,
|
||||
project.path,
|
||||
project.discoveredTooling,
|
||||
);
|
||||
if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.discoveredTooling = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
resolveDiscoveredToolingStatus(
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): Exclude<DiscoveredToolingStatus, 'pending'> {
|
||||
return resolution === 'accept' ? 'accepted' : 'dismissed';
|
||||
}
|
||||
|
||||
resolveWorkspaceDiscoveredTooling(
|
||||
workspace: WorkspaceState,
|
||||
serverIds: string[],
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): void {
|
||||
workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus(
|
||||
workspace.settings.discoveredUserTooling,
|
||||
serverIds,
|
||||
this.resolveDiscoveredToolingStatus(resolution),
|
||||
);
|
||||
}
|
||||
|
||||
resolveProjectDiscoveredTooling(
|
||||
project: ProjectRecord,
|
||||
serverIds: string[],
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): void {
|
||||
project.discoveredTooling = applyDiscoveredMcpServerStatus(
|
||||
project.discoveredTooling,
|
||||
serverIds,
|
||||
this.resolveDiscoveredToolingStatus(resolution),
|
||||
);
|
||||
}
|
||||
|
||||
equalDiscoveredToolingState(
|
||||
left?: DiscoveredToolingState,
|
||||
right?: DiscoveredToolingState,
|
||||
): boolean {
|
||||
const stripRuntime = (servers: DiscoveredMcpServer[]) =>
|
||||
servers.map(({ probedTools: _, ...rest }) => rest);
|
||||
return JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(left).mcpServers))
|
||||
=== JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(right).mcpServers));
|
||||
}
|
||||
|
||||
equalProjectCustomizationState(
|
||||
left?: ProjectCustomizationState,
|
||||
right?: ProjectCustomizationState,
|
||||
): boolean {
|
||||
const normalizedLeft = normalizeProjectCustomizationState(left);
|
||||
const normalizedRight = normalizeProjectCustomizationState(right);
|
||||
return JSON.stringify({
|
||||
instructions: normalizedLeft.instructions,
|
||||
agentProfiles: normalizedLeft.agentProfiles,
|
||||
promptFiles: normalizedLeft.promptFiles,
|
||||
}) === JSON.stringify({
|
||||
instructions: normalizedRight.instructions,
|
||||
agentProfiles: normalizedRight.agentProfiles,
|
||||
promptFiles: normalizedRight.promptFiles,
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueCustomizationWatcherUpdate(task: () => Promise<void>): Promise<void> {
|
||||
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
|
||||
this.customizationWatcherUpdateQueue = scheduledTask.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return scheduledTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
isScratchpadProject,
|
||||
type ProjectGitDetails,
|
||||
type ProjectGitDiffPreview,
|
||||
type ProjectGitFileReference,
|
||||
type ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { setSessionRunGitSummary, type SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { GitService } from '@main/git/gitService';
|
||||
|
||||
const GIT_REFRESH_DEBOUNCE_MS = 750;
|
||||
const GIT_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
type GitContextManagerDeps = {
|
||||
gitService: GitService;
|
||||
loadWorkspace: () => Promise<WorkspaceState>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
|
||||
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
|
||||
requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
|
||||
syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
|
||||
syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
|
||||
pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
|
||||
pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
|
||||
updateSessionRun: (
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
updater: (run: SessionRunRecord) => SessionRunRecord,
|
||||
) => SessionRunRecord | undefined;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
};
|
||||
|
||||
export class GitContextManager {
|
||||
private readonly gitService: GitService;
|
||||
private readonly loadWorkspace: () => Promise<WorkspaceState>;
|
||||
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
private readonly requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
|
||||
private readonly requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
|
||||
private readonly requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
|
||||
private readonly syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
|
||||
private readonly syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
|
||||
private readonly pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
|
||||
private readonly pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
|
||||
private readonly updateSessionRun: GitContextManagerDeps['updateSessionRun'];
|
||||
private readonly emitRunUpdated: GitContextManagerDeps['emitRunUpdated'];
|
||||
|
||||
private didStartPeriodicProjectGitRefresh = false;
|
||||
private pendingProjectGitRefreshIds = new Set<string>();
|
||||
private pendingRefreshAllProjects = false;
|
||||
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
|
||||
private runningProjectGitRefresh?: Promise<void>;
|
||||
|
||||
constructor(deps: GitContextManagerDeps) {
|
||||
this.gitService = deps.gitService;
|
||||
this.loadWorkspace = deps.loadWorkspace;
|
||||
this.persistWorkspace = deps.persistWorkspace;
|
||||
this.requireProject = deps.requireProject;
|
||||
this.requireSession = deps.requireSession;
|
||||
this.requireSessionRun = deps.requireSessionRun;
|
||||
this.syncProjectDiscoveredTooling = deps.syncProjectDiscoveredTooling;
|
||||
this.syncProjectCustomization = deps.syncProjectCustomization;
|
||||
this.pruneUnavailableSessionToolingSelections = deps.pruneUnavailableSessionToolingSelections;
|
||||
this.pruneUnavailableApprovalTools = deps.pruneUnavailableApprovalTools;
|
||||
this.updateSessionRun = deps.updateSessionRun;
|
||||
this.emitRunUpdated = deps.emitRunUpdated;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.projectGitRefreshTimer) {
|
||||
clearTimeout(this.projectGitRefreshTimer);
|
||||
this.projectGitRefreshTimer = undefined;
|
||||
}
|
||||
if (this.periodicProjectGitRefreshTimer) {
|
||||
clearInterval(this.periodicProjectGitRefreshTimer);
|
||||
this.periodicProjectGitRefreshTimer = undefined;
|
||||
}
|
||||
this.didStartPeriodicProjectGitRefresh = false;
|
||||
}
|
||||
|
||||
scheduleProjectGitRefresh(projectId?: string): void {
|
||||
if (projectId) {
|
||||
this.pendingProjectGitRefreshIds.add(projectId);
|
||||
} else {
|
||||
this.pendingRefreshAllProjects = true;
|
||||
this.pendingProjectGitRefreshIds.clear();
|
||||
}
|
||||
|
||||
if (this.projectGitRefreshTimer) {
|
||||
clearTimeout(this.projectGitRefreshTimer);
|
||||
}
|
||||
|
||||
this.projectGitRefreshTimer = setTimeout(() => {
|
||||
this.projectGitRefreshTimer = undefined;
|
||||
void this.flushScheduledProjectGitRefresh();
|
||||
}, GIT_REFRESH_DEBOUNCE_MS);
|
||||
this.projectGitRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
|
||||
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
|
||||
}
|
||||
|
||||
async getProjectGitDetails(projectId: string, commitLimit = 20): Promise<ProjectGitDetails> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit);
|
||||
}
|
||||
|
||||
async getProjectGitFilePreview(
|
||||
projectId: string,
|
||||
file: ProjectGitFileReference,
|
||||
): Promise<ProjectGitDiffPreview | undefined> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
return this.gitService.getWorkingTreeFilePreview(project.path, file);
|
||||
}
|
||||
|
||||
async discardSessionRunGitChanges(
|
||||
sessionId: string,
|
||||
runId: string,
|
||||
files?: ProjectGitFileReference[],
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const run = this.requireSessionRun(session, runId);
|
||||
if (run.workspaceKind !== 'project') {
|
||||
throw new Error('Run change review is only available for project-backed sessions.');
|
||||
}
|
||||
|
||||
if (!run.postRunGitSummary) {
|
||||
throw new Error('This run does not have any tracked git changes to discard.');
|
||||
}
|
||||
|
||||
await this.gitService.discardRunChanges(
|
||||
this.resolveRunWorkingDirectory(session, project, run),
|
||||
{
|
||||
summary: run.postRunGitSummary,
|
||||
preRunBaselineFiles: run.preRunGitBaselineFiles,
|
||||
files,
|
||||
},
|
||||
);
|
||||
|
||||
await this.refreshProjectGitContexts([project.id]);
|
||||
const refreshedWorkspace = await this.loadWorkspace();
|
||||
const refreshedSession = this.requireSession(refreshedWorkspace, sessionId);
|
||||
const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId);
|
||||
const nextRun = await this.refreshSessionRunGitSummary(
|
||||
refreshedSession,
|
||||
refreshedProject,
|
||||
run.requestId,
|
||||
nowIso(),
|
||||
);
|
||||
if (nextRun) {
|
||||
this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun);
|
||||
}
|
||||
|
||||
return this.persistWorkspace(refreshedWorkspace);
|
||||
}
|
||||
|
||||
async runProjectGitMutation(
|
||||
projectId: string,
|
||||
mutation: (project: ProjectRecord) => Promise<void>,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
if (isScratchpadProject(project)) {
|
||||
throw new Error('Git operations are not available for the Scratchpad project.');
|
||||
}
|
||||
|
||||
await mutation(project);
|
||||
return this.refreshProjectGitContexts([project.id]);
|
||||
}
|
||||
|
||||
resolveRunWorkingDirectory(
|
||||
session: SessionRecord,
|
||||
project: ProjectRecord,
|
||||
run: SessionRunRecord,
|
||||
): string {
|
||||
return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path;
|
||||
}
|
||||
|
||||
async refreshSessionRunGitSummary(
|
||||
session: SessionRecord,
|
||||
project: ProjectRecord,
|
||||
requestId: string,
|
||||
occurredAt: string,
|
||||
): Promise<SessionRunRecord | undefined> {
|
||||
const run = session.runs.find((candidate) => candidate.requestId === requestId);
|
||||
if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = await this.gitService.computeRunChangeSummary(
|
||||
this.resolveRunWorkingDirectory(session, project, run),
|
||||
{
|
||||
generatedAt: occurredAt,
|
||||
preRunSnapshot: run.preRunGitSnapshot,
|
||||
preRunBaselineFiles: run.preRunGitBaselineFiles,
|
||||
},
|
||||
);
|
||||
|
||||
return this.updateSessionRun(session, requestId, (currentRun) =>
|
||||
setSessionRunGitSummary(currentRun, summary));
|
||||
}
|
||||
|
||||
async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const projects = projectIds?.length
|
||||
? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId))
|
||||
: workspace.projects;
|
||||
|
||||
let didRefreshGit = false;
|
||||
let didSyncProjectTooling = false;
|
||||
let didSyncProjectCustomization = false;
|
||||
for (const project of projects) {
|
||||
didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit;
|
||||
didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling;
|
||||
didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization;
|
||||
}
|
||||
|
||||
const didPruneSelections = didSyncProjectTooling
|
||||
? this.pruneUnavailableSessionToolingSelections(workspace)
|
||||
: false;
|
||||
const didPruneApprovalTools = didSyncProjectTooling
|
||||
? await this.pruneUnavailableApprovalTools(workspace)
|
||||
: false;
|
||||
|
||||
return (
|
||||
didRefreshGit
|
||||
|| didSyncProjectTooling
|
||||
|| didSyncProjectCustomization
|
||||
|| didPruneSelections
|
||||
|| didPruneApprovalTools
|
||||
)
|
||||
? this.persistWorkspace(workspace)
|
||||
: workspace;
|
||||
}
|
||||
|
||||
startPeriodicProjectGitRefresh(): void {
|
||||
if (this.didStartPeriodicProjectGitRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.didStartPeriodicProjectGitRefresh = true;
|
||||
this.periodicProjectGitRefreshTimer = setInterval(() => {
|
||||
this.scheduleProjectGitRefresh();
|
||||
}, GIT_REFRESH_INTERVAL_MS);
|
||||
this.periodicProjectGitRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
stopPeriodicProjectGitRefresh(): void {
|
||||
if (this.periodicProjectGitRefreshTimer) {
|
||||
clearInterval(this.periodicProjectGitRefreshTimer);
|
||||
this.periodicProjectGitRefreshTimer = undefined;
|
||||
}
|
||||
this.didStartPeriodicProjectGitRefresh = false;
|
||||
}
|
||||
|
||||
async flushScheduledProjectGitRefresh(): Promise<void> {
|
||||
if (this.runningProjectGitRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = this.pendingRefreshAllProjects
|
||||
? undefined
|
||||
: [...this.pendingProjectGitRefreshIds];
|
||||
this.pendingRefreshAllProjects = false;
|
||||
this.pendingProjectGitRefreshIds.clear();
|
||||
|
||||
this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
console.error('[aryx git]', error);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await this.runningProjectGitRefresh;
|
||||
} finally {
|
||||
this.runningProjectGitRefresh = undefined;
|
||||
if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) {
|
||||
this.scheduleProjectGitRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshGitContextForProject(project: ProjectRecord): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.git) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.git = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
project.git = await this.gitService.describeProject(project.path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import electron from 'electron';
|
||||
|
||||
import type { QuickPromptSettings } from '@shared/domain/tooling';
|
||||
|
||||
export class GlobalHotkeyService {
|
||||
private currentAccelerator: string | undefined;
|
||||
private lastFailedAccelerator: string | undefined;
|
||||
private callback: (() => void) | undefined;
|
||||
|
||||
register(settings: QuickPromptSettings, callback: () => void): void {
|
||||
this.callback = callback;
|
||||
|
||||
if (!settings.enabled) {
|
||||
this.unregister();
|
||||
return;
|
||||
}
|
||||
|
||||
const accelerator = toElectronAccelerator(settings.hotkey);
|
||||
|
||||
// Already registered this accelerator — nothing to do
|
||||
if (accelerator === this.currentAccelerator) return;
|
||||
|
||||
// Don't retry an accelerator that already failed (avoids log spam on
|
||||
// repeated workspace-updated events during startup)
|
||||
if (accelerator === this.lastFailedAccelerator) return;
|
||||
|
||||
this.unregister();
|
||||
|
||||
// Access globalShortcut lazily to ensure electron is fully initialised
|
||||
const { globalShortcut } = electron;
|
||||
if (!globalShortcut) {
|
||||
console.error('[globalHotkey] electron.globalShortcut is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registered = globalShortcut.register(accelerator, callback);
|
||||
if (registered) {
|
||||
console.info(`[globalHotkey] Registered: ${accelerator}`);
|
||||
this.currentAccelerator = accelerator;
|
||||
this.lastFailedAccelerator = undefined;
|
||||
} else {
|
||||
console.warn(
|
||||
`[globalHotkey] Failed to register accelerator: ${accelerator}` +
|
||||
' — it may be reserved by the OS or another application',
|
||||
);
|
||||
this.currentAccelerator = undefined;
|
||||
this.lastFailedAccelerator = accelerator;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[globalHotkey] Error registering ${accelerator}:`, err);
|
||||
this.currentAccelerator = undefined;
|
||||
this.lastFailedAccelerator = accelerator;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-registers with updated settings (e.g. hotkey string changed). */
|
||||
update(settings: QuickPromptSettings): void {
|
||||
if (!this.callback) return;
|
||||
this.register(settings, this.callback);
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
if (this.currentAccelerator) {
|
||||
try {
|
||||
const { globalShortcut } = electron;
|
||||
globalShortcut?.unregister(this.currentAccelerator);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
this.currentAccelerator = undefined;
|
||||
}
|
||||
this.lastFailedAccelerator = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unregister();
|
||||
this.callback = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a portable hotkey string to an Electron accelerator.
|
||||
*
|
||||
* - "Super" → "Meta" (Win key on Windows, Cmd on macOS)
|
||||
* - Strings already using Electron-native names pass through unchanged.
|
||||
*/
|
||||
function toElectronAccelerator(hotkey: string): string {
|
||||
return hotkey.replace(/\bSuper\b/gi, 'Meta');
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { SessionToolingSelection, WorkspaceToolingSettings, McpServerDefinition } from '@shared/domain/tooling';
|
||||
import {
|
||||
listAcceptedDiscoveredMcpServers,
|
||||
type DiscoveredMcpServer,
|
||||
type DiscoveredToolingState,
|
||||
} from '@shared/domain/discoveredTooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber';
|
||||
import { getStoredToken } from '@main/services/mcpTokenStore';
|
||||
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
|
||||
|
||||
type McpProbeManagerDeps = {
|
||||
loadWorkspace: () => Promise<WorkspaceState>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
probeMcpServers?: typeof probeServers;
|
||||
tokenLookup?: (serverUrl: string) => string | undefined;
|
||||
performMcpOAuthFlow?: typeof performMcpOAuthFlow;
|
||||
requiresOAuth?: typeof requiresOAuth;
|
||||
};
|
||||
|
||||
export class McpProbeManager {
|
||||
private mcpProbeUpdateQueue = Promise.resolve();
|
||||
|
||||
private readonly loadWorkspace: () => Promise<WorkspaceState>;
|
||||
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
private readonly probeMcpServers: typeof probeServers;
|
||||
private readonly tokenLookup: (serverUrl: string) => string | undefined;
|
||||
private readonly performMcpOAuthFlow: typeof performMcpOAuthFlow;
|
||||
private readonly requiresOAuth: typeof requiresOAuth;
|
||||
|
||||
constructor(deps: McpProbeManagerDeps) {
|
||||
this.loadWorkspace = deps.loadWorkspace;
|
||||
this.persistWorkspace = deps.persistWorkspace;
|
||||
this.probeMcpServers = deps.probeMcpServers ?? probeServers;
|
||||
this.tokenLookup = deps.tokenLookup ?? ((serverUrl) => getStoredToken(serverUrl)?.accessToken);
|
||||
this.performMcpOAuthFlow = deps.performMcpOAuthFlow ?? performMcpOAuthFlow;
|
||||
this.requiresOAuth = deps.requiresOAuth ?? requiresOAuth;
|
||||
}
|
||||
|
||||
async probeAndAuthenticateHttpMcpServers(
|
||||
tooling: WorkspaceToolingSettings,
|
||||
selection: SessionToolingSelection,
|
||||
): Promise<void> {
|
||||
const httpServers = selection.enabledMcpServerIds
|
||||
.map((id) => tooling.mcpServers.find((server) => server.id === id))
|
||||
.filter((server): server is McpServerDefinition => !!server && server.transport !== 'local')
|
||||
.filter((server) => server.transport === 'http' || server.transport === 'sse');
|
||||
|
||||
if (httpServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[aryx oauth] Probing ${httpServers.length} HTTP MCP server(s) for OAuth requirements…`);
|
||||
|
||||
for (const server of httpServers) {
|
||||
if (server.transport === 'local') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingToken = this.tokenLookup(server.url);
|
||||
if (existingToken) {
|
||||
console.log(`[aryx oauth] Skipping ${server.name} — token already stored`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const needsAuth = await this.requiresOAuth(server.url);
|
||||
if (!needsAuth) {
|
||||
console.log(`[aryx oauth] ${server.name} does not require OAuth`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[aryx oauth] ${server.name} requires OAuth — starting flow…`);
|
||||
const result = await this.performMcpOAuthFlow({ serverUrl: server.url });
|
||||
if (result.success) {
|
||||
console.log(`[aryx oauth] ${server.name} authenticated successfully`);
|
||||
void this.reprobeServerByUrl(server.url).catch((error) => {
|
||||
console.error('[aryx mcp-probe] re-probe after auth failed:', error);
|
||||
});
|
||||
} else {
|
||||
console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async probeAllAcceptedMcpServers(workspace: WorkspaceState): Promise<void> {
|
||||
const targets = [
|
||||
...this.listAcceptedDiscoveredServerDefinitions(
|
||||
workspace,
|
||||
(server) => !server.probedTools || server.probedTools.length === 0,
|
||||
),
|
||||
...workspace.settings.tooling.mcpServers.filter(
|
||||
(server) => server.tools.length === 0 && (!server.probedTools || server.probedTools.length === 0),
|
||||
),
|
||||
];
|
||||
|
||||
await this.probeWorkspaceMcpServers(workspace, targets);
|
||||
}
|
||||
|
||||
async probeDiscoveredMcpServersFromState(
|
||||
workspace: WorkspaceState,
|
||||
state?: DiscoveredToolingState,
|
||||
): Promise<void> {
|
||||
const targets = listAcceptedDiscoveredMcpServers(state)
|
||||
.filter((server) => !server.probedTools || server.probedTools.length === 0)
|
||||
.map((server) => this.discoveredServerToDefinition(server));
|
||||
await this.probeWorkspaceMcpServers(workspace, targets);
|
||||
}
|
||||
|
||||
async probeDiscoveredMcpServers(
|
||||
workspace: WorkspaceState,
|
||||
state: DiscoveredToolingState | undefined,
|
||||
serverIds: ReadonlyArray<string>,
|
||||
): Promise<void> {
|
||||
const targets = listAcceptedDiscoveredMcpServers(state)
|
||||
.filter((server) => serverIds.includes(server.id))
|
||||
.map((server) => this.discoveredServerToDefinition(server));
|
||||
await this.probeWorkspaceMcpServers(workspace, targets);
|
||||
}
|
||||
|
||||
async probeWorkspaceMcpServers(
|
||||
workspace: WorkspaceState,
|
||||
targets: ReadonlyArray<McpServerDefinition>,
|
||||
): Promise<void> {
|
||||
const uniqueTargets = [...new Map(targets.map((server) => [server.id, server])).values()];
|
||||
if (uniqueTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIds = uniqueTargets.map((server) => server.id);
|
||||
await this.enqueueMcpProbeUpdate(async () => {
|
||||
if (this.addMcpProbingServerIds(workspace, targetIds)) {
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await this.probeMcpServers(uniqueTargets, this.tokenLookup, (result) =>
|
||||
this.enqueueMcpProbeUpdate(async () => {
|
||||
const didUpdateProbing = this.removeMcpProbingServerIds(workspace, [result.serverId]);
|
||||
const didApplyResult = this.applyMcpProbeResult(workspace, result);
|
||||
if (didUpdateProbing || didApplyResult) {
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
await this.enqueueMcpProbeUpdate(async () => {
|
||||
if (this.removeMcpProbingServerIds(workspace, targetIds)) {
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reprobeServerByUrl(serverUrl: string): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const targets: McpServerDefinition[] = [];
|
||||
|
||||
for (const server of workspace.settings.tooling.mcpServers) {
|
||||
if (server.transport !== 'local' && server.url === serverUrl) {
|
||||
targets.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
const allDiscovered = [
|
||||
...(workspace.settings.discoveredUserTooling?.mcpServers ?? []),
|
||||
...workspace.projects.flatMap((project) => project.discoveredTooling?.mcpServers ?? []),
|
||||
];
|
||||
|
||||
for (const server of allDiscovered) {
|
||||
if (server.status === 'accepted' && server.transport !== 'local' && server.url === serverUrl) {
|
||||
targets.push(this.discoveredServerToDefinition(server));
|
||||
}
|
||||
}
|
||||
|
||||
await this.probeWorkspaceMcpServers(workspace, targets);
|
||||
}
|
||||
|
||||
listAcceptedDiscoveredServerDefinitions(
|
||||
workspace: WorkspaceState,
|
||||
predicate?: (server: DiscoveredMcpServer) => boolean,
|
||||
): McpServerDefinition[] {
|
||||
const definitions: McpServerDefinition[] = [];
|
||||
|
||||
for (const state of this.listDiscoveredToolingStates(workspace)) {
|
||||
for (const server of listAcceptedDiscoveredMcpServers(state)) {
|
||||
if (predicate && !predicate(server)) {
|
||||
continue;
|
||||
}
|
||||
definitions.push(this.discoveredServerToDefinition(server));
|
||||
}
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
listDiscoveredToolingStates(workspace: WorkspaceState): Array<DiscoveredToolingState | undefined> {
|
||||
return [
|
||||
workspace.settings.discoveredUserTooling,
|
||||
...workspace.projects.map((project) => project.discoveredTooling),
|
||||
];
|
||||
}
|
||||
|
||||
addMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
|
||||
return this.updateMcpProbingServerIds(workspace, serverIds, 'add');
|
||||
}
|
||||
|
||||
removeMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
|
||||
return this.updateMcpProbingServerIds(workspace, serverIds, 'remove');
|
||||
}
|
||||
|
||||
updateMcpProbingServerIds(
|
||||
workspace: WorkspaceState,
|
||||
serverIds: ReadonlyArray<string>,
|
||||
operation: 'add' | 'remove',
|
||||
): boolean {
|
||||
const next = new Set(workspace.mcpProbingServerIds ?? []);
|
||||
const before = next.size;
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
if (operation === 'add') {
|
||||
next.add(serverId);
|
||||
} else {
|
||||
next.delete(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
if (next.size === before) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.size === 0) {
|
||||
delete workspace.mcpProbingServerIds;
|
||||
} else {
|
||||
workspace.mcpProbingServerIds = [...next];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
applyMcpProbeResult(workspace: WorkspaceState, result: McpProbeResult): boolean {
|
||||
if (result.status !== 'success' || result.tools.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const server of workspace.settings.tooling.mcpServers) {
|
||||
if (server.id !== result.serverId) {
|
||||
continue;
|
||||
}
|
||||
server.probedTools = result.tools;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const state of this.listDiscoveredToolingStates(workspace)) {
|
||||
for (const server of state?.mcpServers ?? []) {
|
||||
if (server.id !== result.serverId) {
|
||||
continue;
|
||||
}
|
||||
server.probedTools = result.tools;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
discoveredServerToDefinition(server: DiscoveredMcpServer): McpServerDefinition {
|
||||
if (server.transport === 'local') {
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: 'local',
|
||||
command: server.command,
|
||||
args: [...server.args],
|
||||
cwd: server.cwd,
|
||||
env: server.env ? { ...server.env } : undefined,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: server.transport,
|
||||
url: server.url,
|
||||
headers: server.headers ? { ...server.headers } : undefined,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
private async enqueueMcpProbeUpdate(update: () => Promise<void>): Promise<void> {
|
||||
const next = this.mcpProbeUpdateQueue.then(update, update);
|
||||
this.mcpProbeUpdateQueue = next.catch(() => undefined);
|
||||
await next;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user